ブラウザプッシュ通知とユーザー個別に内容を送信する実装方法 in GAMY

## ブラウザプッシュ通知が流行りだした
Webプッシュ、ブラウザプッシュなどと呼ばれる機能はブラウザのPush APIを使い実装され、現在ChromeとFirefoxで使えるようになっています。
毎週のように新たなブラウザプッシュが送れるサービスが登場していますが、基本的には一律ですべてのユーザーに送信するものが多いように感じます。
その中でもFaceBookはユーザー個別にネイティブアプリと同様の内容でブラウザにプッシュ通知を送っています。

ユーザー個別で通知内容をカスタマイズしたいのと、
その辺のサービスを使ってロックインされると困るので、FaceBookのコードを参考にしつつ自分で実装してみました。
ChromeとFirefoxで微妙にPushの送り方が違うので、今回はGCM（Google Cloud Messaging）用の解説です。

## Pushを送ってみるとこんな感じ
[![GAMY](https://qiita-image-store.s3.amazonaws.com/0/7132/775c5e26-6795-5b6b-ef56-3fd594dacaac.png)](https://gamy.jp/)


## Service Worker
Push APIを使用するにはService Workerが動作している必要があります。

manifest.jsonの作りかたなど、Service Workerことはじめ的な内容はこちらで投稿しています。
[http://qiita.com/narikei/items/4240f03542f29e313989](http://qiita.com/narikei/items/4240f03542f29e313989)


## Google Developers Consoleでプロジェクトを登録
[https://console.developers.google.com](https://console.developers.google.com) でプロジェクトを作成して、
この中にあるAPI ManagerからGoogle Cloud Messagingを有効にしておきます。
また、GCM送信用にサーバーのAPI キーも作っておいてください。
![Google Developers Console](https://qiita-image-store.s3.amazonaws.com/0/7132/f4f4dd2a-5176-4636-19b9-d4cddc635baf.png)
いつの間にかこれに一本化されてる

## `manifest.json`にgcm_sender_idを追加する
```json:/manifest.json
{
    "name": "GAMY（ゲーミー）- みんなのゲーム攻略メディア",
    "short_name": "GAMY攻略",
    "icons": [
        {
            "src": "/static/img/icon/144.png",
            "sizes": "144x144",
            "type": "image/png"
        }
    ],
    "start_url": "/",
    "display": "standalone",
    "theme_color": "#e91e63",
    "gcm_sender_id": "12345678910",
    "gcm_user_visible_only": true
}
```

`gcm_sender_id`にはGoogle Developers Consoleから取得するプロジェクト番号、
`gcm_user_visible_only`はChrome 44から[Deprecate](https://code.google.com/p/chromium/issues/detail?id=471534)になっているのでなくても大丈夫です。


## PushManagerのendopointの取得

ServiceWorkerのPushManagerを使いプッシュを送信するエンドポイントを取得できます。

```js
window.addEventListener('load', function() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/serviceWorker.js')
      .then(function(registration) {
        return registration.pushManager.getSubscription().then(function(subscription) {
          if (subscription) {
            return subscription
          }
          return registration.pushManager.subscribe({
            userVisibleOnly: true
          })
        })
      }).then(function(subscription) {
        var endpoint = subscription.endpoint
        console.log("pushManager endpoint:", endpoint) // https://android.googleapis.com/gcm/send/******:******......
      }).catch(function(error) {
        console.warn("serviceWorker error:", error)
      })
  }
})
```

このコードを実行すると通知の表示に関するダイアログが表示され、これを許可すれば取得できます。
![確認ダイアログ](https://qiita-image-store.s3.amazonaws.com/0/7132/4fa439fc-b92b-5e4e-de11-643da3f7841b.png)


取得できるendpointは`https://android.googleapis.com/gcm/send/******......`といった形です。
Chromeでは`https://android.googleapis.com/gcm/send/`の後に続く文字列がGCMのregistrationIdとなります。

後でGCM送信時に使うためendopointはAjaxなどでサーバーに送信し、userIdと一緒にDBにでも保存してください。


## サーバーからGCMの送信
GCMの送信方法は通常のAndroidアプリに送信するときと同じです。
これ系はたくさんライブラリがあるのでこのへん使っておけば良いと思います。

- Java [https://github.com/google/gcm](https://github.com/google/gcm)
- Ruby [https://github.com/sghael/speedy_gcm](https://github.com/sghael/speedy_gcm)
- Ruby [https://github.com/spacialdb/gcm](https://github.com/spacialdb/gcm)

送信に必要なのはendpointではなくregistrationIdだという点に注意


## ServiceWorkerのデバッグ

Developer Tools -> Resouces -> Service Workers でデバッグが可能です。
![Service Workers Debugger](https://qiita-image-store.s3.amazonaws.com/0/7132/e2394b96-23cb-1110-3def-a3ae5684819c.png)
歯車に下向きの矢印のアイコンでファイルのアップデート、
ベルのアイコンでPush通知が再現され`self.addEventListener("push", func)`が実行されます。

通常のChromeでServiceWorkerのデバッグをしているとちょこちょこ固まるので、Canaryでやった方がよいかもしれません。

[chrome://serviceworker-internals/](chrome://serviceworker-internals/)でブラウザ全体のServiceWorkerを把握できます

## ServiceWorkerから別ファイル読み込み

ServiceWorkerで読み込むファイルはスコープの関係で、ドメイン直下にあることが好ましいですが、
サービスの構成や使っているフレームワークやgulpなどの制約でドメイン直下にファイルを置くことが難しい場合があります。

このようなとき`importScripts`を使い回避することができます。
登録するServiceWorkerのファイルはStaticな形でドメイン直下に置き、実際に編集するファイルは別の場所に置いておくといった運用が可能です。

```js:/serviceWorker.js
importScripts('/assets/js/sw.js')
```
だいたいRequireJSのようなイメージ


## Push通知の表示実装

`self.addEventListener("push", func)`を記述すると、Push通知が来た際に実行されます。

```js:/assets/js/sw.js
self.addEventListener("push", function(event) {
  event.waitUntil(
    self.registration.showNotification("Push通知タイトル", {
      body: "Push通知本文"
    })
  )
})
```

ここまで実装できたらDeveloper Toolsでベルアイコンをクリックしてみてください。このような通知がくれば成功です。
<img width="394" alt="通知１" src="https://qiita-image-store.s3.amazonaws.com/0/7132/adc5d942-5fc1-add0-910d-472071396868.png">

殺風景なので少し設定を追加してみます。

```js:/assets/js/sw.js
self.addEventListener("push", function(event) {
  event.waitUntil(
    self.registration.showNotification("Push通知タイトル", {
      icon: "/icon.png",
      body: "Push通知本文",
      tag: "push-test",
      actions: [{
        action: "act1",
        title: "ボタン１"
      }, {
        action: "act2",
        title: "ボタン２"
      }],
      vibrate: [200, 100, 200, 100, 200, 100, 200]
    })
  )
})
```
<img width="394" alt="通知２" src="https://qiita-image-store.s3.amazonaws.com/0/7132/4bace19e-61e1-6644-f685-3395b57dadea.png">

- iconに画像ファイルのパスを入れるとアイコンが表示されます。
- tagは同一の文字列が入った通知をまとめてくれ、連続で表示させないようにする設定です。
- actionsはまだCanaryでしか動作しませんがカスタムボタンを増やすことができます。押したaction名は`notificationclick`の`event.action`で取得できます。
- vibrateはスマートフォン限定で通知時に振動させられる。

## ユーザー個別に通知内容のカスタマイズする

ブラウザのプッシュではネイティブアプリのように、プッシュにテキストやURLなどの情報を入れることはメッセージを[暗号化](https://tools.ietf.org/html/draft-ietf-webpush-encryption-01)しないとできません。
つまりブラウザのプッシュでは**プッシュが来た**ということしかわからないという仕様です。

なのでプッシュが来てからFetch API（XHRの新しくて使いやすいやつ）でサーバーに詳細内容を問い合わせて、
取得した結果を表示させるという処理が必要になります。


例えばこんなレスポンスを返すAPIがあったとします。

```json:/notifications.json?endpoint=***********
{
  "title": "GAMY",
  "icon": "/icon.png",
  "body": "◯◯さんがあなたのコメントに返信しました。",
  "url": "https://gamy.jp/notifications"
}
```

そしてJS側ではFetch API使った形に書き直します

```js:/assets/js/sw.js
function getEndpoint() {
  return self.registration.pushManager.getSubscription()
    .then(function(subscription) {
      if (subscription) {
        return subscription.endpoint
      }
      throw new Error('User not subscribed')
  })
}

self.addEventListener("push", function(event) {
  event.waitUntil(
    getEndpoint()
    .then(function(endpoint) {
      return fetch('/notifications.json?endpoint=' + endpoint)
    })
    .then(function(response) {
      if (response.status === 200) {
        return response.json()
      }
      throw new Error('notification api response error')
    })
    .then(function(response) {
      self.registration.showNotification(response.title, {
        icon: response.icon,
        body: response.body
      })
    })
  )
})
```

`function getEndpoint()`で`endpoint`を取得し、Fetchで投げるAPIにパラーメーターとして付け加えています。
このendpointでユーザーを判別し通知内容を返すようにサーバーに実装します。

デバイス毎に通知内容を格納する配列があり、古い順にPOPしていくイメージです。

```rb:notifications_controller.rb
render :json => ["通知５", "通知４", "通知３", "通知２", "通知１"].pop
```

このレスポンスの情報を`showNotification`に入れ個別の内容を表示することができます。

<img width="394" alt="通知３" src="https://qiita-image-store.s3.amazonaws.com/0/7132/609e0738-891f-ef3b-f483-52d560760b47.png">

どっちかっていうとサーバー側が頑張る感じ


## 通知をクリックしたときの処理
通知をクリックしたときには、その該当ページをブラウザで開く挙動を期待します。


`showNotification`にdataを追加し、その中にurlを入れています。
クリックしたときに実行される`self.addEventListener('notificationclick', func)`を追加しました。

```js:/assets/js/sw.js
self.addEventListener("push", function(event) {
  event.waitUntil(
    getEndpoint()
    .then(function(endpoint) {
      return fetch('/notifications.json?endpoint=' + endpoint)
    })
    .then(function(response) {
      if (response.status === 200) {
        return response.json()
      }
      throw new Error('notification api response error')
    })
    .then(function(response) {
      self.registration.showNotification(response.title, {
        icon: response.icon,
        body: response.body,
        data: {
          url: response.url
        }
      })
    })
  )
})

self.addEventListener('notificationclick', function(event) {
  event.notification.close()
  
  var url = "/"
  if (event.notification.data.url) {
    url = event.notification.data.url
  }
  
  event.waitUntil(
    clients.matchAll({type: 'window'}).then(function() {
      if(clients.openWindow) {
        return clients.openWindow(url)
      }
    })
  )
})
```

これで通知をタップするとURLが開くようになります。


## おわり
サービスワーカーの目玉とも言えるブラウザプッシュ機能ですが、
最小限の構成であれば数１０行のコードで実現することができました。
すでにネイティブアプリへプッシュを行っているサービスであれば、けっこう簡単に実装できそうです。

実際にはもう少し考慮すべきところはありますが、自分が運用する[GAMY](https://gamy.jp/)で導入し始めました。
これでサーバーのアラートとか実装したい、メールより便利そう