📊

Apollo Clientのキャッシュから考えるサーバー設計

2023/02/24に公開

こんにちは、バックエンドエンジニアのyrmz です。
ここ最近は、TypeScript+NexusでGraphQLサーバーを書いています。

はじめに

 皆さん、GraphQLのキャッシュは使っていますか?
GraphQLのキャッシュはフロントエンドの話と思われがちですが、キャッシュの性能を発揮するにはバックエンドエンジニアもその仕組みを理解することが重要になってきます。

本記事では、キャッシュの性能を発揮するために、バックエンドエンジニアがGraphQLサーバー構築で考慮すべき点を紹介します。
※ クライアントライブラリは、Apollo ClientやRelayなどがありますが、本記事ではApollo Clientを前提とします。

GraphQLとは

 GraphQLとはAPIのためのクエリ言語であり、そのクエリを実行するためのランタイムです。
クライアント側が必要とする情報のみをクエリ言語でリクエストし、取得することができます。
これにより、レスポンスのデータ量をコントロールし、削減できます。

ApolloClientのキャッシュの仕組み

 サーバーから受け取ったレスポンスは、ROOT_QUERYオブジェクトに追加された状態でキャッシュに保存されます。
例えば、Userを取得する場合は、以下のような流れになります。

  • 初回
    1. クライアントはid:5のUserをリクエストします。
    2. キャッシュにid:5のUserが存在するか確認します。
    3. 初回はキャッシュに存在しないため、サーバーへUserの取得を問い合わせます。
    4. サーバーから取得したUserはキャッシュされます。
    5. Userをクライアントに返却します。
  • 2回目以降
    1. クライアントはid:5のUserをリクエストします。
    2. キャッシュにid:5のUserが存在するか確認します。
    3. サーバへは問い合わせずに、クライアントにUserを返却します。

この時、ROOT_QUERYにはフィールド名(引数)をキーとするオブジェクトとして追加されます。

キャッシュに保存されたオブジェクト
{
  "ROOT_QUERY": {
    // フィールド名(引数)がキーになる
    "user(id:5)":{
      "id": 5,
      "name": "ユーザー5",
    }
  }
}

キャッシュの正規化

GraphQLのクエリは、親オブジェクトに加えて、それに関連する子オブジェクトも指定することができます。
実際には、さらに複雑なクエリによるデータ取得が行われるでしょう。
複雑なデータをそのままキャッシュに保存してしまうと、同じidを持つ似通ったオブジェクトを複数保持してしまうことになり、キャッシュの重複につながります。

重複があるキャッシュ
{
  "ROOT_QUERY":{
    "users":[
      {
        "id": 1,
        "name": "ユーザー1",
        "posts": [
          // 重複しているオブジェクトが存在する
          {"id": 1,"title": "投稿1","body":"投稿内容","_typename": "Post"},
          {"id": 2,"title": "投稿2","body":"投稿内容","_typename": "Post"}
        ],
        "__typename": "User"
      },
      {
        "id":2,
        "name":"ユーザー2",
        "posts":[
          // 重複しているオブジェクトが存在する
          {"id":1,"title":"投稿1","body":"投稿内容","tag":"技術記事","_typename":"Post"}
        ],
        "__typename":"User"
      }
    ]
  }
}

キャッシュの重複はできる限り避けるべきです。
Apollo Clientは自動的にオブジェクトを最小単位で切り出し、保存する機能(正規化)によって、この問題を解決しています。
切り出したオブジェクトは__typename:idをキーとして保存され、__ref:__typename:idから参照されます。
GraphQLの特性上、idが同じオブジェクトでも保持しているフィールドが異なることがあります。
Apollo Clientはこのようなオブジェクトを1つにまとめて正規化してくれます。

正規化されたキャッシュ
{
    "ROOT_QUERY": {
    // フィールド名がキーになる
    "users": [
      {"__ref":"User:1"},
      {"__ref":"User:2"}
    ]
  }

  // 「__typename:id」をキーとしたオブジェクト
  "User:1": {
    "id": 1,
    "name": "ユーザー1",
    "__typename": "User",
    "posts":[
      {"__ref":"Post:1"},
      {"__ref":"Post:2"}
    ]
  },
  "User:2": {
    "id":2,
    "name":"ユーザー2",
    "__typename":"User",
    "posts":[
      {"__ref":"Post:1"}
    ]
  },
  "Post:1":{"id":1,"title":"投稿1","price":1000,"_typename":"Post"},
  "Post:2":{"id":2,"title":"投稿2","_typename":"Post"},
}

そのため、GraphQLサーバーから__typenameとidが返却されることは重要です。
仮にサーバー側から返却されなかったとしても、クライアント側でキーを独自に設定できますが、可能な限りデフォルトの設定に従うことをお勧めします。

Mutationによるキャッシュの更新

Mutationによってはキャッシュを更新できないことがあり、それを回避するには対策が必要です。
更新できないパターンは以下です。

  1. 更新したフィールドがレスポンスに含まれていない
  2. キャッシュされたコレクションの順序が変わる
  3. Mutationが追加、削除の操作を行う

1. 更新したフィールドがレスポンスに含まれていない

キャッシュはレスポンスを元に各オブジェクトを更新します。
そのため、更新したフィールドをレスポンスに含めなければなりません。
1つのMutationで複数オブジェクトのフィールドを更新する場合は、オブジェクトのリレーションを追加しサーバーから返却できるようにしましょう。

2. キャッシュされたコレクションの順序が変わる

実はキャッシュは配列内のオブジェクトの順序を保持しています。
しかし、順序を入れ替えてたという情報も更新してしまうと、キャッシュに反映できません。
これは、ROOT_QUERYのどのキーの配列が更新されたのかを知ることができないためと考えられます。
順序を入れ替えたオブジェクトを含む配列全体を再取得することで解決できます。
特にスキーマ設計で考慮する点はないと思いますが、知っておくと良いでしょう。

3. Mutationが追加、削除の操作を行う

ROOT_QUERYのどのコレクションが更新されたのかを知ることができないためと考えられます。
追加が成功した後にクライアント側でキャッシュにも追加するという処理を記述してあげます。
キャッシュを更新するためにも、追加Mutationは追加したオブジェクトの情報を返してあげましょう。

addUser.ts
const [mutate, { data, error }] = useMutation<
    UserTypes.AddUser, 
    UserTypes.AddUserVariables
  >(
    ADD_USER,
    {
      update (cache, { data }) {
        const newUserFromResponse = data?.addUser.user;
        const existingUsers = cache.readQuery<GetAllUsers>({
          query: GET_ALL_USERS,
        });

        if (existingUsers && newUserFromResponse) {
          cache.writeQuery({
            query: GET_ALL_USERS,
            data: {
              users: [
                ...existingUsers?.users,
                newUserFromResponse,
              ],
            },
          });
        }
      }
    }
  )

削除の場合も、追加と同じような理由からキャッシュが更新されません。
キャッシュを更新するには、クライアント側でキャッシュから対象のオブジェクトを削除します。
キャッシュ削除にはidから対象のオブジェクトを探し出しますので、削除Mutationのレスポンスとしては削除したオプジェクトのid一覧を返してあげましょう。

deleteUser.ts
const [mutate, { data, error }] = useMutation<
  DeleteUserTypes.DeleteUser, 
  DeleteUserTypes.DeleteUserVariables
>(
  DELETE_USER,
  {
    update (cache, el) {
      const deletedId = el.data?.deleteUser.user?.id
      const allUsers = cache.readQuery<GetAllUsers>({ query: GET_ALL_USERS });

      cache.writeQuery({
        query: GET_ALL_USERS,
        data: {
          users: allUsers?.users.filter((t) => t?.id !== deletedId)
        }
      });

      cache.evict({ id: el.data?.deleteUser.user?.id })
    }
  }
)

まとめ

Apollo Clientは取得したデータをインメモリキャッシュに正規化して自動的に保存します。
以下のようにサーバーを設計することで、キャッシュ機構の性能を発揮することができます。

  • idと__typenameを返却できるようにする。
  • 更新した項目を返却できるようにする。
    • 更新項目のフィールドをオブジェクトに追加
    • オブジェクトを跨ぐ場合は、リレーション先を追加
  • 更新Mutationのレスポンスとして、更新したオブジェクトを返却する
  • 削除Mutationのレスポンスとして、削除したオブジェクトのidを返却する

おまけ

バックエンドエンジニアもApollo Clientのキャッシュに興味を持ってもらえましたか?
実際にどうキャッシュされているか見てみたいという方もいるでしょう。
そんな方のためにApollo Client Devtoolsが提供されています。
ApolloClient初期化時にconnectToDevTools:trueにするとブラウザのdevtoolから確認できます。

Apollo Client Devtoolsを有効にする。
new ApolloClient({
  uri: 'https://xxx.com',
  cache: new InMemoryCache(),
  connectToDevTools: true
});

参考文献

Demystifying Cache Normalization
Caching in Apollo Client
Developer tools


トリドリのプロダクト開発部では通年採用を実施しています! ユーザの幸せについて真剣に議論したり、「やってみたい」で新しい技術に挑戦をしてみたり、気づいたら30分雑談していたり、ゆるく真面目に開発しています。もし興味を持ってもらえたら、こちらを読んでみてください!

https://toridoridevs.page.link/29hQ

GitHubで編集を提案
toridori tech blog

Discussion