本記事はRettyマイクロサービス強化月間の四つ目の記事です.
RettyのtoB開発チームでエンジニアをしています鈴木です. 社会人エンジニアも早いことに1年が経ってしまい, “ピチピチエンジニア” の称号と権利を失ってしまいました.
今年は “深みと勢いのエンジニア” として活動しています.
ピチピチエンジニアとして投稿した以前の記事では, その時おすすめの焼き芋を紹介したので今回も最近おすすめのお店としてMEARIを載せます.
今まで実家の焼き鳥が一番美味しいと思っていた自分に衝撃を与えたお店です.
早速本題に入りますが, RettyのtoB開発チームではtoC開発チームと同様にPHPで作られた大きいモノリスからGoで書かれたマイクロサービスへの移行が進んでいます. 現在, Rettyにおけるとても重要なシステムである予約APIの仕様を変える施策を行っていて, これを機にマイクロサービス化することになり, ソフトウェアアーキテクチャの設計を担当することになりました.
本記事では, そんな自分がソフトウェアアーキテクチャの設計する際に意識していたことなどをまとめようと思います.
予約APIについて
まずは, 今回マイクロサービス化しようとしている予約APIについて簡単に説明します.
予約APIは実際にお店に予約したり, 予約の状況を取得することができるAPIです.
Rettyの色々なところで用いられていて, ユーザーさんがお店を予約する画面や, 検索画面に出てくる予約受付状況など様々なところで利用しています.
ユーザーさんのお店探し体験や, お店会員さんへの送客, Rettyの収益を担うとても重要なサービスです.
技術的には, toB側の全てを司ってくれているモノリスアプリケーションの中にPHPで実装されていて, 一部Elasticsearchを介して情報を提供しています.
Rettyのマイクロサービス化について
Rettyではマイクロサービス化がtoC開発チームから進められていて, まずは取得機能から移行が進められています.
今では一部契約店舗の表示や既存の処理で負荷がとても高かった部分に使用されています.
今回は, そんなマイクロサービス化に初めてtoB開発チームが進出して, 第一回目として予約APIの移行をしようとしています.
予約マイクロサービス
いきなり結果から入ってしまいますが, 下図のようにソフトウェアの構成を設計しました. RettyではマイクロサービスはCleanArchitectureにのっとって実装されているため, 対応関係がわかりやすいように左側に有名な図を模したものをつけてみました.
大方針
まずは今回の予約APIのマイクロサービス設計の大方針として
- マイクロサービス化が目的ではないので, 何を解決したいのかに対して設計を当てよう
- 今しなくていいことに関しては後で判断ができるような実装にしよう
を置いていました.
特に一つ目に関しては意識しないと "俺の考えた最強の構成" か "Rettyの考えたマイクロサービステンプレにただ乗っかっただけ" になってしまう恐れがあります.
そのため, 新しい何かを入れるにしても, 既存のマイクロサービスに合わせるにしても何らかの理由は持とうと頑張っていました.
判断したこと
本当にマイクロサービスに切り出すのが良いか?から見直す
まず最初に, マイクロサービス化を目的にしないためにそもそもを考えていました.
また, マイクロサービス化する際には境界づけられたコンテキストを意識して切り出す単位を決めているのですが, ドメインの整理のついていなくて上手く単位が決められないサービスはモジュラモノリスとして切り出して整理がついてから個別のマイクロサービスに切り離す方法もあるのでその選択肢を取ることも考えました.(マイクロサービス化してしまった後より, 同じコードベース上にあったほうが境界づけられたコンテキストの定義直しが簡単だからです)
マイクロサービス化および切り出す単位に関しては
- 現在のコードは複雑になりすぎて, 開発の難易度が高い.
- サポートの切れたフレームワークとバージョンの低いPHPで書かれたモノリスのメンテナンスは厳しい.
- 組織の方針でマイクロサービス化を進めている & マイクロサービス化のハードルは十分に下がっている.(これに関してはマイクロサービス月間最後の記事「terraformによるマイクロサービス構築」をご覧ください)
- 予約は境界づけられたコンテキストとして十分に見れるので, マイクロサービスとして切り出しても, 大きな統合や切り直しが発生することはなさそう
- ただし, 今回はモノリスから切り出す対象には入っていないが, 予約と密接に絡んでくる在庫(予約はこの在庫を確保することを意味する)という概念があり, これに関しては簡単には切り離せなさそう
という理由から,
- 予約マイクロサービスとして切り出すのは良さそう
- 在庫の切り出しの際に今回作るマイクロサービスをモジュラモノリスとして扱えるようにしたほうがいいかも(今からコードをトップレベルから予約と在庫に区切るのはYAGNI感強いので在庫切り出しのタイミングで良い)
と判断しています
言語・アーキテクチャ
Rettyではマイクロサービスの使用技術を横軸で揃えており, 代表的なものでいうと
- gRPC
- Go(一部KotlinやRustで書かれているが適材適所で用いられている & Kotilin → GoへのReplace計画もある)
- CleanArchitecture
が採用されています.
toB開発チームでの本格的なマイクロサービス化は今回が初めてです.
今まではPHPで書かれた巨大なアプリケーションの開発・運用をしており, 古くから所属しているメンバーを中心にPHP, MVCでの実装を得意としていました.
そのため, 横軸で揃えられているものに合わせようとすると今までの全てが変わることになります.
しかし, 使用技術を揃えることによる新規参加勢のリードタイム減少やシンプルにGoの利点を必要としていて, これらに関しては他のマイクロサービスと合わせることにしました.
テンプレのアーキテクチャを踏襲しない
一方で既存と変えたところもあり, 同じCleanArchitectureでも内部の構成を変化させています.
既存のマイクロサービスはCleanArchitectureの文脈でよく見る構成を取ってますが, Rettyのマイクロサービス化は取得系の移行を先に進めている影響で以下の事柄が発生しています.
- Entityが表示要件に影響されている.
- Repositoryが集約のルート単位ではなく, 単なるDAOとなっている.
- ビジネスロジックはUseCaseやEntityに寄せようという方針はあるが, ほとんどはRepositoryの具象クラスでSQLで表現されている.
- Entityはほとんどロジックを持たずRepositoryからServiceへの移し替えの箱となっている.
これらは既存のマイクロサービスの構成に取得系の関心のみが反映された結果となります.
サービスにもよりますが, 一般的に取得系と更新系の関心は違うところにあることが多いので, 一つのモデルでの共存は難しいです.
今回我々が行っている予約APIのマイクロサービス化も取得系からの移行であるため, 何も対策せずこの構成をそのまま予約マイクロサービスにでも利用した場合, 以下の理由から, 同じ結果を迎えることを避けることはできないと考えています.
- 各層や各コンポーネントの役割の理解が浸透していない
- お手本にするコードがその結果を迎えている
- 同じ結果に進んじゃう方が楽
そうなると更新系の実装の際にEntityの扱いに困ってしまったり, ViewModelになってしまったEntityが本来持つべきだったロジックはサービス全体に溶け込み, "コードが複雑で施策開発の難易度が高くてこれ以上追加していきたくない"という課題感はマイクロサービスにしたところで解決されなくなってしまいます.
たしかに予約関連で括り出せることはメリットにはなりますが, コードの複雑度がさほど変わらず, 予約以外のコンテキストのデータを持ってくるのにネットワークが挟まるようになったことで考慮することも増えていて, 開発の難易度が下がるかと言われるとそうではないなと思います. 実装している今でもそう感じています.
また, 逆の問題として, 下図のように更新系の関心で定義されたモデルと実際のユースケースの関心のミスマッチで生じた, 暗黙的な仕様共有により修正の難易度が高いという問題もすでに存在していました.
今回の設計ではこれらの開発難易度に関しては必ず対処するとして, 結果として下図の右のようにCQRSを採用しました.
CQRS採用の理由は取得系と更新系の関心の分離してロジックや型を目的に対してシンプルにすることで, 以下の二つを達成することにあります.
- 課題として持っていた複雑度を下げて保守容易性を向上させる.
- 実装よりもユースケースに目がむくように仕向ける.
これはまだ実装中なのでちゃんとしたフィードバックは得られていないですが, 今の所狙い通りの結果を生んでいて, 取得の実装で用いられる単語やデータ構造がユースケースに沿ったものになり, 素直な実装の期待値が上がっています.
ライブラリの必要性を確認する
次に, 他のサービスで使用されているライブラリを使わない選択をしたので紹介です
前提としてライブラリを選択する際の重要な指標に
- そのライブラリのための実装をどれぐらい必要とするか
- そのライブラリの習得難易度
を持つようにしていました. 予約ドメインはRettyの中では安定したドメインとなっており, 一度実装されるとなかなか変化しないことが予測されます. そのため, そのライブラリを入れて実装の素直さを失うことや, 習得難易度の高さはたまにくるバグ修正や仕様変更のスピードを損ないます.
まず一つ目の選択はDIツールの選択です.
Rettyでは依存性注入にDIツールを使用しているサービスがいくつかあり, wireとdigが使われていますが, 予約サービスではDIすることが少なく, むしろDIツールのための実装と習得のデメリットの方が大きいと感じているため採用せずmain.goで手動で依存性の注入を行うことにしました.
次に, ORMの選択です. (今は取得系実装しかしないので, QueryUseCasesに限った話)
RettyのマイクロサービスでORMを使っているサービスは一つあり, Gormを使用しています. Gormに関しては自分も業務やプライベートで使い倒しているので嫌いではないですが( GoConferenceでも発表しています!GormをVersionUpしたお話 - Speaker Deck), 予約サービスでは使用せず, database/sqlでシンプルに書くことにしています.
Gormのための実装はそんなに多くない印象なのでそこは良いのですが, Gormを使いたいほど複雑な構造体にマッピングをしていない, Gormの習得難易度は結構高いという点から採用をしていません.
また
- reflectionを内部で用いることによるパフォーマンス懸念
- ミスしていてもコンパイル時にエラーを出してくれない
- 定義されているmodelを使い回された場合に別々の意図を持ったメソッドがモデルを介して結合してしまう可能性を取りたくなかった
と言う理由があり, 採用しないことを決めました.
他の選択肢として静的なものもあるのですが, それに関しても複雑な構造体にマッピングをする予定がないという点からあまりメリットを感じていないので今は採用をしていなくて, メリットが勝る時に何かを入れればいいと思っています.
取り外すのは難しいですが入れるのは比較的容易なので今無理に判断する必要はないなという判断です.
テスト
今回のサービスは正しく動かないことが損失に直結するため, テストに関しては特に重視したいと考えていて, テストが書かれやすくなる環境の方を重視しました.
まず, 書きやすい見やすい習得の必要がほとんどないという点からtestifyを採用しています.
次に, QueryUseCaseに関してはデータの取り方がほぼ全てになるので, service, usecaseのユニットテストはあまり意味をなさず, queryProcessorのsqlmockを用いたテストも, テスト作成の大変さに比べて得られるものはほとんどないのでそれらは書かないという方針を立てました. その代わりにテスト用のDBとfixtureを用いたqueryProcessorのテストを行っています.
fixtureを用いたテストは今までのPHPでの開発でも行っていて, 資産が利用できそう & これまでと書き味が変わらないのでGoの開発に慣れていないメンバーでも書きやすいという面でテストを書かなくなる流れにはなりにくいので採用しています.
しかし, Goにおけるfixture関連のライブラリは弱く, 活発でないものが多く, 触る頻度の少ないこのサービスに不安定なものはなるべく入れたくないという思いから, sql fileをただ実行してくれる薄いライブラリを自作しました.
意図的に意思決定を遅延したこと
今から在庫サービスの切り出しを意識しない
将来訪れる在庫ロジックの切り出しに関しては, パッケージを区切ればマイクロサービス風且つ同じコードベース上で境界づけられたコンテキストを変えながら実装できるので, その時に以下の形を提案しようと思っています.
パフォーマンスが必要になったらアーキテクチャを変える
事業のためにさらに高いパフォーマンスやリソース効率が求められている場合, CommandUseCasesとQueryUseCasesを分けるだけで別のスケーリングポリシーを適用できるようになり, CommandとQueryの需要差に対してリソース効率のいいサービス展開を実現できたり, パフォーマンス向上のためにQuery用のデータソースを用いることもできます.
しかし, 今そのようなことは望まれておらず, その時が来たら判断するのが一番情報が揃っていていい判断ができるので, 実装が進んでいくにつれて不可逆にならないように決定的な設計を避けています.
予約マイクロサービス設計まとめ
以上が, 予約マイクロサービス設計時の自分の判断になります. 課題がわかっていて今でも判断できる部分に関してはしっかりと判断して, 今じゃなくて良い部分に関しては将来どの方向に進みそうかは見つつ何もしないようにしています.
次は, そんな予約マイクロサービスに外から接続する口を設置するお話です.
予約Gateway
予約Gatewayをどこにどう置くか
マイクロサービス群はプライベートなサブネット上に構築されているので, 使うためには外から接続する口が必要になります.
Rettyのマイクロサービスの世界に接続できる口は既に二つ存在していて, web-api-gatewayと呼ばれる, 現在は一部契約店舗のお店詳細画面の表示に使われている口と古いRettyからマイクロサービスの機能を呼び出すために設置されたgrpc-gateway(ソフトウェアのgrpc-gatewayと混同してしまうため, 以降retty-grpc-gatewayと称します)です.
以下にそれぞれの特徴を簡単にまとめます
- web-api-gateway
- retty-grpc-gateway
- privateなgateway
- Rettyのマイクロサービスに属していない他のサービスからマイクロサービスの機能を利用するために設定されている
- Go, grpc-gatewayを用いたRESTとgRPCの変換だけを担っているとても薄いサービス
- 改修頻度は低い.
- 高いtrafficを受け付けていて, ちゃんと動いているので信頼性は高い
上記の特徴からわかるように, retty-grpc-gatewayはpublicな呼び出しを想定しておらず, もしどこかに同居させるとした場合の選択肢はweb-api-gatewayの一択でした.
もし同居させられるならコードの追加だけで済むので, 予約APIを実装していくことの利点と欠点を出してみた図(下図)を見てみると, web-api-gatewayに同居は良くないから新しく作ろう!という結論に至りました.
web-api-gateway評価ポイント | 利点 | 欠点 |
---|---|---|
すでに本番稼働している | コードの追加だけで済む | 既存のものを壊さないように実装していく必要がある |
GraphQL | そもそもGraphQLを必要としていない. RESTと比べてエコシステムがまだ安定はしていない. GraphQL特有のセキュリティ観点が増える. クライアントがGraphQLを話せる(話したい)とは限らない. | |
改修頻度の多さ | 不能になる可能性は予約API単体gatewayを作るよりはるかに高くなってしまう. | |
開発に関して | 今までバラバラで開発して独立してDeployできていたものができなくなる. 他のチームもgatewayの実装をしている場合stagingの検証状況を伺ってリリースする必要があり, マイクロサービスの組織論的な利点を損なうことになる. | |
障害対応に関して | web-api-gatewayが落ちた時にお店探しも予約もできなくなりRettyの全てが使えなくなる. さらにweb-api-gatewayの障害対応を担当するチームは予約文脈の知識が少ないため, 切り分けが難しくなり対応が遅れる. |
新しく作る際の技術などは, retty-grpc-gatewayが
というgatewayとしてあってほしい特徴をしっかり持っていたため, まるっきり同じ構成で実装を行なっています.
とても簡略化していますが, 概要は下図です.
以上の構成で叩きを作って, Slackで投げかけて意見を募集したところ
既存のretty-grpc-gatewayではリクエストの増加によりマイクロサービス側の負荷が高まった時に, スケールアウトしてもgatewayのgRPC connection poolが更新されずに新しいコンテナにリクエストが行かないという課題があるという情報をいただき, 最終的には下図のように, 予約のマイクロサービスに対してgatewayをSidecar形式で配置する形となりました.
最終的な構成を決めた当初は, 予約でtrafficがスパイクすることはなさそうだけど, 課題があるなら克服しておくかーという課題感でしたが, 直近でいくとGo to Eatなどのキャンペーンが絡んだ時に予約のシステムが止まるかヒヤヒヤしたという経験があったことを聞くと, 自分はドメインを考えないで設計をするという愚かなことをしたんだなと猛省しました.
Rettyにはオフィスアワーも設けて敷居をガンガン下げてくれる分析チームがいて気軽に聞ける環境があったというのに…
まとめ
以上が予約APIマイクロサービス及びそのgatewayの設計意図です. この設計が少なくとも間違いにはならなかったかどうかは予約の全てが移行し終わった時にようやく知ることができるのでその時を待ちながら, 途中でダメそうと気づいたらその時に寄って変えようと思っています.