こんにちは。テクノロジー本部バックエンド開発グループの江良です。 この記事は CADDi Advent Calendar 19 日目の記事です。昨日は、狭間さんによる「GraphQL PaginationのNestJSでの実装」でした!
「バックエンド開発グループの〜」と自己紹介したばかりで恐縮なのですが、今日はフロントエンドの話をします。
はじめに
これはなに
- Apollo Client の 3.0 で追加されたキャッシュ周りの新機能を試してみた記事です
offsetLimitPagination
とrelayStylePagination
について触れています- 実際に手元で動かせるコードを使って、ステップ・バイ・ステップで説明します
能書きはいいからコードを見せてくれ、という人はこちらをご覧ください。 gushernobindsme/apollo-client-v3-practice
まえがき
私が所属する原価計算システムの開発チームでは、
- バックエンド
- BFF
- フロントエンド
という構成でシステムを提供しています。
バックエンド・BFF 間は gRPC、BFF・フロントエンド間は GraphQL で通信しています。 フロントエンドから BFF の GraphQL サーバにアクセスする際に使用しているのが、今回お話する Apollo Client というライブラリです。
弊チームでは、現在 Apollo Client のバージョン 2.6.9
を使用しているのですが、3.0
以降で登場したキャッシュ周りの機能がなかなか便利そうだったので、今後のバージョンアップに備えて試してみたことをまとめてみます。
使用したライブラリのバージョン
検証には以下のバージョンを使用しました。
- @apollo/client: 3.3.4
- graphql: 15.4.0
Apollo Client について
Apollo Client のキャッシュとは
Apollo Client は、GraphQL クエリの結果をインメモリのキャッシュに保存します。
クエリの結果は正規化して保存され、 InMemoryCache
というクラスから簡単に操作できます。
InMemoryCache
は 公式ガイド にも記載の通り、簡単に使い始められます。
import { InMemoryCache, ApolloClient } from '@apollo/client'; const client = new ApolloClient({ // ...other arguments... cache: new InMemoryCache(options) });
保存されたキャッシュには InMemoryCache
の以下のメソッドを使うことでアクセスできます。
- readQuery
- readFragment
- writeQuery
- writeFragment
また、Apollo Client 3.0 からはキャッシュ内の個々のフィールドを更新するために modify
というメソッドが追加されています。「mutation を実行した後、その結果をキャッシュに書き戻したい」といったユースケースで便利です。
詳細は 公式ガイド のほか、弊社フロントエンドエンジニアの桐生さんの記事にも詳しく書かれていますので、気になる方は読んでみてください。
Apollo Client 3.0 の新機能
Apollo Client 3.0 ではいくつもの新機能が追加されています。
詳細は Apollo の公式ブログ と マイグレーションガイド に譲りますが、その中でも特にパワフルなのが Pagination helpers
の追加です。
これは文字通りページネーションの実装を助ける便利なヘルパ機能になります。
ページネーションの設計
さて、ここでちょっと脱線してページネーションを実現する API の設計方針について考えてみましょう。 ページネーションの設計は数あれど、大まかなパターンとしては以下の二種類に整理できるかと思います。
- オフセットベース(Offset-based pagination)
- カーソルベース(Cursor-based pagination)
オフセットベース
オフセットベースはいわゆる offset
と limit
を使ってページングを行うやり方です。
offset にデータの取得開始位置を指定し、 limit に取得するデータ件数を指定します(SQL を書いたことのある人には馴染みのあるアレですね)。
例えばこんな風に指定すると、
SELECT * FROM transactions LIMIT 10 OFFSET 20;
先頭の 20 行目から 10 件のデータを取得してください、という意味になります。
カーソルベース
カーソルベースはデータの取得を開始する位置をインデックスではなく、トークンで指定するやり方です。
この方式では first
にデータ件数、 after
にデータの取得開始位置を表す base64 エンコードされたカーソルを指定します。
{ user { id name friends(first: 10, after: "opaqueCursor") { edges { cursor node { id name } } pageInfo { hasNextPage } } } }
この方式は GraphQL のサイトにて ベストプラクティス として紹介されているほか、GraphQL クライアントの Relay でも紹介されています。
閑話休題
さて、話を Apollo Client に戻します。
Apollo Client 3.0 では、上述した二種類の API のページング処理をいい感じにしてくれる便利な機能を提供しています。 Pagination helpers は InMemoryCache に対するオプションとして設定できます。
先ほど紹介した InMemoryCache
のインスタンスを生成するコードを思い出してみましょう。
公式ガイド によると、ここに offsetLimitPagination
を指定するとオフセットベースの API のページング処理がいい感じになります。
const cache = new InMemoryCache({ typePolicies: { Query: { fields: { comments: offsetLimitPagination(), }, }, }, });
また公式ガイドの このページ によれば、ここに relayStylePagination
を指定するとカーソルベースの API のページング処理もいい感じになるそうです。
const cache = new InMemoryCache({ typePolicies: { Query: { fields: { comments: relayStylePagination(), }, }, }, });
本当に、そんなうまい話があるのでしょうか?
サンプルコードで学ぶ Apollo Client 3.0
導入
ということで、早速コードを書いて検証してみます。 やってみたことは以下の通りです。
- オフセットベースのレスポンスを返す GraphQL のエンドポイントを実装する
- カーソルベースのレスポンスを返す GraphQL のエンドポイントを実装する
- Apollo Client 3.0 を組み込んだフロントエンドを実装し、Pagination helpers を設定する
ここでは、ページングの動作を検証するためのシンプルな CRUD アプリケーションを実装してみます。
概要
ということで完成したのがこちらのリポジトリです。 gushernobindsme/apollo-client-v3-practice
- backend ディレクトリに NestJS 製の Graph サーバを実装
- frontend ディレクトリに React 製のフロントエンドを実装
という構成になっています。 ページネーション以外の話題については、本記事では省略します。NestJS を使ったバックエンドの実装については、前日の狭間さんの記事に詳しく書いてありますので、是非読んでみてください!
Offset-based なページネーションを実装する
まず、オフセットベースの GraphQL の定義を用意します。 (バックエンド側の実装については割愛します。)
type Query { sharks(offset: Int, limit: Int): [Shark] } type Shark { id: Int originalTitle: String japaneseTitle: String rate: Int }
次に、 offsetLimitPagination
を設定した InMemoryCache
を用意します。
const cache = new InMemoryCache({ typePolicies: { Query: { fields: { sharks: offsetLimitPagination(), } } } }) const client = new ApolloClient({ // other settings cache });
GraphQL のドキュメント定義を用意して、
const GET_SHARKS = gql` query getSharks($offset: Int, $limit: Int) { sharks(offset: $offset, limit: $limit) { id originalTitle japaneseTitle rate } } `;
戻り値の型を用意して、
interface SharksModel { sharks: Shark[]; }
useQuery
の hooks を実装します。
const { loading, error, data, fetchMore } = useQuery<SharksModel>( GET_SHARKS, { variables: { offset: 0, limit: 10 }, }, );
最後に hooks を呼び出す component を実装して完成です。
// ... 略 <table> // ... 略 <tbody> {data && data.sharks.map((shark) => { return ( <tr key={shark.id}> <th>{shark.id}</th> <td>{shark.originalTitle}</td> <td>{shark.japaneseTitle}</td> <td> {shark.id && ( <Ratings id={shark.id} rate={shark.rate || 0} mutation={updateShark} /> )} </td> </tr> ); })} </tbody> </table> // ... 略
次の 10 件をフェッチするためのボタンも設置します。
次のデータの取得は fetchMore
メソッドを呼ぶことで簡単に実装できます。
Core pagination API - Client (React) - Apollo GraphQL Docs
<Button onClick={async () => { await fetchMore({ variables: { offset: data?.sharks.length, }, }); }} > fetch more </Button>
Cursor-based なページネーションを実装する
次にカーソルベースの GraphQL の定義を用意します。
お作法にしたがって connection
に edges
と pageInfo
を、 edges
に node
を定義してみます。
type Query { sharks(first: Int!, after: String): SharkConnection } type SharkConnection { edges: [SharkEdge] pageInfo: PageInfo } type SharkEdge { node: Shark cursor: String } type PageInfo { endCursor: String hasNextPage: Boolean }
次に、 relayStylePagination
を設定した InMemoryCache を用意します。
const cache = new InMemoryCache({ typePolicies: { Query: { fields: { sharks: relayStylePagination(), } } } }) const client = new ApolloClient({ // other settings cache });
GraphQL のドキュメント定義を用意して、
export const GET_SHARKS = gql` query getSharks($cursor: String) { sharks(first: 10, after: $cursor) { edges { cursor node { id originalTitle japaneseTitle rate } } pageInfo { endCursor hasNextPage } } } `;
戻り値の型を用意して、
interface SharksModel { sharks: SharkConnection; }
useQuery の hooks を実装します。
const { loading, error, data, fetchMore } = useQuery<SharksModel>( GET_SHARKS, { variables: { cursor: '' }, }, );
最後に hooks を呼び出す component を実装して完成です。
// ... 略 <table> // ... 略 <tbody> {data && data.sharks && data.sharks.edges && data.sharks.edges.map((shark) => { const node = shark.node; return ( <tr key={node?.id}> <th>{node?.id}</th> <td>{node?.originalTitle}</td> <td>{node?.japaneseTitle}</td> <td> {node?.id && ( <Ratings id={node.id} rate={node.rate || 0} mutation={updateShark} /> )} </td> </tr> ); })} </tbody> </table> // ... 略
次の 10 件をフェッチするためのボタンはこんな感じです。
{data && data.sharks.pageInfo?.hasNextPage && ( <Button onClick={async () => { await fetchMore({ variables: { cursor: data?.sharks?.pageInfo?.endCursor, }, }); }} > fetch more </Button> )}
動作確認
実装が一通り書けたのでさっそく動かしてみましょう。
「fetch more」ボタンを押すと次の 10 件が表示されます。
さっそくデータを追加してみましょう。
追加できました。
☆アイコンを押して評価をつけることもできます。 ( フランケンジョーズ は CG が本当にひどいので☆ 1 つです。)
こちらも無事更新できました。
(ちょっとわかりにくいのですが)実際にうまくキャッシュが動作している様子は、先ほどご紹介したリポジトリをクローンして起動することでも検証できます。是非お手元で動かしてみてください。
おわりに
ということで Apollo Client 3.0 で追加された新機能 Pagination helpers のご紹介でした。
明日は、寺田さんによる「RustでRAMの動作原理をシミュレートする」です。お楽しみに!