[Golang1.8] Genericsを活用してClean Architectureなリポジトリを作る
もしクリーンアーキテクチャって何?と思った方は、これを読む前にググったり、以下の記事を読んでほしい。
導入
長く続いたGenericsがない時代、GolangでCAは無理もしくは向いていないという意見があった。というのも抽象的なインターフェースを定義するにはinterface{}型を使い、型チェックするようなコードを書くしかなかった。Golangは明示的な処理を書くのには向いていたが、抽象的な処理を書くには苦労が多かったように思う。とはいえ、Golangにはクラスベースオブジェクト指向言語が普通に備えているクラスはないが、強力な関数type宣言やduck typing的Interface宣言があった。あとGenericsさえあれば・・・という気がしていた。
そんなGolangでもようやくGenericsが使えるようになったので、Clean ArchitectureなRepositoryを書いてみた。目標は以下を達成すること。
domain/repository/serviceパッケージ間の依存関係を制御する
domainはrepositoryもserviceもaws-sdkも参照しない
repositoryは、domainとaws-sdkのみ参照可能
serviceは、domainのみ参照可能
なお、上記ルールをgolangci-ling上でdepguardを使ってチェックしている。
試行錯誤
└── cmd
└── main.go
└── internal
├── handler
| └──handler.go
├── domain
| └──task.go
├── repository
| └──task.go
└── service
└──task.go
Domain
扱いたいモデル(Task)と、それを永続化するリポジトリ上に生成(Create)する、および更新(Update)するだけのインターフェースを定義する。それ以外(DeleteやGetなど)は省略。
package domain
type Task struct {
TaskId string
Status string
}
type TaskRepository interface {
Create(ctx context.Context, task *Task) error
Update(ctx context.Context, taskId string, fields...???) error
}
さてここで問題。Updateの引数fieldsをどうするか。どの属性を更新したいか分からないのでoptionalな属性にしたいが、Golangにはnullがなく、かつpythonのようにデフォルト値を扱う事もできない。こういう場合golangではfunctional optionが推奨されている。
Functional Optionにも2つのタイプがある。まずこちらが古くから推奨されているオリジナルなFunctional Option。
package domain
type Task struct {
TaskId string
Status string
}
type TaskRepository[S any] interface {
Create(ctx context.Context, task *Task) error
Update(ctx context.Context, taskId string, fields...TaskOption[S]) error
}
// Functional Option
type TaskOption[S any] func(s *S)
で、こんな風に実装する。
func WithStatus( status string ) TaskOption[exp.UpdateBuilder] {
return func(b *UpdateBuilder) {
*b = b.Set(exp.Name("status"), exp.Value(status))
}
}
ただ、この方法ではUpdateに渡すfieldsの値が関数であるため、引数のテストが難しい難点がある。
次が、修正Functional Option。
package domain
type Task struct {
TaskId string
Status string
}
type TaskRepository[S any] interface {
Create(ctx context.Context, task *Task) error
Update(ctx context.Context, taskId string, fields...TaskField[S]) error
}
// Functional Option
type TaskField[S any] interface {
ApplyTask(s *S)
}
これは下記の記事で紹介されていたもの。引数を関数ではなく値自身とし、値にapply関数をぶら下げることで、引数のテストを可能にしたFunctional Optionだ。
しかしこれだけだと、ServiceがFunctionalOptionを作る方法に困る。
package service
import "hogehoge/internal/domain"
import "hogehoge/internal/repository" # 禁止!
type TaskService[S any] struct {
repo domain.TaskRepository[S]
}
func (s *TaskService) DoSomething(ctx context.Contet, ...) {
// ...
s.repo.Update(ctx, taskId, repository.WithStatus(status))
}
上記のようにServiceがrepositoryを参照せざるを得ない。これでは目標は達成できない。そこで・・・
package domain
type Task struct {
TaskId string
Status string
}
type TaskRepository[S any] interface {
Create(ctx context.Context, task *Task) error
Update(ctx context.Context, taskId string, fields...TaskField[S]) error
// Task#Update Functional Options
WithStatus(string) TaskField[S]
}
type TaskField[S any] interface {
ApplyTask(s *S)
}
TaskRepositoryに、自分自身のOptionを生成するメソッドをもたせることにした。Serviceはこうなる。
package hogehoge.service
import "hogehoge.domain"
type TaskService[S any] struct {
repo domain.TaskRepository[S]
}
func (s *TaskService) DoSomething(ctx context.Contet, ...) {
// ...
r := s.repo
r.Update(ctx, taskId, r.WithStatus(status))
}
こうしてServiceのコードからめでたくrepositoryへの参照が消えうせた。
Repository
次にRepositoryの実装。今回はデータベースとしてAWS dynamodbを、libraryとしてaws-sdk-go-v2を採用した。
このライブラリでは、dynamodbに対する更新クエリを作成するためのビルダー(expression.UpdateBuilder)が用意されているのでこれを利用した。(Update an item in a table参照)
domain層で出てきたSは、expression.UpdateBuilderになる。
package hogehoge.repository
import "hogehoge.domain"
import exp "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
import "github.com/aws/aws-sdk-go-v2/service/dynamodb"
type TaskRepository struct {
client *dynamodb.Client
table string
}
// func (r *TaskRepository) Get # 省略
func (r *TaskRepository) Update( ctx context.Context, taskId string,
fields...domain.TaskFields[exp.UpdateBuilder]) error {
b := &exp.UpdateBuilder{}
for _, f := range fields {
f.ApplyTask(b)
}
expr, _ := exp.NewBuilder().WithUpdate(*b).Build()
_, err = r.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{
TableName: &r.table,
Key: map[string]types.AttributeValue{
"taskId": &types.AttributeValueMemberS{
Value: taskId,
},
},
ExpressionAttributeNames: expr.Names(),
ExpressionAttributeValues: expr.Values(),
UpdateExpression: expr.Update(),
})
return err
}
func (r *TaskRepository) WithStatus(status string) {
return Status(status)
}
type Status string
func(s Status) ApplyTask(b *exp.UpdateBuilder) {
*b = b.Set(exp.Name("status"), exp.Value(s))
}
型パラメータSを用いることで、domain層からaws-sdkへの参照がなくなっている。
最終形
Domain
package hogehoge.domain
type Task struct {
TaskId string `dynamodbav:"taskId"`
Status string `dynamodbav:"status"`
}
type TaskRepository[S any] interface {
Create(ctx context.Context, task *Task) error
Update(ctx context.Context, taskId string, fields...TaskField[S]) error
WithStatus(status string) Taskfield[S]
}
// Functional Option
type TaskField[S any] interface {
ApplyTask(s *S)
}
Service
package hogehoge.service
import "hogehoge.domain"
type TaskService[S any] struct {
repo domain.TaskRepository[S]
}
func NewTaskService[S any](repo domain.TaskRepository) *TaskService {
return &TaskService[S]{repo: repo}
}
func (s *TaskService) DoSomething(ctx context.Contet, ...) {
// ...
r := s.repo
r.Update(ctx, taskId, s.repo.WithStatus(status))
}
Repository
package hogehoge.repository
import "hogehoge.domain"
import exp "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
import "github.com/aws/aws-sdk-go-v2/service/dynamodb"
type TaskRepository struct {
client *dynamodb.Client
table string
}
func NewTaskRepository(client *sqs.Clinet, table string) *TaskRepository {
return &TaskRepository{client: client, table: table}
}
// func (r *TaskRepository) Get # 省略
func (r *TaskRepository) Update( ctx context.Context, taskId string,
fields...domain.TaskFields[exp.UpdateBuilder]) error {
b := &exp.UpdateBuilder{}
for _, f := range fields {
f.ApplyTask(b)
}
expr, _ := exp.NewBuilder().WithUpdate(*b).Build()
_, err = r.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{
TableName: &r.table,
Key: map[string]types.AttributeValue{
"taskId": &types.AttributeValueMemberS{
Value: taskId,
},
},
ExpressionAttributeNames: expr.Names(),
ExpressionAttributeValues: expr.Values(),
UpdateExpression: expr.Update(),
})
return err
}
func (r *TaskRepository) WithStatus(status string) {
return Status(status)
}
type Status string
func(s Status) ApplyTask(b *exp.UpdateBuilder) {
*b = b.Set(exp.Name("status"), exp.Value(s))
}
main
package main
import "hogehoge.service"
import "hogehoge.repository"
taskRepo := repository.NewTaskRepository(dynamodbClient, tableName)
taskService := service.NewTaskService(taskRepo)
// ...
結論
目的であった、パッケージ間の依存関係の制御ができた
修正Functionalオプションを採用した。これにより、オプショナルな引数を実現しながら、テストで引数の検証ができるようになった
Genericsパラメータによって、repositoryの実装詳細がdomainに露出しないようにした
domain Repositoryインターフェースにfunctional optionの生成メソッドを付与することで、serviceがrepositoryの実装を知らなくてもfunctional optionを生成できるようにした
Golangだから腕力に頼るしかない、純オブジェクト指向言語じゃないから、といった部分をRepository周りからは一掃できたのではないか
付録:Genericsの制限
domain.TaskRepository[S any] のSだが、本当はdomain.TaskRepository[repository.TaskRepository]としたい。なぜかというと、expression.UpdateBuilderというパラメータが表に出てくるのが、repositoryを使う側にとって分かりにくいと感じるからだ。あくまでも使う側はrepository.TaskRepositoryを使いたいのであって、UpdateBuilderを使いたいわけではない。だがこれを宣言するには
type TaskRepository[S TaskRepository[S]] struct {
}
こうしないといけない。だが今のgolangのgenericsは、こういった型パラメータの循環は許していない。Javaは余裕なんですがね・・・
interface TaskRepository<T extends TaskRepository<T>> {
}
付録:依存性チェック
└── cmd
└── main.go
└── .golangci.yml
└── internal
├── handler
| └── handler.go
| └── .golangci.yml
├── domain
| └── task.go
| └── .golangci.yml
├── repository
| └── task.go
| └── .golangci.yml
└── service
| └── task.go
| └── .golangci.yml
└── .golangci.yml
└── .golangci.yml
今回はlinterとしてgolangci-ymlを使ったので、設定は.golangci.ymlに書いてている。ただし、depguardの設定を階層的に書く方法がわからなかったので、各々のディレクトリに設定ファイルを配置してフォルダごとにlintチェックをした。
依存性の制約は、レビューではチェックしきれない事が多いと感じる。ルール上だけの約束事は、必ず形骸化する。なのでもしアーキ上必要な依存性制約があるなら、linterで必ずチェックすることを薦める。
この記事が気に入ったらサポートをしてみませんか?