Apollo ClientのInMemoryCacheとMutationに関する調査・考察

本記事は はてなエンジニアAdvent Calendar 2019 3日目のエントリーです。

はじめに

Apollo Client (執筆時 v2.6.4)は、Apollo プラットフォームが提供する JavaScript で実装された GraphQL クライアントです。Apollo Client には InMemoryCache (執筆時 v1.6.3)という強力なキャッシュ機構が用意されており、クエリの結果をキャッシュすることができます。このキャッシュ機構は、多くの場面で API サーバーとの通信回数を削減しパフォーマンスの向上に貢献します。しかし、Mutation によるオブジェクトの作成・更新・削除を行う際には、Apollo Client を使う側がその変更をキャッシュに反映するよう意識する必要があります。例えば、オブジェクトAを含むリストがキャッシュされていたとして、オブジェクトAを Mutation で削除した場合、キャッシュに含まれるオブジェクトAも無効化されるのが望ましいでしょう。そうした操作を効果的に行うには、InMemoryCache の動作を理解することが不可欠です。本記事では、まず Apollo Client の InMemoryCache の動作を具体例を用いて解説します。そのあとで、Mutation を行う際のキャッシュの取り扱いについて調査・考察した内容をまとめます。

InMemoryCache の動作

InMemoryCache はクエリの結果のオブジェクトをキャッシュします。クエリの結果はツリー状のデータ構造ですが、キャッシュするときはデータを正規化して保存します。正規化の方法については こちら で詳しく述べられています。大雑把な動作としては、ツリーをたどって見つかった各オブジェクトについて、以下のようにキーを決定して、単純なキーバリューの組に分解します。

  • id (ないし_id)フィールドと __typename フィールドが存在するオブジェクトは "(__typename):(id)" というキーで保存する
    • (補足): Apollo Client はデフォルトで __typename フィールドを補完してクエリを発行するため、プログラマ__typename フィールドを明示する必要はない
  • 上の条件を満たさなければ、ツリーにおけるそのオブジェクトまでの経路をキーとして保存する

GitHub が提供する GraphQL API を使って例を見てみます。以下のようなクエリを Apollo Client で実行します。

query  {
  viewer {
    id
    name
    repositories(first: 10) {
      edges {
        node {
          id
          name
        }
      }
    }
  }
}

結果は以下のような JSON になります。

{
  "viewer": {
    "id": "yigarashi",
    "name": "Yuu Igarashi",
    "repositories": {
      "edges": [
        { "node": { 
            "id": "repo0", 
            "name": "repository_0" 
          } 
        }, 
        { "node": { 
            "id": "repo1", 
            "name": "repository_1" 
          } 
        }, 
        ... 
      ]
    }
  }
}

キャッシュされているデータは client.store.cache.data で参照できます。以下のようなキーが並んでいます。

  • ROOT_QUERY
  • User:yigarashi
  • $User:yigarashi.repositories({"first":10})
  • $User:yigarashi.repositories({"first":10}).edges.0
  • ...
  • $User:yigarashi.repositories({"first":10}).edges.9
  • Repository:repo0
  • ...
  • Repository:repo9

ROOT_QUERY はトップレベルのデータを表す特殊なキーです。先頭が $ で始まっているキーはオブジェクトまでのパスから生成されたものです。それ以外は (__typename):(id) の形式で生成されたものです。確かに、RepositoryConnectionRepositoryEdgeid を引いておらず、それ以外のオブジェクトでは id を引いており、上で説明した正規化戦略と結果が一致していることがわかります。

各キーに対応する値は基本的にクエリの結果で返されたデータですが、参照先がキャッシュ内に存在する場合はそのキーだけを持つようになっています。例えば $User:yigarashi.repositories({"first":10}).edges.0 というキーの値は { node: { id: " Repository:repo0", ... } } というようになっており、キャッシュ内にある Repository オブジェクトを指すようになっています。

次回のクエリ実行では、デフォルトではまずキャッシュのデータからクエリの結果を構築しようとし、データが足りればそのまま返して、足りなければサーバーに問い合わせることになります。

InMemoryCache と Mutation

書き込み操作を行うときにマスターデータとキャッシュの一貫性を気にする必要があります。例えば、キーが Repository:repo0 のキャッシュが存在する状態で、repo0 の名前を変更する Mutation を発行したら、キャッシュされているリポジトリのデータも更新しなければいけません。さもなくば、キャッシュが維持されている間は画面にリポジトリ名の変更が反映されません。私が知る限り、キャッシュの恩恵を受けながらキャッシュを更新する方法として、4つが Apollo Client のドキュメントにまとめられています。以下では、それらを簡単に紹介するとともに、メリット・デメリットについてまとめます。また、最後にそれらを組み合わせた実践的な使い方を考察します。

1. Mutation の結果としてオブジェクトを取得する

こちら で述べられているように、Mutation の結果としてオブジェクトを取得し、それによってキャッシュを更新します。例えば GitHubリポジトリ名の更新は、次のような Mutation を発行するだけでオブジェクトが再取得され、キャッシュの Repository:repo0 というキーに対応するオブジェクトが更新されます。

mutation {
  updateRepository(input: { repositoryId: "repo0", name: "NewRepoName0" }) {
    id
    name
  }
}
  • メリット
    • Mutation で必要なフィールドを含むように気をつけるだけでよく簡単
  • デメリット
    • 更新操作でしか機能しない(要素の作成や削除は自動的に反映されない)

2. 変更したオブジェクトを含みうるクエリを明示的に発行する

こちら の前半で述べられているように、mutation 関数のオプショナル引数である refetchQueries を用いて、Mutation による変更が反映されるべきクエリを明示的に引き直してキャッシュを更新します。GitHub API の例に沿うなら、createRepository mutation を行なった後に viewer.repositories を引き直すといった場面が考えられるでしょう。キャッシュと Mutation について考える時、「作成・削除操作とそのオブジェクトを含むリスト」という場面が代表的な悩みのタネになりますが、この方法はその解決策の1つとなります。

  • メリット
    • オブジェクトの作成や削除に伴うキャッシュの更新に対応できる
  • デメリット
    • 大規模アプリケーションで引き直すべきクエリが増えたとき管理が難しい
    • 実際にサーバーにリクエストを送信する(軽微なデメリット)

3. キャッシュを直接書き換える

こちら の後半で述べられているように、mutation 関数のオプショナル引数である upadte を用いて、キャッシュを直接操作して更新を反映します。readQuery でキャッシュからデータを読み出し、それを書き換えて、writeQuery で再びキャッシュに書き込むのが基本の形になります。オブジェクトの作成や削除に対応できるという点で2番目の方法と類似しています。

  • メリット
    • オブジェクトの作成や削除に伴うキャッシュの更新に対応できる
    • キャッシュの操作のみで完結するので通信のオーバーヘッドがない
  • デメリット
    • 明示的なキャッシュ操作が必要で実装コストが高い
    • 大規模アプリケーションで操作すべきキャッシュを網羅するのが難しい

4. キャッシュポリシーを調整する

こちら で述べられているように、query 関数の fetchPolicy をキャッシュしないような設定にすることで、パフォーマンスや通信コストと引き換えに煩雑なキャッシュ更新操作を避けることができます。fetchPolicyここ にまとめられており、クエリごとに用途にあったポリシーを選択することができます。このメリット・デメリットは、キャッシュポリシーや扱うデータの特性に大きく依存するため、ここで一概にまとめるのは難しいでしょう。

以上の手法をどのように組み合わせるか

実際のアプリケーションでは、データの特性に応じて異なる戦略を取るのが効果的であると考えます。例えば以下のようなことが考えられます。

  • トップページの「新着コンテンツ」のような箇所では、ユーザーのコンテンツ操作に応じて頻繁にデータが更新される必要はないことが多いです。そのような場合はキャッシュをしても問題はなく、Mutation 時の明示的な更新も考える必要がないかもしれません。
  • ログインユーザーに見せる「自分のコンテンツ一覧」のような箇所では、ユーザーのコンテンツ作成・削除が直ちに反映されるのが望ましいでしょう。そのような箇所に限って 、2. や 3. の手法をとったり、キャッシュポリシーを必ずサーバーに問い合わせるものに設定するのは効果的です。
  • 作成・削除のUIと一覧表示が同じ画面内に存在するときは、特に 3. が有効です。ページ遷移がない分、通信のオーバーヘッドが目立ちやすい箇所なので、ブラウザ内の計算のみで完結することでユーザー体験を向上させることができるでしょう。

まとめ

本記事では、まず Apollo Client の InMemoryCache の動作についてまとめました。InMemoryCache はクエリの結果を正規化して保存しており、平らなキーバリュー型のデータ構造になっていることを説明しました。次に、そうした知識を踏まえて、Mutation のあとにどのようにキャッシュを更新したら良いかについてまとめました。キャッシュの更新は大きく分けて4つの方法があり、データの特性に合わせて戦略を変えたり、複数の方法を組み合わせるのが良いでしょう。