🚿

Clean Architecture with Go

2022/09/11に公開

💡 はじめに

この記事は、クリーンアーキテクチャとGo言語を半年ほど勉強した人の知見をまとめたものです。この記事の対象者としては、

  • クリーンアーキテクチャ聞いたことあるけどGoでの実装方法?
    といった方を想定しています。

自分自身も、完全に理解しているとは言い難いですが、ある程度知見が溜まってきたのでまとめておきます。Go言語でクリーンアーキテクチャを実現したい方の参考になれば幸いです。

🤔 クリーンアーキテクチャとは?

  • システム設計ガイドラインの1つ
  • メリット
    • 疎結合、関心の分離により変化に強い
    • テストが容易
  • デメリット
    • コードが冗長
    • わかりにくい

🛠 実際に実装してみる

クリーンアーキテクチャに関しては、ネット上の記事を読んでもあまりピンとこないかもしれないので、実際に実装してみることあるいは実際のコードを読むことが理解の近道だと思います。その際、参考になるレポジトリを以下に示します(他にもいろいろサンプルレポジトリが転がっていますgithubで調べると良いでしょう)。
https://github.com/bxcodec/go-clean-arch
https://github.com/zhashkevych/go-clean-architecture
https://github.com/eminetto/clean-architecture-go-v2

目標

この記事での目標は、GoでクリーンアーキテクチャのテンプレをGithubに作ることとします。テンプレを持っておくことで今後勉強する際に何かと役に立つかと思います。
完成形はこちら
https://github.com/yagikota/clean_architecture_with_go

使用技術

使用技術はこんな感じです

  • 言語
    • 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から見ていく。

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を見ていく。

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を見ていく。

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を見ていく。

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を見ていく。

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

pkg/adapter/http/router.go
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でも同じ感じに渡していくイメージ。

📔 参考文献

アーキテクチャ
https://little-hands.hatenablog.com/entry/2018/12/10/ddd-architecture
https://christina04.hatenablog.com/entry/go-clean-architecture
https://zenn.dev/maru44/articles/b9e07e91a0ea77
https://www.amazon.co.jp/gp/product/4863543727/
DI
https://qiita.com/ogady/items/34aae1b2af3080e0fec4#作ったもの

🍀 まとめ

いかがでしょうか?おそらく参考になった方もいれば全然参考にならたかった方もいると思います。また、この記事の内容が正解とも限りません。なので、色々な記事やサンプルコードを読み、そして、実際に手を動かしてみることが大切だと思います。
間違っている点やなどあればご指摘いただけるとありがたいです。適宜修正いたします😀。
ソースコード再掲
https://github.com/yagikota/clean_architecture_with_go

Eng Ver

Discussion

ログインするとコメントできます