本番環境の Service Worker

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

概要

Service Worker ライブラリを使用して、Google I/O 2015 ウェブアプリを高速かつオフライン ファーストにした方法をご覧ください。

概要

今年の Google I/O 2015 ウェブアプリは、Google のデベロッパー リレーションズ チームが作成し、優れた音声/映像テストを執筆した Instrument の友人によるデザインに基づいています。私たちのチームの使命は、I/O ウェブアプリ(IOWA というコードネームで呼ばれるもの)が、最新のウェブでできることをすべて紹介できるようにすることでした。オフラインファーストのエクスペリエンスは、必須機能リストの最上位に位置していました。

このサイトの他の記事を最近ご覧になった方なら、Service Worker に出会ったことは間違いなく、IOWA のオフライン サポートが Service Worker に大きく依存していることに驚かないでしょう。IOWA の実際のニーズを受けて、Google は 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 ハッシュを持つ既存のリソースはそのまま残ります。つまり、以前サイトにアクセスしたユーザーは、変更されたリソースの最小セットをダウンロードするだけなので、キャッシュ全体が期限切れになった場合よりもはるかに効率的に使用できます。

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

sw-toolbox、さまざまなニーズに応えます

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

sw-toolbox 上に構築したカスタム リクエスト ハンドラの例をいくつか示します。スタンドアロンの JavaScript ファイルを Service Worker のスコープに pull する sw-precacheimportScripts parameter を使用して、これらをベース 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 リクエストを介してバックエンド サーバーに対して行われました。そのため、ユーザーがオフラインのときに、状態変更リクエストを処理する最適な方法を検討しました。そこで、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 アナリティクス のインサイトが犠牲になるわけではありませんGoogle アナリティクスのバックエンドに適切なイベント アトリビューション時間が確実に届くように、キューに登録された各リクエストに qt パラメータが追加され、リクエストの最初に試行されてからの経過時間が設定されています。Google アナリティクスでは、最大 4 時間までの qt の値を公式にサポートしているため、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 を介して、アプリケーションのシェルに積極的なキャッシュ ファースト戦略を実装しました。つまり、Service Worker はページ上の 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 を会議の毎日のバックエンドで動的に更新する必要があるため、I/O 中にこの動作で問題が発生しました基盤となるテンプレート ファイルは静的で変更されていないため、Service Worker の更新フローはトリガーされませんでした。また、YouTube 動画を更新する場合のサーバーからの動的レスポンスは、多くのユーザーのキャッシュされたレスポンスになってしまいました。

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

プレキャッシュ リクエストのキャッシュ無効化

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

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

キャッシュ無効化のよりクリーンな解決策は、reload への事前キャッシュに使用される各 Requestキャッシュ モードを設定することです。これにより、レスポンスがネットワークから確実に送信されます。ただし、この記事の執筆時点では、キャッシュ モード オプションは Chrome ではサポートされていません

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

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

オフラインであっても、個人のスケジュールを表示することが IOWA のエクスペリエンスの中核であるため、Google はキャッシュ データを使用することが適切であると判断しました。ユーザーがログアウトしたときは、以前にキャッシュされたセッション データを必ず消去しました。

    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);
            });
          });
        });
      }
    });

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

Service Worker は、キャッシュに保存されたレスポンスを確認するときに、リクエスト URL をキーとして使用します。デフォルトでは、リクエスト URL は、キャッシュに保存されたレスポンスの保存に使用される URL と完全に一致する必要があります。これには、URL の検索部分のクエリ パラメータも含まれます。

そのため、開発中に URL パラメータを使用してトラフィックの流入元をトラッキングし始めた際に、問題が生じました。たとえば、通知をクリックしたときに開く URL に utm_source=notification パラメータを追加し、ウェブアプリ マニフェストstart_urlutm_source=web_app_manifest を使用しました。以前にキャッシュに保存されたレスポンスと一致した URL が、パラメータが追加されたときにミスとして認識されていました。

これは、Cache.match() を呼び出すときに使用できる ignoreSearch オプションで部分的に対処できます。残念ながら、Chrome は ignoreSearchまだサポートしていません。サポートしている場合でも、これはオール オア ナッシングの動作です。そこで必要なのは、一部の 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 は、今すぐ使用を開始できる段階的な拡張機能です。適切に構造化されたウェブアプリの一部として使用すると、ユーザーにとって速度とオフラインのメリットが大きくなります。