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

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

Douglas Parker
Douglas Parker
Joel Riley
Joel Riley
Dikla Cohen
Dikla Cohen

これは、Google 情報チームが外部向け PWA の構築中に学んだ教訓を取り上げたブログ投稿シリーズの第 1 回目です。これらの投稿では、直面した課題、それらを克服するためにとったアプローチ、問題を回避するための一般的なアドバイスを紹介します。ただし、PWA の概要を完全に説明しているわけではありません。チームの経験から学んだことを共有することが目的です。

この最初の投稿では、最初に簡単な背景情報を確認してから、Service Worker について学んだすべての内容を掘り下げます。

背景情報

『Bullet』は、2017 年半ばから 2019 年半ばにかけて活発に開発されました。

PWA の構築を選んだ理由

開発プロセスを詳しく見ていく前に、このプロジェクトで PWA の構築が魅力的な選択肢だった理由を確認しましょう。

  • 反復処理を迅速に行えること。公開情報は複数の市場で試験運用されるため、特に有益です。
  • 単一のコードベース。ユーザーは Android と iOS でほぼ均等に分散していました。PWA とは、両方のプラットフォームで動作する単一のウェブアプリを構築できることを意味します。これによりチームのスピードと影響力が増しました
  • ユーザーの行動に関係なくすぐに更新されます。PWA は自動で更新されるため、古いクライアントの数を減らすことができます。クライアントの移行時間が非常に短く、バックエンドの破壊的変更を push することができました。
  • ファーストパーティ製アプリやサードパーティ製アプリと簡単に統合できます。そのような統合はアプリには要件でした。PWA では多くの場合、URL を開くだけで済みました。
  • アプリをインストールする際の煩わしさを解消しました。

Google のフレームワーク

公開情報には Polymer を使用しましたが、適切にサポートされている最新のフレームワークであればどれでも使用できます。

Service Worker について学んだこと

Service Worker がなければ PWA を構築できません。Service Worker には、高度なキャッシュ戦略、オフライン機能、バックグラウンド同期など、多くの機能が備わっています。Service Worker は複雑さが増す一方で、そのメリットの方が複雑さを上回っていることがわかりました。

可能であれば生成する

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

ただし、社内の技術スタックが原因で、ライブラリを使用して Service Worker を生成、管理することはできませんでした。以下で学ぶ内容は、時折その内容を反映したものになるでしょう。詳細については、未生成の Service Worker の注意点をご覧ください。

すべてのライブラリが Service Worker 互換とは限らない

一部の JS ライブラリでは、想定どおりに動作しないことがあります。たとえば、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 で push される
  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、その他のベスト プラクティスに関するレポートを生成します。継続的インテグレーションで Lighthouse を実行し、PWA の基準に違反した場合にアラートを送信することをおすすめします。これは実際に一度起こりました。Service Worker がインストールされていなくても、本番環境への push の前に気づきませんでした。Lighthouse を CI の一部として使用すれば、防ぐことができます。

継続的デリバリーの採用

Service Worker は自動更新されるため、ユーザーがアップグレードを制限することはできません。これにより、古いクライアントの数が大幅に削減されます。ユーザーがアプリを開くと、Service Worker は古いクライアントを提供し、新しいクライアントのダウンロードを遅らせていました。新しいクライアントがダウンロードされると、新機能にアクセスするためにページを更新するようユーザーに求められます。ユーザーがこのリクエストを無視した場合でも、次回ページを更新したときに新しいバージョンのクライアントを受け取ります。そのため、ユーザーが iOS/Android アプリと同様の方法でアップデートを拒否することは非常に困難です。

クライアントの移行時間が非常に短く、バックエンドの互換性を破る変更を push することができました。通常、ユーザーが新しいクライアントに更新してから 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 で直接使用できるため、Cookie Store API をサポートしているブラウザではこの回避策が不要になります。

生成されていない 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 の構築についてご不明な点がある場合は、作成者プロフィールにアクセスしてお問い合わせ方法をご確認ください。