現代のアプリケーションにおけるWeb APIの実装形式(2) GraphQL
現代のウェブアプリケーションにおけるWeb APIの連載第二回。今回は「GraphQL」の特徴や実装例を解説し、その利点やユースケースを考察します。
GraphQLとは
GraphQLは、Facebook (現Meta) が開発したデータクエリ言語であり、クライアントが必要なデータをリクエストすることができるAPIの仕様です。最大の特徴は、クライアントが必要なデータを自由に設定してリクエストを送ることができる点であり、REST APIなどの従来のWeb APIと比較してデータの取得効率や柔軟性が高いAPI仕様です。[1]
なぜGraphQLが生まれたのか
Facebookは、2012年にモバイルアプリケーションを再構築する取り組みを始めました。その中で今までよりもより複雑なUIを構築する必要があり、それに合わせて従来のREST APIでで提供されているデータと実際に必要なデータの間にギャップが生じました。そのため、FacebookはGraphQLを開発し、データの取得を効率化するためのAPI仕様として採用しました。[2]
GraphQLの特徴
GraphQLは、リクエストを受け付けるサーバーとリクエストを送信するクライアントの間でデータの取得を効率化するためのAPI仕様です。
独自クエリ言語による柔軟なデータ取得
GraphQLの最大の特徴は、クライアントが必要なデータを自由に設定してリクエストを送ることができる点です。リクエストを送信するクライアントは、GraphQLエンドポイントに対して必要なデータを取得するためのクエリ文字列を送信し、サーバーはそのクエリ文字列に対して必要なデータを取得してレスポンスを返します。これにより、クライアントが必要なデータを一度のリクエストで取得することができ、データの取得効率が向上します。
# クエリ文字列の例
query {
user(id: 1) {
id
name
email
}
posts {
id
title
content
}
}
例えば、上記のクエリ文字列では、user
とposts
という2つのデータを取得するためのクエリを定義しています。user
とposts
それぞれのデータのフィールドを指定することで、必要なデータを取得することができます。もしも、user
のデータの中でemail
のデータが不要であれば、クエリ文字列からemail
のフィールドを削除することで、不要なデータを取得することなくデータを取得することができます。
# 不要なデータを取得しないクエリ文字列の例
query {
user(id: 1) {
id
name
}
posts {
id
title
content
}
}
これをREST APIで実現するためには、複数のエンドポイントに対して複数のリクエストを送信する必要があり、データの取得効率が低下します。また、REST APIでは返却されるデータの構造が固定されているため、クライアントが必要なデータ構造をそのまま取得することができないという問題があります。これを解決できるのがGraphQLの特徴です。
スキーマによる型安全なデータ取得
GraphQLでは、スキーマを使用してデータの型を定義することができます。スキーマを使用することで、クライアントが不正なデータを取得することを防ぐことができ、データの取得における安全性を向上させることができます。
# スキーマの例
type User {
id: ID!
name: String!
email: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
サーバー側では、定義したスキーマに従ってデータを返却することで、型安全なデータ取得を実現することができます。クライアントは、スキーマに定義されたデータの型を参照してデータを取得することができ、データの取得における安全性を向上させることができます。
既存のコードとの統合
GraphQLは飽くまでAPIの仕様であり、既存のコードとの統合が容易です。既存のデータベースやAPIと統合するためのラッパーを作成することで、GraphQLを使用してデータの取得を行うことができます。
実際にGraphQLを使ってみる
では、実際にGraphQLを使ってAPIを実装し、リクエストを送ってみましょう。以下は、GraphQLを使って簡単なサーバーとクライアントを実装する例です。
なお、今回作成したプロジェクトは公開しておりますので、興味のある方はクローンして手元で動かしてみてください。
https://github.com/mast1ff/2024-10-14_graphql-with-apolllo ↗
プロジェクトの作成
GraphQLはも前回のgRPCと同様に複数の言語での実装がサポートされています。詳細は公式でサポートされている言語とライブラリ ↗でご確認ください。
GraphQLはリクエストを送信するクライアントとリクエストを受け付けるサーバーの2つのコンポーネントで構成されています。今回は、サーバー側の実装をNode.jsで行い、クライアント側の実装をReactを用いたブラウザのJavaScript(TypeScript)で行います。
今回、GraphQLのサーバー実装にはApollo Server ↗を使用し、クライアント実装にはネイティブの fetch
APIを使用してリクエストを送信します。
サーバーの実装
まずは、サーバーの実装から始めましょう。以下のコードは、hello
というクエリを受け付けてHello, World!
という文字列を返すサーバーの実装例です。
パッケージのインストール
$ pnpm add graphql @apollo/server
$ pnpm add -D typescript tsx @types/node
サーバー側のコーディング
まずは簡単なサーバーを実装してみましょう。以下のコードは、hello
というクエリを受け付けてHello, World!
という文字列を返すサーバーの実装例です。
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
const resolvers = {
Query: {
hello: () => "Hello, World!",
},
};
const typeDefs = `
type Query {
hello: String
}
`;
const server = new ApolloServer({ typeDefs, resolvers });
const url = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at ${url}`);
GraphQLサーバーには、resolvers
とtypeDefs
という2つのプロパティを設定する必要があります。resolvers
は、クエリを受け付けてデータを返すための関数を定義するプロパティであり、typeDefs
は、スキーマを定義するための文字列を定義するプロパティです。
なお、今回はApollo Serverの組み込みのstartStandaloneServer
関数を使用してサーバーを起動していますが、express ↗と統合するための各種ミドルウェアも提供されています。 [3]
クライアントの実装
次に、クライアントを実装してみましょう。今回クライアント側の実装にはVite ↗を使用します。
パッケージのインストール
$ pnpm add react react-dom
$ pnpm add -D vite @vitejs/plugin-react @types/react @types/react-dom
クライアントのコーディング
以下のコードは、GraphQLサーバーに対してhello
クエリを送信してレスポンスを受け取るクライアントの実装例です。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GraphQL + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/client/main.tsx"></script>
</body>
</html>
import { useEffect } from "react";
import { createRoot } from "react-dom/client";
funciton App() {
useEffect(() => {
let ignore = false;
const query = `
query {
hello
}`;
fetch("http://localhost:4000/graphql",{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
})
.then((res) => res.json())
.then((res) => {
if (!ignore) {
console.log(res.data.hello);
}
});
return () => {
ignore = true;
};
}, []);
return (
<>
<h1>GraphQL + React</h1>
</>
)
}
const root = createRoot(document.getElementById("root"));
root.render(<App />);
サーバーとクライアントの起動
サーバーとクライアントを起動するために、以下のコマンドを実行してください。
$ pnpm tsx server/main.ts
# http://localhost:4000 にサーバーが起動します
$ pnpm vite
# http://localhost:5173 にクライアントが起動します
ブラウザでhttp://localhost:5173
にアクセスすると、コンソールにHello, World!
という文字列が表示されることが確認できます。
簡単な本と図書館の一覧アプリを作る
さて、ここまででGraphQLの基本的な使い方を学びました。次に、簡単な本と図書館の一覧アプリを作成しながらより実践的な使い方を確認していきましょう。以下のコードは、GraphQLサーバーに対してbooks
とlibraries
というクエリを送信してレスポンスを受け取るクライアントの実装例です。
サーバー側のtypeDefs
の定義
より多くの実装が存在するアプリでは、typeDefs
の定義も膨大となります。
※typeDefsで定義できるスキーマや型はGraphQLの仕様 ↗を参照してください。
const typeDefs = `
type Book {
id: ID!
title: String!
author: String!
library: Library
}
type Library {
id: ID!
name: String!
address: String!
books: [Book!]
}
type Query {
books: [Book!]!
book(id: ID!): Book
libraries: [Library!]!
library(id: ID!): Library
}
type AddBookMutationResponse {
code: String!
success: Boolean!
message: String!
book: Book
}
type AddLibraryMutationResponse {
code: String!
success: Boolean!
message: String!
library: Library
}
type Mutation {
addBook(title: String!, author: String!): AddBookMutationResponse
addLibrary(name: String!, address: String!): AddLibraryMutationResponse
}
`;
このままでは見づらい上に管理もややこしいため、typeDefs
を別ファイルに分割して管理することが一般的です。この際、schema.graphql
などのファイル名で保存します。
type Book {
id: ID!
title: String!
author: String!
library: Library
}
type Library {
id: ID!
name: String!
address: String!
books: [Book!]
}
type Query {
books: [Book!]!
book(id: ID!): Book
libraries: [Library!]!
library(id: ID!): Library
}
type AddBookMutationResponse {
code: String!
success: Boolean!
message: String!
book: Book
}
type AddLibraryMutationResponse {
code: String!
success: Boolean!
message: String!
library: Library
}
type Mutation {
addBook(title: String!, author: String!): AddBookMutationResponse
addLibrary(name: String!, address: String!): AddLibraryMutationResponse
}
サーバー側のresolvers
の定義
resolvers
もtypeDefs
同様に膨大となります。アプリケーションではデータベースなどのデータソースからデータを取得するための関数を定義する必要があるため実装自体の省略は出来ませんが、受け取る引数や返り値の型についてはtypeDefs
で定義した内容をもとに生成することが出来ます。
TypeScriptの型コードを生成するためのツールをインストールし、型の生成を自動化しましょう。
$ pnpm add -D @graphql-codegen/cli @graphql-codegen/introspection @graphql-codegen/typescript @graphql-codegen/typescript-resolvers
コード生成のための設定ファイルであるcodegen.ts
を作成し、以下のように設定します。
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
schema: "./schema.graphql", // スキーマファイルのパス
generates: {
"__generated__/resolvers-types.ts": { // 生成される型定義ファイルのパス
plugins: ["typescript", "typescript-resolvers"],
config: {
useIndexSignature: true,
contextType: "../server/main#MyContext", // 生成される型定義ファイルに含まれる`Context`のファイルと型名
},
},
},
};
export default config;
上記のうち、contextType
には、サーバー起動時に実行したcontext
プロパティから渡されるオブジェクトの型を指定します。このオブジェクトには、データベースのコネクションやセッション情報などのresolver
関数で使用するデータを格納することができます。
// ...
import * as fs from "node:fs";
// スキーマファイルを読み込む
const typeDefs = fs.readFileSync("./schema.graphql", "utf-8");
export interface MyContext {
dataSources: {
// データソースのインスタンスを定義
}
}
const server = new ApolloServer<MyContext>({ typeDefs, resolvers });
const { url } = startStandaloneServer(server, {
listen: { port: 4000 },
context: () => {
return {
dataSources: {
// データソースのインスタンスを生成
}
}
}
});
// ...
準備ができたら、型定義ファイルを生成しましょう。
$ pnpm graphql-codegen --config codegen.ts
このコマンドの実行により、__generated__/resolvers-types.ts
というファイルが生成され、typeDefs
で定義したスキーマに基づいた型定義が生成されます。
生成された型定義ファイル
import { GraphQLResolveInfo } from 'graphql';
import { MyContext } from '../server/main';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
export type RequireFields<T, K extends keyof T> = Omit<T, K> & { [P in K]-?: NonNullable<T[P]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string; }
String: { input: string; output: string; }
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
};
export type AddBookMutationResponse = {
__typename?: 'AddBookMutationResponse';
book?: Maybe<Book>;
code: Scalars['String']['output'];
message: Scalars['String']['output'];
success: Scalars['Boolean']['output'];
};
export type AddLibraryMutationResponse = {
__typename?: 'AddLibraryMutationResponse';
code: Scalars['String']['output'];
library?: Maybe<Library>;
message: Scalars['String']['output'];
success: Scalars['Boolean']['output'];
};
export type Book = {
__typename?: 'Book';
author: Scalars['String']['output'];
id: Scalars['ID']['output'];
library?: Maybe<Library>;
title: Scalars['String']['output'];
};
export type Library = {
__typename?: 'Library';
address: Scalars['String']['output'];
books?: Maybe<Array<Book>>;
id: Scalars['ID']['output'];
name: Scalars['String']['output'];
};
export type Mutation = {
__typename?: 'Mutation';
addBook?: Maybe<AddBookMutationResponse>;
addLibrary?: Maybe<AddLibraryMutationResponse>;
};
export type MutationAddBookArgs = {
author: Scalars['String']['input'];
title: Scalars['String']['input'];
};
export type MutationAddLibraryArgs = {
address: Scalars['String']['input'];
name: Scalars['String']['input'];
};
export type Query = {
__typename?: 'Query';
book?: Maybe<Book>;
books: Array<Book>;
libraries: Array<Library>;
library?: Maybe<Library>;
};
export type QueryBookArgs = {
id: Scalars['ID']['input'];
};
export type QueryLibraryArgs = {
id: Scalars['ID']['input'];
};
export type WithIndex<TObject> = TObject & Record<string, any>;
export type ResolversObject<TObject> = WithIndex<TObject>;
export type ResolverTypeWrapper<T> = Promise<T> | T;
export type ResolverWithResolve<TResult, TParent, TContext, TArgs> = {
resolve: ResolverFn<TResult, TParent, TContext, TArgs>;
};
export type Resolver<TResult, TParent = {}, TContext = {}, TArgs = {}> = ResolverFn<TResult, TParent, TContext, TArgs> | ResolverWithResolve<TResult, TParent, TContext, TArgs>;
export type ResolverFn<TResult, TParent, TContext, TArgs> = (
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo
) => Promise<TResult> | TResult;
export type SubscriptionSubscribeFn<TResult, TParent, TContext, TArgs> = (
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo
) => AsyncIterable<TResult> | Promise<AsyncIterable<TResult>>;
export type SubscriptionResolveFn<TResult, TParent, TContext, TArgs> = (
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo
) => TResult | Promise<TResult>;
export interface SubscriptionSubscriberObject<TResult, TKey extends string, TParent, TContext, TArgs> {
subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs>;
resolve?: SubscriptionResolveFn<TResult, { [key in TKey]: TResult }, TContext, TArgs>;
}
export interface SubscriptionResolverObject<TResult, TParent, TContext, TArgs> {
subscribe: SubscriptionSubscribeFn<any, TParent, TContext, TArgs>;
resolve: SubscriptionResolveFn<TResult, any, TContext, TArgs>;
}
export type SubscriptionObject<TResult, TKey extends string, TParent, TContext, TArgs> =
| SubscriptionSubscriberObject<TResult, TKey, TParent, TContext, TArgs>
| SubscriptionResolverObject<TResult, TParent, TContext, TArgs>;
export type SubscriptionResolver<TResult, TKey extends string, TParent = {}, TContext = {}, TArgs = {}> =
| ((...args: any[]) => SubscriptionObject<TResult, TKey, TParent, TContext, TArgs>)
| SubscriptionObject<TResult, TKey, TParent, TContext, TArgs>;
export type TypeResolveFn<TTypes, TParent = {}, TContext = {}> = (
parent: TParent,
context: TContext,
info: GraphQLResolveInfo
) => Maybe<TTypes> | Promise<Maybe<TTypes>>;
export type IsTypeOfResolverFn<T = {}, TContext = {}> = (obj: T, context: TContext, info: GraphQLResolveInfo) => boolean | Promise<boolean>;
export type NextResolverFn<T> = () => Promise<T>;
export type DirectiveResolverFn<TResult = {}, TParent = {}, TContext = {}, TArgs = {}> = (
next: NextResolverFn<TResult>,
parent: TParent,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo
) => TResult | Promise<TResult>;
/** Mapping between all available schema types and the resolvers types */
export type ResolversTypes = ResolversObject<{
AddBookMutationResponse: ResolverTypeWrapper<AddBookMutationResponse>;
AddLibraryMutationResponse: ResolverTypeWrapper<AddLibraryMutationResponse>;
Book: ResolverTypeWrapper<Book>;
Boolean: ResolverTypeWrapper<Scalars['Boolean']['output']>;
ID: ResolverTypeWrapper<Scalars['ID']['output']>;
Library: ResolverTypeWrapper<Library>;
Mutation: ResolverTypeWrapper<{}>;
Query: ResolverTypeWrapper<{}>;
String: ResolverTypeWrapper<Scalars['String']['output']>;
}>;
/** Mapping between all available schema types and the resolvers parents */
export type ResolversParentTypes = ResolversObject<{
AddBookMutationResponse: AddBookMutationResponse;
AddLibraryMutationResponse: AddLibraryMutationResponse;
Book: Book;
Boolean: Scalars['Boolean']['output'];
ID: Scalars['ID']['output'];
Library: Library;
Mutation: {};
Query: {};
String: Scalars['String']['output'];
}>;
export type AddBookMutationResponseResolvers<ContextType = MyContext, ParentType extends ResolversParentTypes['AddBookMutationResponse'] = ResolversParentTypes['AddBookMutationResponse']> = ResolversObject<{
book?: Resolver<Maybe<ResolversTypes['Book']>, ParentType, ContextType>;
code?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
message?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type AddLibraryMutationResponseResolvers<ContextType = MyContext, ParentType extends ResolversParentTypes['AddLibraryMutationResponse'] = ResolversParentTypes['AddLibraryMutationResponse']> = ResolversObject<{
code?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
library?: Resolver<Maybe<ResolversTypes['Library']>, ParentType, ContextType>;
message?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type BookResolvers<ContextType = MyContext, ParentType extends ResolversParentTypes['Book'] = ResolversParentTypes['Book']> = ResolversObject<{
author?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
library?: Resolver<Maybe<ResolversTypes['Library']>, ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type LibraryResolvers<ContextType = MyContext, ParentType extends ResolversParentTypes['Library'] = ResolversParentTypes['Library']> = ResolversObject<{
address?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
books?: Resolver<Maybe<Array<ResolversTypes['Book']>>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type MutationResolvers<ContextType = MyContext, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = ResolversObject<{
addBook?: Resolver<Maybe<ResolversTypes['AddBookMutationResponse']>, ParentType, ContextType, RequireFields<MutationAddBookArgs, 'author' | 'title'>>;
addLibrary?: Resolver<Maybe<ResolversTypes['AddLibraryMutationResponse']>, ParentType, ContextType, RequireFields<MutationAddLibraryArgs, 'address' | 'name'>>;
}>;
export type QueryResolvers<ContextType = MyContext, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = ResolversObject<{
book?: Resolver<Maybe<ResolversTypes['Book']>, ParentType, ContextType, RequireFields<QueryBookArgs, 'id'>>;
books?: Resolver<Array<ResolversTypes['Book']>, ParentType, ContextType>;
libraries?: Resolver<Array<ResolversTypes['Library']>, ParentType, ContextType>;
library?: Resolver<Maybe<ResolversTypes['Library']>, ParentType, ContextType, RequireFields<QueryLibraryArgs, 'id'>>;
}>;
export type Resolvers<ContextType = MyContext> = ResolversObject<{
AddBookMutationResponse?: AddBookMutationResponseResolvers<ContextType>;
AddLibraryMutationResponse?: AddLibraryMutationResponseResolvers<ContextType>;
Book?: BookResolvers<ContextType>;
Library?: LibraryResolvers<ContextType>;
Mutation?: MutationResolvers<ContextType>;
Query?: QueryResolvers<ContextType>;
}>;
型定義を使ったresolvers
の定義
まずはデータを取得するためのdataSources
を定義しましょう。
import {
AddBookMutationResponse,
AddLibraryMutationResponse,
Book,
Library,
} from "../__generated__/resolvers-types";
// 本のデータベース
const BooksDB: Omit<Required<Book>, "__typename" | "library">[] = [
{
id: "1",
title: "Harry Potter and the Chamber of Secrets",
author: "J.K. Rowling",
},
{
id: "2",
title: "The Hobbit",
author: "J.R.R. Tolkien",
},
{
id: "3",
title: "1984",
author: "George Orwell",
},
{
id: "4",
title: "To Kill a Mockingbird",
author: "Harper Lee",
},
{
id: "5",
title: "The Great Gatsby",
author: "F. Scott Fitzgerald",
},
{
id: "6",
title: "The Catcher in the Rye",
author: "J.D. Salinger",
},
{
id: "7",
title: "Pride and Prejudice",
author: "Jane Austen",
},
{
id: "8",
title: "The Lord of the Rings",
author: "J.R.R. Tolkien",
},
{
id: "9",
title: "Animal Farm",
author: "George Orwell",
},
{
id: "10",
title: "Moby Dick",
author: "Herman Melville",
},
];
// 本を取得するモデル
export class BooksDataSource {
getBooks(): Book[] {
return BooksDB.map((book) => ({
...book,
library: LibrariesDB.find((l) => l.bookIDs.includes(book.id)),
}));
}
getBook(id: string): Book | null {
const book = BooksDB.find((book) => book.id === id);
return {
...book,
library: LibrariesDB.find((lib) => lib.bookIDs.includes(book.id)),
};
}
async addBook(book: Omit<Book, "id">): Promise<AddBookMutationResponse> {
if (book.title && book.author) {
const lastID = BooksDB.pop().id;
const id = (Number.parseInt(lastID) + 1).toString();
BooksDB.push({
title: book.title,
author: book.author,
id,
});
return {
code: "200",
success: true,
message: "New book added!",
book: {
...book,
id,
},
};
}
return {
code: "400",
success: false,
message: "Invalid input",
book: null,
};
}
}
// 図書館のデータベース
const LibrariesDB: (Omit<Required<Library>, "__typename" | "books"> & {
bookIDs: string[];
})[] = [
{
id: "1",
name: "New York Public Library",
address: "476 5th Ave, New York, NY 10018, USA",
bookIDs: ["1", "2", "3"],
},
{
id: "2",
name: "British Library",
address: "96 Euston Rd, London NW1 2DB, UK",
bookIDs: ["4", "5", "6"],
},
{
id: "3",
name: "Library of Congress",
address: "101 Independence Ave SE, Washington, DC 20540, USA",
bookIDs: ["7", "8", "9"],
},
{
id: "4",
name: "Bibliothèque nationale de France",
address: "Quai François Mauriac, 75706 Paris, France",
bookIDs: ["10"],
},
];
// 図書館を取得するモデル
export class LibrariesDataSource {
getLibraries(): Library[] {
return LibrariesDB.map((library) => {
const books = BooksDB.filter((book) => library.bookIDs.includes(book.id));
return {
...library,
books,
};
});
}
getLibrary(id: string): Library | null {
const library = LibrariesDB.find((library) => library.id === id);
return {
...library,
books: BooksDB.filter((book) => library.bookIDs.includes(book.id)),
};
}
async addLibrary(
library: Omit<Library, "id">
): Promise<AddLibraryMutationResponse> {
if (library.name && library.address) {
const newLibrary = {
id: String(LibrariesDB.length + 1),
name: library.name,
address: library.address,
bookIDs: [],
};
LibrariesDB.push(newLibrary);
return {
code: "200",
success: true,
message: "New library added!",
library: {
...newLibrary,
books: [],
},
};
}
return {
code: "400",
success: false,
message: "Invalid input",
library: null,
};
}
}
次に、resolvers
を定義しましょう。
import { QueryResolvers } from "../../__generated__/resolvers-types";
export const queries: QueryResolvers = {
books: async (_, __, { dataSources }) => {
return dataSources.booksAPI.getBooks();
},
book: async (_, { id }, { dataSources }) => {
return dataSources.booksAPI.getBook(id);
},
libraries: async (_, __, { dataSources }) => {
return dataSources.librariesAPI.getLibraries();
},
library: async (_, { id }, { dataSources }) => {
return dataSources.librariesAPI.getLibrary(id);
},
};
今回は深掘りしませんが、mutaion
を定義することで、データの追加や更新、削除などの操作を行うことができます。
今回は本と図書館の追加を行うaddBook
とaddLibrary
を定義してみましょう。
import { MutationResolvers } from "../../__generated__/resolvers-types";
const mutations: MutationResolvers = {
addBook: async (_, { title, author }, { dataSources }) => {
return dataSources.booksAPI.addBook({ title, author });
},
addLibrary: async (_, { name, address }, { dataSources }) => {
return dataSources.librariesAPI.addLibrary({ name, address });
},
};
export default mutations;
最後に、resolvers
を統合しましょう。
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import type { Resolvers } from "../__generated__/resolvers-types";
import * as fs from "node:fs";
import { BooksDataSource, LibrariesDataSource } from "./datasources";
import { queries } from "./resolvers/queries";
import mutations from "./resolvers/mutations";
const typeDefs = fs.readFileSync("./schema.graphql", { encoding: "utf-8" });
export interface MyContext {
dataSources: {
booksAPI: BooksDataSource;
librariesAPI: LibrariesDataSource;
};
}
const resolvers: Resolvers = {
Query: queries,
Mutation: mutations,
};
const server = new ApolloServer<MyContext>({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async () => {
return {
dataSources: {
booksAPI: new BooksDataSource(),
librariesAPI: new LibrariesDataSource(),
},
};
},
});
console.log(`Server ready at: ${url}`);
これで、GraphQLサーバーの実装は完了です。
Apollo Sandboxを使ってクエリを実行する
今回サーバーの実装に使用したApollo Serverには、GraphQLのクエリを実行するためのApollo Sandbox ↗が付属しています。
ブラウザでhttp://localhost:4000
にアクセスしたら、画面内のエディタに以下のクエリを入力して実行してみましょう。
query {
books {
id
title
author
library {
id
name
address
}
}
}
この例では、books
クエリを実行して、本の一覧を取得しています。クエリを実行すると、サーバーからデータが返ってくるので、右側のResponse
画面でデータを確認してみましょう。
ローカルでサーバーの開発を進める際には、Apollo Sandboxを使ってクエリを実行することで、データの取得や更新の確認を行いながら開発を進めることができます。
クライアント側の実装
次に、クライアント側の実装を行いましょう。今回はReactを使用して実装します。
ルーティングの設定
$ pnpm add react-router-dom
クライアント側のコーディング
import { createRoot } from "react-dom/client";
import {
createBrowserRouter,
Link,
Outlet,
RouterProvider,
} from "react-router-dom";
import { BooksRoute } from "./books";
import { BookRoute } from "./book";
import { LibrariesRoute } from "./libraries";
import { LibraryRoute } from "./library";
const router = createBrowserRouter([
{
path: "/",
element: (
<>
<h1>Welcome to the library!</h1>
<Outlet />
<nav style={{ display: "flex", gap: "10px" }}>
<Link to="/list_books">Books</Link>
<Link to="/list_libraries">Libraries</Link>
</nav>
</>
),
children: [
{
path: "/list_books",
element: <BooksRoute />,
},
{
path: "/retrieve_book",
element: <BookRoute />,
},
{
path: "/list_libraries",
element: <LibrariesRoute />,
},
{
path: "/retrieve_library",
element: <LibraryRoute />,
},
],
},
]);
function App() {
return (
<>
<RouterProvider router={router} />
</>
);
}
const root = createRoot(document.getElementById("root"));
root.render(<App />);
クライアント側の型定義
import { Book, Library } from "../__generated__/resolvers-types";
export type QueryResponse<T> = {
data: T;
};
export type BooksQueryResponse = QueryResponse<{ books: Book[] }>;
export type BookQueryResponse = QueryResponse<{ book: Book }>;
export type LibrariesQueryResponse = QueryResponse<{ libraries: Library[] }>;
export type LibraryQueryResponse = QueryResponse<{ library: Library }>;
本の一覧を表示するルート
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Book } from "../__generated__/resolvers-types";
import { BooksQueryResponse } from "./types";
export function BooksRoute() {
const [books, setBooks] = useState<Book[]>([]);
useEffect(() => {
let ignore = false;
const query = `
query Books {
books {
id
title
author
library {
id
name
}
}
}`;
fetch("/graphql", {
method: "POST",
body: JSON.stringify({ query }),
headers: {
"Content-Type": "application/json",
},
})
.then<BooksQueryResponse>((res) => res.json())
.then((res) => {
if (ignore) return;
setBooks(res.data.books);
});
return () => {
ignore = true;
};
}, []);
return (
<>
<h2>Books</h2>
<p>Here are some books:</p>
<ul>
{books.map((book) => (
<li key={book.title}>
<Link to={`/retrieve_book?id=${book.id}`}>
<strong>{book.title}</strong> by {book.author}
</Link>
</li>
))}
</ul>
</>
);
}
本の詳細を表示するルート
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Book } from "../__generated__/resolvers-types";
import { BookQueryResponse } from "./types";
export function BookRoute() {
const [searchParams] = useSearchParams();
const id = searchParams.get("id");
if (!id) {
return <h1>No book found</h1>;
}
const [book, setBook] = useState<Book | null>(null);
useEffect(() => {
let ignore = false;
const query = `
query Book($id: ID!) {
book(id: $id) {
id
title
author
library {
id
name
}
}
}`;
fetch("/graphql", {
method: "POST",
body: JSON.stringify({ query, variables: { id } }),
headers: {
"Content-Type": "application/json",
},
})
.then<BookQueryResponse>((res) => res.json())
.then((res) => {
if (ignore) return;
setBook(res.data.book);
});
return () => {
ignore = true;
};
}, []);
return book ? (
<>
<h2>{book.title}</h2>
<p>by {book.author}</p>
{book.library ? (
<p>
Available at{" "}
<Link to={`/retrieve_library?id=${book.library.id}`}>
{book.library.name}
</Link>
</p>
) : null}
</>
) : null;
}
図書館の一覧を表示するルート
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { LibrariesQueryResponse } from "./types";
import { Library } from "../__generated__/resolvers-types";
export function LibrariesRoute() {
const [libraries, setLibraries] = useState<Library[]>([]);
useEffect(() => {
let ignore = false;
const query = `
query Libraries {
libraries {
id
name
address
}
}`;
fetch("/graphql", {
method: "POST",
body: JSON.stringify({ query }),
headers: {
"Content-Type": "application/json",
},
})
.then<LibrariesQueryResponse>((res) => res.json())
.then((res) => {
if (ignore) return;
setLibraries(res.data.libraries);
});
return () => {
ignore = true;
};
}, []);
return (
<>
<h2>Libraries</h2>
<p>Here are some libraries:</p>
<ul>
{libraries.map((library) => (
<li key={library.name}>
<Link to={`/retrieve_library?id=${library.id}`}>
<strong>{library.name}</strong> at {library.address}
</Link>
</li>
))}
</ul>
</>
);
}
図書館の詳細を表示するルート
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Library } from "../__generated__/resolvers-types";
import { LibraryQueryResponse } from "./types";
export function LibraryRoute() {
const [searchParams] = useSearchParams();
const id = searchParams.get("id");
if (!id) {
return <h1>No library found</h1>;
}
const [library, setLibrary] = useState<Library | null>(null);
useEffect(() => {
let ignore = false;
const query = `
query Library($id: ID!) {
library(id: $id) {
id
name
address
books {
id
title
}
}
}`;
fetch("/graphql", {
method: "POST",
body: JSON.stringify({ query, variables: { id } }),
headers: {
"Content-Type": "application/json",
},
})
.then<LibraryQueryResponse>((res) => res.json())
.then((res) => {
if (ignore) return;
setLibrary(res.data.library);
});
return () => {
ignore = true;
};
}, []);
return library ? (
<>
<h2>{library.name}</h2>
<p>Located at {library.address}</p>
<h3>Books</h3>
<ul>
{library.books?.map((book) => (
<li key={book.title}>
<Link to={`/retrieve_book?id=${book.id}`}>{book.title}</Link>
</li>
))}
</ul>
</>
) : null;
}
実際に動かしてみる
ここまでで、GraphQLサーバーとクライアントの実装が完了しました。サーバーを起動して、ブラウザでクライアントを開いてみましょう。
package.jsonにdev
スクリプトを追加して、サーバーとクライアントを同時に起動できるようにします。
{
"scripts": {
"dev:server": "tsx watch server/main.ts",
"dev:client": "vite",
"dev": "npm-run-all --parallel dev:server dev:client"
}
}
$ pnpm add -D npm-run-all
$ pnpm run dev
実装から考えるGraphQLのメリットとデメリット
ここまで、GraphQLを用いた簡易的なアプリケーションの実装を行いました。この実装を通じて、GraphQLのメリットとデメリットを考察してみましょう。
メリット
フレキシブルなデータ取得
REST APIでは、エンドポイントごとにデータの取得方法が決まっているため、必要なデータを取得するために複数回のリクエストを送る必要があります。一方、GraphQLでは、クエリを使って必要なデータを取得できるため、データ取得の効率が向上します。
データの一元管理
GraphQLでは、スキーマを定義することで、データの構造を一元管理することができます。そのため、データの変更があった場合、スキーマを変更するだけで、関連するすべてのクエリに反映されるため、データの整合性を保つことができます。
クライアント側の負荷軽減
クエリを使って必要なデータを取得できるため、不要なデータを取得することがなくなります。そのため、クライアント側の負荷を軽減することができます。
デメリット
学習コスト
GraphQLはREST APIとは異なるアーキテクチャを持っているため、学習コストが高いと言われています。特に、スキーマの定義やクエリの作成など、新しい概念を学ぶ必要があります。
オーバーヘッド
GraphQLはREST APIよりも柔軟なデータ取得が可能ですが、その分、オーバーヘッドが発生する可能性があります。特に、複雑なクエリを実行する場合、データベースへの負荷が増加することがあります。
キャッシュの難しさ
GraphQLはクエリごとにデータを取得するため、キャッシュの管理が難しいと言われています。特に、クエリの結果がリアルタイムに変化する場合、キャッシュの有効性を保つことが難しいことがあります。
GraphQLを使用すべきケース
これらのメリットやデメリットから、GraphQLを使用すべきケースを考えてみましょう。
// 具体的な開発のケースを考える
モバイルアプリケーションのバックエンド
モバイルアプリケーションでGraphQLを採用する最大の利点は、複雑なUIや画面遷移において、最適なデータ取得を一度に行うことができることです。また、ネットワーク帯域幅の制限がある環境でも、必要最小限のデータ取得により効率的な通信が可能になります。
Webアプリケーションのダッシュボード
ダッシュボードのような複数のデータソースからの情報を一画面に表示する場合、GraphQLの利点が特に発揮されます。従来のREST APIでは複数のエンドポイントへのリクエストが必要でしたが、GraphQLでは1回のクエリで必要なデータを全て取得できます。また、ユーザーの権限や設定に応じて表示するデータを動的に変更することも容易です。
マイクロサービスアーキテクチャ
複数のマイクロサービスが存在する環境では、GraphQLをBFFとして活用することで、フロントエンドとバックエンドの連携を効率化できます。各マイクロサービスのデータを統合し、クライアントに最適な形で提供することが可能になります。また、新しいマイクロサービスの追加や既存サービスの変更にも柔軟に対応できます。
まとめ
本記事では、GraphQLを使用したWeb APIの実装について解説しました。GraphQLはREST APIと比較して、データ取得の柔軟性や効率性が高いため、複雑なデータ取得やデータの一元管理が必要な場合に有用です。ShopifyやGitHubなど、多くの企業がGraphQLを採用しており、2024年現在では十分に”枯れた”技術と言えるでしょう。