見出し画像

[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で必ずチェックすることを薦める。


この記事が気に入ったらサポートをしてみませんか?