Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

Mercari Web版 に Workbox で Service Worker を導入する話

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 でこのようなことをつぶやいております。

f:id:h1tima:20171217224744p:plain

PWA のような比較的新しい構成の技術スタックが必要になってくることもあり、今回はサイトのオフライン対応を検証して、お客さまにオフラインでもメルカリを楽しんでいただくべく、導入検討してみることにしました。

Workbox で導入してみる

Sevice Worker を起動してオフライン対応するのはとても大変です。厳密に言うと Offline Cache を持たせるのはそんなに難しくないのですが、Service Worker や Cache を含めたライフライクルを考慮するのがとても大変です。Client側 (ブラウザ) に Cache されるため、 Cache Strategy や Expire の期限を間違えると配信側からコントロール出来ないファイルなどが生じてしまいます。

Sevice Worker は IndexedDB と CacheStorage というストレージに格納されます。そして当然ですがそれらは有限で、手動で Cache を破棄する仕組みを作らなくてはいけませんでした。

f:id:h1tima:20171217223539p:plain

そういったライフサイクルの考慮の煩雑な部分をライブラリ側でやってくれ、比較的簡単に設定できるのが、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 は余計なクエリパラメータがついていると全滅してしまうようです。

f:id:h1tima:20171217194731p:plain

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 から返されません。 f:id:h1tima:20171217203515p:plain

もちろんオフラインでも動作しません。 f:id:h1tima:20171217203608p:plain

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 は完成です。 f:id:h1tima:20171217211305p:plain

オフラインでも、一度訪問したページで RuntimeCache ルールと一致する URL やアセットなら動作します。 f:id:h1tima:20171217211914p:plain

その他にも、 add to home screen などを地味にやりました。これは本当にアセットさえあればすぐに出来ますし、Android はフルスクリーンで表示されます。 以下の animation はわかりづらいかもしれませんが、機内モードにしてもオフラインなのできちんと動作します。

f:id:h1tima:20171218205026g:plain

まとめ

残念ながらこの記事を公開する時にはやや検証不足で、本番に投入することはできませんでしたが、 Workbox のような便利なライブラリを使えば、既存のサイトでも1日ほどで比較的容易にオフライン対応出来る事ががわかりました。

ただ、結局 Workbox のような便利なライブラリにも Cache の Expire や、 Strategy をきちんと考慮しなくてはなりません。 特にメルカリのような一日100万出品以上あるような更新頻度の高いサービスでは、それこそサイトの更新性は重要だと私は思います。

また、現時点ですが期待していたパフォーマンスの向上などは オフラインの時 の恩恵しか受けられなくて、またどうしても一度訪問する必要があるとなると、オフラインの恩恵はきちんと設計を考えないとお客さまにとって便利なものにするには難しいなと感じました。

特に Service Worker 自体の起動がどうしてもあるので、オフライン体験向上と パフォーマンスの両軸向上は Workbox だけでは難しいのではと感じています。 (もちろん、オフライン体験向上のためのライブラリなので、素晴らしいものではあります。) Navigation Preload などを使って Service Worker の boot の並列化するとははまだ試してないので、どこかで試して見たいと思います。

パフォーマンスに関しては、App Shell ModelPRPL Pettern を使った実装を進める事により、 Cache Strategy や Expire が明確になるなと感じました。 もちろん既存のアーキテクチャから大幅に改修しないと実現出来ない部分もありますが、必要性を改めて感じれて、良い経験になりました。

冒頭にも軽く触れましたが、メルカリでは Web の再構築を一緒にやってくれるエンジニアを絶賛募集しております。

私の Twitter でも Facebook でもいいですので、是非お気軽にお話させてください。

メルカリでは Web の強化に向けてフロントエンドエンジニアを募集しています!一緒に働ける仲間をお待ちしております。

採用情報 | 株式会社メルカリ

明日 20 日は @ikkou さんから面白い制度の発表があるみたいですよ!お楽しみに!