やりたかったこと
- Go言語を使用したDIパターンについて学ぶ
- Clean Architectureの実装について学ぶ
- gomockを使ったテストを実装する
DI(Dependency Injection)パターンとは
DIパターンはよく「依存性の注入」と言われていて”???”ってなってたけど「依存されるオブジェクトの注入」だと理解しました。このパターンを利用して、依存関係逆転の原則(DIP)を実現することができます。
Go言語でDIの実現方法
依存する実装を定義したinterfaceをつくり、そのinterfaceをstruct(構造体)のフィールドとして持たせます。その構造体に依存することで、DIパターンを実現可能です。
具体的な実装はこのサイトを参考にしました。
・morikuni blog -GoにおけるDI
Clean Architectureについて
この説明は多くの方が解説しているため詳細は割愛します。
簡単に言うと、ソフトウェアの各レイヤーの依存関係を工夫し、保守性を向上させたアーキテクチャパターンです。Clean Architecture では、上記のDIパターンによって、依存性を逆転させます。これにより、ビジネスロジックがDB、Webフレームワークに依存しなくなり、DBやUIの変更が発生した際に、ビジネスロジックに手を入れる必要がなくなります。
定番の円形の図はイマイチイメージが湧きにくかったのですが、わかりやすく平面化された図を見つけたので掲載します。
出典:新卒にも伝わるドメイン駆動設計のアーキテクチャ説明[DDD]
作ったもの
今回は、DIとClean Architectureを試してみたかっただけなので、お馴染みToDoアプリのAPI Serverを作りました。特に入力チェックとかもせずシンプルなCRUD処理のみ実装してます。
開発環境
- Go 1.12
- MySQL
- github.com/go-sql-driver/mysql v1.4.1
- github.com/golang/mock v1.3.1
- github.com/labstack/echo v3.3.10
ディレクトリ構成
└── src
├── domain
| ├──model
| | └──todo.go
| └──repository
| └──todo.go
├── infra
| ├──sqlhandler.go
| └──todo.go
├── handler
| ├──router.go
| └──todoHandler.go
├── usecase
| └──todo.go
├── injector
| └──injector.go
└── web.go
アーキテクチャ図とディレクトリ構成の対照表
アーキテクチャー図 | 今回の実装 |
---|---|
Presentation層 | handler |
Infrastructure層 | infra |
Usacase層 | usecase |
Domain層 | domain |
実装
依存関係を図で表してみた
今回、初めてGo言語でClean Architectureを実装してみましたが、作っているうちにだんだん依存関係がわからなくなってきたので、簡単なUML図モドキを作ってみたらめちゃくちゃスッキリしました。
実線が依存、点線が実装です。
domain層
model/todo.goではTodoアプリのドメインモデルを定義しています。
本来であれば、”登録日が期限日を超過しているTodoを登録できない”などのドメインに即した実装もここに含まれますが、今回は簡単なAPI ServerなのでDBのカラムとほぼ同義になってしまっています。
package model
//Todo is TodoModel
type Todo struct {
ID int `json:"id"` //TaskのID
Task string `json:"task"` //Task自体
LimitDate string `json:"limitDate"` //Taskの完了期限
Status bool `json:"status"` //Taskの状態(0=未済,1=済)
}
上のmodelの永続化を行うリポジトリ、TodoRepository を定義します。
ここではClean Architectureの依存性の順番を守るためinterface(抽象)のみを定義し、実際の実装はinfra層で行います。
package repository
import (
"todo/domain/model"
)
//TodoRepository is interface for infrastructure
type TodoRepository interface {
FindAll() (todos []*model.Todo, err error)
Find(word string) (todos []*model.Todo, err error)
Create(todo *model.Todo) (*model.Todo, error)
Update(todo *model.Todo) (*model.Todo, error)
}
infra層
infra/todo.goでは、domain層のTodoRepositoryで定義したinterfaceを満たすメソッドを持つ構造体を実装します。
package infra
import (
"fmt"
"todo/domain/model"
"todo/domain/repository"
)
type TodoRepository struct {
SqlHandler
}
func NewTodoRepository(sqlHandler SqlHandler) repository.TodoRepository {
todoRepository := TodoRepository{sqlHandler}
return &todoRepository
}
func (todoRepo *TodoRepository) FindAll() (todos []*model.Todo, err error) {
rows, err := todoRepo.SqlHandler.Conn.Query("SELECT * FROM todos")
defer rows.Close()
if err != nil {
fmt.Print(err)
return
}
for rows.Next() {
todo := model.Todo{}
rows.Scan(&todo.ID, &todo.Task, &todo.LimitDate, &todo.Status)
todos = append(todos, &todo)
}
return
}
func (todoRepo *TodoRepository) Find(word string) (todos []*model.Todo, err error) {
rows, err := todoRepo.SqlHandler.Conn.Query("SELECT * FROM todos WHERE task LIKE ?", "%"+word+"%")
defer rows.Close()
if err != nil {
fmt.Print(err)
return
}
for rows.Next() {
todo := model.Todo{}
rows.Scan(&todo.ID, &todo.Task, &todo.LimitDate, &todo.Status)
todos = append(todos, &todo)
}
return
}
func (todoRepo *TodoRepository) Create(todo *model.Todo) (*model.Todo, error) {
_, err := todoRepo.SqlHandler.Conn.Exec("INSERT INTO todos (task,limitDate,status) VALUES (?, ?, ?) ", todo.Task, todo.LimitDate, todo.Status)
return todo, err
}
func (todoRepo *TodoRepository) Update(todo *model.Todo) (*model.Todo, error) {
_, err := todoRepo.SqlHandler.Conn.Exec("UPDATE todos SET task = ?,limitDate = ? ,status = ? WHERE id = ?", todo.Task, todo.LimitDate, todo.Status, todo.ID)
return todo, err
}
infra/sqlhandler.goでは、実際に今回使用するMySQLとのコネクションを生成します。
package infra
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
type SqlHandler struct {
Conn *sql.DB
}
func NewSqlHandler() *SqlHandler {
conn, err := sql.Open("mysql", "root:password@tcp(localhost:3306)/todo")
if err != nil {
panic(err.Error)
}
sqlHandler := new(SqlHandler)
sqlHandler.Conn = conn
return sqlHandler
}
handler層
handler/router.goでは、URLのルーティングを定義しています。
今回はWebフレームワークとしてechoを使用しました。
package handler
import (
"github.com/labstack/echo"
)
func InitRouting(e *echo.Echo, todoHandler TodoHandler) {
e.GET("/", todoHandler.View())
e.GET("/search", todoHandler.Search())
e.POST("/todoCreate", todoHandler.Add())
e.POST("/todoEdit", todoHandler.Edit())
}
handler/todoHandler.goは、routerから呼び出され、
- レクエストで渡されたデータをusecase層への受け渡す。
- 戻り値として受け取ったデータを、JSON形式で返す。
という役割を担っています。
package handler
import (
"net/http"
"todo/domain/model"
"todo/usecase"
"github.com/labstack/echo"
)
type TodoHandler struct {
todoUsecase usecase.TodoUsecase
}
func NewTodoHandler(todoUsecase usecase.TodoUsecase) TodoHandler {
todoHandler := TodoHandler{todoUsecase: todoUsecase}
return todoHandler
}
func (handler *TodoHandler) View() echo.HandlerFunc {
return func(c echo.Context) error {
models, err := handler.todoUsecase.View()
if err != nil {
return c.JSON(http.StatusBadRequest, models)
}
return c.JSON(http.StatusOK, models)
}
}
func (handler *TodoHandler) Search() echo.HandlerFunc {
return func(c echo.Context) error {
word := c.QueryParam("word")
models, err := handler.todoUsecase.Search(word)
if err != nil {
return c.JSON(http.StatusBadRequest, models)
}
return c.JSON(http.StatusOK, models)
}
}
func (handler *TodoHandler) Add() echo.HandlerFunc {
return func(c echo.Context) error {
var todo model.Todo
c.Bind(&todo)
err := handler.todoUsecase.Add(&todo)
return c.JSON(http.StatusOK, err)
}
}
func (handler *TodoHandler) Edit() echo.HandlerFunc {
return func(c echo.Context) error {
var todo model.Todo
c.Bind(&todo)
err := handler.todoUsecase.Edit(&todo)
return c.JSON(http.StatusOK, err)
}
}
usecase層
usecase層では、ドメイン層で定義されたメソッドを呼び出すためのユースケース記述レベルの抽象度の高いコードを記載します。usecase層はdomain層で定義したinterfaceに依存しているため、interfaceで定義されたメソッドを実装したmockを利用することでusecase層のテストを行うことができます。(詳細は後述)
package usecase
import (
"todo/domain/model"
"todo/domain/repository"
)
type TodoUsecase interface {
Search(string) (todo []*model.Todo, err error)
View() (todo []*model.Todo, err error)
Add(*model.Todo) (err error)
Edit(*model.Todo) (err error)
}
type todoUsecase struct {
todoRepo repository.TodoRepository
}
func NewTodoUsecase(todoRepo repository.TodoRepository) TodoUsecase {
todoUsecase := todoUsecase{todoRepo: todoRepo}
return &todoUsecase
}
// Search 入力された内容でTodoを検索する
func (usecase *todoUsecase) Search(word string) (todo []*model.Todo, err error) {
todo, err = usecase.todoRepo.Find(word)
return
}
// View はTodoの一覧を表示する
func (usecase *todoUsecase) View() (todo []*model.Todo, err error) {
todo, err = usecase.todoRepo.FindAll()
return
}
// Add はTodoを新規追加する
func (usecase *todoUsecase) Add(todo *model.Todo) (err error) {
_, err = usecase.todoRepo.Create(todo)
return
}
// Edit はTodoを編集する
func (usecase *todoUsecase) Edit(todo *model.Todo) (err error) {
_, err = usecase.todoRepo.Update(todo)
return
}
injector(依存性解決)
DI用の関数(Inject関数)を用意し、以下の2点を実装して依存関係を解決します。
- オブジェクトを引数0個で取得できるようにする。
- オブジェクトのコンストラクタ(NewHoge関数)を呼び出し、他のInject関数を使ってオブジェクトを注入する。
ここで言うオブジェクトとは、interface、structを指しています。
package injector
import (
"todo/domain/repository"
"todo/handler"
"todo/infra"
"todo/usecase"
)
func InjectDB() infra.SqlHandler {
sqlhandler := infra.NewSqlHandler()
return *sqlhandler
}
/*
TodoRepository(interface)に実装であるSqlHandler(struct)を渡し生成する。
*/
func InjectTodoRepository() repository.TodoRepository {
sqlHandler := InjectDB()
return infra.NewTodoRepository(sqlHandler)
}
func InjectTodoUsecase() usecase.TodoUsecase {
TodoRepo := InjectTodoRepository()
return usecase.NewTodoUsecase(TodoRepo)
}
func InjectTodoHandler() handler.TodoHandler {
return handler.NewTodoHandler(InjectTodoUsecase())
}
main
package main
import (
"fmt"
"todo/infra/handler"
"todo/injector"
"github.com/labstack/echo"
)
func main() {
fmt.Println("sever start")
todoHandler := injector.InjectTodoHandler()
e := echo.New()
handler.InitRouting(e, todoHandler)
e.Logger.Fatal(e.Start(":8080"))
}
##テストを書いてみる
mockを使用したテストを書いてみます。
gomockとmockgenを利用します。
go get github.com/golang/mock/gomock
go install github.com/golang/mock/mockgen
mockgenを使うと与えたGoのソースからそのコード中で定義されたinterfaceのmockを生成してくれます。
mockgen -source domain/repository/todo.go -destination mock_todo/mock_repository_todo.go
mockgenにより出力されたmock
MockTodoRepository という構造体に、domain層で定義したTodoRepositoryのinterfaceを満たす実装がされていることがわかります。
// Code generated by MockGen. DO NOT EDIT.
// Source: domain/repository/todo.go
// Package mock_repository is a generated GoMock package.
package mock_repository
import (
reflect "reflect"
model "todo/domain/model"
gomock "github.com/golang/mock/gomock"
)
// MockTodoRepository is a mock of TodoRepository interface
type MockTodoRepository struct {
ctrl *gomock.Controller
recorder *MockTodoRepositoryMockRecorder
}
// MockTodoRepositoryMockRecorder is the mock recorder for MockTodoRepository
type MockTodoRepositoryMockRecorder struct {
mock *MockTodoRepository
}
// NewMockTodoRepository creates a new mock instance
func NewMockTodoRepository(ctrl *gomock.Controller) *MockTodoRepository {
mock := &MockTodoRepository{ctrl: ctrl}
mock.recorder = &MockTodoRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockTodoRepository) EXPECT() *MockTodoRepositoryMockRecorder {
return m.recorder
}
// FindAll mocks base method
func (m *MockTodoRepository) FindAll() ([]*model.Todo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FindAll")
ret0, _ := ret[0].([]*model.Todo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindAll indicates an expected call of FindAll
func (mr *MockTodoRepositoryMockRecorder) FindAll() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAll", reflect.TypeOf((*MockTodoRepository)(nil).FindAll))
}
// Find mocks base method
func (m *MockTodoRepository) Find(word string) ([]*model.Todo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Find", word)
ret0, _ := ret[0].([]*model.Todo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Find indicates an expected call of Find
func (mr *MockTodoRepositoryMockRecorder) Find(word interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockTodoRepository)(nil).Find), word)
}
// Create mocks base method
func (m *MockTodoRepository) Create(todo *model.Todo) (*model.Todo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Create", todo)
ret0, _ := ret[0].(*model.Todo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Create indicates an expected call of Create
func (mr *MockTodoRepositoryMockRecorder) Create(todo interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockTodoRepository)(nil).Create), todo)
}
// Update mocks base method
func (m *MockTodoRepository) Update(todo *model.Todo) (*model.Todo, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", todo)
ret0, _ := ret[0].(*model.Todo)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Update indicates an expected call of Update
func (mr *MockTodoRepositoryMockRecorder) Update(todo interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockTodoRepository)(nil).Update), todo)
}
mockを利用してusecase.goのテストを書く
mockの構造体を生成してusecase.goに受け渡すことでテストを記載します。
今回はお試しということで、正常な型で返ってくることをチェックしています。
package mock_repository
import (
reflect "reflect"
"testing"
model "todo/domain/model"
"todo/usecase"
"github.com/golang/mock/gomock"
)
func TestView(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var expected []*model.Todo
var err error
mockSample := NewMockTodoRepository(ctrl)
mockSample.EXPECT().FindAll().Return(expected, err)
// mockを利用してtodoUsecase.View()をテストする
todoUsecase := usecase.NewTodoUsecase(mockSample)
result, err := todoUsecase.View()
if err != nil {
t.Error("Actual FindAll() is not same as expected")
}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Actual FindAll() is not same as expected")
}
}
func TestSearch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var expected []*model.Todo
var err error
word := "test"
mockSample := NewMockTodoRepository(ctrl)
mockSample.EXPECT().Find(word).Return(expected, err)
// mockを利用してtodoUsecase.Search(word string)をテストする
todoUsecase := usecase.NewTodoUsecase(mockSample)
result, err := todoUsecase.Search(word)
if err != nil {
t.Error("Actual Find(word string) is not same as expected")
}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Actual Find(word string) is not same as expected")
}
}
func TestAdd(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var expected *model.Todo
var err error
mockSample := NewMockTodoRepository(ctrl)
mockSample.EXPECT().Create(expected).Return(expected, err)
// mockを利用してtodoUsecase.Add(todo *model.Todo)をテストする
todoUsecase := usecase.NewTodoUsecase(mockSample)
err = todoUsecase.Add(expected)
if err != nil {
t.Error("Actual Find(word string) is not same as expected")
}
}
func TestEdit(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
var expected *model.Todo
var err error
mockSample := NewMockTodoRepository(ctrl)
mockSample.EXPECT().Update(expected).Return(expected, err)
// mockを利用してtodoUsecase.Edit(todo *model.Todo)をテストする
todoUsecase := usecase.NewTodoUsecase(mockSample)
err = todoUsecase.Edit(expected)
if err != nil {
t.Error("Actual Find(word string) is not same as expected")
}
}
最後に
今回は、DIパターンを使って、Clean Architectureっぽいものを実装してみました。Uncle Bobの構成とは若干異なる形(Interface Adaptersなどは用意していない)で作りましたが、やりたいことはできたと思います。
これだけの処理を実装するのに、思った以上にコードを書く必要があるので、結構体力使いました。
間違っている部分も多くあると思いますので、指摘等あれは是非お願いします。
また、一通りやってみたものの、やはりClean ArchitectureやDDDについて深く学ぶ必要があると強く感じました。。。
mockを使ったテストについても、ライブラリを使いこなして、実務レベルで活用できるようにいろいろ試していきたいと思います。