こんにちは、株式会社スマートバンクでサーバーサイドエンジニアをしている mitani です。
この記事は2022/10/21~22に開催されたKaigi on Railsでお話した内容を記事にしたものです。当日の発表はYouTubeからご覧いただくことができますが、まだご覧になっていない方で動画よりも文字で見たいという方向けに、特にお伝えしたかったポイントに絞りながらご紹介したいと思います。
外部サービスと状態管理
サービスを作る際、状態管理は至るところで実装する必要のある機能です。例えばログイン状態、会員の登録状態等のどのようなサービスでも共通的に実装するものから、商品の購入状態、決裁フローの現在状態などサービスによって異なるものまで様々な状態管理があります。状態管理の設計方法はその機能の要件によって大きく変わってくるものではありますが、正確に状態を記録しつつ、いかにシンプルな実装を維持するかは長くサービスを運用していく上で重要なテーマの一つだと思います。
状態管理の難易度は機能要件によって大きく変わりますが、自サービス内で完結する状態と比較して、外部サービスを利用する場合の状態管理は難易度があがります。外部サービスを利用する場合には、自サービスと外部サービスの2箇所で状態を同期的に管理する必要がある点、外部サービス側の管理方法は変更できないため上手く整合性がつく形で状態管理方法を設計する必要がある点の2点が難易度を高めるポイントだと考えています。
B/43ではサービスの性質上、多くの外部サービスを利用しています。その実装経験から得られた学びについてこの記事では紹介していきます。
B/43で使われている外部サービス
B/43はプリペイドカードというサービスの性質上、決済をしていただく前にカードへの入金が必要となります。この入金手段は、ECサイトにおける決済手段と同じように、多様な方法を提供して誰でも手軽に入金できるようにすることが重要です。B/43と同じようなプリペイドカードを提供しているサービスにおいても、コンビニ / クレカ / 銀行口座連携 / あとばらいなどの入金方法は一般的に備わっている機能になっており、入金の不便さでサービス利用を諦めたり離脱されることがないようにする必要があります。そのため、B/43ではリリース時にはコンビニ・ペイジー・銀行振込の3種類にしか対応していませんでしたが、リリース以降に少しずつ入金方法を拡充して現在では7種類の入金方法を提供しています。
新しい入金方法に対応するためには、その入金機能を提供している外部サービスを利用します。例えばコンビニ入金に対応しているA社の外部サービスを利用したり、あとばらいに対応しているB社の外部サービスを利用したりと、複数社と契約して機能を提供しています。
外部サービスによってAPIの仕様や入金のフローなどに違いがあるため、機能開発する際には入金手段毎にテーブル設計やModel、Controllerを実装する必要があります。状態管理の方法も外部サービスの仕様によって大きく異なるため、仕様書を確認しながら個別に設計していく必要があります。とはいえ、それぞれ全く違う設計を考える必要はなく、大枠が同じで細部が異なるようなケースも多く、大別すると以下の3パターンに区別することができます。
以降では、この3つのパターンについて深堀りしていきます。
リアルタイム型
リアルタイム型は入金の一連の操作がアプリ内だけで完結するパターンです。以下の画像のように、金額を入力して決定を押すと即座に入金が完了します。
リクエストフローは以下のような形で、ユーザーのアプリ操作のリクエストを受けて、サーバー内部から外部サービスを呼び出します。外部サービスに入金用のRESTful APIが提供されていて、入金APIを呼び出して成功レスポンスが返ってきたら残高を増やして入金処理を完了します。
このようなパターンで入金機能を実装する際には、①適切な粒度に処理を分けてDBトランザクションを分離すること、②発生するエラーの種類を整理して検知とリカバリ手段を考えることの2点を考慮することでより安全な実装を行うことができます。
①適切な粒度に処理を分けてDBトランザクションを分離する
外部サービスへのリクエストはDBのトランザクションの外で行うことが望ましいです。トランザクションの外で行うことにより、外部サービスのリクエスト中にテーブルのロックを専有してしまったり、失敗した際のロールバックですべての情報が消えてしまう危険性を排除できます。リクエストフローをベースに考えると、以下のようにトランザクションを分離することで安全に処理を行うことができます。
トランザクションの1つ目が外部サービスを呼び出す前の処理です。ここでは入金額だったりユーザー状態など、入金に必要なバリデーションを実施した上でDBにリクエスト情報を保存します。リクエスト情報を保存しておくことで、後続の処理に失敗した場合でもリクエスト内容を追跡することができます。
トランザクションの2つ目は外部サービスのリクエスト後の処理です。ここでレスポンスに応じて入金を行ったり、失敗情報を保存します。
最後の3つ目は、入金とは直接関係のないPushやメールなどの通知処理です。2つ目のトランザクションと分けることで、仮に失敗したとしても入金処理自体は成功状態を維持することができます。DBのトランザクションは失敗時にロールバックしたい起点を考えて、非重要な処理を分離して実装することでより安全にデータを扱うことができます。なお、ここの処理はキューを使っていたり更に外部サービスを呼び出したりとデータベースのトランザクションでないこともあるため、括弧書きとなっています。
②発生するエラーの種類を整理して検知とリカバリ手段を考える
入金の処理中に発生するエラーは、通信経路と合わせて検討すると整理がしやすいです。
エラーの①~③はユーザーのリクエストから外部サービスが処理を行う”行きの経路”で発生するエラーです。バリデーションエラーから外部サービス側でのエラーまで、どのポイントでエラーが発生しても自社側に失敗と記録すれば外部サービス側とのデータ不整合は発生しません。
一方でエラーポイント④~⑤は外部サービスからユーザーへの結果通知までの”帰りの経路”で発生するエラーです。ここでエラーが発生するとデータ不整合の恐れがあります。例えば外部サービス側で処理が成功していたとしても、時間がかかりすぎて通信タイムアウト状態になった場合、自社側では失敗と記録しますが外部サービス側では成功状態となっています。他にも成功レスポンスを受け取った後にバリデーションエラーや後続処理エラーが発生して成功を記録できなかった場合にもデータ不整合状態が発生してしまいます。
このような状態を避けるため、成功レスポンスを受け取った後は失敗の可能性がある処理を避けたり、成功の記録と後続の処理を分離して実行できるようにして影響範囲を狭める必要があります。また、通信タイムアウトのような予期せぬエラーが発生した際にはエラートラッキングツールで通知するなどの監視の仕組みを整えることも大切です。
状態遷移をまとめると以下のような形です。状態不整合エラーが発生した場合には監視ツールで検知し、手動なり自動なりでリカバリを行う必要があります。想定しているエラーが発生した場合には失敗状態を記録し、1からユーザーにやり直してもらうようなフローを設計します。
事前予約型
事前予約型は入金方法や入金額を予め指定して予約情報を作った後、コンビニやATMなどのリアル店舗や外部サイトを経由して入金するパターンです。
リクエストフローは支払いの準備段階と、実際の支払い段階の2ステップに分けて整理できます。支払い準備段階では、リアルタイム型と同じようにユーザーのアプリ操作を受けて外部サービスを呼び出して予約情報を作成します。支払い段階ではコンビニのレジで予約情報を表示したり、ATMで入力したりして支払います。支払いが完了すると自社サーバー側で成功結果を受け取ります。
このパターンの入金機能を実装する際には、①アプリ外でのイベントを意識して設計することと、②発生するエラーの種類を整理して検知とリカバリ手段を考えることの2点が重要です。リアルタイム型と同じようにDBトランザクション管理も重要ですが、考えることは同じとなるためここでは割愛します。
①アプリ外でのイベントを意識して設計すること
事前予約型では、支払準備が完了してから支払いが行われるまでにタイムラグが存在します。例えば自宅で予約情報を作成した後、近所のコンビニに歩いて向かって支払いをする場合、数分~数十分のタイムラグがありえます。
このタイムラグ中に決済や出金などの別の操作をしたり、あるいは別の入金方法で入金したりすると、ユーザーの残高状態が変わってしまいます。例えば残高上限XX円のような制約が存在する場合、入金予約時には上限以内であってもタイムラグ中に他の入金方法での入金や決済の返金などがあった場合、残高上限を超えてしまうようなケースが考えられます。そのため、他の入金方法を選んだときには、予約中の入金額も加味して上限バリデーションを実装するなどの考慮が必要になります。このようにユーザーがタイムラグ中に他の操作を行うことを前提に、その他の機能を実装する必要があります。
もう1点アプリ外でのイベントとして、外部サービス側からの結果通知の方法について考慮する必要があります。利用する外部サービスの仕様次第にはなりますが、コンビニのレジのようにユーザーが支払うタイミングでは自社サーバーへリクエストが送られず、支払いが完了したときのみ成功通知を送ってくるような外部サービスを利用する場合には注意が必要です。この場合、自社サービス側では成功通知を拒否することができず、失敗を返却するとリトライ処理にて成功情報が再送されてしまいます。
例えばB/43には残高上限100万円という制限があります。多くの入金方法では、入金する前に入金額をユーザーに入力してもらい、事前にバリデーションを行うことで上限を超えないように担保できます。しかし、銀行振込による入金時にはアプリ内で入金額を入力することなく、他銀行から直接振り込み操作をすることで入金ができます。この場合、入金前のバリデーションを設定することができないため、コンビニ入金などの予約段階でのバリデーションは通っても、入金の成功通知が来たタイミングで上限を超えてしまうケースがあります。他の入金だけでなく、決済の取り消しによる返金や招待報酬の付与など、入金以外の残高変動によっても発生する可能性があります。
このような場合でも入金の成功通知は拒否できないため、そのまま入金処理を行って超えてしまった金額のみを一旦退避しておき、別処理で返金を行うなど結果整合性を求めるような工夫が必要です。
②発生するエラーの種類を整理して検知とリカバリ手段を考える
事前予約型で発生する可能性のあるエラーポイントは以下の通りです。支払いの準備段階で考えることはリアルタイム型と同じため割愛します。
エラーポイント①~②は、支払準備から支払いまでのタイムラグ中に発生するものです。例えばユーザーが離脱したり、時間がかかりすぎて有効期限切れになるなどです。この場合は再度1からやり直してもらうことで解消できます。
エラーポイント③~⑤は成功通知を受け取る際のエラーです。図のように成功通知を送ってくる外部サービスの多くは失敗時のリトライ処理を備えているところが多いです。そのため、③のネットワークエラーや④の予期せぬエラー発生時にはリトライにて解消することができます。また、⑤の処理に時間がかかりすぎてタイムアウトになったりした場合、リトライ処理をそのまま受け付けると成功処理が2重実行されてしまうため、同一のリクエストIDが成功の場合には処理をせずに成功レスポンスだけ返すなどの冪等性を保つ仕組みが必要になります。
状態遷移をまとめると以下のような形です。支払い準備段階でのエラーはすべて失敗状態と記録してユーザーに1からやり直してもらえれば問題ありません。支払い以降のエラーはできるだけリトライ処理にて解消されるように冪等性が担保された作りにしておくことで運用の手間を省くことができます。
完全非同期型
最後は完全非同期型です。完全非同期型は銀行振込など外部サイトの操作のみで完結するパターンです。
リクエストフローは事前予約型の後半部分を抜き出したものと同じになります。
このパターンの入金機能を実装する際には、外部サービスからのリクエスト要件を満たした実装を考えることが重要です。DBトランザクションやエラー発生時の種類や考慮は事前予約型と同じことを考えればよいため割愛します。
外部サービスからのリクエスト要件を満たした実装
完全非同期型ではアプリを使って入金額を決めたりといった事前操作が一切なく、外部サイトやサービス側での操作の結果がリクエストとして送られてきます。そのため、入金前のバリデーションを行うことができません。リクエストを受け取った後も常に成功を返す必要があるため、リクエスト受付時にバリデーションを行うこともできません。
その仕様のもと、リクエストを受け取った後に残高上限を超えたり、機能制限中のユーザーへ入金が行われたりと意図しない状態になったときの対応方法をあらかじめ決めておき、マニュアル化して対応できるように準備しておくことが重要です。
リコンサイル
事前予約型、完全非同期型のように結果通知が非同期に行われる方式に共通するリコンサイルという仕組みを紹介します。
事前予約型、完全非同期型のように結果通知が行われるパターンでは、通知が送られるまでは入金されたことが自社DB側ではわかりません。そのため、通信ネットワークの異常でそもそも通知が送られなかったり、検知漏れにより失敗に気づかなかったケースなどで、気づかないうちにデータ不整合が起きていることもあります。多くの場合、リトライ処理で解消することもありますが、長時間のメンテナンス中だったりでリトライ処理の最大回数を超えてしまったりすると、リトライ処理でのリカバリもできなくなってしまいます。
そのような場合でも自動的にデータ不整合を検知し、解消する仕組みとしてリコンサイルというものがあります。リコンサイルは複数のシステム間のデータ整合性を確認する処理で、例えば外部サービス側に過去の履歴を取得するAPIがある場合、定期的に履歴を取得し、自社DB側の結果と付き合わせることでロストした分を発見し、そのデータを取り込むことでデータ不整合を解消することができます。
B/43でもいくつかの入金方法でデータ不整合の可能性があるものに対してリコンサイルの処理を実装しています。
まとめ
以上がB/43で導入してきた入金手段の実装方法をパターン化した3つについての紹介でした。入金手段によって細部の作りは異なるものの、大枠としての設計の方向性や注意点の参考にしていただければと思います。
Kaigi on Railsの登壇ではもう少し詳しく説明している部分もあるので、ぜひそちらの動画も御覧いただければと思います。また、発表資料と当日の様子が少しわかるTogetterのまとめもありますので、合わせてご覧ください。