この記事は 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 にしたようなライブラリ、というようなイメージです。
使っているときにはあまり意識しないかもしれませんが、何か困ったら どの部分の問題なのか切り分けて調査をするのが重要 です。また、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
node
node の REPL には次のように入れてください。
const InMemoryCache = require('apollo-cache-inmemory').InMemoryCache
const gql = require('graphql-tag')
const cache = new InMemoryCache()
ここまでで事前準備は完了です!
まずは、writeQuery を実行してみます。これは、クエリの発行をしたときにキャッシュにデータを追加/更新するためのものです。writeQuery の引数は、クエリと、GraphQL で取得したデータです。
cache.writeQuery({
query: gql`query {
users(name: "john") {
id, name, __typename
}
}`,
data: { users: [{ id: '1111', name: 'john', __typename: 'User' }] }
})
さて、これによってデータはどう変わっているか、確認してみましょう。
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 のフラグメントのデータを更新(例: あるユーザーの名前を変更する、など)のためのものです。
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 ライフを!