こちらは ラクス 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)
アプリケーション仕様
- 以下の操作ができる
- ユーザ名を指定してユーザを登録する
- カレンダー名を指定してカレンダーを登録する
- ユーザが任意のカレンダーにエントリーする
- 特定のユーザがエントリー済みのカレンダーをすべて参照する
- 特定のカレンダーにエントリー済みのユーザをすべて参照する
設計
ドメインモデル
こちらです。(自分でテーマと仕様を決めておきながら答えが分からなかった・・・)
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しました。
個人的な工夫ポイントは以下のあたりです。
- リクエストスコープのトランザクションを実現する共通処理として
ApiTransactionManager
を追加 - google/wire を使ってControllerの生成関数をコンパイル時に作成
- すべての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しています。
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.RepositoryProviderSet
、application.ServiceProviderSet
は、
それぞれRepositoryとApplicationServiceを生成する関数(Provider関数と呼ばれる)のセットになっています。
wire.Build
にProvider関数をセットすることで、関数の戻り値の型となる関数を自動生成しています。
各ProviderSetはそれぞれ/src/infrastructure/wire.go
、/src/application/wire.go
に記述しています。
また、自分がハマった注意点なのですが、RepositoryはDomain層のinterfaceに依存しているため、
以下のようにwire.Bind
を使って実体を返却するProvider関数とinterfaceの型を紐づける必要があります。
//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を定義します。
package userinterface
import (
"github.com/gin-gonic/gin"
)
type Controller interface {
Handler() gin.HandlerFunc
}
このinterfaceを実装するように、各Controllerを定義していきます。
以下のUserFindController
もHandler()
メソッドを実装しています。
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
型でHttpMethod
、Path
、CtlInitializer
(Controllerを生成する関数)を定義して、
CreateRouter()
でループを回してルーティングの設定をしています。
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
はとてもシンプルになります。
package main
func main() {
CreateRouter().Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}
終わりに
「自分頑張った!」 と思う箇所を中心に説明させていただきましたが、いかがだったでしょうか。
実際はもう少しシンプルにできたら良いのにと思う部分が多く、
リファクタリングの余地もありまくりなので、(てかそもそもバグがあるので、)
しばらくコツコツとコードの修正をしていきたいと思います。
そして、ゆくゆくは今回得た知識を業務で生かせる場面を見つけられると良いなーと思っております。
明日は @rs_tukki さんの記事になります!お楽しみにdesu!
それでは、メリーーーー