Google WireによるGoのコンパイル時依存性注入:実践ガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

背景と理由

新しいGoバックエンドを立ち上げる際、構造体を手動で接続(ワイヤリング)するのは簡単です。データベースプールをインスタンス化し、リポジトリに渡し、それをサービスでラップしてHTTPハンドラーを登録します。しかし、サービスが成長して依存関係が25〜30個に増えると、main.go は瞬く間に300行を超える手に負えないコードへと膨れ上がります。コンストラクタを1つ変更しただけで、何十箇所もの呼び出し元で連鎖的な修正を強いられることになります。

Java(Spring)やNode.js(NestJS)などのエコシステムでは、実行時のリフレクションやサービスロケーターを使ってこの問題を解決します。Go開発者も同様にリフレクションベースのツールを採用することが多く、特にUberの fx や dig が有名です。これらも機能はしますが、実行時DIには厄介な障害モードが存在します。依存関係が欠落していてもコンパイルは正常に通ってしまい、ステージング環境や本番環境へのデプロイ後の起動時に初めてpanicを起こすのです。

Google Wireは根本的に異なるアプローチをとります。Wireはランタイムコンテナではなく、コードジェネレーターです。コンパイル時に標準的なGoのコンストラクタを解析し、コンポーネント同士を結びつける明示的でイディオマティックなGoコードを出力します。循環参照を作ってしまったり引数を渡し忘れたりしても、ローカルマシンのコンパイル時に即座に検知・停止します。Kubernetesの深夜の起動クラッシュを調査する羽目になるより、わずか2秒のローカルビルドで依存関係の破損に気付ける方が遥かに優れています。

インストール

Wireは2つのコンポーネントで構成されています。wire.Build などのヘルパー型を提供するランタイムライブラリと、コードを解析してインジェクターを生成するCLIツールです。

既存のワークスペースがない場合は、まずGoモジュールを初期化します。

mkdir go-wire-demo && cd go-wire-demo
go mod init github.com/example/go-wire-demo

Wireのコード生成CLIツールをGoのbinディレクトリにインストールします。

go install github.com/google/wire/cmd/wire@latest

システムの PATH に $GOPATH/bin を追加して、シェルがGoバイナリを見つけられるようにします。

export PATH=$PATH:$(go env GOPATH)/bin
wire help

最後に、Wireのランタイムパッケージを go.mod に追加します。

go get github.com/google/wire@latest

基本構成

Wireは主に2つの基本要素(プリミティブ)を中心に動作します。

  • Provider(プロバイダ): 値と任意のerrorを返す通常のGoコンストラクタ関数(例: NewUserRepository(cfg *Config) (*UserRepository, error))。
  • Injector(インジェクタ): Wireがどのターゲット構造体を組み立てるかを指定するために wire.Build(...) を呼び出す関数宣言。

1. ドメインコンポーネントの作成

設定構造体、ユーザーリポジトリ、通知サービスを含む小さなサービスを作成します。このコードを app.go として保存してください。

package main

import (
	"errors"
	"fmt"
)

type Config struct {
	DatabaseURL string
}

func NewConfig() *Config {
	return &Config{
		DatabaseURL: "postgres://user:secret@localhost:5432/production_db",
	}
}

type UserRepository struct {
	Config *Config
}

func NewUserRepository(cfg *Config) (*UserRepository, error) {
	if cfg.DatabaseURL == "" {
		return nil, errors.New("データベースURLは空にできません")
	}
	return &UserRepository{Config: cfg}, nil
}

type NotificationService struct {
	Repo *UserRepository
}

func NewNotificationService(repo *UserRepository) *NotificationService {
	return &NotificationService{Repo: repo}
}

type App struct {
	Notifier *NotificationService
}

func NewApp(notifier *NotificationService) *App {
	return &App{Notifier: notifier}
}

func (a *App) Start() {
	fmt.Printf("接続されたDBでアプリケーションを実行中: %s\n", a.Notifier.Repo.Config.DatabaseURL)
}

2. Wireインジェクタ定義の記述

次に、wire.go にインジェクタのシグネチャを定義します。1行目のビルドタグに注目してください。

//go:build wireinject
// +build wireinject

package main

import (
	"github.com/google/wire"
)

func InitializeApp() (*App, error) {
	wire.Build(
		NewConfig,
		NewUserRepository,
		NewNotificationService,
		NewApp,
	)
	return nil, nil
}

//go:build wireinject 制約は、通常のビルド時にこのファイルを無視するよう標準のGoコンパイラに指示します。nil, nil を返しているのはコンパイラを通すための単なるプレースホルダーであり、Wireがコード生成時にこの関数本体を完全に置き換えます。

3. Provider Setによる整理

プロジェクトが成長してコンストラクタが10〜15個を超えると、単一の wire.Build 呼び出しにすべての関数を渡すのは煩雑になります。wire.NewSet を使用して関連するコンストラクタをグループ化しましょう。

// 共通パッケージまたはwire.go内
var ServiceSet = wire.NewSet(
	NewConfig,
	NewUserRepository,
	NewNotificationService,
)

これで、ルートの依存関係とともに ServiceSet をインジェクタに直接渡せるようになります。

func InitializeApp() (*App, error) {
	wire.Build(ServiceSet, NewApp)
	return nil, nil
}

4. DIコードの生成

プロジェクトディレクトリで wire コマンドを実行します。

wire

Wireはコンストラクタの依存グラフを検査し、戻り値の型と引数を照合して、wire_gen.go を生成します。

// Wireによって生成されたコード。編集しないでください。

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

func InitializeApp() (*App, error) {
	config := NewConfig()
	userRepository, err := NewUserRepository(config)
	if err != nil {
		return nil, err
	}
	notificationService := NewNotificationService(userRepository)
	app := NewApp(notificationService)
	return app, nil
}

出力されるのは素直なGoコードです。コンストラクタの呼び出し順序を正しく並べ替え、エラーの伝播を処理し、実行時のリフレクションオーバーヘッドは一切発生しません。

5. エントリーポイントの作成

コード生成が完了したら、main.go でエントリーポイントを接続します。

package main

import (
	"log"
)

func main() {
	app, err := InitializeApp()
	if err != nil {
		log.Fatalf("アプリケーションの初期化に失敗しました: %v", err)
	}

	app.Start()
}

動作検証とモニタリング

それでは、コンパイル時の安全性が謳い文句どおりに機能するか検証してみましょう。

1. サービスのビルドと実行

標準のGoツールチェーンでプログラムを実行します。

go run .

ターミナルを確認すると、main.go で手動配線することなくツリー全体が正常に初期化されたことがわかります。

接続されたDBでアプリケーションを実行中: postgres://user:secret@localhost:5432/production_db

2. コンパイル時エラーの検証

ビルド時の安全性を検証するために、依存関係が欠落した状況を再現してみましょう。wire.go 内の NewConfig をコメントアウトします。

func InitializeApp() (*App, error) {
	wire.Build(
		// NewConfig を削除
		NewUserRepository,
		NewNotificationService,
		NewApp,
	)
	return nil, nil
}

CLIを再実行します。

wire

明確な診断メッセージとともに出力生成が即座に失敗します。

wire: /path/to/go-wire-demo/wire.go:10:1: inject InitializeApp: *main.Config のプロバイダが見つかりません
	プロバイダ "NewUserRepository" 内の *main.UserRepository で必要とされています (/path/to/go-wire-demo/app.go:21:1)

本番環境で初期化されていないポインタによるクラッシュに遭遇する代わりに、わずか50ミリ秒で設定ミスを発見できます。

3. CIパイプラインへの統合

古くなったファイルや手動で編集された wire_gen.go がコミットされるのを防ぐため、CI(継続的インテグレーション)パイプラインにチェックを追加します。

# CI環境でのステップ(例:GitHub Actions)
go install github.com/google/wire/cmd/wire@latest
wire ./...
git diff --exit-code wire_gen.go

このコマンドは、コミットされたソースファイルに対してコード生成を実行します。ローカルで wire を実行せずに誰かがプロバイダのシグネチャを変更した場合、git diff --exit-code が未コミットの変更を検出し、プルリクエストをブロックします。

Share: