Goのベタープラクティスを追い求める

Goのpackage構成と開発のベタープラクティス

(images: github.com/egonelbre/gophers)

こんにちは。 データエンジニアリンググループ(CETチーム)の寺下です。 自分の所属するCETチームでは今まで主にScala、Pythonなどを使ってAPIや基盤を実装してきましたが、最近では徐々にGoによる実装も増えてきており、GAE/GKE上で本番運用を行っています。

本記事ではGoのプロダクトにおいてDDDライクなpackage構成で実装する際の注意点や、汎用的に通用するであろう実装のTipsについて書いていきます。 本記事で紹介する例がベストプラクティスだというわけではありませんので、あくまで実装の一例程度に捉えて頂けると幸いです。

gopher

Goのアーキテクチャ

Goは言語仕様がシンプルかつフォーマッタが強力なため、syntaxレベルでは開発者によってコードの品質がブレにくいというメリットがあります。 しかしながら「どのようにpackageを切るか」「どのライブラリ・フレームワークを使えば良いか」等についてはデファクトスタンダードと言えるものがない状況であり、開発者間でも意見が分かれるところです。 また、プロダクトの規模や性質によっても最適な構成は異なります。

自分が開発する際はWebフレームワークは使わず標準の net/http を利用することが多いです。理由としては機能が必要十分に提供されており、細かなカスタマイズがしやすいためです。

package構成のパターンについてはgopherの中でもこれまで議論がなされてきており、以下のブログや資料にいくつかの例がまとまっています。 実際の開発の際にも参考にさせて頂きました。

自分の関わったプロダクトでも上記の資料で挙げられているようなDDD(Domain-driven design: ドメイン駆動設計)ライクなpackage構成を採用し、Layered Architectureを意識した開発を行っています。 ただし、あくまで戦術的にLayered Architectureを採用しているだけで、本格的なDDDを行っているわけではありません。

package構成

現在開発しているprojectのpackage構成は以下のようになっています。 あまり重要でないものは省略しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.
├── application
│   └── usecase
│       ├── xxx_usecase.go
│       └── yyy_usecase.go
├── domain
│   ├── model
│   ├── repository
│   │   └── xxx_repository.go
│   └── service
├── infrastructure
│   └── persistence
│       └── datastore
│           └── xxx_repository.go
├── interfaces
│   └── api
│       └── server
│           ├── auth
│           ├── handler
│           │   ├── app_handler.go
│           │   ├── xxx_handler.go
│           │   └── yyy_handler.go
│           ├── httputils
│           ├── middleware
│           └── router
└── registry
    └── registry.go

それぞれのpackageの責務について簡単に説明します。

interfaces

interfaces はLayered ArchitectureにおけるUI(Presentation)層です。Goの言語仕様である interface とは別のものです。 外部からの入力を受け取り、情報を出力する責務を担います。 入出力のプロトコルはhttpであったり標準入出力であったり複数のパターンが考えられるため、それぞれに対応したsubpackageを切ります。 http とすると net/http とpackage名が被ってしまい混乱を生むため http ではなく api というsubpackage名にしています。 UI層ではこれらの入出力の差異を吸収し、application層で定義されたuse caseを実行します。

application

application はLayered Architectureにおけるapplication層です。 domain層が提供しているビジネスロジックを呼び出し、use caseに合わせてタスクを調整する責務を担います。 application直下の usecase packageにはいわゆるapplication serviceを配置します。 service にしてしまうと domain service なのか application service なのか分かりづらいという問題があるため、Clean Architectureで使われる usecase という名前にしています。 1つのuse caseは1つの要求(APIであれば1つのエンドポイント)に対応させ、基本的に再利用しません。

domain

domain はLayered Architectureにおけるdomain層です。 ビジネスロジックとそれに伴う概念を全てここに配置します。 具体的にはdomain service, repository, entity, value objectなどが含まれます。

infrastructure

infrastructure はLayered Architectureにおけるinfrastructure層です。 永続化、メッセージ送信などの技術的機能を提供します。 DIP(依存関係逆転の原則)を適用するため、repository等の実装詳細をここで定義します。

DIPについてはググるとたくさん出てくるので詳細な説明は割愛しますが、以下の原則です。

上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも「抽象」に依存すべきである。
「抽象」は実装の詳細に依存してはならない。実装の詳細が「抽象」に依存すべきである。

“実装の詳細が「抽象」に依存” というところが一番重要で、Goでは抽象は interface で表現できます。

registry

registry は最終的にDIを解決するためのオブジェクト生成処理のルールを記述します。 簡易的なDIコンテナだと思っていただければ大丈夫です。

実装

domain -> infrastructure -> application -> interfaces -> registry の順で説明します。 なお、実際にはGAEのprojectを扱っているため所々でGAEに依存したコードが登場する可能性があります。

domain

domainにはservice, model, repositoryを定義します。 domain層にコアロジックを記述する際、可能な限りロジックはserviceではなくmodelに寄せることが重要です。 理由としては、DDDの文脈で指摘されているようなドメインモデル貧血症を引き起こしてしまうためです。

repositoryに関してはDIPでinfrastructure層に実装を定義するため、interfaceのみを定義します。

1
2
3
4
5
package repository

type UserRepository interface {
    FindOne(ctx context.Context, userID int) (*model.User, error)
}

repositoryのメソッドの第一引数にcontextを渡す点がGo特有の実装になります。 repositoryの中にcontextを埋め込んでしまうのは推奨されていません。

context - The Go Programming Language

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx:

repositoryをinterfaceで定義するというのは有名ですが、同様にserviceもinterfaceのみを定義します。

1
2
3
4
5
package service

type XxxService interface {
    DoSomething(ctx context.Context, foo int) (error)
}

メッセージ送信など、要素技術に依存してしまう実装はinfrastructureに書いた方が良いと思います。 しかしdomain層の補助的なserviceで、要素技術に依存せず実装の差し替えが不要な場合は同一ファイル内にそのまま実装を定義してしまっても大きな問題はありません。 同一ファイル内に実装する場合はinterface名の先頭を小文字にしたprivateな構造体とpublicなコンストラクタを作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package service

type XxxService interface {
    DoSomething(ctx context.Context, foo int) (error)
}

type xxxService struct {}

func NewXxxService() XxxService {
    return &xxxService{}
}

func (x *xxxService) DoSomething(ctx context.Context, foo int) error {
    // some code
}

上記の例のように interface を公開することで、interfaceと実装を分離することができます。 これによってdomain層を利用するapplication層がdomainの抽象に依存できるようになり、レイヤー間が疎結合になります。 xxxService の構造体はあくまでデフォルトの実装に過ぎず、他のレイヤ(application層)のテスト時には XxxService の interface を満たすmockを利用することができます。 interface を返すコンストラクタ は実装の詳細になってしまうため他のレイヤから直接利用せず、後述するregistryから呼び出します。

また、modelに関してもinterfaceのみを公開するのが理想ですが、実装コストの観点から直接構造体を定義してしまうパターンが多いです。

infrastructure

infrastructureではrepository interface等の実装を行います。 永続化の実装方式に応じて persistence/datastore, persistence/inmem のようにsubpackageを切っています。

実装は以下のようになります。NewUserRepository の戻り値の型をdomain層で定義したinterfaceにすることだけ注意して下さい。

1
2
3
4
5
6
7
8
9
10
11
package datastore

type UserRepository struct {}

func NewUserRepository() repository.UserRepository {
    return &UserRepository{}
}

func (r *UserRepository) FindOne(ctx context.Context, userID int) (*model.User, error) {
    // some code
}

application

application層ではuse case(またはapplication service)を定義します。 use caseは入力を受け取りdomain層で定義されたロジックを呼び出して結果を返すだけの薄いレイヤです。 今までと同様にuse caseもinterfaceで定義を公開します。これによってhandlerのテストが容易になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package usecase

type UserUseCase interface {
    GetUserList(ctx context.Context) (*model.Users, error)
    GetUserDetails(ctx context.Context, id int) (*model.User, error)
}

type userUseCase struct {
    repository.UserRepository
}

func NewUserUseCase(r repository.UserRepository) UserUseCase {
    return &userUseCase{r}
}

func (u *userUseCase) GetUserList(ctx context.Context) (*model.Users, error) {
    // some code
}
func (u *userUseCase) GetUserDetails(ctx context.Context, id int) (*model.User, error) {
    // some code
}

実装時の注意点としては、http.ResponseWriter*http.Request を参照しないようにし、interfacesと分離することです。 また、上記の例ではuse caseの入出力の型をdomain層に依存させてしまっていますが、application層で入出力専用の型を定義するとレイヤ間の結合をより小さくすることができます。 ただし、構造体の詰め替えコストが増えてしまうという欠点もあるため、状況に応じて使い分けられると良いです。

interfaces

interfacesにはrouter, handler, adapter, middlewareなどを実装します。 この中でhandlerの実装のみがuse caseに依存するので、use caseのinterfaceを満たすmockを作ることでhandlerは独立してテストが記述できます。 handlerは以下のように実装しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package handler

type WarmupHandler interface {
    Warmup(ctx context.Context, w http.ResponseWriter, r *http.Request) error
}

type warmupHandler struct {
    u usecase.WarmupUseCase
}

func NewWarmupHandler(u usecase.WarmupUseCase) WarmupHandler {
    return &warmupHandler{u}
}

func (s *warmupHandler) Warmup(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
    // some code
}

func(ctx context.Context, w http.ResponseWriter, r *http.Request) error は独自定義のhandler型で、contextを受け取ってerrorを返せるようにしています。 handlerは以下のように httputils packageに定義しています。

1
2
3
package httputils

type AppHandlerFunc func(ctx context.Context, w http.ResponseWriter, r *http.Request) error

次にrouterの実装についてです。routerを作成するためには定義したhandlerを全て利用することになります。 そのため、全てのhandlerのinterfaceを満たすAppHandlerを作成してrouterの実装から依存させるようにすると、router単体のテストが楽に行えるようになります。 AppHandler は以下のようにinterfaceにinterfaceを埋め込む形で定義します。

1
2
3
4
5
6
7
8
package handler

type AppHandler interface {
    XxxHandler
    YyyHandler
    ZzzHandler
    // embed all handler interfaces
}

registry

最後に、interfaceと実装を結びつけるために簡易DIコンテナとして registry を利用します。 registryにはオブジェクトの生成処理を全て記述し、依存を解決させます。 ここはDIコンテナのライブラリなども利用できますが、筋肉だけでも何とかなります。

最終的にはrouterの実装が依存する AppHandler の実装が生成できればOKです。

1
2
3
4
5
6
7
8
9
10
package registry

type Registry interface {
    NewUserRepository() repository.UserRepository
    NewUserUseCase() usecase.UserUseCase
    // ...
    NewAppHandler() http.Handler
}

// 実装は省略

あとは main でregistry自体を生成してあげれば必要なオブジェクトが全て揃います。

まとめ

戦術的DDDライクなpackage構成を採用した場合の実装について簡単に紹介しました。 単純にレイヤを切り分けるだけでは不十分で、本記事で紹介したように interface を適切に利用して抽象を公開することが重要です。

最後になりますが、リクルートライフスタイルでは一緒に働けるGopherを募集しています ʕ◔ϖ◔ʔ۶

寺下 翔太

(データエンジニアリングチーム)

VimとEDMが好きです

NEXT