レイヤードアーキテクチャで各層で「importしてメソッドを呼び出す」のではなく「構造体(あるいはクラス)として実装する」理由は、以下のような設計上のメリットを得るため。
-
依存性の注入 (Dependency Injection) の容易化 構造体を使うことで、インターフェースを注入可能になり、モジュール間の結合度を下げ、テスト時に依存するリポジトリやサービスを簡単にモックに差し替えることもできる。
-
状態を持つ実装を可能にする データベース接続やキャッシュを各層で跨って利用できる
- 依存性の注入とは、クラスや構造体が必要とする依存オブジェクトを自身で生成するのではなく、外部から提供(注入)する設計手法。
- これにより、モジュール間の結合度を低くし、柔軟性やテストの容易性を向上させることができる。
依存オブジェクトを自身で生成する例
package service
import (
"example.com/repository"
)
type UserService struct {
Repository *repository.MySQLUserRepository // インターフェースではなくインスタンス
}
func NewUserService() *UserService {
repo := &repository.MySQLUserRepository{} // 自分で依存を生成
return &UserService{Repository: repo}
}
MySQLUserRepositoryに依存しているため、例えばMockRepositoryに変更したい場合にUserServiceのコードを修正する必要がある。
依存オブジェクトを外部から提供する例(DI)
package service
import (
"example.com/repository"
)
type UserService struct {
Repository repository.UserRepository // インターフェースに依存
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{Repository: repo} // 外部から依存を注入
}
実際のMySQLUserRepositoryだけでなく、MockRepositoryなどの別実装を注入できる。
testfixturesライブラリを利用し、テスト関数内で初期データの投入を行う。 fixtures の投入処理では テーブルのデータが完全に入れ替えられる。(テーブルが削除された後、新規作成される)
テストデータは別ファイル(package testdata)に切り出し再利用できるようにする。
値渡しの特徴
- 概要
- 構造体の コピーを渡す 方法です。
- メモリ上で新しいインスタンスが生成され、オリジナルのデータとは独立します。
- メリット
- 安全性が高い
- 呼び出し先でデータを変更しても、オリジナルに影響を与えない。
- 意図しない副作用が起きないため、堅牢な設計が可能。
- 小さなデータに適している
- 構造体が小さい場合、コピーコストが低いので効率的。
- シンプルな設計
- ポインタ管理やnilチェックが不要。
- デメリット
- 大きなデータの場合、コピーコストが高い
- 構造体が大きいと、メモリ消費や処理時間に影響する。
- 変更が必要な場合に不便
- 呼び出し先でオリジナルを変更したい場合、値渡しだと変更できない。
- 使いどころ
- データが小さく、変更の必要がない場合。
- レイヤードアーキテクチャのservice層 → handler層のように、不変なデータを渡す場合。
ポインタ渡しの特徴
- 概要
- 構造体の メモリアドレス(ポインタ)を渡す 方法です。
- 呼び出し先はオリジナルデータへの参照を取得します。
- メリット
- メモリ効率が良い
- 構造体が大きい場合でもコピーせずに渡せるため、効率的。
- 呼び出し先でデータの変更が可能
- 呼び出し先で直接データを操作する場合に便利。
- デメリット
- 安全性が低い
- 呼び出し先でデータを変更するとオリジナルにも影響する。
- 意図しない副作用が起こる可能性がある。
- コードが複雑になる
- nilチェックが必要。
- ポインタの扱いに注意が必要。
- 使いどころ
- 構造体が大きい場合。
- 呼び出し先でデータを更新する必要がある場合。
- パフォーマンスが特に重要な処理。
比較項目 | 値渡し | ポインタ渡し |
---|---|---|
メモリ効率 | 小さなデータなら良い | 大きなデータに適している |
データの安全性 | 呼び出し元のデータを守れる | 呼び出し元のデータに影響を与える可能性 |
変更の必要性 | 変更しない場合に適している | 変更が必要な場合に適している |
コードの簡潔さ | シンプルで扱いやすい | nil チェックやポインタの管理が必要 |
用途 | 不変のデータ、軽量データを渡す | 可変データ、重量級データを渡す |
- 結論
- 小さな構造体や不変データ → 値渡しが推奨
- 例: UserResponseのように小さなデータをservice層からhandler層に渡す場合。
- 大きな構造体や変更が必要なデータ → ポインタ渡しが推奨
- 例: 大規模なデータ処理や変更を前提とした設計。
- 小さな構造体や不変データ → 値渡しが推奨
シンプルで安全な設計を目指すなら、まずは 値渡し を基本とし、パフォーマンスや変更要件を理由に ポインタ渡し を採用するか検討するのが良い。
以下の時刻データをfixturesとtestdataに入れておいた
# fixtures
due_date: "2024-01-01 00:00:00"
# testdata
time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
テスト結果は以下の通りで一致しなかった。
got = due_date=Sun Dec 31 15:00:00 2023
want= due_date=Mon Jan 1 00:00:00 2024
fixturesで設定した値はTZが未定義のため、コード→OS→DBのどこかでTZが設定されてしまったと思われる。DB上もSun Dec 31 15:00:00 2023となっている
確認した結果、Goの実行環境のOSが原因である可能性が高い
- コード:Goの環境変数 TZ や time パッケージで指定していないとデフォルトで UTC になる。
- OS:tzsetコマンドで確認すると
Asia/Tokyo
となっていた - DB:コンテナで動作する PostgreSQL のタイムゾーン (TZ) 設定は、明示的に指定しない限りデフォルトで UTC になる。
ISO 8601 形式でUTC を Z (Zulu) で指定 due_date: "2024-01-01T00:00:00Z"
- no new variables on left side of :=
service/user.goのSignInメソッド内の
err = bcrypt.CompareHashAndPassword
をerr := bcrypt.CompareHashAndPassword
とするとエラーになる。var1, err :=
のような形はvar1
の部分が変われば再びerr
を定義できる。
# これはOK
var1, err := func1()
var2, err := func2()
# これもOK
var1, err := func1()
err = func2()
# これはerror
var1, err := func1()
err := func2()
- json: Unmarshal(non-pointer service.UserRequest) handlerのBindメソッドへの引数が値渡しになっていたことが原因、インスタンス化してポインタ渡しにすることで解消
repo=<your_repository> # github.com/<user_name>/<repository_name>の形式
go mod init ${repo}
go mod tidy
- Userエンティティの作成
ent/schema/user.go
が作成される
go run -mod=mod entgo.io/ent/cmd/ent new User
- Userエンティティのフィールドを定義
ent/schema/user.go
のfunc (User) Fields()
内を編集- https://entgo.io/ja/docs/schema-fields
- アセットの作成
ent/user
にアセットが作成される
go generate ./ent
docker-compose up -d
# delete
docker-compose down --rmi all --volumes --remove-orphans
go mod tidy
go run cmd/main.go migrate
- テーブル確認
docker exec -it postgres.local psql -U admin -d sampledb -c "\dt"
List of relations
Schema | Name | Type | Owner
--------+-------+-------+-------
public | users | table | admin
(1 row)
- SELECT
docker exec -it postgres.local psql -U admin -d sampledb -c "select * from users;"
id | name | email | password
-------+--------+--------------------+--------------------------------------------------------------
10001 | alice | [email protected] | $2a$10$IUjSMm7z8i6QaF5BfOc7wOKRkQqdDZ4TkmzutyAOe42vwteaKiqsO
10002 | bob | [email protected] | $2a$10$ExzssGX4xS4joeZx7aO9SOpWXLBzhAQxjMBleRxf8ziC961FkJ7qq
go test -v ./...
go test -v ./test/repository
go run cmd/main.go
curl -X GET http://localhost:8080/healthcheck
curl -X POST http://localhost:8080/user -H "Content-Type: applic
ation/json" -d '{"Name": "alice", "Email": "[email protected]", "Pa
ssword": "alicepassword"}'
curl -X GET http://localhost:8080/user/1
- 準備
go get github.com/golang/mock/[email protected]
go install github.com/golang/mock/[email protected]
go mod tidy
- mock生成
mockgen -source=user.go -destination=./mock/user_mock.go -package=repository
mockgen -source=task.go -destination=./mock/task_mock.go -package=repository
# サインアップ
curl -X POST http://localhost:8080/signup -H "Content-Type: application/json" -d '{"name": "teru", "email":"[email protected]", "password": "terupassword"}'
# サインイン
token=$(curl -X POST http://localhost:8080/signin -H "Content-Type: application/json" -d '{"name": "teru", "email":"[email protected]", "password": "terupassword"}'| tr -d '"')
# task登録
curl -X POST http://localhost:8080/task \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d '{"title": "task01", "description": "task01description", "status": "TODO", "due_date": "2024-01-01T00:00:00Z"}'
# task一覧
curl -X GET http://localhost:8080/task?p=1 \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json"
# task参照
curl -X GET http://localhost:8080/task/10001 \
-H "Authorization: Bearer $token"