【urql】urqlのRequestPolicyでハマったのでRequestPolicyの挙動確認をしてみた
こんにちは!
スペースマーケットでフロントエンドエンジニアをしている原口です。
先日テストを書いていたところ、urqlのキャッシュにより少しハマったためurqlのRequestPolicyについて調べてみました。
はじめに
対象読者
- urqlのRequestPolicyの挙動を知りたい方
この記事で書かないこと
- urqlについての詳しい解説
また前段が長くなってしまったため、RequestPolicyの挙動だけ知りたいよ、という方は以下のセクションからお読みください🙏
ようやく本題
テストを書いてたらハマった
urqlを使用し、データを取得する関数(fetchRecommendRoomList
)を作り、その関数のテストを書いていました。
関数とテストは以下のようなものです。
export const fetchRecommendRoomList = async (
client: Client,
variables: Variables,
) => {
const { data } = await client
.query<FeaturePageRecommendQuery, FeaturePageRecommendQueryVariables>(
FeaturePageRecommendDocument,
{
// variables
uid: variables.uid,
...略
},
)
.toPromise()
return data
}
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件でした。
原因を探る
原因を探るために以下を試してみました。
- 該当のテストのみを実行する
-
server.resetHandlers()
が動いているか確認 -
server.resetHandlers()
をテスト内で実行する
1. 該当のテストのみを実行する
まずは以下のようにtest.only
を使用して、該当のテストのみを実行をしてみました。
test.only
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),
},
},
})
})
こちらでは期待通りテストが成功しました。
server.resetHandlers()
が動いているか確認
2. 次に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()
が呼ばれていないということはなさそうです。
server.resetHandlers()
をテスト内で実行する
3. 次にテスト2の中でもserver.resetHandlers()
を実行するようにしてみました。
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)
)
今回ハマった原因はまさにこれで、冒頭に記載しているテストでは下記のことが起こっていました。
-
beforeEach
でリストが6件が返される - テスト1でレスポンスがキャッシュされる(リストが6件返ってくるデータ)
- テスト2の中でリストを3件返すように指定はしているが、queryとvariablesが同じためキャッシュされたレスポンスが返される
- 結果テスト2ではリストが6件返されるため、テストが落ちてしまう
原因の切り分け > 1. 該当のテストのみを実行するで行ったtest.only
するとテストが成功したのも、テスト1が実行されていないためにキャッシュが残らないので、テスト2の中で上書きしたmockFeaturePageRecommendQuery
からの値(リスト3件)が返ってきていたと説明がつき納得できます。
どのように解決したか
この問題を解決するための解決策は以下の2つになります。
- variablesの値を変更する
-
Request Policy
を指定して、毎回APIにリクエストを送りキャッシュを返さないようにする(本記事の本題です。後述します。)
今回はキャッシュを返したくないテストのvariablesを変更することで対応しました。
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を挙動確認を行います。
以下の流れで確認していきます。
- クライアントとローカルサーバーを立てる
- データを取得するクエリに
console.log
を仕込む - クライアントから各policyを指定した状態で、サーバー側にリクエストを送る
- 2で仕込んだ
console.log
が発火するかを確認する
1. クライアントとローカルサーバーを立てる
クライアント側のページは以下を用意しました。
サーバーはApollo Federation Demoを使用しました。
Apollo Federation Demoは、READMEに書いてあるとおりの手順を行えば、すぐにローカルサーバーを立てることができます。
クライアント側も、yarn install
をしてからyarn dev
を叩けばページが開かれます。
(めちゃくちゃシンプルなページ)
console.log
を仕込む
2. データを取得するクエリに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.tsx
のgetServerSideProps
内でデータの取得を行っているので、ここで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 },
};
};
console.log
が発火するかを確認する
4. 2で仕込んだサーバーを立てたターミナルを見てみると、以下のようにログが出力されているのを確認できました。
[start-service-*products] call products
[start-service-*products] { first: 5 } // firstの初期値は5
確認していく
ここまでくればあとは順番に確認していくだけです。
以下の手順で出力されるログを確認していきます。
- policyを指定した状態で、ページにアクセスをする
- ブラウザをリロードし、ページを再度表示させる
- 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を使用することで、キャッシュをコントロールすることができる
最後に
スペースマーケットでは以下の職種を絶賛募集中です!
ちょっと話を聞いてみたいといったようなカジュアルな面談でも構いませんので、ご興味のある方は是非ご応募お待ちしております!
その他採用についての情報はこちらをご覧ください!
採用技術スタックについてはこちらをご覧ください!
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion