GraphQLの基本概念
GraphQLは、Facebookが開発したAPIのためのクエリ言語です。クライアントが必要なデータを正確に指定でき、オーバーフェッチ・アンダーフェッチを防ぎます。
REST API vs GraphQL
REST API(3リクエスト必要):
sequenceDiagram
participant Client
participant Server
Client->>Server: GET /users/1
Server-->>Client: user data
Client->>Server: GET /users/1/posts
Server-->>Client: posts data
Client->>Server: GET /users/1/followers
Server-->>Client: followers data
GraphQL(1リクエストで完結):
sequenceDiagram
participant Client
participant Server
Client->>Server: POST /graphql<br/>query { user(id: 1) { name, posts, followers } }
Server-->>Client: 全データを一度に返却
セットアップ
# 依存関係のインストール
npm install @apollo/server graphql graphql-tag
npm install -D typescript @types/node
# コード生成ツール
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers
プロジェクト構成
src/
├── graphql/
│ ├── schema/
│ │ ├── user.graphql
│ │ ├── post.graphql
│ │ └── index.ts
│ ├── resolvers/
│ │ ├── user.resolver.ts
│ │ ├── post.resolver.ts
│ │ └── index.ts
│ ├── context.ts
│ └── server.ts
├── dataloaders/
│ ├── userLoader.ts
│ └── postLoader.ts
├── services/
│ ├── userService.ts
│ └── postService.ts
└── index.ts
スキーマ定義
# src/graphql/schema/user.graphql
type User {
id: ID!
email: String!
name: String!
avatar: String
posts: [Post!]!
followers: [User!]!
following: [User!]!
createdAt: DateTime!
updatedAt: DateTime!
}
input CreateUserInput {
email: String!
name: String!
password: String!
}
input UpdateUserInput {
name: String
avatar: String
}
type Query {
me: User
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0): [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
followUser(userId: ID!): User!
unfollowUser(userId: ID!): User!
}
# src/graphql/schema/post.graphql
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
comments: [Comment!]!
likes: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
createdAt: DateTime!
}
input CreatePostInput {
title: String!
content: String!
published: Boolean = false
}
input UpdatePostInput {
title: String
content: String
published: Boolean
}
extend type Query {
post(id: ID!): Post
posts(
limit: Int = 10
offset: Int = 0
authorId: ID
published: Boolean
): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
extend type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
likePost(postId: ID!): Post!
addComment(postId: ID!, content: String!): Comment!
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
コード生成設定
# codegen.yml
schema: "src/graphql/schema/**/*.graphql"
generates:
src/graphql/__generated__/types.ts:
plugins:
- typescript
- typescript-resolvers
config:
contextType: ../context#GraphQLContext
mappers:
User: ../../models/User#UserModel
Post: ../../models/Post#PostModel
scalars:
DateTime: Date
# コード生成実行
npx graphql-codegen
リゾルバー実装
// src/graphql/resolvers/user.resolver.ts
import { Resolvers } from '../__generated__/types';
import { GraphQLContext } from '../context';
import { UserService } from '../../services/userService';
export const userResolvers: Resolvers<GraphQLContext> = {
Query: {
me: async (_, __, context) => {
if (!context.userId) return null;
return context.loaders.user.load(context.userId);
},
user: async (_, { id }, context) => {
return context.loaders.user.load(id);
},
users: async (_, { limit, offset }, context) => {
return context.services.user.findMany({ limit, offset });
},
},
Mutation: {
createUser: async (_, { input }, context) => {
return context.services.user.create(input);
},
updateUser: async (_, { id, input }, context) => {
context.assertAuthenticated();
context.assertOwnership(id);
return context.services.user.update(id, input);
},
deleteUser: async (_, { id }, context) => {
context.assertAuthenticated();
context.assertOwnership(id);
await context.services.user.delete(id);
return true;
},
followUser: async (_, { userId }, context) => {
context.assertAuthenticated();
return context.services.user.follow(context.userId!, userId);
},
unfollowUser: async (_, { userId }, context) => {
context.assertAuthenticated();
return context.services.user.unfollow(context.userId!, userId);
},
},
User: {
posts: async (user, _, context) => {
return context.loaders.postsByAuthor.load(user.id);
},
followers: async (user, _, context) => {
return context.loaders.followers.load(user.id);
},
following: async (user, _, context) => {
return context.loaders.following.load(user.id);
},
},
};
// src/graphql/resolvers/post.resolver.ts
import { Resolvers } from '../__generated__/types';
import { GraphQLContext } from '../context';
export const postResolvers: Resolvers<GraphQLContext> = {
Query: {
post: async (_, { id }, context) => {
return context.loaders.post.load(id);
},
posts: async (_, args, context) => {
const { limit, offset, authorId, published } = args;
const [posts, totalCount] = await Promise.all([
context.services.post.findMany({
limit: limit + 1, // 次ページ存在確認用
offset,
authorId,
published,
}),
context.services.post.count({ authorId, published }),
]);
const hasNextPage = posts.length > limit;
const edges = posts.slice(0, limit).map((post) => ({
node: post,
cursor: Buffer.from(`post:${post.id}`).toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: offset > 0,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount,
};
},
},
Mutation: {
createPost: async (_, { input }, context) => {
context.assertAuthenticated();
const post = await context.services.post.create({
...input,
authorId: context.userId!,
});
// サブスクリプション通知
context.pubsub.publish('POST_CREATED', { postCreated: post });
return post;
},
updatePost: async (_, { id, input }, context) => {
context.assertAuthenticated();
const post = await context.loaders.post.load(id);
context.assertOwnership(post?.authorId);
return context.services.post.update(id, input);
},
likePost: async (_, { postId }, context) => {
context.assertAuthenticated();
return context.services.post.like(postId, context.userId!);
},
addComment: async (_, { postId, content }, context) => {
context.assertAuthenticated();
const comment = await context.services.post.addComment({
postId,
content,
authorId: context.userId!,
});
context.pubsub.publish(`COMMENT_ADDED_${postId}`, {
commentAdded: comment,
});
return comment;
},
},
Subscription: {
postCreated: {
subscribe: (_, __, context) => {
return context.pubsub.asyncIterator(['POST_CREATED']);
},
},
commentAdded: {
subscribe: (_, { postId }, context) => {
return context.pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]);
},
},
},
Post: {
author: async (post, _, context) => {
return context.loaders.user.load(post.authorId);
},
comments: async (post, _, context) => {
return context.loaders.commentsByPost.load(post.id);
},
},
};
DataLoaderでN+1問題を解決
// src/dataloaders/userLoader.ts
import DataLoader from 'dataloader';
import { db } from '../db';
import { UserModel } from '../models/User';
export const createUserLoader = () =>
new DataLoader<string, UserModel | null>(async (ids) => {
const users = await db.user.findMany({
where: { id: { in: ids as string[] } },
});
const userMap = new Map(users.map((user) => [user.id, user]));
return ids.map((id) => userMap.get(id) ?? null);
});
export const createFollowersLoader = () =>
new DataLoader<string, UserModel[]>(async (userIds) => {
const follows = await db.follow.findMany({
where: { followingId: { in: userIds as string[] } },
include: { follower: true },
});
const followerMap = new Map<string, UserModel[]>();
for (const follow of follows) {
const followers = followerMap.get(follow.followingId) ?? [];
followers.push(follow.follower);
followerMap.set(follow.followingId, followers);
}
return userIds.map((id) => followerMap.get(id) ?? []);
});
// src/dataloaders/postLoader.ts
export const createPostLoader = () =>
new DataLoader<string, PostModel | null>(async (ids) => {
const posts = await db.post.findMany({
where: { id: { in: ids as string[] } },
});
const postMap = new Map(posts.map((post) => [post.id, post]));
return ids.map((id) => postMap.get(id) ?? null);
});
export const createPostsByAuthorLoader = () =>
new DataLoader<string, PostModel[]>(async (authorIds) => {
const posts = await db.post.findMany({
where: { authorId: { in: authorIds as string[] } },
orderBy: { createdAt: 'desc' },
});
const postMap = new Map<string, PostModel[]>();
for (const post of posts) {
const authorPosts = postMap.get(post.authorId) ?? [];
authorPosts.push(post);
postMap.set(post.authorId, authorPosts);
}
return authorIds.map((id) => postMap.get(id) ?? []);
});
Context設定
// src/graphql/context.ts
import { PubSub } from 'graphql-subscriptions';
import { createUserLoader, createFollowersLoader } from '../dataloaders/userLoader';
import { createPostLoader, createPostsByAuthorLoader } from '../dataloaders/postLoader';
import { UserService } from '../services/userService';
import { PostService } from '../services/postService';
import { AuthenticationError, ForbiddenError } from './errors';
export interface GraphQLContext {
userId: string | null;
loaders: {
user: ReturnType<typeof createUserLoader>;
post: ReturnType<typeof createPostLoader>;
postsByAuthor: ReturnType<typeof createPostsByAuthorLoader>;
followers: ReturnType<typeof createFollowersLoader>;
following: ReturnType<typeof createFollowingLoader>;
commentsByPost: ReturnType<typeof createCommentsByPostLoader>;
};
services: {
user: UserService;
post: PostService;
};
pubsub: PubSub;
assertAuthenticated: () => void;
assertOwnership: (ownerId: string | null | undefined) => void;
}
const pubsub = new PubSub();
export const createContext = async ({ req }): Promise<GraphQLContext> => {
// 認証トークンからユーザーID取得
const token = req.headers.authorization?.replace('Bearer ', '');
const userId = token ? await verifyToken(token) : null;
return {
userId,
loaders: {
user: createUserLoader(),
post: createPostLoader(),
postsByAuthor: createPostsByAuthorLoader(),
followers: createFollowersLoader(),
following: createFollowingLoader(),
commentsByPost: createCommentsByPostLoader(),
},
services: {
user: new UserService(),
post: new PostService(),
},
pubsub,
assertAuthenticated() {
if (!this.userId) {
throw new AuthenticationError('Authentication required');
}
},
assertOwnership(ownerId) {
if (ownerId !== this.userId) {
throw new ForbiddenError('Not authorized');
}
},
};
};
サーバー設定
// src/graphql/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext, GraphQLContext } from './context';
async function startServer() {
const app = express();
const httpServer = http.createServer(app);
// スキーマ作成
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocket設定(サブスクリプション用)
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const serverCleanup = useServer(
{
schema,
context: async (ctx) => createContext({ req: ctx.extra.request }),
},
wsServer
);
// Apollo Server設定
const server = new ApolloServer<GraphQLContext>({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use(
'/graphql',
cors<cors.CorsRequest>(),
express.json(),
expressMiddleware(server, {
context: createContext,
})
);
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`Server ready at http://localhost:${PORT}/graphql`);
console.log(`Subscriptions ready at ws://localhost:${PORT}/graphql`);
});
}
startServer();
エラーハンドリング
// src/graphql/errors.ts
import { GraphQLError } from 'graphql';
export class AuthenticationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: { code: 'UNAUTHENTICATED' },
});
}
}
export class ForbiddenError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: { code: 'FORBIDDEN' },
});
}
}
export class ValidationError extends GraphQLError {
constructor(message: string, field?: string) {
super(message, {
extensions: {
code: 'BAD_USER_INPUT',
field,
},
});
}
}
export class NotFoundError extends GraphQLError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, {
extensions: { code: 'NOT_FOUND', resource, id },
});
}
}
クライアント側の利用例
// クライアント側コード例
import { gql, useQuery, useMutation, useSubscription } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts($limit: Int, $offset: Int) {
posts(limit: $limit, offset: $offset) {
edges {
node {
id
title
content
author {
id
name
avatar
}
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`;
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
}
}
`;
const POST_CREATED = gql`
subscription OnPostCreated {
postCreated {
id
title
author {
name
}
}
}
`;
// React Hooksで利用
function PostList() {
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { limit: 10, offset: 0 },
});
const [createPost] = useMutation(CREATE_POST, {
refetchQueries: ['GetPosts'],
});
useSubscription(POST_CREATED, {
onData: ({ data }) => {
console.log('New post:', data.data.postCreated);
},
});
// ...
}