この章で行うこと

  • クエリサービスを理解する
  • クエリサービスを実装する
  • テストを書く

クエリサービス

リポジトリと同じく、データ操作を行う役割を持ちますが、なぜわざわざ「クエリサービス」を用意するのでしょうか。それを理解するにあたって、CQRSから述べていきます。

CQRS(Commanad Query Responsibility Segregation)

コマンドクエリ責務分離と言って、参照系と更新系の処理をわける考え方です。

https://little-hands.hatenablog.com/entry/2019/12/02/cqrs

今回の実装では、リポジトリに更新系の処理を、クエリサービスに複雑な参照系の処理を、と言ったふうに分離させます。 (https://little-hands.hatenablog.com/entry/2019/12/02/cqrs より引用)

ここでいう「複雑な参照系」は、複数の集約を組み合わせた結果を返すような処理のことです。それ以外の単一の集約に限る参照系は単純なのでリポジトリが担うようにします。

次に、リポジトリで複数集約の参照を扱う例を考えながら、その「つらさ」を見ていきます。

リポジトリが複数集約の参照系を担う場合

リポジトリは、ユーザーリポジトリ・タスクリポジトリといったように、集約単位で用意しています。これは、集約単位の永続化により、データの整合性を保てるというメリットがあるからです。

しかし、複数集約が絡むと、パフォーマンスが悪化し、処理も冗長になったりとデメリットがあります。例を用いて考えます。

この後実装しますが、「タスク取得機能」を例としましょう。

取得結果には、以下を含めるものとします。

  • タスク内容
  • タスク状態
  • ユーザー名

idからタスクを取得するというものです。タスクを管理しているユーザーの情報も欲しいので、紐づくユーザー名も合わせて返すようにします。

この場合、「タスク集約」「ユーザー集約」の二つが絡んでいることがわかります。

リポジトリを使った場合、以下のような流れで結果を返すことになります。

  • タスクリポジトリから、idをキーとしてタスク(レコード)を取得
  • 上で得たタスクが持つuser_idをキーとして、ユーザーリポジトリからユーザー名を取得
  • 取得したタスクとユーザー名をオブジェクトに詰め替えて結果を返す

おそらく、以下のようなコードになるでしょう。

タスクリポジトリに FetchTaskById メソッドを実装した場合
func (tr *taskRepository)FetchTaskById(userRepository *userRepository, id int) (map[string]interface{}, error) {
	// タスクを取得
	t, _ := tr.FindById(id)
	// ユーザーを取得
	u, _ := userRepository.FindById(t.UserID)

    // オブジェクトに詰め替えて返す
}

しかしこれだと、SQLクエリが2回発生してしまっています。

  • usersテーブルにselect文(tr.FindById(id))
  • tasksテーブルにselect文(userRepository.FindById(t.UserID))

それに、タスクリポジトリがユーザーリポジトリを利用しているのも違和感です。集約単位で切っていたリポジトリの整合性が破綻してしまっています。

このように、リポジトリが複数集約を扱うのはアンチパターンと言えそうです。

CQRSを導入

そこで、CQRSを導入します。

リポジトリに代わり、「クエリサービス」オブジェクトにDBの参照を任せましょう。また、リード(参照)モデルとして、DTOを定義します。これは、FetchTaskById()が返す型となります。

タスククエリサービスに FetchTaskById() を実装した場合
func (tqs *taskQueryService) FetchTaskById(ctx context.Context, id string) (*taskUsecase.FetchTaskDTO, error) {
    // JOIN句を使ってクエリを実行
	r, _ := tqs.querier.FetchTaskById(ctx, id)

	// Stateをint→strにする必要があり、
	// State型のメソッドを使用するために再構成する
	t := taskDomain.ReconstructTask(
		r.ID,
		r.UserID,
		r.Content,
		int(r.State),
	)
	return &taskUsecase.FetchTaskDTO{
		ID:       t.GetID(),
		UserName: r.Name,
		UserId:   t.GetUserId(),
		Content:  t.GetContent().Value(),
		State:    t.GetState().StrValue(),
	}, nil
}

sqlcが生成した querier.FetchTaskById(ctx, id) の元になったSQLクエリは以下の通り。

-- name: FetchTaskById :one
select t.id,u.name,t.user_id,t.content,t.state
from tasks as t inner join users as u
on t.user_id = u.id
where t.id = sqlc.arg(id);

usersテーブル、tasksテーブルをJOINしてレコードを返すクエリを書いています。

これで、1回のクエリのみの実行となりパフォーマンスが向上しました。

このように、複数集約(複数テーブル)にまたがる参照系の処理は「クエリサービス」として実装し、部分的なCQRSの導入を行う方針でいきます。

クエリインターフェース

リポジトリのインターフェースはドメイン層に定義していました。

実はすでにクエリサービスのインターフェースを5章にて定義していますね。

// app/application/usecase/task/interface_task_queryservice.go
package task

import "context"

//go:generate mockgen -package=task -source=./interface_task_queryservice.go -destination=./mock_task_queryservice.go
type TaskQueryService interface {
	FetchTaskById(ctx context.Context, id string) (*FetchTaskDTO, error)
	FetchUserTasks(ctx context.Context, userId string) ([]*FetchTaskDTO, error)
	FetchAllTasks(ctx context.Context) ([]*FetchTaskDTO, error)
}

type FetchTaskDTO struct {
	ID       string
	UserName string
	UserId   string
	Content  string
	State    string
}

なぜユースケースに配置したか

ユースケースにインターフェースを定義しているのは、クエリサービスによって取得したい型などの要件がユースケースによって異なるからです。

これにより、各ユースケースごとに必要なデータの型や条件を定義できるようになります。

つまり、クエリサービスの結果はユースケースの要件に依存しており、ユースケースを中心にデータの流れを設計することで、柔軟かつ一貫した振る舞いを実現しているのです。

https://little-hands.hatenablog.com/entry/2019/12/02/cqrs#%E3%81%AA%E3%81%9CQueryService%E3%81%AE%E5%AE%9A%E7%BE%A9%E3%81%8CUseCase%E5%B1%A4%E3%81%AA%E3%81%AE%E3%81%8B

クエリサービスの実装

タスククエリサービスを実装します。app/adapter/queryserviceディレクトリを作成してください。

まずはリポジトリの時と同様、ormのインターフェースであるQuerierをクエリサービスの方にも定義しておきます。

interface_querier.go の実装
// app/adapter/queryservice/interface_querier.go
package queryservice

import "context"

// ORMはこのインターフェースを満たすように設計する
type Querier interface {
	FetchTaskById(ctx context.Context, id string) (FetchTaskByIdRow, error)
	FetchAllTasks(ctx context.Context) ([]FetchAllTasksRow, error)
	FetchUserTasks(ctx context.Context, userID string) ([]FetchUserTasksRow, error)
}

type FetchTaskByIdRow struct {
	ID      string
	Name    string
	UserID  string
	Content string
	State   int32
}

type FetchAllTasksRow struct {
	ID      string
	Name    string
	UserID  string
	Content string
	State   int32
}

type FetchUserTasksRow struct {
	ID      string
	Name    string
	UserID  string
	Content string
	State   int32
}

次にタスククエリサービスを実装しましょう。実装はリポジトリとほとんど変わりありません。

task_queryservice.go の実装
// app/adapter/queryservice/task_queryservice.go
package queryservice

import (
	"context"
	"database/sql"

	taskUsecase "github.com/kakkky/app/application/usecase/task"
	"github.com/kakkky/app/domain/errors"
	taskDomain "github.com/kakkky/app/domain/task"
)

type taskQueryService struct {
	querier Querier
}

func NewTaskQueryService(querier Querier) taskUsecase.TaskQueryService {
	return &taskQueryService{
		querier: querier,
	}
}

func (tqs *taskQueryService) FetchTaskById(ctx context.Context, id string) (*taskUsecase.FetchTaskDTO, error) {
	r, err := tqs.querier.FetchTaskById(ctx, id)
	if err != nil && errors.Is(err, sql.ErrNoRows) {
		return nil, errors.ErrNotFoundTask
	}
	if err != nil {
		return nil, err
	}
	// Stateをint→strにする必要があり、
	// State型のメソッドを使用するために再構成する
	t := taskDomain.ReconstructTask(
		r.ID,
		r.UserID,
		r.Content,
		int(r.State),
	)
	return &taskUsecase.FetchTaskDTO{
		ID:       t.GetID(),
		UserName: r.Name,
		UserId:   t.GetUserId(),
		Content:  t.GetContent().Value(),
		State:    t.GetState().StrValue(),
	}, nil

}

func (tqs *taskQueryService) FetchUserTasks(ctx context.Context, userId string) ([]*taskUsecase.FetchTaskDTO, error) {

	rs, err := tqs.querier.FetchUserTasks(ctx, userId)
	if err != nil {
		return nil, err
	}
	dtos := make([]*taskUsecase.FetchTaskDTO, 0, len(rs))
	for _, r := range rs {
		t := taskDomain.ReconstructTask(
			r.ID,
			r.UserID,
			r.Content,
			int(r.State),
		)
		dtos = append(dtos, &taskUsecase.FetchTaskDTO{
			ID:       t.GetID(),
			UserName: r.Name,
			UserId:   t.GetUserId(),
			Content:  t.GetContent().Value(),
			State:    t.GetState().StrValue(),
		})
	}
	return dtos, nil
}

func (tqs *taskQueryService) FetchAllTasks(ctx context.Context) ([]*taskUsecase.FetchTaskDTO, error) {
	rs, err := tqs.querier.FetchAllTasks(ctx)
	if err != nil {
		return nil, err
	}
	dtos := make([]*taskUsecase.FetchTaskDTO, 0, len(rs))
	for _, r := range rs {
		t := taskDomain.ReconstructTask(
			r.ID,
			r.UserID,
			r.Content,
			int(r.State),
		)
		dtos = append(dtos, &taskUsecase.FetchTaskDTO{
			ID:       t.GetID(),
			UserName: r.Name,
			UserId:   t.GetUserId(),
			Content:  t.GetContent().Value(),
			State:    t.GetState().StrValue(),
		})
	}
	return dtos, nil
}

定義したインターフェースに合わせてsqlcの具体実装も追加しましょう。

sqlc_querier.go に追加するコード
// app/infrastructure/db/sqlc/sqlc_querier.go

// 追加

func (sq *SqlcQuerier) FetchTaskById(ctx context.Context, id string) (queryservice.FetchTaskByIdRow, error) {
	row, err := sq.queries.FetchTaskById(ctx, id)
	return queryservice.FetchTaskByIdRow(row), err
}

func (sq *SqlcQuerier) FetchAllTasks(ctx context.Context) ([]queryservice.FetchAllTasksRow, error) {
	rows, err := sq.queries.FetchAllTasks(ctx)
	var r []queryservice.FetchAllTasksRow
	for _, row := range rows {
		r = append(r, queryservice.FetchAllTasksRow(row))
	}
	return r, err
}

func (sq *SqlcQuerier) FetchUserTasks(ctx context.Context, userID string) ([]queryservice.FetchUserTasksRow, error) {
	rows, err := sq.queries.FetchUserTasks(ctx, userID)
	var r []queryservice.FetchUserTasksRow
	for _, row := range rows {
		r = append(r, queryservice.FetchUserTasksRow(row))
	}
	return r, err
}

テスト

これもリポジトリの時とそう変わりません。app/infrastructure/queryservice_testディレクトリを作成してください。

TestMainを記述し、コンテナやDB接続処理を実装します。

queryservice_test.go の実装
// app/infrastructure/queryservice_test/queryservice_test.go
package queryservice

import (
	"log"
	"testing"

	"github.com/kakkky/app/infrastructure/db"
	"github.com/kakkky/app/infrastructure/db/container"
)

func TestMain(m *testing.M) {
	//dockertestコンテナを起動
	pool, resource := container.NewDockertestContainer()
	log.Println("success to start dockertest container")
	defer func() {
		container.RemoveDockertestContainer(pool, resource)
		log.Println("success to remove dockertest container")
	}()
	// DBに接続
	testDB := container.NewDB(pool, resource)
	log.Println("success to connect test-db")
	defer testDB.Close()
	// マイグレーションを適用させる
	container.SetupDB()
	log.Println("success to apply migrations")
	// dbパッケージ変数にテスト用DBをセット
	db.SetDB(testDB)
	log.Println("dockertest & test-db settings complete")

	m.Run()
}

タスククエリサービスのテストを記述します。

task_queryservice_test.go の実装
// app/infrastructure/queryservice_test/task_queryservice_test.go
package queryservice

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/adapter/queryservice"
	"github.com/kakkky/app/application/usecase/task"
	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/infrastructure/db/sqlc"
	"github.com/kakkky/app/infrastructure/db/testhelper"
)

func TestTaskQueryService_FetchTaskById(t *testing.T) {
	taskQueryService := queryservice.NewTaskQueryService(sqlc.NewSqlcQuerier())
	type args struct {
		id string
	}
	tests := []struct {
		name    string
		args    args
		want    *task.FetchTaskDTO
		errType error
		wantErr bool
	}{
		{
			name: "正常系",
			args: args{
				id: "1",
			},
			want: &task.FetchTaskDTO{
				ID:       "1",
				UserName: "user1",
				UserId:   "1",
				Content:  "content",
				State:    "todo",
			},
			wantErr: false,
		},
		{
			name: "準正常系:指定idのタスクが見つからない",
			args: args{
				id: "invalid",
			},
			errType: errors.ErrNotFoundTask,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			testhelper.SetupFixtures(t,"testdata/fixtures/users.yml", "testdata/fixtures/tasks.yml")
			ctx := context.Background()
			got, err := taskQueryService.FetchTaskById(ctx, tt.args.id)
			if (err != nil) != tt.wantErr && tt.errType != nil && errors.Is(err, tt.errType) {
				t.Errorf("taskQueryService.FetchTaskById = error %v, want errType %v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("taskQueryService.FetchTaskById = error %v, wantErr %v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("taskQueryService.FetchTaskById() -got,+want : %v", diff)
			}
		})
	}
}

func TestTaskQueryService_FetchUserTasks(t *testing.T) {
	taskQueryService := queryservice.NewTaskQueryService(sqlc.NewSqlcQuerier())
	type args struct {
		user_id string
	}
	tests := []struct {
		name    string
		args    args
		want    []*task.FetchTaskDTO
		wantErr bool
	}{
		{
			name: "正常系",
			args: args{
				user_id: "1",
			},
			want: []*task.FetchTaskDTO{
				{
					ID:       "1",
					UserName: "user1",
					UserId:   "1",
					Content:  "content",
					State:    "todo",
				},
				{
					ID:       "2",
					UserName: "user1",
					UserId:   "1",
					Content:  "content",
					State:    "todo",
				},
				{
					ID:       "3",
					UserName: "user1",
					UserId:   "1",
					Content:  "content",
					State:    "doing",
				},
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			testhelper.SetupFixtures(t,"testdata/fixtures/users.yml", "testdata/fixtures/tasks.yml")
			ctx := context.Background()
			got, err := taskQueryService.FetchUserTasks(ctx, tt.args.user_id)
			if (err != nil) != tt.wantErr {
				t.Errorf("taskQueryService.FetchUserTasks = error %v, wantErr %v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("taskQueryService.FetchUserTasks) -got,+want : %v", diff)
			}
		})
	}
}

func TestTaskQueryService_FetchTasks(t *testing.T) {
	taskQueryService := queryservice.NewTaskQueryService(sqlc.NewSqlcQuerier())
	tests := []struct {
		name    string
		want    []*task.FetchTaskDTO
		wantErr bool
	}{
		{
			name: "正常系",
			want: []*task.FetchTaskDTO{
				{
					ID:       "1",
					UserName: "user1",
					UserId:   "1",
					Content:  "content",
					State:    "todo",
				},
				{
					ID:       "2",
					UserName: "user1",
					UserId:   "1",
					Content:  "content",
					State:    "todo",
				},
				{
					ID:       "3",
					UserName: "user1",
					UserId:   "1",
					Content:  "content",
					State:    "doing",
				},
				{
					ID:       "4",
					UserName: "user2",
					UserId:   "2",
					Content:  "content",
					State:    "doing",
				},
				{
					ID:       "5",
					UserName: "user2",
					UserId:   "2",
					Content:  "content",
					State:    "done",
				},
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			testhelper.SetupFixtures(t,"testdata/fixtures/users.yml", "testdata/fixtures/tasks.yml")
			ctx := context.Background()
			got, err := taskQueryService.FetchAllTasks(ctx)
			if (err != nil) != tt.wantErr {
				t.Errorf("taskQueryService.FetchUserTasks = error %v, wantErr %v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("taskQueryService.FetchUserTasks) -got,+want : %v", diff)
			}
		})
	}
}

fixtureデータも用意しておきましょう。

testfixtureファイル
# app/infrastructure/queryservice_test/testdata/fixtures/tasks.yml
- id: 1
  user_id: 1
  content: content
  state: 0

- id: 2
  user_id: 1
  content: content
  state: 0

- id: 3
  user_id: 1
  content: content
  state: 1

- id: 4
  user_id: 2
  content: content
  state: 1

- id: 5
  user_id: 2
  content: content
  state: 2

# app/infrastructure/queryservice_test/testdata/fixtures/users.yml
- id: 1
  name: user1
  email: [email protected]
  hashed_password: password

- id: 2
  name: user2
  email: [email protected]
  hashed_password: password

- id: 3
  name: user3
  email: [email protected]
  hashed_password: password

実装が終わったら、クエリサービスのテストを回しておいてパスするかを確かめましょう。

make test-query

現段階のディレクトリ

.
├── .github
│   └── workflows
│       └── test.yml
├── .gitignore
├── Dockerfile.app
├── Makefile
├── app
│   ├── .air.toml
│   ├── adapter
│   │   ├── queryservice
│   │   │   ├── interface_querier.go
│   │   │   └── task_queryservice.go
│   │   └── repository
│   │       ├── interface_querier.go
│   │       ├── task_repository.go
│   │       └── user_repository.go
│   ├── application
│   │   └── usecase
│   │       ├── task
│   │       │   ├── create_task_usecase.go
│   │       │   ├── create_task_usecase_dto.go
│   │       │   ├── create_task_usecase_test.go
│   │       │   ├── delete_task_usecase.go
│   │       │   ├── delete_task_usecase_dto.go
│   │       │   ├── delete_task_usecase_test.go
│   │       │   ├── fetch_task_usecase.go
│   │       │   ├── fetch_task_usecase_dto.go
│   │       │   ├── fetch_task_usecase_test.go
│   │       │   ├── fetch_tasks_usecase.go
│   │       │   ├── fetch_tasks_usecase_test.go
│   │       │   ├── fetch_user_tasks_usecase.go
│   │       │   ├── fetch_user_tasks_usecase_dto.go
│   │       │   ├── fetch_user_tasks_usecase_test.go
│   │       │   ├── interface_task_query_service.go
│   │       │   ├── mock_task_query_service.go
│   │       │   ├── update_task_state_usecase.go
│   │       │   ├── update_task_state_usecase_dto.go
│   │       │   └── update_task_state_usecase_test.go
│   │       └── user
│   │           ├── fetch_users_usecase.go
│   │           ├── fetch_users_usecase_dto.go
│   │           ├── fetch_users_usecase_test.go
│   │           ├── register_usecase.go
│   │           ├── register_usecase_dto.go
│   │           ├── register_usecase_test.go
│   │           ├── unregister_usecase.go
│   │           ├── unregister_usecase_dto.go
│   │           ├── unregister_usecase_test.go
│   │           ├── update_profile_usecase.go
│   │           ├── update_profile_usecase_dto.go
│   │           └── update_profile_usecase_test.go
│   ├── config
│   │   ├── config.go
│   │   └── config_test.go
│   ├── domain
│   │   ├── errors
│   │   │   ├── errors.go
│   │   │   └── errors_test.go
│   │   ├── task
│   │   │   ├── interface_task_repository.go
│   │   │   ├── mock_task_repository.go
│   │   │   ├── task.go
│   │   │   ├── task_content.go
│   │   │   ├── task_state.go
│   │   │   └── task_test.go
│   │   └── user
│   │       ├── interface_user_domain_service.go
│   │       ├── interface_user_repository.go
│   │       ├── mock_user_domain_service.go
│   │       ├── mock_user_repository.go
│   │       ├── user.go
│   │       ├── user_domain_service.go
│   │       ├── user_domain_service_test.go
│   │       ├── user_email.go
│   │       ├── user_hashed_password.go
│   │       ├── user_test.go
│   │       └── users.go
│   ├── go.mod
│   ├── go.sum
│   ├── infrastructure
│   │   ├── db
│   │   │   ├── connect.go
│   │   │   ├── container
│   │   │   │   ├── db.go
│   │   │   │   └── dockertest.go
│   │   │   ├── migrations
│   │   │   │   ├── 000001_create_users_table.down.sql
│   │   │   │   ├── 000001_create_users_table.up.sql
│   │   │   │   ├── 000002_create_tasks_table.down.sql
│   │   │   │   └── 000002_create_tasks_table.up.sql
│   │   │   ├── mysql
│   │   │   │   └── conf.d
│   │   │   │       └── my.conf
│   │   │   ├── sqlc
│   │   │   │   ├── db.go
│   │   │   │   ├── models.go
│   │   │   │   ├── queries
│   │   │   │   │   ├── tasks.sql
│   │   │   │   │   └── users.sql
│   │   │   │   ├── sqlc.yml
│   │   │   │   ├── sqlc_querier.go
│   │   │   │   ├── tasks.sql.go
│   │   │   │   └── users.sql.go
│   │   │   └── testhelper
│   │   │       └── fixture.go
│   │   ├── kvs
│   │   │   └── redis_client.go
│   │   ├── queryservice_test
│   │   │   ├── queryservice_test.go
│   │   │   ├── task_queryservice_test.go
│   │   │   └── testdata
│   │   │       └── fixtures
│   │   │           ├── tasks.yml
│   │   │           └── users.yml
│   │   └── repository_test
│   │       ├── repository_test.go
│   │       ├── task_repository_test.go
│   │       ├── testdata
│   │       │   └── fixtures
│   │       │       ├── tasks.yml
│   │       │       └── users.yml
│   │       └── user_repository_test.go
│   ├── main.go
│   └── tmp
│       ├── build-errors.log
│       └── main
├── compose.yml
├── go.work
├── go.work.sum
└── pkg
    ├── go.mod
    ├── go.sum
    ├── hash
    │   ├── hash.go
    │   └── hash_test.go
    └── ulid
        ├── ulid.go
        └── ulid_test.go