Open55

golangで認証機能を搭載したtodoアプリAPIを実装する

kakkkykakkky

ハンズオンの2周目を、カスタマイズしつつ開発する

以下の書籍を数ヶ月前に一度ハンズオンとして行いましたが、その後色々な知見を得る中で、「自分ならこうしたい」を模索しつつ改めて開発してみようと思います。

https://book.mynavi.jp/manatee/c-r/books/detail/id=131170

機能

概要

認証付きのTODOタスクを管理するAPIサーバー

メソッド パス 概要
POST /user ユーザー登録
GET /user 登録されているユーザー表示
DELETE /user ユーザー削除
POST /tasks タスク登録
GET /tasks タスク表示
PUT /task/{id} タスク状態を変更(done)
DELETE /task/{id} タスク削除
POST /auth/login ログイン
DELETE /auth/logout ログアウト

ディレクトリ構成から考えていく

こんなものでいいでしょうか...

.
├── Dockerfile                # Dockerコンテナのビルドを定義するファイル
├── Makefile                  # プロジェクトのビルド・管理タスクを定義するMakefile
├── app
│   ├── adapter
│   │   ├── presentation      # プレゼンテーション層 (HTTPリクエストやレスポンスの処理)
│   │   │   └── middleware    # HTTPミドルウェア (認証、ロギングなど)
│   │   └── repository        # リポジトリ層 (データアクセスの抽象化)
│   ├── application
│   │   └── usecase           # ユースケース層 (ビジネスロジック)
│   ├── cmd
│   │   └── main.go           # エントリーポイントとなるGoのメイン関数
│   ├── domain
│   │   ├── task              # ドメイン層 (エンティティや値オブジェクト)
│   │   └── user              # ユーザー関連のドメインロジック
│   └── infrastructure
│       ├── auth              # 認証関連の処理 (JWTなど)
│       │   └── jwt           # JWTの生成や検証の実装
│       ├── db                # データベース接続やマイグレーション
│       ├── kvs               # キーバリューストア関連 (例えば、Redisなど)
│       ├── router            # ルーティングの設定
│       └── server            # サーバー設定 (HTTPサーバー、エンドポイントの設定など)
│           └── pkg           # サーバーに関連するパッケージ
├── cli
│   ├── main.go               # CLIアプリケーションのエントリーポイント
│   └── migration.go          # マイグレーション用のGoスクリプト
└── compose.yml               # Docker Composeの設定ファイル

迷ったのはauthの配置です。ドメインとは少し外れる気がします。
今回はjwtを使うし、認証関連を自前で実装することはまずないと思うので、外部ライブラリに依存するinfrastructure(framework&driver)層におくことにしました。
ユースケースとして、ログイン・ログアウト・認可を用意して、ログイン・ログアウトはハンドラ、認可をミドルウェアとしてプレゼンテーション層で利用します。

kakkkykakkky

開発環境を整える

使用技術選定

  • データベース : MySQL
  • キーバリューストア : Redis (認証で使用)
  • webサーバー : net/http (標準)
  • ORM : sqlc

webサーバーに、フレームワーク/ルーティングライブラリを導入しない理由は、以下の記事にて。

https://zenn.dev/yuta_kakiki/articles/768f2ff1fa38c1

sqlcを選んだのは、最終的に実行されるSQLが明らかであることが理由としてあります。
railsのActive Recordなどでは、「これってどんなクエリが実行されたんだろう?」と気になり、若干不透明な部分があります。(ログを見てわかる)
sqlcは、SQLを書いて、それをコンパイルしてGoのコードにしてくれます。非常にシンプルです。

https://docs.sqlc.dev/en/latest/index.html

docker

本番環境にアップするならマルチステージビルドを設定したりするのが理想ですがあくまで開発環境でしか扱わないため、今回は実装しません。
開発用コンテナに便利な、ホットリロードを実現するairを導入します。
https://qiita.com/urakawa_jinsei/items/44b27d2c23d1c8bb68d7
以下がdockerfileになります。

FROM golang:1.23.1

WORKDIR /src
# ワーキングディレクトリにソースコードをコピー
COPY ./ ./ 

# github.com/kakkky/pkgの依存関係をダウンロード
WORKDIR /src/pkg
RUN go mod download

# github.com/kakkky/appの依存関係をダウンロード
WORKDIR /src/app
RUN go mod download

# 必要なツールをインストール
WORKDIR /src
RUN go install github.com/air-verse/air@latest
RUN go install go.uber.org/mock/mockgen@v0.3.0
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.23.0

# airを起動
WORKDIR /src
CMD ["air"]

db、redisなしの状態のdockercomposeファイルは以下です。

services:
  api:
    build:
      dockerfile: ./Dockerfile
      context: .
    ports:
      - "8080:8080"
    volumes:
      - type: bind
        source: ./
        target: /src #コンテナ内の指定のディレクトリにマウントされる
    tty: true

golangでプログラムを実行するgo run main.goなどは、内部でビルドしたバイナリファイルを実行しているので、変更を反映したいときはその都度、go runしなければなりません。
そこで、ホットリロードを使用すれば、Airがプロジェクト全体を監視してくれているので変更時に自動でビルド〜実行を行ってくれるのです。

api-1  | building...
api-1  | running...
api-1  | app/cmd/main.go has changed   //変更を感知
api-1  | building... //再ビルド
api-1  | running... //実行

air initコマンドで生成される設定ファイル(.air.toml)のこの部分だけ書き換えています。
エントリーポイントを合わせているだけです。

[build]
.
.
  cmd = "go build -o ./tmp/main ./app/cmd/main.go"
.
.

はて?
go modがない?

todo-api  | building...
todo-api  | app/cmd/main.go:6:2: no required module provides package github.com/go-sql-driver/mysql: go.mod file not found in current directory or any parent directory; see 'go help modules'
todo-api  | failed to build, error: exit status 1

解決策

app/cmd/main.goとしていたが、そうするとうまくいかなかったので、app/main.goに配置。
airの監視もコンテナ内にコピーしたapp/ディレクトリ配下としました。

FROM golang:1.23.1

WORKDIR /src

COPY ./ ./

WORKDIR /src/app
RUN go mod download

# 必要なツールをインストール
WORKDIR /src
RUN go install github.com/air-verse/air@latest
RUN go install go.uber.org/mock/mockgen@v0.3.0
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.23.0

# airを起動
WORKDIR /src/app
CMD ["air"]
.air.toml
[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ./main.go"

この後、Makefileやdbのセットアップを行います

kakkkykakkky

開発環境を整える - DB編

データベースにはMySQLを使用し、マイグレーションにはgo-migrate というツールを新たに導入してみます。
go-migrateに関しては、ローカルでbrewでダウンロードしてもいいのですが、今回はdocker上ですべて完結させたいと思います。デメリットとして、コンテナが若干重くなるのがあります。

go installでできるんですね。brewでするしかないのかなと思っていました。
https://github.com/golang-migrate/migrate/blob/master/cmd/migrate/README.md

Dockerfile.api
FROM golang:1.23.1

WORKDIR /src

COPY ./ ./

WORKDIR /src/app

RUN go mod download

# 必要なツールをインストール

RUN go install github.com/air-verse/air@latest
RUN go install go.uber.org/mock/mockgen@v0.3.0
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.23.0
# 追加
RUN go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

# airを起動
CMD ["air"]
compose.yml
services:
  api:
    container_name: todo-api
    build:
      dockerfile: ./Dockerfile.api
      context: .
    volumes:
      - type: bind
        source: ./
        target: /src
    ports:
      - "8080:8080"
    tty: true
    environment:
      TODO_DB_HOST: db
      TODO_DB_PORT: 3306
      TODO_DB_USER: user
      TODO_DB_PASSWORD: pswd
      TODO_DB_NAME: todo-db
    depends_on:
      - db
  db:
    container_name: todo-db
    image: mysql:8.0.40-debian
    platform: linux/amd64
    volumes:
      - type: volume
        source: db-store
        target: /var/lib/mysql
      - type: bind
        source: ./app/infrastructure/db/mysql
        target: /etc/mysql/conf.d:cached
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: todo-db
      MYSQL_USER: user
      MYSQL_PASSWORD: pswd
      MYSQL_ALLOW_EMPTY_PASSWORD: true
    tty: true
volumes:
  db-store:

動作確認

DB

マイグレーションで簡単なテーブルを作成し、読み込めるかを確認します。
compose upすると、、

todo-db   | 2024-12-03 13:27:14+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
todo-db   | 2024-12-03 13:27:14+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.40-1debian12 started.
todo-db   | 2024-12-03T13:27:16.182055Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.40) starting as process 1
todo-db   | 2024-12-03T13:27:16.261596Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
todo-db   | 2024-12-03T13:27:16.356023Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.356231Z 0 [ERROR] [MY-012639] [InnoDB] Write to file ./#innodb_redo/#ib_redo10_tmp failed at offset 0, 1048576 bytes should have been written, only 0 were written. Operating system error number 28. Check that your OS and file system support files of this size. Check also that the disk is not full or a disk quota exceeded.
todo-db   | 2024-12-03T13:27:16.356646Z 0 [ERROR] [MY-012640] [InnoDB] Error number 28 means 'No space left on device'
todo-db   | 2024-12-03T13:27:16.356792Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.368615Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.368644Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.380982Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.381008Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.392702Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.392736Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.405044Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.405083Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.416785Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.416825Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.429262Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.429302Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.441744Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.441793Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.453997Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.454051Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.470183Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.470216Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.484433Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.484470Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.485162Z 1 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.485314Z 1 [ERROR] [MY-012267] [InnoDB] Could not set the file size of './ibtmp1'. Probably out of disk space
todo-db   | 2024-12-03T13:27:16.485559Z 1 [ERROR] [MY-012926] [InnoDB] Unable to create the shared innodb_temporary.
todo-db   | 2024-12-03T13:27:16.485702Z 1 [ERROR] [MY-012930] [InnoDB] Plugin initialization aborted with error Generic error.
todo-db   | 2024-12-03T13:27:16.487270Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.487305Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.489702Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.489741Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.491669Z 0 [Warning] [MY-012638] [InnoDB] Retry attempts for writing partial data failed.
todo-db   | 2024-12-03T13:27:16.491713Z 0 [ERROR] [MY-012888] [InnoDB] Cannot resize redo log file ./#innodb_redo/#ib_redo10_tmp to 3 MB (Failed to set size)
todo-db   | 2024-12-03T13:27:16.871446Z 1 [ERROR] [MY-010334] [Server] Failed to initialize DD Storage Engine
todo-db   | 2024-12-03T13:27:16.873031Z 0 [ERROR] [MY-010020] [Server] Data Dictionary initialization failed.
todo-db   | 2024-12-03T13:27:16.873510Z 0 [ERROR] [MY-010119] [Server] Aborting
todo-db   | 2024-12-03T13:27:16.889285Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.40)  MySQL Community Server - GPL.
todo-db exited with code 1

はてはて....

以下を実行。

docker system prune -a --volumes

これによって未使用データを削除します。
https://matsuand.github.io/docs.docker.jp.onthefly/engine/reference/commandline/system_prune/

めちゃくちゃ未使用のVolumeが溜まっていたようです。

.
.
.
r5piey5hk2vjhi2x2fji7h0yu
iq8d54zq4ouzhij2zx862o7li
hztxlogzlz749ekk8jikdfs0c

Total reclaimed space: 39.88GB

GUIので消したと思ってたんですけどね。
DBのセットアップはこれで完了です。

次は、go-migrateで作成したテーブルがsqlcで読み込めるかどうかを確かめます。

kakkkykakkky

開発環境を整えるーマイグレーション編

https://zenn.dev/farstep/books/f74e6b76ea7456/viewer/4cd440

上記の記事に従い、試していくと、
エラーが。

2024/12/03 18:37:08 error: failed to open database, "mysql://user:pswd@tcp(db:3306)/todo-db?parseTime=true": Error 1045: Access denied for user 'user'@'172.18.0.3' (using password: YES)
root@b21301e6461c:/src/app# 

以下のような記事を見つけました。
記事に従ってみると解決。
https://zenn.dev/tojima/articles/32bbfe85dd0022

簡単にマイグレーションができそうです。(簡易的に行った)
sqlc関係は、また実装が必要な時に行おうと思います。
DBとの接続は終了です。

kakkkykakkky

Makefileを作成する

以下を参考にしつつ、取り合えあえず必要そうなコマンドを用意していく。
https://qiita.com/yoskeoka/items/317a3afab370155b3ae8

Makefikeの記法、よくわからん

AIにほとんど作らせ、手直し程度にいじったくらいです。
マイグレーション、テスト、コンテナ操作あたりを一旦入れておきました。

help: # コマンド確認
	@echo "\033[32mAvailable targets:\033[0m"
	@grep "^[a-zA-Z\-]*:" Makefile | grep -v "grep" | sed -e 's/^/make /' | sed -e 's/://'


##############
### テスト ####
##############

# テスト処理の共通化
# パラメータ:
# - path=<テスト対象のパス> (デフォルト: ./...)
# - opts=<追加オプション> (デフォルト: なし)
# - tags=<ビルドタグ> (デフォルト: なし)
define tests
	$(if $(TEST_TAGS),\
		go test -timeout 10m -tags=$(TEST_TAGS) $(TEST_PATH) $(TEST_OPTIONS),\
		go test -timeout 10m $(TEST_PATH) $(TEST_OPTIONS)\
	)
endef

# appディレクトリの全体テスト
# コマンド例: $ make test-app opts="-run TestXxx"
test-app:
	$(eval TEST_PATH=./...)
	$(eval TEST_TAGS=$(tags))
	$(eval TEST_OPTIONS=${opts})
	@echo "Running all tests in app..."
	cd ./app && $(call tests)

# ドメイン層のテスト
# コマンド例: $ make test-domain path=./app/domain/... opts="-run TestXxx"
test-domain:
	$(eval TEST_PATH=$(or $(path),./app/domain/...))
	$(eval TEST_TAGS=$(tags))
	$(eval TEST_OPTIONS=${opts})
	@echo "Running tests in domain..."
	cd ./app/domain && $(call tests)

# インフラ層のテスト
# コマンド例: $ make test-infra path=./app/infrastructure/... opts="-run TestXxx"
test-infra:
	$(eval TEST_PATH=$(or $(path),./app/infrastructure/...))
	$(eval TEST_TAGS=$(tags))
	$(eval TEST_OPTIONS=${opts})
	@echo "Running tests in infrastructure..."
	cd ./app/infrastructure && $(call tests)

# pkgのテスト
# コマンド例: $ make test-pkg path=./pkg/... opts="-run TestXxx"
test-pkg:
	$(eval TEST_PATH=$(or $(path),./pkg/...))
	$(eval TEST_TAGS=$(tags))
	$(eval TEST_OPTIONS=${opts})
	@echo "Running tests in pkg..."
	cd ./pkg && $(call tests)


#####################
##### コンテナ操作 ####
#####################

# docker-composeにおけるDockerfileのビルド
build:
	@echo "Building Docker images..."
	docker compose build 

# docker compose up
up:
	@echo "Starting containers with docker-compose up..."
	docker compose up -d

# docker compose down
down:
	@echo "Stopping containers with docker-compose down..."
	docker compose down

# docker compose logs -f
logs:
	@echo "Fetching logs with docker-compose logs..."
	docker compose logs -f

# docker compose ps
ps:
	@echo "Viewing running containers with docker-compose ps..."
	docker compose ps



########################
### DBマイグレーション ####
########################

MIGRATE_PATH = app/infrastructure/db/migration
DB_URL = "mysql://user:pswd@tcp(db:3306)/todo-db?parseTime=true"

# マイグレーションファイルを作成
# コマンド例: $ make migrate-create name=<migration-name>
migrate-create:
	$(eval NAME=$(or $(name),$(error "Error: Please specify a migration name using name=<name>")))
	@echo "Creating migration file..."
	migrate create -ext sql -dir $(MIGRATE_PATH) -seq $(NAME)

# マイグレーションを適用
# コマンド例: $ make migrate-up
migrate-up:
	@echo "Applying migrations..."
	migrate --path $(MIGRATE_PATH) --database "$(DB_URL)" -verbose up

# マイグレーションをロールバック
# コマンド例: $ make migrate-down
migrate-down:
	@echo "Rolling back migrations..."
	migrate --path $(MIGRATE_PATH) --database "$(DB_URL)" -verbose down


後々、テストなどは追加で書いてくことになると思われます。一旦。
kakkkykakkky

Redisコンテナ

抽象化してkvsコンテナとして扱うことにしました。

  kvs:
    container_name: todo-kvs
    image: redis:latest
    ports:
      - "6379:6379"
    volumes:
      - type: volume
        source: kvs-store
        target: /data
    tty: true
kakkkykakkky

Config作成

もう一つ有名かつ使ったことある中にcaarlos0/envというものもありますが、
https://github.com/caarlos0/env

こちらのパッケージを利用します。
少しだけスター数が多いのと、コミュニティも活発っぽかったというのと、幾分かすっきりした記述で環境変数を読み込めるという理由です。
https://github.com/kelseyhightower/envconfig

Configはどこからでも参照できるようにしておきます。

// パッケージ変数として設定
var config Config

func InitConfig() error {
	if err := envconfig.Process("", &config); err != nil {
		return err
	}
	return nil
}
kakkkykakkky

DBセットアップもう少し

go-migrateでマイグレーションを管理して、sqlcでそれを読み込む

https://zenn.dev/tchssk/articles/a701d3ce5f9b6b

https://docs.sqlc.dev/en/stable/howto/ddl.html#golang-migrate

https://qiita.com/SDTakeuchi/items/292ae0c01be945ef47a0

gop-migrateでマイグレーションした歴をスキーマとして追跡できるらしい。

sqlc.yml
version: "2"
sql:
  - engine: "mysql"
    queries: "./queries/"
    schema: "./migrations/"
    gen: 
      go:
        package: "sqlc"
        out: "./"
        emit_json_tags: true
        emit_prepared_queries: false
        emit_interface: true
        emit_exact_table_names: false
        emit_empty_slices: true

main.goでmysql.NewDB()を呼び出し、内部でパッケージ変数(db)として*sql.DBをセット。
repositoryで、sqlc.New(db)として、クエリを実行できるようにする。

package mysql

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/kakkky/app/config"
)

// パッケージ変数としてDB接続を管理
var (
	db *sql.DB
)

// パッケージ変数として*sql.DBをセット
func setDB(d *sql.DB) {
	db = d
}

func GetDB() *sql.DB {
	return db
}

func NewDB(ctx context.Context, cfg *config.Config) func() {
	db, close, err := connect(
		ctx,
		cfg.MySQL.User,
		cfg.MySQL.Password,
		cfg.MySQL.Host,
		cfg.MySQL.Port,
		cfg.MySQL.Name,
	)
	if err != nil {
		// DB接続失敗は復旧不可
		panic(err)
	}
	// パッケージ変数にセット
	setDB(db)
	return close
}

const (
	maxRetriesCount = 5
	delay           = 5 * time.Second
)

// DBへ接続
// 失敗したらリトライさせる
func connect(ctx context.Context, user, password, host, port, name string) (*sql.DB, func(), error) {
	for i := 0; i < maxRetriesCount; i++ {
		dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", user, password, host, port, name)
		db, err := sql.Open("mysql", dsn)
		if err != nil {
			return nil, nil, fmt.Errorf("failed to open db : %w", err)
		}
		// 接続確認
		if err := db.PingContext(ctx); err == nil {
			// 呼び出しもと(main)でクローズ処理を強制させるために関数を返す
			return db, func() { db.Close() }, nil
		}
		// 接続できなかったらリトライ
		log.Printf("could not connect to db: %v", err)
		log.Printf("retrying in %v seconds...", delay/time.Second)
		time.Sleep(delay)
	}
	return nil, nil, fmt.Errorf("could not connect to db after %d attempts", maxRetriesCount)
}

kakkkykakkky

webサーバを実装 〜サーバー処理のカプセル化〜

サーバー起動、シャットダウン等の処理とマルチプレクサによるルーティング処理は分離させる。
また、書籍の中のハンズオンでは、ポート番号を動的に設定できるようにポートをリッスンする処理も分離していたが、今回は必要ない&複雑性がますと考えたのでそこは見送る。

また、シャットダウン処理を行うには、http.Serve型のListenAndServeを呼び出す必要がある。
そのため、*http.Serverを独自のserver型でラップして、コンストラクタとして用意した。
server型にメソッドを持たせて、サーバの起動処理をカプセル化する。

コンストラクタには、マルチプレクサをhttp.Hadlerとして抽象化しても良かったが、マルチプレクサを渡すことを強制させたかったので、明示的に具体型を書くことにした。

server/server.go

// *http.Server型をラップする
type server struct {
	srv *http.Server
}

// 独自のserver.Server型を返すコンストラクタ
func NewServer(port string, mux *http.ServeMux) *server {
	return &server{
		&http.Server{
			Addr:    port,
			Handler: mux,
		},
	}
}
kakkkykakkky

webサーバーの実装 ~起動&シャットダウン処理を実装~

実装は以下の通り。

server.go
// サーバーを起動する
func (s *server) Run(ctx context.Context) error {
	ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	// シグナルの監視をやめてリソースを開放する
	defer stop()
	eg, ctx := errgroup.WithContext(ctx)
	eg.Go(func() error {
		// サーバー起動中にエラーが起きると、ctxに伝達される(Shutdownによるエラーは正常なので無視)
		if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Printf("http server on %s failed : %+v", s.srv.Addr, err)
			return err
		}
		return nil
	})
	// サーバーからのエラーとシグナルを待機する
	<-ctx.Done()
	// シャットダウンをするまでのタイムアウト時間を設定
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	// シャットダウン
	if err := s.srv.Shutdown(ctx); err != nil {
		log.Fatalf("failed to shutdown http server on %s : %+v", s.srv.Addr, err)
	}
	// ゴルーチンのクロージャ内のエラーを返す
	// ゴルーチンを待機する
	return eg.Wait()
}

ポイントは大きく3つ

1. サーバーの起動をゴルーチンで管理し、エラーを受け取る
2. キャンセル通知を受け取るとサーバーを正常にシャットダウン
3. シグナルを受け取るとサーバーを正常にシャットダウン

1. サーバーの起動をゴルーチンで管理し、エラーを受け取る

この部分。

// 省略

eg, ctx := errgroup.WithContext(ctx)
	eg.Go(func() error {
		// サーバー起動中にエラーが起きると、ctxに伝達される(Shutdownによるエラーは正常なので無視)
		if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Printf("http server on %s failed : %+v", s.srv.Addr, err)
			return err
		}
		return nil
	})

// 省略

	// ゴルーチンのクロージャ内のエラーを返す
	// ゴルーチンを待機する
	return eg.Wait()

*http.Serve.ListenAndServe()、つまりサーバーの起動処理をgoroutineで実行するメリットは、キャンセル通知を受け取ってシャットダウンさせられることだと思っています。

*http.Serve.ListenAndServe()はブロッキング処理です。そのまま呼び出すと、プログラムでこの行以降、すすみません。複数のサーバーを立てる際にもこの実装は有効です。
しかしながら、ゴルーチンで実行することの一番の旨みは、外部でキャンセル通知をリッスンしつつサーバーのシャットダウンを実装できることじゃないかなと思います。

https://www.reddit.com/r/golang/comments/i6fopj/should_we_run_nethttp_server_listenandserve/?rdt=48302

また、単なるゴルーチンでなく、errgroup準標準パッケージを使用しているところもポイントでしょう。
ゴルーチン内のエラーハンドリングをしやすくするのがこれです。

ゴルーチンの同期をとるには、チャネルかsync.WaitGroupがあります。後者では、ゴルーチンの完了を待つだけであって、エラーハンドリングが困難です。
しかし、errgroupだと、ゴルーチン内でエラーが発生した場合にゴルーチン外部でエラーを受け取れます(加えてゴルーチンの完了を待機)。

ゴルーチン内でエラーが出た際に以下が実行されます(つまり、エラーを検知できる)

<-ctx.Done  // 終了通知を受け付ける
// シャットダウン処理

これは、errgroup.WithContext(ctx context.Context)が内部的にキャンセル処理を自然と持たせているから可能なことです。
以下は、その実装の中身です。

func WithContext(ctx context.Context) (*Group, context.Context) {
    // ctxにキャンセル処理を付け足している
	ctx, cancel := withCancelCause(ctx)
	return &Group{cancel: cancel}, ctx
}

そして、以下の部分では、ゴルーチンの完了待機+ゴルーチンないで起きたエラーを返すものとなっています。

return eg.Wait() //errorを返す

とりあえずゴルーチン+エラーハンドリングがやりやすくなるのがこの準標準パッケージerrgroupです。

2. キャンセル通知を受け取るとサーバーを正常にシャットダウン

以下に当たるかなと。

	// サーバーからのエラーとシグナルを待機する
	<-ctx.Done()
	// シャットダウンをするまでのタイムアウト時間を設定
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	// 
	if err := s.srv.Shutdown(ctx); err != nil {
		log.Fatalf("failed to shutdown http server on %s : %+v", s.srv.Addr, err)
	}

contextのキャンセル通知を待ち受け、*http.Serve型のメソッドであるShutdownメソッドを呼び出すことでサーバーのプロセスを中断するようにしています。
また、シャットダウンするまでのタイムアウト時間を設けており、5秒以内にWebサーバの処理が完了しなければ、シャットダウンが強制的に行われます。つまり、まだ接続があったりしていたとしても、強制的にシャットダウンとなります。
以下で、キャンセル処理を受け付けているShutdownメソッドの内部実装を載せます。

func (srv *Server) Shutdown(ctx context.Context) error {
	// 省略
	for {
		if srv.closeIdleConns() {
			return lnerr
		}
		select {
		case <-ctx.Done(): //キャンセル通知を待ち受けている
			return ctx.Err() // エラーを返す
		case <-timer.C:
			timer.Reset(nextPollInterval())
		}
	}
}

(余談)
自分は結構引っかかったのですが、「キャンセルってことは、シャットダウン処理自体がキャンセルってことではないのか?」と考えたんですね。
結論、キャンセル通知は強制終了トリガーとして動きます。強制的にシャットダウンを終了させます。

上の実装を見てみると、select文の上にif文srv.closeIdleConns()があるのがわかります。此れは、アイドル状態の接続をクローズさせるもので、可能な限り全ての接続を安全に閉じるためにループが回っていると考えます。これが、なかなか接続がきれなかったとき、長い間待たされることになります。そうなった場合に、一定時間立ったら、「安全に」接続を切るのを諦めて強制的に切断します。
ここでいう「安全」は、システムの安定性やデータの破損等を防いだりをした上での切断です。

外部から、コンテクストによって強制終了させることもできる、shutdownメソッドの柔軟性がわかりました。

3. シグナルを受け取るとサーバーを正常にシャットダウン

以下にあたります。

ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	// シグナルの監視をやめてリソースを開放する
	defer stop()
// 省略
// サーバーからのエラーとシグナルを待機する
	<-ctx.Done()

サーバーやコンテナが終了する際には、アプリケーションプロセスが終了シグナルを受け取ります。
処理ちゅうにシグナルを受け取った際、処理を正しく終了させるまでプロセスを終了しないことが理想です。

つまり、エラーハンドリング(からのキャンセル通知)の時と同様に、終了シグナルを受け取った際に*http.Serve.Shutdownメソッドを呼べればいいのです。`

*http.Serve.Shutdownメソッドには、コメントの第一文にこんな文言があります。

Shutdown gracefully shuts down the server without interrupting any active connections.
訳 : アクティブな接続を中断することなく、サーバーを優雅にシャットダウンする

このメソッドでシャットダウンさせとけばそれはもう所謂、グレースフルシャットダウンなわけであり、開発者側でそれをユースケースによって正しく呼び出すために実装する必要があるということなんですね。すごい、そう考えると実装が単純な気がしてきました。

signal.NotifyContext関数の第二引数以降は、検知したいシグナルを入れます。
今回は、ハンドリングの割り込みシグナルSIGINT(os.Interrupt)と、終了シグナルSIGTERMに対応させています。
また、defer文でstop関数を呼んでいます。
此れは、signal.NotifyContextの内部実装のコメントを見てください。

// The stop function releases resources associated with it, so code should
// call stop as soon as the operations running in this Context complete and
// signals no longer need to be diverted to the context.

func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {
	ctx, cancel := context.WithCancel(parent)
	// 省略
	return c, c.stop
}

The stop function releases resources associated with it, so code should call stop as soon as the operations running in this Context complete and signals no longer need to be diverted to the context.
訳 : stop関数は、それに関連するリソースを解放するので、コードは、このContextで実行されている操作が完了し、シグナルがもはやContextに流用される必要がなくなるとすぐにstopを呼び出すべきである。

必ずstopを呼び出せって話しです。それに従いましょう。
シグナルを監視してくれているわけですので、終了したらその分のリソースを解放する必要があるってことですね。
そういう、絶対この関数呼べよお前...みたいなやつはdefer文で呼ぶようにします。

というような感じで、サーバーの起動&エラーハンドリング/シグナル検知に対応したグレースフルシャットダウンを実装しました。
次は、マルチプレクサの処理を書いていきます。

kakkkykakkky

ルーティング処理の実装

ルーティングが登録されたマルチプレクサを返します。
ヘルスチェックハンドラを用意しておきました。

router/mux.go
// ルーティングを登録したマルチプレクサを返す
func NewMux() *http.ServeMux {
	mux := http.NewServeMux()
	handleHealth(mux)
	return mux
}

// ヘルスチェック
func handleHealth(mux *http.ServeMux) {
	h := func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-type", "application/json;charset=utf-8")
		w.WriteHeader(http.StatusOK)
		s := fmt.Sprintf("%d %s", http.StatusOK, "OK")
		m := map[string]string{"status": s}
		if err := json.NewEncoder(w).Encode(m); err != nil {
			log.Printf("failed to encode to json : %v", err)
		}
	}
	mux.HandleFunc("/health/", h)
}

この返り値となるマルチプレクサは、main関数がわで、サーバー起動処理の関数に渡されます。

// マルチプレクサをDI
srv := server.NewServer(cfg.Server.Port, router.NewMux())
srv.Run(ctx)

ミドルウェアの扱い

ルーティングには、ミドルウェアも絡んできます。
なので、この段階で大体こんな感じにするかという仮実装をしておきます。

課題として、複数のミドルウェアを適用させたい場合、ハンドラをめちゃくちゃネストしないといけないです。
それを解決するために、複数のミドルウェアを束ねることができる関数を作成しておきます

// 適用させたい順で、ミドルウェアを引数に入れる
// composeMiddewares(M1,M2,M3)とした場合、M1(M2(M3()))といったようにラップされたミドルウェアを返す
func composeMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
	return func(h http.Handler) http.Handler {
		for i := range middlewares {
			h = middlewares[len(middlewares)-(i+1)](h)
		}
		return h
	}
}

以下の実装を参考にしました。

https://zenn.dev/saka1/articles/b9a233d8518acd

使い方としては、まず各ハンドラ登録関数の中で、middlewareパッケージからミドルウェアをとってきて、適用させたいミドルウェアをこの関数に入れ、複合ミドルウェアを生成します。(❶)

適用させたいハンドラに複合ミドルウェアを適用させます。(❷)

ハンドラが増えてくると、幾つも幾つもmdw(handler)とする必要があります。
これには、メリットデメリットがあると思ってます。
デメリットとしては、記述が重複すること。面倒。書き忘れる可能性がある。
メリットは、ハンドラによって適用させたいミドルウェアの組み合わせが違う可能性もあるし、そもそも適用させなくていいパターンもある。

柔軟に組めるのはこのパターンだと思ったので、デメリットを受け入れました。

// ヘルスチェック
func handleHealth(mux *http.ServeMux) {
	mdw := composeMiddlewares(middleware.LoggingMiddleware)  // ❶
	h := func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-type", "application/json;charset=utf-8")
		w.WriteHeader(http.StatusOK)
		s := fmt.Sprintf("%d %s", http.StatusOK, "OK")
		m := map[string]string{"status": s}
		if err := json.NewEncoder(w).Encode(m); err != nil {
			log.Printf("failed to encode to json : %v", err)
		}
	}
	mux.Handle("/health/", mdw(http.HandlerFunc(h)))  // ❷
}

複数のミドルウェアを適用させたい場合はcomposeMiddleware関数を使えばいいですし、1つだけを適用させる場合は、関数を使わずにそのままミドルウェアを適用させる形になると思います。

また、例えばhandleUser内で、ハンドラごとに適用させたいミドルウェアの組み合わせが異なる際は、composeMiddleware関数を二度呼び出して、2種類の複合ミドルウェアを用意すればいいです。

kakkkykakkky

swaggerでAPI ドキュメントをつけてみる

ネットで見ると、色々やり方はあったが、自分は以下のようにセットアップ。
基本的に以下のドキュメントに従って行ないました。
https://github.com/swaggo/swag?tab=readme-ov-file#supported-web-frameworks

https://github.com/swaggo/http-swagger

ドキュメントによれば、go installでインストールする必要があるようです。
install系は基本的にDockerfileに記載しておきたい方針なので、以下のようにRUNコマンドを追加します。

Dockerfile.api
FROM golang:1.23.1
// 省略
RUN go install github.com/air-verse/air@latest
RUN go install go.uber.org/mock/mockgen@v0.3.0
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.23.0
RUN go install github.com/swaggo/swag/cmd/swag@latest  //追加
RUN go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
// 省略

以下のようにコマンドを打つと、docs/ディレクトリを自動生成してくれます。
基本的に触ることはないでしょう。

swag init
 ├── docs
│   │   ├── docs.go
│   │   ├── swagger.json
│   │   └── swagger.yaml

次に、main.goにブランクインポートします。
適当にアノテーションをつけました。

import (
	"context"
	"log"

	"github.com/kakkky/app/config"
	_ "github.com/kakkky/app/docs"
	"github.com/kakkky/app/infrastructure/db/mysql"
	"github.com/kakkky/app/infrastructure/router"
	"github.com/kakkky/app/infrastructure/server"
)

// @title       TodoAPI
// @version     2.0
// @description This is TodoAPI by golang.
// @host        localhost
// @BasePath    /api/v2/
func main() {
	ctx := context.Background()
	cfg, err := config.NewConfig()
	if err != nil {
		log.Fatalf("failed to read config : %v", err)
	}
	if err := run(ctx, cfg); err != nil {
		// エラー処理
	}
}

エンドポイントにも適当につけて確かめます。

// HealthCheckHandler godoc
// @Summary     apiのヘルスチェックを行う
// @Description Returns the health status of the API
// @Router      /health [get]
func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json;charset=utf-8")
	body := map[string]string{"message": "health check"}
	if err := json.NewEncoder(w).Encode(body); err != nil {
		log.Printf("failed to encode to json : %v", err)
	}
}

ドキュメントを反映させるためには、swag initを行なう必要があるようです。
できました。

Makefileにコマンドを登録しておきました。

#############
## swagger ##
#############

# コメントをパースしてドキュメント生成
swag:
	@echo "Generating document by swagger..."
	cd ./app && swag fmt &&swag init
kakkkykakkky

やっと機能実装

ここまでで、DB&サーバー(+ルーティング)等のインフラ系(redisはこの後やる)、Dockerやswagger等の開発環境を実装しました。

やっとこさAPI機能面の以下の流れで進めていく。

  1. ユーザー登録、削除、閲覧
  2. 認証、認可機能
  3. Todoタスク機能

それぞれ、ドメイン〜ユースケース〜プレゼンテーション(ハンドラ+プレゼンター)の順に実装していく。
ユニットテストを実装しつつ、エンドポイント(ハンドラ)のテストでは統合テストを適用します。

kakkkykakkky

ドメインエラー

ドメイン層で起きるエラーは、ビジネスロジックに起因するものでありユーザーにフィードバックが必要なエラーである
https://zenn.dev/sutefu23/articles/19d548aaa5222b#ドメイン層で起きるエラー

ドメイン層でエラーを受けとって返すために、エラーハンドリングを実装する。
今回はなるべくシンプルにエラー型を定義したいなと考えて構成を探していると、以下の記事を見つけた。

https://zenn.dev/mizuko_dev/articles/3bafd1f6ff3f27

domain/error/error.goとして、ドメイン層におけるエラー型を実装する。
独自エラー型は、Err-とプレフィックスをつけるのが慣習のようなので、律儀にそれに倣う。

エラー型は適宜追加していく。

domain/errors/errors.go
package errors

import (
	"errors"
)

// ユーザー関連
var (
	ErrInvalidEmail     = errors.New("invalid email address")
	ErrPasswordTooShort = errors.New("password is too short")
)

// errors.Isをラップ
// パッケージ名の衝突を考慮
func Is(err, target error) bool {
	return errors.Is(err, target)
}

kakkkykakkky

アプリケーションの仕様

そういえば。
ユースケースとエンドポイント、ユビキタス言語を定義しておきます。

ユースケース

  • ユーザーの登録
  • ユーザーの情報編集
  • ユーザーの削除
  • ユーザーのログイン
  • ユーザーのログアウト
  • ユーザーのタスク登録
  • ユーザーのタスク削除
  • ユーザーのタスク編集
  • ユーザーのタスク状態変更

ユビキタス言語

ピックアップ。
ユーザーとタスクがドメインモデル。

言語 説明
ユーザー 認証やタスクを登録・管理する人。
ユーザー登録 ユーザーの情報をシステムに保存する操作。
ユーザー編集 ユーザーの名前や認証情報などを更新する操作。
ユーザー削除 ユーザーの情報をシステムから削除する操作。
ログイン ユーザーがシステムに認証を行いアクセスする操作。
ログアウト ユーザーがシステムから認証状態を解除する操作。
タスク ユーザーによって操作される項目。
タスク登録 タスクを新しく作成しシステムに保存する操作。
タスク編集 タスクの内容や詳細を変更する操作。
タスク削除 タスクをシステムから削除する操作。
タスク状態 タスクの進捗を表す属性(例: todo/doing/done)。
タスク状態変更 タスクの状態(todo/doing/done)を更新する操作。

エンドポイント

メソッド パス 概要
POST /user ユーザー登録
DELETE /user ユーザー削除
PUT /user ユーザー編集
POST /auth/login ログイン
DELETE /auth/logout ログアウト
POST /tasks タスク登録
GET /tasks タスク一覧表示
GET /task/{id} タスク表示
PUT /task/{id} タスク編集
PUT /task/{id}/state タスク状態変更
DELETE /task/{id} タスク削除
kakkkykakkky

プレゼンターの実装

healthチェックハンドラーを今後の例とするため、現在はハンドラとプレゼンター(JSONに整形してレスポンスに含める)が混合となっていますが、分離しておきます。

プレゼンター=JSONフォーマットにしてレスポンスを返すものとします。

成功時と失敗時で分けて定義してみました。
返すJSONのパターンに一貫性を持たせたいので、各ハンドラでハンドラごとの型を埋め込んだり、エラーメッセージを埋め込むようにしています。

adapter/presentation/presenter/success.go
type SuccessResponse[T any] struct {
	Status int `json:"status"`
	Data   T   `json:"data"`
}

func RespondStatusOK[T any](w http.ResponseWriter, respBody T) {
	respondSuccess(w, http.StatusOK, respBody)
}

func respondSuccess[T any](w http.ResponseWriter, statusCode int, respBody T) {
	w.Header().Set("Content-Type", "application/json;charset=utf-8")
	w.WriteHeader(statusCode)
	jsonResp := SuccessResponse[T]{
		Status: statusCode,
		Data:   respBody,
	}
	if err := json.NewEncoder(w).Encode(jsonResp); err != nil {
		RespondStatusInternalServerError(w, err.Error())
	}
}

失敗時の方は、エラーコードのパターンが増えていくことが予想されるので、たくさんRespondStatusXXX関数を付け足していくことになると思います。

adapter/presentation/presenter/failure.go

type SuccessResponse[T any] struct {
	Status int `json:"status"`
	Data   T   `json:"data"`
}

func RespondStatusOK[T any](w http.ResponseWriter, respBody T) {
	respondSuccess(w, http.StatusOK, respBody)
}

func respondSuccess[T any](w http.ResponseWriter, statusCode int, respBody T) {
	w.Header().Set("Content-Type", "application/json;charset=utf-8")
	w.WriteHeader(statusCode)
	jsonResp := SuccessResponse[T]{
		Status: statusCode,
		Data:   respBody,
	}
	if err := json.NewEncoder(w).Encode(jsonResp); err != nil {
		RespondStatusInternalServerError(w, err.Error())
	}
}

成功時には、Dataフィールドを含めたJSONを返します。
ジェネリクスを使用して、Dataフィールドに入る型を指定可能にしています。
これは、JSONにエンコードする際にDataの型はエンドポイントによって異なるからです。正しく型を柔軟に反映させるためには、ジェネリクスは好手と見て使ってみました。

ヘルスチェックハンドラは以下のようになりました。

adapter/presentation/health/handler.go
// HealthCheckHandler godoc
// @Summary     apiのヘルスチェックを行う
// @Description apiのヘルスチェックを行う。ルーティングが正常に登録されているかを確かめる。
// @Success     200 {object} presenter.SuccessResponse[healthResponse] "Health check message""
// @Router      /health [get]
func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
	resp := healthResponse{
		HealthCheck: "ok",
	}
	presenter.RespondStatusOK(w, resp)
}
adapter/presentation/health/response.go
type healthResponse struct {
	HealthCheck string `json:"health_check"`
}

うまく表示されました。

% curl -i  http://localhost:8080/health
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Date: Mon, 09 Dec 2024 07:57:58 GMT
Content-Length: 44

{"status":200,"data":{"health_check":"ok"}}

ちなみに、Swaggoのアノテーションはジェネリクスにも対応していたようです。
いけるかな、えいってやってみたらできてました。

// @Success     200 {object} presenter.SuccessResponse[healthResponse] "Health check message""

後から調べてみると、ドキュメントの一番下にありました。

https://github.com/swaggo/swag?tab=readme-ov-file#how-to-use-generics

kakkkykakkky

idはULIDとする

task、userともにidをulidとします。

https://giginc.co.jp/blog/giglab/uuid-ulid

https://zenn.dev/emiksk/articles/e2716c0af75eea

衝突の可能性も低く、タイムスタンプが含まれている点でソートできるので採用することにしました。

ulidの生成や、ulidの解析(形式が正しいか)に関してはドメインから外れるため、汎用的な処理を配置するpkg/に実装しています。

ローカルの別ディレクトリに実装したパッケージをインポートするには、replaceを用います。
矢印左側のパスを、矢印右側の相対パスで解釈するようになります。

app/go.mod
// ローカルパッケージをインポート
require github.com/kakkky/pkg v0.0.0
replace github.com/kakkky/pkg => ../pkg
kakkkykakkky

ユーザードメインオブジェクトを実装

emailとpasswordは値オブジェクトとした。
以下の点を考慮してのことである。

  • emailはバリデーションロジックがある
  • passwordは認証の際に、値との比較やパスワードをハッシュ化して保存するなど値に関わるロジックがある

passwordの実装

https://zenn.dev/kou_pg_0131/articles/go-digest-and-compare-by-bcrypt

実装

domain/user/password.go
package user

import (
	"unicode/utf8"

	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/pkg/hash"
)

type password struct {
	value string
}

const (
	minPasswordLength = 6
)

func newPassword(value string) (password, error) {
	// バリデーション
	if minPasswordLength >= utf8.RuneCountInString(value) {
		return password{}, errors.ErrPasswordTooShort
	}
	// ハッシュ化する
	hashed, err := hash.Hash(value)
	if err != nil {
		return password{}, err
	}

	return password{value: string(hashed)}, nil
}

func reconstructPassword(hashedValue string) password {
	return password{value: hashedValue}
}

// ハッシュ化されたパスワードと比較
func (p password) Compare(target string) bool {
	return hash.Compare(p.value, target)
}

ハッシュ化や、ハッシュ化した値と平文の比較に関しては、汎用的処理としてpkgに切り出した。
具体的な実装はドメインから外れる気がしたので、hashパッケージに切り出して具体的な処理を隠蔽した感じです。

kakkkykakkky

Userドメインオブジェクト

新規にインスタンスを作成して返すコンストラクタと、リポジトリから得たデータをドメインオブジェクトとして再構成するための2つを用意。

domain/user/user.go
type User struct {
	id       string
	email    email
	name     string
	password password
}

// 新たなユーザーを作成する
func NewUser(
	email string,
	name string,
	password string,
) (*User, error) {
	validatedEmail, err := newEmail(email)
	if err != nil {
		return nil, err
	}
	HashedPassword, err := newPassword(password)
	if err != nil {
		return nil, err
	}
	return &User{
		id:       ulid.NewUlid(),
		email:    validatedEmail,
		name:     name,
		password: HashedPassword,
	}, nil
}

// 既存のユーザーを返す
// リポジトリからのみ使用する
// インスタンスの再構成
func ReconstructUser(
	id string,
	email string,
	name string,
	password string,
) *User {
	return &User{
		id:       id,
		email:    reconstructEmail(email),
		name:     name,
		password: reconstructPassword(password),
	}
}

テストではそれぞれの値にバリデーションが当たってるかどうかを確かめる。


func TestNewUser(t *testing.T) {
	t.Parallel()
	type args struct {
		name     string
		email    string
		password string
	}
	tests := []struct {
		name    string
		args    args
		want    *User
		wantErr bool
	}{
		{
			name: "正常系",
			args: args{
				name:     "test",
				email:    "example@test.com",
				password: "password",
			},
			want: &User{
				email: email{value: "example@test.com"},
				name:  "test",
			},
			wantErr: false,
		},
		{
			name: "異常系:emailアドレスが不正",
			args: args{
				email: "test.com",
			},
			wantErr: true,
		},
		{
			name: "異常系:パスワードが短い(4文字)",
			args: args{
				password: "test",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got, err := NewUser(tt.args.email, tt.args.name, tt.args.password)
			if (err != nil) != tt.wantErr {
				t.Fatalf("NewUser() error=%v,but wantErr %v", err, tt.wantErr)
			}
			if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(User{}, email{}, password{}), cmpopts.IgnoreFields(User{}, "id", "password")); diff != "" {
				t.Errorf("NewProduct() -got,+want :%v ", diff)
			}
		})
	}
}
kakkkykakkky

usersテーブルを作成

マイグレーションします。

できた。

mysql> show full columns
    ->  from users;
+----------+-----------+--------------------+------+-----+---------+-------+---------------------------------+---------+
| Field    | Type      | Collation          | Null | Key | Default | Extra | Privileges                      | Comment |
+----------+-----------+--------------------+------+-----+---------+-------+---------------------------------+---------+
| id       | char(26)  | utf8mb4_0900_ai_ci | NO   | PRI | NULL    |       | select,insert,update,references |         |
| email    | char(254) | utf8mb4_0900_ai_ci | NO   | MUL | NULL    |       | select,insert,update,references |         |
| name     | char(255) | utf8mb4_0900_ai_ci | NO   |     | NULL    |       | select,insert,update,references |         |
| password | char(255) | utf8mb4_0900_ai_ci | NO   |     | NULL    |       | select,insert,update,references |         |
+----------+-----------+--------------------+------+-----+---------+-------+---------------------------------+---------+
4 rows in set (0.06 sec)

ただ、一点、makefileにmigrateコマンドをタスクとして定義していて、dsnをベタガキしていた。
そこで、migrateしても、hostが解決されず困っていた。
指定していたurlは

DB_URL = mysql://user:pswd@tcp(db:3306)/todo-db?parseTime=true

hostは、dbとしているが、これはローカルで実行しても解決されない。docker-composeのservice名がホストとなり、それを期待していたがローカルで実行してもdocker ネットワーク外なので通じない。

makeタスクを修正。
コンテナ内で実行されるようにした。

# マイグレーションを適用
# コマンド例: $ make migrate-up
migrate-up:
	@echo "Applying migrations..."
	docker compose run api migrate --path $(MIGRATE_PATH) --database "$(DB_URL)" -verbose up
kakkkykakkky

値オブジェクトpasswordをhashedPasswordに変更

パスワードのハッシュ化・比較を、password値オブジェクトにカプセル化していたましたが、、、

パスワードをハッシュ化することは、ドメイン知識からは外れる気がしていた。。。なぜかというと、ハッシュ化はセキュリティのためのアプリケーション由来のもの。元からドメインとして備わっている要件ではない気がしました。

そんなところにうってつけの記事。

https://zenn.dev/10inoino/articles/69cb27159fc121

元から、ハッシュ化されたパスワードを持つものとしてモデリングすれば、値オブジェクトにハッシュの比較処理等を実装していてもおかしくない

といったもの。
採用しました。

kakkkykakkky

GithubActionsを導入

え、今?って感じですが、この先、pkgもappも手動テストするとなれば面倒間違いなし。
ここから統合テスト等も追加していくとなれば、なおさら。

https://zenn.dev/kiwichan101kg/articles/2d6850ff72bc98

これみる。
テストに関していったんこれでいこう。

name: test

on:
    # トリガー
    push:
        branches:
            - main
        # 非機能要件のファイルの変更は除外
        paths-ignore:
            - "app/docs/**"
            - "Dockerfile.**"
            - "compose.yml"
            - "air.toml"
jobs:
    # ユニットテスト
    unit-test:
        name: Run Unit Tests
        runs-on: ubuntu-latest
        steps:
            # チェックアウト
            - name: Checkout Code
              uses: actions/checkout@v4
            # Goのセットアップ
            - name: Go Setup
              uses: actions/setup-go@v5
              with:
                # goバージョンを参照するファイル
                go-version-file: app/go.mod
                # キャッシュ
                cache: true
                # 依存関係のキャッシュにおける追跡ファイル
                cache-dependency-path: app/go.sum
            # マルチモジュールを1つのワークスペースで管理
            - name: Setup Workspace
              run: go work init ./pkg ./app
            # appモジュールのテスト
            - name: Run App Test
              run: make test-app
            # pkgモジュールのテスト
            - name: Run Pkg Test
              run: make test-pkg
kakkkykakkky

レイヤーの依存関係を図式しておく

ドメインオブジェクトとしてUserを実装したが、この後、ドメイサービスやユースケース、リポジトリを実装していく中で、依存関係を整理しておく。

DDDとクリーンアーキテクチャが混ざっている感じになっている。

kakkkykakkky

UserのCRUD処理を実装する計画

何がドメインルールだ??
色々整理します。

まず、登録に関しては、emailによる重複を避けさせたいので、それはドメインルールだろう。
存在を確かめるためのExistsメソッドはドメインオブジェクト自身に実装すると不自然である(有名な話?)から、これはドメインサービスとする。

んー、それぐらいかなぁ?とりあえず。

kakkkykakkky

UserDomainService.IsExists()

email値オブジェクトを渡し、リポジトリにそれを渡してもらう。
リポジトリ内で、ユーザーが見つからなかった場合は、カスタムエラー(ErrUserNotFound)を返すようにする。Exists()においては、見つからないことがエラーではないため、エラーをハンドリングして、カスタムエラーであれば、エラーとしては返さない。それ以外の要因が絡んでいればエラーとして返す。

func (uds userDomainService) IsExists(ctx context.Context, email email) (bool, error) {
	user, err := uds.userRepository.FindByEmail(ctx, email)
	// ユーザーが見つからない場合はエラーではない
	if err != nil && errors.Is(err, errors.ErrNotFoundUser) {
		return false, nil
	}
	// それ以外ならエラーとして返す
	if err != nil {
		return false, err
	}
	return user != nil, nil
}

kakkkykakkky

gomockを用いたドメインサービスのテスト

https://zenn.dev/sanpo_shiho/articles/01da627ead98f5

リポジトリからErrNotFoundUserが返る場合はエラー判定とならない。

func TestUserDomainService_IsExists(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		email   email
		mockFn  func(m *MockUserRepository)
		want    bool
		wantErr bool
	}{
		{
			name:  "正常系: ユーザー存在する",
			email: email{value: "test@example.com"},
			mockFn: func(m *MockUserRepository) {
				// ErrNotFoundUserを返す
				m.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(&User{}, nil)
			},
			want:    true,
			wantErr: false,
		},
		{
			name:  "正常系: ユーザーが存在しない",
			email: email{value: "test@example.com"},
			mockFn: func(m *MockUserRepository) {
				m.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(nil, errors.ErrNotFoundUser)
			},
			want:    false,
			wantErr: false,
		},
		{
			name:  "異常系:リポジトリからErrNotFoundUser以外のエラーが返る場合",
			email: email{value: "test@example.com"},
			mockFn: func(m *MockUserRepository) {
				m.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(nil, errors.New("Database error"))
			},
			want:    false,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			ctrl := gomock.NewController(t)
			mockUserRepository := NewMockUserRepository(ctrl)
			// モック呼び出しの設定
			tt.mockFn(mockUserRepository)
			// ドメインサービスを作成
			sut := NewUserDomainService(mockUserRepository)
			ctx := context.Background()
			got, err := sut.IsExists(ctx, tt.email)
			if (err != nil) != tt.wantErr {
				t.Errorf("want error %v,but got %v : %v", tt.wantErr, got, err)
			}
			if got != tt.want {
				t.Errorf("userDomainService.IsExists()= %v,but want %v", got, tt.want)
			}

		})
	}
}

kakkkykakkky

リポジトリの実装

インターフェースを記述しておきました。
依存関係をドメインに向けるために、ドメイン層に記述しています。

//go:generate mockgen -package=user -source=./interface_user_repository.go -destination=./mock_user_repository.go
type UserRepository interface {
	Save(ctx context.Context, user *User) error
	FindByEmail(ctx context.Context, email Email) (*User, error)
	FetchAllUsers(ctx context.Context) (Users, error)
	Update(ctx context.Context, user *User) error
	Delete(ctx context.Context, user *User) error
}

リポジトリを実装するには、sqlcでクエリを生成する必要があるためそこから始めます。

スキーマファイルには、go-migrateにより管理しているマイグレーションファイルを使用します。
sqlcはgo-migrateをサポートしているので、こういったことが可能のようです。

sqlc.yml
version: "2"
sql:
  - engine: "mysql"
    queries: "./queries/"
    schema: "../migrations/"
    gen: 
      go:
        package: "sqlc"
        out: "./"
        emit_json_tags: false #構造体にjsonタグをつけない
        emit_prepared_queries: true #プリペアードステートメント
        emit_interface: true #queryごとにインターフェースを生成
        emit_exact_table_names: false #テーブル名(s抜き)がそのまま構造体としない
        emit_empty_slices: true #:manyクエリでnilの代わりに空スライスが返される

userRepository

sqlcで生成されたgoのメソッドは、*Queriesがレシーバとして定義されています。

func (q *Queries) FetchAllUser(ctx context.Context) ([]FetchAllUserRow, error) {
    //省略
}

*Queriesは、用意されている以下の関数によって取得できます。

func New(db DBTX) *Queries {
	return &Queries{db: db}
}

DBTXを見たす具象型を引数に渡すと、*Queriesを取得できます。
DBTXは、これもsqlcに用意されたインターフェースであり、sql.DBsql.TXの抽象型となっています。

いちいち、repositoryに定義したメソッドごとに、パッケージ変数に保持した*sql.DBを取得してNew()に入れて〜とすると面倒です。
そのため、sqlcディレクトリ配下に、以下のコードを置きました。

sqlc/queries.go
package sqlc

// パッケージ変数としてQueriesを保持
var (
	queries *Queries
)

func SetQueries(db DBTX) {
	queries = New(db)
}

func GetQueries() *Queries {
	return queries
}

DBの接続処理の中でこれを呼び出そうとも思ったのですが、そうするとDBに接続するというfunc NewDB()関数の役割に異物混入な感じがしたので、main.goで行います。
db.GetDB()によって、DB接続を取り出し、sqlcのクエリオブジェクトをセットします。

main.go
func run(ctx context.Context,cfg *config.Config) error {
	// データベース接続を初期化し、終了時にクローズする
	close := db.NewDB(ctx, cfg)
	defer close()

	// sqlc を使用したクエリ実行のために、sqlcパッケージにクエリオブジェクト変数を設定
	sqlc.SetQueries(db.GetDB())

	// サーバーを起動し、指定したポートでリクエストを処理
	srv := server.NewServer(cfg.Server.Port, router.NewMux())
	srv.Run(ctx)

	return nil
}

リポジトリの各メソッドの中で、以下のように取得させるようにしました。

func (ur *userRepository) Save(ctx context.Context, user *user.User) error {
    //パッケージ変数から取得
	queries := sqlc.GetQueries()
	
}

リポジトリーオブジェクト(構造体)の中でDBを取得してNew(db DBTX) *Queriesを行うことを計画していましたが、そうすると、トランザクション(ユースケース層がになうものとします)に対応させたくなってきたりした時の柔軟性が乏しいと考え、メソッドごとにNew(db DBTX) *Queriesを呼び出すことにします。

トランザクションに対応させるかどうかは、このAPIの要件にあわなそうですが、無理やり実装するかもです。

kakkkykakkky

sqlcで生成されたメソッドが、引数を受け取らない。

あれ、おかしいな。
name,emailを引数に受け取るんだけども。


const insertUser = `-- name: InsertUser :exec
insert into users (
    name,
    email
) values (
    $1,
    $2
)
`

func (q *Queries) InsertUser(ctx context.Context) error {
	_, err := q.exec(ctx, q.insertUserStmt, insertUser)
	return err
}

プリペアードステートメントをやめて、マクロを使用して引数を渡すようにしたらできた。

-- name: InsertUser :exec
insert into users (
    name,
    email,
    hashed_password
) values (
    sqlc.arg(name),
    sqlc.arg(email),
    sqlc.arg(hashed_password)
);
const insertUser = `-- name: InsertUser :exec
insert into users (
    name,
    email,
    hashed_password
) values (
    ?,
    ?,
    ?
)
`

type InsertUserParams struct {
	Name           string
	Email          string
	HashedPassword string
}

func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) error {
	_, err := q.db.ExecContext(ctx, insertUser, arg.Name, arg.Email, arg.HashedPassword)
	return err
}
kakkkykakkky

リポジトリのメソッドを実装

func (ur *userRepository) Save(ctx context.Context, user *user.User) error {
	queries := sqlc.GetQueries()
	params := sqlc.InsertUserParams{
		Name:           user.GetName(),
		Email:          user.GetEmail().Value(),
		HashedPassword: user.GetHashedPassword().Value(),
	}
	if err := queries.InsertUser(ctx, params); err != nil {
		return err
	}
	return nil
}

func (ur *userRepository) FindByEmail(ctx context.Context, email user.Email) (*user.User, error) {
	queries := sqlc.GetQueries()
	u, err := queries.FindUserByEmail(ctx, email.Value())
	if err != nil && errors.Is(err, sql.ErrNoRows) {
		return nil, errors.ErrNotFoundUser
	}
	if err != nil {
		return nil, err
	}
	user := user.ReconstructUser(
		u.ID,
		u.Email,
		u.Name,
		u.HashedPassword,
	)
	return user, nil
}

func (ur *userRepository) FetchAllUsers(ctx context.Context) (user.Users, error) {
	queries := sqlc.GetQueries()
	us, err := queries.FetchAllUser(ctx)
	if err != nil && errors.Is(err, sql.ErrNoRows) {
		return nil, errors.ErrNotFoundUser
	}
	if err != nil {
		return nil, err
	}
	// あらかじめ配列のキャパを確保しておく
	users := make(user.Users, 0, len(us))
	for _, u := range us {
		user := user.ReconstructUser(
			u.ID,
			u.Email,
			u.Name,
			u.HashedPassword,
		)
		users = append(users, user)
	}
	return users, nil
}

func (ur *userRepository) Update(ctx context.Context, user *user.User) error {
	queries := sqlc.GetQueries()
	params := sqlc.UpdateUserParams{
		ID:    user.GetID(),
		Name:  user.GetName(),
		Email: user.GetEmail().Value(),
	}
	if err := queries.UpdateUser(ctx, params); err != nil {
		return err
	}
	return nil
}

func (ur *userRepository) Delete(ctx context.Context, user *user.User) error {
	queries := sqlc.GetQueries()
	if err := queries.DeleteUser(ctx, user.GetID()); err != nil {
		return err
	}
	return nil
}

sqlcで生成されたメソッドを見ていくと、func (r *sql.Row) Scan(dest ...any) error が使用されている。
このためレコードが見つからない場合はsql.ErrNoRowsエラーが返ってくるようになっている。

func (q *Queries) FindUserByEmail(ctx context.Context, email string) (FindUserByEmailRow, error) {
	row := q.db.QueryRowContext(ctx, findUserByEmail, email)
	var i FindUserByEmailRow
	err := row.Scan(
		&i.ID,
		&i.Email,
		&i.Name,
		&i.HashedPassword,
	)
	return i, err
}

エラーを比較して、その場合は、ドメインエラーを返すようにしている。

unc (ur *userRepository) FindByEmail(ctx context.Context, email user.Email) (*user.User, error) {
	queries := sqlc.GetQueries()
	u, err := queries.FindUserByEmail(ctx, email.Value())
	if err != nil && errors.Is(err, sql.ErrNoRows) {
		return nil, errors.ErrNotFoundUser
	}
	if err != nil {
		return nil, err
	}
	user := user.ReconstructUser(
		u.ID,
		u.Email,
		u.Name,
		u.HashedPassword,
	)
	return user, nil
}

kakkkykakkky

リポジトリのテストを、dockertestを用いて書く

dockertestでテストのときにコンテナを一時的に立ち上げるようにする。
repository以外にも、ハンドラのE2Eテストでも使用するので、infrastructureのdbディレクトリにcontanerパッケージを置き、その中にdockertestのコンテナ内でDBを立ち上げる処理を記述する。
それを、TestMainの形(同パッケージにおけるテスト共通処理)で呼び出す。

infrastructure/db/container/dockertest.go

var (
	user_name = "root"
	password  = "secret"
	hostname  = "localhost"
	dbName    = "test-db"
	port      int //ポート番号は起動したコンテナから取得する(ランダム)
)

// DockerTestのコンテナを起動
func NewDockerTestContainer() (*dockertest.Pool, *dockertest.Resource) {
	// デフォルトでUnixソケットを使用する
	pool, err := dockertest.NewPool("root:")
	if err != nil {
		log.Fatalf("failled to construct pool:%v", err)
	}
	// Dockerに接続確認
	if err := pool.Client.Ping(); err != nil {
		log.Fatalf("failed to connect to Docker: %v", err)
	}
	// コンテナの起動設定を指定
	runOptions := &dockertest.RunOptions{
		Repository: "mysql",
		Tag:        "8",
		Env: []string{
			"MYSQL_ROOT_PASSWORD=" + password,
			"MYSQL_DATABASE=" + dbName,
		},
		// 開発環境の設定に揃える
		Cmd: []string{
			"mysqld",
			"--character-set-server=utf8mb4",
			"--collation-server=utf8mb4_unicode_ci",
		},
	}
	resource, err := pool.RunWithOptions(runOptions, func(hc *docker.HostConfig) {
		// コンテナは停止後に自動削除される
		hc.AutoRemove = true
		// コンテナが異常終了しても再起動しない
		hc.RestartPolicy = docker.RestartPolicy{
			Name: "no",
		}
	})
	if err != nil {
		log.Fatalf("failed to start resource: %v", err)
	}
	return pool, resource
}

// コンテナを削除する
func RemoveDockerTestContainer(pool *dockertest.Pool, resource *dockertest.Resource) {
	if err := pool.Purge(resource); err != nil {
		log.Fatalf("failed to  purge resource: %s", err)
	}
}

ファイルを分けて、DB接続処理を記述します。

infrasturucture/db/container/db.go
// DBに接続する
func NewDB(pool *dockertest.Pool, resource *dockertest.Resource) *sql.DB {
	var db *sql.DB
	// エラーだと再実行を繰り返す
	if err := pool.Retry(func() error {
		var err error
		// 公開ポート番号を取得
		port, err = strconv.Atoi(resource.GetPort("3306/tcp")) //コンテナ内のポート番号は3306
		if err != nil {
			return err
		}
		// DBに接続
		db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", user_name, password, hostname, port, dbName))
		if err != nil {
			return err
		}
		// 接続確認
		return db.Ping()
	}); err != nil {
		log.Fatalf("failed to connect to database: %v", err)
	}
	return db
}

次に、DB内のテーブルをマイグレーションしてやる必要があります。
go-migrateのドキュメントを確認して、CLIではなくgoプロジェクト内でマイグレーションを適用する方法を探します。

https://github.com/golang-migrate/migrate

https://github.com/golang-migrate/migrate/tree/v4.18.1/database/mysql

以下の実装となりました。

infrastructure/db/container/db.go

func SetupDB() {
	migrationsPath := getMigrationsPath()
	m, err := migrate.New(migrationsPath, fmt.Sprintf("mysql://%s:%s@tcp(%s:%d)/%s?parseTime=true", userName, password, hostname, port, dbName))
	if err != nil {
		log.Fatalf("failed to create migrate instance:%v", err)
	}
	// マイグレーションをテストDBに適用させる
	if err := m.Up(); err != nil {
		if errors.Is(err, migrate.ErrNoChange) {
			log.Println("migrations have already been applied")
		} else {
			log.Fatalf("failed to up migrations:%v", err)
		}
	}
}

const migrationsRelativePath = "../migrations"

// どこからSetupDBを呼び出してもmigrationsファイルへのパスを取得できるようにする
func getMigrationsPath() string {
	// コールスタックを遡って、Callerを呼んだ階層の情報を取得する
	// Callerを呼んだ階層とは、つまりこのcontainerディレクトリのこと
	_, callerFile, _, ok := runtime.Caller(0)
	if !ok {
		log.Fatal("failed to get caller directory")
	}

	callerPath := filepath.Dir(callerFile)
	migratiionsAbsPath := "file://" + filepath.Join(callerPath, migrationsRelativePath)
	return migratiionsAbsPath
}

適用させたいマイグレーションを置いているパス(開発の中でCLIを用いているディレクトリ)と、DBのURLを渡してやれば、マイグレーションを行うインスタンスが作成され、Up()を適用すると、未適用のマイグレーションファイルを適用してくれるというものです。

app/infrastructure/db
├── connect.go
├── container
│   ├── db.go //ここを記述中
│   └── dockertest.go
├── migrations  // ここを適用してくれる
│   ├── 000001_create_users_table.down.sql
│   ├── 000001_create_users_table.up.sql
│   ├── 000002_change_column_password_to_hashed_password.down.sql
│   ├── 000002_change_column_password_to_hashed_password.up.sql
│   ├── 000003_add_column_timestamp_to_users.down.sql
│   └── 000003_add_column_timestamp_to_users.up.sql
├── mysql
│   └── conf.d
│       └── my.conf
└── sqlc
    ├── db.go
    ├── models.go
    ├── querier.go
    ├── queries
    │   └── user.sql
    ├── queries.go
    ├── sqlc.yml
    └── user.sql.go

工夫点として、 getMigrationsPath()を実装している点です。
「マイグレーションファイルへのパスを渡す」ために、../migrationsとしていましたが、これではうまくパスとして反映されませんでした。
というのも、それでは「SetupDBを呼び出した」階層を基点としてパスを探索するからです。
SetupDB()は、例えばrepositoryディレクトリにて呼ばれるわけなので、このままですと、repositoryデゥレクトリから、../migrationsを探索します。

├── adapter
│   │   ├── presentation
│   │   │   ├── health
│   │   │   │   ├── handler.go
│   │   │   │   └── response.go
│   │   │   ├── middleware
│   │   │   └── presenter
│   │   │       ├── failure.go
│   │   │       └── success.go
│   │   ├── query_service
│   │   └── repository      //ここから、../migrationsを探す =>ない。
│   │       ├── repository_test.go
│   │       ├── user_repository.go
│   │       └── user_repository_test.go

理想としているのは、SetupDBがある階層を基点としてmigrationsディレクトリに辿り着くことです。repository層からだけでなく、ハンドラの統合テストを行う際には別のディレクトリからまたSetDBが呼ばれることを考慮すると、どこから呼び出してもinfrastructure/db/containerを基点とした方が、関数を使う側としてはラクです。
そのため、runtimeパッケージを使用します。
以前にも同じような(少し違うけど)ケースに遭遇したので、解決法はパッと思い出せました。

https://zenn.dev/link/comments/fa1acfdbdabea5

要は、ランタイムに入って、コールスタックを操作します。
getMigrationsPathの中でruntime.Caller(0)を呼び出すと、Caller関数が呼ばれた階層がパスとして返ってきます。
コールスタックは、下から積み上げなので、以下のようになります。

Caller(skip int)の引数、skipは、遡るスタックの数を表すので、0を指定するとCallerが呼ばれた回想になります。この場合は、Caller(1)としても階層は変わりませんね。
ただ、Caller(2)とすると、

2024/12/13 18:09:48 failed to create migrate instance:failed to open source, "file:///Users/yutakakiki/practice_environment/go/todo-rest-api/app/adapter/migrations": open .: no such file or directory

2こスタックを遡るので、SetupDBの呼び出している階層、つまり、repositoryディレクトリから探索するので、一つ階層を上げたadapterディレクトリ配下にmigrationsディレクトリを探そうとしてしまっています。

あとは、テストデータを入れるためのfixtureを設定できればいいです。

kakkkykakkky

userRepository.Save()のテスト

リポジトリーテスト共通化処理。

repositrory/repository_test.go
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)
	// sqlcパッケージ変数*Queriesをセット
	sqlc.SetQueries(db.GetDB())
	log.Println("dockertest & test-db settings complete")
	m.Run()
}

userRepositroy.Save()の挙動を確かめるのに、userRepository.FindByEmail()を使用しました。

repository/user_repository_test.go
func TestUserRepository_Save_And_FindByEmail(t *testing.T) {
	t.Parallel()
	// ユーザーインスタンスを用意
	user1, _ := user.NewUser(
		"user1@example.com",
		"user1",
		"password",
	)
	userRepository := NewUserRepository()
	type arg struct {
		user  *user.User
		email user.Email
	}
	// テストテーブル
	tests := []struct {
		name    string
		arg     arg
		want    *user.User
		wantErr bool
	}{
		{
			name: "正常系: 1人ユーザーが追加され、emailで検索できる",
			arg: arg{
				user:  user1,
				email: user1.GetEmail(),
			},
			want:    user1,
			wantErr: false,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctx := context.Background()
			// ユーザーを保存
			if err := userRepository.Save(ctx, tt.arg.user); (err != nil) != tt.wantErr {
				t.Errorf("userRepository.Save()=error %v ,but wantErr:%v", err, tt.wantErr)
			}
			// ユーザーをemailで検索
			got, err := userRepository.FindByEmail(ctx, tt.arg.email)
			// 返すエラーはErrNotFoundUserのみであるべき
			if (err != nil) != tt.wantErr {
				t.Errorf("userRepository.FindByEmail() = error %v,but wantErr :%v", err, tt.wantErr)
			}
			if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(user.User{}, user.Email{}, user.HashedPassword{}), cmpopts.IgnoreFields(user.User{}, "id", "hashedPassword")); diff != "" {
				t.Errorf("userRepository.FindByEmail() -got,+want :%v ", diff)
			}
		})
	}
}

ただ、userRepository.FindByEmail()はまだテストすべき点があります。
Recordが見つからなかった際はドメインエラーを返すように実装しているので、そこを確かめるためにも、このメソッド単体でのテストは用意する必要があります。
そのためには、テストデータを用意する必要があるため、なんらかのパッケージを用いてテストデータを流し込んでテストごとにクリーンアップする処理を次に用意してから、userRepositoryの他のテストを実行していきます。

kakkkykakkky

testfixtureパッケージでフィクスチャ作成

dbディレクトリに、test_helperディレクトリを配置し、フィクスチャをDBに流し込むためのヘルパー処理を書きました。

infrastructure/db/fixtures.go
func SetupFixtures(t *testing.T, fixture_path string) {
	t.Helper()
	fixtures, err := testfixtures.New(
		testfixtures.Database(db.GetDB()),
		testfixtures.Dialect("mysql"),
		testfixtures.SkipResetSequences(),
		testfixtures.Files(
			fixture_path,
		),
	)
	if err != nil {
		log.Printf("testfixtures failed to create Loader instance:%v", err)
	}
	// テーブルのデータを削除&用意
	if err := fixtures.Load(); err != nil {
		log.Printf("testfixtures failed to load fixtures:%v", err)
	}
}

Load()関数では、テストデータを削除してから流し込んでくれるようです。

以下、フィクスチャを使ったテスト。

func TestUserRepository_FindByEmail(t *testing.T) {
	t.Parallel()
	userRepository := NewUserRepository()
	user1, _ := user.NewUser(
		"user1@test.com",
		"user1",
		"password",
	)
	user2, _ := user.NewUser(
		"user2@test.com",
		"user2",
		"password",
	)
	type args struct {
		email user.Email
	}
	tests := []struct {
		name    string
		args    args
		want    *user.User
		errType error
		wantErr bool
	}{
		{
			name: "正常系:ユーザーを検索できる",
			args: args{
				email: user1.GetEmail(),
			},
			want:    user1,
			wantErr: false,
		},
		{
			name: "準正常系:ユーザーが見つからなければErrNotFoundUserが返ってくる",
			args: args{
				// 存在しないuser2のemailで検索
				email: user2.GetEmail(),
			},
			errType: errors.ErrNotFoundUser,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// user1のみがDBに保存されている
			testhelper.SetupFixtures(t, "testdata/fixtures/users/users.yml")
			ctx := context.Background()
			got, err := userRepository.FindByEmail(ctx, tt.args.email)
			// 期待されるエラータイプが設定されている場合はそれも検証
			if (err != nil) != tt.wantErr && tt.errType != nil {
				t.Errorf("userRepository.FindByEmail() =error:%v, want errType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("userRepository.FindByEmail() =error:%v, wantErr:%v", err, err)
			}
			if diff := cmp.Diff(got, tt.want, cmp.AllowUnexported(user.User{}, user.Email{}, user.HashedPassword{}), cmpopts.IgnoreFields(user.User{}, "id", "hashedPassword")); diff != "" {
				t.Errorf("userRepository.FindByEmail() -got,+want :%v ", diff)
			}
		})
	}
}

kakkkykakkky

ユースケースの実装

認証機能は後回しにするとして、ユーザの閲覧(全体)・登録・更新・削除のユースケースを実装してきます。
また、以下の図のように、Domainを知っているのはapplication層までで留めておくべきです。
Usecaseの機能をハンドラが使用するわけですが、例えばRegisterUsecaseがDomainオブジェクトである*user.Userを返してしまうと、ドメインオブジェクトの影響範囲が大きくなりすぎてしまいます。
また、ユースケース層以降は技術的問題を解決する場所(json,ハンドラ,サーバー,ルーティング,,etc)であるため、ドメイン知識を流出させるのはここまでであるべきなのです。そのために、DataTransferObject、通称DTOを使用します。

以下のようにファイルを作っておきました。
登録から進めていきます。

   ├── application
│   │   └── usecase
│   │       └── user
│   │           ├── edit_profile_usecase.go  //編集
│   │           ├── list_users_usecase.go //ユーザーをリスト化
│   │           ├── register_usecase.go // 登録
│   │           └── unregister_usecase.go //退会
kakkkykakkky

(寄り道)ドメインエラーをもう少ししっかりと定義

ドメインエラーは、ビジネスルールを満たさなかったことが原因で起こすべきエラーのため、クライアントにフィードバックが必要。
今までは、個別のエラー(ErrUserNotFound)などしか判別できていませんでした。

ただ、例えばハンドラでは、ドメインエラーかそうじゃないかによって、プレゼンターを使い分けたいです。ステータスコードを出し分けるためとかにも。

今気づいたので、ここでエラー独自型を定義しておきました。
ErrDomainは単にerrorを持っているだけにしておき、Unwrap()は実装していません。
なるべくシンプルにエラーを扱いたかったのでこうしました。

domain/errors.go
// ユーザー関連のドメインエラー
var (
	ErrAlreadyRegisterd = newErrDomain("ErrAlreadyRegisterd", "you have been already registerd")
	ErrInvalidEmail     = newErrDomain("ErrInvalidEmail", "invalid email address")
	ErrPasswordTooShort = newErrDomain("ErrPasswordTooShort", "password is too short")
	ErrNotFoundUser     = newErrDomain("ErrNotFoundUser", "user not found")
)

// タスク関係のドメインエラー
var ()

// ドメインエラー
type ErrDomain struct {
	err error
}

// ドメインエラーのコンストラクタ
func newErrDomain(errType, message string) *ErrDomain {
	return &ErrDomain{
		err: fmt.Errorf("errType => %v,Message => %v", errType, message),
	}
}

// ドメインエラーかどうか
func IsDomainErr(target error) bool {
	var errDomain *ErrDomain
	return errors.As(target, &errDomain)
}

// errorインターフェースを満たすため
func (e *ErrDomain) Error() string {
	return e.err.Error()
}

// errors.Isをラップ
// パッケージ名の衝突を考慮
func Is(err, target error) bool {
	return errors.Is(err, target)
}
func New(message string) error {
	return errors.New(message)
}

テストも書いておきました。

func TestErrors(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name string
		fn   func() error
		err  *ErrDomain
	}{
		{
			name: "ErrDomain型としても、個別のエラー型としても判別できる",
			fn: func() error {
				return ErrNotFoundUser
			},
			err: ErrNotFoundUser,
		},
		{
			name: "エラー型がラップされていても判別できる",
			fn: func() error {
				wrappedErr := fmt.Errorf("error occured:%w", ErrNotFoundUser)
				return fmt.Errorf("error occured:%w", wrappedErr)
			},
			err: ErrNotFoundUser,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			err := tt.fn()
			if err != nil && !Is(err, tt.err) {
				t.Errorf("failed to identificate error")
			}
			if !IsDomainErr(err) {
				t.Errorf("failed to identificate ErrDomain")
			}
		})
	}
}

kakkkykakkky

デメテルの法則に従う。

「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」を読んでいて、今の自分の実装で引っかかる部分がありました。
Userドメインオブジェクトは、値オブジェクトhashedPasswordを持っています。
ユーザーが持つハッシュ化したパスワード

domain/user_hashed_password.go
// ハッシュ化されたパスワードと比較
func (p HashedPassword) Compare(target string) bool {
	return hash.Compare(p.value, target)
}

デメテルの法則とは、オブジェクト同士のメソッド呼び出しに関するガイドライン的法則です。
主に「集約」の話題で使われる法則です。

  • オブジェクト自身
  • 引数として渡されたオブジェクト
  • インスタンス変数
  • 直接インスタンス化したオブジェクト

たとえば車を運転するときタイヤに対して直接命令しないのと同じように、オブジェクトのフィールドに直接命令をするのではなく、それを保持するオブジェクトに対して命令を行い、フィールドは保持しているオブジェクト自身が管理すべきだということです。
成瀬 允宣. ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 (p.408). 株式会社翔泳社. Kindle 版.

集約ルートからのみ、値オブジェクトは操作されるべきです。
そうすると、ドメインルールが散らばることがこの先もなくなり、保守性が高まります。

Compaireメソッドをプライベートにし、Userに持たせました。

domain/user.go
// パスワードを比較する
func (u *User) ComparePassword(plainPassword string) bool {
	return u.hashedPassword.compare(plainPassword)
}
domain/user/user_hashed_password.go
// ハッシュ化されたパスワードと比較
// 集約ルートUserから呼び出す
func (p HashedPassword) compare(target string) bool {
	return hash.Compare(p.value, target)
}
kakkkykakkky

プレゼンテーション層の実装

ハンドラー型を満たす以下を実装

type PostUserHandler struct {
	registerUsecase *user.RegisterUsecase
}

func NewPostUserHandler(registerUsecase *user.RegisterUsecase) *PostUserHandler {
	return &PostUserHandler{
		registerUsecase: registerUsecase,
	}
}

// @Summary     ユーザーの作成
// @Description 新しいユーザーを作成します。
// @Tags        User
// @Accept      json
// @Produce     json
// @Param       request body     PostUserRequest                             true "ユーザー作成のための情報"
// @Success     201     {object} presenter.SuccessResponse[PostUserResponse] "作成されたユーザーの情報"
// @Failure     400     {object} presenter.FailureResponse                   "不正なリクエスト"
// @Failure     500     {object} presenter.FailureResponse                   "内部サーバーエラー"
// @Router      /user [post]
func (puh *PostUserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// jsonをデコード
	var params PostUserRequest
	if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
		presenter.RespondBadRequest(w, err.Error())
		return
	}
	if err := validation.NewValidation().Struct(&params); err != nil {
		presenter.RespondBadRequest(w, err.Error())
		return
	}
	// DTOに詰め替える
	input := user.RegisterUsecaseInputDTO{
		Name:     params.Name,
		Email:    params.Email,
		Password: params.Password,
	}
	// ユースケースに渡して実行
	ctx := r.Context()
	output, err := puh.registerUsecase.Run(ctx, input)
	if (err != nil) && errors.IsDomainErr(err) {
		presenter.RespondBadRequest(w, err.Error())
		return
	}
	if err != nil {
		presenter.RespondInternalServerError(w, err.Error())
		return
	}
	resp := PostUserResponse{
		Email: output.Email,
		Name:  output.Name,
	}
	presenter.RespondCreated(w, resp)
}

kakkkykakkky

ユーザー更新に再構成ファクトリはダメじゃないか??

update_profile_usecase.go
func (epu *UpdateProfileUsecase) Run(ctx context.Context, input UpdateProfileUsecaseInputDTO) (
	*UpdateProfileUsecaseOutputDTO, error,
) {
	u, err := epu.userRepository.FindById(ctx, input.ID)
	if err != nil || u == nil {
		return nil, err
	}
	// ここが怪しい
	u = user.ReconstructUser(
		input.ID,
		input.Email,
		input.Name,
		"",
	)
	if err := epu.userRepository.Update(ctx, u); err != nil {
		return nil, err
	}
	return &UpdateProfileUsecaseOutputDTO{
		ID:    u.GetID(),
		Email: u.GetEmail().Value(),
		Name:  u.GetName(),
	}, nil
}
// 既存のユーザーを返す
// インスタンスの再構成
func ReconstructUser(
	id string,
	email string,
	name string,
	hashedPassword string,
) *User {
	return &User{
		id:             id,
		email:          reconstructEmail(email),
		name:           name,
		hashedPassword: reconstructHashedPassword(hashedPassword),
	}
}

再構成は、リポジトリからインスタンスを復元するに徹するべきであって、その点においてリポジトリからしか使われるべきでないはず。
さらに、ユーザーインスタンスを更新するのに、Emailをバリデーションなしで通してしまうのも問題(本当はここから違和感を感じたところから始まった)。

かといって、newUserはIDを生成するので使えない。
と言うわけで、ドメインにインスタンス更新ロジックを記述。

domain/user.go
// ユーザーオブジェクト更新
func UpdateUser(
	id string,
	email string,
	name string,
	hashedPassword string,
) (*User, error) {
	validatedEmail, err := newEmail(email)
	if err != nil {
		return nil, err
	}
	return &User{
		id:             id,
		email:          validatedEmail,
		name:           name,
		hashedPassword: reconstructHashedPassword(hashedPassword),
	}, nil
}

最終的にユースケースは以下のようになった。

func (epu *UpdateProfileUsecase) Run(ctx context.Context, input UpdateProfileUsecaseInputDTO) (
	*UpdateProfileUsecaseOutputDTO, error,
) {
	// 存在しているユーザーしか編集できない
	u, err := epu.userRepository.FindById(ctx, input.ID)
	// エラーかユーザーがnilの場合はエラー
	if err != nil || u == nil {
		return nil, err
	}
	// 値が空の場合は既存情報を入れる
	if input.Name == "" {
		input.Name = u.GetName()
	}
	if input.Email == "" {
		input.Email = u.GetEmail().Value()
	}
	// input情報をもとに、更新情報を反映したインスタンスを作成
	updatedUser, err := user.UpdateUser(
		input.ID,
		input.Email,
		input.Name,
		u.GetHashedPassword().Value(), //パスワードはそのまま
	)
	if err != nil {
		return nil, err
	}
	if err := epu.userRepository.Update(ctx, u); err != nil {
		return nil, err
	}
	// 更新したオブジェクトをDTOに詰め替える
	return &UpdateProfileUsecaseOutputDTO{
		ID:    updatedUser.GetID(),
		Email: updatedUser.GetEmail().Value(),
		Name:  updatedUser.GetName(),
	}, nil
}

kakkkykakkky

ロガーミドルウェア実装

以下のようになった。

// HTTP リクエストとレスポンスをログに記録するミドルウェア
func Logger(h http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		// リクエストの処理
		requestLog(r)

		// レスポンス用ラッパーを作成
		buf := &bytes.Buffer{} //レスポンスボディを書き込むためのバッファ
		rww := newResponseWrapper(rw, buf)

		// ハンドラ呼び出し
		// ラッパーをResponseWriterとして渡し、バッファにもレスポンスを書き込むようにする
		h.ServeHTTP(rww, r)

		// レスポンスの処理
		responseLog(rww, buf)
	})
}

// リクエストボディを取得しログに出力
func requestLog(r *http.Request) {
	reqBody, _ := io.ReadAll(r.Body)
	defer r.Body.Close()

	strReqBody := string(reqBody)
	if strReqBody == "" {
		strReqBody = "{}"
	}

	// リクエストボディをログに記録
	fmt.Printf("-----------------------------------------------------------------------------------------------------\n"+
		"[REQUEST]\n"+
		"Timestamp  : %s\n"+
		"Method     : %s\n"+
		"URL        : %s\n"+
		"Body       : \n%s\n",
		time.Now().Format(time.RFC3339),
		r.Method,
		r.URL.Path,
		strReqBody)

	// リクエストボディを再代入
	r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}

//  レスポンスボディを整形しログに出力
func responseLog(rww *rwWrapper, buf *bytes.Buffer) {
	var formattedRespBody bytes.Buffer
	json.Indent(&formattedRespBody, buf.Bytes(), "", "    ")
	status := rww.statusCode
	fmt.Printf(
		"\n[RESPONSE]\n"+
			"Status     : %d\n"+
			"Body       :\n%s"+
			"-----------------------------------------------------------------------------------------------------\n",
		status,
		&formattedRespBody,
	)
}

// レスポンスのログ記録用にレスポンスをラップ
type rwWrapper struct {
	rw          http.ResponseWriter
	multiWriter io.Writer
	statusCode  int
}

func newResponseWrapper(rw http.ResponseWriter, buf io.Writer) *rwWrapper {
	return &rwWrapper{
		rw:          rw,
		multiWriter: io.MultiWriter(rw, buf),
	}
}

// http.ResponseWriterインターフェースを満たすためのメソッド
func (rww *rwWrapper) Header() http.Header {
	return rww.rw.Header()
}
func (rww *rwWrapper) Write(b []byte) (int, error) {
	return rww.multiWriter.Write(b)
}

func (rww *rwWrapper) WriteHeader(statusCode int) {
	rww.statusCode = statusCode
	rww.rw.WriteHeader(statusCode)
}

ポイントは、http.ResponseWriterインターフェースを満たすラッパー構造体を定義してレスポンスボディを読み取り可能にした点です。
見れば分かり通りですが、書き込み用のメソッドしか定義されていません。(Response"Writer"なので)

type ResponseWriter interface {
	Header() Header
	Write([]byte) (int, error)
	WriteHeader(statusCode int)
}

読み取るためには、インターフェースを満たしつつ、ResponseWriterに出力するのと同時に、ログに吐く用の書き込み先を用意して、書き込ませる必要があります。

type rwWrapper struct {
	rw          http.ResponseWriter
	multiWriter io.Writer
	statusCode  int
}

io.MultiWriterを使用します。

// MultiWriter は、提供された複数の writer への書き込みを複製する writer を作成します。
// これは、Unix の tee(1) コマンドに似ています。
//
// 各書き込みは、リストされた各 writer に対して順番に書き込まれます。
// もしリストされた writer のいずれかがエラーを返した場合、
// その書き込み操作全体が停止し、エラーが返されます。
// リストの後続の writer への書き込みは行われません。
func MultiWriter(writers ...Writer) Writer {
    //省略
}

http.ResponseWriterio.Writerを入れてラッパー構造体を初期化しています。

func newResponseWrapper(rw http.ResponseWriter, buf io.Writer) *rwWrapper {
	return &rwWrapper{
		rw:          rw,
		multiWriter: io.MultiWriter(rw, buf),
	}
}

具体型として、io.Writerにはbuf := &bytes.Buffer{} を入れています。io.Writerを満たす[]byteを用意してくれます。
http.ResponseWriterを満たすためのメソッドWriteでは、multiWriter.Write()によって、両方のライターに書き込んでもらっています。
これによって。レスポンスとして送られるのと同時にバッファにも書き込まれ、ログに使うことができています。

func (rww *rwWrapper) Write(b []byte) (int, error) {
	return rww.multiWriter.Write(b)
}
kakkkykakkky

テーブル形式で出力される

こんな感じです一旦。

todo-api  | -----------------------------------------------------------------------------------------------------
todo-api  | [REQUEST]
todo-api  | Timestamp  : 2024-12-18T05:25:50Z
todo-api  | Method     : GET
todo-api  | URL        : /users
todo-api  | Body       : 
todo-api  | 
todo-api  | 
todo-api  | [RESPONSE]
todo-api  | Timestamp  : 2024-12-18T05:25:50Z
todo-api  | Status     : 200
todo-api  | Body       :
todo-api  | {
todo-api  |     "status": 200,
todo-api  |     "data": [
todo-api  |         {
todo-api  |             "id": "01JFAVVESPAFRHB2JZWFZDGRDD",
todo-api  |             "name": "update!"
todo-api  |         },
todo-api  |         {
todo-api  |             "id": "01JFBZRB3RSFJW5FDNEB4FAWYC",
todo-api  |             "name": "segbsb"
todo-api  |         }
todo-api  |     ]
todo-api  | }
todo-api  | -----------------------------------------------------------------------------------------------------
kakkkykakkky

認証ってどこの層????

まず、ドメイン層ではないことは最初に言えそうです。
認証は明らかにアプリケーションとして成り立たせるのに必要な技術的機能要件であるから、ドメイン知識は持ち得ませんよね。

となると、、候補は

  • インフラ層
  • ユースケース層
  • アダプタ層

が残りますね。
アダプタは違うかな。ミドルウェア(認可)とハンドラ(ログイン・ログアウト)はそこで実装するけれども。
ユースケースもどうだろうなぁ。ログイン、ログアウトはユースケースとしても用意する...気がしているけど、
認証で肝心のjwtに関する処理をどこの層と位置付けるべきなのか。
消去法的に見ればインフラ(framework&driver)そうになるのかなといった感じです。実際、外部のライブラリありきの実装なので間違ってはいなさそうですね。

問題は、これを使うのならば、依存関係の方向を外側に向けないようにしなければなりません。
ユースケース層にJWTのインターフェースをおき、具体実装としてinfrastructure層にauth/jwt.goを置きます。

これなら大丈夫かな汗汗。

https://zenn.dev/praha/articles/5c05ab671fb7ab#認証結果のインターフェースはアプリケーション層に書くの?

kakkkykakkky

JWT関連の実装

以下のように配置しておいた。依存関係逆転は守れていると思います。
公開鍵・秘密鍵で行う暗号化のアルゴリズムRS256を使用する方針でいきます。
色々参考にしつつ、また実装していくことにします。

├── app
│   ├── adapter
│   │   ├── repository
│   │   │   ├── token_authenticator_repository.go //kvsでトークンを操作する
│   │   │   
│   ├── application
│   │   └── usecase
│   │       └── user
│   │           ├── auth //ユーザ認証系ユースケース
│   │           │   ├── interface_token_authenticator_repository.go //リポジトリのインタフェース
│   │           │   └── interface_token_authenticator.go //トークンを生成する処理のインターフェーす
│   ├── domain
│   ├── infrastructure
│   │   ├── auth
│   │   │   └── jwt_authenticator.go //トークンを生成する・認証する処理の具体実装

https://iketechblog.com/go-jwt/

https://golang-jwt.github.io/jwt/usage/create/

https://zenn.dev/nameless_sn/articles/the_best_practice_of_jwt

https://zenn.dev/yuki_tu/articles/fd1bd44becebd5

kakkkykakkky

JWTによる認証の実装の流れをここで整理したい

ログイン

  1. 入力されたemail&passwordを入力させる
    • emailでユーザーを検索
    • passwordをhashed_passwordと比較(Userドメインオブジェクトに実装したロジックがある)
  2. 1が通れば(エラーがないならば)、JWTトークンを発行する
    • JWTIDにはulidを識別子とする
    • 有効期限情報も付加する
    • クレームにはuser_id等を含める
  3. JWTトークンIDをRedisに保存する
  4. JWTを秘密鍵で署名する
  5. 署名ずみのJWTトークン返す
    • ハンドラがjsonに詰めてクライアントに返すことになる

認可制御

  1. ログイン済みのユーザーがリクエストを送る
    • ヘッダにJWTトークンを付加する
  2. サーバーがヘッダのトークンを読み取る
  3. トークンを公開鍵で復号し、jwtidとuser_idを取得する
  4. redisにuser_idをキーとしてレコードがあるか問い合わせる
    • あったら、バリューとしてjwtidを取得する
  5. redisから取得したjwtidと、ヘッダーのトークンから復号して取り出したjwtidを比較する
    • 有効期限が切れていたら、セッションエラーとする
    • 比較に成功したら、redisのレコードに設定していた有効期限を延長させる
  6. リクエストスコープのContextにuser_idを含める
  7. 後続のハンドラー処理は、Contextからuser_idを取得して処理を行う
    • 退会処理などパスパラメータに含めているので、認証の実装が済んだ後でまた書き換える
kakkkykakkky

秘密鍵、公開鍵の生成

秘密鍵

openssl genrsa 4096 > ./app/infrastructure/auth/certificate/private.pem

公開鍵

さっき生成した秘密鍵を元に生成。

openssl rsa -pubout < ./app/infrastructure/auth/certificate/private.pem > ./app/infrastructure/auth/certificate/public.pem
writing RSA key

これを、それぞれ*rsa.XxxxKey型にパースする必要がありそう。

https://christina04.hatenablog.com/entry/2017/04/15/042646

kakkkykakkky

認証認可できたけど、また本でしっかり書くことにします。

kakkkykakkky

プレゼンテーション層(エンドポイント)をテストする

ハンドラ・プレゼンター・ミドルウェアを統合テストとして行う案。
ユニットテストとして行うと、ユースケースをモック化する必要があり、テストコードの複雑性がかなり増す。
また、アプリケーションが依存している外部サービス(DB、KVS等々)も実際に検証を行いたい。

runn やpostmanを用いたシナリオテストなどあるが、今回はgoldenファイルを用いたE2Eテストを行うことにした。

kakkkykakkky

(結合テストのその前に)認可制御ミドルウェアのロジックをユースケースに移譲する

ゴールデンテストを用いたE2Eテストはレスポンスを返すその部分だけ担保するためのもの。とすれば、やはりロジックの正しさを担保するのはユニットテストであるべき。
とすると、今自分が書いた状態の認可制御ミドルウェアはすごくテストしにくい。

現在のミドルウェアのコード

type UserIDKey struct{}

func Authorication(tokenAuthenticator auth.TokenAuthenticator, tokenAuthenticatorRepository auth.TokenAuthenticatorRepository) func(h http.Handler) http.Handler {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			userID, err := authenticateRequest(r, tokenAuthenticator, tokenAuthenticatorRepository)
			if err != nil {
				presenter.RespondUnAuthorized(w, err.Error())
				return
			}
			// コンテキストにuserIDを含める
			ctx := context.WithValue(r.Context(), UserIDKey{}, userID)
			// 後続の処理(ハンドラ)を実行する
			h.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func authenticateRequest(r *http.Request, tokenAuthenticator auth.TokenAuthenticator, tokenAuthenticatorRepository auth.TokenAuthenticatorRepository) (string, error) {
	// JWT トークンを取得
	signedToken, err := getSignedToken(r)
	if err != nil {
		return "", err
	}

	// トークンを検証
	token, err := tokenAuthenticator.VerifyToken(signedToken)
	if err != nil {
		return "", err
	}

	// トークンの有効期限を検証
	if err := tokenAuthenticator.VerifyExpiresAt(token); err != nil {
		return "", err
	}

	// JWT クレームから情報を取得
	jti, err := tokenAuthenticator.GetJWTIDFromClaim(token)
	if err != nil {
		return "", err
	}
	userID, err := tokenAuthenticator.GetSubFromClaim(token)
	if err != nil {
		return "", err
	}

	// KVS から保存された jti を取得
	jtiFromKVS, err := tokenAuthenticatorRepository.Load(r.Context(), userID)
	if err != nil {
		return "", err
	}

	// jti が一致しない場合はエラー
	if jti != jtiFromKVS {
		return "", errors.New("invalid JWT ID")
	}

	return userID, nil
}

func getSignedToken(r *http.Request) (string, error) {
	authorizationHeader := r.Header.Get("Authorization")
	if authorizationHeader == "" {
		return "", errors.New("Authorization Header is missing")
	}

	// スペースを区切りとして最大2つに分割
	parts := strings.SplitN(authorizationHeader, " ", 2)
	if len(parts) != 2 || parts[0] != "Bearer" {
		return "", errors.New("invalid Authorization header format")
	}

	return parts[1], nil
}

テストしたいのは、認証ロジックの部分。
ミドルウェアがそういったロジックを持つべきではないため、ユースケースを用意して、認証ロジック自体はそこでテストします。

すっきりしたミドルウェア。
あくまで、ヘッダから署名済みトークンを取り出してユースケースに渡し、結果によってレスポンスするに徹するようになった。責務としてもいい感じ。
contextにuserIDを付加するのはミドルウェアの責務でいいと思います。userIDは後続のハンドラ処理で使う情報なので、ここで処理しています。

func Authorication(authorizationUsecase *auth.AuthorizationUsecase) func(h http.Handler) http.Handler {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			signedToken, err := getSignedToken(r)
			if err != nil {
				presenter.RespondUnAuthorized(w, err.Error())
				return
			}
			input := auth.AuthorizationInputDTO{SignedToken: signedToken}
			output, err := authorizationUsecase.Run(r.Context(), input)
			if err != nil {
				presenter.RespondUnAuthorized(w, err.Error())
			}
			userID := output.UserID
			// コンテキストにuserIDを含める
			ctx := context.WithValue(r.Context(), UserIDKey{}, userID)
			// 後続の処理(ハンドラ)を実行する
			h.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func getSignedToken(r *http.Request) (string, error) {
	authorizationHeader := r.Header.Get("Authorization")
	if authorizationHeader == "" {
		return "", errors.New("Authorization Header is missing")
	}
	// スペースを区切りとして最大2つに分割
	parts := strings.SplitN(authorizationHeader, " ", 2)
	if len(parts) != 2 || parts[0] != "Bearer" {
		return "", errors.New("invalid Authorization header format")
	}
	return parts[1], nil
}

リポジトリとトークン処理をもつコンポーネントを駆使して、認証ロジックを担っている。

type AuthorizationUsecase struct {
	tokenAuthenticator           TokenAuthenticator
	tokenAuthenticatorRepository TokenAuthenticatorRepository
}

func NewAuthorizationUsecase(
	tokenAuthenticator TokenAuthenticator,
	tokenAuthenticatorRepository TokenAuthenticatorRepository,
) *AuthorizationUsecase {
	return &AuthorizationUsecase{
		tokenAuthenticator:           tokenAuthenticator,
		tokenAuthenticatorRepository: tokenAuthenticatorRepository,
	}
}

func (au *AuthorizationUsecase) Run(ctx context.Context, input AuthorizationInputDTO) (
	*AuthorizationOutputDTO,
	error,
) {
	// 公開鍵で署名済みトークンを検証する
	// 解読されたトークンが返る
	token, err := au.tokenAuthenticator.VerifyToken(input.SignedToken)
	if err != nil {
		return nil, err
	}
	// トークンの有効期限を検証
	if err := au.tokenAuthenticator.VerifyExpiresAt(token); err != nil {
		return nil, err
	}
	// JWT クレームから情報を取得
	jti, err := au.tokenAuthenticator.GetJWTIDFromClaim(token)
	if err != nil {
		return nil, err
	}
	userID, err := au.tokenAuthenticator.GetSubFromClaim(token)
	if err != nil {
		return nil, err
	}
	// KVS から保存された jti を取得
	// ログアウトしている場合は、KVSから対応のjtiが削除されるため、
	// ここでエラーが返る
	jtiFromKVS, err := au.tokenAuthenticatorRepository.Load(ctx, userID)
	if err != nil {
		return nil, err
	}
	// jti が一致しない場合はエラー
	if jti != jtiFromKVS {
		return nil, errors.New("invalid JWT ID")
	}
	return &AuthorizationOutputDTO{
		UserID: userID,
	}, nil
}
func TestAuthorizationUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		input   AuthorizationInputDTO
		mockFn  func(ma *MockTokenAuthenticator, mar *MockTokenAuthenticatorRepository)
		want    *AuthorizationOutputDTO
		wantErr bool
	}{
		{
			name: "正常系:トークンの認証に成功し、userIDを返す",
			input: AuthorizationInputDTO{
				SignedToken: "signedToken",
			},
			mockFn: func(ma *MockTokenAuthenticator, mar *MockTokenAuthenticatorRepository) {
				ma.EXPECT().VerifyToken(gomock.Any()).Return(&jwt.Token{}, nil)
				ma.EXPECT().VerifyExpiresAt(&jwt.Token{}).Return(nil)
				ma.EXPECT().GetJWTIDFromClaim(&jwt.Token{}).Return("jti", nil)
				ma.EXPECT().GetSubFromClaim(&jwt.Token{}).Return("userID", nil)
				mar.EXPECT().Load(gomock.Any(), "userID").Return("jti", nil)
			},
			want: &AuthorizationOutputDTO{
				UserID: "userID",
			},
			wantErr: false,
		},
		{
			name: "準正常系:kvsから得たjtiとトークンから得たjtiが一致しない",
			input: AuthorizationInputDTO{
				SignedToken: "signedToken",
			},
			mockFn: func(ma *MockTokenAuthenticator, mar *MockTokenAuthenticatorRepository) {
				ma.EXPECT().VerifyToken(gomock.Any()).Return(&jwt.Token{}, nil)
				ma.EXPECT().VerifyExpiresAt(&jwt.Token{}).Return(nil)
				ma.EXPECT().GetJWTIDFromClaim(&jwt.Token{}).Return("jti", nil)
				ma.EXPECT().GetSubFromClaim(&jwt.Token{}).Return("userID", nil)
				mar.EXPECT().Load(gomock.Any(), "userID").Return("", nil)
			},
			want: &AuthorizationOutputDTO{
				UserID: "userID",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctrl := gomock.NewController(t)
			mockAuthenticator := NewMockTokenAuthenticator(ctrl)
			mockAuthenticatorRepository := NewMockTokenAuthenticatorRepository(ctrl)
			tt.mockFn(mockAuthenticator, mockAuthenticatorRepository)

			sut := NewAuthorizationUsecase(mockAuthenticator, mockAuthenticatorRepository)
			ctx := context.Background()
			got, err := sut.Run(ctx, tt.input)
			if (err != nil) != tt.wantErr {
				t.Errorf("AuthorizationUsecase.Run error=%v,but wantErr%v", err, tt.wantErr)
			}
			// エラーが起きた場合はoutputを返さない
			if got == nil {
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("AuthorizationUsecase.Run -got,+want :%v", diff)
			}
		})
	}
}

kakkkykakkky

結合テストの方針

プレゼンテーション層(ハンドラ+プレゼンター)のテストは、
モックを用意せずに統合テストとして行い、その際にゴールデンテストを用いる。

ゴールデンテストとして行う意向としては、リクエストに対するレスポンス構造を担保するため。
レスポンス構造の不変をテストできるが、ロジックはテストできない。

中身のロジックは、ユースケース、ドメインモデル、サービスそれぞれのユニットテストで守っている。

https://speakerdeck.com/toc0522/go-de-golden-file-test?slide=8
https://swet.dena.com/entry/2020/03/16/173000
https://developer.so-tech.co.jp/entry/2021/11/01/155158

ゴールデンテストライブラリ、goldieを使用。
https://github.com/sebdah/goldie。

TestMainでテスト用DB、テスト用サーバー等々を用意。
レスポンスの構造が保たれているかを検証する。
プレゼンター層に置こうか迷いましたが、エンドポイントとなるとルーティングも絡んできます。そのため、infrastructure/api_test/としてテストファイルを置いていくことにしました。

kakkkykakkky
//go:build integration_write

とすると、WARNINGが出てしまった。メッセージがコードエディタにおそらく表示されるので、それに従えばいいが、以下のようにすればいいです。

setting.json
 "go.buildFlags": [
    "-tags=integration_read,integration_write",
  ],
kakkkykakkky

Todoタスク機能

ユースケース

  • ユーザーのタスク登録
  • ユーザーのタスク削除
  • ユーザーのタスク編集
  • ユーザーのタスク状態変更

ユビキタス言語

言語 説明
タスク ユーザーによって操作される項目。
タスク登録 タスクを新しく作成しシステムに保存する操作。
タスク編集 タスクの内容や詳細を変更する操作。
タスク削除 タスクをシステムから削除する操作。
タスク状態 タスクの進捗を表す属性(例: todo/doing/done)。
タスク状態変更 タスクの状態(todo/doing/done)を更新する操作。

エンドポイント

メソッド パス 概要
POST /task タスク登録
GET /tasks タスク一覧表示
GET /task/{id} タスク表示
PATCH /task タスク状態変更
DELETE /task/{id} タスク削除
kakkkykakkky

ドメインオブジェクト実装

コンストラクタ、再構成、更新メソッド(stateのみ)を実装。
ドメインサービスは要しませんので、リポジトリ、ユースケース、ハンドラと順に実装していきます。

package task

import "github.com/kakkky/pkg/ulid"

type Task struct {
	id      string
	userId  string
	content content
	state   state
}

func NewTask(
	userId string,
	content string,
	state string,
) (*Task, error) {
	validatedContent, err := newContent(content)
	if err != nil {
		return nil, err
	}
	validatedState, err := newState(state)
	if err != nil {
		return nil, err
	}
	return &Task{
		id:      ulid.NewUlid(),
		userId:  userId,
		content: validatedContent,
		state:   validatedState,
	}, nil
}

// リポジトリから使用
func ReconstructTask(
	id string,
	userId string,
	content string,
	state int, //DBにはint型でタスク状態を保存している
) *Task {
	return &Task{
		id:      id,
		userId:  userId,
		content: reconstructContent(content),
		state:   reconstructState(state),
	}
}

func (t *Task) UpdateState(
	state string,
) (*Task, error) {
	validatedState, err := newState(state)
	if err != nil {
		return nil, err
	}
	return &Task{
		id:      t.id,
		userId:  t.userId,
		content: t.content,
		state:   validatedState,
	}, nil
}

kakkkykakkky

ユースケース

  • タスク作成
  • タスク削除
  • タスク一覧表示
  • タスク表示
  • タスク状態更新
kakkkykakkky

リポジトリ

タスク表示に関しては、タスク内容、タスク状態、タスク作成者を表示させたい。
その際には、CQRSパターンを利用して、クエリサービスモデルを部分的に導入・実装し、ユースケースがそれを利用する。

CQRS

クエリサービスというオブジェクトを用意して、複数集約を参照する際に有用なパターン。
クエリサービスオブジェクトのインターフェースを usecase 層に配置する主な理由は、ユースケースが参照する集約パターンに依存しているから。
user+taskを取得したいというのは、ドメイン知識ではなくユースケースが決めていること。

https://zenn.dev/shmi593/articles/c1baeb2d453929
https://little-hands.hatenablog.com/entry/2019/12/02/cqrs