AmazonSNSを使ったpush通知の実装について

iOSアプリ開発で、AmazonSNSを使ってpush通知を実装したことについて書きます。

バックエンドはRailsで、フロントはSwiftです。

主に、バックエンドでpush通知を送付する機構を構築し、アプリ側ではそれを受け取ることだけをやってもらうような構成で実装したので、主にバックエンド側でのお話になります。

## 構成図
最初に作った機構の全体像を載せておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/2dd9f9c0-278b-6204-114f-3af761b05737.png)


## push通知とは？

言葉の定義（前提）を確認しておいた方がいいので、あえて書きます。
push通知を設計していて、自分が一番よく混乱したのが、push通知とお知らせ通知の概念を混同していたことでした。

基本的にpush通知はアプリのフォアグラウンドで受け取るため、push通知で来たメッセージを見た後は、その通知の内容の履歴を永続化しておくことはできません。

アプリ側のキャッシュ領域のようなところで持たせて保存しておく方法もあるようですが、アプリを再インストールすると、どっちみち消えてしまうため通知の履歴を永続化したい場合はあまり意味はないようです。

AWSにも問い合わせたのですが、AmazonSNSでは、push通知の内容を履歴として保持しておいて、例えば、SDKなどで呼び出すような機構は用意していないということでした。（2021年時点）

そのため、push通知の内容をお知らせ通知として履歴に残すには、その内容をDBに保存するテーブルが必要だということを知っておくことが重要です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/e43beba8-8818-007f-4af1-f96166a35d8f.png)



### push通知はいわゆるこういうやつ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/1d585eb0-5980-edb2-5ab5-106eac55c01c.png)


アプリ起動中だったらリアルタイムで上部に表示される仕様が多い
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/a57834f4-a1aa-26b1-a4ac-510775a94afb.png)


### お知らせ
いわゆるpush通知で配信された内容を後からも見たい場合に表示しておく一覧のようなイメージです。
- ユーザーに対してpush通知した履歴を一覧で表示するもの
    - push通知として通知した内容をサーバー側DBに保持しておいて表示するのがメインですが、iPhone側のpush通知設定をオフにしている場合でもお知らせ自体は受領して一覧保存する要件なども考慮が必要だったりしてややこしかった

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/f679c828-4893-b9e8-429a-c7bb41fbf5fd.png)



## 今回の実装要件
### 通知の要件
今回実装した、通知トリガーはざっくり以下のようになります。
- フォローされたときに通知
- フォローしているユーザーが投稿したときに通知
- 自分の投稿にコメントされた時
- 自分のコメントに返信があった時
- 運営からのお知らせを管理画面から送った時

### 複数端末対応
-  同一ユーザーが複数の端末でアプリをインストールする場合（iPhoneとiPadでインストールしている場合など）
    - お知らせは全ての端末に一律配信する（端末は２つでもユーザーは1人というケースに対応）
        - お知らせの設定は１ユーザー/１設定なので同一ユーザーであれば全端末で一律で同じ設定が同期されるようにする
    - push通知はそれぞれの使っている端末の設定に従い、ONにした端末にだけpush通知を配信する


例えば.............
ユーザーがipadとiphoneに同じアプリをインストールしている場合


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/b5917492-7cab-6616-fb5f-c2e5fbd3fb0b.png)

## ざっくりのテーブル構成
上記の要件を色々勘案すると下記のようなテーブルになりました。

- お知らせ設定のon/offの設定テーブル
- 端末を特定するデバイストークンとエンドポイントを管理するテーブル
- 各デバイス/エンドポイントが購読しているサブスクリプションを管理するテーブル
- お知らせ履歴を管理するためのテーブル

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/be092038-cf4a-d0a6-93cb-7dbd5c650501.png)


## 導入するライブラリ
### AWS SNS
### gem aws-sdk-sns
https://rubygems.org/gems/aws-sdk-sns/versions/1.15.0?locale=ja
https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SNS/Client.html

基本的には、gemをinstallしてあげれば特にconfigで何か設定するとかはないです。リソースへのアクセス権限はECSに与えました。


## Amazon SNSを使ったpush通知の概要
- ECSタスクロールにAmazonSNSFullAccessのポリシーをアタッチ
- ECSタスクロールにAmazonSNSFullAccessポリシーをアタッチしコンテナがSNSにアクセスできるようにする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/5977a8c3-aa41-a904-0a38-1d29b77348d0.png)


## 事前準備
- プラットフォームアプリケーション作成時に必要なAPNsの証明書を用意
    - 今回はiOS版のアプリとしてリリースしているので、iOSのディベロッパーツールで発行する際にサンドボックス or プロダクション用を選択して作成できる

### 主なやること
- プラットフォームアプリケーションの事前作成（APNsの証明書作成（上述）が必要）

### AWS SNSでプラットフォームアプリケーションの作成

#### プラットフォームアプリケーションの作成
- プッシュ通知からプラットフォームアプリケーションの作成へ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/725f941f-e50b-1962-bd62-fa8792475f85.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/846da4dd-92c2-7bab-4613-da32bfdd681a.png)


- 証明書ファイルをアップロードし認証ファイルを読み込みを押下（パスワードは設定していなければ特に入力必要なし）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/432528f4-e9d1-bda9-887c-82c14494f064.png)


- サービスロールは新しいサービスロールを作成してアタッチ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/a041531e-8b42-9a25-f398-48aefe6a69af.png)

## Amazon SNSを使ったpush通知の概要
以下のスクショは、実際にAmazonSNSのコンソールにある図解ですが、こちらの図解がわかりやすくて仕組みの概要を表しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/06241774-3a73-3966-5454-0fb0f051bab6.png)

以下に、提供しているSDKのメソッドと共に実際にpush通知を送る流れを順に解説していきます。
https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SNS/Client.html

- プラットフォームアプリケーションの生成
    - ステージング/本番など各環境ごとに作りpush通知機構の大元になりこのプラットフォーム上に各デバイスに対してのエンドポイントが格納されていく

- エンドポイントの生成([#create_platform_endpoint(params = {})](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SNS/Client.html#create_platform_endpoint-instance_method))
    - 各デバイスに一意に付与されたデバイストークンから実際に各デバイスがpush通知を受け取るエンドポイントを生成
    - パラメータにデバイストークンとプラットフォームアプリケーションのarnなどを指定
    - デバイストークンはアプリの再インストールやバージョンアップ時に更新されるのでその度にエンドポイントの更新が必要


- 通知の発行（主に2種類）
    - エンドポイントに直接通知する([#publish(params = {})](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SNS/Client.html#publish-instance_method))
        - paramsにエンドポイント
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/bde6234e-66e6-05bf-fc41-e915ada5ea99.png)

   
    - トピックに通知([#publish(params = {})](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SNS/Client.html#publish-instance_method))
        - pramasにトピックを指定するとトピックに登録されているエンドポイント全てに一斉にpush通知を送信できる
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/2f5c308e-ac86-6e99-3d9d-786ceb0e673d.png)


- トピックの生成(#create_topic(params = {}))
    - 複数のエンドポイントにまとめて通知したい場合エンドポイントを登録しておくための受け皿を作る
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/4d757d44-c801-4650-3530-dc30517616cf.png)


- トピックにエンドポイントを登録(#subscribe(params = {}) )
    - 指定したトピックにエンドポイントを登録
- トピックからエンドポイントの登録を解除(#unsubscribe(params = {}))
    - 指定したトピックから指定したエンドポイントの登録を解除
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/ffd604d6-d772-741d-7cb9-e5dfa3b7d0dc.png)


## Push通知関連のトリガーについて
push通知関連のトリガーを整理していきます。

### アプリ初回起動時にエンドポイントを生成する
- push通知配信を許可するかどうかの設定
    - アプリ内でお知らせを受領する設定とは全く別もの
        - 初回起動時にダイアログを表示してOKを押すとフロントエンドからエンドポイントを生成するAPIを叩いてもらうようにした
        - そもそもpush通知の配信を許可しない人はエンドポイントを生成しない


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/39c59fa0-6503-86d8-404a-b312221a0739.png)


## お知らせの設定(アプリ側の制御)の仕様を整理


アプリ側でお知らせ自体を受領しお知らせ一覧に履歴を保存して表示するかどうかも設定できるようにしています。ここで整理が必要なのが、アプリ側のお知らせ設定と、iPhone側のpush通知設定との共存です。


アプリ側のお知らせ設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/a0d32a4d-8a49-1126-cd91-66af7abc5d1e.png)

iPhone側のpush通知設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/39c59fa0-6503-86d8-404a-b312221a0739.png)


今回は以下のように整理するとスムーズにいきました。これらは仕様なので、色々考え方はあると思います。

- アプリ側のお知らせ設定をOFFにすれば、バックエンド側でpush通知自体のトリガも走らせず、お知らせの保存もしないようにした
- これは言うなれば、iPhone側のpush通知の設定をONにしていても、アプリ側のお知らせ設定をOFFにしていると、push通知も当然来ないし、お知らせ一覧に履歴も当然残らないという形で、アプリ側でのお知らせ設定を大元の設定とする仕様で整理した。
- 要は、お知らせの設定とpush通知の設定は連動しないし、アプリ側のお知らせの設定がトリガーの大元になる。


## Push通知ロジックのトリガー一覧

- お知らせ通知設定画面でオン/オフ
    - お知らせ通知設定レコードの更新（DB）
- お知らせ通知設定画面で「運営からのお知らせを通知」をオン
    - 運営からのお知らせ用のトピックを購読(subscribe)（SNS）
    - 運営からのお知らせ用のトピックの購読(subscribe)を保存（DB）
    - お知らせ通知設定レコードの更新（DB）
- お知らせ通知設定画面で「運営からのお知らせを通知」をオフ
    - 運営からのお知らせ用のトピックの購読(subscribe)を削除（SNS）
    - 運営からのお知らせ用のトピックの購読(subscribe)を削除（DB）
    - お知らせ通知設定レコードの更新（DB）

- ユーザーにフォローされたとき（フォローされたときに通知がオンの時）
    - エンドポイントにPush通知配信（SNS）
    - フォローされたユーザーに紐づく全てのエンドポイントへPush通知配信（SNS）
    - お知らせに履歴を残す(DB)

- ユーザーにフォローされたとき（フォローされたときに通知がオフの時）
とくになし

- フォローしているユーザーが投稿したとき
    - 投稿者のトピックを作成(SNS)
    - そのトピックにフォロされている人たちのエンドポイントをsubscribe(登録)する(SNS)
    - そのトピックにpush通知送信(SNS)
- お知らせ通知履歴のレコード登録（DB）
    - 通知完了後作成したトピックごと削除(SNS)


- 管理画面で運営からのお知らせを登録したとき
    - お知らせのレコード登録（DB）
    - 運営からのお知らせ用のトピックを購読(subscribe)しているユーザーすべてを対象にPush通知配信（SNS）
- 管理画面で運営からのお知らせを特定ユーザーに配信したとき
    - 通知管理のレコード登録（DB）
    - 特定ユーザーのエンドポイントに直接Push通知配信（SNS）

## 再インストールされた場合への対応
再インストールされた場合の対応も考えておかないといけないのが、結構面倒くさいです。再インストールされると、アプリが持つ端末を識別するためのデバイストークンが変わってしまう上、アプリ上で持っていた旧デバイストークンの情報も綺麗さっぱり消えてしまうからです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/003bb051-8637-3699-404a-133805c6cd7c.png)

### どう言うことか
図解して説明します。

テーブル的にはここのテーブルの話になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/a5b400dd-c352-c5ad-7fdb-03aad83bd10d.png)


通常系が以下の図のパターンで、このパターンであれば、仮に何かのios端末の事情で自動でデバイストークンが変更されても、アプリ側で旧デバイストークン情報は保持しているので、それを使って変更されたことをアプリ側で検知して、APIをリクエストしてDBのレコードを特定し変更後の新デバイストークンの値でDBを更新してしまえばいいだけなので特にややこしいことはありません。これにより、現在そのユーザーで有効となっているデバイストークンの情報と、DBで記録しているデバイストークンの情報は常に一致した状態を保てます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/a6dc3896-9a4d-4ff4-dec8-db53886a3de4.png)


ややこしいのが以下です。

ユーザーがアプリをホーム画面から削除すると、基本アプリ側が保持しているデバイストークンは変更されるし、アプリ側からも消えてしまうので、もし、その後に、そのユーザーが再度アプリをインストールして同一ユーザーでログインし直しても、前にどのエンドポイントを使っていたか認識が出来なくなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/98c74c3b-4bfe-01f8-8648-94331bc4feac.png)


つまり、DBにはそのユーザーのデータは残っているし、当然そのユーザーが持っていたデバイストークンのデータは残っているのですが、そのユーザーが複数端末で使ってしまっていれば、デバイストークンのレコードは複数あり、結局、どのトークンがその端末のものなのかアプリ側で認識ができないことになります。

そこで考えた対応策は、

- **再インストールされた場合は、もう一回フロントエンドから新たにエンドポイントを生成するAPIを叩いてもらい新たに生成したものを使うようにする**
- **古いものは残ってしまうので定期的にバッチで削除する**

これらの対応で乗り切りました。

こうすると、旧デバイストークンのレコードは残ってしまいますが、再インストール時に新たにレコードを作るため、そちらを正としてpush通知を配信することができます。
バッチ処理で旧デバイストークンのデータはお掃除するので古いデータが再インストールのたびに溜まり続けることもありません。


## 参考コードの例

最後に、じゃー実際どういうコードを書いたのかと言うのをごく一部だけ抜粋して紹介します。
基本、SDKのメソッド実行でpush通知を配信するので、それらの処理は全てserviceクラスに寄せて処理を共通化しました。例示なので、色々省略しているのでイメージです。

```ruby:aws/sns_service.rb
class Aws::SnsService

  attr_reader :client, :config

  #
  # SNSクライアントを初期化
  #
  def initialize
    # IAMユーザーの認証情報は利用せず、IAMロール(ECSタスクロール)を利用しています
    @client = Aws::SNS::Client.new(region: リージョン名を変数で入れる)
    @config = {
      apns: APNS,
      platform_application_arn: プラットフォームアプリケーション名を変数で入れる
    }
  end

  #
  # トピックの作成
  # @param [User] user ユーザー
  # @return [Struct]
  #
  def create_topic(user)
    @client.create_topic(name: "環境名_ユーザーのUID_日付みたいな感じにしました")
  end

  #
  # トピックの削除
  # @param [String] topic_arn トピックARN
  # @return [Aws::EmptyStructure]
  #
  def delete_topic(topic_arn)
    @client.delete_topic(topic_arn: topic_arn)
  end

  #
  # エンドポイントの作成
  # - Push通知用エンドポイント
  # - プラットフォームアプリケーションはAWSマネジメントコンソールから事前に作成している
  # @param [String] uid ユーザーUID
  # @param [String] device_token デバイストークン
  # @return [String] エンドポイントARN
  #
  def create_endpoint(uid:, device_token:)
    res = @client.create_platform_endpoint(
      # プラットフォームアプリケーションはAWSマネジメントコンソールから事前に作成している
      platform_application_arn: @config[:platform_application_arn],
      token: device_token,
      custom_user_data: { user_uid: uid }.to_json
    )
    res.endpoint_arn
  end

  #
  # エンドポイントに紐づくデバイストークンを更新
  # @param [String] device_token デバイストークン
  # @param [String] endpoint_arn エンドポイントARN
  # @return [Struct]
  #
  def update_endpoint(device_token:, endpoint_arn:)
    @client.set_endpoint_attributes(
      {
        endpoint_arn: endpoint_arn,
        attributes: {
          Enabled: 'true',
          Token: device_token
        }
      }
    )
  end

  #
  # エンドポイントの削除
  # - フォローされたときのPush通知用エンドポイント
  # @param [User] user ユーザー
  # @return [Array]
  #
  def delete_endpoint(user)
    user.push_notice_tokens.map do |p|
      @client.delete_endpoint(endpoint_arn: p.endpoint_arn)
    end
  end
```


いかがだったでしょうか？？ちょっと、概念的な話が多かったかもしれないですが、だいぶ悩み抜いて作った機構なので、誰かの役に立てば嬉しいです！！