HTML5Experts.jp

超詳解!Service Worker Deep Dive ── HTML5 Conference 2016セッションレポート

はじめまして。GoogleでChromeの開発をしている保呂毅です。
Chromeの中では特にService Worker周りを担当してまして、最近はNavigation Preloadという新機能をがんばって実装しています。 先日開催されたHTML5 Conference 2016でService Worker周辺の最近(ここ1年くらい)の動向に関する発表をさせていただいきました。
今回は、この発表の内容を振り返りたいと思います。

Service Workerとは

まず本題に入る前に簡単にService Workerの説明します。 Service Workerとはどういうものかと言いますと、 下のコードのようにnavigator.serviceWorker.registerというAPIで登録する、バックグラウンドで動作するJavaScript実行環境です。

navigator.serviceWorker.register('./sw.js', {scope: './'});

登録されたServiceWorkerでは、ページからのネットワークリクエストを横取りすることができます。 下のコードでは、ページからのネットワークリクエストを横取りしてHello Worldという文字列をページに返しています。

self.addEventListener('fetch', event => {
  event.respondWith(new Response('Hello World!'));
});

また、ページからのネットワークリクエストの横取りをする以外にも、ページを開いていなくても Push通知を受け取って Notification (通知)を表示する、Push Notificationsという機能も備えています。

Service Workerは新しいAPIでして、まだ利用できるブラウザが限られています。 Is ServiceWorker ready?というページに各ブラウザの対応状況が書かれています。ChromeとFirefox、Opera、Samsungのブラウザは対応していて、Edgeは開発中、Safariは検討中となっています。

Push Notifications

Push Notificationsの仕組み

Push Notificationはどういう仕組みかといいますと、WebサービスのサーバーからFCM (Firebase Cloud Messaging)のサーバーにメッセージをポストすると、それがブラウザに届いて通知を表示できるというものです。FCMは、最近GCM (Google Cloud Messaging)から名前が変わりました。

Push Event

self.addEventListener('push', event => {
event.waitUntil(
self.registration.showNotification( 'Hello', { body: 'We have received a push message.', icon: 'message.png', tag: 'tag' }));
});

このコードはService Worker上のPushイベントハンドラです。WebサービスのサーバーからFCMのサーバーにPOSTリクエストすると、このイベントハンドラが呼びだされます。このコードでは、ServiceWorkerRegistrationshowNotification というAPIで下のような通知を表示しています。 タイトルが’Hello’で中身が’We have received a push message.’で、アイコンにmessage.pngを指定しています。

Notificationclick Event

self.addEventListener('notificationclick', event => {
  event.notification.close();
  clients.openWindow('/messages');
});
このコードはNotificationClickイベントハンドラで、先ほどの通知をクリックした際の処理を書いています。このコードでは、通知を閉じて、messagesというページをopenWindowで開いています。

Payload Data

ここまでの機能は2015年4月にリリースされたChrome 42から使えます。 ところが、Chrome 49までは、Pushメッセージにデータを含めることができないという制限がありました。例えばチャットアプリなどでチャットの内容を通知に表示する場合は、Fetch APIを使ってWebサーバーに問い合わせる必要がありました。 それが、今年の4月にリリースされたChrome 50からは、Pushメッセージ自体に暗号化したデータを含めることができるようになりました。

self.addEventListener('push', event => {
  if (event.data) {
    console.log(event.data.json());
  }
});

Service Worker側ではこちらのコードのようにPushEventにdataというプロパティがついて、データを読めるようになります。 サーバーからどう送信するかの詳細はこちらのURLで説明されているので、興味のある方はぜひ読んでみてください。Node.js用のライブラリ Web Push library for Node.js もありますので、Node.jsをサーバーで使っている方はこちらを使うといいかと思います。

Notification Actions

続きまして、Chrome 48から使える機能、Notification Actionsです。この機能を使うと上の絵のように、ボタンを追加できるようになります。

self.registration.showNotification(
  'Hello',
  {
    body: 'We have received a push message.',
    icon: 'message.png',
    tag: 'tag',
    data: 1234,
    actions: [
      {action: 'like', title: 'Like', icon: 'like.png'},
      {action: 'reply', title: 'Reply', icon: 'reply.png'}]
  });
コードを見ると、Actionsという項目で指定しているのが分かるかと思います。各ボタンに対応する、actionの文字列と、titleの文字列と、iconの画像を指定しています。

Notificationclick Event

このボタンをクリックすると、NotificationClickイベントハンドラが実行され、NotificationClickEventのactionを見ることで、どのボタンがクリックされたのかがわかります。

self.addEventListener('notificationclick', event => { 
  var messageId = event.notification.data;
  event.notification.close();
  if (event.action == 'like') {
    silentlyLikeItem();
  } else if (event.action == 'reply') {
    clients.openWindow('/messages?reply=' + messageId);
  } else {
    clients.openWindow('/messages?reply=' + messageId);
  }
});
このコードの場合、likeがクリックされたらsilentlyLikeItem()というメソッドを呼び出しています。Replyがクリックされた場合は、該当するメッセージのページを開いています。ボタン以外の部分がクリックされた場合も同様です。 ちなみに、メッセージを表示するURLのメッセージIDにnotification.dataの値を使っていますが、こちらはさきほどの通知を表示する際に指定したdataが渡ってきています。 この値は、Pushイベントのdataから取得するといいかと思います。

Standard Web Push Protocol

Chrome 51までは、Web Pushを実現するためには、以下のような手順が必要でした。この認証方法はFCM独自のプロトコルです。

  1. 事前にFCMに登録をしてgcm_sender_idというIDを取得
  2. manifest.jsonにそのIDを記述
  3. FCMサーバーへのPOSTリクエストの際にAuthorizationヘッダにAPIキーを指定する

ところが、Chrome 52からVoluntary Application Server Identification (VAPID) という標準化されたプロトコルでサーバー認証することでFCMへの事前登録が必要なくなりました。 詳しくはこちらのURLを御覧ください。

Stream

サーバーにFetchリクエストをして、帰ってきたレスポンスに手を加えてページに返すService Workerを考えます。

Chrome 51までは、このような処理をした場合、上のアニメにあるようにレスポンスを最後まで読んでからでないと、ページに返すことができませんでした。 しかし、今年の7月にリリースされたChrome 52から、Streamの新しい機能を使うことで、サーバーからのレスポンスを逐次処理しながらページに返すことができるようになりました。

具体的な例で解説します。

ページのヘッダとフッタを事前にキャッシュに入れておいて、ページを読み込む際はコンテンツだけをサーバに問い合わせて、Service Worker内で文字列連結してページに返す。ということを考えます。

self.addEventListener('install', event => {
    event.waitUntil(
      caches.open('cache_name')
        .then(cache => cache.addAll(['./header.txt',
                                     './footer.txt'])));
});

まず、ヘッダとフッタをキャッシュに入れるには、Installイベントハンドラで上のコードのようにCacheStorage APIを使います。この例ではheader.txtfooter.txtをキャッシュに保存しています。 caches.open()でキャッシュを開き、取得したcacheに対して、cache.addAll()を呼んでヘッダとフッタをサーバーから取ってきて保存しています。

self.addEventListener('fetch', event => {
  var url = event.request.url;
  if (!url.endsWith('.html'))
    return;

event.respondWith( Promise.all( [caches.match('./header.txt'), fetch(url + '.txt'), caches.match('./footer.txt')]) .then(responseList => Promise.all(responseList.map(res => res.text()))) .then(textList => new Response(textList.join(''), {headers:[['content-type', 'text/html']]}))); });

次に、Fetchイベントハンドラで、先ほど保存したヘッダとフッタをキャッシュから取り出して、サーバーから取得したコンテンツと文字列連結します。 先ほど保存したheader.txtfooter.txtをキャッシュから読み込みつつ、Fetch APIで元のURLに”.txt”をつなげたURLにネットワークリクエストを投げています。 そして、それらすべてのレスポンスから、text()でコンテンツ本文を取得し、その後、joinで連結しています。 これで目的通り、ヘッダとフッタを連結してページに返すことができるのですが、問題があります。 先ほど書いたとおり、サーバーからのコンテンツ全体を取得完了するまでページに返せないのです。 そこで、解決方法がStreamです。Streamを使えば、サーバーから取得したコンテンツを徐々にページに返していくことができます。

self.addEventListener('fetch', event => {
    var url = event.request.url;
    if (!url.endsWith('.html'))
      return;

var stream = new ReadableStream({
    【次コード参照】
});

event.respondWith(new Response(
    stream,
    {headers:[['content-type', 'text/html']]}));

});

具体的にコードを見ていきましょう。 Installイベントハンドラは先ほどと同じです。 Fetch イベントハンドラは上のコードのようになります。 ReadableStreamという新しいクラスが登場します。中身は下で説明しますが、ここでReadableStreamをつくって、それをResponseオブジェクトを作る際に渡し、respondWith()でレスポンスをページに返しています。

var stream = new ReadableStream({
start(controller) {
  var promises = [caches.match('./header.txt'),
                  fetch(url + '.txt'),
                  caches.match('./footer.txt')];
  function pushStream(body) {
    var reader = body.getReader();
    return reader.read().then(function proc(result) {
      if (result.done)
        return;
      controller.enqueue(result.value);
      return reader.read().then(proc);
    });
  }
  promises[0]
    .then(response => pushStream(response.body))
    .then(() => promises[1]).then(response => pushStream(response.body))
    .then(() => promises[2]).then(response => pushStream(response.body))
    .then(() => controller.close());
}});
ReadableStreamの中身は、このようになります。 start()というメソッドの中でまず、header.txtfooter.txtのキャッシュからの読み込みと、サーバへのFetchリクエストのPromiseを作っています。 下の方で、まず、ひとつ目のPromise、つまりHeader読み込みのPromiseからレスポンスを取得し、そのレスポンスボディをpushStream()という関数に渡しています。 pushStream()ではbodyからreaderを取得して、順番に読んでいって、controller.enqueue()で詰め込んでいきます。 それが終わったら2つ目のPromise、つまりFetchのPromiseからレスポンスを取得して、あとは同様にbodyの中身を徐々にenqueueしていきます。その後、3つ目のPromise、つまりfooter.txtのPromiseも同様です。最後に、controller.close()を呼んでStreamを閉じています。

このようにすると、サーバーからのレスポンスを最後まで待たずに、徐々にページにデータを返していくことができます。

Unified Media Pipeline

実は、Android Chromeは51まで、audioエレメントやvideoエレメントではAndroidのメディアスタックを利用していました。それが、7月にリリースされたChrome 52からデスクトップ版Chromeと共通化されました。 そのおかげで、

といったことができるようになり、クロスデバイスの開発が容易になりました。 詳細はこちらのURLを参照してください。

Background Sync

Background Syncはどういうものかと言いますと、その名の通り、バックグラウンドでデータを送受信できる機能です。 例えば、「オフライン時にメッセージを書いて、オンラインになったときに自動で送信」といったことができるようになります。AndroidだとChromeアプリを閉じていても動作します。 この機能はChrome 49 から利用可能です。

ちょうどいい動画がYouTubeにあったのでこちらをご覧ください。 この動画は、Service Workerを使ってオフラインでもWikipediaの記事を読むことができるというWebアプリのデモです。このアプリで、キャッシュに入っていない記事を読もうとしたら、エラーになるのですが、Background Syncを使ってオンラインになった時に自動で記事をダウンロードし、ダウンロードが完了したら先ほど紹介したNotificationのAPIで通知を表示しています。

Foreign Fetch

普通の Fetchイベント

Foreign Fetchの説明をする前に通常のFetchイベントを復習します。 a.comのサイトにService Workerが登録されているとします。 この場合、

これらはすべて、Service WorkerのFetchイベントハンドラで横取りすることができます。

しかし、例えば、b.comのサイトのページからa.com上にある画像などのサブリソースへのリクエストはFetchイベントハンドラで横取りすることができません。

Foreign Fetchを使うと

これを可能にするのがForeign Fetchです。 Foreign Fetchを使うと、他のサイト(この図の場合はb.com)からの自分のサイト(この図の場合はa.com)へのサブリソースのリクエストをa.comのService Workerで横取りすることができるようになります。

普通のFetchイベント vs Foreign Fetchイベント

まとめますとこうなります。 「ネットワークリクエストを横取りする」という点は共通です。 相違点としては、 普通のFetchイベントは自分のサイトのページからのネットワークリクエストを横取りします。自分のサイトのHTMLページと、そこからの画像などのサブリソースリクエストも横取りできます。

それに対してForeignFetchイベントでは、他のサイトから自分のサイトへのサブリソースのリクエストを横取りすることができるようになります。Web FontサーバやCDN等で活用できるのではと考えられます。

Foreign Fetch の登録方法

具体的にコードを見ていきましょう

self.addEventListener('install', event => {
    event.registerForeignFetch({
        scopes: ['/myscope/'],
        origins: ['https://b.com/']
      });
  });
Foreign Fetchの登録方法ですが、先程の例のように、a.com上のService Workerが、 b.com からの a.com/myscope/ 以下へのリクエストを横取りしたい場合は、Installイベントハンドラで上のコードのようにregisterForeignFetch()というAPIを叩きます。 scopesに横取りしたいリクエストの範囲、originsにリクエスト元のオリジンを指定します。すべてのオリジンからのリクエストを横取りしたい場合は、originsにアスタリスクを指定します。

上のコードのようにForeign Fetchを登録すると、 a.com/myscope/ 以下へのネットワークリクエストの度にForeignFetchイベントハンドラが実行されるようになります。

Foreign Fetch Event

ForeignFetchイベントハンドラでは普通のFetchイベントと同じようにrespondWith()でページにレスポンスを返すことができるのですが、普通のFetchイベントと違って、辞書に包む(以下のコードの{response: res})必要があります。

self.addEventListener('foreignfetch', event => {
    event.respondWith(
        fetch(event.request).then(res => ({response: res}));
  });
注意点として、このようにしてページに返したレスポンスはページではOpaqueになります。つまり、<img>タグで画面に表示はできるのですが、JavaScriptから中身を読むことができません。 これを読めるようにするにはCORSの設定が必要になります。

CORS (Cross-Origin Resource Sharing)

self.addEventListener('foreignfetch', event => {
  event.respondWith(
     fetch(event.request)
       .then(response =>({response: response,
                          origin: event.origin,
                          headers: ['...']})));
});
具体的にはこのように、respondWith()に渡す辞書でページのoriginを指定します。headersを指定すると、指定したHTTPヘッダもページ側で読めるようになります。

このForeign Fetchですが、まだ実験中の機能でして、試す場合は、Chrome 54以降でchrome://flagsのenable-experimental-web-platform-featuresを有効にしてください。

Header-based Installation

今まで、Service Workerを登録する場合、JavaScriptでnavigator.serviceWorker.register()を呼ぶ必要がありました。

navigator.serviceWorker.register('/sw.js', {scope: '/'});

Header-based Installationを使うと,これが、下のようなHTTPのLink Headerで登録したり、

Link: </sw.js>; rel=serviceworker; scope=/
下のような、HTMLのLink Elementで登録できるようになります。
<link rel="serviceworker" scope="/" href="/sw.js">

ここでポイントとなるのが、サブリソースのリクエストに対するHTTPレスポンスのLink HeaderでもService Workerをインストールできるということです。この機能を使うと、Iframe等でHTMLを読み込ませる必要なく、Foreign FetchのService Workerをインストールできます。CDNなどでサブリソースをサーブしている場合でもCDNのドメインに対してForeign FetchのService Workerを登録できるようになるのです。

この機能もForeign Fetchと同様に実験中の機能でして、試す場合は、Chrome 54以降でchrome://flagsのenable-experimental-web-platform-featuresを有効にしてください。

Origin Trials

Origin TrialとはChromeの実験的な新しいAPIを、申請のあった特定のオリジン(ドメイン)で一定期間だけ使ってもらってフィードバックをもらう仕組みです。 いま(2016年10月現在)はPersistent Storage, Web Bluetooth, Web USB, Foreign Fetch (Header-based Installation含む)が対象になっています。

申請の方法など詳しくはこちらのURLを参照してください

AppCache

Application Cache (通称AppCache)という、Webサイトのオフライン対応のためにかつて提案された機能があります。

しかし、FirefoxはApplication Cacheの非サポート化を明言してまして、Chromeも非セキュアなコンテクストではAppCacheをサポートしなくするとしています。 ですので、Webサイトのオフライン対応はAppCacheではなく、Service Workerを使おうという流れにあります。

Service Workerに移行する際の参考として紹介したいのが、sw-appcache-behaviorというAppCacheの動作を Service Workerを使ってシミュレーションするライブラリです。 もし興味がありましたら、このURLを参照してください。

DevTools

DevToolsは進化が早くてよくUIが変わるのですが、今は、”Application”というタブの中にService Workersというのがありまして、こちらでService Workerの状態を確認したり、デバッグしたりすることができます。

この画面で注目していただきたいのが、このメッセージです。 Service Worker termination by a timeout timer was canceled because DevTools is attached. このメッセージは最近表示するようにしたのですが、「DevToolsがアタッチされてるので、タイムアウトによるService Workerの停止がキャンセルされました」と書かれてます。

Service Workerは一定期間イベントが実行されないと停止し、次回イベントで再起動するようになっています。ただし、DevToolsを開いている間は停止しません。 つまり、通常の状態だと、しばらくほっておくと勝手に停止し、FetchEventなどのイベントハンドラを呼ぶ必要が出ると再起動します。 ですので、Globalスコープに変数を置いて保存していると、再起動時に消えるので、Service Workerのコードを書く際は気をつけてください。

まとめ

駆け足になりましたが、Service Worker周辺の最近(ここ1年くらい)の動向について説明いたしました。下に関連リンクを貼り付けていますので、もっと詳細を確認したい方はご参照ください。

関連リンク集

Introduction to Service Worker

Push

Stream

Unified Media Pipeline

Background Sync

Foreign Fetch

Header-based installation

Origin Trials

Web Bluetooth

WebUSB

当日の講演資料と動画は下記で公開されていますので、こちらも参照してください。