🔥

【Go】テストまで書いて理解するレイヤードアーキテクチャ

2022/01/17に公開約17,700字2件のコメント

はじめに

Go 言語 + レイヤードアーキテクチャでテストコード込の REST API を作ったらとても勉強になったので共有します。
この記事が誰かの参考になれば幸いです。
今回のサンプルコードの最終形は僕の GitHub にあります。

https://github.com/7oh2020/go-rest-api-example

レイヤードアーキテクチャとは?

レイヤードアーキテクチャとは、システムの機能を責務ごとのグループ(レイヤー)に分割して隣り合うレイヤーとだけやり取りできるようにする仕組みです。
レイヤー同士の依存関係は上位から下位への1方向で統一されているのが特徴です。
レイヤー同士はインタフェースでゆるく結合されているため依存性を低く保つことができるというメリットがあります。

レイヤーの種類は一般的に以下のようなものがあります。

1 プレゼンテーション層: GUI や CUI などのユーザーインタフェース
2 アプリケーション層: アプリケーションロジック
3 ドメイン層: ドメインロジック
4 インフラストラクチャ層: データアクセスやネットワークアクセスなど、詳細な実装

依存関係は上から下への1方向で、例えばアプリケーション層はドメイン層に依存しています。

そもそも何故わざわざレイヤーに分割するのか?というと、以下のようなメリットがあるからです。

  • 責務毎にグループ分けされているため、追加修正の際にどこを変更したらいいか分かりやすい
  • 依存関係が1方向なので追加修正の際の影響範囲を特定しやすい
  • インタフェースで抽象化されているためコンポーネントを再利用しやすい
  • 下位レイヤーをモック化できるので責務毎の単体テストがしやすい

例えばもしレイヤー毎に分割せずに作ってしまうと結合度が高くなってしまい、アプリケーションのためのロジックとドメインロジックとデータベースのためのロジックが入り乱れてしまうため追加修正の際にどこを変更したらいいか分かり辛くなります。

責務ごとのレイヤーに分割することでコードの見通しが良くなり、ロジックが増えた際にもコードをシンプルに保つことができます。

次の見出しからは実際のコードを交えて簡単なユーザー管理 API を作成していきます。

環境

要件

  • ユーザーの基本的な CRUD(作成, 更新, 参照, 削除)ができる
  • データベースは MySQL を使用する

エンティティを作成する

エンティティは ID を持つ可変なデータモデルです。
各データは ID で識別されます。例えば同姓同名のユーザーがいても ID が異なれば別のユーザーとして扱えます。

今回はフィールドの宣言のみですが、エンティティにメソッドを追加してそこにビジネスロジックを記述してもいいと思います。
また、構造体に構造体を埋め込んで複雑なデータモデルを表現することもできます。

app/infrastructure/mysql/entity/user.go
package entity

import "time"

// ユーザーのエンティティ
type User struct {
	ID        uint      // ユーザーID
	Name      string    // ユーザー名
	CreatedAt time.Time // 作成日時
	UpdatedAt time.Time // 更新日時
}

func NewUser(id uint, name string, createdAt time.Time, updatedAt time.Time) *User {
	return &User{
		ID:        id,
		Name:      name,
		CreatedAt: createdAt,
		UpdatedAt: updatedAt,
	}
}

リポジトリの作成

リポジトリはエンティティをファイルやデータベースに保存(永続化)する責務を持ちます。

まずリポジトリのインタフェースを定義し、それを実装する struct を作成します。
何故インタフェースを使うのかというと、上位レイヤーはリポジトリのインタフェースのみ参照するためリポジトリ内部でどんな DB を使用しているのかとか、どんな SQL や ORM を使用しているのかなどを知らなくて済みます。

例えば、今回は SQL を直接記述していますが、gormsqlboilerなどの ORM などに書き換えても全然問題ありません。
つまりインタフェースを使うことでレイヤーの責務や知識がカプセル化できるということですね。

リポジトリのインタフェースは以下のようになります。
データベースへ保存する時はエンティティを受け取り、データベースから取得する時はエンティティとして返します。

app/domain/repository/user_repository.go
package repository

import "go-rest-api-example/app/infrastructure/mysql/entity"

type IUserRepository interface {
	FindByID(id uint) (*entity.User, error)
	Create(user *entity.User) error
	Update(user *entity.User) error
	Delete(id uint) error
}

下記はリポジトリの実装です。

/go/src/app/app/infrastructure/mysql/user_repository.go
package mysql

import (
	"database/sql"
	"go-rest-api-example/app/domain/repository"
	"go-rest-api-example/app/infrastructure/mysql/entity"
)

type UserRepository struct {
	*sql.DB
}

// データベースのコネクションを外側から渡せるようにすることでテストが容易になります。
//
// また、関数の戻り値をインタフェース型にして構造体をreturnすると型チェックが行われます。
// 構造体がインタフェースを満たしていない場合はコンパイルエラーになるのですぐに気付けて便利です。
func NewUserRepository(db *sql.DB) repository.IUserRepository {
	return &UserRepository{db}
}

func (r *UserRepository) FindByID(id uint) (*entity.User, error) {
	stmt, err := r.Prepare(`SELECT id, name, created_at, updated_at FROM users WHERE id = ?`)
	if err != nil {
		return nil, err
	}
	defer stmt.Close()

	user := &entity.User{}
	err = stmt.QueryRow(id).Scan(&user.ID, &user.Name, &user.CreatedAt, &user.UpdatedAt)
	if err != nil {
		return nil, err
	}

	return user, nil
}

func (r *UserRepository) Create(user *entity.User) error {
	stmt, err := r.Prepare(`INSERT INTO users(name, created_at) VALUES (?, ?)`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	if _, err := stmt.Exec(user.Name, user.CreatedAt); err != nil {
		return err
	}
	return nil
}

func (r *UserRepository) Update(user *entity.User) error {
	stmt, err := r.Prepare(`UPDATE users SET name = ?, updated_at = ? WHERE id = ?`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	if _, err := stmt.Exec(user.Name, user.UpdatedAt, user.ID); err != nil {
		return err
	}

	return nil
}

func (r *UserRepository) Delete(id uint) error {
	stmt, err := r.Prepare(`DELETE FROM users WHERE id = ?`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	if _, err := stmt.Exec(id); err != nil {
		return err
	}

	return nil

}

リポジトリのテスト

リポジトリの実装がある程度完了したら、リポジトリで発行される SQL が妥当か、渡される値に漏れがないか、COMMIT や ROLLBACK などが期待した順番で呼び出されるかなどをテストします。
sqlmockを使用するとデータベースを動かすことなく SQL や入出力データをテストすることができて便利です。

app/infrastructure/mysql/user_repository_test.go
package mysql

import (
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/infrastructure/mysql/entity"
	"testing"

	"github.com/stretchr/testify/assert"

	"github.com/DATA-DOG/go-sqlmock"
)


func TestUserRepository_Create(tt *testing.T) {
	tt.Run(
		"正常系: エラーなし",
		func(t *testing.T) {
			user := &entity.User{
				Name:      "Alice",
				CreatedAt: common.CurrentTime(),
			}

			// モック用のコネクションを作成
			db, mock, err := sqlmock.New()
			assert.NoError(t, err)
			defer db.Close()

			// SQl、引数、戻り値が意図したものであることを期待する
			mock.ExpectPrepare(`INSERT INTO users`).
				ExpectExec().
				WithArgs(user.Name, user.CreatedAt).
				WillReturnResult(sqlmock.NewResult(1, 1))

				// テスト対象のリポジトリを作成
			r := NewUserRepository(db)


// エラーが発生しないことを期待する
			assert.NoError(t, r.Create(user))

			// 上記で指定した通りにモックが呼ばれることを期待する
			assert.NoError(t, mock.ExpectationsWereMet())
		})
}

・・・

サービスの作成

上記で作成したエンティティやリポジトリのメソッドを組み合わせてビジネスロジックを作り上げるのがサービスの責務です。
システムの中でも特に変更が多く、業務と密接に関係する大事なレイヤーです。

また、サービスはエンティティを扱いますが、上位レイヤーに対しては DTO(Data Transfer Object) に変換してから渡します。
DTO はデータを転送するための struct です。

これは、アプリケーション層にドメイン知識が流出するのを防ぐためです。
もし DTO を使わなかったらドメインロジックがアプリケーションとサービスに散らばってしまい影響範囲が大きくなってしまいます。

DTO はアプリケーション層に特化しておりバリデーションやリクエスト/レスポンスとのバインドにも使用されます。
さらに、エンティティからの変換の際にアプリケーション層に公開したくないフィールドを制限したり整形したりといったことも可能です。

app/common/dto/user_model.go
package dto

import "time"

// IDのみのユーザーデータ
type UserIDModel struct {
	ID uint `query:"id" form:"id" validate:"required"`
}

// ユーザーデータ
type UserModel struct {
	ID        uint      `json:"id" form:"id" validate:"required"`
	Name      string    `json:"name" form:"name" validate:"required,max=32"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

func NewUserModel(id uint, name string, createdAt time.Time, updatedAt time.Time) *UserModel {
	return &UserModel{
		ID:        id,
		Name:      name,
		CreatedAt: createdAt,
		UpdatedAt: updatedAt,
	}
}

サービスのインタフェースと実装は以下のようになります。

convertTo()メソッドと convertFrom()メソッドによりエンティティと DTO を相互変換できるようにしています。
この変換関数はエンティティに持たせてもいいかもしれませんね。

app/domain/service/user_service.go
package service

import (
	"go-rest-api-example/app/common/dto"
	"go-rest-api-example/app/domain/repository"
	"go-rest-api-example/app/infrastructure/mysql/entity"
)

type IUserService interface {
	FindByID(id uint) (*dto.UserModel, error)
	Create(user *dto.UserModel) error
	Update(user *dto.UserModel) error
	Delete(id uint) error
}

type UserService struct {
	repository.IUserRepository
}

func NewUserService(repo repository.IUserRepository) IUserService {
	return &UserService{repo}
}

func (s *UserService) FindByID(id uint) (*dto.UserModel, error) {
	user, err := s.IUserRepository.FindByID(id)
	if err != nil {
		return nil, err
	}
	return s.convertTo(user), nil
}

func (s *UserService) Create(user *dto.UserModel) error {
	u := s.convertFrom(user)
	return s.IUserRepository.Create(u)
}

func (s *UserService) Update(user *dto.UserModel) error {
	u := s.convertFrom(user)
	return s.IUserRepository.Update(u)
}

func (s *UserService) Delete(id uint) error {
	return s.IUserRepository.Delete(id)
}

// エンティティからDTOに変換する
func (s *UserService) convertTo(user *entity.User) *dto.UserModel {
	return dto.NewUserModel(user.ID, user.Name, user.CreatedAt, user.UpdatedAt)
}

// DTOからエンティティに変換する
func (s *UserService) convertFrom(user *dto.UserModel) *entity.User {
	return entity.NewUser(user.ID, user.Name, user.CreatedAt, user.UpdatedAt)
}

サービスのテスト

mockeryパッケージと testify パッケージのモック機能を使うと、コマンドひとつで Go のインタフェースからモックを自動生成できます。
生成したモックは外部からコントロール可能なので、テストの前提条件を自由に設定することができて便利です。

ここではサービス内のドメインロジックのテストに注力したいため、下位レイヤーであるリポジトリをモック化しています。

app/domain/service/user_service_test.go
package service

import (
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/common/dto"
	"go-rest-api-example/app/infrastructure/mysql/entity"
	"go-rest-api-example/mocks"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestUserService_FindByID(tt *testing.T) {
	tt.Run(
		"正常系: エラーなし",
		func(t *testing.T) {
			user := &entity.User{
				ID:        1,
				Name:      "Alice",
				CreatedAt: common.CurrentTime(),
				UpdatedAt: common.CurrentTime(),
			}

			// リポジトリのモックを作成
			r := new(mocks.IUserRepository)

			// モックの.FindByID(1)メソッドが呼び出されることを期待する。呼び出されたら(user, nil)を返す
			r.On("FindByID", user.ID).Return(user, nil)

			// テスト対象のサービス
			s := NewUserService(r)

			// サービスのメソッドを実行
			ret, err := s.FindByID(user.ID)
			// エラーがないことを期待する
			assert.NoError(t, err)

			// 各種フィールドが期待通りか確認
			assert.Equal(t, ret.ID, user.ID)
			assert.Equal(t, ret.Name, user.Name)
			assert.Equal(t, ret.CreatedAt, user.CreatedAt)
			assert.Equal(t, ret.UpdatedAt, user.UpdatedAt)

			// 上記で指定した通りの引数でメソッドが呼ばれることを期待する
			r.AssertExpectations(t)
		})
}

・・・

ハンドラの作成

ハンドラは WEB フレームワークである Echo のルートと関連付けられるモジュールで、アプリケーション層に属します。
リクエスト情報などを含むコンテキストを受け取り、そのパラメータのバリデーションを行ったりレスポンスを生成するなどのアプリケーションロジックを行うのが責務です。
ドメインロジックに関しては内部に埋め込まれたサービスが担います。

また、ハンドラ内で作成日時や更新日時を生成していますが、時間や乱数などの呼び出す度に内容が変わる変数はとてもテストがしにくいです。
特に現在時刻を取得する処理は期待値をどう指定したらいいか分かりませんよね。

なので、通常時は現在時刻が使用されますがテスト時にだけ任意の日時をコンテキスト経由で渡せるように条件分岐しています。

app/handler/user_handler.go
package handler

import (
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/common/dto"
	"net/http"
	"time"

	"go-rest-api-example/app/domain/service"

	"github.com/labstack/echo/v4"
)

type IUserHandler interface {
	// IDからユーザーを取得します。
	FindByID(c echo.Context) error

	// ユーザーを作成します。
	Create(c echo.Context) error

	// ユーザーを更新します。
	Update(c echo.Context) error

	// ユーザーを削除します
	Delete(c echo.Context) error
}

type UserHandler struct {
	service.IUserService
}

func NewUserHandler(srv service.IUserService) IUserHandler {
	return &UserHandler{srv}
}
func (h *UserHandler) FindByID(c echo.Context) error {
	var user dto.UserIDModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, "failed to bind request")
	}
	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}

	ret, err := h.IUserService.FindByID(user.ID)
	if err != nil {
		return echo.NewHTTPError(404, "user is not exists")
	}
	return c.JSON(http.StatusOK, ret)
}

func (h *UserHandler) Create(c echo.Context) error {
	var user dto.UserModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, "failed to bind request")
	}

	// INSERT時はIDを使用しないため、IDのバリデーションをスキップします。
	user.ID = 1

	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}
	if now, ok := c.Get("now").(time.Time); ok {
		// テスト時はコンテキストから時刻を受け取る
		user.CreatedAt = now
	} else {
		// 通常時は現在時刻を取得する
		user.CreatedAt = common.CurrentTime()
	}

	if err := h.IUserService.Create(&user); err != nil {
		return echo.NewHTTPError(500, "failed to create &user")
	}
	return c.String(http.StatusCreated, `{}`)
}

func (h *UserHandler) Update(c echo.Context) error {
	var user dto.UserModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, err.Error())
		// return echo.NewHTTPError(400, "failed to bind request")
	}
	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}
	if now, ok := c.Get("now").(time.Time); ok {
		// テスト時はコンテキストから時刻を受け取る
		user.UpdatedAt = now
	} else {
		// 通常時は現在時刻を取得する
		user.UpdatedAt = common.CurrentTime()
	}

	if err := h.IUserService.Update(&user); err != nil {
		return echo.NewHTTPError(500, "failed to create &user")
	}
	return c.String(http.StatusNoContent, `{}`)
}

func (h *UserHandler) Delete(c echo.Context) error {
	var user dto.UserIDModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, "failed to bind request")
	}
	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}

	if err := h.IUserService.Delete(user.ID); err != nil {
		return echo.NewHTTPError(404, "user is not exists")
	}
	return c.String(http.StatusNoContent, `{}`)
}

ハンドラのテスト

こちらもサービスと同様に mockery + testify でインタフェースからモックを自動生成しています。
今回はハンドラ内のアプリケーションロジックに注力したいため、下位レイヤーであるサービスをモック化しています。

また、上記でも触れたようにここでコンテキスト経由で任意の日時をハンドラに渡しています。

app/handler/user_handler_test.go
package handler

import (
	"fmt"
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/common/dto"
	"go-rest-api-example/app/common/validation"
	"go-rest-api-example/mocks"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/go-playground/validator"
	"github.com/stretchr/testify/assert"

	"github.com/labstack/echo/v4"
)

func TestUserHandler_FindByID(tt *testing.T) {
	tt.Run(
		"正常系: エラーなし",
		func(t *testing.T) {
			// 現在時刻を取得
			now := common.CurrentTime()

			user := dto.NewUserModel(1, "Alice", now, now)

			// GETリクエストを作成
			req := httptest.NewRequest(http.MethodGet, "/user/detail?id=1", nil)

			// Echoインスタンスの作成
			e := echo.New()
			e.Validator = &validation.CustomValidator{V: validator.New()}

			//新しい コンテキストを作成
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			// サービスのモックを作成
			s := new(mocks.IUserService)

			// モックの .FindByID(1)メソッドが呼ばれることを期待する。呼ばれたら(user, nil)を返す。
			s.On("FindByID", user.ID).Return(user, nil)

			// テスト対象のハンドラ
			h := NewUserHandler(s)

			// エラーがないことを期待する
			if assert.NoError(t, h.FindByID(c)) {
				// ステータスコードが200であることを期待する
				assert.Equal(t, http.StatusOK, rec.Code)

				// 上記で指定した通りにモックが呼び出されることを期待する
				s.AssertExpectations(t)
			}
		})
	tt.Run(
		"準正常系: idに0を指定→バリデーションに失敗",
		func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/user/detail?id=0", nil)

			e := echo.New()
			e.Validator = &validation.CustomValidator{V: validator.New()}
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			s := new(mocks.IUserService)
			h := NewUserHandler(s)

			assert.EqualError(t, h.FindByID(c), `code=400, message=failed to validation request`)
		})
	tt.Run(
		"準正常系: idに文字列を指定→バインドに失敗",
		func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/user/detail?id=abc", nil)

			e := echo.New()
			e.Validator = &validation.CustomValidator{V: validator.New()}
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			s := new(mocks.IUserService)
			h := NewUserHandler(s)

			// エラーメッセージが期待通りか確認
			assert.EqualError(t, h.FindByID(c), `code=400, message=failed to bind request`)
		})
}

・・・

DI(依存性の注入)をする

これまで別々に作って単体テストしてきたリポジトリ、サービス、ハンドラ達のインスタンスを生成して1つに合体させます。
各レイヤーは具体的な struct ではなくインタフェースを参照しているため、DI(Dependency Injection)によりゆるく結合されます。

app/common/di/di.go
package di

import (
	"database/sql"
	"go-rest-api-example/app/domain/service"
	"go-rest-api-example/app/handler"
	"go-rest-api-example/app/infrastructure/mysql"
)

func InitUser(db *sql.DB) handler.IUserHandler {
	r := mysql.NewUserRepository(db)
	s := service.NewUserService(r)
	return handler.NewUserHandler(s)

}

ハンドラを WEB ルートに関連付ける

最後に main.go ファイルを作成しデータベースのコネクション作成、ハンドラと WEB ルートの関連付け、WEB サーバーの起動処理を追加します。

データベースの接続情報に関しては docker-compose.yml で定義した環境変数 MYSQL_DSN から取得しています。

docker-compose.yml
environment:
  TZ: "Asia/Tokyo"
  MYSQL_DSN: root:pass@tcp(db:3306)/dev?parseTime=true

main.go は以下のようになります。

main.go
package main

import (
	"database/sql"
	"go-rest-api-example/app/common/di"
	"go-rest-api-example/app/common/validation"
	"os"

	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Validator = &validation.CustomValidator{V: validator.New()}

	// MySQLとの接続を開きます。
	// 接続情報は環境変数MYSQL_DSNから取得しています。
	db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
	if err != nil {
		panic(err)
	}
	defer db.Close()

	setUserRoutes(e, db)

	// WEBサーバーを8080番ポートで起動します。
	e.Logger.Fatal(e.Start(":8080"))
}

// UserHandlerのメソッドとWEBルートを関連付けします。
func setUserRoutes(e *echo.Echo, db *sql.DB) {
	user := di.InitUser(db)
	e.GET("/user/detail", user.FindByID)
	e.POST("/user/create", user.Create)
	e.POST("/user/update", user.Update)
	e.POST("/user/delete", user.Delete)
}

まとめ

今回は下位のレイヤーから説明してきましたが、設計さえできていればどのレイヤーから開発しても問題ありません。
レイヤー間の依存度が低いため、誰かの作業を止めることなく複数人で並行して開発を進めることができるのもこのアーキテクチャのメリットだと思います。

今回は省略しましたが、システムの内容や規模に応じてエンティティにビジネスロジックのメソッドをもたせたり、エンティティを組み合わせて集約を作ったりテストケースをもっと増やして品質を高めたりしてもいいと思います。
また、今回のサンプルコードの完成形は僕の GitHubにあります。

以上です。
ここまで読んで頂きありがとうございました!

Discussion

エンティティは ID を持つ可変なデータモデルです。

こちらですが,エンティティはドメインを表現する「ドメインモデル」ですので,永続化を目的とした「データモデル」とは異なるように思います。

また DDD やクリーンアーキテクチャにおける「エンティティ」はドメインオブジェクトですので,インフラストラクチャ層ではなくドメイン層に置くのが適切かと思います。

上記の User を永続化のためのモデルとして定義されているのであればインフラストラクチャ層に置くのも間違いではないと思いますが,

エンティティにメソッドを追加してそこにビジネスロジックを記述してもいいと思います。

と述べられている通り,やはりドメインオブジェクトとして定義されていると思いますので,app/domain/model/user/user.go のような配置にされる方がよろしいのではないでしょうか。

参考

コメントありがとうございます。
この記事ではユーザーの永続化としてエンティティを使用していたためリポジトリの近くに配置しておりました。

仰る通り、ドメインロジックを含む場合はドメイン層に配置するのが適切だと思います。
ディレクトリ構成はとても悩んでいたのでご指摘ありがとうございます!

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