Apollo のキャッシュ機構、完全に理解した(い)

この記事は JX 通信社アドベントカレンダーGraphQL アドベントカレンダーの4日目です

こんにちは、JX通信社の小笠原(@yamitzky)です。普段はエンジニア部門の統括をしています。

弊社の NewsDigest ではアプリ向けのバックグラウンド API として GraphQL を使っています(サーバーは gqlgen、アプリはライブラリなし)。

tech.jxpress.net

GraphQL の技術スタックとしては Apollo というライブラリが有名だと思うのですが、弊社では使ったことがありませんでした。そこで、今年の 9 月に箱根で開催した開発合宿にて、Apollo Client(React) と Apollo Server の検証を行いました。

Apollo Client を使うときに唯一鬼門だったのが、Apollo の持っているキャッシュ機構です。本日は、Apollo のキャッシュを完全に理解した(い)小笠原が、完全に理解するための方法をお教えいたします。想定読者としては、Apollo はなんとなく触ったことがある方です。

Apollo とは

Apollo は GraphQL のためのプラットフォーム(OSS群というのがわかりやすいでしょうか)で、

  • Apollo Server・・・GraphQL の API(バックエンド)を作るためのもの
  • Apollo Client・・・GraphQL のフロントエンド向けライブラリ。React や Vue などと連携できる
  • Apollo iOS、Apollo Android・・・アプリ向けのクライアントライブラリ

などの OSS から成り立ちます。今回はこの中の Apollo Client 中心の話です。

Apollo Client は、ざっくりと

  • API クライアント本体
  • キャッシュ・状態管理
  • コンポーネントから便利に取ってくるクライアントのラッパー(Hooks)

のような機能から成り立っています*1。雑な例えとしては、Axios(通信)、Redux(状態)、React Redux Hooks(便利ラッパー) を all-in-one にしたようなライブラリ、というようなイメージです。

f:id:yamitzky:20191204201343p:plain

使っているときにはあまり意識しないかもしれませんが、何か困ったら どの部分の問題なのか切り分けて調査をするのが重要 です。また、Apollo Client はキャッシュなしで使うこともできますし、React を経由せず Client 本体だけを使うこともできます。

Apolloのキャッシュの役割

Apollo のキャッシュの役割は、単に「キャッシュ」というよりは、「正規化された、Redux のような巨大な単一の state」という概念が近いです。

例えば次のように画面遷移するユーザー管理アプリを考えたとき、ページ遷移するたびに通信するのではなく、メモリ上の状態を賢く再利用したいケースがあると思います。*2

f:id:yamitzky:20191204204216p:plain

が、実際にはこのあたりが高機能過ぎて、キャッシュの仕組みが複雑であるという意見もあるようです。完全に理解したい。

Apollo キャッシュの読み書き

Apollo のキャッシュ読み書きする際の操作は、次の4つです。

  • readQuery
  • writeQuery
  • readFragment
  • writeFragment

{read,write}Queryはトップレベルの状態の読み書き、{read,write}Fragmentは構造化されたエンティティの読み書きというイメージです。この4つの読み書きと状態変化を理解したら「完全に理解した」と言えるでしょう(か)。

これらの操作は、基本的には ApolloClient 側で勝手に状態更新をする(故に難しい)のですが、自力で使うこともできます。例えば「あるアイテムを新規作成する mutation」など、自力で操作せざるを得ない場合もあります。

Apollo キャッシュを完全に理解する

では、 node の REPL を使って、この4つの読み書きを完全に理解してみます。先程の例でいうところの「キャッシュ」は使うが、「API クライアント」「コンポーネントから便利に取ってくるクライアントのラッパー」は使わないイメージです*3

# ライブラリをインストール
npm install graphql graphql-tag apollo-cache-inmemory
# repl を起動
node

node の REPL には次のように入れてください。

// ライブラリの読み込み
const InMemoryCache = require('apollo-cache-inmemory').InMemoryCache
const gql = require('graphql-tag')

// クライアントの初期化
const cache = new InMemoryCache()

ここまでで事前準備は完了です!

まずは、writeQuery を実行してみます。これは、クエリの発行をしたときにキャッシュにデータを追加/更新するためのものです。writeQuery の引数は、クエリと、GraphQL で取得したデータです。

// name=john に該当するユーザー一覧を取得
cache.writeQuery({
    query: gql`query {
        users(name: "john") { 
            id, name, __typename
        }
    }`,
    data: { users: [{ id: '1111', name: 'john', __typename: 'User' }] } 
})

さて、これによってデータはどう変わっているか、確認してみましょう。

// 不格好なフィールド名からお察しかもしれませんが、.data.data は TypeScript だと private 扱いです
console.dir(cache.data.data, {depth: null})

// 結果(一部略)
{
  'User:1111': { id: '1111', name: 'john', __typename: 'User' },
  ROOT_QUERY:
   { 'users({"name":"john"})':
      [ { type: 'id',
          generated: false,
          id: 'User:1111',
          typename: 'User' } ] } }

結果の中身は Object (連想配列/辞書型的な扱い)で、

  • 型名:id というキーに対して、その ID にあたるアイテムの中身が格納される
  • ROOT_QUERY というキーに対して、 { "変数つきのクエリフィールド": [ アイテムへの参照 ] } という構造でクエリ結果が格納される
  • データは正規化されている

となっています。また、GraphQL スキーマは InMemoryCache に与えていないので、この処理は 動的に行われている というのも注目ポイントです。

次に、 readQuery をしてみます。

cache.readQuery({
    query: gql`query {
        users(name: "john") { 
            id, name, __typename
        }
    }`
})

// 結果
{ users: [ { id: '1111', name: 'john', __typename: 'User' } ] }

クエリ結果は正規化した状態で格納されていたので、正規化されたデータを結合しながら、キャッシュされた結果を返しているということがわかります。

次に、writeFragment をしてみます。writeFragment は GraphQL のフラグメントのデータを更新(例: あるユーザーの名前を変更する、など)のためのものです。

// john さんは yamada さんに改名します
cache.writeFragment({
    id: 'User:1111',
    fragment: gql`fragment UserFields on User {
        name
    }`,
    data: { name: 'yamada', __typename: 'User' }
})

console.dir(cache.data.data, {depth: null})
// 結果
 {
  'User:1111': { id: '1111', name: 'yamada', __typename: 'User' },
  ROOT_QUERY:
   { 'users({"name":"john"})':
      [ { type: 'id',
          generated: false,
          id: 'User:1111',
          typename: 'User' } ] } }

この結果からわかることは、

  • 正規化された個別のアイテムに対して更新できている
  • name というフィールドだけ部分更新ができている

次に、readFragment をしてみます。

cache.readFragment({
    id: 'User:1111',
    fragment: gql`fragment UserFields on User {
        name
    }`
})

// 結果
{ name: 'yamada', __typename: 'User' }

この結果から、指定した ID のアイテムが取れてる他、fragment に指定したフィールドだけの部分取得もできていることがわかります! {read,write}Fragment は GraphQL の fragment のための機能ではありますが、実際には、正規化された各アイテムを部分取得/更新するためのものに過ぎないと言えるでしょう。

最後に、再度 readQuery をして、クエリ結果が正しく更新されたことを確認しましょう。

cache.readQuery({
    query: gql`query {
        users(name: "john") { 
            id, name, __typename
        }
    }`
})
{ users: [ { id: '1111', name: 'yamada', __typename: 'User' } ] }

無事、クエリ結果としても、名前が変更されていますね!

まとめ

今回は、Apollo Client の鬼門?であるキャッシュの読み書きを完全理解しました。 おっとすみません、完全に理解したは言い過ぎました。

ポイントとしては、

  • Apollo Client は「クライアント本体」「キャッシュ」「便利ラッパー」の要素からなる
  • Apollo Client のキャッシュを理解するには、 cache.data.data を見るのが手っ取り早い
  • キャッシュの中身は、正規化された巨大な Object
    • writeQuery は、クエリ結果を ROOT_QUERY などに正規化した状態で保存する
    • readQuery は、正規化した状態で保存された状態を結合しながら、キャッシュ結果を返す
    • {read,write}Fragment は、正規化されたアイテムを部分的に読み書きするためのもの

といったことを紹介しました。

それでは、良い GraphQL ライフを!

*1:実際の概念としては通信周りを Link として切り出しているので、あくまでイメージ図です

*2:fetchPolicy を変更すれば、逆にキャッシュを使わずに常にアクセスしに行くとかもできます

*3:今回は簡略化のため ApolloClient を経由せずキャッシュを直接触っていますが、この4つの読み書きは、ApolloClient を通じて行うべきです