Voicyのバックエンド設計を大公開!! ~ GoとClean ArchitectureとProtobufと ~
こんにちは!!
最近はGoとgRPCとKubernetesばかり触っている、フロントエンドエンジニアのぱんでぃーです!!
このエントリーでは、以前の記事で紹介したバックエンドの中核を担うGoの汎用APIサーバーのアーキテクチャを公開したいと思います!
MySQLやElasticsearchなどのミドルウェアと直接やり取りを行うのはモノリシックな共通APIのみ。
この汎用APIは Wall Hermes と呼ばれており(以下、Hermes)、アプリケーション共通のドメインロジックを担う、モノリシックなAPIサーバーです。Micro Serviceがもてはやされている昨今ですが、あえてモノリシックに作ります。理由は単純に、Voicyはエンジニア組織の規模がまだ小さく、ビジネスロジックの複雑性も低いからです。Micro Serviceによる恩恵よりも、複雑性が高まることによる悪影響の方が大きいと感じたという訳です。(ちなみに、このエントリーでは触れませんが、プロダクト固有のロジックや認証処理は各プロダクト用のBFFが担当します。)
上記エントリーの執筆中は、まだGitレポジトリすら無い段階でしたが、開発も進み、アーキテクチャも固まってきたので、このタイミングで紹介いたします。
# TL;DR
- バックエンドを支えるモノリシックなAPIサーバーの最適なアーキテクチャ設計とは!?
- 変更に強く、テストしやすいGoのパッケージ構成を検討した結果、Clean Architectureを採用。
- Go、Protobuf、Clean Architectureの相性は素晴らしい!!
Why Architecture Design is Important?
そもそもですが、なぜアーキテクチャを重視するのでしょうか?
主な理由は
- ソフトウェアを変更に強くする。
→ リリースサイクルの高速化 - テストしやすくする。
→ プロダクト品質の向上
この2点だと考えています。
そして、なぜそれを新規開発のタイミングから重視するかというと、これらはアーキテクチャに大きく依存し、コードベースが大きくなってしまうと改修コストが増大するからです。
このプロジェクトの目的の1つは、今後Voicyがスピード感を持ってユーザーに価値を届けていくための基盤を構築することです。それを実現するために、現在稼働中のAPIサーバーを新規でリプレイスする訳ですので、上記の2つを特に重視してプロジェクトに臨みました。
What are Best Practices?
Voicyでの事例をご紹介する前に、アーキテクチャ設計の参考にさせて頂いた先人の皆さまの事例をサラッとご紹介します。
- Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート — BASE開発チームブログ
- Goのパッケージ構成の失敗遍歴と現状確認 — timakin — Medium
- Goのサーバサイド実装におけるレイヤ設計とレイヤ内実装について考える
- Goのpackage構成と開発のベタープラクティス — Tech Blog — Recruit Lifestyle Engineer
特に、BASEの東口さん(@hgsgtk)や timakin さんのエントリーは、試行錯誤した遍歴も記載されているため、非常に参考にさせて頂きました!
上記以外にも色々と調べて分かったこととしては・・・
絶対的なベストプラクティスが無いというということでした笑
まぁ、考えてみれば当たり前のことで、サービスの性質やプロダクトの規模によって最適なアーキテクチャは変わってきます。その中で、DDDやClean Architectureなどの設計技法を組み合わせて、組織に合うようにチューニングしているといった印象でした。
Why Clean Architecture?
ではVoicyではどうしたかというと、結論から言うとClean Architetucreを採用しました。
Clean Architetucre自体の解説はこのエントリーでは省きますが、設計哲学の根底には
- ソフトウェアを変更に強くする。
- そのために、コンポーネント間の依存関係を変更しやすいものから、変更しにくいものに依存させるようにする。
という考え方があります。
一方、Goの
- コンポーネント(struct)は抽象(Interface)に依存させる。
- そのInterfaceを実装したstructをアプリケーションの初期化時にDIする。
- テスト時はInterfaceをモック化し、そのコンポーネントの責務だけをテストする。
という、オーソドックスなプログラミングスタイルと上記の設計哲学は相性が良いと考えました。また、言語仕様レベルでパッケージの相互参照をコンパイルエラーとする点なども、依存の方向を重要視するClean Architectureにぴったりです。
こういった理由から、Clean Architectureを採用しました。
そして、開発初期の段階ではできるだけアレンジはせず、必要に応じて柔軟に改良していく方針をとりました。これは、新しいメンバーがプロジェクトに参入した際のラーニングコストをできる限り下げるためです。Clean Architectureを学んだことがある人であれば、直感的に理解できるようにしておきたかったのです。
Define Entity Schema by Protocol Buffers
さて、前置きが長くなりましたが、ここからは具体的なGoのディレクトリ構成を紹介します。
まずはディレクトリ階層のトップです。ここには4つのディレクトリが入っているだけです。
$ tree -L 1 internal/
├── entity
├── usecase
├── handler
└── infrastructure
これはそのままClean Architectureの4つのレイヤーと対応するようにしています。(ただし、Interface Adapters は名前が長いのと、現状ではgRPC Handlerしか入っていないため、 handler
としています。Frameworks & Drivers についても、そのままだと名前が長いため infrastructure
としています。)
ポイントとしては、トップレベルのディレクトリがそのままClean Architectureのレイヤーと対応しているため、Clean Architectureを知らないメンバーでも、Goパッケージの依存方向を管理しやすいという点です。たとえば『 usecase
ディレクトリ配下に入っているGoパッケージが依存して良いのは、 entity
ディレクトリ配下のパッケージだけ』といった伝えるだけでOKです。
各ディレクトリ配下には、以下のようにGoパッケージが配置されています。(全モジュールだと数が多すぎるので、サンプルを載せています)
$ tree -L 3 internal/
├── entity
│ └── repository // Interface of repository
│ ├── audio_repo.go
│ └── user_repo.go
├── handler
│ └── grpchandler
│ ├── audio.go
│ └── user.go
├── infrastructure
│ ├── datastore // Implementation of repository
│ │ ├── audio_db.go
│ │ ├── audio_db_test.go
│ │ ├── user_db.go
│ │ └── user_db_test.go
│ └── middleware
│ └── middleware.go
└── usecase
├── audio_uc.go
├── audio_uc_test.go
├── user_uc.go
└── user_uc_test.go
上記を依存関係も含めて図解するとこんな感じになります。
I/F
はInterfaceで、点線の矢印は『Interfaceの実装』- 実線の矢印は『依存パッケージのimport』
- レイヤーを超える矢印の向きは、必ず下から上へ(変更しやすいものから、変更しにくいものへ)
PB
はProtocol Buffersの定義から自動生成されたGoのstructとinterface- 右側の
pkg
ディレクトリは、外部ライブラリ化したパッケージ郡
ポイントとしては、repository
をInterfaceとしてEntityレイヤーに配置している点です。その責務はもっとも変化しにくいEntityに対する、CRUD処理となるため、 usecase
は repository
に依存する必要があります。ただ、実際には repository
はMySQLやFirestoreといったデータベースとなるため、変化しやすい実装に依存してしまいます。(下向きの矢印となってしまう)これを、Clean Architectureの技法の中核とも言えるDIP(Dependency Inversion Principle)に従い、 抽象(Interface)に依存させることで、依存の方向を制御しています。
ちなみに、 pkg
ディレクトリ配下のライブラリはHermes以外のBFFなどでも使いまわしているため、適宜レポジトリを分割していく予定です。また、レイヤー構造こそありませんが storage
や notification
パッケージなども、Interfaceを公開するようにしています。こうすると、たとえば storage
パッケージであれば、
- Local環境では
InMemoryStorage
- CI環境では、S3互換のオブジェクトストレージである
Minio
- パブリッククラウドのKubernetes環境では、
S3
やGCS
といった具合に、簡単に実装を差し替えることができ、テストもしやすくなります。
現在もかなりアクティブに開発しているため、今後も細かい修正は入るでしょうが、今のところ、変更に強いアプリケーションアーキテクチャに仕上がってきていると感じています。
Entity Generated From Protocol Buffers
本筋からは逸れますが、EntityはProtocol Buffersでスキーマ定義し、別リポジトリで管理しています。これは、Entity自体がフロントエンド・バックエンド問わず、複数のアプリケーションで共通するものだからです。ワークフローとしては、以下のようになります。
.proto
ファイルでEntityのスキーマを定義protoc
コマンドでGoのstructを自動生成(実際には、SwiftやJavaのコードやHTMLのドキュメントも)- 生成されたGoのstructにドメインロジックを表現したメソッドを定義
- HermesやBFFなどの各アプリケーションで、依存パッケージとしてEntityをimport
Protocol BuffersをIDLとして使う、利点については今後のエントリーで詳しく紹介していきたいと思います。
Entire Directory Structure
最後のおまけですが、メインのアプリケーション以外の全体のディレクトリ構成については、下記の Standard Go Project Layout を参考にしています。
すでに internal
や pkg
は登場しましたが、他にも build
や cmd
、 scripts
などもプロジェクトルートに配置しています。
このレイアウト自体は、公式が定めているものではないですが、数多くの有名なOSSプロジェクトが慣習的に従っています。具体的には、KubernetesやPrometheusなどCNCF関連のプロジェクトが多いようです。(上記レポジトリから、リンクを辿れます)
これも些細なことですが、新しくVoicyに入ったメンバーが直感的にどこに何があるか分かる状態にするためのちょっとした工夫です。
Conclusion
変更に強く、テストしやすいGoのパッケージ構成を検討した結果、Clean Architectureに辿りつきました。
ただ、本当に大事なことは『どのアーキテクチャを選択するか?』ではなく、『チームのメンバーと目的を共有し、一貫した設計指針を持つこと』だと考えています。
Clean Architectureも当然、銀の弾丸などではありません。実際に、DBトランザクションのような組み込むことが難しいケースも発生しました。そんな時でも、目的が共有された仲間がいればきっと乗り越えていけるはずです。
Voicyではそんな仲間を募集しています!
それでは皆さん、また次回のエントリーでお会いしましょう!