6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ラクスAdvent Calendar 2021

Day 13

GoでDDDっぽいコードを書く方法をGo初心者が考えてみる

Last updated at Posted at 2021-12-13

こちらは ラクス Advent Calendar 2021 の13日目の記事です。

初めまして。今年の10月にラクスに入社した@Imamottyです。
入社して2ヶ月余りが経過しました。日々覚えることが多く大変ですが、成長を感じる毎日です。
新参者ですが、よろしくお願いします。

テーマ:「アドベントカレンダー」のドメインモデルを考えて、GoでDDDっぽいコードを書いてみる

さて、本題ですが、チームで開発を担当するアプリケーションのバックエンドにGoを採用することになり、
せっかくなのでGoのコーディングの練習にもなりそうな形でアドベントカレンダーのテーマを考えました。

今回は 「アドベントカレンダー」 をテーマにドメインモデルを考えて、
DDDっぽくコーディングすることを目指していきたいと思います。

ちなみにA Tour of Goをなんとか完走した感じのGo初心者なので、実装の稚拙さはお許しください。。
(改善のアドバイスとかもらえると嬉しいです)

前提条件

使用ツール・ライブラリ

  • go 1.17
  • gin-gonic/gin v1.7.7 (Webフレームワーク)
  • gorm.io/gorm v1.22.4 (ORM)
  • google/wire v0.5.0 (DIツール)
  • postgres v14.1 (DB)

アプリケーション仕様

  • 以下の操作ができる
    • ユーザ名を指定してユーザを登録する
    • カレンダー名を指定してカレンダーを登録する
    • ユーザが任意のカレンダーにエントリーする
    • 特定のユーザがエントリー済みのカレンダーをすべて参照する
    • 特定のカレンダーにエントリー済みのユーザをすべて参照する

設計

ドメインモデル

こちらです。(自分でテーマと仕様を決めておきながら答えが分からなかった・・・)

ドメインモデル.drawio.png

API一覧

以下の7本のAPIを作成します。

  • POST /user/create ユーザ登録
  • GET /user/:user_id ユーザ参照
  • POST /calendar/create カレンダー登録
  • GET /calendar/:calendar_id カレンダー参照
  • POST /entry/create エントリー登録
  • GET /entries/calendar/:calendar_id 特定のカレンダーにエントリー済みのユーザをすべて参照
  • GET /entries/user/:user_id 特定のユーザがエントリー済みのカレンダーをすべて参照

実装方針

以下のような実装ルールで実装しました。

レイヤーと依存関係

  • レイヤーはDomain層、UI層、Infrastructure層、Application層に分ける
  • 依存関係は UI層・Infrastructure層 -> Application層 -> Domain層
  • UI層、Infrastructure層、Application層はwireを使ってDIでインスタンスを生成

依存関係逆転の原則(DIP)

  • Domain層にRepositoryのinterfaceを置き、Infrastructure層で実装する
  • ApplicationService等はDomain層のinterfaceに依存する

トランザクションのスコープ

  • DBのトランザクションはAPIのリクエスト全体で共通管理する
  • すべての処理が正常終了でcommit、途中でエラーが発生した場合はrollback

ディレクトリ構成

以下のようなディレクトリ構成にします。

.
├── docker-compose.yml
├── initdb
│   └── setting.sql #postgresのコンテナ起動時に読み込ませるDDL
└── src
    ├── application #Application層のAppServiceを置く
    │   ├── calendar
    │   ├── entry
    │   ├── user
    │   └── wire.go #wire用にAppServiceのProvider関数セットを定義
    ├── domain #Domain層のstruct、interface等を置く
    │   ├── calendar
    │   ├── entry
    │   └── user
    ├── infrastructure #Infrastructure層のRepository実装やORM用のモデルを置く
    │   ├── calendar
    │   ├── entry
    │   ├── user
    │   └── wire.go #wire用にRepositoryのProvider関数セットを定義
    ├── userinterface #UI層のControllerを置く
    │   ├── calendar
    │   ├── entry
    │   ├── user
    │   └── controller.go #Controllerのinterfaceを定義(すべてのControllerで実装する)
    ├── main.go #エントリーポイント
    ├── router.go #ルーティングを定義
    ├── api_transaction_manager.go #ルーティングで使うミドルウェア。HTTPリクエスト単位でDBトランザクションを管理できるようにする
    ├── Dockerfile
    ├── go.mod
    ├── go.sum
    ├── wire.go #wireで各Provider関数をControllerにDIする設定を定義
    └── wire_gen.go #wireの成果物(編集不可)

実装内容

実装したソースコードをこちらのリポジトリにpushしました。
個人的な工夫ポイントは以下のあたりです。

  1. リクエストスコープのトランザクションを実現する共通処理としてApiTransactionManagerを追加
  2. google/wire を使ってControllerの生成関数をコンパイル時に作成
  3. すべてのControllerについて、Routingの設定を共通化してちょっとコードを短くする

それぞれ説明したいと思います。

1. トランザクションをリクエストスコープにする

パスごとの挙動を制御する関数であるgin.HandlerFunc func(*gin.Context)を同一パスに複数バインドすることができるので、
共通処理としてトランザクション開始、commit/rollbackを行うApiTransactionManagerを使ってトランザクションの制御をしています。
tx := db.Begin()でトランザクションを開始して、c.Set("db", tx)で後続のHandlerFuncにトランザクションを開始したDBオブジェクトを渡すことができます。
その後、c.Next()で後続のHandlerFuncの処理を実行します。
最終的にc.Errorsに値が入っている場合はrollback、それ以外はcommitしています。
また、途中でpanicが発生した場合も、defer func()で捕捉してrollbackしています。

/src/api_transaction_manager.go
package main

import (
	"github.com/gin-gonic/gin"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

func ApiTransactionManager(c *gin.Context) {
	defer func() {
		if r := recover(); r != nil {
			db, _ := c.Get("db")
			if db != nil {
				db.(*gorm.DB).Rollback()
			}
			c.JSON(200, gin.H{
				"status": "ng",
				"errors": []string{r.(string)},
			})

		}
	}()

	dsn := "host=db user=test password=password dbname=test01 port=5432 sslmode=disable TimeZone=Asia/Tokyo"
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
	if err != nil {
		c.AbortWithError(500, err)
	}

	tx := db.Begin()
	c.Set("db", tx)
	c.Next()

	if len(c.Errors) > 0 {
		tx.Rollback()
		errorList := c.Errors
		c.JSON(200, gin.H{
			"status": "ng",
			"errors": errorList.Errors(),
		})
	}

	tx.Commit()
}

2. google/wireでDI

google/wire は、必要な関数を選択して、DI済みのオブジェクトを返却する関数を生成してくれるライブラリです。

たとえば、*entry.EntryCreateControllerを返却する関数であるInitializeEntryCreateControllerは、
以下のように変数を生成する多くの関数を呼ぶ必要があります。

func InitializeEntryCreateController(db *gorm.DB) *entry.EntryCreateController {
	entryRepository := entry2.NewEntryRepository(db)
	userRepository := user2.NewUserRepository(db)
	userFindService := user3.NewUserFindService(userRepository)
	calendarRepository := calendar2.NewCalendarRepository(db)
	calendarFindService := calendar3.NewCalendarFindService(calendarRepository)
	entryCreateService := entry3.NewEntryCreateService(entryRepository, userFindService, calendarFindService)
	entryCreateController := entry.NewEntryCreateController(entryCreateService)
	return entryCreateController
}

この実装はgoogle/wireによって自動生成された後の関数で、元々の実装はこんな感じです。

func InitializeEntryCreateController(db *gorm.DB) *entry.EntryCreateController {
	wire.Build(
		infrastructure.RepositoryProviderSet,
		application.ServiceProviderSet,
		entry.NewEntryCreateController,
	)
	return new(entry.EntryCreateController)
}

infrastructure.RepositoryProviderSetapplication.ServiceProviderSetは、
それぞれRepositoryとApplicationServiceを生成する関数(Provider関数と呼ばれる)のセットになっています。
wire.BuildにProvider関数をセットすることで、関数の戻り値の型となる関数を自動生成しています。

各ProviderSetはそれぞれ/src/infrastructure/wire.go/src/application/wire.go に記述しています。

また、自分がハマった注意点なのですが、RepositoryはDomain層のinterfaceに依存しているため、
以下のようにwire.Bindを使って実体を返却するProvider関数とinterfaceの型を紐づける必要があります。

/src/infrastructure/wire.go
//go:build wireinject

package infrastructure

import (
	calendar_domain "advent-calendar/domain/calendar"
	entry_domain "advent-calendar/domain/entry"
	user_domain "advent-calendar/domain/user"
	"advent-calendar/infrastructure/calendar"
	"advent-calendar/infrastructure/entry"
	"advent-calendar/infrastructure/user"

	"github.com/google/wire"
)

var RepositoryProviderSet = wire.NewSet(
	user.NewUserRepository,
	wire.Bind(new(user_domain.UserRepository), new(*user.UserRepository)),
	calendar.NewCalendarRepository,
	wire.Bind(new(calendar_domain.CalendarRepository), new(*calendar.CalendarRepository)),
	entry.NewEntryRepository,
	wire.Bind(new(entry_domain.EntryRepository), new(*entry.EntryRepository)),
)

3. Routingの共通化

gin.HandlerFuncをルーターに渡してパスに処理をバインドしますが、
ルーティング周りが煩雑になりすぎるので、各Controllerにgin.HandlerFuncを返却するよう実装してみました。

まずはControllerの共通interfaceを定義します。

/src/userinterface/controller.go
package userinterface

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

type Controller interface {
	Handler() gin.HandlerFunc
}

このinterfaceを実装するように、各Controllerを定義していきます。
以下のUserFindControllerHandler()メソッドを実装しています。

/src/userinterface/user/user_find_controller.go
package user

import (
	user_app "advent-calendar/application/user"
	"advent-calendar/domain/user"
	"errors"

	"github.com/gin-gonic/gin"
)

type UserFindController struct {
	userFindService user_app.UserFindService
}

func NewUserFindController(userService user_app.UserFindService) *UserFindController {
	c := new(UserFindController)
	c.userFindService = userService
	return c
}

func (controller *UserFindController) Handler() gin.HandlerFunc {
	return func(c *gin.Context) {
		v := c.Param("user_id")
		if len(v) == 0 {
			c.Error(errors.New("user_id is required"))
			return
		}

		id, err := user.CreateIdFrom(v)
		if err != nil {
			c.Error(err)
			return
		}

		user, err := controller.userFindService.Find(id)
		if err != nil {
			c.Error(err)
			return
		}
		c.JSON(200, gin.H{
			"status": "ok",
			"user": gin.H{
				"id":   user.Id,
				"name": user.Name,
			},
		})
	}
}

そして、ルーティングの設定を管理してルーターを生成するrouter.goを定義します。
privateなendPoint型でHttpMethodPathCtlInitializer(Controllerを生成する関数)を定義して、
CreateRouter()でループを回してルーティングの設定をしています。

/src/router.go
package main

import (
	"advent-calendar/userinterface"
	"net/http"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

type endPoint struct {
	HttpMethod     string
	Path           string
	CtlInitializer func(db *gorm.DB) userinterface.Controller
}

var routings = []endPoint{
	{
		HttpMethod:     http.MethodPost,
		Path:           "/user/create",
		CtlInitializer: func(db *gorm.DB) userinterface.Controller { return InitializeUserCreateController(db) },
	},
	{
		HttpMethod:     http.MethodGet,
		Path:           "/user/:user_id",
		CtlInitializer: func(db *gorm.DB) userinterface.Controller { return InitializeUserFindController(db) },
	},
	{
		HttpMethod:     http.MethodGet,
		Path:           "/calendar/:calendar_id",
		CtlInitializer: func(db *gorm.DB) userinterface.Controller { return InitializeCalendarFindController(db) },
	},
	{
		HttpMethod:     http.MethodPost,
		Path:           "/calendar/create",
		CtlInitializer: func(db *gorm.DB) userinterface.Controller { return InitializeCalendarCreateController(db) },
	},
	{
		HttpMethod:     http.MethodGet,
		Path:           "/entries/calendar/:calendar_id",
		CtlInitializer: func(db *gorm.DB) userinterface.Controller { return InitializeCalendarEntryListFindController(db) },
	},
	{
		HttpMethod:     http.MethodGet,
		Path:           "/entries/user/:user_id",
		CtlInitializer: func(db *gorm.DB) userinterface.Controller { return InitializeUserEntryListFindController(db) },
	},
	{
		HttpMethod:     http.MethodPost,
		Path:           "/entry/create",
		CtlInitializer: func(db *gorm.DB) userinterface.Controller { return InitializeEntryCreateController(db) },
	},
}

func CreateRouter() *gin.Engine {
	r := gin.Default()
	for _, ep := range routings {
		ctlInitializer := ep.CtlInitializer
		handlerFunc := func(c *gin.Context) {
			db := c.MustGet("db").(*gorm.DB)
			(ctlInitializer(db).Handler())(c)
		}
		r.Handle(ep.HttpMethod, ep.Path, ApiTransactionManager, handlerFunc)
	}

	return r
}

router.goでルーターの設定まで終わっているので、main.goはとてもシンプルになります。

/src/main.go
package main

func main() {
	CreateRouter().Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

終わりに

「自分頑張った!」 と思う箇所を中心に説明させていただきましたが、いかがだったでしょうか。
実際はもう少しシンプルにできたら良いのにと思う部分が多く、
リファクタリングの余地もありまくりなので、(てかそもそもバグがあるので、)
しばらくコツコツとコードの修正をしていきたいと思います。
そして、ゆくゆくは今回得た知識を業務で生かせる場面を見つけられると良いなーと思っております。

明日は @rs_tukki さんの記事になります!お楽しみにdesu!
それでは、メリーーーー

6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?