2015.03.26

Service Worker で進化する Cache


フロントエンド大好き D.M. です。最近ようやく Chrome で使えるようになってきた Service Worker について紹介します。

Service Worker とは?

Service Worker はブラウザ内でバックグラウンドで動作する Web プロキシです。これまでブラウザが内部でやっていたことをもっと低レベルなAPIで操作できるようになります。
基本情報を要点だけ書いていきます。

できること

・リクエストを横取りできる。
・リクエスト:レスポンスをキー:バリューでキャッシュできる。
・オフラインで動作できる。
・サーバから PUSH 通知を受信できる。
・バックグラウンドでコンテンツを同期できる。

できないこと

・ HTTP 通信ができない。(通信を横取する仕組みのためHTTPS限定。HTTPの場合、通信を他人にハイジャックされると改ざんされてしまうリスクがある。)
・ DOM 操作はできない。(あくまでバックグラウンド処理。)

実際想定されているユースケースは以下のようなものがあります。
・1度取得したコンテンツをキャッシュする。
・コンテンツをバックグラウンドで同期する。
・必要なリソースを先読みする。
・Ajax 通信のモックテスト
・通信をプロキシしてバリデーションチェック

Mozzila の公開してる仕様です。ここの情報が現状最も詳細です。

<参考URL>
Service Worker API
https://developer.mozilla.org/ja/docs/Web/API/ServiceWorker_API

各ブラウザ対応状況は以下のサイトでわかります。例によって Chrome 先行、続いて Mozzila 、IE と Safari は沈黙といった感じ。

<参考URL>
is SERVICEWORKER ready?
https://jakearchibald.github.io/isserviceworkerready/


Cache API の利用


新しい概念なので、説明だけではよくわかりません。早速実例に挑戦します。 HTML5Rocks の記事をそのままなぞって Cache API を利用してみます。実装ではこちらのコードを拝借しております。最近同じようなやってみた系の記事がチラホラ出てきたのでコンテンツとしては月並みですが、実際やってみたら結構新しい発見がありました。

<参考URL>
Service Worker の紹介 – HTML5 Rocks
http://www.html5rocks.com/ja/tutorials/service-worker/introduction/

やりたいこと

・Cache API を利用して事前にキャッシュを作る。
・リクエストを横取し、キャッシュにして、次回のリクエストからはそれを返す。

Service Worker の Cache API とブラウザキャッシュの違い

通常のブラウザにはブラウザキャッシュの機能があります。これはブラウザが自動的に行っていました。またブラウザごとに仕様の統一もありませんでした。Service Worker では Cache API を利用して開発者が自分自身の方法でキャッシュを実装することができます。

Service Worker の Cache API と AppCache の違い

HTML5 には AppCache というキャッシュの仕組みがありましたが、ほとんど誰にも使われていません(正直使いづらかった)。 Service Worker では Cache API を使って AppCache の内部で行われていたようなことを自由に実装できるようになりました。低レベルなレイヤーの API が使えるようになったことで、 AppCache が使いづらいと感じていた人でもカスタマイズすることができます。

Service Worker 動かしてみた!


9199.jp のテストページにキャッシュを仕込んでみました。駅の周辺検索の都道府県セレクトボックス選択時にキャッシュを利用します。 Service Worker の仕様上 HTTPS 限定です。

https://secure.9199.jp/js-test/service-worker-test.html


コードの勘所


Service Worker のライフサイクルに応じて発生するイベントごとに処理が記述されます。今回はキャッシュの処理が各イベントごとにどう行われているかがポイントとなります。

Install イベント

ServiceWorker がブラウザに登録されたときに呼ばれます。主にキャッシュ API の初期設定を行います。

activate イベント

新しい ServiceWorker がページをコントロールし始めるときに呼ばれます。主に古いキャッシュの削除を行います。

fetch イベント

ページで HTTP リクエストが発行されたときに呼ばれます。キャッシュを返すかどうかの判定処理を行います。


sw-lifecycle
Service Worker の紹介 – HTML5 Rocks から引用

初めて動作をみるときは以下の機能が重宝します。必ず参照しましょう。
chrome://serviceworker-internals
blog_service-worker-1st-time

上の画面でチェックボックスを ON にすると、 Service Worker 用の DevTools が1つ立ち上がりログを追いやすくなります。こちらも立ち上げ必須です。

blog_service-worker-devtools


呼び出し元HTML側のコード


まずは呼び出し元HTML側のコードです。やっていることは以下の点です。

・ navigator.serviceWorker.register() で Service Worker を登録します。これはどのサンプルでも見られる基本的な実装です。
・ navigator.serviceWorker.getRegistration() にて、 Service Worker が登録されていないか確認し、いた場合は削除する処理をしています。(毎回更新をするという挙動を考えて試しに書いてみました。実際の運用に乗るかは要検討)
・ 2つの Service Worker を異なるスコープで動作させています。 1 ページに 2 つ動かすことができました。逆にスコープが同じ場合は上書きされているように見えます。

<script>

var SCOPE = '/';
var SCOPE2 = '/common/';

//APIの存在を確認します。
if ('serviceWorker' in navigator) {
  //現状登録されている Service Worker の取得します。
  navigator.serviceWorker.getRegistration( SCOPE ).then( function( reg ) {
    console.log( "serviceWorker.getRegistration start" );
    if( reg ){
      console.log( reg );
      //登録されている Service Worker があれば削除します。
      reg.unregister();
    }
    // Service Worker を新規に登録します。
    navigator.serviceWorker.register('/service-worker-test.js',{scope: SCOPE }).then(function(registration) {
      console.log(registration);
      console.log('ServiceWorker1 registration successful with scope: ', registration.scope);
    }).catch(function(err) {
      console.log('ServiceWorker1 registration failed: ', err);
    });
  });
  //現状登録されている Service Worker の取得します。上とはスコープを変えています。
  navigator.serviceWorker.getRegistration( SCOPE2 ).then( function( reg ) {
    console.log( "serviceWorker.getRegistration start" );

    if( reg ){
      console.log( reg );
      //delete every time
      reg.unregister();
    }
    // Service Worker を新規に登録します。上とはスコープを変え、JSも変えています。
    navigator.serviceWorker.register('/service-worker-test2.js',{scope: SCOPE2 }).then(function(registration) {
      console.log('ServiceWorker2 registration successful with scope: ', registration.scope);
    }).catch(function(err) {
    console.log('ServiceWorker2 registration failed: ', err);
  });
  });
}
</script>

ヴァージョン 2 の JS をサーバに置いた際の更新挙動


新しい service-worker-test.js のヴァージョン 2 をサーバに置くと、次のリクエストで JS の取得⇒ Install イベントが発生します。ただ、前のヴァージョンの Service Worker が動作していると Activate イベントが発生しません。ページをリロードしてもまだ前のヴァージョンが動作しています。ブラウザのタブを閉じてページを開きなおすとヴァージョン2が動作を始めました。
確実に更新させるためにヴァージョン 2 がインストールされたタイミングでヴァージョン 1 を terminate するような実装をしようかと思いましたがいいサンプルが見つかりませんでした。 Service Worker がコントロールしているページがある限り古いバージョンが残る仕様のようです。ここはうまい更新のフローを理解しておかないと問題になりそうです。
現状、更新を意図したと思われる registration.update(); が Chrome 40 から利用可能と記述がありましたが、 navigator.serviceWorkerRegistration を console.log() で参照してみると update() は存在せず、実行できませんでした。逆に今回は unresigiter() がありましたので、強制的に毎回更新させるような処理を書いています。

以下の画像で、新しいほうの Service Worker は Install までされるがそれ以降の進まない状況が確認できます。ページが閉じられると次回から新しいほうが Activate されます。
blog_service-worker-not-acvivate

Service Worker 内部の挙動


次は本体である service-worker-test.js の実装です。このJSがバックグラウンドプロキシとして動作しています。
キャッシュ対象となるコンテンツは画面上では都道府県北海道または青森を選択したときに取得される駅一覧 JSON ファイルと、 React の JSXTransformer.js です。

//chrome 41 では cache.add, cache.addAll がまだ存在していないのでライブラリを使う
importScripts('/js-test/serviceworker-cache-polyfill.js');

//キャッシュ名。ファイルのバージョンが変更になった場合ここを変えることで制御する。
var CACHE_NAME = 'json-cache-v1';

console.log(CACHE_NAME);

//キャッシュ対象のファイル。
var cachedUrls = [
  '/common/json/station/line_ie/1.json',
  '/common/json/station/line_ie/2.json',
  '/common/js/react/react-0.12.2/JSXTransformer.js'
];

//インストールイベント発生時に呼ばれる。
self.addEventListener('install', function(event) {
  console.log('インストール成功!');
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log( 'キャッシュ対象に加えます!' );
        console.log( cache ) ;
        try{
            //この段階でサーバにリクエストを発行して事前にキャッシュを生成しています。
            cache.addAll( cachedUrls );
        }catch(e){
            console.log( e );
        }
        return cache;
      })
  );
});

//アクティベートイベント発生時に呼ばれる。
self.addEventListener('activate', function(event) {
  console.log(  "アクティベート!");
  //キャッシュ対象変更時、古いヴァージョンのキャッシュを削除する。
  var cacheWhitelist = [ CACHE_NAME ];
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            console.log("キャッシュ削除");
            console.log(cacheName);
            //キャッシュ削除。
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

//フェッチイベント発生時に呼ばれる。
self.addEventListener('fetch', function(event) {
  
  console.log( 'リクエストをフェッチしました。');
  //console.log( event.request.url ) ;
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        console.log( 'キャッシュの検証をします。。' ) ;
        if (response) {
           console.log( 'キャッシュがあったのでレスポンスはキャッシュから返す!' );
           console.log( response ) ;
           return response;
        }

        var fetchRequest = event.request.clone();

        return fetch(fetchRequest , { mode: 'no-cors' })
          .then(function(response) {
            console.log( "フェッチリクエストで、レスポンスの精査をします" );
            // レスポンスが正しいかをチェック
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            // 重要:レスポンスを clone する。レスポンスは Stream で
            // ブラウザ用とキャッシュ用の2回必要。なので clone して
            // 2つの Stream があるようにする
            var responseToCache = response.clone();
            
            //あらゆる通信を根こそぎキャッシュしてみる。
            caches.open(CACHE_NAME)
              .then(function(cache) {
                console.log( 'event.request をキャッシュに入れます。 ');
                console.log( event.request );
                //キャッシュします。次回からはリクエストされても通信せずにキャッシュから呼ばれます。
                cache.put(event.request, responseToCache);
              });

            return response;
          });

      })
  );
});


キャッシュが効いてないときは処理時間が 20 ms かかります。
blog_service-worker-no-cache

キャッシュが効いていると処理時間は 1 ms です。ブラウザキャッシュに依存せず自分でコントロールできるようになりました。
blog_service-worker-cache

ハマッたエラーの数々


オレオレ証明で発生するエラー

message: "Operation failed by security issue", name: "SecurityError", code: 18
ServiceWorker registration failed:  DOMException: Operation failed by security issue {message: "Operation failed by security issue", name: "SecurityError", code: 18, INDEX_SIZE_ERR: 1, DOMSTRING_SIZE_ERR: 2…}code: 18message: "Operation failed by security issue"name: "SecurityError"__proto__: DOMException
「操作はセキュリティの問題で失敗しました。」パッと見で具体性のないエラーです。本番サーバ等で試験してみたら動作したので、開発機の SSL オレオレ証明書が原因とわかりました。開発機は即席で構築したため、正式なSSL証明書を入れていません。テストのために、私が作成したいわゆるオレオレ証明書が入っています。 Service Woker は HTTPS 必須のためエラーが出てしまうようです。

スコープ設定に問題があるときのエラー

Service Worker ではどの領域をコントロールするかを決めるスコープが非常に重要です。 /js-test/ 配下の service-worker-test.js を Service Worker として登録すると、仕様上 /js-test/ 以下しかコントロールできないという制約が発生します。 /common/ をキャッシュしようとしたら以下のエラーで怒られました。
The scope must be under the directory of the script URL.
Service Worker 初期化のときのコードで JS の置き場と scope の値の整合性がついていないと発生するエラーでした。


importsScript のパスが間違っているときに発生するエラー

上記のスコープを解決するため JS ファイルを移動したので importScript するライブラリのパスがおかしくなってしまいました。そんなときはいきなり AbortError が発生します。これは詳細なメッセージが出ないのでハマリます。
DOMException: ServiceWorker cannot be started {message: "ServiceWorker cannot be started", name: "AbortError", code: 20, INDEX_SIZE_ERR: 1, DOMSTRING_SIZE_ERR: 2…}code: 20message: "ServiceWorker cannot be started"name: "AbortError"__proto__: DOMException

Fetch API のエラー?

現状 Cache API の機能の実装はブラウザごとに不十分です。 chrome 41 でも cache.add と cache.addAll の実装がないため coonsta/cache-polyfill ライブラリを利用する必要があります。ただ、ライブラリ内部で使っている Fetch API でどうやらエラーが出るようです。処理が成功していてもなぜかエラーがひたすら発生します。 ライブラリの内部の cache.addAll のタイミングで Fetch API を呼んでいるというところまではわかったので、その辺かもしれません。いろいろ検索しましたが正直原因が判別つきませんでいた。
Fetch API cannot load . 

今後期待されるブラウザ実装の進展


実際のところ現状ではまだブラウザ実装が足りません。今回触れたのは Service Worker における Cache API の使い方の一部のみです。今後伸びてくると思われる API を以下にまとめます。

Push API

Push は先日 Chrome での実装が発表されています。GCM (Google Cloud Messaging Service) から Chrome ブラウザへ Push ができるという非常に画期的な機能です。

<参考URL>
Chrome 42 ベータ版: プッシュ通知、[ホーム画面に追加] のおすすめ、ES6 Class (2015/3/13)
http://googledevjp.blogspot.jp/2015/03/chrome-42-es6-class.html

Sync API

バックグラウンド同期のイベントを制御する Sync API についても今後の展開が期待されています。

<参考URL>
Background Synchronization Explained
https://github.com/slightlyoff/BackgroundSync/blob/master/explainer.md
Background Sync API for Service Workers
https://code.google.com/p/chromium/issues/detail?id=449443

Fetch API

クロスドメインでもリクエスト可能だったり、 Cookie の送信を細かく制御したり、非常に多機能です。

<参考URL>
Fetch API 解説、または Web において “Fetch する” とは何か?
http://jxck.hatenablog.com/entry/whatwg-fetch

まとめ


・ Chrome では Cache API が使えるようになった。
・ API の機能もまだ不十分だが Cache についてはある程度動作する。
・複数の Service Worker を利用する方法と更新について、ベストプラクティスの検討が必要。

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

エントリー(募集一覧)