TODO:コード例を載せる

Consumable なプロダクトの In-App Purchase は、クライアント側でのアプリ内課金と、サーバ側でのアイテムの付与が1対1になっている必要があります。 課金をしていないのにアイテムの付与をする、あるいは課金をしたのにアイテムを付与していない、という状況は許されません。 今回は、正しくアイテムを付与するためにはどうすればいいのかについて書きます。

正しい付与のやり方

本当に重要な部分は、これだけです。

iOS でアプリ内課金による決済が完了すると、レシートが渡されます。 そのレシートの中にはトランザクションIDが入っていて、それぞれの課金でユニークなIDになっています。 そのため、1個のトランザクションIDで、1回の付与を行えばいいことが分かります。

クライアントは、何とかして、トランザクションIDを含んだレシートをサーバに届ける必要があります。 ネットワークに繋がらない環境であっても、通信中に強制終了されても、アプリを終了されても、頑張って届ける必要があります。 これを満たすためには、最低でも、決済が完了したした時点でレシート情報をアプリ内に保存しておく必要があります。

また、サーバ側から明確に成功か失敗が返るまで、ひたすらリトライをし続ける必要もあります。 アプリを終了して再起動した場合でも、リトライを行う必要があります。 ただし、アプリを消された場合にはどうしようも無いので、これに関してはサポート行きになるでしょう。

サーバは、渡されたレシートの検証を Apple に問い合わせて、付与を行います。 付与を行う際には、必ずトランザクションIDをDBに保存し、そのトランザクションIDが既に付与済みであるかどうかを確認する必要があります。 また、付与済みかどうかを確認し、実際に付与を行い、付与済みフラグを設定するという動作をアトミックに行う必要があります。

まとめると、クライアントは以下の処理を行う必要があります。

  • 課金が完了したら、すぐにレシートをアプリ内のストレージに保存する
    • 複数のレシートが存在するアプリの場合は、キューに入れるような実装にすること
  • サーバにレシートを送信する
  • (※)送信した結果が明確に成功か失敗かを返すまでリトライを行う
    • 成功か失敗が返ってきたらストレージのレシートを削除する
  • 起動時にストレージ内にレシートが残っている場合、(※)のフローへ進む
    • アプリの仕様によっては、起動時でなくても問題ないかもしれない

サーバーは、以下の処理を行う必要があります。

  • クライアントからレシートが送られてきたら、Apple に有効かどうかを問い合わせる
    • 正しく有効だという結果が返されなかった場合、クライアントに失敗かリトライかを返す
  • トランザクション開始、かつレコード取得時には FOR UPDATE を行う。
    • DBに、送られてきたレシートにあるトランザクションIDが含まれたレコードが既に存在してるかを調べる
    • 存在していなければ、そのトランザクションIDを含むレコードを追加する
    • そのレコードが付与済みかを調べる
      • 付与済みだったらここで処理終わり
    • 付与済みでなければ、対象となるレコードに値を加算して保存する
    • 付与済みフラグを立てて保存する
    • コミット

ありがちな間違い

消費型プロダクトのアプリ内課金で、やってしまいがちな間違いについて列挙してみました。 これらの方法を採らないように注意しましょう。

レシートをアプリ内に保存しない

前述の通り、アプリをユーザが終了させた場合でも、レシートをサーバに届ける必要があります。 そのため、レシートをアプリ内に保存せず送信するのは非常に危険です。 必ず保存してから送信を行いましょう。

また、起動時には送信していないレシート情報があるかどうかを確認し、そのようなレシートがあったら送信するフローへ遷移しましょう。

リトライを行わない

リトライは必須です。 クライアントがネットワークに繋がらず、サーバに届けられない可能性があるからです。 サーバにレシートを送信していない状態でリトライをやめてしまうと、そのレシートに対する付与が行なわれず、付与の機会を無くしてしまいます。

レシート確認の失敗をハンドリングしない

サーバ側では、レシートが有効かどうかを Apple に問い合わせます。 このときの失敗をハンドリングしてやらないと、不正なレシートであっても課金をしてしまう場合があります。

レシート確認の際に失敗するケースとしては、以下のことが考えられます。

  • Apple のサーバに繋がらない
  • Apple が返してきた結果が壊れていた(HTTP にすらなっていない)
  • Apple が返してきた結果が 200 系以外だった
  • Apple が返してきたデータが有効な JSON になっていない
  • Apple が返してきたデータが有効な JSON になっているけど、必要な項目が入っていない
  • Apple が返してきた JSON のエラーコードが 0 以外だった

これらを考慮し、ちゃんとハンドリングしましょう。

余談になりますが、Apple が返すエラーコードで気をつけなければならないのは、21005 と 21007 です。

21005 は「レシート検証のサーバが落ちている」なので、リトライすれば成功する可能性があります。 そのため、21005 が返された場合には、クライアントにはリトライを促す結果を返した方がいいでしょう。

21007 は「そのレシートはサンドボックスのものだ」という結果なので、サンドボックス側に問い合わせ直す処理が必要になります。

リクエストが来るたびに付与を行う

クライアント側で何度もリトライを行うので、同じレシートは重複する可能性があります。 あるいは、悪意のあるユーザがわざと何度もレシートを送信してくる可能性もあります。

リクエストが来るたびに付与を行うと、上記のような場合に多重に付与してしまうことになります。 必ずトランザクションIDごとに1回の課金を行うようにしましょう。

メッセージキューを使って DB を使わずに付与を行う

何らかのメッセージパッシングの仕組みを使って付与を行う、これ自体は特に問題ではありません。 しかし、「メッセージキューを使ってるから DB を使わずに付与を行なっても構わない」というのは、ほとんどの場合は間違いです。

メッセージキューというのは、ほとんどの実装でメッセージの重複が発生する可能性があります。 例えば Amazon SQS は、メッセージがワーカーに取得された後、ワーカーが ACK を返さず一定時間が経過したら、そのメッセージを復活させます。 そのため、ワーカーが正常に処理して ACK を返したとしても、それが既にタイムアウトになり、他のワーカーが同じメッセージを処理している可能性があります。

RabbitMQ の場合、メッセージの状態を管理する DB を持っているため、重複が発生する確率は少ないでしょう。 しかしゼロではありません。 XA トランザクションはできないため、メッセージの管理と、それに紐付いた状態の管理を同時に変更できないのです。 そのため重複が発生する可能性は依然としてあります。

ActiveMQ の場合、うまく設定すれば重複を発生しないようにできるでしょう。 これは内部でメッセージ状態を管理する DB を持っていて、かつ XA トランザクションの設定ができるからです。

どのメッセージキューを使うにせよ、DB を使わない実装というのは考えられません。 必ず状態を DB で管理するようにしましょう。

そもそも、課金アイテムの付与でメッセージキューを使う理由はありません。 メッセージキューは非同期に結果を返すときに利用するものです。 しかし、ユーザが課金を行うのは、すぐにそのアイテムが欲しいからです。 そのため、例え非同期に動き、UI 上で自由に行動できるようになっていたとしても、ユーザはそのまま待ち続けるでしょう。

付与済みフラグの書き換えと実際の数の増加がアトミックになっていない

トランザクションIDごとにレコードを作り、付与しているかどうかのフラグを管理しているとします。 また、それとは別のテーブル、あるいは別のDBで、ユーザごとにレコードを作り、実際に付与されたアイテムの数を管理しているとします。 このとき、以下の処理をアトミックに行う必要があります。

  1. 付与済みかどうかを確認する
  2. 付与済みでなければ実際に付与を行う
  3. 付与済みフラグを設定する

2つのテーブルが同じ DB にあるなら、単にトランザクションと行ロックを使って処理するだけです。 2つのテーブルが別の DB にあるなら、XA トランザクションなどを利用し、複数の DB に跨って整合性を担保しましょう。