Cognitoを使ったらAndroidアプリプッシュ通知実装にサーバサイドプログラミングが不要になった話

こんにちは、id:hogelog(会員事業部 小室)です。

現在自分が開発しているAndroidアプリのプッシュ通知の実装に Amazon Cognito, Amazon SNS, Amazon DynamoDB を使ったらアプリコード(と、AWSの設定)だけで機能が実現できてしまい、予定していたサーバサイド実装がまったく不要となったのでその知見を共有します。

アプリプッシュ通知の要件

今回実装したプッシュ通知の要件は以下です。

  • プッシュ通知を許可したユーザ全員に共通した内容を一斉通知
  • 通知はバッチプログラムから週に数回程度
  • 年内には一万ユーザぐらいに利用されること目標
  • GCMトークンはデータストアに記録しておく
    • 将来的にはA/Bテストなどをおこなうことも可能なように

当初はこれらの機能を実現するため、適当なRailsアプリでGCMトークンを受け取ってうまいことあれこれするAPIを実装しようと考えていました。

Cognitoを使った構成

Cognitoを使うとAmazon Mobile SDKを用いてAWSの機能に直接アクセスすることが可能となります。 今回は以下の機能を利用しました。

  1. Cognitoによる匿名ユーザ認証
  2. SNSにGCMトークンを登録
  3. SNSのTopicにSubscribe
    • アプリユーザに一括配信するため
  4. DynamoDBに直接データを登録
    • アプリからの扱いやすさ、一括読み込みなどのしやすさから

1. 匿名ユーザ認証

  • Cognito ConsoleでIdentity poolを新規に作成
    • Cognitoはまだ東京リージョンに来ていないので北米リージョンを利用(レスポンスを待つ画面が無いため遠いリージョンでもさほど問題なし)
    • Enable access to unauthenticated identitiesにチェックを入れて匿名ユーザ認証機能を有効とする
  • Cognito SDKの依存関係を追加
compile 'com.amazonaws:aws-android-sdk-cognito:2.2.2'
  • ユーザ認証処理をアプリ内に実装
CognitoCachingCredentialsProvider credentialsProvider = new CognitoCachingCredentialsProvider(
        context,
        "us-east-1:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", // 作成したCognito identity pool ID ARN
        Regions.US_EAST_1
);

これでアプリ初回起動時に匿名ユーザが自動的に作成され、次回以降自動的に同一ユーザとして認証されます。

2. GCMトークンをSNSに登録

compile 'com.amazonaws:aws-android-sdk-sns:2.2.2'
  • 前述したCognito認証情報を用いてGCMトークンをSNSアプリケーションに登録
AmazonSNSClient snsClient = new AmazonSNSClient(credentialsProvider);
snsClient.setRegion(Region.getRegion(Regions.AP_NORTHEAST_1));

CreatePlatformEndpointRequest createRequest = new CreatePlatformEndpointRequest();
createRequest.setPlatformApplicationArn("arn:aws:sns:ap-northeast-1:123456789012:app/GCM/AndroidPushApp"); // 作成したプラットフォームアプリケーションARN
createRequest.setToken("xxxxxxxxxxxxxxxx"); // GCMサーバから受け取ったGCMトークン
CreatePlatformEndpointResult platformEndpoint = snsClient.createPlatformEndpoint(createRequest);

3. SNSのTopicにSubscribe

  • SNS Consoleからトピックを新規作成
  • SNSアプリケーションへの登録のレスポンスに含まれるSNSエンドポイントを用いてSNS Topicを購読
String endpointArn = platformEndpoint.getEndpointArn();
snsClient.subscribe("arn:aws:sns:ap-northeast-1:123456789012:campaign-development", "application", endpointArn); // 作成したトピックARN

4. DynamoDBへのデータ保存

  • DynamoDB Consoleから新規テーブルを作成
    • プライマリキーの属性はハッシュ、ハッシュ属性は文字列とする
  • テーブルに対応するPOJOクラスを作成
@DynamoDBTable(tableName = AwsConstant.DDB_TABLE_NAME)
public class PushToken {
    @DynamoDBHashKey(attributeName = "CognitoIdentityId")
    private final String cognitoIdentityId;

    @DynamoDBAttribute(attributeName = "GcmToken")
    private final String gcmToken;

    @DynamoDBAttribute(attributeName = "SnsEndpoint")
    private final String snsEndpoint;

    public PushToken(String cognitoIdentityId, String gcmToken, String snsEndpoint) {
        this.cognitoIdentityId = cognitoIdentityId;
        this.gcmToken = gcmToken;
        this.snsEndpoint = snsEndpoint;
    }

    public String getCognitoIdentityId() {
        return cognitoIdentityId;
    }

    public String getGcmToken() {
        return gcmToken;
    }

    public String getSnsEndpoint() {
        return snsEndpoint;
    }
}
  • 取得したGCMトークン、SNSエンドポイントをDynamoDBに保存
AmazonDynamoDB ddbClient = new AmazonDynamoDBClient(credentialsProvider);
ddbClient.setRegion(Region.getRegion(Regions.AP_NORTHEAST_1));
DynamoDBMapper ddbMapper = new DynamoDBMapper(ddbClient);
PushToken pushToken = new PushToken(credentialsProvider.getCachedIdentityId(), gcmToken, snsEndpoint);
ddbMapper.save(pushToken);

実際のコードはエラーハンドリングなどもあるためもうちょっと複雑になりますが、 基本的には以上に示したように非常に簡単な記述のみでSNS、DynamoDBにアクセスできます。

プッシュ通知の配信処理

上述したモバイルアプリの処理でSNS Topicに各Androidアプリに紐付いたトークンが登録されるため、プッシュ通知の配信処理は バッチ処理(or 管理ツール等)からSNS Publishエンドポイントを呼び出し を呼び出すだけで完了します。

sns_client = Aws::SNS::Client.new(region: "ap-northeast-1")
sns_client.publish(
  topic_arn: "arn:aws:sns:ap-northeast-1:123456789012:campaign-development", # 作成したSNS Topic ARN
  message: message_json, # 送りたいメッセージのJSON
  message_structure: "json",
)

(弊社の標準的なプログラム言語であるRubyを例としましたが、特に言語は問いません)

2015年6月15日現在、Topicあたり(デフォルトで)1000万までの購読がサポートされているので相当な規模のプッシュ配信とならない限りこの構成で問題なさそうです。

SNS は、デフォルトでトピックあたり 1,000 万のサブスクリプション、アカウントあたり 3,000 のトピックを提供しています。制限の引き上げをリクエストするには、http://aws.amazon.com/support からお問い合わせください。

http://aws.amazon.com/jp/sns/faqs/#limits-restrictions

Cognitoユーザへの権限の付与

弊社ではAWS IAMの権限管理にmiamを利用しているため、 以下のようなDSLでCognito認証ユーザにSNS、DynamoDBへのアクセス権を付与しました。

role "CognitoUnauth", :path=>"/" do
  assume_role_policy_document do
    {"Version"=>"2012-10-17",
     "Statement"=>
      [{"Sid"=>"",
        "Effect"=>"Allow",
        "Principal"=>{"Federated"=>"cognito-identity.amazonaws.com"},
        "Action"=>"sts:AssumeRoleWithWebIdentity",
        "Condition"=>
         {"StringEquals"=>
           {"cognito-identity.amazonaws.com:aud"=>
             "us-east-1:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
          "ForAnyValue:StringLike"=>
           {"cognito-identity.amazonaws.com:amr"=>"unauthenticated"}}}]}
  end

  policy "SNSAccess" do
    {"Version"=>"2012-10-17",
     "Statement"=>
      [{"Effect"=>"Allow",
        # アプリから利用するActionのみ許可
        "Action"=>[
          "sns:CreatePlatformEndpoint",
          "sns:SetEndpointAttributes",
          "sns:Subscribe",
          "sns:Unsubscribe"
        ],
        # アクセス可能リソースを作成したアプリケーション、Topicに限定
        "Resource"=>[
          "arn:aws:sns:ap-northeast-1:123456789012:app/GCM/AndroidPushApp",
          "arn:aws:sns:ap-northeast-1:123456789012:campaign-development"
        ]}]
     }
  end

  policy "DynamoDBAccess" do
    {"Version"=>"2012-10-17",
     "Statement"=>
      [{"Effect"=>"Allow",
        # アプリから利用するActionのみ許可
        "Action"=>[
          "dynamodb:PutItem",
          "dynamodb:UpdateItem"
        ],
        # アクセス可能リソースを作成したアプリケーション、Topicに限定
        "Resource"=>[
          "arn:aws:dynamodb:ap-northeast-1:123456789012:table/push_tokens"
        ],
        # アクセス可能レコードを限定
        "Condition"=>
         {"ForAllValues:StringEquals"=>{
          "dynamodb:LeadingKeys" => ["${cognito-identity.amazonaws.com:sub}"]}}}] # ハッシュキーをCognitoユーザIDに限定
     }
  end
end
  • SNS、DynamoDBの権限付与は実際に扱う操作のみに限定
  • DynamoDBへのアクセスではCognitoユーザIDがハッシュキーとなるレコードにしかアクセスできないように制限
  • (余談) ちなみにIAM権限追加などもアプリエンジニアがPull Request形式で送り、レビュー&マージ&適用される弊社のインフラのdev-ops環境、非常に面白いです。

例示したAndroidアプリコード例については https://github.com/hogelog/aws-mobile-sdk-example にまとめましたので、詳細についてはこちらをご覧ください。

まとめ

以上のようにモバイルアプリからAWSの機能を直接扱うことのできるCognitoにより、サーバサイドの実装なしで 今回のAndroidアプリへのプッシュ通知要件を満たすことができました。

おかげで30営業日ぐらいの見積もりをしていたスケジュールが10営業日ぐらいのスケジュールに圧縮されそうな勢いで大喜びです。(作業量見積もりという意味では失敗しているのですが……)

「モバイルアプリ開発者」「サーバサイド開発者」というくくりで自分の立ち位置を限定させていると、 ふと気づけばあっという間に仕事がなくなっているということもありえそうです。

クックパッドでモバイルアプリ・Rails・AWSなど垣根を越えて幅広い領域を駆使してユーザに価値を届けたいエンジニアの方は http://recruit.cookpad.com/からのご応募、お待ちしております。

雑な雑談からのCognito

ちなみに本案件でのCognitoの採用はGCMトークンを受け取るAPIサーバの実装をめんどうに思った私が弊社インフラ部の星 (@kani_b)さんに 「実装めんどくさいんですけど、なんかこうグッと楽になんとかなりませんか?」と相談したところ「それCognitoで良いのでは?」という返答を得たところから始まりました。 雑なコミュニケーションはやっぱり大事だなあと実感した事例となりました。

ちなみにCognitoは2015年の夏には東京リージョンにもやってくるらしいので今後国内のサービスでももっと利用しやすくなりそうです。各位検討してみてはいかがでしょうか?

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/