AWS Lambda と SNS で作るサーバレス iOS プッシュ通知システム

こんにちは、片岡です。

Amazon SNS を使ってモバイルプッシュ通知システムを構築しようとする場合、大きく分けて 2 つのアーキテクチャが考えられます。
一つは、モバイルアプリ側でエンドポイントを作成するパターン、もう一つはバックエンド側でエンドポイントを作成するパターンです。
今回は、後者のパターンを AWS Lambda を使って構築してみます。

本題に入る前に、一度 SNS のいくつかの基本概念をおさらいしておきます。

## Amazon SNS のおさらい

- エンドポイント
    - 各通知サービス (APNs や CGM) から発行されるトークンから生成される
    - SNS 上で各デバイスを識別する単位
    - エンドポイントを指定して SNS に通知のリクエストを送ることで、個別のデバイスにプッシュ通知を送ることができる
- トピック
    - エンドポイントはトピックを購読 (Subscribe) できる
    - トピックを指定して SNS に通知のリクエストを送ることで、そのトピックを購読しているエンドポイントにプッシュ通知をまとめて送ることができる
- アプリケーション
    - SNS から APNs にプッシュ通知のリクエストを送る際に使う SSL 証明書を管理する (CGM の場合は API キーを管理する)

iOS のプッシュ通知システムを構築する場合は、あらかじめアプリケーションを一つ作成しておき、そのアプリケーションの ARN とアプリ側で取得したトークンをパラメータにして、エンドポイントを作成します。

また、あらかじめ一斉通知用のトピックを作成しておき、そのトピックの ARN とエンドポイント作成時にレスポンスされるエンドポイントの ARN をパラメータにして、サブスクリプションを作成するようにしておきます。
(全デバイスにではなく、もうすこし細かいユーザセグメントに対してまとめて通知を送りたい場合は、その分のトピックも作成 & サブスクライブすることになります)

そして、実際にプッシュ通知を送る段階で、個別のデバイスにプッシュ通知を送りたい場合はエンドポイント ARN を、ブロードキャストでプッシュ通知を送りたい場合はトピックの ARN をパラメータにして、SNS にリクエストを送ります。

## モバイルプッシュ通知システムを構築する 2 つの方法

さて、SNS の基本がおさらいできたところで、話を戻します。

冒頭でも軽く触れましたが、一つは、APNs や CGM から取得したトークンを使ってモバイルアプリ側でエンドポイントを作成するパターン、もう一つは、トークンをバックエンドのサーバに送り、サーバ側でエンドポイントを作成するパターンです。

もうすこし詳しく見てみます。

### モバイルアプリ側でエンドポイントを作成するパターン

![](https://cldup.com/Iuwej4Sdfd.png)

このパターンはサーバサイドのコードを書く必要がなく、アプリ側のコードだけで完結するのが特徴です。

Amazon Cognito を使って AWS リソースへのアクセス許可をおこない、APNs や CGM から取得したトークンをパラメータにして SNS にエンドポイントを作成します。

作成時にエンドポイントの ARN がレスポンスされるので、必要に応じてサブスクリプションの作成をおこないます。
さらに、ユーザ個別にプッシュ通知を送りたい場合は、エンドポイント ARN を DynamoDB に保存しておき、特定のユーザ向けに通知が送れるようにしておきます。

既にアプリから Cognito や DynamoDB を使っていて新規に通知の機能をつけたい場合や、バックエンドは必要ないがお知らせだけ一斉通知で送りたいようなアプリを実装する場合は、こちらのパターンが向いているかと思います。

### バックエンド側でエンドポイントを作成するパターン

![](https://cldup.com/OD3DOZl84J.png)

こちらは、アプリ側の処理は APNs や CGM から取得したトークンをサーバに送信するだけです。
(場合によってはログインユーザの ID 等と関連付けて送ることになるかと思います)

サーバ側はトークンを受け取り、SNS にエンドポイントを作成し、必要に応じてトピックをサブスクライブした後、エンドポイント ARN をデータベースに保存します。

サーバ側から SNS にリクエストを送れば、特定のユーザへの通知、一斉通知、特定のトピックへの通知など柔軟におこなえます。

また、あとからトピックを追加して既存のエンドポイントのサブスクリプションを作ったり、ユーザの属性が変わることによってサブスクリプションを付け替えたりする必要がある場合でも、比較的容易に対応できます。

既に EC2 上に構築したデータベースが稼働していて通知の機能を追加したい場合や、通知を送りたいユーザセグメントがバックエンド側で頻繁に変わる (トピックの増減が頻繁にある) ような場合には、こちらのパターンを採用することになるかと思います。

今回は API Gateway + Lambda + DynamoDB を使ってこちらのパターンのシステムを簡単に構築してみます。

### 今回構築するシステム

![](https://cldup.com/dBinZO8qp5.png)

アプリはトークンを取得した後、API Gateway にトークンとユーザ ID を送信します。
特定のユーザにプッシュ通知を送ることを想定してユーザ ID も一緒に送ることにします。

また、API Gateway には API キーによるアクセス制限を設定することにします。

リクエストを受けた API Gateway は Lambda をキックして、エンドポイントの作成、サブスクリプションの作成をおこなった後、DynamoDB にエンドポイント ARN を保存します。

では、実装にはいります。

## 実装

API Gateway はコンソールから Lambda を作る際に自動で作成できるようになっているので、今回やることは以下の 5 つになります。

- SNS のアプリケーション & トピックの作成
- DynamoDB のテーブルの作成
- Lambda 関数の作成
- API Gateway の使用量プラン & API トークンの作成
- iOS アプリの実装

### SNS のアプリケーション & トピックの作成

まずは、SNS のダッシュボードからアプリケーションの作成をおこないます。

![](https://cldup.com/ZFJY0aDjwv.png)

アプリケーションの名前は「my-app-dev」とします。
今回は Sandbox の APNs を使用するので Push notification platform は 「Apple development」にします。
Apple のデベロッパーコンソールから作成したプッシュ通知用の SSL 証明書をここからアップロードします。

![](https://cldup.com/87V_jBfGeK.png)

つぎに、トピックの作成です。

![](https://cldup.com/wfzpXsdQV6.png)

Topic name は「my-topic-dev-all」とします。
Display name は空でも大丈夫です。

![](https://cldup.com/JYOji_Kx5Z.png)

### DynamoDB のテーブルの作成

DynamoDB のダッシュボードに移ります。

![](https://cldup.com/6xUWUSCwd1.png)

テーブル名は「users-dev」、プライマリーキーは「id」とします。 

![](https://cldup.com/62xYNc0oZq.png)
￼
### Lambda 関数の作成

つぎは、Lambda 関数の作成です。

![](https://cldup.com/1JRWl-mKJ7.png)
￼
ブランク関数を選択します。

![](https://cldup.com/IifdDH5HQy.png)
￼
Lambda 関数のトリガーを選択する画面に移りますので、「API Gateway」を選択します。

![](https://cldup.com/hWLsQutVnn.png)
￼
すると、API Gateway を作成する画面になります。セキュリティは「アクセスキー使用でのオープン」を選択します。
それ以外の項目はデフォルトのままでも構いません。

![](https://cldup.com/zZAzMsyGCr.png)

Lambda 関数の設定画面に移ります。
名前は「my-func」とします。

![](https://cldup.com/Omerm2u98f.png)

この画面で以下のコードを入力します。(下に解説つけます)
今回はデフォルトの Node.js 4.3 を使用します。

`snsPlatformApplicationArn` と `snsTopicArn` の中の `{REPLACE_WITH_YOUR_AWS_ACCOUNT_ID}` は、各々の AWS アカウントの ID に書き換えてください。
あるいは、SNS のコンソールからそれぞれの ARN をコピーしたほうが早いかもしれません。

```js
const AWS = require('aws-sdk');

AWS.config.update({ region: 'ap-northeast-1' });

const sns = new AWS.SNS();
const snsPlatformApplicationArn = 'arn:aws:sns:ap-northeast-1:{REPLACE_WITH_YOUR_AWS_ACCOUNT_ID}:app/APNS_SANDBOX/my-app-dev';
const snsTopicArn = 'arn:aws:sns:ap-northeast-1:{REPLACE_WITH_YOUR_AWS_ACCOUNT_ID}:my-topic-dev';
const docClient = new AWS.DynamoDB.DocumentClient();
const docTableName = 'users-dev';

const createSnsEndpoint = (token) => {
  return new Promise((resolve, reject) => {
    const params = {
      PlatformApplicationArn: snsPlatformApplicationArn,
      Token: token
    };
    sns.createPlatformEndpoint(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

const enableSnsEndpoint = (endpointArn) => {
  return new Promise((resolve, reject) => {
    const params = {
      Attributes: {
        Enabled: 'true'
      },
      EndpointArn: endpointArn
    };
    sns.setEndpointAttributes(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

const createSnsSubscription = (endpointArn) => {
  return new Promise((resolve, reject) => {
    const params = {
      Protocol: 'application',
      TopicArn: snsTopicArn,
      Endpoint: endpointArn
    };
    sns.subscribe(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

const storeSnsEndpointToDynamoDB = (id, endpointArn) => {
  return new Promise((resolve, reject) => {
    const params = {
      TableName: docTableName,
      Key: { id: id },
      UpdateExpression: 'add snsEndpoints :value',
      ExpressionAttributeValues: { ':value': docClient.createSet([endpointArn]) },
      ReturnValues: 'UPDATED_NEW'
    };
    docClient.update(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

const responseData = (event, context, data) => {
  const responseBody = {
    data: data,
    input: event
  };
  const response = {
    statusCode: 200,
    body: JSON.stringify(responseBody)
  };
  context.succeed(response);
};

const responseError = (event, context, err) => {
  const responseBody = {
    err: err.stack,
    input: event
  };
  const response = {
    statusCode: 400,
    body: JSON.stringify(responseBody)
  };
  context.succeed(response);
};

exports.handler = (event, context) => {
  try {
    const body = JSON.parse(event.body);
    const userId = body.userId;
    const snsEndpointToken = body.snsEndpointToken;

    if (!userId || !snsEndpointToken) {
      return responseError(event, context, new Error('Missing parameter `userId` or `snsEndpointToken`'));
    }

    createSnsEndpoint(snsEndpointToken)
      .then(data => enableSnsEndpoint(data.EndpointArn).then(() => Promise.resolve(data)))
      .then(data => createSnsSubscription(data.EndpointArn).then(() => Promise.resolve(data)))
      .then(data => storeSnsEndpointToDynamoDB(userId, data.EndpointArn))
      .then(data => responseData(event, context, data))
      .catch(err => responseError(event, context, err));   
  } catch(e) {
    return responseError(event, context, new Error('Problems parsing JSON'));
  }
};
```
￼
つぎに、「Lambda 関数ハンドラおよびロール」という項目のロールから「カスタムロールの作成」を選択します。

![](https://cldup.com/mtoDpilBI9.png)
￼
すると、次のような画面に遷移します。
「編集」をクリックして、ポリシーの入力フォームに以下の JSON を入力します。

![](https://cldup.com/m4Ajxc28mw.png)

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "sns:CreatePlatformEndpoint",
        "sns:SetEndpointAttributes",
        "sns:Subscribe"
      ],
      "Resource": "arn:aws:sns:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:UpdateItem"
      ],
      "Resource": "arn:aws:dynamodb:*:*:*"
    }
  ]
}
```

「許可」をクリックすると元の画面に戻りますので、確認後 Lambda 関数を作成します。

### API Gateway の使用量プラン & API トークンの作成

API Gateway のダッシュボードに移ります。
「使用量プラン」のメニューに移り、作成ボタンをクリックします。

名前は「my-plan」とします。
スロットリングとクォータの設定は適当で構いません。無効にしておいても OK です。

![](https://cldup.com/HNkODwhkrr.png)

つぎの画面で「API ステージの追加」ボタンをクリックして、API は「LambdaMicroservice」、ステージは「prod」を選択します。

![](https://cldup.com/IS0y0C9lws.png)
￼
つぎの画面では「API キーを作成して使用量プランに追加」をクリックします。

![](https://cldup.com/k6L-3tHmdD.png)
￼
すると、以下のようなダイアログが出ますので、名前を入力して保存します。
今回は「my-api-key」とします。

![](https://cldup.com/rSKrAHWgVu.png)
￼
アプリ側で API キーを使用するので、「API キー」のメニューから作成した API キーを選択して値をコピーしておきます。

![](https://cldup.com/ZaSx1at-gP.png)
￼
また、メニューから「LambdaMicroservice」→「ステージ」→「prod」と進み、「URL の呼び出し」の値もコピーしておきます。
こちらの値に「/my-func」を追加した URL が、アプリ側から叩く API のエンドポイントになります。

￼![](https://cldup.com/ueal4sgWur.png)

以上で AWS 上での操作は完了です。
コマンドラインから API を叩いてシステムが正常に動いているか確認できます。

API キーと URL は、それぞれ書き換えてください。

```
$ curl -v -H "Content-Type: application/json" -H "x-api-key: Is4JQkFGpS1X0mIXhrHvn79Ul6Pk1ZhTalh5JAjK" -X POST -d "{\"userId\":\"hogehoge\",\"snsEndpointToken\":\"fugafuga\"}" https://ue45qrif29.execute-api.ap-northeast-1.amazonaws.com/prod/my-func
```

200 が返ってくれば成功です。
コンソールから DynamoDB の「users-dev」テーブルに「hogehoge」という id の項目が追加されていることや、SNS の「my-app-dev」アプリケーションに「fugafuga」という名前のエンドポイントが確認できるかと思います。

### iOS アプリの実装

最後にアプリ側の実装を Swift でおこないます。
HTTP のリクエストには Alamofire というライブラリを使っています。

`userId` 決め打ちで送っていますが、実際にはここにログインユーザの ID などを指定することになるかと思います。

```swift:AppDelegate.swift
import UIKit
import UserNotifications
import Alamofire

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
        }
        application.registerForRemoteNotifications()
        return true
    }

    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let deviceTokenString = deviceToken.map { String(format: "%.2hhx", $0) }.joined()
        Alamofire.request(
            "https://ue45qrif29.execute-api.ap-northeast-1.amazonaws.com/prod/my-func",
            method: .post,
            parameters: ["userId": "abc123", "snsEndpointToken": deviceTokenString],
            encoding: JSONEncoding.default,
            headers: ["x-api-key": "Is4JQkFGpS1X0mIXhrHvn79Ul6Pk1ZhTalh5JAjK"]
        ).responseJSON { response in
            NSLog("response: \(response)")
        }
    }

}
```

アプリを起動してエンドポイント作成が上手くいっていることを確認して、コンソールからプッシュ通知を送ってみましょう。
iPhone に通知が届けば成功です。

## Lambda 関数の簡単な解説

メインの処理は以下の箇所になります。

```
createSnsEndpoint(snsEndpointToken)
      .then(data => enableSnsEndpoint(data.EndpointArn).then(() => Promise.resolve(data)))
      .then(data => createSnsSubscription(data.EndpointArn).then(() => Promise.resolve(data)))
      .then(data => storeSnsEndpointToDynamoDB(userId, data.EndpointArn))
      .then(data => responseData(event, context, data))
      .catch(err => responseError(event, context, err)); 
```

- `createSnsEndpoint` でエンドポイント作成しています。
    - SNS ではリクエストされたトークンを持つエンドポイントが既に存在している場合は、新たに作成せずにそのエンドポイントをレスポンスするようになっているので、何も考えずにとりあえずリクエストを送っています。
- `enableSNSEndpoint` でエンドポイントの Enabled 属性を true にしています。
    - アプリがアンインストールされたりして通知に失敗していると、Enabled 属性が false になっている場合があります。
    - 再インストール時に APNs からアンインストール前と同じトークンを取得した場合、有効なトークンにも関わらずエンドポイントの Enabled が false のままになってしまいます。
    - これを回避するために、必ず Enabled 属性が true になるようにしています。
- `createSnsSubscription` でトピックをサブスクライブしています。
- `storeSnsEndpointToDynamoDB` で DynamoDB にデータを保存しています。
    - 項目が存在しない場合は新規作成、存在する場合は更新されるように `update` 関数を使っています。
    - 文字列セット型の `snsEndpoints` 属性にエンドポイントの ARN を保存しています。

## さいごに

API Gateway と Lambda、DynamoDB を使ってバックエンド側でエンドポイントを作成するシステムを簡単？に構築してみました。

今回構築したシステムはアプリ側から送るユーザ ID も決め打ちのもので、アクセス制限も API キーによるものでしたが、Cognito の User Pools と API Gateway のカスタムオーソライザーと組み合わせることで、ユーザのサインアップ/ログインもサーバレスに構築できます。
参考：[Integrating Amazon Cognito User Pools with API Gateway](https://aws.amazon.com/jp/blogs/mobile/integrating-amazon-cognito-user-pools-with-api-gateway/)

また、今回は通知を送るほうについてはあまり触れませんでしたが、こちらも DynamoDB Streams と Lambda を組み合わせるなどしてデータドリブンしたいところです。

また別の記事で紹介したいと思います。