クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

Parse.com終了に伴いプッシュ通知をAWS SNSに乗り換える

みなさんさようなら、インフラ部の@h3_potetoです。

昨年からクラウドワークスではアプリを開発しています。

クラウドワークス 仕事検索&仕事開始アプリ

クラウドワークス 仕事検索&仕事開始アプリ

  • クラウドワークス
  • ビジネス
  • 無料

play.google.com

アプリなので、当たり前のようにプッシュ通知が実装されています。

今日はそのプッシュ通知の裏側を支えていたParse.comが終了になるので、AWS SNSに乗り換えた話をします。

CrowdWorksのParse.com事情

CrowdWorksでのParse.comの利用は限定的で、プッシュ通知の機能のみを使っていました。

デバイス登録

  1. アプリ起動時にデバイストークンを受け取る
  2. アプリ側でParse側にデバイストークン登録
  3. InstallationIdを受け取り、その値をCrowdWorksのAPIエンドポイントにPOST
  4. CrowdWorks側で、Parse.comのInstallationIdをDBに登録

という手順で、デバイス登録が行われます。

f:id:h3poteto:20160616201130p:plain

プッシュ送信

デバイス登録が済んだ状態で、

  1. プッシュが必要なイベントが発生(メッセージ受信等)
  2. 対象ユーザのデバイスを絞込
  3. Parse.comにプッシュ用のデータを送信
  4. Parse.comがAPNsやGCMにデータを送る
  5. APNsやGCMから端末にプッシュが届く

というような流れでプッシュ通知が送信されます。

f:id:h3poteto:20160616201142p:plain

Parse.comの終了

2016年1月29日にParse.comが終了されるという発表がありました。

http://blog.parse.com/announcements/moving-on/

乗り換え先の検討

終了するのであれば、乗り換え先を考えるしかありません。

以下の記事で、移行先について検討されていたため、参考にしました。

qiita.com

qiita.com

takuya.hamazo.tv

CrowdWorksではプッシュ通知しか使っておらず、この先もプッシュ通知の運用ができれば良いと思ったため、AWS SNSを使うことにしました。 SNSのプッシュ通知なら構築したことがあったのと、そもそもサーバも全てAWS上にあるので、楽だというのもかなりあります。

SNSを使った際の構成

デバイス登録とプッシュ送信の手順は先程書きましたが、これをAWS SNSに載せ替えた場合に、以下の様な構成でプッシュを送信します。

デバイス登録

  1. アプリがデバイストークンを受け取る
  2. AWS Cognitoへの認証をかける
  3. Cognito経由でSNSにデバイストークンを登録する
  4. 登録成功時に受け取れるARNを、CrowdWorksのAPIエンドポイントにPOST
  5. CrowdWorks側でARNをDBに登録

f:id:h3poteto:20160616201226p:plain

プッシュ送信

  1. プッシュが必要なイベントが発生(メッセージ受信等)
  2. 対象ユーザのデバイスを絞込
  3. APNsやGCMのメッセージペイロードを組み立てる
  4. 対象デバイスが紐付いているSNS ARNに対してメッセージを送信
  5. SNSがAPNsやGCMにメッセージ送信
  6. APNsやGCMから端末にメッセージが届く

f:id:h3poteto:20160616201233p:plain

実際の移行

懸案事項

  • プッシュ通知が送信できないタイミングが発生しないか?
  • プッシュ通知を送れなくなるデバイスが発生しないか?

というようなことが考えられます。 おそらく、既に動いているプッシュ通知の載せ替えというのは、そうあるものではないので……。

以下の記事を参考にさせてもらい、次のような作戦を立てました。

dev.classmethod.jp

移行作戦

これには、CrowdWorks本体のアプリケーションと、iOSやAndroid用のアプリでの作業が混ざってきます。 区別のため、CrowdWorks本体アプリケーションの修正を[Rails側]、アプリ側への修正を[アプリ側]と書いておきます。

  • [Rails側]CrowdWorksのデバイス登録APIを改修し、Paser.comのInstallationId以外に、SNSのARNも受け取って登録できるようにしておく
  • [アプリ側]「アプリにAWS SDKを導入し、Cognito経由でSNSにデバイス登録後、ARNを上記のエンドポイントにPOSTする」ところまで作って一旦リリース

この時点では、アプリはParse.comにも登録して、SNSにも登録させます。 登録は両方にしておいて、CrowdWorks側へのデバイス登録時にも、Parse.comのInstallationIdとSNSのARNの両方を送ってもらいます。 ただし、この時点では、まだプッシュ通知はParse.com経由で全て送信しておきます。

  • [Rails側]CrowdWorksをメンテナンス状態にして、デバイス登録処理・プッシュ通知送信処理を全て停止する
  • この隙にParse.comのデータをエクスポートする
  • エクスポートされたデータをAWS SNSに登録
  • [Rails側]登録完了したARNをCrowdWorksのDBに登録
  • [Rails側]メンテナンスを終了

ここまでで過去にParse.comに登録されたユーザのデータが全てSNSに登録され、プッシュ通知が送れるようになります。 新規でデバイス登録をするユーザは、当然新しいバージョンをアプリを使っているはずなので、Parse.comにもAWS SNSにも登録されます。 これで、全てのデバイスがAWS SNSに登録されていることになるため、「プッシュ通知が送れなくなるデバイス」は発生しません。

  • [アプリ側]アプリでSNS経由のプッシュ通知を送ってバグがないことを確認
  • [Rails側]プッシュ通知の送信ロジックをSNS経由に変更したコードをリリースする
  • [アプリ側]「不要なParse.comへの登録コードを削除」したバージョンをリリース

これで、Parse.comへのアクセスがなくなり、全てAWS SNS経由でプッシュ通知が送信されるようになります。

やっぱりいちばん怖いのはメンテですよね。

移行メンテ

実際の移行メンテでは、作業時間として2時間くらいを想定していました。 実際には2時間以上かかりましたが……。

移行での悩みどころは

  • Parse.comからエクスポートしてきたデータの整形
  • SNSに登録されたARNをどうやってCrowdWorksのDBに登録するか

です。

データ整形

データの整形については、いろいろ紹介されていますが、自分でスクリプトを書きました。 AWS SNSのCreate Application Endpointを開いてみると、サンプルのCSVがダウンロードできるので、カラム数少ないし簡単に書けるかと思います。

require 'json'
require 'csv'

import_ios = []
import_android = []
open("installation.json") do |io|
  json = JSON.load(io)
  json["results"].each do |result|
    case result["deviceType"]
    when "android"
      import_android.push(result)
    when "ios"
      import_ios.push(result)
    else
      raise "deviceTypeがおかしいよ!"
    end
  end
end

import_csvs_ios = []
import_ios.each_slice(5000).to_a.each do |slice|
  import_csv_ios = CSV.generate do |csv|
    slice.each do |result|
      unless result["deviceToken"].nil?
        data = [
          result["deviceToken"],
          result["installationId"]
        ]
        csv << data
      end
    end
  end
  import_csvs_ios.push(import_csv_ios)
end


import_csvs_android = []
import_android.each_slice(5000).to_a.each do |slice|
  import_csv_android = CSV.generate do |csv|
    slice.each do |result|
      unless result["deviceToken"].nil?
        data = [
          result["deviceToken"],
          result["installationId"]
        ]
        csv << data
      end
    end
  end
  import_csvs_android.push(import_csv_android)
end


import_csvs_ios.each_with_index do |import_csv_ios, index|
  File.open("installation_ios_#{index}.csv", 'w') do |file|
    file.write(import_csv_ios)
  end
end

import_csvs_android.each_with_index do |import_csv_android, index|
  File.open("installation_android_#{index}.csv", 'w') do |file|
    file.write(import_csv_android)
  end
end

できあがるファイルを、5000件ずつ分割していますが、これの理由は後述します。

これをやると、DeviceTokenと、InstallationIdが入ったCSVができあがります。

登録したARNをDBに移す

AWS SNSのCreate Application Endpointで、CSVを読み込ませて複数件一斉登録すると、完了後に、goodTokens.csvbadTokens.csv がダウンロードできます。

goodTokens.csv はその名の通り、登録が成功したデータの、ARN, DeviceToken, CustomUserData が入っています。 badTokens.csv は、重複登録等で登録できなかったデータの、DeviceToken, CustomUserData が入っています。

重複登録になるものは(想定はされているが)、今回CrowdworksのDBにARNを登録してやる必要が無いので、badTokens.csv については無視することにしました。

登録後に、goodTokens.csv を使ってCrowdworks側のDBにARNを登録します。 先ほどのスクリプトで吐き出した、deviceTokenが含まれるCSVには、InstallationIdも含まれています。これを、CustomUserDataとして登録してやります。

そのため、goodTokens.csv のCustomUserDataには、Parse.comのInstallationIdが入った状態となります。

そして、CrowdWorksのDB内では、Parse.comのInstallationIdも、SNS ARNも登録できるようになっています。 なので、このParse.comのInstallationIdを鍵にして、SNS ARNをDBに登録していきます。

CREATE TABLE `devices` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) DEFAULT NULL,
  `installation_id` varchar(255) DEFAULT NULL,
  `sns_arn` text,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
);

省略しましたが、こんな感じのテーブルになってます。

で、以下の様なスクリプトを流します。

require 'csv'

# iosのgoodToken.csv: sns_arn_ios.csv
# androidのgoodToken.csv: sns_arn_android.csv
def update_sns_arn(data)
  puts data
  # 一列目がarn、二列目がdeviceToken、三列目がParseInstallationId
  device = Device.where(installation_id: data[2]).first
  if device.nil?
    # さて、もしparse側にだけ存在してDB内に存在しないデバイスを発見してしまったらどうしよう?
    # ここで登録してしまってもいい気もするが、ユーザとのひも付けができないので、結局プッシュ対象になり得ない
    # ということは飛ばしてしまっても良いのでは?
    puts "#{data} is not exist in database"
  else
    device.update_attributes!({
      sns_arn: data[0]
    })
    puts "#{device.inspect} updated."
  end
end

CSV.foreach('sns_arn_ios.csv') do |data|
  update_sns_arn(data)
end

CSV.foreach('sns_arn_android.csv') do |data|
  update_sns_arn(data)
end

これでSNSに登録されたARNが、DB上のdevicesテーブルに更新されました。

Tips

ログの置き場所

Parse.comでは、プッシュの送信ログが見られました。 なんと本文まで見られるという設計でした。

SNSでは、送信ログはCloudWatchに飛ばされているので、そちらからみることが出来ます。 ただし、見られるのは、送信ステータスやメッセージIDまで。本文などは見られないので、Parse.comほどの便利さではないです。

送信不可能なDeviceTokenの扱い

APNsの場合、不正なDeviceTokenでプッシュを送りつけると、エラーが返ってきます。 その状態でもプッシュを送り続けると、そのうちプッシュ通知送信自体が拒否されるようになってしまいます。

不正なDeviceTokenというのは、DeviceTokenが間違っているというだけではなく、たとえばアプリをアンインストールされた場合、そのDeviceTokenは使用できなくなります。 そういったものまで全て含まれます。

Parse.comでは、これらの管理をきっちりやってくれていて、APNsからエラーが返ってきたDeviceTokenにはプッシュを送信していませんでした。

SNSでも同様の機構は備えており、プッシュ送信に失敗したARNでは、リクエストが来てもプッシュ通知を送信しない仕組みになっています。

SNS登録の限界値

AWS SNSのCreate Application Ednpointの画面、ここでCSVを読み込めますが……

f:id:h3poteto:20160615160541p:plain

これ、何件くらいのCSVまでいけると思いますか?

Parse.comに登録されていたデバイスの件数が数万件だったため、当然エクスポートして、SNS登録のために用意したCSVも数万行になりました。

そのCSVをこの画面で読み込ませたところ、読み込みはうまくいきました。

しかし、Add Endpoint を押すと、一気に数万件のリクエストが走り、走り、走り続けて、途中で何かが切れます。 その後応答はなく、なんとエンドポイント登録が途中で頓挫します。

そのため、goodTokens.csvbadTokens.csv もダウンロードできないまま、1万件くらいのデバイスが裏側で登録され、ブラウザはそのまま固まります。

困ったことに、どれだけ待っていても1万件以上は登録されませんでした。 おそらくそれ以上のリクエストを受け付けられないのだと思います。

SNS登録用のCSV作成スクリプトで、結果を5000件ずつのファイルに分割したのはそういう理由があります。 あまりよい方法とは思えませんが、5000件程度だったら、数分で登録完了し、レスポンスが返ってきて、goodTokens.csv がダウンロードできました。

これはかなりの罠でした。

もし、もっと件数が多い場合や、手動対応がイヤな場合は、aws-cliや、sdkを使ってAPIを直接呼び出しての登録をおすすめします。 ただし、その場合は、まとめた一括登録ではなくなるので、今回のように goodTokens.csv が手に入らなくなりますが……。

参考になった事例

今回の乗り換えに際して、以下の記事を参考にさせてもらいました。

dev.classmethod.jp

dev.classmethod.jp

qiita.com

qiita.com

Parse.comって使いやすかったよ

SNSは前から使ったことがあったので、今回はじめてわかったことではないのですが、やっぱりParse.comほどの親切さはありません。 Simple Notification Serviceだし、シンプルなのです、いいことですが。

そのため、以前よりもいろんなことを考慮してプッシュ通知送信ロジックを作りなおす必要がありました。

やっぱりParse.com使いやすかったよ。

© 2016 CrowdWorks, Inc., All rights reserved.