Apollo Client 3.0 ではじめる快適キャッシュ生活

こんにちは。テクノロジー本部バックエンド開発グループの江良です。
この記事は CADDi Advent Calendar 19 日目の記事です。昨日は、狭間さんによる「GraphQL PaginationのNestJSでの実装」でした!

「バックエンド開発グループの〜」と自己紹介したばかりで恐縮なのですが、今日はフロントエンドの話をします。

目次

はじめに

これはなに

  • Apollo Client の 3.0 で追加されたキャッシュ周りの新機能を試してみた記事です
  • offsetLimitPaginationrelayStylePagination について触れています
  • 実際に手元で動かせるコードを使って、ステップ・バイ・ステップで説明します

能書きはいいからコードを見せてくれ、という人はこちらをご覧ください。
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)

オフセットベース

オフセットベースはいわゆる offsetlimit を使ってページングを行うやり方です。
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 の定義を用意します。
お作法にしたがって connectionedgespageInfo を、 edgesnode を定義してみます。

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の動作原理をシミュレートする」です。お楽しみに!

Takumi Era
  • Takumi Era
  • 泥水すすってきたやつは大体友達