ネイティブアプリ風Webアプリ「PWA」を実現する3つの技術
数年前からGoogleは「Progressive Web Apps」(PWA)という技術を提唱してその普及を推進している。PWAはネイティブアプリケーションのように動作するWebアプリケーションであり、オフラインでも動作し、プッシュ通知などの機能も利用できる。本記事ではこのPWAの中核となる技術の解説と、PWAに対応したWebアプリケーションを作成するための流れを紹介する。
目次
「Progressive Web Apps」(PWA)とは
一昔前は「ネットサービス」といえばPCのWebブラウザからアクセスして利用するものがほとんどだった。しかし、スマートフォンが普及した昨今では多くのサービスがスマートフォン向けの対応を行っている。今ではPCからのアクセスよりもスマートフォンからのアクセスのほうが多いサービスは珍しくなく、スマートフォン向けの専用アプリを用意しているサービスも多い。
とはいえ、ネイティブアプリ開発にはWebアプリケーションとは異なるプラットフォーム固有の知識が必要となる。Webアプリケーションと同じ技術を使ってネイティブアプリを開発できるフレームワークなどもあるが、それでもいくつかの追加工程は必要となるほか、プラットフォームごとの制約もあり、アプリストアでの配布を行うためにはプラットフォームの審査も必要となる。
Webアプリケーションの強みとしては、OSを問わずさまざまなプラットフォームで動作する点がある。画面サイズやWebブラウザごとの挙動の違いに対する配慮こそ必要だが、基本的には同一のコードでPCでもスマートフォンでもタブレットでも動作するアプリケーションを作成できる。また、昨今ではWebブラウザの機能拡張が活発に行われており、たとえばWebブラウザから端末のカメラやマイク、各種センサなどにアクセスするAPIや、端末内にデータを保存するためのAPIも導入されている。これらを利用することで、Webアプリケーションでもネイティブアプリと比べて遜色ない機能が実装できるようになっている。
Webアプリケーションにはネットワーク回線が利用できない場所では利用できないという弱点があるが、これを解決するための技術も登場した。また、Androidでは端末のホーム画面からWebアプリケーションを起動する仕組みも導入されている。こういった機能を利用することで、Webアプリケーションでありながら、ネイティブアプリケーションに近いユーザー体験を提供できるものが実装できるようになっており、そういったアプリケーションを「Progressive Web Apps」(PWA)と呼ぶ。
PWAの特徴
PWAはGoogleなどが提唱している概念で、具体的な要件などは明確ではないのだが、その特徴としてMDN web docsでは次のように解説されている。
- Discoverable:コンテンツを検索エンジンで見つけることができる
- Linkable:URLでアプリやコンテンツを共有できる
- Safe:アプリとサーバーとの通信経路が安全で第三者からの攻撃を受けない
- Responsive:携帯電話、タブレット、PC、スマートTV、スマート冷蔵庫などさまざまなデバイスで利用できる
- Progressive:古いWebブラウザでも基本的な機能は使用でき、最新Webブラウザではフル機能が使用できる
- Installable:アプリを端末のホーム画面に追加できる
- Network independent:オフラインや貧弱なネットワーク環境でも使用できる
- Re-engageable:新しいコンテンツがあるときに通知を送信できる
このうち「Discoverable」や「Linkable」については一般的なWebサイトも備える特徴であり、また「Safe」についてはSSL/TLSを用いたHTTPS接続によって実現される。「Responsive」や「Progressive」についても、昨今では多くのWebサイトで課題とされているもので、既存技術であるCSSメディアクエリや一般的なJavaScirptライブラリ/フレームワークなどで対応できるものだ。そのため、PWAで特に重要な特徴は「Installable」や「Network independent」、「Re-engageable」の3つと言える。
本記事では、この3つの特徴を実現するための「Web App Manifest」および「Service Workers」、「Push」の3つの技術について解説していく。
「Installable」を実現する技術「Web App Manifest」
「Installable」を実現する技術は、「Web App Manifest」と呼ばれている。これは特定の設定ファイルを用意してWebページ内の「link」タグでそのファイルを指定することで、デバイスのホーム画面にそのWebアプリにアクセスするためのアイコンを追加できるようにするものだ。これによって端末のホーム画面からネイティブアプリのようにWebアプリを起動することができる。
この設定ファイルでは表示するアイコンやアプリケーション名のほか、カテゴリやアプリケーションの簡単な説明といった属性も定義でき、これらをWebアプリケーションカタログなどの用途に利用することも想定されているようだ。
「Network independent」を実現する技術「Service Workers」
モバイル端末では状況によって通信回線の品質が大きく変化する可能性がある。たとえば地下などの場所では通信しにくくなるほか、基地局が整備されておらず圏外になるような場所もまだある。スマートフォンアプリではネットワークが利用できる場所でコンテンツをダウンロードし、端末内に保存(キャッシュ)しておくことで、圏外や通信が繋がりにくい場所でもコンテンツにアクセスできる仕組みを導入しているものがあるが、これと同様のことをWebアプリでも可能にする技術が「Service Workers」だ。
Service Workersは、指定したサイト(もしくはURL)へのネットワークアクセスに対してプロクシのように動作するJavaScriptプログラムをWebブラウザ内でバックグラウンドで実行するものだ(図1)。
Service Workersでは指定したサイト内(もしくはディレクトリ以下)のネットワークアクセスを完全に「横取り」することができ、たとえば前回のアクセス時に取得したコンテンツを端末内にキャッシュしておいて次回以降のアクセスではそれを返すことができるほか、端末内で生成したデータをあたかもWebサーバーにアクセスして取得したかのように返すこともできる。
「Re-engageable」を実現する技術「Push」
「Re-engageable」は、「Push」という仕組みで実現できる。これは、サーバーからの通知をバックグラウンドで受信し、それをトリガーにしてあらかじめ指定しておいた処理を実行する技術で、Service Workersと組み合わせて使用する。たとえばサーバーが提供しているデータに対し何らかの変更が発生した際にPushを利用してその旨をWebブラウザに通知し、その通知をトリガーにバックグラウンドでキャッシュを更新する、といったことが可能となる。
PWAの問題点
このようにWebアプリケーション開発において大きな可能性を持つPWAであるものの、サポートするサービスはまだ多くない。普及が進んでいない理由の1つに、Webブラウザ側でのAPIのサポート不足がある。たとえばService Workersについては、Google Chromeは2015年にリリースされたバージョン40からサポートしているが、Safariでサポートされるようになったのは2018年にリリースされたバージョン11.1(iOS版は11.3)からだ。また、PushについてはSafariは未だに実装されていない(デスクトップ版はApple独自仕様のものが実装されている)。Web App Manifestに関しては現在でも「実験的」という位置付けで、Androidではサポートが進んでいるものの、iOS端末では未知というステータスだ。こういった状況であるため、開発時には対象とするWebブラウザがどのAPIをサポートしているかを確認しておく必要がある。
WebアプリケーションをAndroidのホーム画面に登録できる「Web App Manifest」
それでは、まずはWebアプリケーションをデバイスのホーム画面に登録できるようにするための技術である「Web App Manifest」について解説していこう。こちらは非常にシンプルな仕組みで、JSON形式でWebアプリケーションの情報を記述したファイルを用意し、そのファイルのURLをHTML内のhead要素内に次のようなlinkタグで記述するだけで実現できる。
<link rel="manifest" href="<マニフェストファイルのURL>">
なお、マニフェストファイルは「.json」もしくは「.webmanifest」(MIMEタイプは「application/manifest+json」)という拡張子に設定するのが一般的だ。仕様では「.webmanifest」という拡張子が規定されているようだが、現状は「.json」でも認識されるという。
このマニフェストファイルで定義できる情報は次の表1のとおりだ。
属性名 | 指定できる値 | 説明 |
---|---|---|
background_color | CSSの「background-color」プロパティが認識できる文字列 | スタイルシートが読み込まれるまでにページ背景として使用する色 |
categories | カテゴリ文字列もしくはその配列 | アプリケーションのカテゴリ |
description | 文字列 | アプリケーションを説明する文字列 |
dir | "auto"もしくは"ltr"、"rtl" | 文字を表示する際の方向 |
display | "fullscreen"もしくは"standalone"、"minimal-ui"、"browser" | Webアプリケーションの表示方法 |
iarc_rating_id | 文字列(レーティングID) | 年齢レーティングを行っているIARCがアプリケーションによって発行されたレーティングID |
icons | アイコンURL、サイズ、形式を格納した配列 | 使用するアプリケーションアイコン |
lang | 文字列 | アプリケーションの言語。日本語なら"ja-JP" |
name | 文字列 | アプリケーション名 |
orientation | "any"もしくは"natural"、"landscape"、"landscape-primary"、"landscape-secondary"、"portrait"、"portrait-primary"、"portrait-secondary" | アプリケーションの表示方向 |
prefer_related_applications | trueもしくはfalse | 関連するネイティブアプリケーションが存在する場合そちらを優先してインストールするかどうか |
related_applications | アプリケーション情報を格納した配列 | 関連するアプリケーション |
scope | 文字列 | アプリケーションのスコープ(提供URL/ディレクトリ) |
screenshots | 画像URL、サイズ、形式を格納した配列 | アプリストアなどに表示するアプリケーションのスクリーンショット |
serviceworker | Service Workersの情報を記述したオブジェクト | アプリケーションが使用するService Workersの情報 |
short_name | 文字列 | アプリケーション名を短縮表示する際に使われる代替アプリケーション名 |
start_url | URL | アプリケーションの開始URL |
theme_color | 色を指定する文字列 | アプリケーションのテーマカラー |
Web App Manifestについてはまだ規格の策定中であり、Webブラウザによってサポートされる情報が異なるほか、今後これらが変更される可能性もある点には注意したい。また、「category」や「description」、「iarc_rating_id」、「lang」、「screenshot」といった属性はWebアプリケーションの配信ストアのようなサービスでの利用を前提としているようで、現状あまり意味をなさない。そのため、現状では指定しておいた方が良い属性は以下の6つに留まる。
- background_color
- display
- icons
- name
- short_name
- theme_color
これらを指定したマニフェストファイルは、次のようになる。
{ "background_color": "<背景色>", "display": "<表示方法>", "icons": [{ "src": "<アイコンのURL>", "sizes": "<縦>x<横>", "type": "<アイコンとして使用する画像のMIMEタイプ>" }, "src": "<アイコンのURL>", "sizes": "<縦>x<横>", "type": "<アイコンとして使用する画像のMIMEタイプ>" }, ... ], "name": "<アプリケーション名>", "short_name": "<短いアプリケーション名>", "theme_color": "<テーマカラー>" }
ただし、現状でこの機能をサポートするWebブラウザは限られており、現状はAndroid端末でのみサポートされている。Android端末ではWeb App Manifestに対応したWebアプリケーションへのアクセス時にホーム画面に登録するためのポップアップが表示され、そこで「ホーム画面に追加」などのボタンをタップすることで、Webアプリケーションを起動するためのアイコンをホーム画面に追加できる。また、Chrome 47以降ではnameおよびbackground_color、icons属性から自動的にアプリケーション起動時のスプラッシュスクリーンを表示する機能も提供されている。
バックグラウンドでネットワークプロクシ処理を実行する「Service Workers」
続いてはService Workersについて説明していこう。前述のとおり、Service WorkersはWebブラウザとWebサーバーの間に割り込む、プロクシのように動作するJavaScriptプログラムを実現するものだ。PWAにおいてはオフライン時にWebサーバーにアクセスせずにWebブラウザ内に保存された各種リソースをWebアプリケーションに返すために使われる。
Service Workersは「Web Workers」という、JavaScriptコードを非同期に実行させる技術をベースとして実装されている。このWeb Workersについても簡単に紹介しておこう。
マルチスレッド的な非同期処理を実現するWeb Workers
多くのプログラミング言語では、プログラム中から別スレッドもしくは別プロセスを起動して並列処理を行う仕組みが用意されている。Web Workersはこれらに似た仕組みで、実行させたいコードを引数で指定してWorker()コンストラクタを実行することで、指定したコードをメインのスレッドとは別のスレッド(もしくはプロセス等)で実行できる。
Web Workersの特徴として、Web Workerとして実行されるコード(Worker()コンストラクタで指定したコード)は、その呼び出し元コード(Worker()コンストラクタを実行するコード)とは完全に独立して実行され、変数や関数定義などを共有しない点がある。さらに、Workerオブジェクトによって実行されるコードはDedicatedWorkerGlobalScopeというコンテキスト内で実行され、利用できる機能が制限される。具体的にはFunctions and classes available to Web Workersドキュメントで解説されているが、たとえばWebブラウザのWindowオブジェクトやDOMにはアクセスできない。
Workerオブジェクトによって実行されるコードとその呼び出し元コードとの通信は、メッセージを送受信することで行える。たとえばWorkerオブジェクトによって実行されたコード内でpostMessage()メソッドを実行すると、引数で与えたメッセージが実行元のWorkerオブジェクトに送信される。WorkerオブジェクトではaddEventListener()メソッドもしくはonmessage()プロパティを使って「message」イベントに対するイベントハンドラを登録しておくことで、メッセージ受信時に指定されたメッセージを引数として受け取ってコードを実行できる。
逆に、WorkerオブジェクトからはpostMessage()メソッドでメッセージを送信できる。メッセージが送信されると、Workerオブジェクトによって実行されたコード内ではmessageイベントが発生し、こちらも同様にイベントハンドラを使うことで送信されたメッセージを受け取ることができる。
Web WorkersとService Workersの違いとイベント処理
Service WorkersはWeb Workersから派生したもので、基本的な特性は同じであるが、いくつかの点で違いがある。まず、Service WorkersはService WorkersGlobalScopeというコンテキスト内で実行される。このコンテキスト内ではメッセージを送信するpostMessage()メソッドが利用できない一方で、コンテンツをキャッシュするために利用できるCacheStorage型のcachesオブジェクトなどがあらかじめ定義されている。
また、Web WorkersはWorker()コンストラクタを使ってコードの実行を開始させるが、Service Workersではコードを直接実行させるのではなく、ServiceWorker.register()メソッドを使って実行したいコードを登録することで処理を開始させる。さらに、Web Workersでは呼び出し元コードを実行するページが閉じられるとWeb Workerとして実行されたコードも終了するが、Service Workersでは呼び出し元ページが閉じられても永続的に動作する。Webブラウザを終了させても、再びWebブラウザが起動した際にService Workersは再起動する。ただし、Service Workersは常時バックグラウンドで動作しているわけではない。後述するイベントの発生時に必要に応じて起動され、コード内で定義されたイベントハンドラが実行され、イベントハンドラの実行が完了するとアイドル状態となる。
なお、Service WorkersではJavaScriptのPromise機構が多用されるため、使いこなすにはPromiseについての知識が必須となる。ドキュメントなどを確認して把握しておこう。
Service Workersの実装方法
Service Workersとしてコードを実行するには、実行するコードのURLを引数として与えてnavigatorオブジェクトのserviceWorker.register()というメソッドを実行する。Service Workersに対応していないWebブラウザの場合はserviceWorkerプロパティが存在しないので、それを利用してService Workersが利用できるかを判定できる。具体的なコードは次のようになる。
if ('serviceWorker' in navigator) { navigator.serviceWorker.register('<実行するコードのURL>) .then(registration => { // 登録成功時に実行する処理を記述 }) .catch(err => { // 登録失敗時に実行する処理を記述 }); } else { // Service Workersが利用できないときに実行する処理を記述 }
ちなみに、navigator.serviceWorker.register()では第2引数としてオプションが格納されたオブジェクトを渡すことも可能だ。現時点で指定できるオプションはService Workersの適用範囲を示す「scope」オプションのみとなっている。このオプションは相対パスで指定するのが一般的で、このパスに合致するページでのみService Workersが動作するようになる。このオプションを省略した場合、navigator.serviceWorker.register()を実行しているページが存在するディレクトリが指定されたように振る舞う。
navigator.serviceWorker.register()を実行すると、Webブラウザはまず第1引数で指定したURLがそのページと同じオリジン内で、かつscopeオプションで指定したスコープ(scopeオプションが指定されなかった場合はnavigator.serviceWorker.register()を実行したページと同じディレクトリ以下)にあるかどうかをチェックする。この条件を満たしていない場合、Service Workersの登録に失敗する。
続いてURLからJavaScriptコードを取得してパース・実行する。このときリソースをロードできなかったり、JavaScriptとしてのパースに失敗したりした場合も登録に失敗する。
JavaScriptコードの実行が完了すると、続けてそのコード内で「self」として参照されているオブジェクトで「install」イベントが発生する。通常はコード内でこのイベントに対するイベントハンドラを定義しておき、そこでService Workersを動かすために必要な処理や静的リソースのキャッシュといった処理を実行する。もしこのイベントハンドラ内で何らかのエラーが発生した場合、Service Workersのインストールは行われない。なお、Service Workersの更新時には、この処理が完了するまでは古いService Workersは実行を継続する。installイベントの処理が完了し、かつ古いService Workers内で実行されていた処理が完了したタイミングで古いService Workersは停止される。
installイベントが完了すると、続いて「activate」イベントが発生する。Service Workersの更新時は、Service Workersの入れ替えが完了し、かつ古いService Workersが停止したタイミングでこのイベントが発生する。installイベントの発生時とは異なり、activateイベントの発生時には新しいService Workersのみが動作していることが保証されることから、このイベントハンドラではキャッシュの削除・追加といったService Workersの更新に関連するような処理を実行するのが一般的だ。
これらの処理が完了すると、Service Workersを登録したページが存在するディレクトリ以下(もしくはnavigator.serviceWorker.register()のscopeオプションで指定したパス)へのネットワークアクセス(リクエスト)が発生するたびに、Service Workersに対して「fetch」イベントが発生するようになる。このイベントに対応するイベントハンドラではFetchEvent型のオブジェクトが引数として与えられ、このオブジェクトのrespondWith()メソッドを実行することで、そのリクエストに対する応答を行える。
そのほか、Service WorkersではPush関連や通知関連のイベントも定義されている(表2)。
イベント名 | 説明 |
---|---|
install | インストール処理の開始 |
activate | アクティベート処理の開始 |
fetch | Webページのリクエスト発生 |
push | Server Push通知の受信 |
notificationclick | 表示された通知をユーザーがクリックした |
notificationclose | 表示された通知をユーザーが閉じた |
sync | SyncManager.registerが呼び出された |
また、将来的には課金関連のイベントなども実装が検討されているようだ。
Service Workersの実装例
それでは、実際にService Workersを実装した例を見てみよう。今回の記事で使用しているコードはhttps://osdn.net/users/hylom/pf/pwa_samplecode/scm/tree/master/で公開しており、手元にダウンロードもしくはクローンし、適当なWebサーバーで公開することで試せるようになっている。簡易的なWebサーバーとして動作するPythonコードも用意しており、ディレクトリ内で「make」コマンドを実行すればこのWebサーバーが8080版ポートで動作するようMakefileも用意した。
ここで紹介するサンプルコード(「service_worker」)は、JavaScriptを使ってWebサーバーから非同期に「data.json」というファイルを取得し、そのファイル中「data」というキーで定義されている文字列を表示するものだ。ページのロードだけでなく「更新」ボタンを押したタイミングでも同様の処理が実行される(図2)。
このページではService Workersが登録されていなければ「インストール」ボタンが、登録されていた場合は「アンインストール」ボタンが表示され、これらをクリックすることでService Workersの登録もしくは登録解除ができるようになっている(図3)。なお、Service Workersが利用できないWebブラウザの場合はどちらも表示されない。
ちなみに、Service Workersの登録状況はGoogle Chromeであればデベロッパーツールの「Application」タブ内にある「Service Workers」項目で確認できる(図4)。
「インストール」ボタンをクリックしてService Workerを登録すると、以降はページのロードやdata.jsonのダウンロード時にService Workerが介入し、適宜キャッシュを返すようになる。例えばChromeのデベロッパーツールの「Network」タブでネットワークを「Offline」に設定した場合でも、ページをロードできるようになる(図5)。
このサンプルコードでは、オフライン時にはdata.jsonへのアクセスについてはキャッシュではなく、「offline(<ランダムに生成した数字>)」という文字列を返すようにっており、「更新」ボタンを押すたびに表示される数字が変わることを確認できる。また、デベロッパーツールの「Network」タブで確認すると、ネットワークアクセスは発生せず、Service Worker内で処理が実行されていることが確認できる(図6)。
さて、このページのHTMLファイル(index.html)は次のようになっている。
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8"> <title>ServiceWorker Example</title> </head> <body> <h1>ServiceWorker Example</h1> <img src="./sample.jpeg" width="400" height="267"> <p>Browser is <strong><span id="browser_status"></span></strong>. <button id="update_status_button">更新</button> </p> <div id="install-svcw" style="display:none;"> Service Workerはインストールされていません。 <button id="install_svcworker_button">インストール</button> </div> <div id="uninstall-svcw" style="display:none;"> Service Workerはインストール済みです。 <button id="uninstall_svcworker_button">アンインストール</button> </div> </body> <script src="./index.js"></script> </html>
また、主要な処理はindex.jsというファイルで実装されている。ここではまず、ページのロード完了後に「更新」および「インストール」、「アンインストール」ボタンのクリック時に実行するイベントハンドラを登録し、続いてService Workerの登録状態をチェックする処理を行う「checkServiceWorkerRegistered()」関数と、data.jsonを取得してそのデータをページ内に表示する「updateStatus()」関数を実行するよう、windowオブジェクトに対しイベントハンドラを設定している。
window.addEventListener('load', event => { // 各種イベントハンドラを登録 // 「更新」ボタンをクリック→updateStatus()を実行 document.getElementById("update_status_button") .addEventListener("click", updateStatus); // 「インストール」ボタンをクリック→registerServiceWorker()を実行 document.getElementById("install_svcworker_button") .addEventListener("click", registerServiceWorker); // 「インストール」ボタンをクリック→unregisterServiceWorker()を実行 document.getElementById("uninstall_svcworker_button") .addEventListener("click", unregisterServiceWorker); // ページロード時にService Workerの登録状況をチェックする checkServiceWorkerRegistered(); // ページ内のテキストを更新する updateStatus(); });
Service WorkersはWebブラウザの挙動を変化させるものであるため、今回のサンプルコードのように登録時はユーザーにその旨を確認し、了承を得た上で登録させるのが望ましい。表示しているページを対象にしたService Workersがインストールされているかどうかは、navigator.serviceWorker.getRegistration()メソッドで確認できる。このメソッドは、解決時にService WorkersがインストールされていればServiceWorkerRegistrationオブジェクトを、そうでなければundefinedを返すPromiseを戻り値として返す。
今回のアプリケーションではこれを利用して、Service Workersが登録(インストール)されていればアンインストール、登録されていなければインストールするためのボタンを表示する「checkServiceWorkerRegistered()」という関数を実装している。なお、ここでは単にボタンを表示するだけで、ボタンをクリックしないと実際の処理は行われない。
function checkServiceWorkerRegistered() { // Servie Workerが登録されているかチェックする if (!"serviceWorker" in navigator) { // Service Workerが利用できない環境では // Service Workerに関する表示を行わない return; } navigator.serviceWorker.getRegistration() .then(registration => { // registrationが存在すれば登録済み、 // undefinedなら未登録 // 結果に応じてinstall-svcwもしくはuninstall-svcwを // 表示する const elInst = document.getElementById("install-svcw"); const elUninst = document.getElementById("uninstall-svcw"); if (registration) { elUninst.removeAttribute("style"); elInst.setAttribute("style", "display:none;"); } else { elInst.removeAttribute("style"); elUninst.setAttribute("style", "display:none;"); } }); }
updateStatus()関数は、「./data.json」というURLに対しGETリクエストを送信し、受け取ったデータをJSONとしてパースしてその内容をページ内に表示する処理を行っている。この関数では、XMLHttpRequest()に代わるAPIとして最近多くのブラウザがサポートし始めているfetch APIを使ってリクエストを送信している。
function updateStatus() { // data.jsonをGETし、その結果から // ページ内のステータス表示を更新する updateStatusText("..."); fetch("./data.json") .then(response => { return response.json(); }) .then(result => { if (result.data === undefined) { updateStatusText("no data"); return; } updateStatusText(result.data); }) .catch(err => { console.log(err); updateStatusText(err); }); }
updateStatus()関数内で呼び出されているupdateStatusText()関数は、ページ内で「browser_status」というIDが付けられた要素を取得し、その要素内のテキストを引数で与えられたテキストに置き換える処理を行っている。
function updateStatusText(message) { // ページ内のテキストを更新する // 「browser_status」というIDが付けられた要素を取得 const elem = document.getElementById("browser_status"); if (elem === null) { // 要素が存在しなければエラーログを出力 console.log("no #browser_status element"); return; } // 要素内のテキストを書き換える elem.innerText = message; }
このページでは、前述の通りページ内の「インストール」ボタンをクリックすることでService Workersが登録されるようになっている。「インストール」ボタンのクリック時に実行されるのが次のregisterServiceWorker()関数だ。ここでは「svc_worker.js」というJavaScriptファイルをService Workersとして登録している。
function registerServiceWorker() { // Service Workerを登録する if ("serviceWorker" in navigator) { navigator.serviceWorker.register("./svc_worker.js") .then(registration => { // 成功ログを出力する console.log("Service Worker registration succeed." + `scope: ${registration.scope}`); checkServiceWorkerRegistered(); }) .catch(err => { // 失敗ログを出力する console.log(`Service Worker registration failed: ${err}`); checkServiceWorkerRegistered(); }); } }
また、「アンインストール」ボタンをクリックした際に実行されるunregisterServiceWorker()関数は次のようになっている。ここではnavigator.serviceWorker.getRegistration()で得られるServiceWorkerRegistrationオブジェクトのunregister()メソッドを使って登録解除を実行している。
function unregisterServiceWorker() { // Service Workerの登録を解除する if ("serviceWorker" in navigator) { // Service Workerの登録情報を取得するために // まずServiceWorkerRegistrationオブジェクトを取得する navigator.serviceWorker.getRegistration() .then(registration => { // ServiceWorkerRegistrationオブジェクトを取得できた場合、 // unregister()で登録解除を行う if (registration) { registration.unregister() .then(result => { if (result) { console.log("Service Worker unregister succeed."); } checkServiceWorkerRegistered(); }); } }) .catch(err => { // 失敗ログを出力する console.log("Service Worker unregister failed."); }); } }
Service Workersとして実行されるJavaScriptコード(svc_worker.js)では、前述のように登録時に発生する「install」や「activate」イベント、そしてService Workersを登録したページでネットワークアクセスが発生した際に発生する「fetch」イベントに対するイベントハンドラを定義する。
まずinstallイベントに対するイベントハンドラだが、こちらは前述のとおりnavigator.serviceWorker.register()メソッドが実行され、Service Workersとして実行されるコードがパースされた後に呼び出されるイベントだ。今回のコードでは、ここでcachesオブジェクトを使ってWebページに必要なHTMLファイルやJavaScriptファイル、画像ファイルなどをWebブラウザ内にキャッシュしている。
// キャッシュを識別する名前を定義 const CACHE_NAME = "knowledge_serviceworker_example"; // キャッシュ対象を指定する // 今回は index.html、index.js、sample.jpeg の3つ const TARGET_URLS = [ "index.html", "index.js", "sample.jpeg", ]; // installイベントハンドラを登録 self.addEventListener("install", event => { // 引数で指定したPromiseが解決されるまで待機 event.waitUntil( // キャッシュを開く self.caches.open(CACHE_NAME) .then(cache => { // 指定したURLをすべてキャッシュする return cache.addAll(TARGET_URLS); }) ); });
installイベントに対するイベントハンドラには引数としてInstallEvent型のオブジェクトが渡される。イベントハンドラ内では、このオブジェクトのwaitUntil()メソッドを実行することで処理の成功/失敗を判断するようになっている。waitUntil()メソッドは引数としてPromiseオブジェクトを取り、このPromiseオブジェクトが解決されれば成功、リジェクトされれば失敗となる。
また、fetchイベントに対するイベントハンドラには引数としてFetchEvent型のオブジェクトが渡され、このオブジェクトのrespondWith()メソッドを使って処理結果を返すようになっている。今回は次のようにリクエストのURLをチェックして異なる結果を返すよう実装した。
- 「/data.json」へのリクエストの場合:JavaScript内でレスポンスを生成して返す
- 「/」へのアクセスの場合:index.htmlのキャッシュを返す
- キャッシュされているパスへのリクエストの場合:キャッシュを返す
- それ以外のリクエストの場合:リクエストURLに対しネットワークリクエストを行ってその結果を返す
いずれの場合も、ページ内のJavaScriptコードからはネットワーク経由でそのコンテンツを取得したかのように扱われる。具体的なコードは次のようになる。
// fetchイベントハンドラを登録 self.addEventListener("fetch", event => { // オンラインかどうかをチェック if (self.navigator.onLine) { // オンラインならネットワークリクエストを行う event.respondWith(fetch(event.request)); return; } // リクエストURLをチェック const url = new URL(event.request.url); // data.jsonへのアクセスの場合、特別なレスポンスを返す if (url.pathname == "/data.json") { event.respondWith(async function() { // JSON形式のレスポンスボディを含むレスポンスを作成する const data = `{"data":"offline (${Math.floor(Math.random() * 100)})"}`; const headers = new Headers(); headers.append("Content-Type", "application/json"); const resp = new Response(data, { status: 200, statusText: "OK", headers: headers }); return resp; }()); return; } // ルートへのアクセスの場合、index.htmlを返す let targetRequest = event.request; if (url.pathname == "/") { url.pathname = "/index.html"; targetRequest = url.toString(); } // リクエストに対するレスポンスを設定する event.respondWith( // リクエストに該当するキャッシュを探す self.caches.match(targetRequest, {cacheName: CACHE_NAME}) .then(response => { if (response) { // キャッシュが存在したのでそれを返す return response; } // キャッシュが存在しないのでネットワークリクエストを行う return fetch(event.request); }) ); });
なお、このサンプルコードではself.navigator.onLineプロパティをチェックして、単純にオンライン/オフラインで挙動を切り替えているが、たとえばオンラインでも回線が遅いといったケースや、毎回キャッシュされたコンテンツを返したい、というケースもあるだろう。その辺りは状況に応じてカスタマイズする必要がある。こういったキャッシュ処理を簡潔に記述するための「Workbox」というライブラリも公開されているので、検討してみると良いだろう。
Push機能を実装する
続いてはPush機能について説明していこう。前述のとおり、PushはWebサーバーからWebブラウザに対して非同期でメッセージを送受信できる機能だ。PushはService Workersを使用するのが前提となっており、WebブラウザはPushによって送信されたメッセージを受信すると、そのサイト/ページに紐付けられたService Workersで「push」イベントが発生し、イベントハンドラが実行される。そのため、メッセージを受信する際にはページを開いている必要はない。
Push送信の手順
現行のPushの実装では、Pushを使ったメッセージの送信者はWebサーバーとは必ずしも一致する必要はない。そのため、メッセージ送信元は「Application Server」(アプリケーションサーバー)などと呼ばれている。アプリケーションサーバーは、Webブラウザ上でPush購読処理を行った際に得られるエンドポイント(Webブラウザの提供者が運営しているPushメッセージ送信サービスのURL)に対し、送信したいメッセージや送信先といった情報を含むリクエストを送信することでメッセージを送信する。
具体的には、Webブラウザ上でPush購読処理を実行するPushManager.subscribe()メソッドを実行すると、その戻り値としてエンドポイントのURLやPush購読を行うクライアントを識別するためのSubscription ID(サブスクリプションID)などを含むPushSubscription型のオブジェクトが返されるようになっている(図7)。これらの情報をアプリケーションサーバーに送信し、保存しておく。
Pushでメッセージを送信する際には、このエンドポイントに対してサブスクリプションIDとともにメッセージを送信する。Pushメッセージ送信用サーバーは受け取ったサブスクリプションIDやメッセージを検証し、それを対応するWebブラウザに転送する(図8)。
ただ、単にエンドポイントとサブスクリプションIDのみを使うだけでは、たとえばエンドポイントに対しランダムに生成したIDでメッセージを送りつけるといったような攻撃も可能になってしまう。また、もしエンドポイントが攻撃を受けて乗っ取られるようなケースが発生した場合、通信内容が傍受される懸念もある。そのため現在のPushの実装では公開鍵暗号を使ってメッセージの送信元を検証するための「VAPID」という仕組みや、データをエンドツーエンドで暗号化して送受信する仕組みが導入されている。
これら認証・暗号化の仕組みやメッセージ送受信の流れはMozillaのブログで紹介されているが、やや複雑だ。そのため詳細については割愛するが、流れとしては以下のようになる。
- アプリケーションサーバー(Pushメッセージの送信元)であらかじめ256ビットの楕円DSA(ECDSA)を使った公開鍵と暗号鍵を作成しておく
- Webサーバーはアプリケーションサーバーの公開鍵をWebブラウザに送信し、これを受け取ったWebブラウザはこれらの情報を引数としてPushManager.subscribe()メソッドを実行する
- WebブラウザはPushManager.subscribe()メソッドの戻り値に含まれるエンドポイントや認証トークン、エンドツーエンド暗号化に使用する鍵をアプリケーションサーバーに転送する。アプリケーションサーバーではこれらをデータベースなどに保存しておく
- アプリケーションサーバーがPushを使ってメッセージを送信する際には、まずPushManager.subscribe()メソッドの戻り値として受け取ったエンドツーエンド暗号化用の鍵を使ってメッセージを暗号化し、同じくPushManager.subscribe()メソッドの戻り値として受け取ったトークンやアプリケーションサーバー秘密鍵で生成した署名とともにエンドポイントに送信する
- エンドポイント経由でリクエストを受け取ったPushサービスは署名を検証し、適切であればメッセージをWebブラウザに送信する
- Pushサービスからメッセージを受信したWebブラウザはメッセージを復号し、Service Workersのpushイベントを発生させる
これらの処理はやや複雑なので、あらかじめこれらが実装されたライブラリを使うことをおすすめする。こういったライブラリはいくつかが存在するが、今回はGitHubのweb-push-libsリポジトリで公開されているものを紹介する。
web-push-libsでは、アプリケーションサーバーの公開鍵/秘密鍵を生成するためのvapidというライブラリや、Node.jsやPython、Java、C#、PHPでPushによるメッセージ送信を行うためのライブラリが公開されている。
web-push-libsのvapidツールを使った公開鍵の準備
Pushを使用してメッセージを送信するには、前述のとおりアプリケーションサーバー側で公開鍵・秘密鍵を生成しておく必要がある。これは、web-push-libsのvapidライブラリで行える。
vapidライブラリではJavaScriptおよびPython、Rust向けのコードが提供されている。たとえばPythonでは次のようにpip(Python 3系ならpip3)経由でインストールできる。
$ pip3 install py-vapid
このパッケージをインストールすると、「vapid」コマンドが利用可能になる。なお、Linux環境において非rootユーザーで実行した場合、通常インストール先は~/.local/bin以下になる。
このコマンドを「--gen」オプション付きで実行すると、実行したディレクトリ内に公開鍵(public_key.pem)と秘密鍵(private_key.pem)が生成される。
$ vapid --gen Generating private_key.pem Generating public_key.pem
また、作成した公開鍵/秘密鍵が保存されているディレクトリで「--applicationServerKey」オプション付きでvapidコマンドを実行すると、BASE64でエンコードされたアプリケーションサーバー鍵が表示される。
$ vapid --applicationServerKey Application Server Key = BAB2k ...(省略) ←BASE64でエンコードされたアプリケーションサーバー鍵文字列
Webアプリケーションでは、PushManager.subscribe()を実行する際にこのアプリケーションサーバー鍵文字列をオプション引数として与えれば良い。
web-push-libsのpywebpushを使ったPush送信
前述のようにweb-push-libsではいくつかの言語向けにPushによるメッセージ送信を行うライブラリを提供しているが、今回はPython向けのpywebpushを使ってみよう。pywebpushは、pipコマンド(もしくはpip3コマンド)で次のようにインストールできる。
$ pip3 install pywebpush
pywebpushをインストールすると、同時に同名(「pywebpush」)のPushでメッセージを送信するためのコマンドがインストールされる。このコマンドでは、次のようにしてメッセージを送信できる。
$ pywebpush --data <送信したいデータを格納したファイル> --info <PushManager.subscribe()が返した情報を格納したJSONファイル> --claims <「claims」情報を格納したJSONファイル> --key <アプリケーションサーバーの暗号鍵>
「--info」オプションで指定する「PushManager.subscribe()が返した情報を格納したJSONファイル」というのは、次のようにPushManager.subscribe()が返すPushSubscriptionオブジェクトのtoJSON()メソッドを実行することで得られる。
registration.pushManager.subscribe(options) .then(pushSubscription => { const info = JSON.stringify(pushSubscription.toJSON()); });
なお、toJSON()メソッドが返すのはあくまで文字列ではなくオブジェクトなので、JSON文字列に変換するにはこのようにJSON.stringift()メソッドを実行する必要がある。
また、「--claims」オプションで指定する『「claim」情報を格納したJSONファイル』というのは、次のようなJSONファイルだ。
{ "aud": "<エンドポイントURL>", "exp": <UNIX時刻で表現した有効期限>, "sub": "mailto:<送信者の連絡先メールアドレス>" }
ただし、pywebpushではこのうち「sub」プロパティのみを指定すれば良い。つまり、次のようなJSONファイルを指定すれば良い。
{ "sub": "mailto:<送信者の連絡先メールアドレス>" }
「--key」オプションで指定するアプリケーションサーバーの暗号鍵は、vapidコマンドで生成したものを指定する。今回の例の場合、「private_key.pem」を指定すれば良い。
Service Worker側での処理
Webブラウザに対しPushでメッセージが送信されると、Service Workerでは「push」イベントが送信され、このイベントに対応付けられたイベントハンドラが実行される。このイベントハンドラには引数としてPushEvent型のオブジェクトが渡され、そのdataプロパティで送信されたメッセージを取得できる。
Pushの実装例
それでは、Pushを実装するに当たって実際にどのような処理が必要なのかをサンプルコードで説明していこう。こちらのサンプルコード(「push」)は先に紹介したService Workersのサンプルコードを一部修正したもので、Pushで送信されたメッセージをWebブラウザ内に保存して表示するというものだ。
このページを開くと、「service_worker」サンプルコードと同様にService Workerをインストールするボタンが表示される。異なるのは、ページ上部に「メッセージ:」という表示がある点だ。ここでは、Webブラウザ内に永続的にデータを記録しておけるIndexedDBを使用して作成したデータストア内に格納されたメッセージを表示している(図9)。
ここで「インストール」ボタンをクリックすると、通知を表示する許可を求めるメッセージが表示される(図10)。
今回のサンプルコードでは通知の表示は行わないが、Pushは通知表示にも使われるのでこのような許可が必要になるようだ。ここで「許可」をクリックするとPush購読が開始され、デベロッパーツールのコンソールにエンドポイントなどの情報がJSON形式で表示される(図11)。
本来はこの情報はネットワーク経由でWebサーバーに送信して保存するのだが、今回はサンプルコードということで、このJSON文字列をコピーしてローカルの「subscription.json」というファイルに保存する。この情報を使って、ローカルにインストールした「pywebpush」コマンドを次のように実行するとPushでメッセージが送信される。
$ pywebpush --data data.json --info subscription.json --claims claims.json --key private_key.pem
ここで、たとえば「{ "message": "hello, world!" }」というデータを送信すると、Pushによってこれを受信したWebブラウザは次のようにこのメッセージを表示する(図12)。
前述のとおりメッセージ受信はバックグラウンドで行われるため、このページを開いていなくてもメッセージの受信は実行できる。ただし、Webブラウザが実行されていない場合はメッセージは受信できない。
実際のコード内容は次のようになる。まずテストページのHTMLファイル(index.html)は先のサンプルコードとほぼ同じだ。
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8"> <title>Push Example</title> </head> <body> <h1>Push Example</h1> <div> <h2>メッセージ:</h2> <ul id="messages"> </ul> </div> <div id="install-svcw" style="display:none;"> Service Workerはインストールされていません。 <button id="install_svcworker_button">インストール</button> </div> <div id="uninstall-svcw" style="display:none;"> Service Workerはインストール済みです。 <button id="uninstall_svcworker_button">アンインストール</button> </div> </body> <script src="./test-db.js"></script> <script src="./index.js"></script> </html>
また、メインのスクリプトファイルのindex.jsだけでなく、IndexedDBに関する処理をまとめた「test-db.js」もscriptタグで読み込ませている。ここではIndexedDBのデータストアにメッセージを保存する「addMessage()」と、メッセージを読み出す「getMessages()」の2つのメソッドを定義している。IndexedDBについて詳細は割愛するので、詳しくはこのコードを直接確認してほしい。
メインのJavaScriptコード(index.js)では、windowの「load」イベントに対するイベントハンドラで「インストール」および「アンインストール」ボタンへのイベントハンドラ設定、IndexedDBからのデータ読み出しおよび読み出したデータの表示といった処理を行っている。
window.addEventListener("load", event => { // 各種イベントハンドラを登録 // 「インストール」ボタンをクリック→registerServiceWorker()を実行 document.getElementById("install_svcworker_button") .addEventListener("click", registerServiceWorker); // 「インストール」ボタンをクリック→unregisterServiceWorker()を実行 document.getElementById("uninstall_svcworker_button") .addEventListener("click", unregisterServiceWorker); // ページロード時にService Workerの登録状況をチェックする checkServiceWorkerRegistered(); // メッセージ一覧を更新する reloadMessages(); // 5秒おきにメッセージ一覧を更新する setInterval(reloadMessages, 5000); });
IndexedDBからのデータ読み出しおよび読み出したデータの表示処理はreloadMessages()という関数にまとめている。
// メッセージ一覧を更新する function reloadMessages() { const elem = document.getElementById("messages"); if (elem === null) { console.log("no #message element"); return; } // 子ノードを全削除 while (elem.firstChild) { elem.removeChild(elem.firstChild); } // メッセージを取得 testDb.getMessages() .then(messages => { // 子ノードを作成 for (const msg of messages) { const newElem = document.createElement("li"); newElem.textContent = msg; elem.append(newElem); } }) .catch(error => { console.log(error); }); }
サーバー認証やメッセージの暗号化のためには、Push購読の際にアプリケーションサーバーの公開鍵を登録する必要がある。今回はweb-push-libのvapidコマンドを使って事前に生成しておいたアプリケーションサーバー鍵をソースコード中にハードコードして利用している。
const VAP_APP_SERVER_KEY = "<ここにvapid --applicationServerKeyコマンドで生成した鍵文字列を記述する>";
ページ内の「インストール」ボタンをクリックした際に実行されるregisterServiceWorker()関数では、Service Workersの登録に加えて、Push購読のための設定も行う。Service Workersの登録処理については、先のサンプルコードの場合と変わらない。
Push購読についてはnavigator.serviceWorker.register()メソッドなどが返すServiceWorkerRegistrationオブジェクトのpushManager.subscribe()メソッドを使用する。ただし、このメソッドはService Workersの初期化が完了していないと実行できない。そのため、navigator.serviceWorker.readyプロパティを使ってService Workersがアクティブな状態になった際に処理を実行するようにしている。
function registerServiceWorker() { // Service Workerを登録する if ("serviceWorker" in navigator) { navigator.serviceWorker.register("./svc_worker.js") .then(registration => { // 成功ログを出力する console.log("Service Worker registration succeed." + `scope: ${registration.scope}`); }) .catch(err => { // 失敗ログを出力する console.log(`Service Worker registration failed: ${err}`); checkServiceWorkerRegistered(); }); navigator.serviceWorker.ready.then(registration => { // Service Workerが利用可能になったら // Pushを受信する設定を行う const options = { userVisibleOnly: true, applicationServerKey: VAP_APP_SERVER_KEY, }; registration.pushManager.subscribe(options) .then(pushSubscription => { console.log("subscribe succeed."); const prop = pushSubscription.toJSON(); console.log("property: " + JSON.stringify(prop)); }) .catch(err => { console.log("subscribe failed: " + err); }); checkServiceWorkerRegistered(); }); } }
前述のとおり本来はここでsubscribe()メソッドの実行結果で得られるエンドポイントなどの情報をWebサーバーに送信して保存するべきだが、今回はその代わりにコンソールにそれらをJSON形式で表示している。
「アンインストール」ボタンをクリックした際に実行されるunregisterServiceWorker()関数では、Push購読の解除とService Workersの登録解除を行っている。Push購読情報はPushManager.getSubcription()でPushSubscription型のオブジェクトとして取得できる。Push購読の解除は、このオブジェクトのunsubscribe()メソッドを実行することで行える。
function unregisterServiceWorker() { // Service Workerの登録を解除する if ("serviceWorker" in navigator) { // Service Workerの登録情報を取得するために // まずServiceWorkerRegistrationオブジェクトを取得する navigator.serviceWorker.getRegistration() .then(registration => { // ServiceWorkerRegistrationオブジェクトを取得できた場合、 // まずPush購読を停止する registration.pushManager.getSubscription() .then(subscription => { if (subscription) { subscription.unsubscribe() .then(result => { console.log("unsubscribe succeed."); }) .catch(err => { console.log("unsubscribe error: " + err); }); } }) .catch(err => { console.log("getSubscription error: " + err); }); // unregister()で登録解除を行う if (registration) { registration.unregister() .then(result => { if (result) { console.log("Service Worker unregister succeed."); } checkServiceWorkerRegistered(); }); } }) .catch(err => { // 失敗ログを出力する console.log("Service Worker unregister failed."); }); } }
Service Workersとして実行されるsvc_worker.jsでは、先のサンプルコードをベースとしつつ、pushメッセージを受け取った際に発生するpushイベントのイベントハンドラを追加している。ここではまず、importScripts()メソッドでIndexedDB関連の処理を記述したtest-db.jsファイルを読み込んでいる。Web WorkersやService Workers内で別のJavaScriptファイル中に含まれているオブジェクトや関数を使用したい場合、このようにしてそのファイルを読み込ませる必要がある。
// test-db.jsを読み込む self.importScripts("./test-db.js");
また、pushイベントのイベントハンドラは次のようになる。ここでは受信したメッセージを引数として渡されたイベントオブジェクトから取得し、IndexedDBのデータストアに保存する処理を行っている。
// pushイベントハンドラを登録 self.addEventListener("push", event => { // 通知設定が行われているかをチェック if (!self.Notification || self.Notification.permission !== "granted") { // 通知設定が行われていなければ何もせず終了 return; } // 送信されたデータを取得 var data = {}; if (event.data) { data = event.data.json(); console.log("push notification received: " + JSON.stringify(data)); if (data.message) { testDb.addMessage(data.message) .then(() => { console.log("message saved."); }); } } });
WebアプリケーションのPWA化は難しくはないがそれなりの設計は必要
PWAのコアとなるService Workersは、ただ単にページやコンテンツをキャッシュさせるだけであれば、このように比較的容易に実装できる。ただ、実運用されるアプリケーションで利用する場合はキャッシュやService Workerを更新するタイミングなどの検討が必要となるだろう。
また、Pushについてはセキュリティの関係上やや複雑な仕様になっているが、ライブラリを使用すればこちらも比較的容易に実装できる。ただ、現時点では同時に複数のユーザーを対象にメッセージを送信する仕組みがない点に注意したい。
今回のサンプルではIndexedDBというデータ保存用APIを使用したが、それ以外にも最近のWebブラウザではさまざまなAPIが提供されている。それらはMDNのWeb APIページにまとめられているが、たとえばBluetoohデバイスと通信するWeb Bluetooth APIやデバイスの光センサを使って周辺光の状況を取得するAmbient Light Events、オーディオコンテンツを扱うWeb Audio API、バイブレータを操作するVibration APIといったものも用意されている。PWAについてはまだiOSデバイスでのサポートが不足しているという問題はあるが、今後のWebアプリケーション分野の発展に期待したい。