この記事は「連載:技術基盤強化プロジェクト「RFS」の現在と未来」として書かれたものです。
こんにちは、メルカリの Transaction チームでバックエンドエンジニアをしている @oklahomer です。今回は、各種クライアントサイドとサーバサイドで実装している支払額計算のロジックを、新たなマイクロサービス実装へ移行する取り組みについて紹介します。
この連載では、既に Transaction チームから以下の 2 つの記事が公開されています。この記事の前提を把握いただくためにも、ぜひご一読ください。
関連記事:Understanding and Moderninzing a Legacy Codebase
関連記事:メルカリの取引ドメインにおけるモジュラーモノリス化の取り組み
これらの記事の中では、創業当時から運用されているモノリシックな PHP アプリケーションに取引ドメインのロジックが実装されていること、長年の開発・運用によってコードの一貫性やメンテナンス性が下がっていること、その対応の一環として現在モジュラーモノリス化が進んでいることが紹介されました。
読者の方々の中には、「 Go 言語でマイクロサービス化が進んでいたのでは?」と思った方もいるのではないでしょうか?私も今年の3月に入社するまで、そのような印象を持っていました。この記事では他の2つの記事とは少し毛色が変わり、 Transaction チームが担当する Checkout ドメインのプロジェクトとして実装が進んでいる新たなマイクロサービスの話を紹介します。9年を超える歴史を持つフリマアプリを支えてきたコードベースやアーキテクチャについて知識を深めつつ、より堅牢でより変化に強いアーキテクチャを模索する RFS(Robust Foundation for Speed) について身近に感じていただければ幸いです。
現実装の課題
まず支払額の計算について、現状の課題から整理してみます。
メルカリで商品を購入する際は、決済方法ごとの手数料や配送費用による負担額の加算、または各種ポイントやクーポンの利用による負担額の減算などを加味した上で支払額を決定しなくてはなりません。また、ユーザが購入ページ上でそれらの詳細を更新するたびに内訳を再計算する必要がありますが、この計算は現在クライアントサイドで行っています。簡略化した以下の図を参照ください。
ユーザが購入画面に遷移すると、まずクライアントが Web API を呼び出して購入対象の商品情報を取得します。この際、利用可能な支払い方法やポイント・クーポン情報も同時に取得しています。その後、支払方法の選択やポイント・クーポンの適用有無をユーザが更新するたびに内訳を計算し直しますが、その処理はクライアントサイドで実装されているため、 Web API の呼び出しは行われません。そして支払い方法やその内訳が確定した段階で購入確定用の API エンドポイントが呼び出され、入力値に対するバリデーションが行われた後、購入が確定します。
このフローの場合、ユーザによる更新に対してクライアントサイドで即座に計算結果を表示できる点は利点だと言えるでしょう。その反面、クライアントサイドでそれを実装・メンテナンスするには以下のような欠点があるのも事実です。
各クライアント(iOS/Android/Web)で別々に実装する必要がある
- 各クライアントで QA を行う必要がある
- ロジックの改修時は、iOS/Android は新規バージョンのアプリへ移行を促す必要がある
- 最後にサーバサイドでも計算を行う箇所があるため、計算に関する知識がクライアントサイドとサーバサイドに分散している
この状況で運用を続けると、今後起こり得るビジネス上の要求に対して柔軟に対応することが難しくなってしまいます。
サーバサイドへの統合
現実装が持つ上述の課題を解決するため、この計算ロジックをサーバサイドへと移管することになりました。サーバサイドで計算を行うフローについては、以下の図を参照ください。
先程の図にあるフローとの違いは、ページ遷移時に商品情報と支払い方法の詳細を取得した直後と、ユーザが支払いの内訳を更新する度に Web API が呼ばれている点です。前者の API 呼び出しでは、ユーザが利用可能なポイントやクーポン情報も考慮した支払い方法の内訳を提案し、後者の API 呼び出しでは、ユーザによる入力を反映して内訳を再計算します。購入確定時にサーバサイドでバリデーションを行っていた部分には変わりありません。
これにより、 iOS/Android/Web の 3 クライアントで必要だった実装を一箇所にまとめることができるだけでなく、もともとサーバサイドで持っていたロジックを含めて、その知識や実装は一箇所にまとめられます。実装が一箇所にまとまることでロジックの QA も一つにまとめられるので、メンテナンスコストも下がるでしょう。ロジックの変更もサーバサイドの実装で完結するため、ロジックの変更が生まれるたびに iOS/Android アプリをバージョンアップすることも不要です。
ですが、サーバサイドへの移管は利点ばかりではありません。現状のクライアントサイド実装について「ユーザの入力に対して即座に計算結果を表示できる点は利点」と述べましたが、今後は計算の度に Web API を呼び出すことになるため、そのレイテンシーが問題になることは想像がつきます。仮に Web API のレスポンスが十分に早くとも、そのレスポンスを待ってレンダリングを行う部分のクライアントサイド実装で処理に時間がかかり、ユーザ視点ではレイテンシーが悪化する可能性もゼロではありません。もしくは相対的にレイテンシーが大きい他 Web API のほうが接続環境の影響が出やすく、今回の移行によるユーザ影響は実は小さいかもしれません。この影響を定量的に計測するためにリリース時点から A/B テストを行いつつ、ビジネスサイドへの影響を見ながら改善できるように進行する予定です。
アーキテクチャの選択
では、サーバサイドへ処理を移行するとして、どのようなアーキテクチャが最適なのでしょうか?過去の記事では、現状のモノリシックなコード上で Transaction ドメインを複数サブコンポーネントへと分割し、 API を介して互いの呼び出しを行う実装に切り替えることが紹介されていました。また、これらのサブコンポーネントを最初からマイクロサービスとして実装した場合、現在のように RDBMS のトランザクションによって整合性を担保することができなくなるため、現時点では同じアプリケーション上でモジュラーモノリスとして実装されると紹介しています。
今回登場する支払額計算の機能はサーバサイド視点では新たな実装が多いですから、既存の実装とのしがらみは比較的少ないと言えます。ページ遷移時にクライアントサイドで取得した支払い手段や支払い可能額の詳細とともに、ユーザの入力値をサーバサイドへと渡し、それをもとにサーバサイドで計算を行うフローを採れば、呼び出されるエンドポイントでストレージを参照・更新して必要なデータを取得する必要がありません。これで、先述の整合性モデルに関する課題もなくなります。それであれば、最初からマイクロサービスとして gRPC サービスを提供するほうが再利用もしやすいですし、このサービス単体を対象としてサーバリソースの調整やモニタリングしやすい利点もあります。最終的な全体像は以下の図を参照ください。
重要となるのは赤字で書かれた部分です。先程の図で Web API 内で処理を行うこととなった以下の 3 つのステップで、 "Checkout Fee Calculator" と呼ばれる新たなマイクロサービスが呼び出され、支払額の計算を行っています。
- 利用可能なポイントやクーポン情報も考慮して支払いの内訳を提案する
- ユーザの入力を反映して内訳を再計算する
- 購入確定時に入力をバリデーションする
このマイクロサービスが、支払額の計算やそのバリデーションに関する知識を持ち、関連する機能を gRPC サービスとして提供する責務を持ちます。
マイクロサービス化の実践
メルカリでは既に数多くのマイクロサービスが稼働しているため、マイクロサービス化に際しては社内で溜まっている知見を活かしてスピーディに開発をすすめることができました。たとえば、トレーシングやロギングに必要な設定、そして認証関連のボイラープレートコードなどが含まれた Go アプリケーションのスケルトンが活用できます。
その他にも Kubernetes Configuration Management with CUE で紹介されているように、煩雑になりがちな k8s manifest の編集を CUE で簡潔に行うことができるのは大きな利点でした。多くのサービスで共通するデフォルトの設定が簡単に宣言できる他、必要に応じてカスタマイズできる余地も提供されているため、非常に少ない記述で必要な設定を完了することができます。
また、お客さま影響に基づく実践的なアラート方法にあるような方法で Terraform の datadog_monitor リソースを定義するといったこともできるため、 UI による操作で属人的なモニタリング運用になることなく、Pull Request を通じて適切にレビューしながら各種設定を進められます。社内のドキュメントを読み漁りつつ、適切な設定を実現するよう設定してみると非常に記述量が少なく、それでいて出力結果には期待通りの値が適用されていて、「自分はほとんど何もしてないのだけどな…」という感覚の連続だったのを覚えています。業務で k8s に関わるのが初めてだった私にとっては、これらの設定を見ること自体も大きな学びとなりました。
ロジックの回帰テスト
ここまで紹介したとおり、サーバサイド観点では新たな機能の実装としてマイクロサービスの開発が進み、単体テストの追加も順調に進みました。テストのカバレッジは現在 95% を維持できています。ですが、支払額の計算はメルカリというフリマサービスとしては非常に重要な機能です。そのため結合テストも追加し、また QA チームによる gRPC サービス全体の自動テスト実装も進行中です。
単体テスト、結合テスト、自動テストまで用意されていれば、サービスローンチ後のテスト体制としてはかなりの網羅性かと思いますが、忘れてはならないのは、この機能自体は創業以来長い期間に渡って運用されてきたものだということです。実装の過程では幾度となく既存実装を確認してきましたが、長年運用されてきたロジックを移行するのは不安を伴います。ローンチ時点で間違った仕様を基に実装とテストを進めている可能性も捨てきれません。現状の実装と挙動が変わってしまっていないことを確認するため、新旧実装で計算結果が等しくなることを保証する回帰テストを実施することが重要です。
そこで、過去のログを元に購入時点のデータを再現し、それを Checkout Fee Calculator に引き渡して同じ結果になることを確認する Cron job を実装しました。
上記の図のように、定期的に BigQuery から直近のログを取得し、それに対して新規実装の計算ロジックを適用しています。具体的には、商品の価格や各種手数料を基に合計金額を計算して、それがログにある合計金額と一致すること、そして、それに対して適用された各種支払い方法とその金額、そしてポイントやクーポン利用分の合計が一致することを確認します。不一致があれば新旧のロジックで差分が生まれていますから、それはログを残して調査対象とします。これによって、新旧の実装で挙動が同じであることを保証した上で、その新たな実装に対して十分なテストカバレッジと QA 体制が整えられていることが確信できました。
まとめ
今回は前回までの取引ドメインの記事のようなモノリシックなコードベースと向き合う話から少し変わり、クライアントとサーバをまたいで長年運用されてきたアーキテクチャの課題を解決する取り組みについて紹介しました。日本最大のフリマサービスのコアと言える取引ドメインについて知見を深め、それを設計・実装し直しつつ、新たに Go 実装のマイクロサービスとして切り出す経験ができるなど、実はいいとこ取りなのが Transaction チームです。今回の知見をもとに、他にも新しくマイクロサービスを実装し、運用する機会も増えていく予定です。
RFSという全社横断的なプロジェクトを成し遂げるため、取引ドメインのアーキテクチャを、より堅牢でより柔軟なものに作り変える道はまだまだ半ばです。興味がある方は、ぜひ以下のリンクをご覧いただき、私達の仲間になってください。
Software Engineer, Backend Foundation (PHP/MySQL) – Mercari