JavaScript
Rails
HTML5
Chrome
ServiceWorker

service workerを実際のRailsで開発しているサービスに導入してみよう

More than 3 years have passed since last update.

「service worker」とは?

Chrome ソフトウェアエンジニアの@nhiroki_さんによると、

ページのバックグラウンドで動くイベント駆動の JavaScript 環境
(ゆえに “Service” Worker)

とのこと。

大きくは現在は2つ特徴がある。

  • Cache機能 (オフライン環境でのアクセスを可能に)
  • Push機能 (アプリと同様にリエンゲージメントを高める)

sw-lifecycle.png

Service Workerのライフサイクルは上記。

スクリーンショット 2015-05-06 0.26.52.png
- 引用: Service Worker hackathon 2015 - introduction talk

1秒ってのは、人がシームレスに感じる(=違和感なくかなり心地よい)秒数だが、実際今のmobileでの1秒の内訳は上記にある通り、コネクション張ったり通信を発生させる部分で既に大枠を使っている・・・w
なので"Cache使って、使える秒数長くしようぜ!"ってのがService Workerの発端かと。

昨今のGoogle先生の動きを見ると益々mobileへの注力が進んでいるので、今後はこういう部分まで考慮されて実装されているかどうかはかなりSEO上重要視されそうですね。

もともとChromeアプリのbackgroundを元にしてたみたいなので、chromeアプリを作ってた者からすると、かなりしっくりきますねww

他のキャッシュとの違いは?

"Better App Cache"なんて海外では良く言われているらしく、キャッシュとしての注目度は高い。
少し他のキャッシュとオフライン周りの実装を踏まえた上で比較してみます。

cookie

クライアントサイドの保存領域としては定番のcookie。
1つのcookieに4096バイトまでのデータしか保存出来ないことと、保存した後の通信すべてでcookieデータをサーバに送ることになるため、大きなデータをキャッシュするには向いてません。。。

Web Storage

HTML5より本格的に実装が進んだcookieのようなもの。
cookieとの違いは、5MBまでデータが扱えること。
実装の手軽さと各ブラウザがサポートしている使いやすさがあるものの、ページロード時にlocalstorageを読み込む同期処理が、ブラウザのレンダリングをブロックしてしまうので、容量の大きいデータを格納するにはパフォーマンス的に難あり、、、

参考:
- Web Storage使用上の注意点
- ローカルストレージに簡単な解決策はない

Indexed DB

key-value ストア型のデータベースをローカルに構築できる仕組みのこと。
iOSとAndroidで対応が遅れていたのが、使用上のネックでしたが現在は対応されているので、ほぼどのブラウザでも使用可能。localstorageと違って非同期で読み込みを行うこと、1つのローカルDBに複数のストアを持てることが魅力。個人的には使ったことがないので、デメリットは分からず。。。

スクリーンショット 2015-05-06 0.55.52.png

参考:
[iOS 8] Safari 8 でIndexedDBが使えるようになった!

ApplicationCache

もともとService Workerが改良しようとしていたAPI。
cache manifestと呼ばれるキャッシュ対象のURLを記述したテキストファイル(.appcache)をhtmlから参照することで、記述されているURLの内容をローカルキャッシュとして使用する。
ただ、使い勝手が非常に悪い(らしい)。主にキャッシュコントロール周り。

  • 動的コンテンツの場合実装が難しい(読み込み元のhtmlがキャッシュされるので)
  • JS側で、キャッシュの削除や更新が操作できない

どちらも結構致命的・・・。というか旧仕様だと今のWebの進化に付いていけてないだけか・・・w

参考:
モバイル対応Webアプリケーションのキャッシュ戦略

Cache機能を試してみる

まずは以下をみて設定をして下さい。

※ちなみにChrome Canaryって何?って人はここに詳しく書いてます

個人的に一番詰まったのはScope周りです。

b9581235-f2ba-fbeb-1d89-1d931a044c0d.png

スコープ (scope) とは ServiceWorker がサービスを提供するページ URL の範囲です。このスコープに含まれるページは ServiceWorker からオフラインアクセスやプッシュ通知といったサービスを受けることができます。一方、スコープに含まれないページはサービスを受けることができません。

引用:
ServiceWorker のスコープとページコントロールについて

railsだとassets以下やpublic以下に置くことが多いので、scopeを設定しないと、service-workerを保持しているjsのディレクトリパスがscopeに設定されるので、全く意味ありません、、、w

上記の引用しているリンクでも、register部分は以下のように設定してますが、これが上手くいかず・・・。

引用リンクで実装しているver.
navigator.serviceWorker.register("sw.js", {scope: "/scope/"})
  .then(function(registration) {
      // 登録成功!
    });

自分が実装しようとした設定ver.
navigator.serviceWorker.register("/javascripts/service-worker.js", {scope: "/"})
  .then(function(registration) {
      // 登録成功!
    });

以下の画面が出て上手くregister出来ていない様子・・・。

スクリーンショット 2015-05-06 22.59.35.png

よくよく引用したリンクを読むとパス制限があることが書いてある。。。

スコープの指定にはセキュリティ上いくつかの制約があります (詳しくは「Service worker が拓く mobile web の新しいかたち」の 40 枚目以降を参照)
スコープは同一オリジンしか指定できません。
スコープはスクリプト URL 以下のパスを指定しなければなりません。例えば、/foo/bar/sw.js を登録する場合は、スコープとして /foo/bar/ や /foo/bar/in-scope/ を指定できますが、/foo/in-scope/ のように包含関係にないパスを指定するとリジェクトされます (参考「Launching ServiceWorker without breaking the web」)。
スクリプト URL によるパス制限は Service-Worker-Allowed: ヘッダをサーバ側で指定することで解除することができます (Chrome 42 以降で対応)

やっぱりそうなのか・・・。chrome42移行ではヘッダの設定でなんとかなるらしいので期待しようw
仕方なく/public以下にjsを移動させて下記で実装。

改善ver.
navigator.serviceWorker.register("/service-worker.js", {scope: "/"})
  .then(function(registration) {
      // 登録成功!
    });

何故上手く行かないかは不明・・・orz

service-worker.js
// キャッシュのキーとなる文字列
var CACHE_KEY = 'service-worker-v1';

self.onfetch = function (e) {
  e.respondWith(
    caches.open(CACHE_KEY).then(function (cache) {
      return cache.match(e.request).then(function (response) {
        if (response) {
          return response;
        }
        fetch(e.request.clone()).then(function (response) {
          if (!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }
          cache.put(e.request, response.clone());
          return response;
        });
      });
    })
  );
}

self.onactivate = function (e) {
  console.log('ServiceWorker.onactivate: ', e);
}

初期ページに飛ばすと、ページが上手く表示されない・・・。
もう一度reloadすると上手く表示されるので、cacheがなかった時の挙動がおかしいみたい。
ちょっと後日ブレイクダウンしてみる。

ひとまずはこれで、localサーバ落としてもアクセスできた!
素敵!!!

他のPush通知との違いは?

現状だとWebSocketやNotification APIでネイティブライクなpushを通知することが可能。
現にSNSなどはWebSocketで実装されているケースが多い。{ まぁブラウザ依存させないように実装するとそうなりますよねw

ではServiceWorkerとは何が違うかというと、
ブラウザを閉じていても、ServiceWorkerから通知することが可能

WebSocketはクライアントとサーバのコネクションでしかないので、ブラウザを閉じれば切れますが、
ServiceWorkerはクライアントとは別にバックグラウンドで動くので閉じてもpush出来ると。

まぁあくまでもChromeが動いている場合に限りますがw
ユーザ体験は一旦置いといて、開発者としては素敵!!!w

20141204180839.png

ちなみにpush自体はAndroidのpush通知で使われていたGCMを用いているみたい。
GCMについてよく分からない方はこちらを参考に。

Push通知を実装してみる

こちらも、GCMの登録やmanifestの設定など諸々あるので、こちらを参考にまずは設定して下さい。

必要なのは大きく3つ。

  • notificationをsubscribeするjs (これはサービスで直接使用しているjs)
  • manifest.json (Google先生お馴染みのmanifestファイルwこれにpushの細かい設定します)
  • service-worker.js (実際にpushするときの挙動や、push後の遷移などの挙動を設定)
manifest.json
{
  "name": "Green-development",
  "short_name": "GreenDev",
  "icons": [{
    "src": "images/logo-facebook.gif",
    "sizes": "256x256",
    "type": "image/gif"
  }],
  "start_url": "/",
  "display": "standalone",
  "gcm_sender_id": "#プロジェクト番号",
  "gcm_user_visible_only": true
}

一点注意としては、manifestのgcm_sender_idは下記のIDなので、そこだけ注意して下さい。
※僕はプロジェクトIDをずっといじってました・・・w

gcm.png

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/service-worker.js', {scope: '/'})
  .then(function onFulfilled () {
    console.log('SW was installed!!!');
  }, function onRejected () {
    console.log('SW was not installed!');
  }) //以下push通知
  .then(function(serviceWorkerRegistration) {
    serviceWorkerRegistration.pushManager.subscribe().then(function (sub) {
      console.log(sub)
    })
  }).then(function(subscription) {
    console.log(subscription);
  }).catch(function (err) {
    console.log(err);
  });
}

重要なのは、

  • pushManager.subscribe():push通知を登録する
  • pushManager.getSubscription():登録しているpush通知の一覧を取得する
  • subscription.subscriptionId:登録されているプッシュ通知のID。GCM上で必要。
service-worker.js
self.addEventListener('push', function(evt) {
  evt.waitUntil(
    self.registration.showNotification(
      'Test',
      {
        icon: '/images/favicon.ico',
        body: 'Test push',
        tag: "notification"
      }
    )
  );
}, false);

self.addEventListener('notificationclick', function(evt) {
  evt.notification.close();

  evt.waitUntil(
    clients.matchAll({ type: 'window' }).then(function(evt) {
      var p = location.pathname.split('/');
      p.pop();
      p = location.protocol + '//' + location.hostname + (location.port ? ':'+location.port : '') + p.join('/') + '/';
      for(var i = 0 ; i < evt.length ; i++) {
        var c = evt[i];
        if(((c.url == p) || (c.url == p + 'index.html')) && ('focus' in c))
          return c.focus();
      }
      if(clients.openWindow)
        return clients.openWindow('./');
    })
  );
}, false);

あとは、subscriptionIdとGoogle Developer Console上で与えられたAPIキーを用いてcurlで叩いてみる。

google.png

curl --header "Authorization: key=(server key)" --header Content-Type:"application/json" https://android.googleapis.com/gcm/send -d "{\"registration_ids\":[\"(Webアプリで取得したsubscriptionId)\"]}"

スクリーンショット 2015-05-07 11.00.22.png

無事上記のように出ればOK!
実際にタブを閉じてもwindowが開いていればnotificationでますね。

ちょっと設定部分で手こずりましたが、無事実装できました。

参考:
ChromeでW3C Push APIを使ってみた
WebアプリからもPush通知が!ChromeのPush通知について
Service Workers Push API Hands-on

最後に今後起こりうるWebのパラダイムシフトは?(予想)

結局世の中的に言われているweb⇔ネイティブアプリの垣根はなくなっていくと思っている。
そうなるとWebの世界でもAmazon Congnitoのようなオフライン⇔オンラインを結ぶ技術は益々発達するかと。

20140728232824.png

ネイティブ的な志向でいくと、ServiceWorkerで画面部分のキャッシュをかけ(今後は一定の時間でCacheをupdateかけるイベントを起こせるようになるらしい)indexedDBでオフライン時に残しておきたい行動部分を残すみたいな感じですかね。

future.png

Google先生からすると、iOSのネイティブアプリの部分をぶっ壊してChromeに乗り換えてほしい訳で、Googleが主導してこの流れを作っているのもうなずけますね。
一方でApple的には変わらずネイティブとWebは切り離したいと思うので、Safariにpush通知がつくかでいうと、相当後になりそうな気がしてます。

ユーザ視点で考えても、ゾンビアプリが大量だと思うので、そこまで悪い気はしないですね。
いずれにしてもネイティブに寄っていく流れはほぼ間違いないので(おそらくそのうちSEOの項目にも入るのでは?)、出来るだけ急務でキャッチアップしていきたいですね。