Web Push によるブラウザプッシュ通知

このエントリーは、エキサイト Advent Calendar 2016 の 12/01 の記事です。
エキサイトとしては初のAdvent Calendar参戦です。

Advent Calendar については Wikipedia が詳しいですが
エンジニア界隈では クリスマスまで技術等の投稿をリレーで公開していく企画として
毎年多くの人の投稿で盛り上がっています!
ぜひすべての投稿をウォッチしてください!

こんにちは、ウーマンチーム エンジニアの伊藤です。

ウーマンエキサイト では 11/8 から ブラウザ上での記事のプッシュ通知 (Web Push) を開始しました
現在は一日に数回のおすすめ記事の通知を行っています。

Web Push によるブラウザプッシュ通知_f0364156_12151388.png

アプリでお昼など 定期的 あるいは 即時的 にプッシュ通知で情報を送ることは一般的ですが、
近頃は各種ブラウザの対応に伴い、Web でもプッシュ通知を使って新着情報を届けるサービスが出てくるようになりました。
この Web でのプッシュ通知の技術全体を称して Web Push と呼ばれています。

Web Push は ServiceWorker (Notifcations API + Push API) によって、
ユーザーのブラウザに対してプッシュ通知を行うようになっています。

今回は Web Push 実装に用いた主な技術のご紹介をしたいと思います:


Notifications API

Notifications API はブラウザでデスクトップ通知を行うための API です。
通知を表示するためにはユーザーの通知権限の許可が必要ですのでブラウザは許可を求めるポップアップを表示したりします。

Web Push によるブラウザプッシュ通知_f0364156_16305020.png

ページ内と ServiceWorker 内での Notifications API ではやや異なるのですが
(ServiceWorker の場合は通知に ServiceWorker の registration.showNotification を使用したり)
基本的な流れは同じなので、ここでは簡単な例でページで通知を表示するものを紹介します:

let output = document​.getElementById('text');
/* <p id="text">通知の許可をすると通知が表示されます!</p> */

if (typeof Notification === 'undefined') {
output.textContent = 'お使いのブラウザは Notifications API に対応していません。';
}

/* ユーザーに通知の許可を求める */
Notification.requestPermission().then(
(permission) => {
/* ユーザーが通知を許可してくれなかったとき */
if (permission !== 'granted') {
output.textContent = '通知をお届けできないのが残念です……';
return;
}

/* 許可されていれば通知を作成して表示 */
let notification = new Notification(
'ありがとうございます',
{
body: 'クリックしていただけませんか?',
icon: '//image.excite.co.jp/jp/woman/sp/common/apple-touch-icon.png'
}
);
output.textContent = '通知が表示されましたか?';

/* 通知がクリックされたときになにかする */
notification.onclick​ = (e) => {
notification.close();
output.textContent = 'ありがとうございます!';
};
}
);

↓実際の動作はこんな感じです:

Web Push によるブラウザプッシュ通知_f0364156_16313892.gif

See the Pen Notifications API by sosukeito (@sosuke-ito) on CodePen.


処理の流れ

Notification.requestPermission() によってユーザーに許可を求めます。
Promise.<string> が返ってくるので、その結果の文字列によってユーザーの許可した結果が分かります。

granted が返ってきたときはユーザーは通知の許可をしましたが、
denied (拒否), default (キャンセル) のときは、ユーザーは通知を許可していないということになります。

new Notification(...) で通知を作成することができます 通知に設定できるオプションは以下です:

  • body: 本文
  • icon: アイコン
  • tag: タグ
  • data: データ
  • vibrate: 振動パターン
  • actions: アクション(ボタン)
Web Push によるブラウザプッシュ通知_f0364156_16451072.png

アクション は多くのブラウザではボタンとして表現されています。
たとえばメールの通知であれば「返信」「削除」のようなボタンを追加して、
クリック時に渡されたイベントの action プロパティでどれが選択されたかをチェックすることができます。
(※ただし、アクションは ServiceWorker のほうでしか使うことが出来ないようです)。

データはイベントハンドラで通知に関連したデータを取り出すことができます。
タグは既に送信した通知を再通知する際にどの通知かを指定するのに使用します
(オプション renotify を設定して再通知できます)。

このように少しのコードで非常に簡単にデスクトップ通知を実現することができます。


ServiceWorker

ServiceWorker とはブラウザの別スレッドで動くスクリプトです。
バックグラウンドでサービス的な動作を行うことができます。必要に応じてイベント駆動で処理を実行することが特徴です。

例として挙げられるサービスとしては、キャッシュのコントロールやプッシュ通知などです。
具体的な応用例に興味のある方は、Mozilla による ServiceWorker の応用例を集めたページがありますので
こちらを見るとより具体的なイメージがつかめるかと思います: https://serviceworke.rs/

他方、ServiceWorker は強力なためいくつかの制約があります:

  1. HTTPS 経由で提供されないとロードもできません (localhost は例外です)
  2. 使用できる API が制限されています (XHR は使用できません)
  3. ロードできるページはスコープ (範囲) で制限されています。
    別ドメインはもちろん、スコープが異なるとロードすることができません。

ブラウザでの確認

今ブラウザで動いている ServiceWorker はchrome://serviceworker-internals/ (Google Chrome), about:debugging#workers (Mozilla Firefox) から確認することができます。

Web Push によるブラウザプッシュ通知_f0364156_16330300.png

デバッグは通常の Web ページと同様に開発者ツールを使うことができるので、ブレークポイントを置いていつもの感覚でデバッグを行えます。

Web Push によるブラウザプッシュ通知_f0364156_16332567.png

ServiceWorker を localhost 以外の非 HTTPS 環境で確認する必要がある場合はオプションを指定して、ブラウザのセキュリティを甘めにすると確認できます。Google Chrome (Mac) ではこんな感じです:

open -a "/Applications/Google Chrome.app" --args --unsafely-treat-insecure-origin-as-secure="http://<ServiceWorker を提供する URL>" --user-data-dir=<適当なユーザープロファイル用ディレクトリ> --ignore-certificate-errors  

処理の流れ

プッシュ購読を登録する流れとしては、ServiceWorker をロードして登録を行い、
pushManager を通してプッシュサーバーへの購読を行い、購読情報を受取って登録するという流れになります。

/* serviceWorker がロードされるのを待つ */
/* スコープが有効でなければいつまでたってもロードされない */
navigator.serviceWorker.register('<serviceWorker へのパス>').then(
() => navigator.serviceWorker.ready
).then(
/* ロードされたらプッシュ購読を行う */
(worker) => worker.pushManager.subscribe(
{
'userVisibleOnly': true,
'userVisible' : true
}
)
).then(
(subscription) => {
/* 購読情報 subscription を
後で通知できるように登録しておく... */
}
);

(※ペイロード暗号化を行うとプッシュ時にペイロードを乗せることができますが、購読時に公開鍵を渡す必要があります。詳しくは Google Developers の記事 をご覧ください。Google Chrome では暗号化を使用しない場合は別途 HTML ヘッダに manifest.json の指定が必要です)。

プッシュ通知を行う際にはここで登録された購読情報を参照して、サーバーに対してプッシュのリクエストを投げれば OK です。
プッシュされると ServiceWorker のスクリプト内でpush イベントを受け取ることができます。

self.addEventListener(
'push',
(e) => {
console.log('push されました');
e.waitUntil(Promise.resolve());
}
false
);

プッシュ通知を行うときはここで通知を表示すれば OK です。
(通常 userVisibleOnly を有効にしてプッシュ購読をしているので、通知をしなかったとしても 「バックグラウンドで更新されました」の通知が出ると思います)。


Mocha + Chai + Sinon

JS のテスト, アサーション, テストダブルフレームワークです。
こちらは Web Push 自体とは関係がありませんが、
今回のケースでは ServiceWorker や API 処理の部分で sinon が大いに活用されました。

/**
* テスト対象のクラス
*/
class PushInstaller
{
/**
* 通知許可を求めて OK ならメッセージを出す
* @returns {Promise}
*/
install()
{
return Notification.requestPermission().then(
(permission) => (permission === 'granted')
? console.log('ok!')
: Promise.reject('だめでした')
);
}
}

こんな感じでグローバルなものをべったり使うテストしづらいコードを書いたとしても

/* Notification が未定義 (CLI等) 用の空実装 */
global = global || window;
global.Notification = global.Notification || {
requestPermission()
{
return Promise.reject('denied');
}
};

describe(
'PushInstaller は',
() => {
let installer;
let consoleLogSpy;

beforeEach(
() => {
installer = new PushInstaller();

/* Notification.requestPermission をモックできるようにする */
sinon.stub(Notification, 'requestPermission');

/* console.log がどのように呼び出されたかスパイできるようにする */
consoleLogSpy = sinon.spy(console, 'log');
}
);

afterEach(
() => {
Notification.requestPermission.restore();
consoleLogSpy.restore();
}
);

it(
'ユーザーが許可するとコンソールに "ok!" の文字列を残す',
() => {
/* ここでユーザーが通知を許可した状態をモックする */
Notification.requestPermission.returns(Promise.resolve('granted'));

/* Promise を返すと resolve するまでちょっと待っててくれる!
reject されるとテストは fail する */
return installer.install().then(
() => {
/* 渡された引数もチェックできる */
expect(consoleLogSpy.withArgs('ok!').calledOnce).to.be.true;
}
);
}
);

it(
'ユーザーが許可するとコンソールに "oops..." の文字列を残す (失敗します)',
() => {
Notification.requestPermission.returns(Promise.resolve('granted'));
return installer.install().then(
() => {
expect(consoleLogSpy.withArgs('oops...').calledOnce).to.be.true;
}
);
}
);
}
);

sinon を使うとモックやスパイを使って、返値を指定できたり、メソッドが呼ばれたか確認することもできます。
(モックするグローバルオブジェクトがない環境を想定して、未定義時に空実装で置き換えるようにすると CLI 等でもテストできてよいです)。

↓ブラウザでテストを実行したときはこんな感じになります:

Web Push によるブラウザプッシュ通知_f0364156_16490485.png

See the Pen mocha + chai + sinon by sosukeito (@sosuke-ito) on CodePen.


感想

ここぞとばかりに使ってみたい技術を導入したので、楽しくもあり、悩みポイントやハマりポイントも多くありました。

近年は各ブラウザベンダーの積極的な HTML5 推進により、今までになかった表現ができるようになってきたかと感じています。
ネイティブのアプリなみのことが、手軽にブラウザ上でできるようになってきたのはとても面白い状況ですよね。

今後、サービスへ活かしていけるものがあれば、ぜひ取り入れて面白く魅力的なものをお届けできればよいなと思っています。

明日は多くの人に (もちろんエキサイトでも!) 使われている
リソース指向 WAF の BEAR.Sunday の生みの親
郡山昭仁さんRedux-ReactSSR (サーバーサイドレンダリング) のお話です!

BEAR.Saturday についてはこのブログでも以前ご紹介いたしました!

クリスマスまで続くエキサイト Advent Calendar 2016
ぜひ今後も投稿をウォッチしてください!

エンジニア募集

エキサイトではエンジニアとして一緒に働いてくださる方を
新卒採用中途採用で募集しています。
詳しくは、こちらの採用情報ページをご覧ください。

新しい技術を積極的に使っていける職場でありますので
一緒に技術を使い、語り合いましょう
(私は近頃は既に Electron と Vue.js が気になりつつあります)


by ex-engineer | 2016-12-01 00:00