hiroaki's blog

技術系を中心に気になったこととかいろいろと。

GraphQL を Apollo を使ってキャッチアップしてみた

直近で GraphQL を使いそうな気配がしているので今更ながらキャッチアップしてみた。 最後に書いているが実践で使用できるような細かい設定は引き続き調査して試していくところではあるが、現時点で CRUD 機能をもつ簡易的なアプリケーションを作成するところまでは試してみたので今後の自分用のメモとして初期インストールから実装まででやったことを残しておくこととする。

なお、今回のコードは全て GitHub にアップしているので、もしコメントなどがあれば是非意見をいただきたい。 github.com

GraphQL とは

GraphQL とは API のための問い合わせ言語であり、クライアント/サーバー間通信のための言語仕様である。

今までは API を構築するとなると REST の設計に基づいて実装することが多かったが、取得できるデータが URL と結びついて理解しやすくなった反面、リソースが分離していることで逆に取得するデータが過剰になってしまったりリクエストが増えてしまったり、という問題が起きることがあった。

GraphQL ではクライアントが必要なデータを定義し、サーバー側もそれに従ってスキーマを構築していくことになるため上記のような問題が発生しにくくなる。また、明確にリクエストデータとスキーマで型を定義することになるため、クライアント/サーバー間でどのようなデータをやり取りするのかが開発者に分かりやすくなるというメリットもある。

アプリケーションとリポジトリの構成

今回は JavaScript(TypeScript)を使用し、GraphQL のサーバー/クライアントともに Apollo を使用して実装していくこととする。サーバーとクライアント両方を作成していくので同じリポジトリ内にディレクトリを分けて構成していく。

.
├── README.md
├── client
│   ├── README.md
│   ├── node_modules
│   ├── package.json
│   ├── prettier.config.js
│   ├── public
│   ├── src
│   ├── tsconfig.json
│   └── yarn.lock
└── server
    ├── index.js
    ├── node_modules
    ├── package-lock.json
    └── package.json

GraphQL Server を構築する

GraphQL リクエストを受けるサーバーを構築していく。

初期セットアップ

まずは Node.js のプロジェクトとしてセットアップする。

$ npm init

次に GraphQL のサーバー関連のパッケージをインストールする。

$ npm install apollo-server graphql

GraphQL リクエストを受けるサーバーの構築

インストールが完了したらサーバー側のコードを書いていく。 まずは apollo-server から必要なパッケージを読み込む。

const { ApolloServer, gql } = require("apollo-server");

次に GraphQL でリクエストを受ける際のスキーマ定義を記述していく。

const typeDefs = gql`
  type Book {
    title: String
    author: String
  }

  type Query {
    books: [Book]
  }

  input InputBook {
    title: String
    author: String
  }

  type Mutation {
    addBook(input: InputBook): Int
  }
`;

type Query は特別な記述でアプリケーションのデータを取得する際に使用する。クライアントからくるデータ取得用のクエリを全て記述しておくことでサーバー側で受けるリクエストを定義できる。ここでの type Book のような感じで Query の定義を別出しにして、クエリのオブジェクトをネストして定義することもできる。

アプリケーションのデータに変更を加える場合は type Mutation を使用してミューテーションとして定義する必要がある。また Mutation に使用している変数の型は 入力型 と呼ばれ、input として定義を記載していく。この入力側の定義は引数に対してのみ使用することができる。ページングの情報やフィルタリングなど、実行するアクションは異なるが渡すパラメータが同じ、といった場合にこのような入力型で統一した定義を使用してユーザーが理解しやすくメンテナンスもしやすくなってくる。

引数ありのリクエストは下記のようなフォーマットで受けることができる。

const typeDefs = gql`
  type Book {
    title: String
    author: String
  }

  type Query {
    books(author: String): [Book]
  }
`;

後述するがこれに対してクライアント側で下記のようなリクエストを送ることで値を受け取れるようになる。

// Client
query {
  books(
    author: "Hiroaki Imai" // ここが引数としてサーバー側に渡される
  ) {
    title
    author
  }
}

次にリクエストに対するレスポンスを記述しておく。今回は動作確認が目的なので DB は用意していないが、実際のサービスなどでは DB から取得した値をレスポンスにセットするため、今回のような固定値を配列で用意しておくような処理は不要となる。

// 今回は一時的に Array の変数として準備しておく
let books = [
  {
    id: "4371b53e-e8e0-489e-9d92-fc243bc9dc48",
    title: "The Awakening",
    author: "Kate Chopin",
  },
  {
    id: "f267b324-6dc5-4b01-8d33-7e6dbcd60710",
    title: "City of Glass",
    author: "Paul Auster",
  },
  {
    id: "eb9e1b15-37cb-4f6e-a3de-e3ffbe3a499e",
    title: "Apollo server example.",
    author: "Hiroaki Imai",
  },
];

Apollo Server は、クライアントから呼び出された際にクライアントから要求されたクエリに対してどのようなレスポンスを返す必要があるのがを知っておく必要がある。これを実現するために、リゾルバを使用する。

ゾルバはスキーマの特定のフィールドのデータを返却する関数である。バックエンドデータベースやサードパーティAPI からデータをフェッチするなど、データを取得して定義されたスキーマに従ってクライアントにレスポンスを返す。

下記の例ではリクエストとして渡ってきた値をもとに定義された Array を filter した結果をレスポンスとして返している。

const resolvers = {
  Query: {
    books: (parent, args, context, info) => {
      return books.filter((value) => value.author === args.author);
    },
  },
  Mutation: {
    addBook: (parent, args, context, info) => {
      const requestData = args.input;
      if (requestData.title !== "" && requestData.author !== "") {
        const id = uuidv4();
        books.push({ ...requestData, id });
      }
      return books.length;
    },
  },
};

resolvers の中では parent, args, context, info というパラメータが引数として定義されている。Query の箇所で定義した引数は resolvers 内で args として受けることができる。(parent、context、info に関しては今回の記事では説明を省略)

次に Apollo サーバーにスキーマ定義である typeDefs とそれに対するデータ入力フィールドを定義した resolvers を渡して初期化する。

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

最後にインスタンス化した Apollo サーバーでリクエストを待ち受ける処理を記述する。

server.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});

ここまでで Apollo サーバーの記述は以上となる。この状態で下記のコマンドを実行すれば GraphQL のリクエストを処理する Web サーバーが起動する。

$ node server/index.js
🚀  Server ready at http://localhost:4000/

サーバーの hot reload

開発中の効率を上げるためにサーバーの hot reload を設定する。 nodemon を使用することでサーバー側のコードを変更した際に自動でリロードされるようになる。

$ npm install --save-dev nodemon
$ npx nodemon index.js

GraphQL Client を構築する

初期セットアップ

次にクライアント側を作成していく。今回は React を使用して作成していくこととするため、create-react-app を使用する。(今回は create-react-app 自体の説明は省略する。)TypeScript を使用するためオプションに --template typescript を指定する。

npx create-react-app . --template typescript

クライアント作成に必要なパッケージも合わせてインストールしていく。

npm install @apollo/client graphql --save

データ取得用のリクエストクエリ作成

まずはページ表示時のクエリを作成していく。

下記の用に apollo-client をインポートし、ページの初回表示時にリストの情報を取得するクエリを作成する。

GraphQL ではデータの取得には query オペレーションを使用する。query オペレーションの定義には変数として使用する値の定義も合わせて記述する。ページ初回表示時には存在している全てのデータを取得してくる動きとなるが、ここではデータの検索も想定して特定のパラメータを渡すことができるようにしておく。

今回の例では実際の開発時を想定して QueryData.ts という外部ファイルを作成し、そこにリクエスト用のクエリを記述していくこととする。

// data/QueryData.ts
import { gql } from '@apollo/client'

export const FETCH_BOOK_LIST = gql`
  query FetchBookListQuery($text: String!) {
    books(author: $text) {
      id
      title
      author
    }
  }

create-react-app にて App.tsx というファイルが作成されているので下記のように修正していく。

// App.tsx
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'
import { ApolloClient, InMemoryCache } from '@apollo/client'
import { FETCH_BOOK_LIST } from './data/QueryData'
import { ADD_BOOK, DELETE_BOOK, UPDATE_BOOK } from './data/MutateData'
import 'reset-css'
import './App.css'

type BookList = {
  id: string
  title: string
  author: string
}[]

const client = new ApolloClient({
  uri: 'http://localhost:4000/',
  cache: new InMemoryCache(),
})

function App() {
  const [bookList, setBookList] = useState<BookList>([
    { id: '', title: '', author: '' },
  ])

まずは apollo-client をインポートし、クライアントの初期設定を行っていく。今回はローカルで試すことのみを想定しているため、url は先程 GraphQL サーバーを作成した際に設定した http://localhost:4000/ を指定する。

ページ表示時に実行されるリクエスト処理を作成していく。まずは先程作成したクエリをインポートし、リクエスト用のオブジェクトを作成する。そのオブジェクトを初期化したクライアントの query オペレーションにセットし実行する。query オペレーションは非同期処理で Promise を返すため、then でリクエストが完了したあとの処理を記載しておく。

const fetchList = (text: string = '', forceRefresh: boolean = false) => {
  const requestQuery = {
    query: FETCH_BOOK_LIST,
    variables: { text },
  }
  client
    .query(
      forceRefresh
        ? {
            ...requestQuery,
            fetchPolicy: 'no-cache',
          }
        : requestQuery
    )
    .then((result) => {
      if (result.errors) {
        console.log('Failed to fetch data.')
      } else {
        setBookList(result.data.books)
      }
    })
}

次に fetchList アクションをページ表示時に 1 度のみ実行されるように useEffect を使用して記述しておく。

// For initial rendering.
useEffect(() => {
  fetchList();
}, []);

取得したデータを Reactコンポーネント内で使用してレンダリングすることで、GraphQL で取得したデータを画面で表示できるようになる。

  return (
    // 省略
    {bookList.map((book, index) => {
      const isModifyTarget =
        modifyBookInfo.modify && book.id === modifyBookInfo.id
      return (
        <tr key={index}>
          <td className={'data'}>{index + 1}</td>
    // 省略
  )

パラメータを必要とするクエリの組み立て

ページ表示時のフェッチ処理の部分で説明を省いてしまったが、検索アクションを実行する場合はこのアクションの中で検索用のパラメータを GraphQL のクエリにセットする必要がある。ここでは variables という機能を使用して変数をクエリに渡している。

Template String を使用して GraphQL のクエリ文字列にパラメータを直接展開することももちろんできるが、その場合は好きな文字列を渡すことができてしまうため SQL インジェクションのような攻撃を受けてしまうことが想定される。variables を使って型定義を記述し、入力される変数の型チェックを実施した上で実行クエリに変数をバインディングすることで、セキュリティホールを作らずに安全にリクエストを送信することができるようになる。

データの更新時のリクエストクエリ作成

ここではデータ取得以外のリクエスト方法について説明していく。GraphQL ではデータの「追加、更新、削除」といったデータの更新が必要なリクエストに関しては Mutation オペレーションを使用する。

実際に対象データを追加していくクエリを作成していく。今回は MutateData.ts という名前で外部ファイルを作成してそこに「追加、更新、削除」用のクエリを記載していく構成としている。

import { gql } from "@apollo/client";

export const ADD_BOOK = gql`
  mutation AddBookQuery($input: InputBook) {
    addBook(input: $input) {
      id
      title
      author
    }
  }
`

Apollo client からリクエストする際のメソッドが mutate になっているくらいのみで、基本的にはデータ取得時のクエリとほとんど構成は変わらない。

const addBook = useCallback(() => {
  client
    .mutate({
      mutation: ADD_BOOK,
      variables: { input: { title, author } },
    })
    .then((result) => {
      if (result.errors) {
        console.log('Failed to add data.')
      } else {
        fetchList(searchText, true)
      }
    })
}, [title, author, searchText])

リクエストに関しては Apollo Server の構築の際に Mutation として定義していた addBook が呼び出され、実際にデータが追加されるという流れになっている。

今回のまとめ

今回は導入編ということで環境のセットアップから Apollo というライブラリを使用して、GraphQL のサーバーとクライアントを作成するところまでを説明した。上記では初回の読み込みとデータの追加しか説明していないが、更新と削除まで実装したものを Github にアップしているので詳細はそちらを参照していただきたい。

github.com

また実践導入にあたっては下記のポイントの考慮も必要になってくるため、こちらも色々と試しつつ別途ブログにまとめていきたいと思う。

  • 細かいキャッシュの設定
  • ファイルアップロード(multipart/form-data の扱い)
  • サブスクリプションの活用方法
  • エラーハンドリング

参考