GatsbyとService Workerとデバッグの記録

おはようございます。Progateのコンテンツチーム 福井です。本記事はProgate AdventCalendar 20209日目です。

普段仕事ではProgateのレッスンの企画や制作をしたり、プロジェクトリードとしてチームでモノゴトを進めたり、たまに実装したりしています。

最近では、Progate JourneyというWebプロダクト開発学習ロードマップをリリースしました。

journey.prog-8.com

今日はそんなProgate Journeyのリリース1ヶ月記念(11月9日リリース)ということで、最近踏んだキャッシュに関するバグについて書きたいと思います。

 

背景

Progate JourneyはGatsbyを使って作っています。Gatsbyとは、Web開発の便利なことをよしなにやってくれるReact用のサイトジェネレーターです。

Gatsbyがやってくれる便利なことの1つにパフォーマンスチューニングが挙げられます。SSRやPWA対応、画像の読み込み処理など、フロントエンドのパフォーマンスに関わるトピックをGatsbyではプラグインなどのエコシステムを駆使することで実装コストを最小限にして実現できます。

 

www.gatsbyjs.com

 

Progate Journeyでは下記の理由からGatsbyを採用することにしました。

  • 動的な機能が無く、静的なWebページだったこと
  • 実装者のリソースが少なかったこと(僕とデザイナーの2人)
  • PWA対応したかったこと
  • 個人的な好奇心

Gatsbyは個人開発で触っていたこともあり、比較的スムーズに開発に取り掛かることができました。

PWA対応

さて、ここから徐々に本題に入ります。先ほど、Gatsbyを採用した中にこんな理由がありました。

PWA対応したかったこと

PWA対応には次の3つの要素を満たしている必要があります。

  1. サイトがhttpsで提供されていること
  2. Webアプリマニフェストが存在すること
  3. Service Workerが存在すること

1はインフラで対応するとして、2と3を実現するためにはGatsbyのプラグインを利用します。それぞれ「gatsby-plugin-manifest」と「gatsby-plugin-offline」を使います。

プラグインはnpmからインストールしてgatsby-config.jsで読み込むだけです。

 

なんと、これだけでPWA対応が完了します。モバイルでアクセスするとモーダルが表示されました。

 

f:id:nakedtatsuya:20201205004812j:plain

 

つまり・・・「Gatsbyでは低コストでPWA対応できる」

 

最高ですね。

 

発生したバグについて

さて、ここからが本題です。

そんな感じでPWA対応も無事にでき、ノウノウと開発してたのですが、あるとき1つのバグが報告されました。

 

バグの詳細は

「ページを開いたときにGraqhQLのデータの読み込みでエラーが発生してホワイトアウトする

というものでした。しかし、エラーは毎回起こるわけでなく、不定期。リロードすると直る。という再現が難しく原因がいまいち分からないものでした。

 

Service Workerが怪しい 

デバッグはバグを再現することから始まると思いますが、再現方法がわからなかったので、エラーが発生する原因からざっくり推理してみました。

エラーが置きた状況を整理すると、

  • デプロイ直後に起きる
  • gatsby-plugin-offlineを入れてから起きるようになった

という要因があったのでService Workerが怪しいのではと考えました。

原因

結論から言うと、Service WorkerによってGraqhQLの実行結果のPage dataファイルがキャッシュされ、コードを更新した後もキャッシュされた古いJSONデータを返すことが原因でした。

再現手順としては、Service Workerが入った状態で下記を実行すると何回かに1度の頻度でエラーが確認できます。

  1. GraqhQLのデータを読み込むページにアクセスしてキャッシュを登録
  2. GraqhQLのデータにカラム追加してコードを更新
  3. ビルドして再度ページにアクセス

毎回起こるわけではないのが難しいところなのですが、キャッシュの問題ということまでがわかりました。

しかし、まだキャッシュの設定の具体的にどこが問題でエラーが起きているのか分からなかったため、Gatsbyのドキュメントやgatsby-plugin-offlineで生成されるService Workerのコードを読んでみることにしました。

 

Gatsbyのキャッシュ

まずは公式のドキュメントを確認しましょう。下記のドキュメントには「キャッシュするべきファイルとキャッシュするべきでないファイル」について書いてあります。

www.gatsbyjs.com

 

今回のバグで関係あるのはGraphQLのデータが格納されるPage dataのキャッシュについてです。ドキュメントにはPage dataはブラウザによってキャッシュされるべきではないと書いてあります。

Page data

Similar to HTML files, the JSON files in the public/page-data/ directory should never be cached by the browser. In fact, it’s possible for these files to be updated even without doing a redeploy of your site. Because of this, browsers should be instructed to check on every request if the data in your application has changed.

The cache-control header should be cache-control: public, max-age=0, must-revalidate1

 現状のバグは、HTMLやReactのJSファイルは最新の状態で読み込まれているが、Page dataが古い情報になっていることによる差分でエラーが起きています。つまり、上記のドキュメントに反して、Page dataのJSONファイルにキャッシュが効いちゃってるのが原因なのではと考えました。

 

コードを読む

 Page dataのJSONファイルにキャッシュが効いちゃってるのでは?を調査するために、gatsby-plugin-offlineで生成されるService Workerのコードを読んでみましょう。

生成されるService Workerのファイルは/public/sw.jsにあります。

 

最初の数行でworkboxが使われていることがわかります。

 

sw.js
importScripts("workbox-v4.3.1/workbox-sw.js");
workbox.setConfig({modulePathPrefix: "workbox-v4.3.1"});

 

 workboxはService Workerにキャッシュを格納するときの作業を便利にしてくれるやつです。 

developers.google.com

 

 続いて、少し下の方にいくと重要そうなコードがありました。ここで、ルーティングによってキャッシュの強さを設定してそうです。

Page dataのキャッシュの設定はworkbox.strategies.StaleWhileRevalidateとなっています。他にもjsやcssファイルやstatic配下のファイルにはworkbox.strategies.CacheFirstなどが設定されています。

 

sw.js
workbox.routing.registerRoute(/(\.js$|\.css$|static\/)/, new workbox.strategies.CacheFirst(), 'GET');
workbox.routing.registerRoute(/^https?:.*\/page-data\/.*\.json/, new workbox.strategies.StaleWhileRevalidate(), 'GET');
workbox.routing.registerRoute(/^https?:.*\.(png|jpg|jpeg|webp|svg|gif|tiff|js|woff|woff2|json|css)$/, new workbox.strategies.StaleWhileRevalidate(), 'GET');
workbox.routing.registerRoute(/^https?:\/\/fonts\.googleapis\.com\/css/, new workbox.strategies.StaleWhileRevalidate(), 'GET');

 

どうやら、workbox.strategiesでキャッシュの設定ができるようです。workbox.strategiesを調べてみるとそれぞれのキャッシュのレベルについて説明されていました。

  • Stale-While-Revalidateは基本的にキャッシュを使い、キャッシュが無いときはネットワークを使うもの
  • CacheFirstはStale-While-Revalidateよりも強いキャッシュ

developers.google.com

 

 

Page dataに対して使用しているStale-While-Revalidateは、最新のリソースを持つことがアプリケーションにとって不可欠ではないものに使うと書かれています。

Page dataには最新のリソースを期待しているので、使用用途の条件に反していそうです。

ドキュメントを読み進めていくとStale-While-Revalidateよりも弱いキャッシュがありました。Network Firstです。

  • Network Firstは基本ネットワークで応答がない時にキャッシュの値を返します

この設定に更新してあげれば、Page dataがちゃんと更新されそうです。

 

workboxの設定オーバーライド

では、Page dataのキャッシュをStale-While-RevalidateからNetwork Firstにオーバーライドします。

方法はgatsby-plugin-offlineのドキュメントに書いてあります。プラグインのオプションから設定できます。

 

gatsby-config.js
    {
      resolve: 'gatsby-plugin-offline',
      options: {
        workboxConfig: {
          runtimeCaching: [
            {
              urlPattern: /^https?:.*\/page-data\/.*\.json/,
              handler: 'NetworkFirst',
            },
          ],
        },
      },
    },

 

再度buildするとオーバーライドされていることが確認できました。

 

sw.js
workbox.routing.registerRoute(/^https?:.*\/page-data\/.*\.json/, new workbox.strategies.NetworkFirst(), 'GET'); 

 

エラーの再現手順を試してみると、バグは起きずに無事に解決できました。

 

まとめ

キャッシュなどの知見は深く持っていなかったので、今回学ぶきっかけになってよかったなと思いました。

また、今回の解決策が完全に正しいかはわからないので、よりキャッシュやService Workerなどについて学んでいきたいと思います。

 

「Gatsbyでは低コストでPWA対応できる」

と思考を止めるのではなく、ちゃんと内部の仕組みを理解して使っていくのが大事ですね。

 

おわりに

明日は @satetsu888 さんが スマホにカメラついてるんだからOCRできるでしょという気持ち を書くそうです!ぜひお楽しみに!