本番環境の Service Worker

縦向きのスクリーンショット

概要

サービス ワーカー ライブラリを使用して、Google I/O 2015 ウェブアプリを高速かつオフライン ファーストにする方法を学びます。

概要

今年の Google I/O 2015 ウェブアプリは、Google のデベロッパー リレーションズ チームが、Instrument の友人が作成したデザインに基づいて作成しました。Instrument は、便利なオーディオ/ビジュアル テストを作成しました。私たちのチームの使命は、I/O ウェブアプリ(コードネーム IOWA)に最新のウェブでできることをすべて取り入れることでした。必須機能のリストでは、完全なオフライン ファースト エクスペリエンスが最優先事項でした。

このサイトの他の記事を最近読んだことがあるなら、Service Worker に触れたことがあるはずです。IOWA のオフライン サポートが Service Worker に大きく依存していることは驚くに値しません。IOWA の実際のニーズに基づいて、2 つの異なるオフライン ユースケースを処理する 2 つのライブラリを開発しました。静的リソースのプレキャッシュを自動化する sw-precache と、ランタイム キャッシュとフォールバック戦略を処理する sw-toolbox です。

ライブラリは互いにうまく補完し合うため、IOWA の静的コンテンツ「シェル」が常にキャッシュから直接提供され、動的リソースまたはリモート リソースがネットワークから提供され、必要に応じてキャッシュまたは静的レスポンスにフォールバックするという、優れた戦略を実装できました。

sw-precache を使用したプリキャッシュ

IOWA の静的リソース(HTML、JavaScript、CSS、画像)は、ウェブ アプリケーションのコアシェルを提供します。これらのリソースのキャッシュに際して、特に重要な要件が 2 つありました。ほとんどの静的リソースがキャッシュに保存され、最新の状態に保たれることです。sw-precache は、これらの要件を念頭に置いて構築されています。

ビルド時の統合

sw-precache を IOWA の gulp ベースのビルドプロセスに置き換えます。また、一連の glob パターンを使用して、IOWA が使用するすべての静的リソースの完全なリストを生成するようにします。

staticFileGlobs: [
    rootDir + '/bower_components/**/*.{html,js,css}',
    rootDir + '/elements/**',
    rootDir + '/fonts/**',
    rootDir + '/images/**',
    rootDir + '/scripts/**',
    rootDir + '/styles/**/*.css',
    rootDir + '/data-worker-scripts.js'
]

ファイル名のリストを配列にハードコードしたり、ファイルが変更されるたびにキャッシュ バージョン番号を増やしたりする方法は、特に複数のチームメンバーがコードをチェックインしている場合、エラーが発生しやすくなります。手動で管理する配列に新しいファイルを含めずに、オフライン サポートを中断したくはありません。ビルド時統合により、そのような心配をすることなく、既存のファイルに変更を加えたり、新しいファイルを追加したりできるようになりました。

キャッシュに保存されたリソースの更新

sw-precache は、プリキャッシュされるリソースごとに一意の MD5 ハッシュを含むベースの Service Worker スクリプトを生成します。既存のリソースが変更されるか、新しいリソースが追加されるたびに、Service Worker スクリプトが再生成されます。これにより、Service Worker の更新フローが自動的にトリガーされます。このフローでは、新しいリソースがキャッシュに保存され、古いリソースがパージされます。同じ MD5 ハッシュを持つ既存のリソースはそのまま残ります。つまり、サイトにアクセスしたことがあるユーザーは、変更されたリソースの最小セットのみをダウンロードすることになります。これにより、キャッシュ全体が一括で期限切れになる場合よりも、はるかに効率的なエクスペリエンスが実現します。

ユーザーが IOWA に初めてアクセスしたときに、glob パターンのいずれかに一致する各ファイルがダウンロードされ、キャッシュに保存されます。ページのレンダリングに必要な重要なリソースのみがプリキャッシュされるようにしました。音声/映像のテストで使用されるメディアや、セッションのスピーカーのプロフィール画像などのセカンダリ コンテンツは、意図的にプリキャッシュされませんでした。代わりに、sw-toolbox ライブラリを使用して、これらのリソースのオフライン リクエストを処理しました。

sw-toolbox: あらゆる動的ニーズに対応

前述のように、サイトがオフラインで動作するために必要なすべてのリソースをプリキャッシュすることは現実的ではありません。一部のリソースは大きすぎるか、使用頻度が低すぎて、圧縮するメリットがありません。また、リモート API やサービスからのレスポンスなど、動的リソースもあります。ただし、リクエストがプリキャッシュされていないからといって、必ず NetworkError になるわけではありません。sw-toolbox により、一部のリソースのランタイム キャッシュとその他のリソースのカスタム フォールバックを処理するリクエスト ハンドラを柔軟に実装できるようになりました。また、プッシュ通知に応じて、以前にキャッシュに保存したリソースを更新するためにも使用しました。

以下に、sw-toolbox 上に構築したカスタム リクエスト ハンドラの例を示します。sw-precacheimportScripts parameter を使用して、ベースの Service Worker スクリプトと簡単に統合できました。これにより、スタンドアロンの JavaScript ファイルを Service Worker のスコープに引き込むことができました。

音声/映像のテスト

音声/映像のテストでは、sw-toolboxnetworkFirst キャッシュ戦略を使用しました。テストの URL パターンに一致するすべての HTTP リクエストは、まずネットワークに対して実行され、正常なレスポンスが返された場合は、Cache Storage API を使用してそのレスポンスが保存されます。ネットワークが使用できないときに後続のリクエストが行われた場合は、以前にキャッシュに保存されたレスポンスが使用されます。

ネットワーク レスポンスが正常に返されるたびにキャッシュが自動的に更新されるため、リソースのバージョン管理やエントリの有効期限切れの設定を特別に行う必要はありませんでした。

toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);

スピーカーのプロフィール画像

スピーカー プロファイル画像の目標は、特定のスピーカーの画像が利用可能な場合は以前にキャッシュ バージョンを表示し、使用できない場合はネットワークにフォールバックして画像を取得することでした。そのネットワーク リクエストが失敗した場合、最後の代替として、事前にキャッシュに保存された(常に利用可能な)汎用プレースホルダ画像が使用されました。これは、汎用プレースホルダに置き換え可能な画像を処理する場合によく使用される手法であり、sw-toolboxcacheFirst ハンドラと cacheOnly ハンドラを連結することで簡単に実装できました。

var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';

function profileImageRequest(request) {
    return toolbox.cacheFirst(request).catch(function() {
    return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
    });
}

toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
                    profileImageRequest,
                    {origin: /.*\.googleapis\.com/});
セッション ページのプロフィール画像
セッション ページのプロフィール画像。

ユーザーのスケジュールの更新

IOWA の重要な機能の一つは、ログインしたユーザーが、参加する予定のセッションのスケジュールを作成して維持できることでした。ご想像のとおり、セッションの更新はバックエンド サーバーへの HTTP POST リクエストを介して行われました。Google は、ユーザーがオフラインのときにこれらの状態変更リクエストを処理する最適な方法を見つけるために時間を費やしました。IndexedDB で失敗したリクエストをキューに入れることと、IndexedDB でキューに入っているリクエストをチェックし、見つかったリクエストを再試行するメイン ウェブページのロジックを組み合わせて解決しました。

var DB_NAME = 'shed-offline-session-updates';

function queueFailedSessionUpdateRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, request.method);
    });
}

function handleSessionUpdateRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedSessionUpdateRequest(request);
    });
}

toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
                    handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
                        handleSessionUpdateRequest);

再試行はメインページのコンテキストから行われたため、新しいユーザー認証情報が含まれていることが確実でした。再試行が成功すると、以前にキューに追加された更新が適用されたことをユーザーに知らせるメッセージが表示されます。

simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
    var replayPromises = [];
    return db.forEach(function(url, method) {
    var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
        return db.delete(url).then(function() {
        return true;
        });
    });
    replayPromises.push(promise);
    }).then(function() {
    if (replayPromises.length) {
        return Promise.all(replayPromises).then(function() {
        IOWA.Elements.Toast.showMessage(
            'My Schedule was updated with offline changes.');
        });
    }
    });
}).catch(function() {
    IOWA.Elements.Toast.showMessage(
    'Offline changes could not be applied to My Schedule.');
});

オフライン Google アナリティクス

同様に、失敗した Google アナリティクス リクエストをキューに入れて、ネットワークが利用可能になった後で再試行するハンドラを実装しました。このアプローチでは、オフラインだからといって Google アナリティクスが提供する分析情報を犠牲にする必要はありません。キューに追加された各リクエストに qt パラメータを追加し、リクエストが最初に試行されてから経過した時間に設定しました。これにより、適切なイベント アトリビューション時間が Google アナリティクスのバックエンドに送信されるようになりました。Google アナリティクスでは、qt の値が最大 4 時間まで公式にサポートされているため、Service Worker が起動されるたびに、これらのリクエストを可能な限り早く再生するよう最善を尽くしました。

var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;

function replayQueuedAnalyticsRequests() {
    simpleDB.open(DB_NAME).then(function(db) {
    db.forEach(function(url, originalTimestamp) {
        var timeDelta = Date.now() - originalTimestamp;
        var replayUrl = url + '&qt=' + timeDelta;
        fetch(replayUrl).then(function(response) {
        if (response.status >= 500) {
            return Response.error();
        }
        db.delete(url);
        }).catch(function(error) {
        if (timeDelta > EXPIRATION_TIME_DELTA) {
            db.delete(url);
        }
        });
    });
    });
}

function queueFailedAnalyticsRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, Date.now());
    });
}

function handleAnalyticsCollectionRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedAnalyticsRequest(request);
    });
}

toolbox.router.get('/collect',
                    handleAnalyticsCollectionRequest,
                    {origin: ORIGIN});
toolbox.router.get('/analytics.js',
                    toolbox.networkFirst,
                    {origin: ORIGIN});

replayQueuedAnalyticsRequests();

プッシュ通知ランディング ページ

Service Worker は IOWA のオフライン機能を処理するだけでなく、ブックマークに追加したセッションの更新についてユーザーに通知するプッシュ通知も使用しました。これらの通知に関連付けられたランディング ページには、更新されたセッションの詳細が表示されます。これらのランディング ページは、サイト全体の一部としてすでにキャッシュに保存されていたため、オフラインでも機能していましたが、オフラインで表示した場合でも、そのページのセッションの詳細が最新の状態であることを確認する必要がありました。そのため、プッシュ通知をトリガーした更新を使用して、以前にキャッシュに保存されたセッション メタデータを変更し、結果をキャッシュに保存しました。この最新の情報は、オンラインかオフラインかにかかわらず、セッションの詳細ページを次回開いたときに使用されます。

caches.open(toolbox.options.cacheName).then(function(cache) {
    cache.match('api/v1/schedule').then(function(response) {
    if (response) {
        parseResponseJSON(response).then(function(schedule) {
        sessions.forEach(function(session) {
            schedule.sessions[session.id] = session;
        });
        cache.put('api/v1/schedule',
                    new Response(JSON.stringify(schedule)));
        });
    } else {
        toolbox.cache('api/v1/schedule');
    }
    });
});

注意事項と考慮事項

もちろん、IOWA のような規模のプロジェクトに取り組む際に、いくつかの落とし穴に遭遇しない人はいないでしょう。以下に、発生した問題とその対処方法をいくつか示します。

古いコンテンツ

キャッシュ戦略を計画する場合は、Service Worker で実装するか、標準のブラウザ キャッシュを使用して実装するかにかかわらず、リソースを可能な限り迅速に提供するか、最新のリソースを配信するかがトレードオフの関係にあります。sw-precache を使用して、アプリケーションのシェルに積極的なキャッシュファースト戦略を実装しました。つまり、サービス ワーカーは、ページの HTML、JavaScript、CSS を返す前にネットワークで更新を確認しません。

幸い、Service Worker のライフサイクル イベントを利用して、ページが読み込まれた後に新しいコンテンツが利用可能になったことを検出できました。更新された Service Worker が検出されると、最新のコンテンツを表示するにはページを再読み込みする必要があることをユーザーに知らせるトースト メッセージが表示されます。

if (navigator.serviceWorker && navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.onstatechange = function(e) {
    if (e.target.state === 'redundant') {
        var tapHandler = function() {
        window.location.reload();
        };
        IOWA.Elements.Toast.showMessage(
        'Tap here or refresh the page for the latest content.',
        tapHandler);
    }
    };
}
最新コンテンツのトースト
「最新のコンテンツ」トースト。

静的コンテンツが静的であることを確認する

sw-precache は、ローカル ファイルのコンテンツの MD5 ハッシュを使用し、ハッシュが変更されたリソースのみを取得します。つまり、リソースはほぼすぐにページで使用できるようになりますが、一度キャッシュに保存されたものは、更新された Service Worker スクリプトで新しいハッシュが割り当てられるまでキャッシュに残ります。

I/O 中にこの動作で問題が発生しました。これは、会議の開催中にバックエンドがライブ ストリーミング YouTube 動画 ID を動的に更新する必要があったためです。基盤となるテンプレート ファイルは静的で変更されていないため、Service Worker の更新フローはトリガーされません。YouTube 動画の更新に伴うサーバーからの動的レスポンスが、最終的に多くのユーザーのキャッシュに保存されたレスポンスになっていました。

この種の問題を回避するには、シェルが常に静的で安全にプリキャッシュに保存されるように、ウェブ アプリケーションを構造化します。一方、シェルを変更する動的リソースは個別に読み込まれるようにします。

プリキャッシュ リクエストのキャッシュ破棄

sw-precache は、事前キャッシュに保存するリソースをリクエストすると、ファイルの MD5 ハッシュが変更されていないと判断する限り、そのレスポンスを無期限に使用します。つまり、プリキャッシュ リクエストに対するレスポンスがブラウザの HTTP キャッシュから返されたものではなく、新しいものであることを確認することが特に重要です。(サービス ワーカーで行われた fetch() リクエストは、ブラウザの HTTP キャッシュ内のデータで応答できます)。

プリキャッシュに保存するレスポンスがブラウザの HTTP キャッシュではなくネットワークから直接取得されるように、sw-precache はリクエストする各 URL にキャッシュ破壊クエリ パラメータを自動的に追加します。sw-precache を使用せず、キャッシュ ファーストのレスポンス戦略を使用している場合は、必ず独自のコードで同様の処理を行ってください。

キャッシュ破壊に対するクリーンなソリューションは、プリキャッシュに使用される各 Requestキャッシュ モードreload に設定することです。これにより、レスポンスがネットワークから確実に取得されます。ただし、執筆時点では、Chrome でキャッシュモード オプションはサポートされていません

ログインとログアウトのサポート

IOWA では、ユーザーは Google アカウントを使用してログインし、カスタマイズしたイベント スケジュールを更新できましたが、後でログアウトする可能性もありました。パーソナライズされたレスポンス データのキャッシュ保存は明らかに難しいトピックであり、常に 1 つの正しいアプローチがあるわけではありません。

オフラインでも個人のスケジュールを表示できることが IOWA の中心的な機能であるため、キャッシュに保存されたデータの使用が適切であると判断しました。ユーザーがログアウトしたときに、以前にキャッシュに保存されたセッション データを確実に消去するようにしました。

    self.addEventListener('message', function(event) {
      if (event.data === 'clear-cached-user-data') {
        caches.open(toolbox.options.cacheName).then(function(cache) {
          cache.keys().then(function(requests) {
            return requests.filter(function(request) {
              return request.url.indexOf('api/v1/user/') !== -1;
            });
          }).then(function(userDataRequests) {
            userDataRequests.forEach(function(userDataRequest) {
              cache.delete(userDataRequest);
            });
          });
        });
      }
    });

追加のクエリ パラメータに注意

サービス ワーカーがキャッシュに保存されたレスポンスをチェックする際は、リクエスト URL をキーとして使用します。デフォルトでは、リクエスト URL は、キャッシュに保存されたレスポンスの保存に使用された URL と完全に一致している必要があります。URL の 検索部分のクエリ パラメータも含みます。

そのため、開発中にトラフィックの発生元を追跡するために URL パラメータを使用し始めると、問題が発生しました。たとえば、通知のいずれかをクリックしたときに開く URL に utm_source=notification パラメータを追加し、ウェブアプリ マニフェストstart_urlutm_source=web_app_manifest を使用しました。以前はキャッシュに保存されたレスポンスに一致していた URL が、これらのパラメータが追加されたときに一致しなかったと表示されていました。

これは、Cache.match() を呼び出すときに使用できる ignoreSearch オプションで部分的に対処できます。残念ながら、Chrome では ignoreSearchまだサポートされていません。サポートされていても、すべて有効かすべて無効かの動作になります。必要なのは、意味のある URL クエリ パラメータは考慮しながら、一部の URL クエリ パラメータを無視する方法でした。

最終的に、sw-precache を拡張して、キャッシュの一致を確認する前に一部のクエリ パラメータを削除し、ignoreUrlParametersMatching オプションで無視するパラメータをデベロッパーがカスタマイズできるようにしました。基盤となる実装は次のとおりです。

function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
    var url = new URL(originalUrl);

    url.search = url.search.slice(1)
    .split('&')
    .map(function(kv) {
        return kv.split('=');
    })
    .filter(function(kv) {
        return ignoredRegexes.every(function(ignoredRegex) {
        return !ignoredRegex.test(kv[0]);
        });
    })
    .map(function(kv) {
        return kv.join('=');
    })
    .join('&');

    return url.toString();
}

お客様への影響

Google I/O ウェブアプリでの Service Worker の統合は、これまでにデプロイされた最も複雑な実際の使用方法であると考えられます。Google が作成したツール sw-precachesw-toolbox と、ここで説明するテクニックを使用して、独自のウェブ アプリケーションを構築するウェブ デベロッパー コミュニティの皆様のご活躍を心よりお待ちしております。Service Worker は、今すぐ使用できるプログレッシブ エンハンスメントです。適切に構造化されたウェブアプリの一部として使用すると、ユーザーにとっての速度とオフラインのメリットが大幅に向上します。