Apollo Clientのキャッシュと向き合う
データフェッチはキャッシュとの戦いです。サーバからデータを取得したあと、整合性を保ちながら再利用するために、どんな工夫ができるのでしょうか。
背景
筆者はTypescript環境でReactを用いてフロントエンドを構築する作業を主な生業としていますが、データフェッチの方法としてGraphQLを用いることが多く、その場合のライブラリには主にApollo Clientを使用しています。
GraphQLのクライアントライブラリとしては歴史が深く、黎明期から存在する古株です。そのぶん安定しており機能も豊富ですが、言い換えれば比較的重量級とも言えます。
今回はこのApollo Clientのキャッシュと向き合ってみたいと思います。
データフェッチライブラリのキャッシュ?
Apollo ClientはGraphQLを用いたデータフェッチのためのライブラリです。この表現は誤りではありませんが、全体を正しく表してもいません。
Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. Use it to fetch, cache, and modify application data, all while automatically updating your UI.
Apollo Client は、GraphQL を使用してローカル データとリモート データの両方を管理できる JavaScript 用の包括的な状態管理ライブラリです。これを使用して、アプリケーション データをフェッチ、キャッシュ、および変更しながら、UI を自動的に更新します。
包括的な状態管理ライブラリ(comprehensive state management library)という表現が登場しましたが、これは言い得て妙で、Apollo Clientの性質を綺麗に表していると思います。
Apollo ClientはGraphQLを用いてデータをフェッチできますが、基本的にフェッチされたデータはキャッシュされます。このフェッチされたデータがキャッシュされるということが大変重要なのです。
なぜキャッシュが大切なのか
下記のパターンを考えてみましょう。
const QUERY = gql`
query findUser($userId: Int!) {
getUser(userId: $userId) {
id
name
}
}
`;
type Props = { userId: number };
export const Component = ({ userId }: Props) => {
const { data } = useQuery(QUERY, { variables: { userId }});
// ...省略...
return <p>{data.name}</p>
}
// 100回のhttpリクエストが発生してしまう...?
[...Array(100)].map(() => <Component userId={1} />);
同じ条件・同じレスポンスのAPIへのリクエストを繰り返していますよね。これがもしキャッシュを利用しないものであった場合には、過剰なネットワークリクエストによって(不必要に)ユーザーを待たせ、(不必要に)システムに負荷をかけます。
繰り返しになりますが、Apollo ClientはGraphQLを用いてデータをフェッチし、キャッシュします。ある地点で実行したデータフェッチによってキャッシュが形成され、別の地点で実行したデータフェッチのレスポンスがキャッシュによって賄える場合は、キャッシュから値を返し、ネットワークリクエスト自体を行いません。
type Props = { userId: number };
export const ComponentA = ({ userId }: Props) => {
const { data } = useQuery(QUERY, { variables: { userId }});
// ...省略...
return <p>{data.name}</p>
}
export const ComponentB = ({ userId }: Props) => {
const { data } = useQuery(QUERY, { variables: { userId }});
// ...省略...
return <p>{data.name}</p>
}
// クエリと変数の組み合わせがキャッシュにないので、実際にhttpリクエストされる
<ComponentA userId={1} />
// ComponentAによって既にキャッシュに存在するクエリと変数の組み合わせなので、キャッシュから値を返し、httpリクエストは実行されない
<ComponentB userId={1} />
// クエリと変数の組み合わせがキャッシュにないので、実際にhttpリクエストされる
<ComponentA userId={2} />
これが可能なのはApollo Clientがアプリケーション全体でグローバルなデータストアを構築しているからであり、それは時にReduxやRecoilのようにも見えます。データフェッチのためのライブラリと言うよりはGraphQLのデータフェッチ機能が付いたグローバルなデータストアという趣であり、 包括的な状態管理ライブラリという表現がしっくりきますね。
データフェッチライブラリのキャッシュとストア
ちなみに、上記のような「データフェッチライブラリが高度なキャッシュとグローバルなストアを備える」ことは、2023年12月時点の情勢では比較的標準と言える機能です。GraphQLに限った話ではありませんし、SWRやTanStack Query(React Query)など、多くのライブラリで同様の考え方をすることができるでしょう。
キャッシュを扱う
重要性が理解できたところでキャッシュを扱ってみたいところなのですが、実はApollo Clientを含めた多くのデータフェッチライブラリは、基本的なキャッシュの更新は自動でやってくれます。
Apollo Clientで自動処理をうまく動かすためにはGraphQLのお作法に則る必要があり、主にAPIのレスポンスの型が要件を満たしているかどうかが鍵になります。PrismaやTypeGraphQL等のORMを起点とした技術の連携によって自動生成された型を使用するケースも多いと思いますので、その場合は問題になりません。
行儀の良いGraphQLレスポンスとは
それでも、正しい知識を持たないままカスタムリゾルバを使用したり、あるいはAPI自体を独自に実装したりして、行儀の悪いレスポンスを作成してしまうこともあるかもしれません。どのようにすればいいのでしょうか。
query findUser($userId: Int!) {
getUser(userId: $userId) {
id
name
__typename
}
}
GraphQLレスポンスとして最低限正しく振る舞うためには、下記の条件を確認してください。
-
getUser
は__typename
としてUser
を返す(重要) -
getUser
はUser
として一意なid
を返す(重要)
GraphQLレスポンスには基本的に型(type)があり、型とIDの組み合わせをキーにしてキャッシュを形成します。このどちらかが欠けても正常にキャッシュを形成することができず、仮に形成できても値を正常に保つことができません。オプションで別の値をキーとして使用するように設定することもできますが、対症療法的な茨の道です。
レスポンスがid
もしくは__typename
を欠いてしまう場合には、APIを整えることを検討したほうが建設的でしょう。
基本的なキャッシュの構造
Apollo Clientのキャッシュは大変シンプルな構造をしています。前述で触れたとおりキーになるのはデータの型とid
であり、__typename:id
の形式でフラットにキャッシュを形成します。なぜレスポンスに__typename
とid
を欠いてはいけないのかがよくわかると思います。
また「どのクエリにどのデータがどんな順番で返されたか」を管理しているのがROOT_QUERY
です。これは各データへの参照で形成されていると考えてください。
ROOT_QUERY
├ getUser({ userId: 1 }): { __ref: User:1 }
├ getUser({ userId: 2 }): { __ref: User:2 }
├ getArticle({ articleId: 10 }): { __ref: Article:10 }
└ getAllArticles: { __ref: [{ __ref: Article:1 }, { __ref: Article:2 }, { __ref: Article:10 }, ...] }
User:1
User:2
Article:1
Article:2
Article:10
...
もしgetUser
以外のクエリでid
が1
のUser
が返されることがあっても、User:1
は新しい値で自動的に更新され、getUser({ userId: 1 })
も更新された新しいUser:1
を返すようになります。
基本はこれだけですが、無駄な通信を抑制しながら、グローバルなデータストアとして振る舞っていますよね!
Apollo Client Devtools
Apollo Clientのキャッシュが実際にどのように生成・管理されるかを理解するのに最も有用なのは、Apollo Client Devtoolsを使用してキャッシュの動きを見ることです。
手動でのキャッシュコントロールが必要な一例
idがないか、idが一意でない
理想的でないレスポンスを使用するしかない場合には、キャッシュの識別子をカスタマイズできます。
const cache = new InMemoryCache({
typePolicies: { User: { keyFields: ["userName", "createdAt"] } } },
});
const apolloClient = new ApolloClient({ cache });
User:{"userName":"tarou","createdAt":"2023-01-01"}
apolloClientの初期化時にInMemoryCache
にさまざまなオプションを設定できます。
キャッシュは上記のようなカスタマイズされた値として保持されます。
値の変更によってデータ自体が作成されるケース
クエリで取得した時点ではデータが存在しなかったが、ユーザの操作によってデータが作成され、そのデータが既に実行ずみのクエリの取得条件を満たす場合です。
const QUERY = gql`
query findUser($userId: Int!) {
getUser(userId: $userId) {
id
name
__typename
}
}
`;
type QueryVariables = { userId: number; };
type QueryResult = {
getUser: {
id: number;
name: string;
__typename: "User";
} | null;
};
const { data } = useQuery<QueryResult, QueryVariables>(QUERY, {
variables: { userId },
});
console.log(data?.getUser); // null
このときQuery.getUser({ userId: 1 })
がnullだったとします。
ユーザーが存在しないので、作成するためにMutationを実行したとしましょう。
const MUTATION = gql`
mutation createUser($userId: Int!) {
createOneUser(userId: $userId) {
id
name
__typename
}
}
`;
type MutationVariables = { userId: number; };
type MutationResult = {
createOneUser: {
id: number;
name: string;
__typename: "User";
};
};
const [mutation, { data, loading, error }] = useMutation<
MutationResult,
MutationVariables
>(MUTATION);
}
const result = await mutate({
variables: { userId: 1 },
});
console.log(result?.createOneUser);
// { id: 1, name: "defaultName", __typename: "User" }
Mutation.createOneUser
は__typename: User
を返すので、Apollo ClientのキャッシュにはUser:1
が形成されます。これはQuery.getUser
が期待する型のデータです。
しかし、このUser:1
が、Query.getUser({ userId: 1 })
が返すべき値だったのかどうかはクライアントサイドからは知ることができません。それはAPIを処理するバックエンドのロジックだからです。
ROOT_QUERY
└ getUser({ userId: 1 }): null // キャッシュがこのクエリで得られるべきなのかはわからない
User:1 { id: 1, name: "defaultName", __typename: "User" } // キャッシュ自体は形成できる
よって、このようなケースでは手動でのキャッシュの更新を必要とします。
useMutation
のupdate
でキャッシュの更新処理を記述できます。
const QUERY = gql`
query findUser($userId: Int!) {
getUser(userId: $userId) {
id
name
__typename
}
}
`;
const handleCache: Type = (cache, { data }, { variables }) => {
cache.writeQuery({
query: QUERY,
variables: { userId: variables?.userId },
data: { getUser: data?.createOneUser },
});
};
const [mutation, { data, loading, error }] = useMutation<
MutationResult,
MutationVariables
>(MUTATION, {
update: handleCache, // update関数をセット
});
この例では、cache.writeQuery
を使用してクエリのキャッシュを上書きし、Query.getUser
のvariables.userId
がMutation.createOneUser
のvariables
に渡したuserId
であった場合のレスポンスとしてMutation.createOneUser
が返すUser
を設定するようにキャッシュを更新します。
この更新によって、初回はnull
を返したQuery.getUser
が、Mutation.createOneUser
によって新たに発生した値を同期的に取得したように振る舞わせることができます。
その他さまざまなケースでキャッシュのコントロールの必要は出てくる
上記はあくまでも代表的な一例であり、実際には多彩な要因でキャッシュのコントロールの必要が生じます。値が想定通りに振る舞わないことを解消する目的もあれば、動作に支障はなくてもパフォーマンスを高めるためであったりします。
特にSaaS等のWebアプリケーションであればデータフェッチの最適化の重要度はとても高いと思いますが、基本を理解しておけばそんなに難しいことはないと思いますし、改善のアイディアも湧きやすいかもしれませんね。
適切なキャッシュ管理でユーザーにもシステムにも優しく
積極的にキャッシュを利用することで、ユーザーには快適な動作を、システムには負荷の抑制を提供することができます。DBの情報を常にリアルタイムかつ低いレイテンシで扱えるのならば不要な工夫と言えますが、それが実現可能なのはローカル開発機の中だけか、さもなくばあなたが未来人かでしょう。
もっといい方法があるぜ!とか、わたしはこうしてるよ!とかがあれば是非教えてくださいね。
Discussion