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

本番環境にService Workerを導入しましたので、そちらの実装方法についてまとめます。
一部キャッシュ管理にworkbox-swを使ってます。
正直Railsはあんまり関係ないかも。:rolling_eyes:

導入方法については下記記事を書きましたので、そちらを参考にして見てください
[Sprockets管理のRails4環境でWebサイトをPWAに対応する方法のまとめ（準備〜デプロイ編）]
(https://qiita.com/ykyk1218/items/f296d9078c71cd27db78)


- 各種イベントハンドリング
- キャッシュ管理
- オフライン対応
- 計測
- エラーハンドリング

上記５つの視点でそれぞれの実装方法を確認します。
javascriptの書き方は今風ではなく、古い書き方になっていますが、ご了承ください........ :bow: 

---

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

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

![Untitled (4).png](https://qiita-image-store.s3.amazonaws.com/0/6808/9de0b4ab-8cf4-8783-8d45-f5fc7eb4964d.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の箱だけ作っておきます

```javascript

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

self.addEventListener('install', onInstall);
```

### onActivate

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

```javascript

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

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

```javascript

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のキャッシュ](https://qiita.com/ykyk1218/items/6cb90ef1eed522879dc1#html%E3%81%AE%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5) で解説します。

**serviceworker.js**

```javascript

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

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

self.addEventListener('message', onMessage);
```

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

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

```javascript

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の導入方法](https://qiita.com/ykyk1218/items/f296d9078c71cd27db78#workbox-sw)

[workbox-swについてはこちら](https://developers.google.com/web/tools/workbox/)

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

#### 使い方

```javascript
workbox.routing.registerRoute(<キャッシュ対象>, <キャッシュ戦略>, callback)
```
|引数|値|
---|---
キャッシュ対象|キャッシュ対象のURLを指定します。<br><br>正規表現(new Regex)<br>文字列<br>workbox.routing.Routeオブジェクト
キャッシュ方式|キャッシュの方法を決めます。 ※
callback<br>(option)| コールバックで呼ばれる関数を指定

#### 実装例

```javascript

  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を実行すれば、キャッシュが生成されます。


```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を生成


```javascript

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](https://qiita-image-store.s3.amazonaws.com/0/6808/b0c902ef-aba2-37e3-9420-10252d24a3a0.png)

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


## オフライン対応

### オフラインの見た目変更

日経さんはオンライン、オフラインで見た目を変更しています。
https://r.nikkei.com/

オンライン|オフライン
---|---
<img width="1168" alt="スクリーンショット 2018-08-23 20.48.54.png" src="https://qiita-image-store.s3.amazonaws.com/0/6808/f8665e4c-f81c-e313-afbe-efaf07d94d3d.png">|<img width="1157" alt="スクリーンショット 2018-08-23 20.49.11.png" src="https://qiita-image-store.s3.amazonaws.com/0/6808/e786c851-2e64-fe55-30fa-d6be6b1e3def.png">

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

オンライン、オフラインは `navigator.onLine` で判定出来ます。
フロント側で呼ばれる通常のjavascriptファイルで判定して、表示を変えます。

```javascript

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

## 計測

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

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

GoogleAnalyticsタグ

```javascript
<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

```javascript

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

```javascript

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);
```

フロント側

```javascript

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

```javascript

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

あとはエラーが発生した時に勝手にsentryでエラーが受け取れます。
こちらで意図的に飛ばしたい場合は `Raven.captureException(error)` を実行

```javascript

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

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