この記事は every Tech Blog Advent Calendar 2024(夏) 28日目の記事です。
はじめに
DelishKitchenとヘルシカでバックエンドエンジニアをしているyoshikenです。
今回は新規アプリ開発の際に、Android/iOSアプリの課金処理(subscription)について awa/go-iapを使用して大変便利だったので、その紹介をしたいと思います。
Android/iOSの課金処理のフローについてはサイバーエージェントさんのテックブログで詳しく説明されているので、そちらを参照してください。(この記事を書いてくださった方がそのまま awa/go-iapの原型を作成されたかと
自動購読課金について【Android編】 | サイバーエージェント 公式エンジニアブログ 自動購読課金について【iOS編】 | サイバーエージェント 公式エンジニアブログ
awa/go-iapとは
awa/go-iapは、In-App Purchase (IAP) のサポートを提供するライブラリで、実際にawaでも使用されているので一定の信頼性があり、更新頻度も高く、以前自分がissueを立てた際は次の日には修正されていました。
offerDiscountType is missing in JWSTransactionDecodedPayload · Issue #266 · awa/go-iap
また、今回はAndroid/iOSについての説明をしますが、他にもAmazon AppStoreやHuawei HMSといった決済方法に対応しています。
Android編
サーバー側でやることは
- 署名の検証
- receipt問い合わせ
- acknowledgeの送信
となります
署名の検証
サイバーエージェントさんのテックブログにも書かれていますが、送られてきたreceiptデータの検証に署名を利用します。
awa/go-iapを使用しない場合の実装はサイバーエージェントさんにお任せするとして…
awa/go-iapのVerifySignature関数を使用することで、簡単に署名の検証ができます。
https://pkg.go.dev/github.com/awa/go-iap@v1.32.0/playstore#VerifySignature
以下に実装例を出します
import ( "encoding/base64" "github.com/awa/go-iap/playstore" "yourapp/types" ) // Verify receiptが改竄されていないかを検証 // receiptData: レシートデータ、Androidから送られてくるのはbase64化されている // signature: レシートデータの署名 func Verify(receiptData, signature string) (bool, error) { // base64化されているので戻す b, err := base64.StdEncoding.DecodeString(receiptData) if err != nil { return false, err } // レシートデータ自体が正しいか署名を検証 return playstore.VerifySignature(types.GooglePlayPublicKey, b, signature) }
Androidから送られてくるreceiptデータがbase64化されているのでデコード処理が必要という意味で関数化しましたが、実際の処理は一行で終わるので無理に関数化しなくても良いかもしれません。
また、検証にはGooglePlayのPublicKeyが必要ですが、これはGooglePlayConsoleから取得できます。
subscription問い合わせ
receipt自体の検証が完了したら、購入情報を取得するために再度GooglePlayに問い合わせを行います。
こちらの問い合わせもVerifySubscriptionV2関数を使用することで簡単に実装できます。
https://pkg.go.dev/github.com/awa/go-iap@v1.32.0/playstore#Client.VerifySubscriptionV2
import ( "context" "github.com/awa/go-iap/playstore" "google.golang.org/api/androidpublisher/v3" "github.com/avast/retry-go/v4" "yourapp/types" ) type PurchaseData struct { AutoRenewing bool `json:"autoRenewing"` OrderID string `json:"orderId"` PackageName string `json:"packageName"` ProductID string `json:"productId"` PurchaseTime int64 `json:"purchaseTime"` PurchaseState int64 `json:"purchaseState"` DeveloperPayload string `json:"developerPayload"` PurchaseToken string `json:"purchaseToken"` } func Verifyreceipt(ctx context.Context, receiptData, signature string) (*androidpublisher.SubscriptionPurchaseV2, error) { // 先程のVerify関数で返り値にdecode済みのreceiptデータを追加しても良いかもしれません b, err := base64.StdEncoding.DecodeString(receiptData) if err != nil { return nil, err } var purchaseData PurchaseData err = json.Unmarshal(b, &purchaseData) if err != nil { return nil, err } request, err := playstore.New([]byte(types.GooglePlayServiceAccount)) if err != nil { return nil, err } var res *androidpublisher.SubscriptionPurchaseV2 // ちょくちょく5xxを返すことがあるのでリトライ処理を入れています err = retry.Do( func() error { res = &androidpublisher.SubscriptionPurchaseV2{} res, err = request.VerifySubscriptionV2(ctx, purchaseData.PackageName, purchaseData.PurchaseToken) if err != nil { return err } return nil }, retry.RetryIf(func(err error) bool { // 任意のエラーハンドリング if strings.Contains(err.Error(), "Service Unavailable") { return true } }), ) if err != nil { return nil, err } return res, nil }
acknowledgeの送信
署名の検証やらdb保存やら諸々が完了してレスポンスを返却するまえにGooglePlayに対してacknowledgeを送信して購入を承認する必要があります。
逆をいうと、acknowledgeを送信しないと購入が完了しないので、エラー時はacknowledgeさえ送信しないことに注意を払えばなんとかなります。
acknowledgeですが、こちらも関数が用意されており、 AcknowledgeSubscription
を使用します。
import ( "context" "github.com/awa/go-iap/playstore" "google.golang.org/api/androidpublisher/v3" "yourapp/types" ) type PurchaseData struct { AutoRenewing bool `json:"autoRenewing"` OrderID string `json:"orderId"` PackageName string `json:"packageName"` ProductID string `json:"productId"` PurchaseTime int64 `json:"purchaseTime"` PurchaseState int64 `json:"purchaseState"` DeveloperPayload string `json:"developerPayload"` PurchaseToken string `json:"purchaseToken"` } func (u *UseCaseImpl) AckSub(ctx context.Context, purchaseReceipt PurchaseData) error { cl, err := playstore.New([]byte(types.GooglePlayServiceAccount)) if err != nil { return err } return cl.AcknowledgeSubscription(ctx, purchaseReceipt.PackageName, purchaseReceipt.ProductID, purchaseReceipt.PurchaseToken, &androidpublisher.SubscriptionPurchasesAcknowledgeRequest{}) }
acknowledgeには冪等性があるので何度再送しても大丈夫です。
iOS編
iOSもAndroidと似たようなものですが、serverから見るとAndroidより簡単です。
実際にApplestoreに問い合わせが必要な部分はreceiptの検証のみです。
以下が実装例です
import ( "context" "github.com/awa/go-iap/appstore" "github.com/avast/retry-go/v4" ) func VerifyReceipt(ctx context.Context, receiptData, password string) (*appstore.IAPResponse, error) { request := appstore.IAPRequest{ ReceiptData: receiptData, Password: password, } var res *appstore.IAPResponse err := retry.Do( func() error { res = &appstore.IAPResponse{} err := appstore.IAPClient.Verify(ctx, request, res) if err != nil { return err } err = appstore.HandleError(res.Status) if err != nil { return err } return nil }, retry.RetryIf(func(err error) bool { // 任意のエラーハンドリング if strings.Contains(err.Error(), "Service Unavailable") { return true } }), ) if err != nil { return res, err } return res, nil }
GooglePlayと同じく時折5xxを返すのでリトライ処理を入れています。
iOSはクライアント購入時にack相当はしているのでサーバー側でのacknowledgeは不要です。
おわりに
以上のように、awa/go-iapを使用することで、Android/iOSの課金の購入処理を簡単に実装することができます
今回例に上げたのはsubscriptionのみですが、単発購入の場合も似たような実装になるかと思います。
ただ、subscriptionの場合は購入後の処理のほうが複雑で、定期的な購入情報を問い合わせるか、通知(App Store Server Notifications/RTDN)を受けるかしたあとにそれぞれのプロダクトに合わせた処理をしていきます。(今回は購入処理だけです
自分が実装するさいにawa/go-iapの参考になるドキュメントが少なかったため、今回の記事を書きました。誰かのお役に立てれば幸いです。