Mercari Advent Calendar 2017 19日目は フロントエンドチームの @_hitima が JP Web版 にてサイトのオフライン対応を検証している話をします。
メルカリのWeb版強化への道
メルカリは iOS と Android のアプリ版のほかに Web ブラウザから利用可能な Web 版があります。アプリ版と機能面で差はあるものの、購入から出品まで一通りのことはできるようになっています。本エントリではWeb版の強化施策として現在進行中の Service Worker 導入について解説します。
Facebook のザッカーバーグ CEO はかつてこのような事を言いました。
When I’m introspective about the last few years I think the biggest mistake that we made, as a company, is betting too much on HTML5 as opposed to native… because it just wasn’t there.
iOS 向けのアプリを HTML5 ベースにしたのは失敗だったと。もう5年以上も前の話ですね。
Facebook のこの有名な話は、パフォーマンスが起因する話が主だったと思いますが、Service Worker
, App Shell Model
, PRPL Pettern
などを駆使した Google 提唱する Progressive Web Apps
と言う HTML / JS / CSS
だけでほぼ構成された新しいスタンダードが台頭してきていて、そのほとんどが今、解決されようとしています。
PWA
とよく呼称されますね。 主にモバイルユーザーの体験向上を目指す技術の集合体を指す名前だと私は理解しています。(マーケティング用語だと言う方もいます)
弊社会長の山田も、Twitter でこのようなことをつぶやいております。
PWA のような比較的新しい構成の技術スタックが必要になってくることもあり、今回はサイトのオフライン対応を検証して、お客さまにオフラインでもメルカリを楽しんでいただくべく、導入検討してみることにしました。
Workbox で導入してみる
Sevice Worker を起動してオフライン対応するのはとても大変です。厳密に言うと Offline Cache を持たせるのはそんなに難しくないのですが、Service Worker や Cache を含めたライフライクルを考慮するのがとても大変です。Client側 (ブラウザ) に Cache されるため、 Cache Strategy や Expire の期限を間違えると配信側からコントロール出来ないファイルなどが生じてしまいます。
Sevice Worker は IndexedDB と CacheStorage というストレージに格納されます。そして当然ですがそれらは有限で、手動で Cache を破棄する仕組みを作らなくてはいけませんでした。
そういったライフサイクルの考慮の煩雑な部分をライブラリ側でやってくれ、比較的簡単に設定できるのが、Google が出している Workbox になります。
公式にもありますが、次のようなケースは導入検討をおすすめします。
- 運営しているサイトをオフライン対応させたい
- 再訪問時の負荷パフォーマンスを向上させたい
Workbox 導入方法
developers.google.com に詳しく乗っていますが、自分の理解のためにも当ブログを書いています。
webpack / gulp / npm script での導入方法が紹介されていますが、メルカリでは webpack の導入方法を検証してみます。
まずは Workbox を単体で install します。
npm のサイトは --save-dev
で紹介されていますが、 公式 を信用して --save
で install します。
npm install --save workbox-sw
そして webpack で使うために workbox-webpack-plugin
を入れます。
$ npm install workbox-webpack-plugin --save-dev
install したら webpack.config の plugin 内に以下のように記述します。
// webpack.config.js ※実際の設定ではありません new WorkboxBuildWebpackPlugin({ cacheId: 'mercari-web', globDirectory: 'webroot/', globPatterns: [ '*.{jpg,png,gif,webp}', 'assets/**/*.{css,jpg,png,gif,webp,svg,ttf}', ], globIgnores: [ 'dont-add-pre-cache.png', ], // swSrc: __dirname + '/app/serviceworker.js', swDest: path.join('webroot/', 'sw.js'), clientsClaim: true, skipWaiting: true, runtimeCaching: [ // index { urlPattern: '/jp/', handler: 'networkFirst', options: { cacheName: 'topPage', cacheExpiration: { maxAgeSeconds: 60 * 60 * 24, }, }, }, ], }),
webpack で Cache するファイルを指定していきます。 それぞれのプロパティが何を示すのか見ていきましょう。
いくつかは未検証ですが、公式より翻訳してみました。
Properties
parameter | description |
---|---|
cacheId | Optional String Cache に付けられるID。 localhost: などで複数開発する時に必要 |
globDirectory | String Cache を指定するディレクトリ。globPatterns の基準ディレクトリになる |
globPatterns | Optional Array of String ここに記載されたパターンにマッチしたファイルが生成するsw.jsファイルの PreCache manifest に記述される |
globIgnores | Optional (String or Array of String) globPatterns のパターンにマッチしたファイルから除外したい file / dir などをここに記載 |
swSrc | String injectManifest()でのみ有効 PreCache 意外の記述を手動でやりたい場合に指定する。 injectManifest() については後述します |
swDest | String Service Worker の build 後の パスやファイルを指定します |
clientsClaim | Optional Boolean generateSW() のみ有効 injectManifest() で使う場合は手動で swSrc のターゲットに書く Service Worker が active になった時にすぐにクライアントの制御を開始するかどうか |
skipWaiting | Optional Boolean generateSW() のみ有効 injectManifest() で使う場合は手動で swSrc のターゲットに書く Service Worker が 待っている lifecycle stage をスキップするかどうか |
runtimeCaching | Optional Array of Object generateSW() のみ有効 injectManifest() で使う場合は手動で swSrc のターゲットに書く runtimeCaching のルールを書きます。runtimeCaching について詳しくは後述します。 |
ignoreUrlParametersMatching | Optional Array of RegExp generateSW() のみ有効 injectManifest() で使う場合は手動で swSrc のターゲットに書く この配列の正規表現のいずれかと一致する場合、一致したパラメータは PreCache を検証する前に削除されます。 [/./] を使用すると、すべての URL パラメータを無視できます。トラフィック監視のための code や外部ベンダーの code など パラメータが 予想出来ない場合に便利 |
handleFetch | Optional Boolean workbox-sw がネットワークリクエストに応答する fetch イベントハンドラを作成するかどうか。 開発中など Service Worker が古くなったコンテンツを提供したくない場合に役立ちます。 |
directoryIndex | Optional String generateSW() のみ有効 injectManifest() で使う場合は手動で swSrc のターゲットに書く ‘/’で終わるURLのリクエストが失敗すると、値がURLに追加され、2回目のリクエストが行われます。 |
templatedUrls | Optional Object with (Array or String) properties サーバー側のロジックに基づいてURLが生成される場合、その内容は複数のファイルやその他の一意の文字列値に依存することがあります。 文字列の配列とともに使用すると、それらは globPatterns として解釈され、パターンに一致するファイルの内容は URL を一意にバージョンするために使用されます。単一の文字列で使用すると、指定された URL に対して帯域外で生成された一意のバージョン情報として解釈されます。 |
maximumFileSizeToCacheInBytes | Optional number PreCache するファイルの最大サイズを決めておくことができます。間違って globPatterns の値と一致していた可能性のある非常に大きなファイルを意図せずに事前にキャッシングできなくなります。単位は byte |
manifestTransforms | Optional Array of ManifestTransform マニフェスト変換の配列。生成されたマニフェストに対して順番に適用されます。 modifyUrlPrefix または dontCacheBustUrlsMatching も指定されている場合、対応する変換が最初に適用されます。 |
modifyUrlPrefix | Optional Object with String properties Web ホスティング設定が local の開発環境と一致しない場合などに、manifest files のエントリの prefix の操作ができ ます |
dontCacheBustUrlsMatching | Optional RegExp この正規表現に一致するアセットは、 URL を介して一意にバージョン管理されているものとみなされます。これは、 PreCache を設定する際に行われる通常の HTTP Cache 破棄が行われなくなります。既存のビルドプロセスで各ファイル名にハッシュ値がすでに挿入されている場合は、これらの値を検出するRegExpを指定することをお勧めします。これを設定すると、sw.js 側の 対象ファイルのリビジョンが外されます。 |
navigateFallback | Optional String generateSW() のみ有効 injectManifest() で使う場合は手動で swSrc のターゲットに書く あらかじめ Cache されていない URL に対する Navigation Request に応答する NavigationRoute を作成するために使用されます。 SPA などに有効で App Shell Model にもとづいて全てのページで再利用される UI などに使うためのものです。 |
navigateFallbackWhitelist | Optional generateSW() のみ有効 injectManifest() で使う場合は手動で swSrc のターゲットに書く Array of RegExp ナビゲーションルートが適用される URL を制限する正規表現の配列。 |
詳しくは WorkboxWebpackPlugin やその config から行けるページからご覧ください。
生成物は swDest
配下で、以下のようなファイルが出来ています。これを js から読み込む形になります。
// sw.js importScripts('workbox-sw.prod.v2.1.2.js'); /** * DO NOT EDIT THE FILE MANIFEST ENTRY * * The method precache() does the following: * 1. Cache URLs in the manifest to a local cache. * 2. When a network request is made for any of these URLs the response * will ALWAYS comes from the cache, NEVER the network. * 3. When the service worker changes ONLY assets with a revision change are * updated, old cache entries are left as is. * * By changing the file manifest manually, your users may end up not receiving * new versions of files because the revision hasn't changed. * * Please use workbox-build or some other tool / approach to generate the file * manifest which accounts for changes to local files and update the revision * accordingly. */ const fileManifest = [ { "url": "android-chrome-144x144.png", "revision": "d39066b92534e01491725e6ffb2b217d" }, ... ]; const workboxSW = new self.WorkboxSW({ "cacheId": "mercari-web", "skipWaiting": true, "clientsClaim": true }); workboxSW.precache(fileManifest); workboxSW.router.registerRoute('/jp/', workboxSW.strategies.networkFirst({ "cacheName": "topPage", "cacheExpiration": { "maxAgeSeconds": 86400 } }), 'GET');
app.js などで以下のように記載し、ページ表示時に実行するようにしておく。
// app.js if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('sw.js').then(registration => { console.log('SW registered: ', registration); }).catch(registrationError => { console.log('SW registration failed: ', registrationError); }); }); }
これで一通りの Service Worker の登録は完了し、 Offline Cache の仕組みが導入されました。ここまではすぐに完了すると思います。
私たちの冒険はここからですが、その前に Workbox や Service Worker の大事な 4つの概念を見ていきましょう。
PreCache と RuntimeCache
名前の通りですが、
PreCache は Service Worker 起動時に事前に静的リソースを Cache するもので、
RuntimeCache は 正規表現なども含められる特定の url が fetch されたら Cache しましょうと言うものです。
静的リソースを PreCache して、動的なリソースや API からの Get Request などを RuntimeCache するようなイメージだと思います。
generateSW() と injectManifest()
前項の Properties にも記載されているのが散見される generateSW()
と injectManifest()
とは、 workbox-webpack-plugin
から提供されている JS ファイル出力方法の違いです。
generateSW()
は webpack.config.js
で全て制御し、そのまま使える Service Worker の起動ファイルを出力します。
細かいチューニングや Workbox 側だと制御出来ない事をやろうとした場合、injectManifest()
に切り替えて上げる必要があります。
方法は上述の swSrc
を指定して、 injectManifest の名前の通り PreCache の manifest ファイル以外を自分で書きます。 generateSW()
で生成された runtimeCache を自分で書いて行く形ですね。何も困る事が無い限りは generateSW()
で進めた方が良いと思います。また、 injectManifest()
にした場合 webpack 側で指定している プロパティが効かなくなるものがあるので、注意が必要です。
※ 上述の generateSW() のみ有効
を参考にしてください。
Cache Strategy
優先的に Cache を見に行くのか、ネットワークを見に行くのか、などを選択してそのリソースはどれに適しているのかを考える必要があります。
RuntimeCache
に設定していきましょう。以下のような形ですね。
{ urlPattern: new RegExp('assets/fonts/'), handler: 'cacheFirst', },
handler Properties
parameter | description |
---|---|
CacheFirst | 1度 Cache されたらアップデートされません。長期間更新されないアセットに最適です |
CacheOnly | Cache からのみレスポンスを返します。caches.match() を直接呼ばず、Cache 設定を使用し、RequestWrapperで定義されたプラグインを起動します |
NetworkFirst | ネットワークを優先して返します。オフラインや呼び出し先の都合で通信が失敗した場合は Cache を呼び出します |
NetworkOnly | ネットワークからのみレスポンスを返します。 |
staleWhileRevalidate | Cache とネットワークの両方から並列に要求され、Cache されたバージョンで応答します。 その時に Cache は、ネットワークから返されるものに置き換えられます |
詳しく知りたい方は workbox-runtime-caching を参照してください。
Cache 期限の制御
PreCache
Workbox で出力したファイルを見ると、リビジョン番号が振られています。
// sw.js { "url": "assets/img/common/common/bg-modal-app-banner.jpg", "revision": "ded796b91262026737e5488b41c9b931" }
リビジョンは PreCache のリストが新しくなれば更新され、 Activate 時によしなに変更分だけ差分検知してくれて削除、再追加してくれるようです。
RuntimeCache
Workbox には workbox-cache-expiration と言う Class があり、キャッシュに期限 ( Expire ) を設ける事が出来ます。
これを指定しないとストレージは溜まって行く一方なので、きちんと戦略を立てる必要があります。
特にアイテム画像などは膨大なので、Expire 期限を短めに設定したり、 UI 部分やロゴなどはかなり長めにとる。などの Cache Strategy が重要になってきます。
以下は実際の今回取りたいキャッシュ戦略ではありませんが、揮発する時間を設けられると言う一例です。
// top page は ネットワークを優先しつつ、一日で Expire するようにする { urlPattern: '/jp/', handler: 'networkFirst', options: { cacheName: 'topPage', cacheExpiration: { maxAgeSeconds: 60 * 60 * 24, }, }, }, // font 関連はそもそも UPDATE することは無いに等しいので、長めに設定する { urlPattern: new RegExp('assets/fonts/'), handler: 'cacheFirst', options: { cacheName: 'fonts', cacheExpiration: { maxAgeSeconds: 60 * 60 * 24 * 30, }, }, },
後述しますが、今回は結果的に PreCache はほぼ使えなかったので、PreCache の Expire の確認は実際にはしていません。
詳しく知りたい方は workbox-cache-expiration を参照してください。
PreCache が 再利用できなかった
メルカリ Web の開発環境で実際に動かしてみたのですが、何と PreCache の再利用が出来ていません。
原因はリソースの呼び出しの時にCDNのキャッシュ対策でクエリパラメータを付けていたせいでした。
PreCache は余計なクエリパラメータがついていると全滅してしまうようです。
dontCacheBustUrlsMatching
を使えば回避ができそうですが、いったん RuntimeCache
で手っ取り早く Offline Cache 出来そうだったので、ここに関しては後で調査することとします。
以下スクリーンショットは、シンプルな構成で PreCache に登録した2つのファイルが、クエリパラメータあるなしでオフラインでどうなるかを検証したものになります。build 時にクエリパラメータを差し込んでるサイトは少なくないと思います。
const fileManifest = [ { "url": "img/hoge.png", "revision": "0c043d85d31bd77aca6dc7d17c3bfa6e" }, { "url": "img/hoge.svg", "revision": "e306aa2643f2a4d0bc1caf29df602f73" }, { "url": "index.html", "revision": "7dc612bd22a1710ad8c318480f474ea5" }, { "url": "index.js", "revision": "a5910ae5d5d1b107b1a9a0590bf084b8" } ];
クエリパラメータで View に書き込んでいるファイルは Service Worker から返されません。
もちろんオフラインでも動作しません。
dontCacheBustUrlsMatching
で対象ファイルを指定して、 sw.js
側の PreCache 指定しているところから revision が消えていることは確認できましたが、やはりクエリパラメータが指定してあるところは PreCache してくれませんでした。
今日は時間の関係で調べられませんでしたが、引き続き解決策を模索しようと思います。
RuntimeCache で静的コンテンツもまとめてキャッシュしてみた
少々強引ですが、 RuntimeCache では正規表現を使いまとめて対象ファイルをキャッシュすることが出来ます。
例えば、 以下のような形で一括で指定できます。本来は静的コンテンツとして取得できない特定の API を一時的にキャッシュしたり、ユーザーの一意のidを含む画像などをキャッシュするいわゆる
PreCache で表現出来ない URL に対して Cache させたい場合に使います。
アセットファイルのフォントを一括してキャッシュしたい場合(上から generateSW() と injectManifest() の記載方法を2つ紹介しておきます)
// webpack.cofig.js => runtimeCaching: [] 内 { urlPattern: new RegExp('assets/fonts/'), handler: 'cacheFirst', },
// 手動の場合は swSrc のターゲットに書く workboxSW.router.registerRoute(/assets/fonts//, workboxSW.strategies.cacheFirst({}), 'GET');
CDN からのアクセスを一括して Service Worker で キャッシュしたい場合
{ urlPattern: new RegExp('https://www-mercari-jp.akamaized.net/'), handler: 'staleWhileRevalidate', },
多少強引ですが、これで Offline Cache は完成です。
オフラインでも、一度訪問したページで RuntimeCache ルールと一致する URL やアセットなら動作します。
その他にも、 add to home screen などを地味にやりました。これは本当にアセットさえあればすぐに出来ますし、Android はフルスクリーンで表示されます。
以下の animation はわかりづらいかもしれませんが、機内モードにしてもオフラインなのできちんと動作します。
まとめ
残念ながらこの記事を公開する時にはやや検証不足で、本番に投入することはできませんでしたが、
Workbox のような便利なライブラリを使えば、既存のサイトでも1日ほどで比較的容易にオフライン対応出来る事ががわかりました。
ただ、結局 Workbox のような便利なライブラリにも Cache の Expire や、 Strategy をきちんと考慮しなくてはなりません。
特にメルカリのような一日100万出品以上あるような更新頻度の高いサービスでは、それこそサイトの更新性は重要だと私は思います。
また、現時点ですが期待していたパフォーマンスの向上などは オフラインの時
の恩恵しか受けられなくて、またどうしても一度訪問する必要があるとなると、オフラインの恩恵はきちんと設計を考えないとお客さまにとって便利なものにするには難しいなと感じました。
特に Service Worker 自体の起動がどうしてもあるので、オフライン体験向上と パフォーマンスの両軸向上は Workbox だけでは難しいのではと感じています。
(もちろん、オフライン体験向上のためのライブラリなので、素晴らしいものではあります。)
Navigation Preload などを使って Service Worker の boot の並列化するとははまだ試してないので、どこかで試して見たいと思います。
パフォーマンスに関しては、App Shell Model
や PRPL Pettern
を使った実装を進める事により、 Cache Strategy や Expire が明確になるなと感じました。
もちろん既存のアーキテクチャから大幅に改修しないと実現出来ない部分もありますが、必要性を改めて感じれて、良い経験になりました。
冒頭にも軽く触れましたが、メルカリでは Web の再構築を一緒にやってくれるエンジニアを絶賛募集しております。
私の Twitter でも Facebook でもいいですので、是非お気軽にお話させてください。
メルカリでは Web の強化に向けてフロントエンドエンジニアを募集しています!一緒に働ける仲間をお待ちしております。
明日 20 日は @ikkou さんから面白い制度の発表があるみたいですよ!お楽しみに!