⛹🏻

【urql】urqlのRequestPolicyでハマったのでRequestPolicyの挙動確認をしてみた

2023/03/07に公開

こんにちは!
スペースマーケットでフロントエンドエンジニアをしている原口です。

先日テストを書いていたところ、urqlのキャッシュにより少しハマったためurqlのRequestPolicyについて調べてみました。

はじめに

対象読者

  • urqlのRequestPolicyの挙動を知りたい方

この記事で書かないこと

  • urqlについての詳しい解説

また前段が長くなってしまったため、RequestPolicyの挙動だけ知りたいよ、という方は以下のセクションからお読みください🙏
ようやく本題

テストを書いてたらハマった

urqlを使用し、データを取得する関数(fetchRecommendRoomList)を作り、その関数のテストを書いていました。
関数とテストは以下のようなものです。

fetchRecommendRoomList.ts
export const fetchRecommendRoomList = async (
  client: Client,
  variables: Variables,
) => {
  const { data } = await client
    .query<FeaturePageRecommendQuery, FeaturePageRecommendQueryVariables>(
      FeaturePageRecommendDocument,
      {
        // variables
        uid: variables.uid,
        ...},
    )
    .toPromise()

  return data
}
fetchRecommendRoomList.test.tsx
import { setupServer } from 'msw/node'

import { mockFeaturePageRecommendQuery } from '../../../index.generated'

import { createUrqlClient } from '@/src/graphql/withUrql'

import { fetchRecommendRoomList } from '../fetchRecommendRoomList'

const server = setupServer()
beforeAll(() => server.listen())
afterEach(() => {
  server.resetHandlers()
})
afterAll(() => server.close())

// lengthに追加した値の分だけmapを回しリストの配列を作成する関数
const generateDummyRoomList = (
  length: number,
  startIndex?: number,
): { id: number; isReservationAvailable: boolean }[] =>
  Array(length)
    .fill(0)
    .map((_, index) => ({
      id: (startIndex ?? 0) + index,
      isReservationAvailable: true,
    }))
// 以下テスト
describe('fetchRecommendRoomList', () => {
  describe('getServerSidePropsのテスト', () => {
    describe('データ取得のテスト', () => {
      beforeEach(() => {
        server.use(
          mockFeaturePageRecommendQuery((req, res, ctx) =>
            res(
              ctx.data({
                recommendFeatureDetail: {
                  uid: 'recommend',
                  rooms: {
                    results: Array(6)
                      .fill(0)
                      .map((_, index) => ({
                        id: index,
                        isReservationAvailable: true,
                      })),
                  },
                },
              }),
            ),
          ),
        )
      })
      describe('レコメンドのテスト', () => {
        test('テスト1: エラーがなければ正常にデータが取得できること', async () => {
          const recommendData = await fetchRecommendRoomList(
            mockClient,
            variables,
          )
          expect(recommendData).toEqual({
            recommendFeatureDetail: {
              rooms: {
                results: generateDummyRoomList(6),
              },
            },
          })
        })
        test('テスト2(今回ハマったテスト): レコメンドのスペース数が3件の場合、レコメンドのスペース数が件数分返ってくること', async () => {
          server.use(
            mockFeaturePageRecommendQuery((req, res, ctx) =>
              res(
                ctx.data({
                  recommendFeatureDetail: {
                    uid: 'recommend',
                    rooms: {
                      results: generateDummyRoomList(3),
                    },
                  },
                }),
              ),
            ),
          )
          const recommendData = await fetchRecommendRoomList(
            mockClient,
            variables
          )
          expect(recommendData).toEqual({
            recommendFeatureDetail: {
              rooms: {
                results: generateDummyRoomList(3),
              },
            },
          })
        })
      })
    })
  })
})

テストでやっていたこととしては以下になります。

  • mswを使用し、GraphQL Code Generatorで生成したmockFeaturePageRecommendQueryの値を返す
  • 上記の処理をbeforeEachでテスト毎に実行
  • afterEachでテストの終了時にserver.resetHandlers()を実行しハンドラーを削除
  • テスト2以外のテストでは、mockFeaturePageRecommendQueryの上書きはしない

自分の想定では、テスト2の中で指定しているmockFeaturePageRecommendQueryがリストを3件返してくれると思っていましたが、実際に返ってくるリストの数は6件でした。

原因を探る

原因を探るために以下を試してみました。

  1. 該当のテストのみを実行する
  2. server.resetHandlers()が動いているか確認
  3. server.resetHandlers()をテスト内で実行する

1. 該当のテストのみを実行する

まずは以下のようにtest.onlyを使用して、該当のテストのみを実行をしてみました。
test.only

fetchRecommendRoomList.test.tsx
test.only('テスト2(今回ハマったテスト): レコメンドのスペース数が3件の場合、レコメンドのスペース数が件数分返ってくること', async () => {
  server.use(
    mockFeaturePageRecommendQuery((req, res, ctx) =>
      res(
        ctx.data({
          recommendFeatureDetail: {
            uid: 'recommend',
            rooms: {
              results: generateDummyRoomList(3),
            },
          },
        }),
      ),
    ),
  )
  const recommendData = await fetchRecommendRoomList(
    mockClient,
    variables
  )
  expect(recommendData).toEqual({
    recommendFeatureDetail: {
      rooms: {
        results: generateDummyRoomList(3),
      },
    },
  })
})

こちらでは期待通りテストが成功しました。

2. server.resetHandlers()が動いているか確認

次にserver.resetHandlers()が実行されているかを確認するために、非常に単純ではありますが、console.log()を仕込んで実行をしてみました。

const server = setupServer();
beforeAll(() => server.listen());
afterEach(() => {
  console.log("server.resetHandlers() start");
  server.resetHandlers();
  console.log("server.resetHandlers() end");
});
afterAll(() => server.close());

仕込んだ2つのログが出力されているため、server.resetHandlers()が呼ばれていないということはなさそうです。

3. server.resetHandlers()をテスト内で実行する

次にテスト2の中でもserver.resetHandlers()を実行するようにしてみました。

fetchRecommendRoomList.test.tsx
test.only('テスト2(今回ハマったテスト): レコメンドのスペース数が3件の場合、レコメンドのスペース数が件数分返ってくること', async () => {
  // ここでserver.resetHandlers()を呼んでみる
  server.resetHandlers()
  server.use(
    mockFeaturePageRecommendQuery((req, res, ctx) =>
      res(
        ctx.data({
          recommendFeatureDetail: {
            uid: 'recommend',
            rooms: {
              results: generateDummyRoomList(3),
            },
          },
        }),
      ),
    ),
  )
  const recommendData = await fetchRecommendRoomList(
    mockClient,
    variables
  )
  expect(recommendData).toEqual({
    recommendFeatureDetail: {
      rooms: {
        results: generateDummyRoomList(3),
      },
    },
  })
})

が、これでもエラーになってしまいます。

原因

自分ではこれ以上原因の特定が難しそうだったため、上長に相談してみたところ、「キャッシュが原因ではないか?」との答えが返ってきました。

urqlのキャッシュについて調べるために公式のドキュメントを読んでみたところ、冒頭にこう書かれていました。

By default, urql uses a concept called Document Caching. It will avoid sending the same requests to a GraphQL API repeatedly by caching the result of each query.

訳: デフォルトでは、urqlはDocument Cachingと呼ばれる概念を使用します。これは、各クエリの結果をキャッシュすることで、同じリクエストを繰り返しGraphQL APIに送信することを避けるものです。

また別のページでは、以下のように書かれています。

In urql, these GraphQL requests are treated as unique objects, which are uniquely identified by the query document and variables (which is why a key is generated from the two). This key is a hash number of the query document and variables and uniquely identifies our GraphQLRequest.

訳: urqlでは、これらのGraphQLリクエストはユニークなオブジェクトとして扱われ、クエリドキュメントと変数によって一意に識別されます(そのため、この2つからキーが生成されます)。このキーはクエリドキュメントと変数のハッシュ番号であり、私たちのGraphQLRequestを一意に識別するものです。

urqlはqueryとvariablesによってハッシュを作り、レスポンスと共にキャッシュされ、同じqueryとvariablesでリクエストを送られるとurqlはキャッシュされたデータを返すということでした。

hash(
  stringify(query) +
  stableStringify(variables)
)

今回ハマった原因はまさにこれで、冒頭に記載しているテストでは下記のことが起こっていました。

  1. beforeEachでリストが6件が返される
  2. テスト1でレスポンスがキャッシュされる(リストが6件返ってくるデータ)
  3. テスト2の中でリストを3件返すように指定はしているが、queryとvariablesが同じためキャッシュされたレスポンスが返される
  4. 結果テスト2ではリストが6件返されるため、テストが落ちてしまう

原因の切り分け > 1. 該当のテストのみを実行するで行ったtest.onlyするとテストが成功したのも、テスト1が実行されていないためにキャッシュが残らないので、テスト2の中で上書きしたmockFeaturePageRecommendQueryからの値(リスト3件)が返ってきていたと説明がつき納得できます。

どのように解決したか

この問題を解決するための解決策は以下の2つになります。

  1. variablesの値を変更する
  2. Request Policyを指定して、毎回APIにリクエストを送りキャッシュを返さないようにする(本記事の本題です。後述します。)

今回はキャッシュを返したくないテストのvariablesを変更することで対応しました。

fetchRecommendRoomList.test.tsx
test('テスト2(今回ハマったテスト): レコメンドのスペース数が3件の場合、レコメンドのスペース数が件数分返ってくること', async () => {
  server.resetHandlers()
  server.use(
    mockFeaturePageRecommendQuery((req, res, ctx) =>
      res(
        ctx.data({
          recommendFeatureDetail: {
            uid: 'recommend',
            rooms: {
              results: generateDummyRoomList(3),
            },
          },
        }),
      ),
    ),
  )
  const recommendData = await fetchRecommendRoomList(
    mockClient,
    {
      ...variables,
      // variablesに含まれるuidの値を変更
      uid: 'hoge',
    },
  )
  expect(recommendData).toEqual({
    recommendFeatureDetail: {
      ...expectSuccessReturnValue,
      rooms: {
        results: generateDummyRoomList(3),
      },
    },
  })
})

ようやく本題

テストが無事通り問題は解決しましたが、解決策2のRequest Policyについて何もわからなかったので実際の挙動も試してみました。
Request Policies

urqlのRequest Policyは4つあり、それぞれ以下の挙動をします。
以下は公式ドキュメントを翻訳したものを表にしたものです。

policy 内容
cache-first
(デフォルト)
cache-firstはキャッシュされた結果を優先し、それ以前の結果がキャッシュされていない場合は API リクエストの送信にフォールバックします。
cache-and-network cache-and-networkはキャッシュされた結果を返しますが、常にAPIリクエストを送信します。これは、データを最新に保ちながら素早く表示するのに適しています。
network-only network-only は、常に API リクエストを送信し、キャッシュされた結果は無視します。
cache-only cache-onlyは、常にキャッシュされた結果かnullを返します。

今回Request Policyを設定していなかったため、デフォルトのcache-firstが指定されていました。
そのためにキャッシュされたレスポンスが返ってきていたということです。

では他のpolicyではどのような挙動になるのでしょうか。
Request Policyを指定するには、queryメソッドのオプションにrequestPolicyを付与することで指定ができます。

const { data } = await client
  .query<FeaturePageRecommendQuery, FeaturePageRecommendQueryVariables>(
    FeaturePageRecommendDocument,
    {
      // variables
    },
    {
      requestPolicy: "cache-and-network",
    }
  )
  .toPromise();

確認方法

簡単なページを用意し、ローカルサーバーにレスポンスを投げて各policyを挙動確認を行います。
以下の流れで確認していきます。

  1. クライアントとローカルサーバーを立てる
  2. データを取得するクエリにconsole.logを仕込む
  3. クライアントから各policyを指定した状態で、サーバー側にリクエストを送る
  4. 2で仕込んだconsole.logが発火するかを確認する

1. クライアントとローカルサーバーを立てる

クライアント側のページは以下を用意しました。
https://github.com/wh2626/urql-request-policy

サーバーはApollo Federation Demoを使用しました。
https://github.com/apollographql/federation-demo

Apollo Federation Demoは、READMEに書いてあるとおりの手順を行えば、すぐにローカルサーバーを立てることができます。

クライアント側も、yarn installをしてからyarn devを叩けばページが開かれます。

(めちゃくちゃシンプルなページ)

2. データを取得するクエリにconsole.logを仕込む

Apollo Federation Demoのサンプルのqueryの中に、topProductsというqueryがあり、variablesによって引いてくるデータの数を変えられるため、今回はこのqueryで検証します。
services/products/index.jsの中にある変数resolverの中に以下のような、ログを仕込みました。

const resolvers = {
  Product: {
    __resolveReference(object) {
      return products.find((product) => product.upc === object.upc);
    },
  },
  Query: {
    topProducts(_, args) {
      // ここにconsol.logを仕込む
      console.log("call products");
      console.log(args);
      return products.slice(0, args.first);
    },
  },
};

これでリクエストがあった場合に、console.logが発火します。

3. クライアントから各policyを指定した状態で、サーバー側にリクエストを送る

次にpolicyの指定を行います。
クライアント側のsrc/pages/index.tsxgetServerSideProps内でデータの取得を行っているので、ここでvariablesやrequestPolicyを指定します。

export const getServerSideProps = async () => {
  const { data } = await client
    .query(
      ExampleQueryDocument,
      {
        // variables
      },
      {
        // requestPolicy
      }
    )
    .toPromise();
  return {
    props: { data },
  };
};

何も指定をしない状態だと上に貼ったように3件の家具の名前が表示されていましたが、取得するデータを数を決める値(first)に1を指定すると、このように1件のデータが返ってきます。

export const getServerSideProps = async () => {
  const { data } = await client
    .query(
      ExampleQueryDocument,
      {
        // variables
        // 取得するデータの数を指定
        first: 1,
      },
      {
        // requestPolicy
      }
    )
    .toPromise();
  return {
    props: { data },
  };
};

4. 2で仕込んだconsole.logが発火するかを確認する

サーバーを立てたターミナルを見てみると、以下のようにログが出力されているのを確認できました。

[start-service-*products] call products
[start-service-*products] { first: 5 } // firstの初期値は5

確認していく

ここまでくればあとは順番に確認していくだけです。
以下の手順で出力されるログを確認していきます。

  1. policyを指定した状態で、ページにアクセスをする
  2. ブラウザをリロードし、ページを再度表示させる
  3. variablesを変更し、ページにアクセスをする

cache-first

cache-firstはキャッシュされた結果を優先し、それ以前の結果がキャッシュされていない場合は API リクエストの送信にフォールバックします。
デフォルト値のため、requestPolicyを何も指定していない場合は、cache-firstが適用されます。

const { data } = await client
  .query(
    ExampleQueryDocument,
    {
      // variables
    },
    {
      // requestPolicy
    }
  )
  .toPromise();

初回ページアクセス時 ログ

[start-service-*products] call products
[start-service-*products] { first: 5 }

ページリロード時 ログ

なし(キャッシュが返ってくるため)

variablesの変更

const { data } = await client
  .query(
    ExampleQueryDocument,
    {
      // variables
      first: 1,
    },
    {
      // requestPolicy
    }
  )
  .toPromise();

variables変更後 ログ

[start-service-*products] call products
[start-service-*products] { first: 1 }

cache-and-network

cache-and-networkはキャッシュされた結果を返しますが、常にAPIリクエストを送信します。これは、データを最新に保ちながら素早く表示するのに適しています。

const { data } = await client
  .query(
    ExampleQueryDocument,
    {
      // variables
    },
    {
      // requestPolicy
      requestPolicy: "cache-and-network",
    }
  )
  .toPromise();

初回ページアクセス時 ログ

[start-service-*products] call products
[start-service-*products] { first: 5 }

ページリロード時 ログ

// cache-and-networkは常にAPIにリクエストを送信するため、ログが出力される
[start-service-*products] call products
[start-service-*products] { first: 5 }

variablesの変更

const { data } = await client
  .query(
    ExampleQueryDocument,
    {
      // variables
      first: 1,
    },
    {
      // requestPolicy
      requestPolicy: "cache-and-network",
    }
  )
  .toPromise();

variables変更後 ログ

[start-service-*products] call products
[start-service-*products] { first: 1 }

network-only

network-onlyは、常にAPIリクエストを送信し、キャッシュされた結果は無視します。

export const getServerSideProps = async () => {
  const { data } = await client
    .query(
      ExampleQueryDocument,
      {
        // variables
      },
      {
        // requestPolicy
        requestPolicy: "network-only",
      }
    )
    .toPromise();
  return {
    props: { data },
  };
};

初回ページアクセス時 ログ

[start-service-*products] call products
[start-service-*products] { first: 5 }

ページリロード時 ログ

[start-service-*products] call products
[start-service-*products] { first: 5 }

variablesの変更

export const getServerSideProps = async () => {
  const { data } = await client
    .query(
      ExampleQueryDocument,
      {
        // variables
        first: 3,
      },
      {
        // requestPolicy
        requestPolicy: "network-only",
      }
    )
    .toPromise();
  return {
    props: { data },
  };
};

variables変更後 ログ

[start-service-*products] call products
[start-service-*products] { first: 3 }

cache-only

cache-onlyは、常にキャッシュされた結果かnullを返します。

export const getServerSideProps = async () => {
  const { data } = await client
    .query(
      ExampleQueryDocument,
      {
        // variables
      },
      {
        // requestPolicy
        requestPolicy: "cache-only",
      }
    )
    .toPromise();
  return {
    props: {
      data: data ?? {}, // dataが返ってこないため、データがない場合は{}を返すように設定
    },
  };
};

cache-onlyについては、予めキャッシュを残しておく手段が思いつかなかったため設定方法のみとさせていただきます。
(再現方法が分かり次第追記いたします)

以上となります。

まとめ

  • urqlはDocument Cachingと呼ばれる仕組みを持っており、各クエリの結果をキャッシュすることで、同じリクエストを送らないようにしている
  • queryとvariablesを組み合わせてハッシュを作り、レスポンスと共にキャッシュ化している
  • requestPolicyを使用することで、キャッシュをコントロールすることができる

最後に

スペースマーケットでは以下の職種を絶賛募集中です!
https://www.wantedly.com/projects/1113570
https://www.wantedly.com/projects/1113544
https://www.wantedly.com/projects/1061116

ちょっと話を聞いてみたいといったようなカジュアルな面談でも構いませんので、ご興味のある方は是非ご応募お待ちしております!

その他採用についての情報はこちらをご覧ください!
https://spacemarket.co.jp/recruit/engineer/

採用技術スタックについてはこちらをご覧ください!
https://www.whatweuse.dev/company/spacemarket

GitHubで編集を提案
スペースマーケット Engineer Blog

Discussion