Clean Architecture with Go
💡 はじめに
この記事は、クリーンアーキテクチャとGo言語を半年ほど勉強した人の知見をまとめたものです。この記事の対象者としては、
- クリーンアーキテクチャ聞いたことあるけどGoでの実装方法?
といった方を想定しています。
自分自身も、完全に理解しているとは言い難いですが、ある程度知見が溜まってきたのでまとめておきます。Go言語でクリーンアーキテクチャを実現したい方の参考になれば幸いです。
🤔 クリーンアーキテクチャとは?
- システム設計ガイドラインの1つ
- メリット
- 疎結合、関心の分離により変化に強い
- テストが容易
- デメリット
- コードが冗長
- わかりにくい
🛠 実際に実装してみる
クリーンアーキテクチャに関しては、ネット上の記事を読んでもあまりピンとこないかもしれないので、実際に実装してみることあるいは実際のコードを読むことが理解の近道だと思います。その際、参考になるレポジトリを以下に示します(他にもいろいろサンプルレポジトリが転がっていますgithubで調べると良いでしょう)。
目標
この記事での目標は、GoでクリーンアーキテクチャのテンプレをGithubに作ることとします。テンプレを持っておくことで今後勉強する際に何かと役に立つかと思います。
完成形はこちら
使用技術
使用技術はこんな感じです
- 言語
- Go
- RDBMS
- MySQL
- ライブラリ
- echo
- SQLBoiler
作るもの
クリーンアーキテクチャの理解が目的なので学生情報を取得する簡単なAPIを作る。
API定義書はこちらを https://editor.swagger.io/ に貼り付けて確認。
下準備
未完成なコード
git clone -b develop git@github.com:yagikota/clean_architecture_with_go.git
最低限のコードを用意してある
完成形のコードはこちら
git clone git@github.com:yagikota/clean_architecture_with_go.git
フォルダ構成
pkg
├── adapter(黄色)
│ └── http
│ ├── health_check.go
│ ├── router.go // エンドポイントを書く
│ └── student_handler.go // ハンドラーを書く
├── config
│ └── config.go // 環境変数の読み込みなどを書く
├── domain(緑色)
│ ├── model // model配下にSQLBoilerでmodelが自動生成される
│ │ ├── boil_queries.go
│ │ ├── boil_table_names.go
│ │ ├── boil_types.go
│ │ ├── boil_view_names.go
│ │ ├── mysql_upsert.go
│ │ └── students.go
│ ├── repository // DBへの操作をinterfaceで定義する
│ │ └── student_repository.go
│ └── service // repositoryとusecaseの橋渡し
│ └── student_service.go
├── infra(オレンジ色)
│ ├── db_conn.go // DBとのコネクションを張る
│ └── mysql
│ └── mysql.go // domain/repositoryで定義されているDBへの操作を具体的に書く
└── usecase
├── model // DBから自動生成したmodelを使いやすいように変換(抽象化)する
│ └── student.go
└── student_usecase.go // ごちゃごちゃ処理を書く
https://little-hands.hatenablog.com/entry/2018/12/10/ddd-architecture より引用
現状のコードの説明
- 現状のコードには、APIが1つ作られている。コードを実行し、
localhost:8080/v1/students
にアクセスすると、
[
{
"id": 1,
"name": "Yamada Ichirou",
"age": 22,
"class": 1
},
{
"id": 2,
"name": "Yamada Jirou",
"age": 22,
"class": 1
},
...
{
"id": 10,
"name": "Yamada Jurou",
"age": 21,
"class": 4
}
]
が返ってくる。
以下では、現状のコードにAPIを1つ追加してみる。その過程で、コードの意味なども見ていく。
実装
repository層
まず、pkg/domain/repository/student_repository.go
から見ていく。
package repository
import (
"context"
"github.com/yagikota/clean_architecture_wtih_go/pkg/domain/model"
)
// Iはinterfaceを表現
type IStudentRepository interface {
SelectAllStudents(ctx context.Context) (model.StudentSlice, error)
SelectStudentByID(ctx context.Context, id int) (*model.Student, error) // 追加
}
ここでは、DBの各テーブルへの最低限の操作をinterfaceで定義する。今回は、DBに対してSelectAllStudents
, SelectStudentByID
という2つの操作を定義するだけで具体的な処理は書かない(具体的な処理はpkg/infra/mysql/mysql.go
に書く)。
interfaceについて簡単に説明すると、interfaceとは説明書(仕様書) である。
// 説明書
type IDummy interface {
Method1() // 条件1
Method2() // 条件2
}
例えば、上のinterfaceは、2つの条件Method1()
とMethod2()
が書かれた説明書IDummy
を表現している。
今回の場合だと、
IStudentRepositoryの説明書
* IStudentRepositoryには以下のmethodが実装されています。
* SelectAllStudents(ctx context.Context) (model.StudentSlice, error)
* SelectStudentByID(ctx context.Context, id int) (*model.Student, error)
みたいな意味になる。interfaceを使うメリットはinfra層の部分で説明する。
infra層
次に、pkg/infra/mysql/mysql.go
を見ていく。
// インターフェースを満たすstruct
type studentRepository struct {
DB *sql.DB
}
// NewXXXXについては後ほど説明する
func NewRoomRepository(db *sql.DB) repository.IStudentRepository {
return &studentRepository{
DB: db,
}
}
func (sr *studentRepository) SelectAllStudents(ctx context.Context) (model.StudentSlice, error) {
// DBに対する具体的な操作
return model.Students().All(ctx, sr.DB)
}
// 追加
func (sr *studentRepository) SelectStudentByID(ctx context.Context, studentID int) (*model.Student, error) {
// DBに対する具体的な操作
whereID := fmt.Sprintf("%s = ?", model.StudentColumns.ID)
return model.Students(
qm.Where(whereID, studentID),
).One(ctx, sr.DB)
}
ここでは、まず、repository層で追加したFindStudentByID
の具体的な処理を追加する。こうすることで、*studentRepository
は先ほどpkg/domain/repository/student_repository.go
で定義したIStudentRepository
を満たすことになる。よって、infra層とrepository層がIStudentRepository
と言う説明書に基づいて繋がったといえる。
このように、各層をinterfaceに基づいて接続してくことで、疎な結合(interfaceさえ満たしていれば、片方の層の処理はもう片方の層の処理を気にしなくても実装できる) を実現できる。テストする際も、目の前の層だけに着目すればいいのでテストしやすい。
service層
次に、pkg/domain/service/student_service.go
を見ていく。
package service
import (
"context"
"github.com/yagikota/clean_architecture_wtih_go/pkg/domain/model"
"github.com/yagikota/clean_architecture_wtih_go/pkg/domain/repository"
)
type IStudentService interface {
FindAllStudents(ctx context.Context) (model.StudentSlice, error)
FindStudentByID(ctx context.Context, id int) (*model.Student, error)
}
// インターフェースを満たすstruct
type studentService struct {
repo repository.IStudentRepository
}
// NewXXXX(コンストラクタ)については後ほど説明する
func NewStudentService(sr repository.IStudentRepository) IStudentService {
return &studentService{
repo: sr,
}
}
func (ss *studentService) FindAllStudents(ctx context.Context) (model.StudentSlice, error) {
return ss.repo.SelectAllStudents(ctx)
}
func (ss *studentService) FindStudentByID(ctx context.Context, id int) (*model.Student, error) {
return ss.repo.SelectStudentByID(ctx, id)
}
ここでは、respository層での操作を組み合わせた処理を行う。今回は、単純に処理を受け流しているだけ。
また、
type studentService struct {
repo repository.IStudentRepository
}
のようにservice層で定義するinterface(IStudentService
)を満たすオブジェクト(studentService
)の内部にはrepository層で定義したinterfaceが入っているので、repository層で定義したinterfaceを用いる場合
return ss.repo.SelectAllStudents(ctx)
return ss.repo.SelectStudentByID(ctx, id)
のように取り出す形になる(マトリョーシカ🪆みたいに)。
初期化のためのコンストラクタ NewStudentService
も用意しておく。
他の層でも同じようなことを行う。
service層の必要性に関してはこちらの記事がわかりやすい。
usecase層
次に、pkg/usecase/student_usecase.go
を見ていく。
import (
"context"
"github.com/yagikota/clean_architecture_wtih_go/pkg/usecase/model"
"github.com/yagikota/clean_architecture_wtih_go/pkg/domain/service"
)
type IStudentUsecase interface {
FindAllStudents(ctx context.Context) (model.StudentSlice, error)
FindStudentByID(ctx context.Context, id int) (*model.Student, error) // 追加
}
// インターフェースを満たすstruct
type studentUsecase struct {
svc service.IStudentService
}
// NewXXXX(コンストラクタ)については後ほど説明する
func NewUserUsecase(ss service.IStudentService) IStudentUsecase {
return &studentUsecase{
svc: ss,
}
}
func (su *studentUsecase) FindAllStudents(ctx context.Context) (model.StudentSlice, error) {
msSlice, err := su.svc.FindAllStudents(ctx)
if err != nil {
return nil, err
}
sSlice := make(model.StudentSlice, 0, len(msSlice))
for _, ms := range msSlice {
sSlice = append(sSlice, model.StudentFromDomainModel(ms))
}
return sSlice, nil
}
// 追加
func (su *studentUsecase) FindStudentByID(ctx context.Context, id int) (*model.Student, error) {
ms, err := su.svc.FindStudentByID(ctx, id)
if err != nil {
return nil, err
}
return model.StudentFromDomainModel(ms), nil
}
usecase層では、service層から取ってきたデータを変換してadapter層に渡す処理を書く。今回は、StudentFromDomainModel(ms)
の部分で変換をおこなっている。変換処理のロジックはpkg/usecase/student_usecase.go
に書いてある。
adapter層
handler
次に、pkg/adapter/http/student_handler.go
を見ていく。
// インターフェースを満たすstruct
type studentHandler struct {
usecase usecase.IStudentUsecase
}
// コンストラクタ
func NewStudentHandler(su usecase.IStudentUsecase) *studentHandler {
return &studentHandler{
usecase: su,
}
}
func (sh *studentHandler) FindAllStudents() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
student, err := sh.usecase.FindAllStudents(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, student)
}
}
// 追加
func (sh *studentHandler) FindStudentByID() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
studentID, err := strconv.Atoi(c.Param("student_id"))
if err != nil {
return c.JSON(http.StatusBadRequest, err.Error())
}
student, err := sh.usecase.FindStudentByID(ctx, studentID)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, student)
}
}
adapter層のhandlerでは、リクエストを受け取って、後続の処理に引き渡し、結果をレスポンスとして返す。
router
const (
apiVersion = "/v1"
healthCheckRoot = "/health_check"
// student系
studentsAPIRoot = apiVersion + "/students"
studentIDParam = "student_id" // 追加
)
func InitRouter() *echo.Echo {
e := echo.New()
e.Use(
middleware.Logger(),
middleware.Recover(),
)
// ヘルスチェック
healthCheckGroup := e.Group(healthCheckRoot)
{
relativePath := ""
healthCheckGroup.GET(relativePath, healthCheck)
}
// student
// DI
mySQLConn := infra.NewMySQLConnector()
studentRepository := mysql.NewStudentRepository(mySQLConn.Conn)
studentService := service.NewUserService(studentRepository)
studentUsecase := usecase.NewUserUsecase(studentService)
studentGroup := e.Group(studentsAPIRoot)
{
handler := NewStudentHandler(studentUsecase)
// v1/students
relativePath := ""
studentGroup.GET(relativePath, handler.FindAllStudents())
// v1/students/{student_id}
relativePath = fmt.Sprintf("/:%s", studentIDParam)
studentGroup.GET(relativePath, handler.FindStudentByID())
}
return e
}
adapter層のrouterでは、エンドポイントの登録やDI(Dependancy Injection)を行う。以下では、DIの説明をする。
mySQLConn := infra.NewMySQLConnector()
studentRepository := mysql.NewStudentRepository(mySQLConn.Conn)
studentService := service.NewUserService(studentRepository)
studentUsecase := usecase.NewUserUsecase(studentService)
handler := NewStudentHandler(studentUsecase)
この部分が、DIをしている。日本語では、依存性(依存するオブジェクト)の注入と言われる。
studentRepository := mysql.NewStudentRepository(mySQLConn.Conn)
studentService := service.NewUserService(studentRepository)
studentUsecase := usecase.NewUserUsecase(studentService)
handler := NewStudentHandler(studentUsecase)
クリーンアーキテクチャでは、handler→usecase→service→repository
と言う依存関係があるので、handlerの初期化にはusecaseが、usecaseの初期化にはseriviceが、serviceの初期化にはrepositoryが必要になってくる。その際、使用するのが各ファイルに実装してあるNewXXXX
と言う関数(コンストラクタ)である。コンストラクタの返り値はinterfaceにしておくことで、各層をinterfaceで接続でき、疎結合を実現できる。各層で定義したコンストラクタを呼び出してhandlerを作成し、後続の処理に渡す。要は、handlerではマトリョーシカ🪆を作り、🪆を一つ開けてusecaseに渡しserivice, repositoryでも同じ感じに渡していくイメージ。
📔 参考文献
アーキテクチャ
DI🍀 まとめ
いかがでしょうか?おそらく参考になった方もいれば全然参考にならたかった方もいると思います。また、この記事の内容が正解とも限りません。なので、色々な記事やサンプルコードを読み、そして、実際に手を動かしてみることが大切だと思います。
間違っている点やなどあればご指摘いただけるとありがたいです。適宜修正いたします😀。
ソースコード再掲
Discussion