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 について学んだこと

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

可能であれば生成します

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

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

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

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

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

サービス ワーカー スクリプトを初期化するときに 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 workers] ペイン)を有効にし、[Network] パネルで [Disable cache] チェックボックスを有効にしてメモリ キャッシュも無効にすることで、この設定を実現できます。より多くのブラウザに対応するため、Service Worker でキャッシュを無効にするフラグを追加し、デベロッパー ビルドでデフォルトで有効にするという別のソリューションを採用しました。これにより、デベロッパーはキャッシュの問題を生じさせることなく、常に最新の変更を取得できます。ブラウザでアセットがキャッシュに保存されないようにするために、Cache-Control: no-cache ヘッダーも含めることが重要です。

灯台

Lighthouse には、PWA に役立つさまざまなデバッグツールが用意されています。サイトをスキャンし、PWA、パフォーマンス、ユーザー補助、SEO などのベスト プラクティスに関するレポートを生成します。継続的インテグレーションで Lighthouse を実行し、PWA の基準のいずれかに違反した場合にアラートを送信することをおすすめします。実際に、サービス ワーカーがインストールされず、本番環境にプッシュする前に気付かなかったというケースがありました。Lighthouse を CI の一部にしておけば、このような事態は防げたでしょう。

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

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

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

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

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

単体テスト

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