ServiceWorkerを用いたキャッシング戦略 ~Wikiアプリケーションを例に~

ServiceWorkerを用いたキャッシング戦略 ~Wikiアプリケーションを例に~



daiiz

Nota, Inc.を作っています


Nota Inc.
Gyazo
スクリーンショットを使ったコミュニケーションと情報収集のためのツール
Scrapbox
あらゆる情報をつなげて整理する知識共有サービス
rakusai shokai daiiz progfay yutaro で作っている
daiiz は去年の夏から参加


Scrapbox
Wikiみたいなノートアプリ
複数人での同時編集できる
文中リンクで繋げて思考する

フルJavaScript実装のSingle Page Application
サーバーサイド
クライアントサイド
/shokai/Scrapboxの開発 - React & Websocketで作るリアルタイムWiki

本日の発表で具体例として登場します


本日お話しすること
概要
ユーザーによってコンテンツが頻繁に更新されるようなウェブサービスにService Workerを導入して、ローカルに持てるリソースを徐々に増やし活用していく取り組みについて

目次
キャッシュパターン
静的リソースのキャッシュ
動的リソースのpreftch
オフライン表示に向けた動的リソースのキャッシュ
今後


1. ServiceWorkerとは
プログラム可能なネットワークプロキシ
オフラインで動作させるために必要な機能を提供してくれる
ネットワークリクエストへの介入や処理機能
レスポンスをプログラムから操作できるキャッシュ機能

JavaScript Workerの一種なので、DOMにはアクセスできない
UIスレッドとの通信はpostMessageを用いる

起動、停止のタイミングはブラウザ任せ
一定時間イベントが来ないと勝手に停止する
次にイベントが来たタイミングで自動で起動する
Chrome DevTools を開いていると、停止しようとした瞬間を観測できる


ServiceWorkerの対応状況
各ブラウザでの対応状況
ServiceWorkerに関連するAPIの実装状況と仕様を確認できる
各ブラウザでのデバッグ方法も学べる

Scrapboxでの導入
オフラインアプリの骨組みを小さく作りながら実験していた
Scrapboxと似た構成で練習して問題点の洗い出し

ServiceWorkerの導入
ユーザーの環境に登録する
js
Copied!
// Window
const registration = await navigator.serviceworker.register('/sw.js', {scope: '/'})

ServiceWorker自身の更新
ブラウザが自動で更新してくれる
登録した sw.js がbyte単位で変更がないかを確認している
installが完了するとwating worker状態で待機
適切なタイミングでactivate
controllerになる
画面をリロードしてclientを更新した後
または
activateイベントで clients.claims() を実行し完了した後


ServiceWorker導入前
何をキャッシュして、いつ使うかはブラウザ任せ
HTTP header Cache-Control のmax-ageに大きい値を与える策など
画像やフォントは、運よくキャッシュがあるかもしれない
これだけあってもオフライン時に画面を構成するのは無理


ServiceWorker導入後
何をキャッシュするか、どのタイミングで使うかを開発者側でコントロールできる
発行されたリクエストを横取りしてResponse返却の間に色々できる
Responseにheaderを加えたり
イチから組み立てたり
任意のタイミングでリクエストを発行できる
事前にResponseをキャッシュして温めておくなど
他にもいろいろできる
これらの処理をJavaScriptで書ける!


UIスレッド ↔ ServiceWorker
特に意識ことは必要はない
普段どおりHTTPリクエストを発行するだけで良い
aタグをクリック
XHRライブラリ
どこ由来のデータか気にする必要もない
ローカルキャッシュか?リモートサーバーか?
postMessageで通信する方法もある


ServiceWorker ↔ Network, CacheStorage
ServiceWorkerには色んなイベントが飛んでくる
アプリで必要な機能に関するイベントハンドラを実装していく

FetchEvent
UIスレッドでリクエストが発生すると飛んでくるイベント
same originリクエストに限らず、すべて通ってくる

FetchEventのハンドリング
ブラウザのデフォルト動作にfallbackする
respondWith() 内でレスポンスをつくって返す

respondWith() をマスターすれば完璧
networkを優先するか?
cacheStorageを優先するか?
コンテンツによって使い分けていく

respondWith の書き方を見ていきましょう


2. キャッシュパターン
ネットワークのみ
キャッシュファースト
ネットワークファースト



fallback to the network
ブラウザのデフォルト動作にfallbackする
直ちにnetworkに取りに行く

ServiceWorkerでハンドリングしない (または、できない) リクエストに対して使う
POST, DELETE など、GET以外のリクエスト
http, file など、httpsでないリクエスト
デバッグ用途として http://localhost に限っては大丈夫

respndWith に渡るまえにreturnするだけでOK
sw.js
Copied!
self.addEventListener('fetch', event => {
return
})
ServiceWorker導入前と動作は何も変わらない


キャッシュファースト

sw.js
Copied!
self.addEventListener('fetch', event => {
event.respondWith(async function () {
const req = event.request
const res = await caches.match(req)
if (res) return res
return fetch(req.clone())
}())
})

ネットワークの状況に依らずにCacheStorageに保存したResponseを優先
キャッシュがなければネットワークリクエストを発行
寿命が長いコンテンツに向いている
HTML, JS, CSS, 画像, Fonts など
CacheStorage全体から探してResponseを取得する


ネットワークファースト

sw.js
Copied!
self.addEventListener('fetch', event => {
event.respondWith(async function () {
const req = event.request
if (navigator.onLine) {
const res = await fetch(req.clone())
if (res) return res
}
return caches.match(req)
}())
})
ネットワークからの取得を優先
offline環境でのみCacgeStorageを頼る
更新頻度が高く、寿命が短いコンテンツに向いている
ユーザーによって作成されるページデータなど


Cache API について
RequestInfo, Response のペアを保存できるKey Value Store
RequestInfo: Request object または URL文字列
Window, ServiceWorkerGlobalScope の両方から参照できる

Cacheの作成、取得
cacheNameを指定して caches.open(cacheName) でCacheオブジェクトを作成
この中に保存していく


Scrapboxでのキャッシング戦略
アプリケーション構成
基本はSPA
client routerを通して画面ごとにReact Componentを出し分ける
最初に全部をまとめてロード可能
共通で使うHTML
JS, CSS, Fonts, UI画像

ページコンテンツ、ページリスト
都度APIを呼んでJSONで取得

ユーザーがアップロードした画像
主にGyazoから取得
ページの扉画像、本文中のアイコンとしても使われる


Scrapboxでのキャッシング戦略
全ページで共通に使うHTML
SPAであることを活かし、最初のHTMLもキャッシュから返す

Scrapboxでは app.html と呼んでいる
CacheStorageから取得して使う
キャッシュの生成日から1ヶ月以上経過している場合はリモートサーバーから取り直す

アプリ画面に関して、どのURLにアクセスしてもこれが返される
ServiceWorkerでRequestを横取りしてResponseを組み立てる
sw.js
Copied!
function createSinglePageRequest (req) {
const url = new URL(req.url).origin + '/app.html'
return new Request(url, {
method: req.method,
headers: req.headers,
credentials: req.credentials,
cache: req.cache,
mode: 'same-origin',
redirect: 'manual'
})
}

オンライン時
Scrapbox画面の下地をいきなり表示できる

オフライン時
Scrapboxのメッセージ画面を出せる


Scrapboxでのキャッシング戦略
JS, CSS, UI画像などのassets (静的リソース)
ネットワーク状況に依らずにキャッシュファースト
画面表示のためのリソースのfetchが落ち着いたら、assets cacheを更新
最新版があればダウンロード

projectデータ、pageデータなどのAPI (動的リソース)
現時点では計画的なキャッシュをしていない
最近見たページはオフラインでも表示できるようになった
全ページを本格的にキャッシュしてネットワークファーストで応答する作戦は検討中
マウスホバー時にprefetchをして高速に表示する


3. 静的リソースのキャッシュ
assets cacheを作成するタイミングの検討
Scrapboxでのassets cacheの更新フロー


assetsキャッシュ作成のタイミング
assets cache作成 / 更新のタイミングを3つ検討したい
リソースにアクセスされる都度?
新しいServiceWorkerがactivateされたとき?
ServiceWorker自身の更新の仕組みを利用する
アプリ画面表示のたびに最新のキャッシュを保持しているか確認?
assetsキャッシュ更新の仕組みを自前で実装する


assetsキャッシュ作成のタイミング案①
リソースにアクセスされるたびに逐次追加
一番最初に思いつくシンプルな方法
問題
リソース間の整合性が保てない
古い画像あり、新しいCSSあり、JSは持っていない など
優先度が付けられない
CacheStorageの割り当て容量を有効活用できない
何がキャッシュされているか把握できない


assetsキャッシュ作成のタイミング案②
新しいServiceWorkerがactivateされたとき
ServiceWorkerのコードにキャッシュすべきassets URLリストを書いておく
このリストを更新すれば、SWのコードを変更することによってブラウザ自動更新が走る
これによりCacheStorageの内容も更新できる
という仕組みを利用

install時に新しいassets cacheを作成
sw.js
Copied!
self.addEventListener('install', event => {
// ここで新しいasstes cacheを作成する
// 直ちにactivateする
event.waitUntil(self.skipWaiting())
})

activate時に古いものを削除
js
Copied!
self.addEventListener('activate', event => {
// 直ちに全clientに適用する
event.waitUntil(self.clients.claim())
})

問題
controllerの切り替わりが不安定だった
ServiceWorkerのコードを変更しても入れ替わらないときがある
WebSocketを使っているからだろうか
ブラウザをリロードしても、WebSocketの接続が切れていないタブがあると現在のcontrollerのactive状態が終了しないようだった


assetsキャッシュ作成のタイミング案③
アプリ画面表示の度にキャッシュが最新であるかを確認
ServiceWorker自身の更新の仕組みに頼らずに、assets cacheのみを更新する
キャッシュすべきリソースのURLを列挙したjsonデータを配信するAPIを用意

Scrapboxではこの方針を採用した


assetsのホワイトリスト
アクセス毎に逐次キャッシュする方針でないので、キャッシュすべきリソースリストを定義しておく必要がある
ここで指定したリソースを一気に cache.addAll(urls) する
HTML, JS, CSS のversionが揃う
JSは新しいがCSSは古いというようような不整合がおきない

URLの配列を渡して使う
成功したならば、すべてのリソースのキャッシュに成功している
200以外のstatusを返すリソースが1個でもあれば、TypeErrorで失敗する
no-cors modeでfetchしたopaqueなResponseを保存できない点に注意


assetsのホワイトリスト
キャッシュすべきassetsのURLのリストとassets-versionを記述しているファイル

js build時にnpm scriptsのtaskで生成される
server js, client js で使っているURLリストを共用できる
アプリとassets.jsonの乖離が起きない

CDNから読み込むリソースも含まれる
CSS内部から読み込んでいるfontファイルのURLなども書いている
クエリパラメータ付きのURLを呼んでいることがあるので注意!
Cacheから返していると思い込んでいたが、 cache.match() でhitしないので、実際はnetworkリクエストを飛ばしていた
オフラインモードでシミュレートすること大事

すべてのURLが status 200 を返すことをtest
cache.addAll でコケてキャッシュ更新に失敗することを防ぐ


assetsリストのversion管理
app.htmlとJavaScript, CSS, 画像, fontなどをひとまとめで、一意に与えられるversion番号
assets間の整合性を担保できる
つまり、これらのうちどれかが更新されればversionが上がる
ビルド日時で管理している
assets-20181109-103746 というフォーマットになっている
assets-年月日-時分秒
そのままcacheNameとして使っている
assets-version の使いどころ
Response Header
app.html のhtmlタグのdata属性
<html data-assets-version='assets-20181109-103746'>
client jsで、現在表示しているassetsが最新であるかを把握できる


assets cacheの更新
サイレント自動アップデートを実装
ユーザーが気づかないうちにbackgroundで更新される
次にリロードした瞬間から最新版を使える
UIスレッドからのFetchEventが落ち着いてから実行する
最後のfetchから数秒経過するまで待つ

順に行う
assets.jsonをfetchする
最新のassets versionを取得
最新のバージョンのキャッシュがあるか確認
指定したバージョンをcacheNameとするcacheが存在するかを見るだけでは不十分
cache.addAllが中断されていた場合、空のcacheオブジェクトが存在する
sw.js
Copied!
async function cacheExists (version) {
if (!(await caches.has(version))) return false
const cache = await caches.open(version)
// cacheオブジェクト内にRequestが存在することも確認する
return (await cache.keys()).length > 0
}
なければ新しく作り、古いものを削除

QuotaExceededErrorをハンドル
CacheStorageの割り当て容量超過時に発火する
page dataなど (後述) 他のAPIのキャッシュを破棄して容量を確保
アプリ画面を構成するための重要リソースなので、こちらを優先する

ChromeのPrivate Windowを使うとdebugしやすい
割り当て容量が小さめ (〜10MB) に設定されている
QuotaExceededErrorを発生させやすい


assets cache導入の効果
サーバーから304 Not modified を待つよりも速い
サーバーに送るリクエスト数が減少
レイテンシが高い環境での初期レンダリング時間短縮
Chrome DevToolsのNetworkタブで、Slow 3Gに速度を絞るとわかる
assets cacheなし
12.5秒

assets cacheあり
9秒

オフライン表示の第一歩


4. 動的リソースのprefetch
= Scrapboxページデータのprefetch
オンライン時
ページロード時間の短縮
postMessageでUIスレッドからServiceWorkerにキャッシュ要請する例


prefetch cache作成と破棄
aタグにonmouseenterしたとき
GET /api/pages/:projectName/:pageTitle のレスポンスをキャッシュ

ServiceWorkerにURLを渡してfetchを要求する
client ↔ SW間はpostMessageを使って通信
clientでfetchを実行すると?
SWでfetchEventハンドラを通る
キャッシュファーストな応答される場合、ネットワークリクエストが飛ばずに最新のデータを得られない

どちらか早いほうのタイミングで破棄
キャッシュを使用した直後
保存から15秒経過


postMessage
client ↔ ServiceWorker間で通信するときに使う

Dedicated Worker (Web Worker) でも使ったことがある
js
Copied!
// Window
const worker = new Worker('dedicated-worker.js')
worker.onmessage = event => {
// Web Workerからの応答を受信
}
worker.postMessage(data)

js
Copied!
// DedicatedWorkerGlobalScope
self.addEventListener('message', event => {
// ここでタスクを処理
// clientに返信
self.postMessage({result: ''})
})

同様にService Workerとも通信できる
clientとworkerが1対1対応していないので、呼び出し方が少々複雑
MessageChannelを作成してやりとりする
port1, port2 の対を得る
js
Copied!
// Window
const channel = new MessageChannel()
channel.port1.onmessage = event => {
// ServiceWorkerからの応答を受信
}
navigator.serviceworker.controller.postMessage(data, [channel.port2])

sw.js
Copied!
self.addEventListener('message', event => {
// ここでタスクを処理
// client (event.ports[0]) に返答
event.ports[0].postMessage({result: ''})
})


React component <PrefetchOnHover>
aタグにmouseenterしたときにSWにpostMessageする
aタグにwrapして使う
jsx
Copied!
<PrefetchOnHover urls={['/api/pages/Nota/東京Node学園祭']}>
<a href='/Nota/東京Node学園祭'>東京Node学園祭</a>
</PrefetchOnHover>

React Component
js
Copied!
function PrefetchOnHover ({children, urls}) {
return (
<span onMouseEnter={() => prefetch(urls)}>
{children}
</span>
)
}

postMessageしてServiceWorkerにprefetch要求する
js
Copied!
// Window
async function prefetch (urls) {
const {controller} = await navigator.serviceWorker
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = event => {
resolve(event.data)
}
controller.postMessage({
title: 'prefetch',
body: {urls}
}, [channel.port2])
})
}

ServiceWorkerではMessageEventをハンドル
sw.js
Copied!
self.addEventListener('message', event => {
event.waitUntil(async function () {
const {urls} = event.data
// ここで各urlをfetchしてcacheに追加する
// await fetch(new Request(url, {credentials: 'same-origin'}))
event.ports[0].postMessage({title: 'prefetch'})
}())
})


prefetchするかどうか
低速ネットワーク環境ではprefetchしたくない
mouseenter時のprefetchが終わらない状態でclickされると、最悪2回分のレスポンスを待たないといけない
ブラウザのネットワーク接続状況を取得できるAPI
navigator.connection.downlink
回線速度
navigator.connection.rtt
Round Trip Time
往復遅延時間

まだChromeでしか使えない 2018/11/22 現在

代替実装
試しにprefetchしてみて、3秒以内で取得できなかったら機能をしばらくオフにする
Promise.race を使って、prefetchとtimeoutを競わせる
sw.js
Copied!
const result = await Promise.race([
fetchAndCache(request),
delay(3000).then(() => 'timeout')
])
if (result === 'timeout') temporarilyDisable()


prefetchの効果 ①
高速にページを表示できる
click前にはデータをfetch済みなので高速
アドレスバーが変わってから画面が更新されるまでの時間が短くなった

Random Jump Buttonの動作も速い
押す前に次のページのデータ取得が終わっている


prefetchの効果 ②
レイテンシの大きいネットワークで発生するbugが表面化した

Cacheを操作できるようになり、これまで発生頻度が低く見落としていたbugが再現しやすくなった
リンククリックしてページデータを取得している間に、他のユーザーがページを書き換えている場合がある
→ そのまま編集すると、変更がコンフリクトしてしまう
データ取得とclient jsに反映されるタイミングの間にラグができやすくなったため
編集の途中でコンフリクトするケースについては、コンフリクト解消が正常に動いていたが
最初からコンフリクトしていた場合の処理に問題があった


5. 動的リソースのキャッシュ
ページのオフライン表示を実現する
サーバー上の最新状態ではないものの、最近見た状態の内容を提示できる機能


CacheStorageから返されたResponseであるか?
どういう手法を選択するにしても必要になる
Wikiアプリとしては、見ているページが最新状態でないなら提示したい

Response に Header X-Serviceworker-Cache を付けてcache.put() する
'true'
取得日時など
sw.js
Copied!
async function setHeader (res) {
if (res.type === 'opaque') return res
const headers = new Headers(res.headers)
headers.set('X-Serviceworker-Cached', 'true')
return new Response(await res.blob(), {
status: res.status,
statusText: res.statusText,
headers
})
}

Response objectを明示的に与えられる
自前で組み立てたResponseを保存するときに便利

Responseを返却する際に付けても良い
が、レスポンスのbodyが大きいとcloneするのに時間がかかることがある

UIスレッドでレスポンスのheaderを読んでCache経由であることがわかればいい
「offline mode」、「◯時間前のキャッシュを表示しています」などのメッセージを出せる


GET APIレスポンスをすべてキャッシュ
GET APIのresponseをキャッシュしてoffline時に使う
ネットワークファーストで応答しつつ、リソースにアクセスされる度に逐次追加していく方式
assets cacheで検討したが採用しなかったアイデアだが、ここでは使えそう
sw.js
Copied!
export async function respondApiNetworkFirst (req) {
const respondCache = () => {
return caches.match(getCacheUrl(req.url))
}
let remoteRes
try {
// navigator.onLineを信用せずに、実際にリクエストを発行して判断する
remoteRes = await fetch(req.clone())
} catch (err) {
return respondCache()
}
if (remoteRes) {
if (remoteRes.ok) updateApiCache(req, remoteRes.clone())
return remoteRes
}
return respondCache()
}
navigator.onLine === true を信用しない
onLineでも、WiFiにログインしていなかったり、経路に問題があったりして実質オフラインの場合がある
実際にアクセスしてみて判定する


Cache objectを分ける
キャッシュを管理しやすいようにcacheNameを工夫する
Responseから判断する
URLの字面を見る
Response Headerを使う
日付を使う


cache名にprojectNameを使う
Requestのpathnameの字面からprojectNameを取得する
pathnameが /api/page/:projectName/:pageTitle のようになっているので、正規表現によってマッチングさせて頑張ることが可能
projectNameの変更に対して弱い
pathnameから取得できないケースもある

サーバーから送信する際にResponse HeaderにprojectIdを載せる
projectが削除されるまで一意な値を使ったほうが安全
APIのendpoint URLを変えずに対応可能

一定期間を経過したCacheを削除したいが
cache内のHTTP responseを1つずつ見ていくことは現実的でない


cache名に日付を使う
日付毎にCache objectに保存しておくと、定期的に古いCacheを削除しやすい
api-2018-11-16 というcacheNameの字面を見るだけで古いかどうか判断できる

同一URLとそのResponseが複数のCache objectに入っているとき、 caches.match(url) はどのResponseを返すか?
こういう状況

古いcacheが優先して返される

一番新しいcacheを探すには?
日付順にcacheをopenして、探していく必要がある
sw.js
Copied!
async function findLatestCache (req) {
const cacheNames = await caches.keys()
for (const date of cacheNames.sort().reverse()) {
const cache = await caches.open(date)
const res = await cache.match(req, {ignoreSearch: true})
if (res) return res
}
return null
}
cache.match() のoptionsに {ignoreSearch: true} をセットするとsearch queryを無視して取得できる
cacheをputするときにURLを正規化する必要がなくなる


Offline mode
オフラインになる前に、ページリストや欲しいページを見ておく必要はあるが
一度以上見たページはオフライン時にもイチから構築できる


課題
オフライン表示できるページが限定的
オンラインで見ていないページはオフラインでも見れない
画像のキャッシュがブラウザ任せ


6. 今後検討したいアイデア
Offline modeの改良
オフラインでの書き込み


project内の全pageデータを一気にキャッシュ
project export用のjsonを活用する

オフラインで必要なprojectのみ、ユーザーに予めダウンロードしてもらう
既存のproject export機能のようなボタンを押すだけでダウンロード可能にしたい
不要になったら削除してもらう

このデータをオフライン表示のソースとして使う
ローカルDBとして、Cacheデータにアクセスして加工して使う
APIリクエストに対するレスポンスをServiceWorker内で組み立てる
ページデータをすべて持っているので、殆どのAPIに対応可能

ServiceWokrerから定期的にデータの更新を確認してキャッシュを最新に保つこともできそう
予め指定されたprojectだけをwatchしていればよい


データの保持方法
CacheStorageを使う
/api/page-data/export/:projectName.json のResponseをそのまま保存
多少扱いやすい形式に整えてから保存
UIスレッド ↔ ServiceWorker だけで使うendpointを作って通信する手もアリ
架空のRequest /api/cache/page-data に対するResponseとして保存
全ページを一括で取得することになるので扱いづらい

IndexedDBも使う
pageごとに取得、更新できる
定期的な更新を考えるなら、page単位で操作できたほうが便利


オフラインでの書き込み
送信に失敗したリクエストをIndexedDBなどに保持
インターネット接続が復帰したら再送信


まとめ
ServiceWorkerについて

オフラインキャッシュの実装
キャッシュパターンの考察
リソースの種類によって適切な戦略を選ぶ

ServiceWorkerが入ってアプリ化の土台が整った
オンライン時のキャッシュの活用
静的リソースをキャッシュ優先で使う
APIレスポンスをprefetchして読み込み高速化
オフラインでも読めるようにする
オンライン時にアクセスしたページの逐次キャッシュ

今後
オフラインでも書けるようにする
柔軟なOffline mode + ServiceWorker Background Sync = Offline edit
Powered by Helpfeel