GoのWireを使ってみてよかった件
今回DI(Dependency Injection)をGoのアプリケーション内でやりたいなと思い、
少し前に触っていたwireでやってみた話を書いていきます。
What's DI?
いろんな方が書かれていると思うので、詳細には触れません。
端的に言ってしまうと、必要なモジュールを外から受け取る(注入)デザインパターンです。
自身で生成するのではなく、外から渡してもらうということですね。
そうすることで、テスト容易性が向上する・低結合で実装できる・責務を分離しやすくなる、などの
メリットが享受できます。
What's wire?
大規模なシステムにも強いという特徴があります。
wireを使うまえ
今回はアプリケーションの細かいところにはあまり気にせず、wireを使うとどのように良いことがあるのかを書いていきます。
まず責務を分離するように記述していくと、以下のような依存構成になるのではないでしょうか。
部分的にかいつまんで話すと、
- ユースケースはビジネスロジックの処理を実行するために、DBの変更が必要になる。
- そうなるとDBを操作したいが、直接操作するとDBに依存してしまうためリポジトリ階層を経由する
- そうなるとユースケースはリポジトリと依存する
- リポジトリはDBと依存することになる
- こうなるとユースケースを使いたいハンドラはユースケースを使うために、DBまでの情報を渡す必要がある
さらに機能が増えていくとさらに下記のように広がっていきますよね。
このようになった場合、DIを使わない構成だと以下のように実装されると思われます。
package main
import (
"context"
"log"
"os"
"sample-app/internal/api"
"sample-app/internal/api/observe"
"sample-app/internal/config"
"sample-app/internal/handler"
"sample-app/internal/repository"
"sample-app/internal/route"
"sample-app/internal/usecase"
"github.com/joho/godotenv"
)
func main() {
// .envファイルの読み込み
if err := godotenv.Load(); err != nil {
log.Fatalf("Error loading .env file: %v", err)
}
ctx := context.Background()
endpoint := os.Getenv("ENDPOINT")
tracerCfg := observe.OtelTracerConfig{
Endpoint: endpoint,
ServiceName: "vote-app",
}
// 各依存関係を手動で初期化する例
// 1. アプリケーションの設定生成
appConfig := config.NewConfig()
// 2. トレーサーの初期化とEchoの生成
otelTracerManager := observe.NewOtelTracerManager(tracerCfg)
echoWithCleanup := api.NewEcho(otelTracerManager)
// 3. Redisクライアントの生成
client, err := config.NewRedisClient(appConfig)
if err != nil {
log.Fatalf("failed to initialize redis client: %v", err)
}
// 4. リポジトリの初期化
voteRepository := repository.NewVoteRepository(client)
// 5. ユースケースの初期化
voteUseCase := usecase.NewVoteUseCase(voteRepository)
// 6. ハンドラの初期化
voteHandler := handler.NewVoteHandler(voteUseCase)
// 7. ルーターの初期化
router := route.NewRouter(voteHandler)
// 8. サーバの初期化
server := api.NewServer(appConfig, echoWithCleanup, router)
// サーバのクリーンアップが必要ならdeferで登録
if server.Cleanup != nil {
defer server.Cleanup()
}
log.Println("Starting API server on port 8080...")
server.Run(ctx)
}
これはあくまでハンドラが一つの場合なので、ハンドラが増えたり、DBが増えたりするだけで
どんどん肥大化していくことが予想されます。
そうなると、main.goはどんどん可読性を落とすことになると思います。
一方でDIせずに各自で必要なモジュールを生成していくとテスト容易性を低下させ、
結合後も上がっていきますね。
そこでwireを使ってみます。
wireを使う
まずインストールします。
go get github.com/google/wire/cmd/wire
wireファイルを実装する
依存性注入のために最終的にはwireが自動生成するファイルをプログラム上は参照することに
なりますが、そのための定義ファイルを実装します。
//go:build wireinject
package wire
import (
"vote-app/internal/api"
"vote-app/internal/api/handler"
"vote-app/internal/api/observe"
"vote-app/internal/api/route"
"vote-app/internal/api/usecase"
"vote-app/internal/config"
"vote-app/internal/repository"
"github.com/google/wire"
)
var SuperSet = wire.NewSet(
config.NewConfig, // Config の生成
config.NewRedisClient, // Redis クライアントの生成
observe.NewOtelTracerManager, // OpenTelemetry トレーサーの生成
repository.NewVoteRepository, // Redis ベースのリポジトリ
usecase.NewVoteUseCase, // VoteUseCase の生成
handler.NewVoteHandler, // VoteHandler の生成
route.NewRouter, // Router の生成
api.NewEcho, // Echo の生成
api.NewServer, // Server の生成
)
// `InitializeServer()` の引数として `OtelTracerConfig` を渡せるようにする
func InitializeServer(cfg observe.OtelTracerConfig) (*api.Server, error) {
wire.Build(SuperSet)
return nil, nil
}
説明していきます。
//go:build wireinject
これはビルドタグです。
このファイルは実際に動作するものではなく、生成元だよ、ということが示されてるものですね。
var SuperSet = wire.NewSet(
config.NewConfig, // Config の生成
config.NewRedisClient, // Redis クライアントの生成
observe.NewOtelTracerManager, // OpenTelemetry トレーサーの生成
repository.NewVoteRepository, // Redis ベースのリポジトリ
usecase.NewVoteUseCase, // VoteUseCase の生成
handler.NewVoteHandler, // VoteHandler の生成
route.NewRouter, // Router の生成
api.NewEcho, // Echo の生成
api.NewServer, // Server の生成
)
細かい設定内容までは割愛しますが、これらは各コンポーネントのプロバイダー関数を定義しています。
例えば、以下の2ファイルについてです。
type VoteHandler struct {
usecase *usecase.VoteUseCase
}
func NewVoteHandler(usecase *usecase.VoteUseCase) *VoteHandler {
return &VoteHandler{usecase: usecase}
}
// 投票結果を保存する API
func (h *VoteHandler) SaveVote(c echo.Context) error {
...
こちらはハンドラです。ユースケースを受け取ることを想定しています。
さらにユースケースはリポジトリを受け取るようになっています。
package observe
...
func NewOtelTracerManager(cfg OtelTracerConfig) *OtelTracerManager {
return &OtelTracerManager{
endpoint: cfg.Endpoint,
serviceName: cfg.ServiceName,
}
}
func (m *OtelTracerManager) Init(ctx context.Context) (TracerProvider, error) {
// OTLP エクスポーターを作成
traceExporter, err := otlptrace.New(
これはトレース用の処理です。送信先の設定を受け取って初期化します。
echoサーバに組み込む必要があります。
func InitializeServer(cfg observe.OtelTracerConfig) (*api.Server, error) {
wire.Build(SuperSet)
return nil, nil
}
こちらの処理がサーバから依存関連があるものを全て初期化する関数になります。
上で記載したトレースの設定については外部から受け取る必要があるため
引数で受け取っています。
また、api.serverは自前で定義した以下の設定を作っています。
type Server struct {
Config *config.Config
Echo *echo.Echo
Router *route.Router
Cleanup func()
}
つまり、自動生成する際に、ここにあるものを準備できれば良いということです!
- api.NewServer:Serverを作る。そのために幾つかの設定が必要
func NewServer(cfg *config.Config, e *EchoWithCleanup, router *route.Router) *Server {
return &Server{
Config: cfg,
Echo: e.Echo,
Router: router,
Cleanup: e.Cleanup,
}
}
- EchoWithCleanupの設定を作るにはNewEchoの関数が必要となる
- configの設定を作るにはNewConfigの関数が必要となる
- Routerの設定を作るにはNewRouterの関数が必要となる
- NewRouterはハンドラたちをルート設定するため、ハンドラとハンドラが依存してるものたちを定義しないといけない
- だからユースケース、リポジトリ、今回はDBをRedisにしてるためRedisのプロバイダー関数をここで定義している
さらに便利なのは、ここで渡したcfg observe.OtelTracerConfig
は明示的にNewOtelTracerManager
に渡していないにも関わらず、自動的に生成コードで渡されることです。
生成してみます。
wire internal/api/wire/wire.go
wire: command-line-arguments: wrote xxxxxxxx/internal/api/wire/wire_gen.go
生成ができました。生成は wire
+ wire対象のファイルパス
で実行可能です。
以下生成されたファイルです。
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package wire
import (
"github.com/google/wire"
"sample-app/internal/api"
"sample-app/internal/api/handler"
"sample-app/internal/api/observe"
"sample-app/internal/api/route"
"sample-app/internal/api/usecase"
"sample-app/internal/config"
"sample-app/internal/repository"
)
// Injectors from wire.go:
func InitializeServer(cfg observe.OtelTracerConfig) (*api.Server, error) {
configConfig := config.NewConfig()
otelTracerManager := observe.NewOtelTracerManager(cfg)
echoWithCleanup := api.NewEcho(otelTracerManager)
client, err := config.NewRedisClient(configConfig)
if err != nil {
return nil, err
}
voteRepository := repository.NewVoteRepository(client)
voteUseCase := usecase.NewVoteUseCase(voteRepository)
voteHandler := handler.NewVoteHandler(voteUseCase)
router := route.NewRouter(voteHandler)
server := api.NewServer(configConfig, echoWithCleanup, router)
return server, nil
}
// wire.go:
var SuperSet = wire.NewSet(config.NewConfig, config.NewRedisClient, observe.NewOtelTracerManager, repository.NewVoteRepository, usecase.NewVoteUseCase, handler.NewVoteHandler, route.NewRouter, api.NewEcho, api.NewServer)
先ほどwireファイル側で設定しておいたInitializeServer
がこちらでも作られています。
これがプログラム内から呼ばれる関数になります。
func InitializeServer(cfg observe.OtelTracerConfig) (*api.Server, error) {
configConfig := config.NewConfig()
otelTracerManager := observe.NewOtelTracerManager(cfg)
echoWithCleanup := api.NewEcho(otelTracerManager)
client, err := config.NewRedisClient(configConfig)
if err != nil {
return nil, err
}
voteRepository := repository.NewVoteRepository(client)
voteUseCase := usecase.NewVoteUseCase(voteRepository)
voteHandler := handler.NewVoteHandler(voteUseCase)
router := route.NewRouter(voteHandler)
server := api.NewServer(configConfig, echoWithCleanup, router)
return server, nil
}
設定時にSuperSetで定義した関数たちが使用されて、APIサーバを起動して返却しています。
SuperSetには列挙するような形で記載できるのですが、こちらでは整合性をとるように
ちゃんと初期化されて渡されています。
main.goでは以下のように呼び出せます。
package main
import (
"context"
"log"
"os"
"sample-app/internal/api/observe"
"sample-app/internal/api/wire"
"github.com/joho/godotenv"
)
func main() {
if err := godotenv.Load(); err != nil {
log.Fatalf("Error loading .env file: %v", err)
}
ctx := context.Background()
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
server, err := wire.InitializeServer(observe.OtelTracerConfig{
Endpoint: endpoint,
ServiceName: "vote-app",
})
if err != nil {
log.Fatalf("failed to initialize server: %v", err)
}
if server.Cleanup != nil {
defer server.Cleanup()
}
log.Println("Starting API server on port 8080...")
server.Run(ctx)
}
サーバを生成してるのは以下だけですね。
server, err := wire.InitializeServer(observe.OtelTracerConfig{
Endpoint: endpoint,
ServiceName: "vote-app",
})
今回はサーバ起動のタイミングで外から必要な情報を渡す必要がありましたが、
main.goは自動生成されたwireファイルから必要な情報を受け取って初期化することができています。
最後に
実はあまりちゃんとDIとかやってきていなかったのですが、非常に便利なツールだなと思いました。
触りたての頃は若干使い方がわからなかったのですが、原理的なことが理解できるようになると
使いづらさもなく良いと感じています。
Let's DI Life !
Discussion