まずはじめに
Clean Architecture という設計思想を理解する記事ではなく、
Go 言語に適したバックエンドの構成を模索していた時に Clean Architecture を元に構築した人の真似をしたら思いのほか良かったため、
自分が実装した時のサンプルコードを載せるといった趣旨の記事。
Clean Architecture とは
Robert C.Martin という方が提唱されている設計方法。
いわゆるディレクトリ構成とか考えるときに、 MVC やら MVVC やら色んなパターンがあるけど、
結果的にそういう構成を考える時のベースになる。
Go + Gin で、例えば MVC をそれとなく実装しようとすると途端に循環参照に陥る。
Clean Architecture は「どのサービスも設計のルールは同じ」という考えで、
どの言語、プロダクトにも使える。らしい。
この設計方法で Go の構築をしてる方々がちらほらいたので実装してみた。
Golang における Clean Architecture のメリット
- ルールに沿って書けば循環参照が絶対に起きない。
- 保守がしやすい。
- 仕様変更やパーツの変更がしやすい。
- テストが非常に書きやすい。
Golang における Clean Architecture のデメリット
- 実装するまでに多くの Go ファイルと時間が必要。
- シンプルな仕様にしないと 1 ファイルの行数が大変なことになる。
- これは自分の理解度が足りないのだと思う。
- 仕様がコロコロ変わると、変更はしやすいとはいえやはり時間がかかる。
時間がかかるといえど
当たり前だけど、
ある程度サンプル書き溜めたり、
時間優先である程度テスト書かなかったりすればそれなりにスピードは上がる。
あとは、扱うファイル数が多いので、集中して書くだけかなと思う。
有名な画像とざっくり説明
Frameworks & Drivers (infrastructure)
- Devices, Web, UI, DB, External Interfaces
DB や Gin などのフレームワーク (表現がしっくり来ないけどユーザーがアクセスするエンドポイント?)、 SDK 、メーラーなどの外部から用意したものとプロダクトを繋ぐ。
これらはプロダクトに沿った形で書かずに、ただ繋ぐだけを意識して書く。
infrastructure は外部に依存する。
Interface Adapters (interfaces)
- Controllers, Gateways, Presenters
infrastructure と usecase を繋ぐ。
infrastructure で書いたただ繋ぐだけのファイルをプロダクト用に汎用性高く使えるように書く。
interfaces は infrastructure に依存する。
Application Business Rules (usecase)
- Use Cases
取得した DB データなどをプロダクト用に加工する。
ビジネスロジック、サービス層とか言われてる部分だと思う。
usecase は interfaces に依存する。
Enterprise Business Rules (domain)
- Entities
いわゆるモデル層。
どこにも依存しない。
完全に独立している。
バックエンドの構成とサンプルコード
- Gin + Gorm + MySQL を使用。
- ex) xxxxx.com/users/1 にアクセスしたら ID: 1 のユーザー情報を取得する。
構成
- /path/to/go/src/psychedelicnekopunch/api
app/
├ infrastructure/ // Frameworks & Drivers (Devices, Web, UI, DB, External Interfaces)
│ ├ Config.go // 環境毎に設定が違うので用意
│ ├ DB.go // MySQL と繋ぐ
│ └ Routing.go // Gin と繋ぐ
│
├ interfaces/ // Interface Adapters (Controllers, Gateways, Presenters)
│ ├ controllers/
│ │ ├ Context.go // インターフェイス (*gin.Context)
│ │ ├ H.go
│ │ └ UsersController.go // Routing.go と usecase を繋ぐ
│ │
│ └ database/
│ ├ DB.go // インターフェイス
│ ├ DBRepository.go // ロールバックできるように用意
│ └ UserRepository.go // infrastructure.DB.go とやりとりをして usecase に返す
│
├ usecase/ // Application Business Rules (Use Cases)
│ ├ DBRepository.go // インターフェイス
│ ├ ResultStatus.go // error とステータスコードの2つを返したかったので作成
│ ├ UserInteractor.go // ビジネスロジック
│ └ UserRepository.go // インターフェイス
│
└ domain/ // Enterprise Business Rules (Entities)
└ Users.go // モデル
tests/ (今回は割愛)
main.go
流れ
- エンドポイントにアクセス (ex. xxxxx.com/users/1)
- ルーティングでコントローラ選択 (infrastructure/Routing.go)
- パラメータを受け取って実行 (interfaces/controllers/UsersController.go)
- usecase/UserInteractor.go へ
- パラメータを受け取って実行 (interfaces/controllers/UsersController.go)
- ルーティングでコントローラ選択 (infrastructure/Routing.go)
- DB接続 (infrastructure/DB.go)
- DB からデータを取得 (interfaces/database/UserRepository.go)
- データを加工してコントローラに返す (usecase/UserInteractor.go)
- interfaces/controllers/UsersController.go へ
- データを加工してコントローラに返す (usecase/UserInteractor.go)
- DB からデータを取得 (interfaces/database/UserRepository.go)
main.go
package main
import (
"github.com/psychedelicnekopunch/gin-clean-architecture/app/infrastructure"
)
func main() {
db := infrastructure.NewDB()
r := infrastructure.NewRouting(db)
r.Run()
}
infrastructure/Config.go
package infrastructure
type Config struct {
DB struct {
Production struct {
Host string
Username string
Password string
DBName string
}
Test struct {
Host string
Username string
Password string
DBName string
}
}
Routing struct {
Port string
}
}
func NewConfig() *Config {
c := new(Config)
c.DB.Production.Host = "localhost"
c.DB.Production.Username = "username"
c.DB.Production.Password = "password"
c.DB.Production.DBName = "db_name"
c.DB.Test.Host = "localhost"
c.DB.Test.Username = "username"
c.DB.Test.Password = "password"
c.DB.Test.DBName = "db_name_test"
c.Routing.Port = ":8080"
return c
}
infrastructure/DB.go
package infrastructure
import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
)
type DB struct {
Host string
Username string
Password string
DBName string
Connection *gorm.DB
}
func NewDB() *DB {
c := NewConfig()
return newDB(&DB{
Host: c.DB.Production.Host,
Username: c.DB.Production.Username,
Password: c.DB.Production.Password,
DBName: c.DB.Production.DBName,
})
}
func NewTestDB() *DB {
c := NewConfig()
return newDB(&DB{
Host: c.DB.Test.Host,
Username: c.DB.Test.Username,
Password: c.DB.Test.Password,
DBName: c.DB.Test.DBName,
})
}
func newDB(d *DB) *DB {
// https://github.com/go-sql-driver/mysql#examples
db, err := gorm.Open("mysql", d.Username + ":" + d.Password + "@tcp(" + d.Host + ")/" + d.DBName + "?charset=utf8&parseTime=True&loc=Local")
if err != nil {
panic(err.Error())
}
d.Connection = db
return d
}
// Begin begins a transaction
func (db *DB) Begin() *gorm.DB {
return db.Connection.Begin()
}
func (db *DB) Connect() *gorm.DB {
return db.Connection
}
infrastructure/Routing.go
package infrastructure
import (
"github.com/gin-gonic/gin"
"github.com/psychedelicnekopunch/gin-clean-architecture/app/interfaces/controllers"
)
type Routing struct {
DB *DB
Gin *gin.Engine
Port string
}
func NewRouting(db *DB) *Routing {
r := &Routing{
DB: db,
Gin: gin.Default(),
Port: c.Routing.Port,
}
r.setRouting()
return r
}
func (r *Routing) setRouting() {
usersController := controllers.NewUsersController(r.DB)
r.Gin.GET("/users/:id", func (c *gin.Context) { usersController.Get(c) })
}
func (r *Routing) Run() {
r.Gin.Run(r.Port)
}
interfaces/controllers/Context.go (インターフェイス)
package controllers
type Context interface {
Param(key string) string
JSON(code int, obj interface{})
}
interfaces/controllers/H.go
package controllers
type H struct {
Message string `json:"message"`
Data interface{} `json:"data"`
}
func NewH(message string, data interface{}) *H {
H := new(H)
H.Message = message
H.Data = data
return H
}
interfaces/controllers/UsersController.go
package controllers
import (
"strconv"
"github.com/psychedelicnekopunch/gin-clean-architecture/app/interfaces/database"
"github.com/psychedelicnekopunch/gin-clean-architecture/app/usecase"
)
type UsersController struct {
Interactor usecase.UserInteractor
}
func NewUsersController(db database.DB) *UsersController {
return &UsersController{
Interactor: usecase.UserInteractor{
DB: &database.DBRepository{ DB: db },
User: &database.UserRepository{},
},
}
}
func (controller *UsersController) Get(c Context) {
id, _ := strconv.Atoi(c.Param("id"))
user, res := controller.Interactor.Get(id)
if res.Error != nil {
c.JSON(res.StatusCode, NewH(res.Error.Error(), nil))
return
}
c.JSON(res.StatusCode, NewH("success", user))
}
interfaces/database/DB.go (インターフェイス)
package database
import (
"github.com/jinzhu/gorm"
)
type DB interface {
Begin() *gorm.DB
Connect() *gorm.DB
}
interfaces/database/DBRepository.go
package database
import (
"github.com/jinzhu/gorm"
)
type DBRepository struct {
DB DB
}
func (db *DBRepository) Begin() *gorm.DB {
return db.DB.Begin()
}
func (db *DBRepository) Connect() *gorm.DB {
return db.DB.Connect()
}
interfaces/database/UserRepository.go
package database
import (
"errors"
"github.com/jinzhu/gorm"
"github.com/psychedelicnekopunch/gin-clean-architecture/app/domain"
)
type UserRepository struct {}
func (repo *UserRepository) FindByID(db *gorm.DB, id int) (user domain.Users, err error) {
user = domain.Users{}
db.First(&user, id)
if user.ID <= 0 {
return domain.Users{}, errors.New("user is not found")
}
return user, nil
}
usecase/DBRepository.go (インターフェイス)
package usecase
import (
"github.com/jinzhu/gorm"
)
type DBRepository interface {
Begin() *gorm.DB
Connect() *gorm.DB
}
usecase/UserInteractor.go
package usecase
import (
"github.com/psychedelicnekopunch/gin-clean-architecture/app/domain"
)
type UserInteractor struct {
DB DBRepository
User UserRepository
}
func (interactor *UserInteractor) Get(id int) (user domain.UsersForGet, resultStatus *ResultStatus) {
db := interactor.DB.Connect()
// Users の取得
foundUser, err := interactor.User.FindByID(db, id)
if err != nil {
return domain.UsersForGet{}, NewResultStatus(404, err)
}
user = foundUser.BuildForGet()
return user, NewResultStatus(200, nil)
}
usecase/UserRepository.go (インターフェイス)
package usecase
import (
"github.com/jinzhu/gorm"
"github.com/psychedelicnekopunch/gin-clean-architecture/app/domain"
)
type UserRepository interface {
FindByID(db *gorm.DB, id int) (user domain.Users, err error)
}
domain/Users.go
package domain
type Users struct {
ID int
ScreenName string
DisplayName string
Password string
Email *string
CreatedAt int64
UpdatedAt int64
}
// この struct はビジネスロジックだと思うので、 usecase で書くべきなのかと思ったけど、
// ここに定義した。
type UsersForGet struct {
ID int `json:"id"`
ScreenName string `json:"screenName"`
DisplayName string `json:"displayName"`
Email *string `json:"email"`
}
func (u *Users) BuildForGet() UsersForGet {
user := UsersForGet{}
user.ID = u.ID
user.ScreenName = u.ScreenName
user.DisplayName = u.DisplayName
if u.Email != nil {
user.Email = u.Email
} else {
empty := ""
user.Email = &empty
}
return user
}