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 を手動で記述する場合は、キャッシュに保存されたリソースを手動で管理し、Workbox など、ほとんどの 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 値をクライアントにエコーバックするだけのエンドポイントをフロントエンド サーバーに作成しました。サービス ワーカーはこのエンドポイントにネットワーク リクエストを行い、レスポンスを読み取って 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 の構築について詳しくお知りになりたい場合は、著者のプロフィールにアクセスしてお問い合わせ方法をご覧ください。