Tech Do | メディアドゥの技術ブログ 

株式会社メディアドゥのエンジニアによるブログです。

電子書籍流通システム「DB4」 のフロントエンドについて(Next.js、SWR) 〜SWR編〜

2022年6月1日に開催したエンジニア向け勉強会「Next.js(SWR・Web3認証)勉強会」。多くの方にご参加いただいたのですが、残念ながら都合が合わなかったり、後でイベントを知ったという方もいらっしゃるのではないでしょうか。

そうした方々向けに、テキスト起こし版を作成しました。スライド資料と合わせてご覧ください。

メディアドゥは、国内2,200社以上の出版社と150店以上の電子書店の間で電子書籍の流通事業を展開しており、国内流通シェアNo.1を誇る電子書籍流通システム「DB4」を自社で開発しています。今回はDB4のフロントエンドにおける使用技術やアーキテクチャ、実践しているテスト手法等をご紹介します。本記事では特に「DB4で利用しているSWR」に関して取り上げています。

登壇者紹介

二俣 雅紀

新卒入社2年目。主にDB4のフロントエンドの開発を担当。メディアドゥでバリューに沿った行動をとったエンジニアに贈られるBest Engineering Values賞を受賞。小旅行が好き。


SWRとは

SWRはReact Hooksライブラリの1つで、データ取得時の状態管理とキャッシュ管理を提供してくれます。Next.jsと同じVercel社が開発しています。

まずこのSWRの基になっているキャッシュ無効化戦略であるstale-while-revalidateについて説明します。stale-while-revalidateはRFC 5861で定義されているCache-Controlヘッダーの拡張になります。RFCではstale-while-revalidateとstale-if-errorという2つの拡張ディレクティブが定義されています。

stale-while-revalidateを使うことによって、キャッシュの更新中に古いキャッシュを返すことができるようになり、画面描画の速度を上げられます。ここで、stale-while-revalidateが拡張機能として定義された背景について簡単に紹介します。

このRFCの著者であるマーク・ノッティンガム氏が、その背景を含めた記事を書かれており、その要約が以下になります。

キャッシュのパフォーマンス改善の手段として、キャッシュが古くなる前に、新しいリソースをpre-fetchする方法がある。しかし、適切にpre-fetchのタイミングを制御しないと「キャッシュ・ネットワーク・バックエンドサーバー」のいずれか、もしくは複数に負担をかけすぎてしまう。 そこで、キャッシュを更新している間は、古いキャッシュの使用を許可すること(stale-while-revalidate戦略)によって、上記の課題を解決することができる。

(出典:mnot「Two HTTP Caching Extensions」, https://www.mnot.net/blog/2007/12/12/stale.html.brotli

このキャッシュパフォーマンスを改善する手段の内、pre-fetchする手法をHTTPヘッダーとして標準化してくれたのがstale-while-revalidateであるという認識です。

ブラウザキャッシュの例

次にstale-while-revalidateを利用した際のブラウザキャッシュの例について紹介します。

例えばブラウザが /hello というリソースに対してリクエストを送って、サーバーが Cache-Control: max-age=300, stale-while-revalidate=600 というヘッダーを付けてレスポンスを返したとします。

するとブラウザは、レスポンスを300秒の寿命を持ったキャッシュとして保存します。そのため、300秒以前の /hello へのリクエストに関してはキャッシュが使われます。キャッシュの寿命が切れてからstale-while-revalidateで指定した時間まで(今回の場合は初回のリクエストから300〜900秒の間)のリクエストに関しては、古いキャッシュが使われます。

ただ、その後にすぐ非同期でサーバーにリクエストを送って、キャッシュを更新します。そのため、この4度目の /hello へのリクエストに関しては、新しいキャッシュが使用されます。このようにstale-while-revalidateを使うことによって、キャッシュ使用時のレスポンスの速さを維持しながら、キャッシュの新鮮度を保てるようになります。

これらを踏まえて、コンポーネント内で useSWR が呼ばれた場合のSWRキャッシュの状態遷移を説明します。ただ、これから行う説明は理解しやすくするためのイメージなので、正確性に欠ける部分があるかも知れません。その点ご了承ください。

  1. コンポーネント内で、useSWRが呼ばれると、SWRキャッシュ内に第1引数をキーとするキャッシュが生成されます。

  2. 第2引数で渡したfetcher関数を使って値を取得します。fetcher関数がデータを取得している間、キャッシュの値はundefinedです。

  3. 取得が完了すると data: {"world": "hello"} のようにキャッシュが更新されて、同時にコンポーネントも更新されます。

  4. その後、何らかのイベントによって再検証(revalidate)が実行されるとfetcher関数が再度サーバーにリクエストを送って、データを取得します。

  5. キャッシュが更新されて、コンポーネントの方も新しいデータ data: {"world": "Bye"} になります。

再検証がはじまってから、実際にキャッシュが更新されるまでの間は古いキャッシュがコンポーネントに表示されているのですが、これがstale-while-revalidateに倣っている部分だと思います。

SWRを使ってみて

SWRを使ってみて、2点ほど良かったと感じている点があります。

まず1点目が、2度目以降のリクエストに関してキャッシュが効くと言うことです。ページネーションのある画面などで、一度取得したページについてはキャッシュが効いて表示が速くなるのですが、SWRを使うだけでそれを実現できるのはとても便利だと感じました。

2点目は、手動再検証が便利だと言うことです。例えば上の図のようなユーザー管理画面があったとします。一覧画面ではSWRを使ってユーザー一覧の情報を取得して、表示しているとします。また、別の画面ではユーザー登録を行っているとします。この場合、登録処理を実行した後にmutate関数にSWRのキーを渡すと、このキーに対応するキャッシュが再検証されます。この例の場合では、 mutate("/user") と実行するだけで一覧画面で使用しているSWRのキャッシュが更新されて、新しい状態のユーザー一覧を表示できます。これによって、キャッシュ更新中にローディング表示する必要なく、実装がすっきりします。

質疑応答

データマネジメントの機能だとSWRにより最新のデータが出ない可能性があるとのことですが、これは最新データが見られないデメリットにならないでしょうか?

そうですね。再検証をうまくコントロールしないと古いデータが表示されてしまいます。先ほど言った手動再検証であったり、自動の再検証(revalidate)をうまくコントロールしないといけません。

他のユーザーが操作したものはSWRで検知できないかと思います。オペレーター同士でデータ操作が被り、それが混乱につながるリスクとSWRを使う利便性の比較を知りたいです

これはSWRに限らずキャッシュ戦略のトレードオフになるかと思います。現状では頻繁に再検証を行うという形で対策しています。例えばデータの登録や更新、ブラウザがフォーカスから外れた際などに再検証を行っています。

データについてはサーバー側で不整合が発生しないように検証していますので、フロントエンド側ではそこまで厳密にチェックはしていません。