<CodeLearn/>
GraphQL レッスン3

リゾルバ実装

Apollo Serverでリゾルバを実装し、データソースと接続しよう

Apollo Server のセットアップ

Apollo Serverは、GraphQLサーバーを構築するための 最も人気のあるライブラリです。スキーマとリゾルバを定義するだけで、 GraphQL APIサーバーを起動できます。

// パッケージのインストール
// npm install @apollo/server graphql

// server.ts
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

// スキーマ定義
const typeDefs = `
  type Book {
    id: ID!
    title: String!
    author: Author!
  }

  type Author {
    id: ID!
    name: String!
    books: [Book!]!
  }

  type Query {
    books: [Book!]!
    book(id: ID!): Book
    authors: [Author!]!
  }

  type Mutation {
    addBook(title: String!, authorId: ID!): Book!
  }
`;

// サンプルデータ
const books = [
  { id: "1", title: "GraphQL入門", authorId: "1" },
  { id: "2", title: "Apollo実践ガイド", authorId: "1" },
  { id: "3", title: "React設計パターン", authorId: "2" },
];

const authors = [
  { id: "1", name: "田中太郎" },
  { id: "2", name: "佐藤花子" },
];

// リゾルバ定義
const resolvers = {
  Query: {
    books: () => books,
    book: (_, { id }) => books.find((b) => b.id === id),
    authors: () => authors,
  },
  Mutation: {
    addBook: (_, { title, authorId }) => {
      const newBook = {
        id: String(books.length + 1),
        title,
        authorId,
      };
      books.push(newBook);
      return newBook;
    },
  },
  // 型リゾルバ
  Book: {
    author: (book) => authors.find((a) => a.id === book.authorId),
  },
  Author: {
    books: (author) => books.filter((b) => b.authorId === author.id),
  },
};

// サーバー起動
const server = new ApolloServer({ typeDefs, resolvers });

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});

console.log(`Server ready at ${url}`);

リゾルバ関数の仕組み

リゾルバ関数は4つの引数を受け取ります。これらを使ってデータの取得・加工を行います。

// リゾルバ関数のシグネチャ
// resolver(parent, args, context, info)

const resolvers = {
  Query: {
    // parent: ルートクエリでは通常 undefined
    // args: クエリの引数
    // context: リクエスト間で共有するデータ(認証情報、DBなど)
    // info: クエリのAST情報(高度な使い方)
    user: (parent, args, context, info) => {
      console.log(args.id);      // クエリ引数
      console.log(context.user); // 認証済みユーザー
      return context.db.users.findById(args.id);
    },

    // 引数のフィルタリング
    posts: (_, { status, limit = 10 }) => {
      let result = posts;
      if (status) {
        result = result.filter((p) => p.status === status);
      }
      return result.slice(0, limit);
    },
  },

  // 型リゾルバ:フィールドの解決
  User: {
    // parent はこの場合 User オブジェクト
    fullName: (parent) => {
      return `${parent.lastName} ${parent.firstName}`;
    },
    // 非同期リゾルバ
    posts: async (parent, _, context) => {
      return await context.db.posts.findByAuthorId(parent.id);
    },
    // 計算フィールド
    postCount: async (parent, _, context) => {
      const posts = await context.db.posts.findByAuthorId(parent.id);
      return posts.length;
    },
  },
};

parent(第1引数)は、 親フィールドのリゾルバが返した値です。型リゾルバでのデータ解決に重要な役割を果たします。

Context の活用

Contextは、すべてのリゾルバ間で共有されるオブジェクトです。 認証情報、データベース接続、外部APIクライアントなどを格納します。

import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { PrismaClient } from "@prisma/client";
import { verifyToken } from "./auth";

const prisma = new PrismaClient();

const server = new ApolloServer({ typeDefs, resolvers });

const { url } = await startStandaloneServer(server, {
  // context 関数はリクエストごとに実行される
  context: async ({ req }) => {
    // 認証トークンの検証
    const token = req.headers.authorization || "";
    let user = null;

    try {
      user = await verifyToken(token.replace("Bearer ", ""));
    } catch (e) {
      // 未認証の場合は user が null
    }

    return {
      db: prisma,      // データベース接続
      user,            // 認証済みユーザー(またはnull)
    };
  },
  listen: { port: 4000 },
});

// リゾルバで context を使う
const resolvers = {
  Query: {
    // 認証が必要なクエリ
    me: (_, __, context) => {
      if (!context.user) {
        throw new Error("認証が必要です");
      }
      return context.db.user.findUnique({
        where: { id: context.user.id },
      });
    },
  },
  Mutation: {
    createPost: async (_, { input }, context) => {
      if (!context.user) {
        throw new Error("認証が必要です");
      }
      return context.db.post.create({
        data: {
          ...input,
          authorId: context.user.id,
        },
      });
    },
  },
};

データソースの接続

リゾルバからデータベースやREST APIなど、さまざまなデータソースに接続できます。 データソースをクラスとして整理するパターンが推奨されます。

// datasources/user-api.ts
import { PrismaClient } from "@prisma/client";

export class UserAPI {
  private db: PrismaClient;

  constructor(db: PrismaClient) {
    this.db = db;
  }

  async getAll() {
    return this.db.user.findMany();
  }

  async getById(id: string) {
    return this.db.user.findUnique({ where: { id } });
  }

  async create(data: { name: string; email: string }) {
    return this.db.user.create({ data });
  }

  async update(id: string, data: { name?: string; email?: string }) {
    return this.db.user.update({ where: { id }, data });
  }

  async delete(id: string) {
    await this.db.user.delete({ where: { id } });
    return true;
  }
}

// context にデータソースを追加
context: async ({ req }) => {
  return {
    dataSources: {
      userAPI: new UserAPI(prisma),
      postAPI: new PostAPI(prisma),
    },
  };
},

// リゾルバでデータソースを使う
const resolvers = {
  Query: {
    users: (_, __, { dataSources }) => {
      return dataSources.userAPI.getAll();
    },
    user: (_, { id }, { dataSources }) => {
      return dataSources.userAPI.getById(id);
    },
  },
};

N+1問題とDataLoader

GraphQLではネストしたクエリが簡単に書ける反面、N+1問題が発生しやすくなります。DataLoaderを使ってバッチ処理で解決しましょう。

// N+1問題の例
// クエリ:
// query { posts { title author { name } } }
//
// 実行されるSQL:
// 1. SELECT * FROM posts          (1回)
// 2. SELECT * FROM users WHERE id = 1  (N回)
// 3. SELECT * FROM users WHERE id = 2
// 4. SELECT * FROM users WHERE id = 3
// → 投稿数Nに応じてクエリが増える!

// npm install dataloader
import DataLoader from "dataloader";

// DataLoader でバッチ処理
const createUserLoader = (db: PrismaClient) => {
  return new DataLoader<string, User>(async (userIds) => {
    // 1回のクエリで複数ユーザーを取得
    const users = await db.user.findMany({
      where: { id: { in: [...userIds] } },
    });

    // IDの順序に合わせてマッピング
    const userMap = new Map(users.map((u) => [u.id, u]));
    return userIds.map((id) => userMap.get(id) || null);
  });
};

// context でリクエストごとにLoaderを作成
context: async ({ req }) => ({
  loaders: {
    userLoader: createUserLoader(prisma),
  },
}),

// リゾルバで DataLoader を使う
const resolvers = {
  Post: {
    author: (post, _, { loaders }) => {
      // 同じティック内のリクエストが自動的にバッチ化される
      return loaders.userLoader.load(post.authorId);
    },
  },
};

// 実行されるSQL:
// 1. SELECT * FROM posts
// 2. SELECT * FROM users WHERE id IN (1, 2, 3)
// → たった2回のクエリで済む!

DataLoaderはリクエストごとに新しいインスタンスを作成してください。 キャッシュが残ると古いデータを返す原因になります。

まとめ

  • Apollo ServerでtypeDefsresolversを定義してGraphQLサーバーを構築
  • リゾルバ関数はparentargscontextinfoの4つの引数を受け取る
  • Contextに認証情報やDB接続を格納し、全リゾルバで共有する
  • データソースをクラスとして整理し、リゾルバをシンプルに保つ
  • N+1問題はDataLoaderでバッチ処理して解決する