golangで認証機能を搭載したtodoアプリAPIを実装する
ハンズオンの2周目を、カスタマイズしつつ開発する
以下の書籍を数ヶ月前に一度ハンズオンとして行いましたが、その後色々な知見を得る中で、「自分ならこうしたい」を模索しつつ改めて開発してみようと思います。
機能
概要
認証付きの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)層におくことにしました。
ユースケースとして、ログイン・ログアウト・認可を用意して、ログイン・ログアウトはハンドラ、認可をミドルウェアとしてプレゼンテーション層で利用します。
開発環境を整える
使用技術選定
- データベース : MySQL
- キーバリューストア : Redis (認証で使用)
- webサーバー : net/http (標準)
- ORM : sqlc
webサーバーに、フレームワーク/ルーティングライブラリを導入しない理由は、以下の記事にて。
sqlcを選んだのは、最終的に実行されるSQLが明らかであることが理由としてあります。
railsのActive Recordなどでは、「これってどんなクエリが実行されたんだろう?」と気になり、若干不透明な部分があります。(ログを見てわかる)
sqlcは、SQLを書いて、それをコンパイルしてGoのコードにしてくれます。非常にシンプルです。
docker
本番環境にアップするならマルチステージビルドを設定したりするのが理想ですがあくまで開発環境でしか扱わないため、今回は実装しません。
開発用コンテナに便利な、ホットリロードを実現するairを導入します。
以下が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"]
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./main.go"
この後、Makefileやdbのセットアップを行います
開発環境を整える - DB編
データベースにはMySQLを使用し、マイグレーションにはgo-migrate
というツールを新たに導入してみます。
go-migrateに関しては、ローカルでbrewでダウンロードしてもいいのですが、今回はdocker上ですべて完結させたいと思います。デメリットとして、コンテナが若干重くなるのがあります。
go installでできるんですね。brewでするしかないのかなと思っていました。
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"]
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
これによって未使用データを削除します。
めちゃくちゃ未使用のVolumeが溜まっていたようです。
.
.
.
r5piey5hk2vjhi2x2fji7h0yu
iq8d54zq4ouzhij2zx862o7li
hztxlogzlz749ekk8jikdfs0c
Total reclaimed space: 39.88GB
GUIので消したと思ってたんですけどね。
DBのセットアップはこれで完了です。
次は、go-migrateで作成したテーブルがsqlcで読み込めるかどうかを確かめます。
開発環境を整えるーマイグレーション編
上記の記事に従い、試していくと、
エラーが。
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#
以下のような記事を見つけました。
記事に従ってみると解決。
簡単にマイグレーションができそうです。(簡易的に行った)
sqlc関係は、また実装が必要な時に行おうと思います。
DBとの接続は終了です。
Makefileを作成する
以下を参考にしつつ、取り合えあえず必要そうなコマンドを用意していく。
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
後々、テストなどは追加で書いてくことになると思われます。一旦。
Redisコンテナ
抽象化してkvsコンテナとして扱うことにしました。
kvs:
container_name: todo-kvs
image: redis:latest
ports:
- "6379:6379"
volumes:
- type: volume
source: kvs-store
target: /data
tty: true
Config作成
もう一つ有名かつ使ったことある中にcaarlos0/env
というものもありますが、
こちらのパッケージを利用します。
少しだけスター数が多いのと、コミュニティも活発っぽかったというのと、幾分かすっきりした記述で環境変数を読み込めるという理由です。
Configはどこからでも参照できるようにしておきます。
// パッケージ変数として設定
var config Config
func InitConfig() error {
if err := envconfig.Process("", &config); err != nil {
return err
}
return nil
}
DBセットアップもう少し
go-migrateでマイグレーションを管理して、sqlcでそれを読み込む
gop-migrateでマイグレーションした歴をスキーマとして追跡できるらしい。
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)
}
テーブルを作成する際、
マイグレーション(create , up)→sqlc generate の順番
またテーブル設計後、sqlcのdocmentを読みます。
webサーバを実装 〜サーバー処理のカプセル化〜
サーバー起動、シャットダウン等の処理とマルチプレクサによるルーティング処理は分離させる。
また、書籍の中のハンズオンでは、ポート番号を動的に設定できるようにポートをリッスンする処理も分離していたが、今回は必要ない&複雑性がますと考えたのでそこは見送る。
また、シャットダウン処理を行うには、http.Serve
型のListenAndServe
を呼び出す必要がある。
そのため、*http.Server
を独自のserver
型でラップして、コンストラクタとして用意した。
server
型にメソッドを持たせて、サーバの起動処理をカプセル化する。
コンストラクタには、マルチプレクサをhttp.Hadler
として抽象化しても良かったが、マルチプレクサを渡すことを強制させたかったので、明示的に具体型を書くことにした。
// *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,
},
}
}
webサーバーの実装 ~起動&シャットダウン処理を実装~
実装は以下の通り。
// サーバーを起動する
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()
はブロッキング処理です。そのまま呼び出すと、プログラムでこの行以降、すすみません。複数のサーバーを立てる際にもこの実装は有効です。
しかしながら、ゴルーチンで実行することの一番の旨みは、外部でキャンセル通知をリッスンしつつサーバーのシャットダウンを実装できることじゃないかなと思います。
また、単なるゴルーチンでなく、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文で呼ぶようにします。
というような感じで、サーバーの起動&エラーハンドリング/シグナル検知に対応したグレースフルシャットダウンを実装しました。
次は、マルチプレクサの処理を書いていきます。
ルーティング処理の実装
ルーティングが登録されたマルチプレクサを返します。
ヘルスチェックハンドラを用意しておきました。
// ルーティングを登録したマルチプレクサを返す
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
}
}
以下の実装を参考にしました。
使い方としては、まず各ハンドラ登録関数の中で、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種類の複合ミドルウェアを用意すればいいです。
swaggerでAPI ドキュメントをつけてみる
ネットで見ると、色々やり方はあったが、自分は以下のようにセットアップ。
基本的に以下のドキュメントに従って行ないました。
ドキュメントによれば、go install
でインストールする必要があるようです。
install系は基本的にDockerfileに記載しておきたい方針なので、以下のようにRUN
コマンドを追加します。
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
やっと機能実装
ここまでで、DB&サーバー(+ルーティング)等のインフラ系(redisはこの後やる)、Dockerやswagger等の開発環境を実装しました。
やっとこさAPI機能面の以下の流れで進めていく。
- ユーザー登録、削除、閲覧
- 認証、認可機能
- Todoタスク機能
それぞれ、ドメイン〜ユースケース〜プレゼンテーション(ハンドラ+プレゼンター)の順に実装していく。
ユニットテストを実装しつつ、エンドポイント(ハンドラ)のテストでは統合テストを適用します。
ドメインエラー
ドメイン層で起きるエラーは、ビジネスロジックに起因するものでありユーザーにフィードバックが必要なエラーである
ドメイン層でエラーを受けとって返すために、エラーハンドリングを実装する。
今回はなるべくシンプルにエラー型を定義したいなと考えて構成を探していると、以下の記事を見つけた。
domain/error/error.goとして、ドメイン層におけるエラー型を実装する。
独自エラー型は、Err-
とプレフィックスをつけるのが慣習のようなので、律儀にそれに倣う。
エラー型は適宜追加していく。
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)
}
アプリケーションの仕様
そういえば。
ユースケースとエンドポイント、ユビキタス言語を定義しておきます。
ユースケース
- ユーザーの登録
- ユーザーの情報編集
- ユーザーの削除
- ユーザーのログイン
- ユーザーのログアウト
- ユーザーのタスク登録
- ユーザーのタスク削除
- ユーザーのタスク編集
- ユーザーのタスク状態変更
ユビキタス言語
ピックアップ。
ユーザーとタスクがドメインモデル。
言語 | 説明 |
---|---|
ユーザー | 認証やタスクを登録・管理する人。 |
ユーザー登録 | ユーザーの情報をシステムに保存する操作。 |
ユーザー編集 | ユーザーの名前や認証情報などを更新する操作。 |
ユーザー削除 | ユーザーの情報をシステムから削除する操作。 |
ログイン | ユーザーがシステムに認証を行いアクセスする操作。 |
ログアウト | ユーザーがシステムから認証状態を解除する操作。 |
タスク | ユーザーによって操作される項目。 |
タスク登録 | タスクを新しく作成しシステムに保存する操作。 |
タスク編集 | タスクの内容や詳細を変更する操作。 |
タスク削除 | タスクをシステムから削除する操作。 |
タスク状態 | タスクの進捗を表す属性(例: 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} | タスク削除 |
プレゼンターの実装
healthチェックハンドラーを今後の例とするため、現在はハンドラとプレゼンター(JSONに整形してレスポンスに含める)が混合となっていますが、分離しておきます。
プレゼンター=JSONフォーマットにしてレスポンスを返すものとします。
成功時と失敗時で分けて定義してみました。
返すJSONのパターンに一貫性を持たせたいので、各ハンドラでハンドラごとの型を埋め込んだり、エラーメッセージを埋め込むようにしています。
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
関数を付け足していくことになると思います。
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の型はエンドポイントによって異なるからです。正しく型を柔軟に反映させるためには、ジェネリクスは好手と見て使ってみました。
ヘルスチェックハンドラは以下のようになりました。
// 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)
}
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""
後から調べてみると、ドキュメントの一番下にありました。
idはULIDとする
task、userともにidをulidとします。
衝突の可能性も低く、タイムスタンプが含まれている点でソートできるので採用することにしました。
ulidの生成や、ulidの解析(形式が正しいか)に関してはドメインから外れるため、汎用的な処理を配置するpkg/
に実装しています。
ローカルの別ディレクトリに実装したパッケージをインポートするには、replaceを用います。
矢印左側のパスを、矢印右側の相対パスで解釈するようになります。
// ローカルパッケージをインポート
require github.com/kakkky/pkg v0.0.0
replace github.com/kakkky/pkg => ../pkg
ユーザードメインオブジェクトを実装
emailとpasswordは値オブジェクトとした。
以下の点を考慮してのことである。
- emailはバリデーションロジックがある
- passwordは認証の際に、値との比較やパスワードをハッシュ化して保存するなど値に関わるロジックがある
passwordの実装
実装
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
パッケージに切り出して具体的な処理を隠蔽した感じです。
Userドメインオブジェクト
新規にインスタンスを作成して返すコンストラクタと、リポジトリから得たデータをドメインオブジェクトとして再構成するための2つを用意。
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)
}
})
}
}
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
値オブジェクトpasswordをhashedPasswordに変更
パスワードのハッシュ化・比較を、password値オブジェクトにカプセル化していたましたが、、、
パスワードをハッシュ化することは、ドメイン知識からは外れる気がしていた。。。なぜかというと、ハッシュ化はセキュリティのためのアプリケーション由来のもの。元からドメインとして備わっている要件ではない気がしました。
そんなところにうってつけの記事。
元から、ハッシュ化されたパスワードを持つものとしてモデリングすれば、値オブジェクトにハッシュの比較処理等を実装していてもおかしくない
といったもの。
採用しました。
GithubActionsを導入
え、今?って感じですが、この先、pkgもappも手動テストするとなれば面倒間違いなし。
ここから統合テスト等も追加していくとなれば、なおさら。
これみる。
テストに関していったんこれでいこう。
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
レイヤーの依存関係を図式しておく
ドメインオブジェクトとしてUserを実装したが、この後、ドメイサービスやユースケース、リポジトリを実装していく中で、依存関係を整理しておく。
DDDとクリーンアーキテクチャが混ざっている感じになっている。
UserのCRUD処理を実装する計画
何がドメインルールだ??
色々整理します。
まず、登録に関しては、emailによる重複を避けさせたいので、それはドメインルールだろう。
存在を確かめるためのExistsメソッドはドメインオブジェクト自身に実装すると不自然である(有名な話?)から、これはドメインサービスとする。
んー、それぐらいかなぁ?とりあえず。
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
}
gomockを用いたドメインサービスのテスト
リポジトリから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)
}
})
}
}
リポジトリの実装
インターフェースを記述しておきました。
依存関係をドメインに向けるために、ドメイン層に記述しています。
//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をサポートしているので、こういったことが可能のようです。
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.DB
とsql.TX
の抽象型となっています。
いちいち、repositoryに定義したメソッドごとに、パッケージ変数に保持した*sql.DB
を取得してNew()
に入れて〜とすると面倒です。
そのため、sqlcディレクトリ配下に、以下のコードを置きました。
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のクエリオブジェクトをセットします。
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の要件にあわなそうですが、無理やり実装するかもです。
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
}
リポジトリのメソッドを実装
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
}
リポジトリのテストを、dockertestを用いて書く
dockertestでテストのときにコンテナを一時的に立ち上げるようにする。
repository以外にも、ハンドラのE2Eテストでも使用するので、infrastructureのdbディレクトリにcontaner
パッケージを置き、その中にdockertestのコンテナ内でDBを立ち上げる処理を記述する。
それを、TestMain
の形(同パッケージにおけるテスト共通処理)で呼び出す。
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接続処理を記述します。
// 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プロジェクト内でマイグレーションを適用する方法を探します。
以下の実装となりました。
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
パッケージを使用します。
以前にも同じような(少し違うけど)ケースに遭遇したので、解決法はパッと思い出せました。
要は、ランタイムに入って、コールスタックを操作します。
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を設定できればいいです。
userRepository.Save()のテスト
リポジトリーテスト共通化処理。
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()
を使用しました。
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の他のテストを実行していきます。
testfixtureパッケージでフィクスチャ作成
dbディレクトリに、test_helperディレクトリを配置し、フィクスチャをDBに流し込むためのヘルパー処理を書きました。
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)
}
})
}
}
ユースケースの実装
認証機能は後回しにするとして、ユーザの閲覧(全体)・登録・更新・削除のユースケースを実装してきます。
また、以下の図のように、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 //退会
(寄り道)ドメインエラーをもう少ししっかりと定義
ドメインエラーは、ビジネスルールを満たさなかったことが原因で起こすべきエラーのため、クライアントにフィードバックが必要。
今までは、個別のエラー(ErrUserNotFound)などしか判別できていませんでした。
ただ、例えばハンドラでは、ドメインエラーかそうじゃないかによって、プレゼンターを使い分けたいです。ステータスコードを出し分けるためとかにも。
今気づいたので、ここでエラー独自型を定義しておきました。
ErrDomain
は単にerrorを持っているだけにしておき、Unwrap()
は実装していません。
なるべくシンプルにエラーを扱いたかったのでこうしました。
// ユーザー関連のドメインエラー
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")
}
})
}
}
デメテルの法則に従う。
「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」を読んでいて、今の自分の実装で引っかかる部分がありました。
Userドメインオブジェクトは、値オブジェクトhashedPasswordを持っています。
ユーザーが持つハッシュ化したパスワード
// ハッシュ化されたパスワードと比較
func (p HashedPassword) Compare(target string) bool {
return hash.Compare(p.value, target)
}
デメテルの法則とは、オブジェクト同士のメソッド呼び出しに関するガイドライン的法則です。
主に「集約」の話題で使われる法則です。
- オブジェクト自身
- 引数として渡されたオブジェクト
- インスタンス変数
- 直接インスタンス化したオブジェクト
たとえば車を運転するときタイヤに対して直接命令しないのと同じように、オブジェクトのフィールドに直接命令をするのではなく、それを保持するオブジェクトに対して命令を行い、フィールドは保持しているオブジェクト自身が管理すべきだということです。
成瀬 允宣. ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 (p.408). 株式会社翔泳社. Kindle 版.
集約ルートからのみ、値オブジェクトは操作されるべきです。
そうすると、ドメインルールが散らばることがこの先もなくなり、保守性が高まります。
Compaireメソッドをプライベートにし、Userに持たせました。
// パスワードを比較する
func (u *User) ComparePassword(plainPassword string) bool {
return u.hashedPassword.compare(plainPassword)
}
// ハッシュ化されたパスワードと比較
// 集約ルートUserから呼び出す
func (p HashedPassword) compare(target string) bool {
return hash.Compare(p.value, target)
}
プレゼンテーション層の実装
ハンドラー型を満たす以下を実装
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(¶ms); err != nil {
presenter.RespondBadRequest(w, err.Error())
return
}
if err := validation.NewValidation().Struct(¶ms); 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)
}
削除の際のステータスコードは??
204をステータスコードとして返し、レスポンスボディはからっぽ、というのが良さそうです。
プレゼンターに以下を用意しておきました。
func RespondNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
ユーザー更新に再構成ファクトリはダメじゃないか??
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を生成するので使えない。
と言うわけで、ドメインにインスタンス更新ロジックを記述。
// ユーザーオブジェクト更新
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
}
ロガーミドルウェア実装
以下のようになった。
// 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.ResponseWriter
とio.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)
}
テーブル形式で出力される
こんな感じです一旦。
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 | -----------------------------------------------------------------------------------------------------
参考
認証ってどこの層????
まず、ドメイン層ではないことは最初に言えそうです。
認証は明らかにアプリケーションとして成り立たせるのに必要な技術的機能要件であるから、ドメイン知識は持ち得ませんよね。
となると、、候補は
- インフラ層
- ユースケース層
- アダプタ層
が残りますね。
アダプタは違うかな。ミドルウェア(認可)とハンドラ(ログイン・ログアウト)はそこで実装するけれども。
ユースケースもどうだろうなぁ。ログイン、ログアウトはユースケースとしても用意する...気がしているけど、
認証で肝心のjwtに関する処理をどこの層と位置付けるべきなのか。
消去法的に見ればインフラ(framework&driver)そうになるのかなといった感じです。実際、外部のライブラリありきの実装なので間違ってはいなさそうですね。
問題は、これを使うのならば、依存関係の方向を外側に向けないようにしなければなりません。
ユースケース層にJWTのインターフェースをおき、具体実装としてinfrastructure層にauth/jwt.goを置きます。
これなら大丈夫かな汗汗。
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 //トークンを生成する・認証する処理の具体実装
JWTによる認証の実装の流れをここで整理したい
ログイン
- 入力されたemail&passwordを入力させる
- emailでユーザーを検索
- passwordをhashed_passwordと比較(Userドメインオブジェクトに実装したロジックがある)
- 1が通れば(エラーがないならば)、JWTトークンを発行する
- JWTIDにはulidを識別子とする
- 有効期限情報も付加する
- クレームにはuser_id等を含める
- JWTトークンIDをRedisに保存する
- JWTを秘密鍵で署名する
- 署名ずみのJWTトークン返す
- ハンドラがjsonに詰めてクライアントに返すことになる
認可制御
- ログイン済みのユーザーがリクエストを送る
- ヘッダにJWTトークンを付加する
- サーバーがヘッダのトークンを読み取る
- トークンを公開鍵で復号し、jwtidとuser_idを取得する
- redisにuser_idをキーとしてレコードがあるか問い合わせる
- あったら、バリューとしてjwtidを取得する
- redisから取得したjwtidと、ヘッダーのトークンから復号して取り出したjwtidを比較する
- 有効期限が切れていたら、セッションエラーとする
- 比較に成功したら、redisのレコードに設定していた有効期限を延長させる
- リクエストスコープのContextにuser_idを含める
- 後続のハンドラー処理は、Contextからuser_idを取得して処理を行う
- 退会処理などパスパラメータに含めているので、認証の実装が済んだ後でまた書き換える
秘密鍵、公開鍵の生成
秘密鍵
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
型にパースする必要がありそう。
認証認可できたけど、また本でしっかり書くことにします。
プレゼンテーション層(エンドポイント)をテストする
ハンドラ・プレゼンター・ミドルウェアを統合テストとして行う案。
ユニットテストとして行うと、ユースケースをモック化する必要があり、テストコードの複雑性がかなり増す。
また、アプリケーションが依存している外部サービス(DB、KVS等々)も実際に検証を行いたい。
runn やpostmanを用いたシナリオテストなどあるが、今回はgoldenファイルを用いたE2Eテストを行うことにした。
(結合テストのその前に)認可制御ミドルウェアのロジックをユースケースに移譲する
ゴールデンテストを用いた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)
}
})
}
}
結合テストの方針
プレゼンテーション層(ハンドラ+プレゼンター)のテストは、
モックを用意せずに統合テストとして行い、その際にゴールデンテストを用いる。
ゴールデンテストとして行う意向としては、リクエストに対するレスポンス構造を担保するため。
レスポンス構造の不変をテストできるが、ロジックはテストできない。
中身のロジックは、ユースケース、ドメインモデル、サービスそれぞれのユニットテストで守っている。
ゴールデンテストライブラリ、goldieを使用。
TestMainでテスト用DB、テスト用サーバー等々を用意。
レスポンスの構造が保たれているかを検証する。
プレゼンター層に置こうか迷いましたが、エンドポイントとなるとルーティングも絡んできます。そのため、infrastructure/api_test/
としてテストファイルを置いていくことにしました。
//go:build integration_write
とすると、WARNINGが出てしまった。メッセージがコードエディタにおそらく表示されるので、それに従えばいいが、以下のようにすればいいです。
"go.buildFlags": [
"-tags=integration_read,integration_write",
],
Todoタスク機能
ユースケース
- ユーザーのタスク登録
- ユーザーのタスク削除
- ユーザーのタスク編集
- ユーザーのタスク状態変更
ユビキタス言語
言語 | 説明 |
---|---|
タスク | ユーザーによって操作される項目。 |
タスク登録 | タスクを新しく作成しシステムに保存する操作。 |
タスク編集 | タスクの内容や詳細を変更する操作。 |
タスク削除 | タスクをシステムから削除する操作。 |
タスク状態 | タスクの進捗を表す属性(例: todo/doing/done)。 |
タスク状態変更 | タスクの状態(todo/doing/done)を更新する操作。 |
エンドポイント
メソッド | パス | 概要 |
---|---|---|
POST | /task | タスク登録 |
GET | /tasks | タスク一覧表示 |
GET | /task/{id} | タスク表示 |
PATCH | /task | タスク状態変更 |
DELETE | /task/{id} | タスク削除 |
ドメインオブジェクト実装
コンストラクタ、再構成、更新メソッド(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
}
ユースケース
- タスク作成
- タスク削除
- タスク一覧表示
- タスク表示
- タスク状態更新
リポジトリ
タスク表示に関しては、タスク内容、タスク状態、タスク作成者を表示させたい。
その際には、CQRSパターンを利用して、クエリサービスモデルを部分的に導入・実装し、ユースケースがそれを利用する。
CQRS
クエリサービスというオブジェクトを用意して、複数集約を参照する際に有用なパターン。
クエリサービスオブジェクトのインターフェースを usecase 層に配置する主な理由は、ユースケースが参照する集約パターンに依存しているから。
user+taskを取得したいというのは、ドメイン知識ではなくユースケースが決めていること。