従来のWebアプリの常識を変える! Service WorkerがもたらすWebの未来

こんにちは。プレイドの@otoanです。

突然ですが、Service Workerって使ったことがありますか?

Service Workerは、オフラインでページを開き続けるためのキャッシュ機構として導入されましたが、それ以上のパラダイムシフトを含んでいると理解され、注目されるようになっています。注目度は高い新機能なのですが、全体像をつかめる記事を意外と見かけなかったので、今回テーマとして選びました。

KARTEでもブラウザ通知機能として、Service Workerの利用を一部始めていますので、その際に得た情報とその考察をまとめたいと思います。個々の機能の詳細やサンプルコードについては、良い記事がたくさん出ていますので探してみてください。

img_link_browser.jpg (132.9 kB)

8/4に公開されたService worker meeting notesについても、最後の方で軽く触れていますが、記事本文内には十分反映できていません(すみません...)。

Workerたち

Workerについては2009年頃からWeb Workerが導入されて以来、ずっと注目されている機能であり続けていますが、実際に使っているケースはまだあまり多くないと思います。

Workerには複数のタイプがあり実現できる機能が異なります。まずは、タイプごとにその機能をおさらいしたいと思います。

Web Worker

最初に導入されたWeb Workerは、ひとことで言うとマルチスレッドでJavascriptを動かすための仕組みです。プロセッサ時間の消費が激しい処理を、メインの処理から切り離すために導入されました。

Javascriptはシングルスレッドを前提とした言語で、イベントを使った非同期の処理は書きやすいものの、並列実行は行えませんでした。WebWorkerはこの制限を取り払うもので、メモリ空間も切り離された子スレッドを作り、メッセージングによって処理を分散させる機能です。

これによって、フロントサイドで大量のテキスト処理や計算を行うようなケースでは、メインスレッドの負荷を下げることが出来ます。

...ただ、正直、実装のコストがかかる割に、有効なユースケースは少ないと思います。

Shared Worker

Firefox, Chromeがサポートしているものの、盛り上がりに欠けているSharedWorkerです。利点を詰め切れないまま、Service Workerに取って代わられそうな不遇な規格という印象があります。日本語だと2010年の記事が検索上位に出てきてしまうという残念な感じです。

ページ間でWorkerを共有することで、今まで不可能だった表示ページ間のメッセージ通信を可能にし、共通の処理を一回で済ますことが出来ます。管理画面系やWebアプリでは有用な機能です。

ただ、Service Workerにあるアップデートの概念などが弱く、ほぼ下位互換の機能として位置づけられている感があります。

あまり調査できていないので、今回は触れません。(今後のService Workerの仕様変更によって、重要度が増す可能性がありそうです)

そして、Service Worker

Web Workerがページの内部で動く機能であるのに対して、Service Workerはページのすぐ外側で動く機能です。(個人的にはShared Workerは横で動くイメージです)

「外側」と呼んでいるのは、ページと外部の通信に割り込む機能があるためです。Service WorkerはプログラマブルなProxyとして動作するように設計されており、高度なキャッシュの管理や、条件に応じた接続先の切り替えができるなど、触れてみると想像以上に自由度の高い機能です。

この「外側で動く」という性質のため、そのライフサイクルやアップデートの手順は複雑です。しかし、この状態遷移周りは良く考えられて設計されており、Workerのアップデートや有効な状態・無効な状態がはっきりと制御できるようになっています。

また、Shared Workerと比べると、このProxyによるリソースの受け渡しのアイデアが優れています。RESTで設計されたWebアプリとの相性がよく、Workerによるリソースの共有化やオフライン時の隠蔽などを、自然な拡張として行う事ができます。Sheared Workerではメッセージングの機能追加が必要でしたが、Service Workerをうまく設計すれば、Workerの有効・無効にかかわらず、ページ側で共通のコードを利用することも出来ます。

Service Workerがもたらすもの

Service Workerが生まれた背景として、Webアプリとネイティブアプリを比べた場合の機能差分の多さ、その環境であるブラウザの弱い部分を埋めなければならないという課題感があったと思われます。Proxyの機能、Cache API、Push APIのいずれも、従来のブラウザでは実現できなかったことをサポートするための機能です。

そのため、Service Workerが当たり前の世界では、従来のWebアプリケーションの常識が過去のものとなります。

Cacheという名の新しいストレージ

スマートフォンでのサイト利用が増える中、ブラウザにはネイティブアプリと比較した場合の弱点として、オフライン時の動作の制御が十分に行えないという課題がありました。

常時安定した回線を前提とするブラウザにとって、クライアントサイドに保持するデータはキャッシュ(=高速化の手段)でしかなく、その用途であれば汎用的にブラウザに処理を移譲することが出来ました。対して、ネイティブアプリでの同様の機能は「ダウンロード済みのアセット」であって、ライフサイクルや更新タイミング、バージョンの管理などを必要とする重要な機能です。

過去、ブラウザでも何度かこの問題への取り組みは行わています。localStorageを用いたアセットの制御を行うアイデアは、保持したデータが同期的に読み込まれるという仕様上の問題などで失敗しました。次のApplication Cacheの試みは直接的にこの課題に取り組むものでしたが、MDNによれば「数多くの取り決め」があり「間違うと壊れる」という難物で、実際に広く使われることはありませんでした。(参考: MDNの記事AppCacheの問題点のまとめ記事

Service WorkerにはCache APIが追加され、この未解決問題がいよいよ解決されると期待されています。Cache APIは一連のデータセットを名前付きで管理することができ、後述のプロキシ機能でページ側に渡すことが出来ます。ネットワークから新しいデータが取得できない場合の制御もプログラマブルに行うことができるので、オフライン時には取得済みのデータセットを使い続けることも出来ます。トランザクションの機能も備えていて、一部のファイルのダウンロードに失敗しても一貫性を保つ事ができます。

WebStorageとは違いパフォーマンスへの影響がないこと、Appliation Cacheとは違いAJAXを踏まえたプログラマブルな管理を指向していることが特徴かと思います。

Cache then network by "The offline cookbook"

プログラマブルなProxy

Service Workerは、対象となるスコープのページからサーバに行われる通信に割り込むことが出来ます。ページそのものの取得、imgやscriptなどのリソースのリクエスト、およびAJAXでのリクエストは、全てService Workerにfetchイベントとして渡ります。イベント処理内で解決されたレスポンスは、あたかもサーバからのレスポンスであるかのようにページ側に返されます。スコープ内のページから外部サーバに対するリクエストも対象です。

fetchの処理の中では、そのままサーバに問い合わせを行うこともCache APIから値を取得することもでき、オフライン時に何を返すかも制御できます。もっと言えば、何もないところから何らかの値を計算して返すこともできますし、IndexedDBへクエリを投げることもできますし、(CORSの制約の許す限り)外部のサーバに問い合わせを行うこともできます。

びっくりですね。要するにおおよそなんでもアリです。リソースのリクエストに対する応答をプログラマブルに解決できるという事であり、これはRESTの考え方と相性が良さそうです。

自分がもっとびっくりしたのは、このService Workerはユーザに許可を取ることなく実行できるという点です。よく考えれば、あくまでサイトの一部として動くものなので、jQueryを使うか・使わないかの選択と同レベルの話ではあります。でも、とてもびっくりしたのですよね。今まで触れられなかった領域の機能だからでしょうか。

少しこの辺りの詳細と制限を解説します。

Service Workerがページに対して有効になるためには、あらかめスクリプトをインストールしておく必要があります。インストール時にそのService Workerが適用される範囲(Scope)も決まります。Scopeはパスの形をしていて、そのパス以下に存在しているページが開かれるとき、ページに対してインストール済みのService Workerが有効化(activate)されます。

まだ仕様が進化しそうな気配はありますが、現時点での制限としては下記のようなものがあります。

  • activateされるService Workerは一つだけです。Scopeのパスの深さや登録順で適用されるものが決まります
  • Service Workerのスクリプトファイルは、同一のOrigin=サイト上になければなりません
  • Service Workerをインストールする時、つまり初回アクセス時にはService Workerが有効になりません

上の2つの制限により、サードパーティとしての提供が難しくなっています。初回アクセス時の問題もちょっと曲者ですね。

ServiceWorker-side templating by "The offline cookbook"

ページというライフサイクルからの脱却

Service Worker自体はページと異なるライフサイクルを持っていて、必要に応じて起動・終了されます。普通、Service Workerの初期スレッドではfetchやactivateイベントにハンドラを登録するなどの初期化を行い、ページからのリクエストを待ち受けます。

一つのService Workerのインスタンスが、複数のページを担当できるところも面白いところです。ページのロード時に、すでに対象のスコープのService Workerが起動している場合、起動済みのインスタンスに対してactivateが行われます。ページはService Workerの中からはclientと呼ばれ、idで区別できます。

ページとService Workerはメッセージ通信を行うことができるので、同一のScope内にある複数のページがService Workerを核として相互にメッセージをやり取りできるようになります。つまり、Service WorkerはScopeを単位としたShared Workerとしての側面も持っています。

同時に複数のタブが開かれた状態のWebアプリ(たとえば管理画面)を想定すると、この性質は非常に有用であることがわかると思います。現状だと、ある一つのタブから情報が更新された場合、他のタブでは古い状態を許容してリロードを待つか、WebSocketなどを使ってサーバを介した同期をする必要がありますが(他のトリックもあるかもしれませんが単純な方法はないと思います)、Service Workerに処理を集約することで、素直な方法で複数タブの同時更新を行うことが出来ます。

また、ページを開く毎にサーバからリソースを取得する(例えばユーザのプロフィール)必要もありません。Service Workerはactivateされたページがあるかぎり破棄されず、コネクションやオンメモリの処理結果も維持されます。そのため、いままでページの初期化の度に行なっていた処理を省略することが出来ます。たとえばWebSocketを張り直したりするような処理ですね。

このことはSPAにもあるような、インスタンスを使い続けることによるメモリの逼迫の問題もいくらかはらみますが、イベントハンドラの開放忘れのような問題からは切り離されているので、問題はよりシンプルです。

ページというライフサイクルから開放されることで、自然と効率化される。というわけです。

(今後の仕様の変化によっては、この節は古い内容になるかもしれません)

サーバからのPush

ネイティブアプリと比べた場合のWebアプリの弱点として、通知の機能が弱い点がよく挙げられます。Push APIはまだ仕様が固まりきっていない規格ですが、この弱点を解消するべくService Workerに追加されたAPIです。

弊社では、KARTE TALKという製品の「ブラウザ通知機能」として開発を行いました。

ブラウザからシステム通知としてエンドユーザに通知を行う機能としては、Notification APIがすでに標準化されています。ただ普通のページ内のAPIとして提供されている機能なので、ブラウザやページが閉じていると、この機能を利用することは出来ませんでした。Push APIはこの弱点を解消します。

構成としてはクライアント-サーバの構造ではなく、中間に「Pushサービス」と呼ばれるサーバが介在するのが今までにない特徴です。Pushサービスはブラウザごとに存在していて、ブラウザが自動的に接続を管理しています。

大まかには、以下のような流れになります。

  • サイトのページ内でService Workerをインストール、Subscribeの開始、サーバの公開鍵を登録
  • エンドユーザから「通知の表示許可」を得る(サイトごとにブラウザが管理)
  • ブラウザからサーバに、通知用のendpoint(Pushサービス上のURL)と鍵・認証などを渡す
  • Service Worker内でPushサービスからのイベントを待ち受け
  • ...ページ遷移、サイト離脱、ブラウザのクローズなど、いろいろ発生...
  • サーバからendpointに通知を送信(コンテンツがあれば暗号化)
  • Pushサービスからブラウザにイベント送信(Windowが開いている必要はない)
  • Service Worker起動
  • pushイベント発火。Service Worker内の処理として、通知の表示、サーバへの通信などを行う
by WebRTC Needs Browser Push Notification

規格の構成から言うと、サーバからPushサービスまでの通信、Service WorkerでのAPI、通知内容暗号化の規約の3つにおおまかに分けて考えると良いと思います。暗号化の規約などは別途規格があるので、実際にはもっと多くの規格から構成されています。

クライアントーサーバ相互の通信という文脈でWebSocketと比べると、レイテンシやスループットを重視していないことが特徴かと思います。その代わり、オンラインでないブラウザに対してもPushの送信を行うことが出来ます。これはPushサービスが通知の要求を常に受け付けているためです。通知にはTTLを設定しておいて、失敗したらサーバが通知を受け取るという仕組みが予定されているようです(Push Message Receipts..仕様策定中)。

規格安定度の進捗としてはまだまだ不安定です。少し前までは暗号化規約が、現在はPushサービスでの送信成功・失敗をサーバに通知する機能などが頻繁に更新されているようです。今までのService Workerの文脈からはいくらか外れていることもあり、この辺りはプロキシ・キャッシュの機能とは独立させようと言う提案もあるようです。(参考

通信回復時の自動同期

Background Sync APIという名前で試験的な実装が始まっています。

今回は調査不十分につきあまり触れませんが、オフラインからオンラインに復帰した時などにService Workerがイベントを受け取ることが出来ます。

オンラインに戻った時のコンテンツの自動更新をサポートできるようになります。

いつから使えるのか?サポートの状況について

通常のサイトで利用するケースを考えると、IE, Edge, SafariがService Workerをサポートしていないことは大きな制限です。すぐに使える状況ではないと言わざるを得ません。

ただ、Edgeでは開発中のステータス、Safariの5年以内の対応予定の中では、対応自体にはポジティブである旨が書いてあるので、規格の将来性としては概ね問題はないものと思われます。(参考:edgeのStatuswebkitの5ヵ年計画

Firefox, Chromeでの対応は十分進んでいるので、管理画面系で対応ブラウザを絞れる場合は、採用を検討し始めてもよいのではないでしょうか。

なお、まだまだ仕様の進化は続いています。8/4に公開されたService worker meeting notesについて最後の項で触れていますが、けっこう大きな仕様変更の可能性もありそうです。

で?いったいどうなるの?

せっかくなので、ちょっと思い切ったことを書いてみようかと思います。

  • サイトは一つのWebApplicationです。SWを核にページ間がつながっており、メッセージングとリソースの共有でゆるやかに統合されます。
  • バックグラウンドでサイトは生き続けます。Push通知は当たり前になり、バックグラウンドでのアセット更新など、表示と無関係な処理が重要になります。バージョンの概念も重要になります
  • REST化がますます進みます。ViewModelのデータも、仮想的なパスに割り当ててSWで実装することができます
  • フロントエンドがクライアントとサーバ(SW)に分かれます。データ取得とその処理はSWの役割で、結果はキャッシュされ、タブ・ウィンドウ間で共有されます。オンライン/オフラインの状態は隠蔽されます
  • 非同期処理はPromiseでの記述に変わります。async, awaitに関する入門記事が増え、async.jsに似たフロー制御のライブラリも広く使われるようになります
  • フレームワークは、サーバ・ワーカ・フロントを統合して管理できる構造になります

...何個くらい当たるかな...?

みなさんはどんな予想図を描きますか?

参考

主に日本語の参考資料です。

(急いで追記)

8/4に、Service worker meeting notesという記事が公開されました。7/28, 29に行われたMozilla, Microsoft, Apple, Google, Samsung, Facebookという主要プレーヤーのディスカッションの内容と意見募集の記事です。(参加者にMicrosoft, Apple, Samsungの名前が挙がっているのは嬉しいですね)

簡単にまとめます。

Multiple service worker instances for parallelisation

  • Service Workerを並列実行したい
  • 100を超えるようなリソースの要求が、単一のJavascriptのスレッドを経由するとボトルネックになる
  • 個々のService Workerのライフサイクルはページより短くなる。(不便ならShared Workerを使ってくれ)
  • breaking changeなのでご意見募集中

Changing update-checking to behave as expected

  • importScriptsで読み込んだファイルの更新チェックのタイミングを変更したい
  • max-ageなどの設定は無視し、Service Workerのアップデートチェックの一部として扱う
  • Service Workerがアップデートされる時、importされたスクリプトのbyte diffを取って、変更があれば更新

Providing an opt-in concurrent fetch

  • SWの起動前にリクエストを投げられるようにする
  • SWの起動時間の分ページの取得が遅くなる(数百ms)ケースがあるので、SW起動前にリクエストを投げておけるオプション
  • ページのリクエストヘッダをSWのインストール時に設定できる

Fetch event & clients

  • clientがまだロードされていない時のclientIdを取得できるようにする
  • ページそのもののfetchイベントには、clientIdが入っていなかった(初期化されていなかったため)
  • 予約済みのidや、<a target="xxx">のように指定した場合はそのidが渡るようになる

Dropping "opener" for service worker pages

  • HTTPのページからtarget="_blank"でHTTPSのページが開かれた時のwindow.openerがセキュリティホール
  • 新しくHTTPSで開かれたページから、window.openerのHTTPページヘの通信を行えてしまう
  • window.openerを制限するレスポンスヘッダはある(?)が、SWでも対応しないといけない

Handling failed requests

  • Service Workerからエラーレスポンスを返した場合の挙動を変更
  • SWなしの自動リロードは'magic'的で好かれないのでやらない
  • 'サービスワーカ無しでリロード'のボタンをブラウザが表示させる方向(?)

Fetches from within a service worker that should go via a fetch event

  • Service Worker内、fetchイベントから発生したリクエストをハンドリングできるようにする
  • showNotificationのアイコンの取得は、SWを経由しないようになっている(ループするかもしれないので)
  • 別なイベントをトリガするようにする

Range requests

  • range requestsに対応したいが、セキュリティの問題を抱えるのでどう解決するか...

minor things

  • いろいろな改善

Wow I love bullet points. ちゃんとした翻訳は誰か書いてくれると思います。

なお、最初の項目の仕様変更が通ると、この記事の「ページというライフサイクルからの脱却」という文章の内容が古くなります(部分的にShared Workerに読み替えて下さい)。Service WorkerをImmutableなものとして扱うというのは、たしかに理にかなっているかもしれません。Shared Workerを介したメッセージングは出来ますし、中〜長期間保持したいデータはCache APIやIndexedDBに置くほうが、意味合い的に正しいケースは多そうです。

美味しいものほど足が早いorz

お後がよろしいようで...

追記(2017/12)

その後の動きをPLAID Advent Calendar 2017で記事にしました。こちらもぜひ。

最後に

ウェブ接客プラットフォーム「KARTE」を運営するプレイドでは、
KARTEを支える技術に興味を持つエンジニア(インターンも!)を募集しています。

詳しくはこちら(Wantedly)をご覧ください。
もしくはこちらのボタンよりお気軽に「話を聞きに行きたい」と押してください!