Google での PWA の作成、パート 1

PWA の開発中に、Bulletin チームが Service Worker について学んだこと。

Douglas Parker 氏
Douglas Parker
Joel Riley 氏
Joel Riley
ディクラ・コーエン
Dikla Cohen

これは、Google の公開情報チームが外部公開 PWA を構築する際に学んだ教訓について、一連のブログ投稿の第 1 回目です。この投稿では、Google が直面した課題、それらを克服するためのアプローチ、問題を回避するための一般的なアドバイスをご紹介します。これは PWA の完全な概要を示すものではありません。チームの経験から得た知見を共有することを目的としています。

この最初の投稿では、まず背景情報を簡単に紹介してから、Service Worker について学習したすべての内容について詳しく説明します。

背景

公開情報は 2017 年半ばから 2019 年半ばまで積極的に開発されていました。

PWA の構築を選んだ理由

開発プロセスを詳しく見ていく前に、なぜ PWA の作成がこのプロジェクトにとって魅力的な選択肢になったのかを見てみましょう。

  • 反復処理を迅速に行える。公開情報は複数の市場で試験運用されるため、特に有益です。
  • 単一のコードベース。Android と iOS のユーザーはほぼ半数を占めていました。PWA は、両方のプラットフォームで機能するウェブアプリを 1 つ構築できることを意味します。これにより チームのスピードと影響力が 高まりました
  • ユーザーの行動とは無関係にすばやく更新:PWA は自動的に更新されるため、古くなったクライアントの数を減らすことができます。バックエンドの変更を極めて短時間で移行できました。
  • ファーストパーティ製アプリやサードパーティ製アプリと簡単に統合。このような統合はアプリの要件でした。PWA では多くの場合、単に URL を開く必要がありました。
  • アプリのインストールの煩わしさをなくしました。

フレームワーク

公開情報には Polymer を使用しましたが、十分にサポートされている最新のフレームワークであればどれでも問題ありません。

Service Worker について学んだこと

PWA を使用するには Service Worker が必要です。Service Worker は、高度なキャッシュ戦略、オフライン機能、バックグラウンド同期など、多くの機能を提供します。Service Worker は若干複雑ですが、そのメリットは複雑さを上回ることがわかっています。

可能であれば生成してください。

Service Worker スクリプトを手作業で記述することは避けます。Service Worker を手動で作成するには、キャッシュに保存されたリソースを手動で管理し、Workbox などのほとんどの Service Worker ライブラリに共通する書き換えロジックを書き換える必要があります。

とはいえ、社内の技術スタックが原因で、Service Worker の生成と管理にライブラリを使用することができませんでした。以下では、その点を念頭に置いて説明します。詳しくは、生成されない Service Worker の問題をご覧ください。

すべてのライブラリが Service Worker と互換性があるわけではない

一部の JS ライブラリは、Service Worker が実行時に想定どおりに動作しないことを想定します。たとえば、window または document が使用できる場合、または Service Worker が使用できない API(XMLHttpRequest、ローカル ストレージなど)を使用していることを前提としています。アプリケーションに必要な重要なライブラリが Service Worker の互換性があることを確認してください。この PWA で認証に gapi.js を使用しようとしましたが、Service Worker をサポートしていないため使用できませんでした。また、ライブラリの作成者は、Service Worker のユースケースをサポートするために、可能な限り JavaScript のコンテキストに関する不要な前提条件を減らす必要があります。たとえば、Service Worker と互換性のない API やグローバルな状態を回避するなどです。

初期化中の IndexedDB へのアクセスを回避する

Service Worker スクリプトの初期化時に IndexedDB を読み取らないでください。読み取らないと、次の望ましくない状況が発生する可能性があります。

  1. ユーザーが IndexedDB(IDB)バージョン N のウェブアプリを使用している
  2. 新しいウェブアプリが IDB バージョン N+1 でプッシュされる
  3. ユーザーが PWA にアクセスすると、新しい Service Worker のダウンロードがトリガーされます
  4. 新しい Service Worker は、install イベント ハンドラを登録する前に IDB から読み取ります。これにより、IDB のアップグレード サイクルが N から N+1 にトリガーされます。
  5. ユーザーがバージョン N の古いクライアントを使用しているため、古いバージョンのデータベースへのアクティブな接続がまだ開いているため、Service Worker のアップグレード プロセスがハングします。
  6. Service Worker がハングし、インストールされない

今回のケースでは、Service Worker のインストール時にキャッシュが無効になっているため、Service Worker がインストールされていない場合、ユーザーは更新されたアプリを受け取ることはありません。

復元性を高める

Service Worker スクリプトはバックグラウンドで実行されますが、I/O オペレーション(ネットワーク、IDB など)の最中であっても、いつでも終了できます。長時間実行プロセスは、いつでも再開可能にする必要があります。

大きなファイルをサーバーにアップロードして IDB に保存する同期プロセスの場合、部分的なアップロードの中断に対するソリューションは、内部アップロード ライブラリの再開可能システムを利用して、アップロードの前に再開可能なアップロードの URL を IDB に保存し、その URL を使用してアップロードが一度で完了しなかった場合に再開するというものでした。また、長時間実行される I/O オペレーションの前に、各レコードについて、プロセスのどの段階にいるかを示すために、状態が IDB に保存されています。

グローバルな状態に依存しない

Service Worker は異なるコンテキストに存在するため、存在するシンボルが多くありません。多くのコードは、window コンテキストと Service Worker コンテキスト(ロギング、フラグ、同期など)の両方で実行されました。コードは、使用するサービス(ローカル ストレージや Cookie など)に対して防御的である必要があります。globalThis を使用すると、すべてのコンテキストで動作する方法でグローバル オブジェクトを参照できます。また、スクリプトが終了して状態が削除されるタイミングは保証されないため、グローバル変数に格納されたデータは慎重に使用してください。

ローカルでの開発

Service Worker の主なコンポーネントは、リソースのローカル キャッシュです。ただし、開発中は、特に更新が遅延する場合には、目的とは正反対になります。問題のデバッグや、バックグラウンド同期、通知などの他の API の操作ができるように、サーバー ワーカーはインストールしておく必要があります。Chrome では、Chrome DevTools で [Bypass for network] チェックボックス([Application] パネル > [Service Worker] ペイン)を有効にし、[Network] パネルの [Disable cache] チェックボックスを有効にしてメモリ キャッシュも無効にします。より多くのブラウザに対応するために、Service Worker のキャッシュを無効にするフラグを含めることで、別のソリューションを選択しました。このフラグは、デベロッパーのビルドでデフォルトで有効になっています。これにより、キャッシュ保存の問題を生じさせることなく、常に最新の変更を取得できるようになります。ブラウザがアセットをキャッシュに保存しないようにするために、Cache-Control: no-cache ヘッダーも含めることが重要です。

灯台

Lighthouse には、PWA に役立つデバッグツールが多数用意されています。サイトをスキャンして、PWA、パフォーマンス、ユーザー補助、SEO、その他のベスト プラクティスに関するレポートを生成します。PWA の条件のいずれかに違反した場合は、継続的インテグレーションで Lighthouse を実行することをおすすめします。この状況は実際には 1 回発生しました。Service Worker はインストールされず、本番環境への push の前に気づかなかったことです。Lighthouse を CI に組み込むことで、これを回避できたでしょう。

継続的デリバリーを活用する

Service Worker は自動的に更新できるため、ユーザーがアップグレードを制限することはできません。これにより、実際の環境で古くなったクライアントの量が大幅に削減されます。ユーザーがアプリを開くと、Service Worker は古いクライアントにサービスを提供し、新しいクライアントを遅延的にダウンロードします。新しいクライアントがダウンロードされると、ユーザーはページを更新して新しい機能にアクセスするように求められます。ユーザーがこのリクエストを無視しても、次回ページを更新したときに、新しいバージョンのクライアントが送信されます。そのため、ユーザーが iOS/Android アプリの場合と同じ方法で更新を拒否するのはかなり困難です。

クライアントの移行時間が非常に短く、バックエンドの互換性を破る変更を行うことができました。通常、破壊的な変更を行う前に、ユーザーが新しいクライアントにアップデートするための 1 か月の猶予を設けます。アプリは最新でない状態で提供されるため、ユーザーがアプリを長時間開いていなければ、古いクライアントが実際に存在することがありました。iOS では、Service Worker は数週間後に強制排除されるため、このような事態は発生しません。Android の場合、この問題は、古くなったコンテンツが表示されないようにしたり、数週間後にコンテンツを手動で期限切れにしたりすることで軽減できます。実際には 古いクライアントの問題に 遭遇することはありません要件の厳格さはユースケースによって異なりますが、PWA は iOS/Android アプリよりもはるかに柔軟性があります。

Service Worker での Cookie 値の取得

Service Worker のコンテキストで Cookie の値にアクセスする必要がある場合があります。このケースでは、ファーストパーティの API リクエストを認証するためのトークンを生成するために、Cookie 値にアクセスする必要がありました。Service Worker では、document.cookies などの同期 API は使用できません。Service Worker からアクティブな(ウィンドウ処理された)クライアントにメッセージを送信して Cookie の値をいつでもリクエストできます。ただし、バックグラウンド同期中など、ウィンドウ処理されたクライアントを使用しなくてもバックグラウンドで Service Worker が実行される可能性もあります。この問題を回避するため、Cookie の値をクライアントにエコーバックするエンドポイントをフロントエンド サーバーに作成しました。Service Worker は、このエンドポイントに対してネットワーク リクエストを行い、レスポンスを読み取って Cookie 値を取得します。

Cookie Store API のリリース以降、この回避策は、ブラウザの Cookie への非同期アクセスを提供し、Service Worker で直接使用できるため、サポートしているブラウザでは必要なくなります。

非生成 Service Worker の注意点

キャッシュに保存された静的ファイルが変更された場合に Service Worker スクリプトが変更されることを確認してください

PWA の一般的なパターンは、Service Worker が install フェーズですべての静的アプリケーション ファイルをインストールすることです。これにより、クライアントは後続のすべてのアクセスで Cache Storage API キャッシュを直接ヒットできます。Service Worker は、Service Worker スクリプトがなんらかの方法で変更されたことをブラウザが検出した場合にのみインストールされるため、キャッシュに保存されたファイルが変更されたときに、Service Worker スクリプト ファイル自体がなんらかの方法で変更されていることを確認する必要がありました。これは、静的リソース ファイルセットのハッシュを Service Worker スクリプト内に埋め込むことで手動で行いました。これにより、リリースごとに個別の Service Worker JavaScript ファイルが生成されました。Workbox などの Service Worker ライブラリがこのプロセスを自動化します。

単体テスト

Service Worker API は、グローバル オブジェクトにイベント リスナーを追加することで機能します。次に例を示します。

self.addEventListener('fetch', (evt) => evt.respondWith(fetch('/foo')));

イベント トリガー(イベント オブジェクトをモック)をモックし、respondWith() コールバックを待ってから、Promise を待ってから、最終的に結果をアサートする必要があるため、テストは面倒になる可能性があります。この構成を簡単に行うには、すべての実装を別のファイルに委任するとテストが容易になります。

import fetchHandler from './fetch_handler.js';
self.addEventListener('fetch', (evt) => evt.respondWith(fetchHandler(evt)));

Service Worker スクリプトの単体テストが難しいため、コアの Service Worker スクリプトは可能な限り必要最小限のものにし、実装の大部分を他のモジュールに分割しました。これらのファイルは標準の JS モジュールにすぎないため、標準のテスト ライブラリを使用して単体テストを簡単に行うことができます。

パート 2、3 にご期待ください

このシリーズのパート 2 とパート 3 では、メディア管理と iOS 固有の問題について説明します。Google での PWA の構築についてご不明な点がありましたら、作成者プロフィールにアクセスしてお問い合わせください。