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

ニュース速報チームが PWA の開発中に Service Worker について学んだこと。

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

これは、外部向け PWA の構築中に Google ニュース速報チームが学んだ教訓に関する一連のブログ投稿の最初の投稿です。これらの投稿では、直面した課題、それらを克服するために取ったアプローチ、落とし穴を回避するための一般的なアドバイスをご紹介します。これは PWA の完全な概要ではありません。チームの経験から学んだことを共有することが目的です。

この最初の投稿では、まず背景情報を少し説明してから、Service Worker について学んだことをすべて説明します。

背景

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

PWA の開発を選択した理由

開発プロセスについて詳しく説明する前に、このプロジェクトで PWA を構築することが魅力的なオプションである理由について説明します。

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

Google のフレームワーク

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

Service Worker について学んだこと

サービス ワーカーなしで PWA を作成することはできません。Service Worker には、高度なキャッシュ戦略、オフライン機能、バックグラウンド同期など、多くの機能があります。Service Worker は複雑さを増しますが、そのメリットは複雑さの増加を上回ると判断しました。

可能であれば生成します

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

ただし、内部テクノロジー スタックが原因で、ライブラリを使用してサービス ワーカーを生成、管理することはできませんでした。以下に示す学習内容には、そのことが反映されています。詳細については、未生成の Service Worker の注意点をご覧ください。

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

一部の JS ライブラリは、サービス ワーカーによって実行されたときに想定どおりに動作しない前提条件を設定しています。たとえば、window または document が使用可能であることを前提としているか、サービス ワーカーが使用できない API(XMLHttpRequest、ローカル ストレージなど)を使用しています。アプリに必要な重要なライブラリがサービス ワーカーと互換性があることを確認します。この PWA では、認証に gapi.js を使用したかったのですが、サービス ワーカーをサポートしていないため、使用できませんでした。ライブラリ作成者は、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 の古いクライアントを使用しているため、古いバージョンのデータベースへのアクティブな接続がまだ開いているため、サービス ワーカーのアップグレード プロセスがハングします。
  6. Service Worker がハングし、インストールされない

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

耐障害性を確保する

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

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

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

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

ローカルでの開発

サービス ワーカーの主要コンポーネントは、ローカルでリソースをキャッシュに保存することです。ただし、開発中は、特に更新が遅延している場合は、これは望ましい結果の正反対です。サーバー ワーカーはインストールしたままにして、問題のデバッグや、バックグラウンド同期や通知などの他の 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 の条件のいずれかに違反している場合にアラートを表示することをおすすめします。実際に、サービス ワーカーがインストールされず、本番環境にプッシュする前に気付かなかったというケースがありました。Lighthouse を CI の一部にしておけば、このような事態は防げたでしょう。

継続的デリバリーの採用

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

クライアントの移行時間を非常に短く抑えながら、破壊的なバックエンドの変更をプッシュアウトできました。通常、Google はユーザーが新しいクライアントに更新できるように 1 か月間待ってから、破壊的変更を加えます。アプリは古い状態で配信されるため、ユーザーがアプリを長期間開いていなかった場合、古いクライアントが実際に存在する可能性がありました。iOS では、Service Worker は数週間後に強制排除されるため、このケースは発生しません。Android では、古いコンテンツを配信しないようにするか、数週間後に手動でコンテンツを期限切れにすることで、この問題を軽減できます。実際には、古いクライアントによる問題は発生していません。どの程度厳しくするかは、特定のユースケースによって異なりますが、PWA は iOS/Android アプリよりもはるかに柔軟性があります。

サービス ワーカーで Cookie 値を取得する

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

Cookie Store API のリリースにより、この回避策は、ブラウザ Cookie への非同期アクセスを提供し、サービス ワーカーによって直接使用できるため、これをサポートするブラウザでは不要になりました。

生成されていない 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 API は、グローバル オブジェクトにイベント リスナーを追加することで機能します。例:

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

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

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 の構築について詳しくお知りになりたい場合は、著者プロフィールにアクセスしてお問い合わせ方法をご覧ください。