本記事はBASEアドベントカレンダー2024の13日目の記事です。
はじめに
Pay IDのテックリードの大木(@roothybrid7)です。
9/30に、PAY株式会社(以下PAY社)が保有していた「Pay ID」のシステム移管を行い、リニューアルしました。
カートやショップ管理画面、Pay IDアプリとは別の独立したシステムとなっており、アプリケーションの技術選定やアプリケーションアーキテクチャの設計に携わりましたので、その辺の話をしたいと思います。
移管の背景
2021年に購入者向けショッピングサービスとして「Pay ID」の提供を開始しましたが、サービスの運用は引き継いだ一方で、決済機能は、PAY社がシステムの開発・運用管理を行うという、長らく分断された状態にありました。
また、クレジットカード会員情報を扱っていることから、移管するにしても、どのデータを移管すればいいか精査しないといけないという課題がありました。
詳細は省きますが、クレジットカードと会員の持つログイン・住所情報を分離することにより、その課題を解決できそうだということと、人員確保できそうという目処が立ったため、移管を決行することとなりました。
技術選定について
システム移管にあたり、元の技術スタックをそのまま踏襲する必要はなかったため、改めてそれを考えることになりました。
まず、プログラミング言語としては、go を採用することにしました。採用理由としては以下の通りです。
- BASE BANKに運用ノウハウあり
- 静的型付けの安心感と文法のシンプルさ
- gofmt等開発者体験を向上させるツールが揃っている
- View周りをフロントエンドに寄せ、APIサーバとしてだけ実装
- 移管のフロントエンドに関する内容は、システムリニューアルでNext.jsのApp Router/Server Actionを使って便利だと思ったところ - BASEプロダクトチームブログ をご覧ください。
goを使ったアプリケーション開発は初めてではないですが、触っていたのはgo1.7 の頃であり、module管理がサポートされたり、ジェネリクスがサポートされたり、goの進化を感じました。
次に、システムに関して、機能としては大きくアカウント管理、クレジットカード情報を扱うためにPAY.JP APIを利用したプロキシとしての機能があります。さらに、Pay IDを利用するクライアントの種類が多いため、認証方式や利用するデータの違いで、それぞれのAPIエンドポイントを実装しなければなりません。
また、トラフィック量にも違いがあり、例えば、カートでは決済する際にアカウントやカード情報を毎回参照するので多いとか、それ以外だと、ログインする時やアカウント編集する時にしかアクセスしないとかあるため、それらを考慮してAPIサーバをそれぞれ用意しました。
APIサーバを分けたとしても、認証方式が異なるだけで、結局全く同じ処理フローを辿ることもあります。また、購入者のための処理と社内管理業務と異なる処理であっても、アカウントの設定変更や退会処理など、同じ機能を利用するといったこともあります。
そのため、コード再利用可能なように、処理単位を適切に分けたり、レイヤー化したりということを意識しました。
これを実現するための技術スタックは以下の通りです。
- Go
- AWS(ECS/Fargate, ALB, CloudFront, WAF, RDS, ElastiCache, SES)
- oapi-codegen(OpenAPI)
- gqlgen(GraphQL)
- SQLBoiler(ORM)
- Uber Fx(DI)
- chi(Router)
- oso(RBAC, ACL)
今回は、PoC(Proof of Value、価値実証)やPoV(Proof of Concept、概念実証)のような実証実験フェーズではなくシステム移管なため、すでに運用されているシステムを、互換性をできる限り保ちつつ取捨選択しながら実装する必要があります。
様々な議論があるとは思いますが、こういったある程度の規模のアプリケーションのコードベースを整理した状態とするには、レイヤ化されたアーキテクチャの導入は必要かなと個人的には思います。
今回、アプリケーションアーキテクチャとしては、クリーンアーキテクチャをベースに採用しています。
クリーンアーキテクチャ
クリーンアーキテクチャは、Clean Coder Blog - The Clean Architecture や 一般的な Web アプリケーションアーキテクチャ - クリーン アーキテクチャ を参考にしていただくとして、主に、ビジネスロジックやモデルを中心に配置し、特定のフレームワークやデータベース、外部APIに依存させないようにインタフェースを定義し、それを通じて利用できるようにします。フレームワークを利用したインタフェースの実装は、以下のInterface Adaptersと呼ばれる層に配置します。それにより単体テストのモックを実装することもできますし、ORM(Object-Relational Mapping)を別のものに差し替えることも可能となります。
さらに、重要なのが図の右下で、Interface AdaptersとUse Casesレイヤー間の通信制御フローを示していますが、依存性逆転の原則により、抽象は詳細に依存してはならない。詳細が抽象に依存すべきであるというところを表現しています。
今回は、APIサーバ複数実装する必要があるが、アカウント登録等の多くのユースケースのフローに差異はなく同じアプリケーションロジックが利用できます。そのため、図にあるところの ControllerやPresenterの実装は、APIサーバ毎に必要だが、Use Caseはひとつでよく、それはこの図のルールに準拠すれば実現できます。
依存関係を水平方向のレイヤー図で表すと以下のようになります。
以下のInterfaceの例だと、InputPortに適応するユースケース処理を実現するアプリケーションロジックを実装し、OutputPortに適応するPresenterを実装します。OutputPortの実装詳細は、APIサーバ毎に別の実装になります。
type InputPort[I any, O any] interface { Exec(ctx context.Context, input I, outputPort O) error } type OutputPort[T any] interface { Write(ctx context.Context, value T) error }
また、OutputPortは値を戻さず、ヘキサゴナルアーキテクチャが推奨するポートアダプター方式を利用しています。ポートで write()
が実行されたら、実装であるアダプターでその出力を何度でも受け取ることができます。エラーが発生しなければ、outputPortの出力データの受け取り手であるPresenterが、データを合成変換し、oapi-codegen等利用しているフレームワークに合う形のデータ構造に変換します。
func Interactor(ctx context.Context, input Input, outputPort OutputPort[Result]) error { account := AccountFromContext(ctx) if account == nil { return outputPort.Write(ctx, Result{Notice: "signupRequired"} } outputPort.Write(ctx, Result{Account: convertPublicFields(account)}) token, err := IssueToken(ctx, input) if err != nil { return err } return outputPort.Write(ctx, Result{Token: token}) }
ちなみに、これは購入者向けのAPIの話で、社内業務での利用を目的としたadmin-apiは、ユースケースが異なるのと、GraphQLを採用しており、そのフレームワークを最大限に活用しているため、これには準拠していません。リリースによる障害が発生したとしてもビジネス的な損失はあまりないことと、柔軟に要件に対応したり、短時間で実装しリリースしたいという目的のためです。
ただ、Entities層にあるコアロジックは、特定のフレームワークには依存しておらず、再利用可能なように定義しているため、例えば、アカウント情報の更新や退会処理などは共有することができます。
Uber Fx
依存関係逆転の原則を実現するために、DIコンテナのUber Fxを利用しました。
ドキュメントがわかりやすく、依存関係が不足している場合、ログに出力してくれるためかなり使いやすかったです。
実装をインタフェースとして提供する際、As()
を利用して明示的にインタフェースを指定する必要があります。主に関数型の実装を追加した時、インタフェースとして提供できてないことがよくありました。
fx.Annotate(
PasswordUpdaterFunc,
fx.As(new(PasswordUpdater)),
)
また、As()
の逆の役割をする From()
というものもあり、これはInterfaceに実装を注入する際に利用します。とある実装で、database/sqlのDBと単体テストで利用するgo-txdbを両方受けとり可能にするため、共通のインタフェースを定義した際に利用することができました。
type Executor interface { Exec(query string, args ...interface{}) (sql.Result, error) //[...snip...] } fx.Annotate( func(executor Executor) AFunc { return func(ctx context.Context, id ID, opts ...Option) error { //[...snip...] }, }, fx.ParamTags(`name:"ro"`), fx.From(new(*sql.DB)), fx.As(new(A)), )
変わったところで言うと、ドメインイベントとイベントハンドラーの登録でも利用しました。
var EventModule = fx.Module("Event", fx.Provide( fx.Annotate( func() []event.Event { events := make([]event.Event, 0, len(event.EventAllTypes)) for _, e := range event.EventAllTypes { events = append(events, e) } return events }, fx.ResultTags(`group:"listenableEvent,flatten"`), ), fx.Annotate( eventhandler.MakeActivityRecorder, fx.ResultTags(`name:"recordActivity"`), fx.As(new(event.Handler)), ), fx.Annotate( eventhandler.NewVerificationMailHandler, fx.ResultTags(`name:"verificationMail"`), fx.As(new(event.Handler)), ), ), fx.Decorate( fx.Annotate( func(events []event.Event) []event.Event { return event.FilterEvents(events, func(event event.Event) bool { switch event.Name() { case "account.created", "account.recovery": return true default: return false } }) }, fx.ParamTags(`group:"listenableEvent"`), fx.ResultTags(`group:"verificationMail"`), ), ), fx.Invoke( asSubscribeHandler("recordActivity", "listenableEvent"), asSubscribeHandler("verificationMail", "verificationMail"), ), )
OpenAPI
APIは複数あるという話をしましたが、実際どのような項目をAPIでやりとりしているかは、移管元のアプリケーションと利用側のアプリケーションのコードを全て確認し、OpenAPIのドキュメントとして記載していきました。また、利用側で全く使われていない項目は、この際まとめて削除していくことも忘れずにしていきました。
さらに、Redocly CLI を利用し、社内ネットワークにドキュメントを公開し、いつでも閲覧できるようにもしました。
Goサーバのコードは、このOpenAPIのスキーマをもとに、oapi-codegen を使って生成しています。
ORM
ORMは、DB Schemaに併せてコード生成できるSQLBoilerを利用しています。
コード生成されているため、エディタでコード補完も効きますし、Eager Loadingやクエリビルダーのような機能もあります。
一点利用していて難しかったのは、LEFT JOINした結果をbindするstructの定義ですね。
タグでテーブル名を指定する必要がありましたし、コード生成されたstructを再利用しようとするとうまく利用できず、自前で定義しなければならなかったり、nullになりうる方は、ポインタで指定しなければいけませんでした。
type JoinedStruct struct { Account Account `boil:"accounts,bind"` Extra *ExtraAttr `boil:"extra_attributes,bind"` }
ただ、前述の通り、クリーンアーキテクチャを採用しており、アプリケーションロジックではSQLBoilerが生成したモデルを直接使っていないため、データベースの操作をする場合、Entitiesのモデル等と相互変換しています。自動で更新すべきカラムを推測できないため、モデル上で何らかの変更を行った際、ドメインイベントを発行し、どのカラムを更新すべきか明示的にWhitelistで指定しています。
ちなみに、全ての更新処理をEntitiesのモデルを通して行っているわけではなく、単に外部APIから取得したレスポンスをDBに同期する場合は、アプリケーションロジックとは関係なく、Interface Adapter層内部での処理のため直接importする形にしています。
同様に、クライアントが要求するデータを単に返すだけの場合も、Entitiesのモデルに変換する意味はないため、ユースケースに特化したデータ構造に変換して返しています。
※現在、SQLBoilerはメンテナンスモードで、今後新機能が追加されることはないため、別のものに移行した方が良さそうです。クリーンアーキテクチャだと、Interface Adapter層を書き直す必要はありますが、他の層は特に変更することなく移行できます。
ドメインイベントを利用した実装
メインロジックの処理フローが正常に完了したとして、メール送信や行動履歴等、副作用的な処理が必要になることがあります。
そのために利用したものが、ドメインイベントです。
この仕組みを使えば、トランザクションをコミット後、ドメインイベントを送信することで、イベントを購読していた各イベントハンドラーがそれぞれの副作用を実行することが可能です。
例えば、アカウント登録が成功した後、認証メールを送信したり、行動履歴を記録したりといったことができます。
その他
実装は、なるべくAPIの互換性を保つように努力しましたが、全てのデータやロジックを移管できないというところと、goでは通常レスポンスにnullを含めることができないということで、クライアント側の実装も変更せざるを得なかったため、追加でその実装も併せて行う必要がありました。他チームとの実装方針の調整等も併せて1ヶ月弱は追加でかかってしまい、リリースを延期せざるを得ない状況ともなりました。
また、開発しやすいように本体とは別のアプリケーションだったり、ライブラリを作成したりもしました。
- OAuth認可フローを試せるミニWebアプリ
- Pay IDアプリのアカウント編集をWebアプリに統合するための認証クライアントライブラリ(Kotlin Multiplatform project)
おわりに
プロジェクトで使われている技術スタックやアプリケーションアーキテクチャについて、簡単に一部ご紹介させていただきました。
システム移管は、互換性をできるだけ壊さないようにする必要があり、新規サービス立ち上げとはまた違った難しさがあり、当初想定してなかった課題が増えたりして、なかなかリリースまで漕ぎ着けられないもどかしさもありました。
Pay IDでは、今後もプロダクトの改善、技術的改善も積極的に行っていきます。現在エンジニアを募集しているので、興味のある方はぜひ採用情報などもご覧ください!
明日は、oliverさんとtandenさんの記事です。お楽しみに〜