現代のアプリケーションにおけるWeb APIの実装形式(2) GraphQL

// Application

// Part of 現代のアプリケーションにおけるWeb API

現代のウェブアプリケーションにおける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
  }
}

例えば、上記のクエリ文字列では、userpostsという2つのデータを取得するためのクエリを定義しています。userpostsそれぞれのデータのフィールドを指定することで、必要なデータを取得することができます。もしも、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!という文字列を返すサーバーの実装例です。

server/main.ts
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サーバーには、resolverstypeDefsという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クエリを送信してレスポンスを受け取るクライアントの実装例です。

index.html
<!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>
client/main.tsx
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サーバーに対してbookslibrariesというクエリを送信してレスポンスを受け取るクライアントの実装例です。

サーバー側の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などのファイル名で保存します。

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の定義

resolverstypeDefs同様に膨大となります。アプリケーションではデータベースなどのデータソースからデータを取得するための関数を定義する必要があるため実装自体の省略は出来ませんが、受け取る引数や返り値の型についてはtypeDefsで定義した内容をもとに生成することが出来ます。

TypeScriptの型コードを生成するためのツールをインストールし、型の生成を自動化しましょう。

$ pnpm add -D @graphql-codegen/cli @graphql-codegen/introspection @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

コード生成のための設定ファイルであるcodegen.tsを作成し、以下のように設定します。

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関数で使用するデータを格納することができます。

server/main.ts
// ...
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で定義したスキーマに基づいた型定義が生成されます。

生成された型定義ファイル
__generated__/resolvers-types.ts
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を定義しましょう。

server/datasources.ts
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を定義しましょう。

server/resolvers/queries.ts
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を定義することで、データの追加や更新、削除などの操作を行うことができます。 今回は本と図書館の追加を行うaddBookaddLibraryを定義してみましょう。

server/resolvers/mutations.ts
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を統合しましょう。

server/main.ts
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が付属しています。

Apollo Sandbox

Apollo Sandbox

ブラウザでhttp://localhost:4000にアクセスしたら、画面内のエディタに以下のクエリを入力して実行してみましょう。

query {
  books {
    id
    title
    author
    library {
      id
      name
      address
    }
  }
}

この例では、booksクエリを実行して、本の一覧を取得しています。クエリを実行すると、サーバーからデータが返ってくるので、右側のResponse画面でデータを確認してみましょう。

Apollo Sandboxのレスポンスの例

Apollo Sandboxのレスポンスの例

ローカルでサーバーの開発を進める際には、Apollo Sandboxを使ってクエリを実行することで、データの取得や更新の確認を行いながら開発を進めることができます。

クライアント側の実装

次に、クライアント側の実装を行いましょう。今回はReactを使用して実装します。

ルーティングの設定

$ pnpm add react-router-dom

クライアント側のコーディング

client/main.tsx
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 />);

クライアント側の型定義

client/types.ts
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 }>;

本の一覧を表示するルート

client/books.tsx
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>
    </>
  );
}

本の詳細を表示するルート

client/book.tsx
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;
}

図書館の一覧を表示するルート

client/libraries.tsx
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>
    </>
  );
}

図書館の詳細を表示するルート

client/library.tsx
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年現在では十分に”枯れた”技術と言えるでしょう。

P.S.

最適化においては気にすることが多すぎて実務では使えていない

参考文献