Edited at

Railsの本番WebサイトでServiceWorkerを使ってキャッシュを生成する

本番環境にService Workerを導入しましたので、そちらの実装方法についてまとめます。

一部キャッシュ管理にworkbox-swを使ってます。

正直Railsはあんまり関係ないかも。:rolling_eyes:

導入方法については下記記事を書きましたので、そちらを参考にして見てください

Sprockets管理のRails4環境でWebサイトをPWAに対応する方法のまとめ(準備〜デプロイ編)


  • 各種イベントハンドリング

  • キャッシュ管理

  • オフライン対応

  • 計測

  • エラーハンドリング

上記5つの視点でそれぞれの実装方法を確認します。

javascriptの書き方は今風ではなく、古い書き方になっていますが、ご了承ください........ :bow:



今回のやりたいこと全体像

カテゴリAの記事にアクセスした時に、カテゴリAの他の記事をキャッシュしてオフラインでも見れるようにしたい、ということをします。

つまり見たページ以外に、関連する他のページをキャッシュします

Untitled (4).png


各種イベントハンドリング

Service Workerで受け取れるイベントには下記があります。

こちらの各種イベントで処理を追加 :writing_hand:

イベント名
イベント発生タイミング

Install
Service Workerをブラウザにinstallするタイミング

Activate
Service Workerがアクティベートされるタイミング ※

Fetch
通信のタイミング

Message
ブラウザ側でpostMessageが実行されたタイミング

※ Service Workerのライフサイクルついて

https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle?hl=ja

下記がそれぞれのイベントでおこなう処理内容になります。

CACHE_NAMEにはこちらで指定したキャッシュ名が入ります。

後述するworkboxだけでキャッシュ管理が完結するのであれば、特に何もしなくてもいけそうです。


onInstall

cacheの箱だけ作っておきます


onInstall = function(event) {
event.waitUntil(caches.open(CACHE_NAME))
}

self.addEventListener('install', onInstall);


onActivate

キャッシュ名にキャッシュバージョンをつけて、キャッシュバージョンをあげた時に古いキャッシュを削除するようにします。


onActivate = function(event) {
event.waitUntil(caches.keys().then(function(cacheNames) {
return Promise.all(cacheNames.filter(function(cacheName) {
return cacheName.indexOf(CACHE_VERSION) !== 0;
}).map(function(cacheName) {
return caches["delete"](cacheName);
}));
}));
};

self.addEventListener('activate', onActivate);


onFetch

キャッシュがあればキャッシュを返すようにします


onFetch = function(event) {
event.respondWith(caches.match(event.request.url).then(function(response) {
if (response) {
return response;
}
return fetch(event.request);
}));
};

self.addEventListener('fetch', onFetch);


onMessage

ここはアプリケーションごとの固有処理になってきます。

フロント側から postMessage でキャッシュするページIDを渡して、キャッシュを生成します。

また古くなったキャッシュは削除するようにしておきます。

この辺りから、少々込み入ってくるので詳細な処理内容については

htmlのキャッシュ で解説します。

serviceworker.js


onMessage = function(event) {
// event.dataにフロントから渡された値が入ってくる

// htmlキャッシュを生成したり、削除する処理
}

self.addEventListener('message', onMessage);

フロント側

フロント側ではpostMessageを実行して、引数にserviceworkerスクリプトに渡したい値を入れます。

実行タイミングは window.onload などで大丈夫です。


navigator.serviceWorker.controller.postMessage(<servicewokerに渡したい値>)


キャッシュ管理

キャッシュ生成タイミングが2種類あります。


  • Runtime Cache: 一度アクセスをして、取得した内容をキャッシュする

  • Pre Cache: ブラウザでアクセスする前に必要な要素をキャッシュします

今回はhtmlのキャッシュと、その他のassetsのキャッシュで処理の方法を分けました。

htmlはPre Cache

assetsはRuntime Cache

です。


assetsのキャッシュ

やりたいこと: アクセスしたページで読み込んだcssとjsと一部画像をキャッシュする

workbox-swを使います。

Sprocketsへのworkbox-swの導入は記事を書きましたので、そちらで確認して見てください

Sprocketsへのworkbox-swの導入方法

workbox-swについてはこちら

workbox.routing モジュールで workbox-runtime キャッシュの仕組みを使ってキャッシュします。


使い方

workbox.routing.registerRoute(<キャッシュ対象>, <キャッシュ戦略>, callback)

引数

キャッシュ対象
キャッシュ対象のURLを指定します。

正規表現(new Regex)
文字列
workbox.routing.Routeオブジェクト

キャッシュ方式
キャッシュの方法を決めます。 ※

callback
(option)
コールバックで呼ばれる関数を指定


実装例


workbox.routing.registerRoute(
new RegExp('<assetsのURLを正規表現で指定>'),
workbox.strategies.staleWhileRevalidate({
cacheName: "<キャッシュ名>",
plugins: [
new workbox.expiration.Plugin({
maxAgeSeconds: 60 * 60 // キャッシュする時間
})
]
})
)


Railsで使う場合の注意点

application.jsやapplication.cssをキャッシュする場合、

new RegExp('.*application\.js | .*application\.css)

と指定をすると、開発環境では問題ありませんが、productionにデプロイした時にハッシュ値がファイル名について来るため失敗します。

application-3994d44c80aa70ad507ff05c1db46g04.js みたいなの

new RegExp('.*application.*\.js | .*application.*\.css)

こうしときましょう。

※workboxで使えるキャッシュ方式について

色々あります。

名前
効果

cacheFirst
最初のキャッシュを見て、キャッシュがなけれなネットワークから取得

cacheOnly
キャッシュだけを見る

networkFirst
最初にネットワークから取得をして、通信できなければキャッシュを見る

networkOnly
ネットワークからのみ取得

staleWhileRevalidate
キャッシュがあればキャッシュを返し、さらにキャッシュの内容をネットワークから取得して更新する

https://developers.google.com/web/tools/workbox/reference-docs/latest/workbox.strategies

workbox-routingの詳細はこちら

https://developers.google.com/web/tools/workbox/reference-docs/latest/workbox.routing


htmlのキャッシュ

やりたいこと: 今見ているページと同カテゴリのページhtmlをキャッシュする

workboxのprecacheの仕組みがあるので、それを使えたらよかったのですが

Installイベントでprecacheをしなければならない関係上、取得したいものを動的に変えたい場合は都合が悪かったです。

そのため自前でキャッシュ生成処理を実装します。


  • キャッシュ作成方法

  • 期限切れキャッシュの削除


キャッシュ作成方法

キャッシュの作成はそれ程難しくないです。

下記のjavascriptを実行すれば、キャッシュが生成されます。


fetch(new Request(<対象ページのURL>, {
<オプション>
})).then(function(response) {
return caches.open(<キャッシュ名>).then(function(cache) {
return cache.put(<対象ページURL>, response);
});
}).catch(function(err) {
//エラー処理
});


期限切れキャッシュの削除

Cache Storageに生成されたキャッシュには有効期限という概念が存在しません。

Chromeの検証ツールで確認をする分には、Time Cachedというカラム?が確認できるのですが、 javascriptでその情報にアクセスすることはできないようです。

そこでキャッシュの有効期限をindexeddbに持たせて管理をします。

キャッシュ削除フロー

1. 記事にアクセス

2. indexeddbを見て、古いtimestampのレコードを削除

3. 古いCache Storageのキャッシュを削除

4. 新しくCache Storageにキャッシュを生成

5. indexeddbにtimestampを生成


onMessage = function(event) {
// event.dataにフロントから渡された値が入ってくる
// 今回は対象ページの記事IDを想定
var articleId = event.data
urls.forEach(function(articleId, i){
timestampe = new Date().getTime() - 86400000 // 1日前
// 期限切れのキャッシュは全部削除する
deleteExpireCache(timestampe) // indexeddbとcache storageから古いデータを削除する関数

.then(function(result){
// result[1]はarticleIdが入ってくる
return searchCache(result[1]) // 対象ページのキャッシュが既に存在しているか確認

}).then(function(result) {
if(!result[0]) {
//対象ページのキャッシュが存在していなければキャッシュを生成する
fetchArticle(result[1])
}
}).catch(function(err){

})
})
}

最終的にindexeddbに入るデータ

seqはこちらで指定して、こういう名前にしてます。

IDみたいなものだと思ってください。

スクリーンショット 2018-08-14 19.11.45.png

indexeddbの操作については割と癖があるので、ちょっとハマってしまったりするのですがここでは一旦割愛...


オフライン対応


オフラインの見た目変更

日経さんはオンライン、オフラインで見た目を変更しています。

https://r.nikkei.com/

オンライン
オフライン

スクリーンショット 2018-08-23 20.48.54.png
スクリーンショット 2018-08-23 20.49.11.png

こんな感じのことをやりたい場合

オンライン、オフラインは navigator.onLine で判定出来ます。

フロント側で呼ばれる通常のjavascriptファイルで判定して、表示を変えます。


online = navigator.onLine;
if(online) {
viewOnline()
}else{
viewOffline()
}
window.addEventListener('online', viewOnline)
window.addEventListener('offline', viewOffline)


計測

workboxを使うことでGoogleAnalyticsでオフライン状態の計測できるようになります。

GA側で適当なカスタムディメンジョンを用意して、そこにoffline状態というのが判定できるように値を入れます。

今回はデフォルトで online の値を入れて、オフライン状態の時に offline の値を入れます。

GoogleAnalyticsタグ

<script>

(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');

ga('create', 'UA-XXXXX-Y', 'auto');

// カスタムディメンジョンのデフォルト値を入れておく
ga('set', 'cd1', 'online');

ga('send', 'pageview');
</script>

serviceworker.js


workbox.googleAnalytics.initialize({
parameterOverrides: {
"cd1": 'offline'
},
hitFilter: function(params) {
var queueTimeInSeconds = Math.round(params.get('qt') / 1000);
params.set('cm1', queueTimeInSeconds);
}
})

参考

https://developers.google.com/web/tools/workbox/modules/workbox-google-analytics


エラーハンドリング

エラー発生時の通知方法です。


  • ブラウザへの通知

  • sentryへの通知


ブラウザ側への通知

Service Worker → ブラウザ は Client.postMessage を使います

Service Workerkのエラーをブラウザに通知したいニーズはあまりなさそうですが、念のため。

serviceworker.js


onFetch = function(event) {

sampleFunc()
.then(function(result){
nextFunc(result)

}).catch(function(err){

notifyError(err, event.source.id)
})
}

function notifyError(error, clientId) {
console.error(error)

self.clients.get(clientId).then(function(client) {
client.postMessage({type: 'error', message: error.toString()})
})
}

self.addEventListener('fetch', onFetch);

フロント側


navigator.serviceWorker.addEventListener('message', function(event) {
if(event.data.type == "error") {
alert("エラーが発生しました")
}


sentryへの通知

sentryを使うとjavascriptエラーの通知を受け取れるようになります。

https://sentry.io/welcome/

npm install --save raven-js

Rails4系の方は

https://qiita.com/ykyk1218/items/f296d9078c71cd27db78#sprockets%E3%81%AE%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E8%A8%AD%E5%AE%9A

この辺りを参考にして、諸々の設定をしてSprocketsで読み込めるようにします。

sentryの設定はこちらを参考してserviceworker.jsに設定をしていきます。

https://docs.sentry.io/clients/javascript/

serviceworker.js


Raven.config("https://<key>@sentry.io/<project>", options).install()

あとはエラーが発生した時に勝手にsentryでエラーが受け取れます。

こちらで意図的に飛ばしたい場合は Raven.captureException(error) を実行


sampleFunc()
.then(function(result){
nextFunc(result)

}).catch(function(err){
Raven.captureException(err)
notifyError(err, event.source.id)
})