Clean Architecture を用いた go + gin のバックエンド (API) 構築

まずはじめに

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

いわゆるモデル層。
どこにも依存しない。

バックエンドの構成とサンプルコード

構成

  • /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 // インターフェイス
  │         └ UserRepository.go // DB.go と usecase を繋ぐ
  │
  ├ usecase/ // Application Business Rules (Use Cases)
  │    ├ 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 へ
  • DB接続 (infrastructure/DB.go)
    • DB からデータを取得 (interfaces/database/UserRepository.go)
      • データを加工してコントローラに返す (usecase/UserInteractor.go)
        • interfaces/controllers/UsersController.go へ

main.go

package main

import (
	"psychedelicnekopunch/api/app/infrastructure"
)

func main() {
	db := infrastructure.NewDB()
	r := infrastructure.NewRouting(db)
	r.Run(":8080")
}

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
		}
	}
}

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"

	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
	Connect *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.Connect = db
	return d
}

func (db *DB) First(out interface{}, where ...interface{}) *gorm.DB {
	return db.Connect.First(out, where...)
}

infrastructure/Routing.go

package infrastructure

import (
	"github.com/gin-gonic/gin"

	"psychedelicnekopunch/api/app/interfaces/controllers"
)

type Routing struct {
	DB *DB
	Gin *gin.Engine
}

func NewRouting(db *DB) *Routing {
	r := &Routing{
		DB: db,
		Gin: gin.Default(),
	}
	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(port string) {
	r.Gin.Run(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"

	"psychedelicnekopunch/api/app/interfaces/database"
	"psychedelicnekopunch/api/app/usecase"
)

type UsersController struct {
	Interactor usecase.UserInteractor
}

func NewUsersController(db database.DB) *UsersController {
	return &UsersController{
		Interactor: usecase.UserInteractor{
			User: &database.UserRepository{ DB: db },
		},
	}
}

func (controller *UsersController) Get(c Context) {

	id, _ := strconv.Atoi(c.Param("id"))

	user, err := controller.Interactor.Get(id)
	if err != nil {
		c.JSON(controller.Interactor.StatusCode, NewH(err.Error(), nil))
		return
	}
	c.JSON(controller.Interactor.StatusCode, NewH("success", user))
}

interfaces/database/DB.go (インターフェイス)

package database

import (
	"github.com/jinzhu/gorm"
)

type DB interface {
	First(out interface{}, where ...interface{}) *gorm.DB
}

interfaces/database/UserRepository.go

package database

import (
	"errors"

	"psychedelicnekopunch/api/app/domain"
)

type UserRepository struct {
	DB DB
}

func (repo *UserRepository) FindByID(id int) (user domain.Users, err error) {
	user = domain.Users{}
	repo.DB.First(&user, id)
	if user.ID <= 0 {
		return domain.Users{}, errors.New("user is not found")
	}
	return user, nil
}

usecase/UserInteractor.go

package usecase

import (
	"psychedelicnekopunch/api/app/domain"
)

type UserInteractor struct {
	User UserRepository
	StatusCode int
}

func (interactor *UserInteractor) Get(id int) (user domain.UsersForGet, err error) {
	// Users の取得
	foundUser, err := interactor.User.FindByID(id)
	if err != nil {
		interactor.StatusCode = 404
		return domain.UsersForGet{}, err
	}
	user = foundUser.BuildForGet()
	interactor.StatusCode = 200
	return user, nil
}

usecase/UserRepository.go (インターフェイス)

package usecase

import (
	"psychedelicnekopunch/api/app/domain"
)

type UserRepository interface {
	FindByID(id int) (event 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
}

関連記事