この記事は JX 通信社アドベントカレンダー、GraphQL アドベントカレンダーの4日目です
こんにちは、JX通信社の小笠原(@yamitzky)です。普段はエンジニア部門の統括をしています。
弊社の NewsDigest ではアプリ向けのバックグラウンド API として GraphQL を使っています(サーバーは gqlgen、アプリはライブラリなし)。
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 にしたようなライブラリ、というようなイメージです。
使っているときにはあまり意識しないかもしれませんが、何か困ったら どの部分の問題なのか切り分けて調査をするのが重要 です。また、Apollo Client はキャッシュなしで使うこともできますし、React を経由せず Client 本体だけを使うこともできます。
Apolloのキャッシュの役割
Apollo のキャッシュの役割は、単に「キャッシュ」というよりは、「正規化された、Redux のような巨大な単一の state」という概念が近いです。
例えば次のように画面遷移するユーザー管理アプリを考えたとき、ページ遷移するたびに通信するのではなく、メモリ上の状態を賢く再利用したいケースがあると思います。*2
が、実際にはこのあたりが高機能過ぎて、キャッシュの仕組みが複雑であるという意見もあるようです。完全に理解したい。
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 を通じて行うべきです