207 Tech Blog

テクノロジーで物流を変える 207 (ニーマルナナ) 株式会社のテックブログ

Apollo ClientでComputed fieldsを使う!Local-only fieldsの活用術

この記事は 207 Advent Calendar 2022の記事です。

qiita.com

207株式会社でソフトウェアエンジニアをしている id:ryo-rm です。 207ではiOS / Android向けのアプリケーション "TODOCUサポーター" を提供しており、GraphQL / Apollo Clientを活用しています。
Apollo Clientにはスマートなキャッシュ管理機構がありますが、 "TODOCUサポーター" では Local-only fields を活用しているので、こちらについて紹介します。

tech.207-inc.com

環境

"TODOCUサポーター" は React Native製ですが、Apollo Clientは Reactと共通の @apollo/client を使っています。
apollo3-cache-persist でキャッシュを永続化しており、永続化したデータの保存先は react-native-mmkv を使っています。*1

  • @apollo/client : ^3.5.5
  • apollo3-cache-persist : ^0.14.1
  • @graphql-codegen/cli : ^2.3.0
  • @graphql-codegen/near-operation-file-preset : ^2.2.2
  • @graphql-codegen/typescript : ^2.4.1
  • @graphql-codegen/typescript-operations : ^2.2.1
  • @graphql-codegen/typescript-react-apollo : ^3.2.2
  • react-native-mmkv : ^2.4.3

Computed Fieldとして使う

バックエンドのレスポンスを加工して表示用に整形する、というのはよくあると思います。

"TODOCUサポーター" では、カラーコードに変換する処理などで使っています*2

当アプリは配送員の方向けのアプリケーションであり、荷物それぞれに時間指定の概念が存在します。時間指定は 午前中/12時~14時/14時~16時などの配達時間帯がありますが、この時間は各社バラバラになっているので、 "時間指定枠" という概念を用意し、個別に変更できるようにしています。
データ構造としてはバックエンドに指定枠の番号 (1~9) が保存されており、フロントエンドで指定枠の番号に対して固定のカラーコードを発行する処理になっています。

時間指定枠の設定。各指定枠に色が決まっている

データ構造を簡単に表わしてみると以下のようになっています。*3

type Product = {
  id: string;

  # 時間指定枠
  timeframeIndex: number;
};

今回は、 timeframeIndex からComputed field のカラーコード timeframeColor を作るのを書いてみます。

ドキュメントの定義

GraphQL Code Generator を使っている場合、 local-schema.graphql などのファイルを用意してドキュメントを生成することで既存の型を拡張することができます。

extend type Product {
  """
  時間指定枠の色
  timeframeIndex とセットで利用する必要がある
  ローカル変数なので、 キャッシュを使わず取得した場合は存在しない
  """
  timeframeColor: String
}

ドキュメントに記載していますが、計算対象に使っている値がキャッシュに存在しない場合は計算できません。
すなわち、上記の timeframeColor を取得するためには timeframeIndex とセットでQueryに書いて取得しキャッシュに書き込まれる必要があります。
キャッシュを使っているので、Apollo Clientの FetchPoliciesnetwork-only を使った場合は取得できません。

TypePolicyの定義

Apollo CacheのTypePolicyを事前に定義しておくことで、フィールドの値から計算した結果を返却することができます。

import type { TypePolicies, FieldPolicy } from '@apollo/client';
import { ApolloClient, InMemoryCache } from '@apollo/client';

export const typePolicies: TypePolicies = {
  Product: {
    fields: {
      timeframeColor: {
        read(exists, { readField }) {
          const timeframeIndex = readField<number>('timeframeIndex');

          // ここで計算する
          switch (timeframeIndex) {
            case 1:
              return '#F06292';
            default:
              return '#9E9E9E';
          }
        },
      } as FieldPolicy<string>,
    },
  },
};

const cache = new InMemoryCache({
  typePolicies,
});

const client = new ApolloClient({
  cache,
});

readFieldProduct.timeframeIndex を参照しています。キャッシュが存在しない場合もあるので、そのケースについても考慮するようにしています。

クエリ

ここまで来たら、 @client をつけて取得するだけです。
ドキュメントの定義 で書いたように、計算元の値がキャッシュに存在する必要があります。

query {
  product(id: "1") {
    id
    timeframeIndex
    timeframeColor @client
  }
}

テストコード

jestでテストコードを書く場合は以下のように書くことができます!

import { gql, InMemoryCache } from '@apollo/client';

import {
  typePolicies,
  DEFAULT_COLOR_VALUE,
} from '~/src/api/apollo/typePolicies/product';

describe('localVariable/product', () => {
  describe('timeframeColor', () => {
    test('時間指定枠 1 からカラーコードが返却される', () => {
      const cache = new InMemoryCache({
        typePolicies,
      });
      const colorQuery = gql`
        query {
          product(id: "1") {
            timeframeIndex
            timeframeColor
          }
        }
      `;
      cache.writeQuery({
        query: colorQuery,
        data: {
          product: {
            __typename: 'Product',
            timeframeIndex: 1,
          },
        },
      });
      const actual = cache.readQuery<{
        product: {
          timeframeColor: string;
        };
      }>({ query: colorQuery });
      expect(actual?.product?.timeframeColor).toEqual('#FFF');
    });
    test('時間指定枠の番号がキャッシュに存在しないとき、デフォルト値が返却される', () => {
      const cache = new InMemoryCache({
        typePolicies,
      });
      const colorQuery = gql`
        query {
          product(id: "1") {
            timeframeColor
          }
        }
      `;
      cache.writeQuery({
        query: colorQuery,
        data: {
          product: {
            __typename: 'Product',
            // timeframeIndex: 9, // キャッシュに値が存在しない
          },
        },
      });
      const actual = cache.readQuery<{
        product: {
          timeframeColor: string;
        };
      }>({ query: colorQuery });
      expect(actual?.product?.timeframeColor).toEqual(DEFAULT_COLOR_VALUE);
    });
  });
});

P.S.

この記事は、 207 Advent Calendar 2022の記事でした。

qiita.com

記事の都合で今回は書けませんでしたが、Reactive variables を利用していたり、永続化したキャッシュを使ってアプリ立ち上げ時の初期表示高速化& cache-and-network で最新化するなど、Apolloキャッシュを活用しています。

Apollo Clientのキャッシュ機構は複雑で難しいですが、使いこなすことができたら強力です!

この記事はReact Nativeでの利用について書いていますが、React Native特有の機能は利用していないため、Webアプリケーションでも同じように活用できると思います。

最後にいつもの宣伝で、

207株式会社では、レガシーな物流業界の変革に挑む配達員向け効率化アプリ「TODOCUサポーター」を開発しています。 GraphQLでガンガン開発していきたいメンバーを大絶賛募集中です!

もし少しでもご興味がありましたら以下のnotionをご覧ください!

207-inc.super.site

*1:Webで言う、localStorageのようなもの

*2:配送員の現在地から配送先への距離にも使っています。配送員の現在地は Reactive variables に保存しています

*3:実際には時間指定枠で1つのデータモデルになっています