Ruby/RailsでAmazon SNSを使用してiOSとAndroidにpush通知を送る方法

## はじめに
もう一度一から実装する時が来るかもしれないので忘れないうちにやり方を残しておきます。
優しいまさかり大歓迎。
Amazon SNSの登録、Amazon リソースネーム (ARN)設定、各デバイスのpush許可トークンの取得は今回は割愛。
検索するとたくさん出てきます。

## 前提知識
Amazon SNSを使用するに **AccessKeyID** と **SecretAccessKey** 、 **Region** が必要です。
Amazon SNSへ登録した段階で発行されるので忘れずにメモしておきましょう。

またARNはアプリに紐づく **Topic ARN** とそのTopic ARNに紐づく **Platform Application ARN** があります。Platform Application ARNはiOS用とAndroid用、テスト用など各環境ごとに設定が必要です。
Platform Application ARNの種類として以下の3つは覚えておくと良いです。

-  **APNS**: iOS用
-  **APNS_SANDBOX**: テスト用ARN
- **GCM**: Android用(最新で推奨されている形式は別の名前だがAmazon SNSの場合GCMで設定しておくと勝手にその形式で送ってくれる)

## 環境

今回はAmazonが出している `aws-sdk`を使用して実装を行いました。

```Gemfile
ruby '2.4.1'
gem 'rails',    '>= 5.1.3'
gem 'aws-sdk', '>= 2.3.22'
```

## 実装1 設定


その情報を元に`aws-sdk`からAmazon SNSを使用するときは

```
client = Aws::SNS::Client.new(
      access_key_id:     {AccessKeyID},
      secret_access_key: {SecretAccessKey},
      region:            {Region},
    )
```
こんな感じでClientを作成してやります。

## 実装2 エンドポイント登録
Amazon SNSでユーザーの端末へプッシュ通知を行うためにはアプリから送られてきたpush用のトークンを登録しその端末用のエンドポイントを作成する必要があります。

### 手順1 エンドポイント作成
実装1で作成したClientを使用し

```
response = client.create_platform_endpoint(
      platform_application_arn: ｛作成したPlatform Application ARN｝,
      token: {端末から送られてきたトークン},
      custom_user_data: { user_id: user_id }.to_json, # どのユーザーのエンドポイントか判断しやすくするためにUserIDを送っている。こうするとAmazon SNSの管理画面から確認しやすくなる。
    )
```
のとしてやるだけでエンドポイントが作成されレスポンスとして帰ってきます。
これで個人へのプッシュ通知が可能になります。

### 手順2 エンドポイントを登録

手順2で作成したエンドポイントを全体pushが受け取れるようにTopic ARNに登録する必要があります。

```
client.subscribe(
      topic_arn:  {作成したTopic ARN},
      protocol:   'application', # 呪文,詳しく知りたい方は公式ドキュメントを読んで下さい
      endpoint:   response.endpoint_arn, # ここのresponseは手順１で作成したもの
    )
```

としてやれば登録が完了します。
これでプッシュ通知を送ることができるようになりました。

あとはサーバー側でエンドポイントをDBに保存するなりしてください。

## 実装3 ペイロードに載せるJSON作成

結構ハマるポイント。
ここが一番つらかった……
未だにルールが完璧にはわからない。なのでツッコミをお待ちしています。

前提として APNSとGCMではJSONの形式が違うので各々に対応したJSONを作成する必要が有る。

### 共通の仕様

aws-sdkにわたすJSONの一回層目は

```
{
	default: {中身},
	APNS_SANDBOX: {中身},
	APNS: {中身},
	GCM: {中身},
}.to_json
```
という感じでどのARNに何を届けるかを指定するようになってます。
ここで気を付けなきればいけないのはdefaultのkeyがないとエラーがでて送れなくなります。
なので忘れずに書いておきましょう。

### APNSの場合
iOSにプッシュ通知を送る時のJSONの中身について解説します。

```
{ aps: 
    { 
     alert: {通知の内容},
     badge: 1,
     sound: 'default',
     'mutable-content': 1
     },
}
```

- `alert`: 　 通知の中身です `{title: 'hogehoge', subtitle: 'hogehoge',  body: 'hogehoge'}`という感じで設定してあげます。
- `badge`:　アプリアイコンにバッチがつくかのフラグです。
- `sound`:　通知が届いた時の音の設定です。色々有るらしいので遊んでみてもいいと思います。
-  `mutable-content`: 　アプリアイコンにつくバッチバッチに何件分有りますよって数字が出るあれです。ここの数字がそのまま出ます。

基本的な通知の内容は`aps`に全て定義します。ただ通知を開いたときにその記事を直接開くようにする場合などは`aps`と同列の階層に定義してあげるとそのまま送ってくれます。
なのでアプリサイドとどんなJSONがいいか前もって話し合っておくといいです。

```例
{ aps: 
    { 
     alert: {title: 'hogehoge', subtitle: 'hogehoge',  body: 'hogehoge'},
     badge: 1,
     sound: 'default',
     'mutable-content': 1
     },
    themeID: 1234, # 記事の詳細に直接飛ぶためのID
    imageURL: 'http://hogehoge.png' # プッシュ通知に表示してもらう画像
}
```

### GCMの場合
Androidにプッシュ通知を送る時のJSONの中身について解説します。

```
{
      collapse_key: 'hoge',
      delay_while_idle: true,
      data: {通知の内容},
}
```

- `collapse_key`: どのアプリの通知だよってのを表すkeyこいつが違うと通知を正しく受け取ってくれないので注意
- `delay_while_idle`: 端末がアクティブな状態になるまで通知を送らないよっていう設定。電源入れたときにまとめて一気にきたりするのはこいつがtrueになっているから。
- `data`:　こいつに通知の内容を全て書く内容はアプリ側が受け取って処理するのでアプリ側と話して決める

`data`の中はAPNSと違いほとんど決まった形はない感じ、なのでその後の処理に使用するデータとかも全部ぶち込める

```例
{
     collapse_key: 'hogehoge',
     delay_while_idle: true,
     data: {
           title: 'hogehoge',
           subtitle: 'hogehoge',
           body: 'hogehoge',
           theme_id: 1234,
           image_url: 'http://hogehoge.png'
    },
}
```

### 補足

上記に上げた以外にもいろんなパラメーターが設定できたはずです。
詳しく知りたい方は公式ドキュメントを見てください。
この内容で正しく送られるのかわからなくなった時は公式のコンソールからテスト送信が出来るので確認しやすいとおもいます。


## 実装4 push通知送信

ここまで来るともう後はとても簡単です。
一行書くだけで送信できます!

### 個別送信の場合

```
client.publish(target_arn: {実装２で作ったエンドポイント}, message: {実装３で作ったJSON}, message_structure: 'json')
```

これだけです。
これで一つのデバイスにpush通知を送ることができます

### 全体通知の場合

```
client.publish(topic_arn: {事前に作っていたTopic ARN}, message: {実装３で作ったJSON}, message_structure: 'json').successful?
```

です。
注意すべきなのは全体の時は必要なパラメーターは`target_arn`ではなく`topic_arn`になります。
Topic全体に送るので当たりまえですね。

## おまけ

こんな感じで実装できるpush通知ですが気を付けとかなければいけないことが何点かあります。

### 個別通知一回と全体通知一回はほぼ同じ値段
一定の条件を満たす大量のユーザーにpush通知を贈りたいとなった場合に個別で送信しようとすると莫大なお金がかかるようになってしまいます。
できるだけそういった実装をしないようにするか、Topicを分けて登録するなどできるだけ全体通知を使用できるように考えておく必要があります。

### 一度送信に失敗するとそのエンドポイントが使用不可なる
Amazon SNSはプッシュ通知を送信した際にどんな状態であっても相手が受信できなかった場合にそのエンドポイントを無効な物と判断し使用不可になります。
一日に一回DBに登録されているエンドポイントが使用可能なものかの確認を行い使用不可になっている場合は削除、再度ユーザーがアプリを使用した際に再度エンドポイントを取得し登録し直す対応をしています。

## おまけその２

実際にプロダクトで動かすならこんな感じになるよっていうコードを載せておきます。
お手柔らかにお願いします……

```
# frozen_string_literal: true
class AmazonSnsService
  def regist_token(token, device_type, user_id)
    app_arn =
      if device_type == :iphone
        {AMAZON_SNS_IOS}
      elsif device_type == :android
        {AMAZON_SNS_ANDROID}
      end

    response = client.create_platform_endpoint(
      platform_application_arn: app_arn,
      token: token,
      custom_user_data: { user_id: user_id }.to_json,
    )

    client.subscribe(
      topic_arn:  {TOPIC},
      protocol:   'application',
      endpoint:   response.endpoint_arn,
    )

    response.endpoint_arn
  rescue Aws::SNS::Errors::InvalidParameter => ex
    result = /Endpoint (\S+) already exists/.match(ex.message)
    if result.blank?
      Rails.logger.error(ex.message)
      Rails.logger.debug(ex.backtrace.join("\n"))
      return
    end
    result[1]
  rescue => ex
    Rails.logger.error(ex.message)
    Rails.logger.debug(ex.backtrace.join("\n"))
    nil
  end

  def request_personal_push_notification(value, device_token = nil, theme_id = nil, image_url = nil)
    data = message_structure(value, theme_id, image_url)

    return false if device_token.blank?
    client.publish(target_arn: device_token, message: data, message_structure: 'json').successful?
  rescue => ex
    Rails.logger.error(ex.message)
    Rails.logger.debug(ex.backtrace.join("\n"))
    false
  end

  def request_whole_push_notification(value, theme_id = nil, image_url = nil)
    data = message_structure(value, theme_id, image_url)

    client.publish(topic_arn: {TOPIC}, message: data, message_structure: 'json').successful?
  rescue => ex
    Rails.logger.error(ex.message)
    Rails.logger.debug(ex.backtrace.join("\n"))
    false
  end

  private

  def client
    @client ||= Aws::SNS::Client.new(
      access_key_id:     {ACCESS_KEY_ID},
      secret_access_key: {SECRET_ACCESS_KEY},
      region:            {REGION},
    )
  end

  def message_structure(value, theme_id, image_url)
    aps_value = { alert: value, badge: 1, sound: 'default', 'mutable-content': 1 }

    apns_contents = { aps: aps_value, themeID: theme_id, imageURL: image_url }

    gcm_contents = {
      collapse_key: {key_name},
      delay_while_idle: true,
      data: value.merge(theme_id: theme_id, image_url: image_url),
    }
    {
      default: aps_value.to_json,
      APNS_SANDBOX: apns_contents.to_json,
      APNS: apns_contents.to_json,
      GCM: gcm_contents.to_json,
    }.to_json
  end
end
```