Help us understand the problem. What is going on with this article?

Go言語でClean Architectureを実現して、gomockでテストしてみた

やりたかったこと

  1. Go言語を使用したDIパターンについて学ぶ
  2. Clean Architectureの実装について学ぶ
  3. 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の変更が発生した際に、ビジネスロジックに手を入れる必要がなくなります。

定番の円形の図はイマイチイメージが湧きにくかったのですが、わかりやすく平面化された図を見つけたので掲載します。

クリーンアーキテクチャ平面図.png
出典:新卒にも伝わるドメイン駆動設計のアーキテクチャ説明[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
     ├── injecteor
     |     └──injector.go
     └── web.go

アーキテクチャ図とディレクトリ構成の対照表
アーキテクチャー図 今回の実装
Presentation層 handler
Infrastructure層 infra
Usacase層 usecase
Domain層 domain

実装

依存関係を図で表してみた

今回、初めてGo言語でClean Architectureを実装してみましたが、作っているうちにだんだん依存関係がわからなくなってきたので、簡単なUML図モドキを作ってみたらめちゃくちゃスッキリしました。
実線が依存、点線が実装です。

Untitled Diagram (1).png
※modelへの依存は多すぎるため一部省略しています。

domain層

model/todo.goではTodoアプリのドメインモデルを定義しています。
本来であれば、”登録日が期限日を超過しているTodoを登録できない”などのドメインに即した実装もここに含まれますが、今回は簡単なAPI ServerなのでDBのカラムとほぼ同義になってしまっています。

domain/model/todo.go
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層で行います。

domain/repository/todo.go
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を満たすメソッドを持つ構造体を実装します。

infra/todo.go
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とのコネクションを生成します。

infra/sqlhandler.go
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を使用しました。

handler/router.go
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形式で返す。

という役割を担っています。

handler/todoHandler.go
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層のテストを行うことができます。(詳細は後述)

usecase/todo.go
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を指しています。

injector/injector.go
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
web.go
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を満たす実装がされていることがわかります。

mock_todo/mock_repository_todo.go
// 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に受け渡すことでテストを記載します。
今回はお試しということで、正常な型で返ってくることをチェックしています。

mock_todo/usecase_todo_test.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を使ったテストについても、ライブラリを使いこなして、実務レベルで活用できるようにいろいろ試していきたいと思います。

参考にしたもの

ogady
Goやクラウド環境について、自身の学びをアウトプットしていきます。 自分で学びながらも人の参考になる記事を書いていけたらと思います。
mediado
私たちメディアドゥは、電子書籍を読者に届けるために「テクノロジー」で「出版社」と「電子書店」を繋ぎ、その先にいる作家と読者を繋げる「電子書籍取次」事業を展開しております。業界最多のコンテンツラインナップとともに最新のテクノロジーを駆使した各種ソリューションを出版社や電子書店に提供し、グローバル且つマルチコンテンツ配信プラットフォームを目指しています。
https://mediado.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした