HTML5Experts.jp

Google I/O 2014 ── ServiceWorker でネイティブアプリとの差を縮めよう

今回お届けするのは、Jake Archibald氏とAlex Russell氏によるServiceWorkerのセッション「Appy Times with ServiceWorker – Bridging the gap between the web and apps」です。

Alex氏はService Workers仕様のEditorで、古くはDojoやChrome Frameに携わっています。TC39やW3CのTAGのメンバーとしても活動し、Extensible Web構想を推し進める一人です。 Jake氏はService Workers仕様の「ゴーストオーサー」だそうです。とても面白い人で、今回も彼のユーモアが炸裂、笑いに包まれた楽しいセッションとなりました。

不確かなリクエストの旅を助けるServiceWorker

セッションでは「ブラウザーランド」を舞台に、ブラウザがキャッシュやインターネットからデータを取ってくるまでのやりとりを、Jake氏とAlex氏が演じて説明するという寸劇から、ServiceWorkerの紹介が行われました。

「(AppCacheの森ではなく)HTTPキャッシュに向かい、忘れっぽい魔術師に尋ねるのだ」
「魔術師が知らなかったら?ほら、忘れっぽいわけですし」
「ならば不確かさの海を渡り、子猫が戯れるインターネットに赴くのだ」
「しかし、これまでにも多くのリクエストが戻ってきていません……」

ブラウザがリソースを取得するまでには様々な不確定要因があり、遅い・繋がらないといったことはユーザーエクスペリエンスに影響します。しかし、現在のWeb標準ではそれをコントロールする術が足りておらず、Webの持つ拡張性という良さを持ってないとのこと。これを解決するものとしてApplication Cacheが提案されましたが、宣言的な記法を導入したため、見た目のシンプルさとは裏腹に、制御不能な挙動や仕様の説明不足が問題となりました(こうした既存の技術で説明不可能な挙動は、“magic”と呼ばれてたりもします)。

しかし、Application Cacheが提供する「キャッシュ」「ルーティング」「バージョン」のという概念は、Webプラットフォームにはぜひ欲しい機能です。そこでこの3機能を持ち、その上で拡張性やデバッグのしやすさを取り込んだ仕様として、ServiceWorkerが提案されました。策定にはMozillaやSamsungをはじめ、多くの開発者も加わっており、実装もChromeとMozillaが競うように進めています。

ServiceWorkerを動かす

登録・インストールで準備

ServiceWorkerはWebアプリによって登録(register)され、ServiceWorkerがインストール(install)されて待機状態となります。インストール時にはinstallイベントが発火するので、このタイミングでアプリに必要なファイルをダウンロードできます。

// ServiceWorkerのスクリプト
this.addEventlistener('install', function (event) {
  // event.waitUntil()は中の処理が終わるまで終了を遅らせる
  event.waitUntil(
    // static-v1という名前のついたキャッシュを用意
    caches.create('static-v1').then(function (cache) {
      // キャッシュしたいリソースをCacheオブジェクトに追加
      // 追加すると自動で取得して、全部取得できたらresolveする
      return cache.add(
        '/',
        '/theme.css',
        '/polymer/polyer.js',
        // ...
      );
    })
  )
});

大活躍なES6 Promises

ServiceWorkerは複数のドキュメントから登録され、それらをコントロールします。ドキュメント主導ではなくイベント駆動であり、必要がなくなれば終了します。このため中の処理に動的なものは含められず、非同期処理が前提です。

こうした非同期処理にはコールバックやイベントなどさまざまなやり方がありますが、ServiceWorkerではECMAScript 6で定義されるPromisesというものを使います。Promisesを使うと、非同期の何かが成功した場合(resolveした)、失敗した場合(rejectされた)それぞれに対応する処理を一定のパターンで書けるため、コードの見やすさや処理漏れを防ぐことに繋がると期待できます。

// ES6 Promisesの例
// someAsyncOperation は非同期処理をPromiseオブジェクトとして返す関数
someAsyncOperation()
.then(success) // resolve(成功)時のアクションはthen()の第一引数の関数で処理
.catch(failure) // reject(失敗)時はcatch()内の関数で処理

Promisesについて解説すると、それだけで別な記事ができてしまうので、今回はJake氏によるPromisesに関するHTML5 Rocksの記事JavaScript Promiseの本をお読みください。

fetchでリクエスト・レスポンスに介入

待機状態のServiceWorkerは、リソースの取得(fetch)やServiceWorkerのアクティベート(activate)などのイベントが発火すると動き出します。オフラインアプリにおいては、fetch時にキャッシュの確認や更新・反映や、フォールバックの提供が考えられるでしょう。

this.addEventListener('fetch', function (event) {
  // respondWith()でレスポンスを好きなようにできる
  // 引数にはResponseオブジェクトにresolveされるPromiseオブジェクトをとれる
  event.respondWith(
    // キャッシュがあればまずそれを表示
    caches.match(event.request)
    .catch(function () {
      // キャッシュにないものはふつうに取得する
      return event.default();
    })
    .catch(function () {
      // ネットワークに問題があるなどで取得不可能な場合はフォールバック
      return caches.match('/fallback.html');
    })
  );
}

参考:Cache APIって?

Cache APIは手軽に使えるキャッシュです。キャッシュと言ってもHTTPキャッシュとは完全に分離されており、さらには明示的な指示がない限り削除されない、ドメインを越えて共有されないといった大きな違いがあります。Cacheオブジェクトはcachesオブジェクトに複数格納できます。また、oninstall時のstatic-v1のように名前をつけて、参照しやすくもできます。

Cache APIのキャッシュは、適当なタイミングで消してやらなければいけません。ServiceWorkerは、自身の更新時にactivateイベントを発火します。このタイミングではfetchイベントが発生する前なので、キャッシュの削除はもちろん、データベースのスキーマ更新などに最適とのことです。

キャッシュを賢く使ってよいオフライン体験を

セッションでは、I/OのKeynoteで発表されたPolymerのPaper Elementsによるクイズアプリ「Topeka」に、ServiceWorkerでオフライン対応を施したアプリをデモしながら解説しました。

Topekaでは、クイズの得点を競い合うためのLeaderboard(スコア表)があります。クイズ本体はキャッシュさせたままでもオフライン動作に問題ありませんが、Leaderboardは順位が変動するのでインストール時にキャッシュさせることはできません。

というわけで、Leaderboardへのアクセスを他のアクセスと分けて処理します。URLのチェックには、URL APIを使うと少し楽です。

// こちらServiceWorkerのファイル
var workerURL = new URL(this.url); // URL APIきました

this.addEventListener('fetch', function (event) { var url = new URL(event.request.url); if (workerURL.origin === url.origin && url.path === '/leaderboard.json') { // leaderboardFetchにLeaderboardの処理を分岐 event.respondWith(leaderboardFetch(event.request)); } else { // その他はふつうにキャッシュがあれば使って、なければ取得するフローで event.respondWith( caches.match(event.request).catch(function () { return event.default(); }) ); } });

// 後ろのコードにつづく…

ここでJake氏は、Leaderboardについても常にリクエストするのではなく、キャッシュがあればまずそれを表示し、並行して最新のLeaderboardをリクエストし、取得後に反映するという、ネイティブアプリのオフライン対応でも使われる方法を提案しました。こうすると、新しいLeaderboardをリクエストしている間は画面が真っ白になるといったことがありません。

Leaderboardのキャッシュがあればそれを用い、その後で新しいLeaderboardを取得し置き換える処理を示すフローチャート

これを実装するとなると、キャッシュされているLeaderboardが最新のものかを確かめ、古い場合はキャッシュを更新しなければいけません。一体どうするのでしょうか。これはまずアプリから「キャッシュを使ってほしい」とServiceWorkerに伝えないといけません。

// こちらかわってアプリのJavaScriptコード
var showingLiveData = false;

// ふつうにネットワークから取得するPromiseオブジェクト var liveDataPromise = fetchJSON('/leaderboard.json') .then(updatePage).then(function () { showingLiveData = true; });

// キャッシュを使うようにServiceWorkerに伝えるPromiseオブジェクト // serviceWorker.controllerはページをコントロールするSWを返す if (navigator.serviceWorker.controller) { // (fetchJSON()ってメソッドは仕様にないけど、fetch()に似た何かでしょう) var cachedDataPromise = fetchJSON('/leaderboard.json', { // てきとーなヘッダでタグづけ。これ重要 headers: { 'X-Cache-Only-Please': 'yep' } }) .then(function (data) { if (!showingLiveData) { updatePage(data); } }); }

// セッションのスライドにはなかったので以下勝手に追加 // たぶんこんなコードが入るはず liveDataPromise.catch(function () { return cachedDataPromise })

キャッシュを使わせるよう適当なヘッダをつけてリクエストします。ServiceWorkerはこのヘッダの有無を確かめ、キャッシュがない場合は新しいデータを取得し、キャッシュに追加します。これを処理するのが、ServiceWorkerのスクリプト側にあるleaderboardFetch関数です。

// こちらふたたびServiceWorker
function leaderboardFetch(request) {
  // タグづけされたリクエストの場合は、マッチするキャッシュをそのまま返す
  if (request.headers.has('X-Cache-Only-Please')) {
    return caches.match(request);
  } else {
    // そうじゃない場合はまずstatic-v1なキャッシュを取得し
    return caches.get('static-v1')
    .then(function (cache) {
      // 最新のleaderboardをとってきてキャッシュを更新
      return cache.add(request);
    })
    .then(function (responses) {
      // それだけじゃ何にもならないのでブラウザにも渡す
      return responses[0];
    });
  }
}

ServiceWorkerでいろいろしてみる

主にオフラインWebアプリの解決策として紹介されるServiceWorkerですが、要はクライアントサイドでプロキシを動かすようなものなので、その気になればかなりいろいろできます。

たとえば、新しい画像フォーマットを使うとして、対応してないブラウザではクライアントサイドでPNGに変換して返すなんてことができます。

this.addEventListener('fetch', function (event) {
  // request.contextにはいろいろあります
  // http://fetch.spec.whatwg.org/#concept-request-context
  if (event.request.context === 'image') {
    event.respondWith(
      // リソースを取得し、そのレスポンスのContent-Typeをみる
      event.default().then(function (response) {
        if (response.headers.get('content-type') === 'image/amazing') {
          // 謎メソッドでPNGに変換
          return transcodeToPng(response.body);
        }
        return response;
      })
    )
  }
})

こちらはもっと一般的(?)なユースケースでしょうか。あるクライアントサイドでテンプレートエンジンを動的に適用しその結果を返すといった、フレームワーク依存が強かったところも少し解決されます。

// importScriptsはWeb Workersのやつですね
importScripts('/template-engine.js');

this.addEventListener('fetch', function (event) { var url = event.request.url; if (event.request.context === 'navigation' && /\/article\//.test(url)) { event.respondWith( fetch(url + '.json').then(function (response) { // asJSON()はStreams APIに移動しそうだけど、いまはFetch仕様にて定義 // http://fetch.spec.whatwg.org/#fetchbodystream return response.body.asJSON(); }).then(function (json) { // JSONとれたらテンプレートを呼び出して return caches.match('/template.tmpl').then(function (response) { return response.body.asText(); }).then(function (template) { // テンプレートを適用したものをHTMLにして返す return new Response(renderTemplate(template, json) { headers: { 'Content-Type': 'text/html' } }); }) }) ); } });

ネコ好きのJake氏は、木曜日になったらすべての画像を猫画像に差し替えてしまうコードを紹介して、拍手をもらっていました。

this.addEventListener('fetch', function (event) {
  if (event.request.context === 'image' && new Date().getDay === 4) {
    event.respondWith(
      caches.match('/kitten.jpg');
    )
  }
});

ServiceWorkerはキャッシュ、ルーティング、バージョニングの仕組みが分離さてているので、いろいろ応用がききますね。

また、ServiceWorkerはWeb Workersの拡張なので、postMessage()でメッセージの送受信が可能です。これを使って、「あとで読む」機能の仕組みを作れます。

// こちらアプリ
navigator.serviceWorker.controller.postMessage({
  action: 'read-later',
  articleId: 12345
});

// こちらServiceWorker this.addEventListener('message', function (event) { if (event.data.action === 'read-later') { // articleIdを持つ記事のリソースを取得してキャッシュに追加する } });

普通のJavaScriptとしてデバッグ可能

ServiceWorkerのパワフルさはお分かりいただけたかと思えますが、ほかにもいろいろ利点があるとのこと。まずAppCacheとは違い、JavaScriptコードなので、DevToolsでデバッグできるという点が大きいでしょう。Chromeでは、chrome://inspect/#service-workerschrome://serviceworker-internals/といった、デバッグに便利なページも用意されています。また、現在Chrome Canaryで導入されているDevToolsの新しいエミュレーションモードでは回線状況のシミュレーションができるので、それを組み合わせると遅いネットワークを想定したテストもできるのでおすすめとのことです。このDevToolsの新しいエミュレーション機能については、Tomomi ImuraさんによるPaul Bakaus氏のセッションレポートで紹介されていますのでお読みください。

少し不便な点といえば、MitM attackを防止するためにHTTPSで提供されたページでしか動作しないことでしょうか。ただ、手元での開発への影響を抑える目的でlocalhostはその例外として機能するとのこと。

ネイティブとの差を埋めるためには

セッションのタイトルには、「Bridging the gap between the web and apps(『Webとアプリの差を埋める』)」とあります。キャッシュやルーティングによって必要なリソースをインストールさせてオフラインでも動作させるのも必要ですが、ネイティブアプリにあってWebにはまだ足りていないものがまだまだあるとのこと。その一例としてJake氏はプッシュやバックグラウンド同期を挙げ、それに対応するWeb標準(Background Sync, Push API, Web Notifications)を紹介しました。

Background Sync以外は策定中で、Notifications仕様については実装もあります。しかし、ServiceWorker対応はこれから。というわけでTopekaでどのようにServiceWorkerと同期・プッシュ・通知が実装されているかを紹介しました。Leaderboardのスコアを同期し、トップを奪われたら通知が来るという機能の実装です。

まずは同期です。ServiceWorkerがインストールされているかを確認し、スコアの同期をとります。

// app.js
sendScore().catch(function () {
  // serviceWorker.readyは常にresolveするPromiseを返す
  navigator.serviceWorker.ready.then(function () {
    // 同期はnavigator.syncに登録するらしいです
    navigator.sync.register('send-score')
    .then(success, failure);
  });
});

この同期は1回きりで終了しますが、特定の間隔で同期するタスクも登録。例として、1時間ごとに質問の更新をするタスクが紹介されました。

// app.js
navigator.serviceWorker.ready.then(function () {
  navigator.sync.register('update-questions', {
    minInterval: 1000 * 60 * 60
  }).then(success, failure);
});

minIntervalはあくまでヒントとして伝えられるだけのようで、ブラウザがもっと賢くスケジューリングするのも構わないと説明していました。

さて、ユーザーが同期を許可すると、ServiceWorkerにsyncイベントが伝えられます。複数の同期をスケジュールしていたので、イベントの idプロパティで処理を振り分けます。

// sw.js
this.addEventListener('sync', function (event) {
  if (event.id === 'update-questions') {
    event.waitUntil(syncQuestions());
  }
  else if (event.id === 'send-score') {
    event.waitUntil(sendScore());
  }
});

渡されたPromiseオブジェクトがrejectされた場合は、同期が再スケジュールされるとのことなので、また登録する必要はないとのこと。

スコアを送信する sendScore() 関数は、データベースからスコアを取得し、それをPOSTします。

// sw.js
function sendScore() {
  return getScoreFromDatabase().then(function (score) {
    // Fetch APIはもちろんPOSTもできます
    return fetch('/post-score', {
      method: 'POST',
      body: new FormData({score: score})
    })
    .then(function (response) {
      if (response.status !== 200) {
        throw Error('Failed to post score');
      }
    });
  });
}

プッシュメッセージについては、Google Cloud Messagingを使った実装でのやり方が紹介されました。

// app.js
var regID = '...'; // サーバーに送った登録ID

navigator.serviceWorker.ready.then(function () { navigator.push.register().then(function (regDetails) { if (regDetails.id !== regID) { return postToServer(regDetails); } }).then(success, failure); });

GCMでは、プッシュの登録が完了すると、以前の登録したプッシュの詳細が返ってくるそうで、今回処理しようとしているものと同じでないかを確認してから、サーバーにメッセージを送っています。

ServiceWorkerでは、pushイベントが通知されます。これを拾ってNotification APIで通知を出します。

// sw.js
this.addEventListener('push', function (event) {
  if (event.message.data === 'lost-lead') {
    new Notification('Lead lost!', {
      body: 'You\'re no longer top!',
      tag: 'leaderboard',
      serviceWorker: true
    });
  }
});

デモでは2つのNexus 5を用意し高得点を競い、トップを奪われた方に通知が届きました。また、Android Wearでもその通知が表示されました。

大規模なカンファレンスにつきものなネットワークの悪さもあって、通知が来るまでに時間がかかりました。みんなそわそわしましたが、「オフラインWebアプリでも問題ないセッションが、デモのせいで失敗するとか…!」「ふー、このセッション自体も非同期とは」といったオフライン・非同期ジョークなどが飛び出し、会場を和ませました。

まだまだあるよServiceWorker

セッションで紹介されたServiceWorkerの機能は以上です。盛りだくさんでした。

このセッションでは、ServiceWorkerの更新については語られませんでした。これについてはJake氏がService Worker – first draft publishedという記事で紹介しています。

ServiceWorkerの実装は少しずつ進んでいますが、仕様の議論も並行しているため、今回紹介したコードが動かなくなる可能性は十分にあります。何かアップデートがあればまたServiceWorkerに関する記事が公開されるかもしれませんので、楽しみにしていてください。