メインコンテンツにスキップ

GoでDIを実践してみた!AWSセッション管理を改善し、テストを格段にやりやすくする方法

タグ: 🏷 Golang ,DI ,AWS

はじめに:AWSクライアントを毎回作ってた

AWS SDKを使うアプリケーションで、S3やDynamoDB、CloudWatchなどのサービスクライアントを、ついついリクエストのたびに生成してしまっていませんか?

「とりあえず動くからいいや」と安易に実装されたクライアント生成処理は、気づかないうちに深刻なパフォーマンス劣化や、後々テストができないコードを生み出す原因になります。

今回は、実際に僕のコードで起きた「メトリクス送信のたびにAWSセッションを生成している」という問題を例に、依存性注入(DI)を使ってどう解決し、パフォーマンスとテストのしやすさを飛躍的に改善できるか、詳しく見ていきましょう。

問題のコード:リクエストのたびに実行される、高コストな初期化処理

問題となっていたのは、APIリクエストの各種メトリクスをCloudWatchに送信するミドルウェアでした。

// ❌ 悪い例:呼び出されるたびにAWSセッションとクライアントを作成
package middleware

import (
	"log"
	"net/http"
	"time"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/cloudwatch"
)

type MetricsMiddleware struct {
	// ...
}

// この関数が呼ばれるたびに、重い初期化処理が走る
func (m *MetricsMiddleware) RecordMetric(metricName string, value float64) error {
	// 🚨 問題点1: 毎回AWSセッションを作成(認証情報読み込み、HTTP通信などが発生)
	sess, err := session.NewSession(&aws.Config{
		Region: aws.String("ap-northeast-1"),
	})
	if err != nil {
		log.Printf("Failed to create AWS session: %v", err)
		return err
	}

	// 🚨 問題点2: 毎回CloudWatchクライアントを作成
	cwClient := cloudwatch.New(sess)

	// メトリクス送信処理...
	_, err = cwClient.PutMetricData(&cloudwatch.PutMetricDataInput{
		// ...
	})
	return err
}

// 1回のリクエストで複数回メトリクスが送信される
func HandleRequest(w http.ResponseWriter, r *http.Request) {
	metrics := &MetricsMiddleware{}
	metrics.RecordMetric("RequestCount", 1)      // 1回目のセッション作成
	metrics.RecordMetric("ProcessingTime", 150)  // 2回目のセッション作成
	metrics.RecordMetric("MemoryUsage", 128)     // 3回目のセッション作成
}

なぜこのコードは致命的なのか。

問題点詳細
パフォーマンスsession.NewSession()は、設定ファイルや環境変数の読み込み、IAMロールの認証情報取得など、50-100msかかる重い処理。リクエストごとに複数回実行されると、レスポンスタイムが劇的に悪化します。
リソースの無駄毎回セッションとクライアントオブジェクトを生成・破棄するため、メモリ使用量が増加し、ガベージコレクション(GC)の負荷を高めます。
テストの困難さこのコードをテストするには、本物のAWS認証情報が必要。テストのたびに実際のCloudWatchにメトリクスが送信されてしまい、外部環境に依存した不安定で実行の遅いテストになります。

解決策:依存性注入(DI)で関心を分離する

この問題を解決する鍵が依存性注入(DI)です。DIとは、あるコンポーネント(この場合はMetricsMiddleware)が必要とする別のコンポーネント(CloudWatchClient)を、内部で生成するのではなく、外部から与える(注入する)設計原則です。

Step 1: 依存関係をインターフェースで定義する

まず、MetricsMiddlewareが依存する「CloudWatchへのメトリクス送信機能」をインターフェースとして定義します。これにより、MetricsMiddlewareは具象的なcloudwatch.CloudWatch型ではなく、抽象的なCloudWatchClientインターフェースに依存することになります。

// ✅ 改善案:testableなインターフェースを定義
package middleware

import "github.com/aws/aws-sdk-go/service/cloudwatch"

// CloudWatchClientは、`PutMetricData`メソッドを持つあらゆる型を表すインターフェース
type CloudWatchClient interface {
	PutMetricData(input *cloudwatch.PutMetricDataInput) (*cloudwatch.PutMetricDataOutput, error)
}

// これにより、*cloudwatch.CloudWatch型だけでなく、自作のモックも受け入れ可能になる
var _ CloudWatchClient = (*cloudwatch.CloudWatch)(nil)

Step 2: 依存性を外部から注入できるように構造体を変更する

次に、MetricsMiddlewareがコンストラクタでCloudWatchClientインターフェースを受け取るように変更します。

// ✅ 改善案:依存性注入(DI)を適用したMetricsMiddleware
type MetricsMiddleware struct {
	functionName string
	namespace    string
	cwClient     CloudWatchClient // 🚨 外部から注入されるクライアント
}

// NewMetricsMiddlewareはコンストラクタで依存性を受け取る
func NewMetricsMiddleware(
	functionName, namespace string,
	client CloudWatchClient, // インターフェース型で受け取る
) *MetricsMiddleware {
	return &MetricsMiddleware{
		functionName: functionName,
		namespace:    namespace,
		cwClient:     client, // 注入されたクライアントを保持
	}
}

func (m *MetricsMiddleware) RecordMetric(metricName string, value float64) error {
	// 保持しているクライアントを再利用するため、セッション作成は不要!
	_, err := m.cwClient.PutMetricData(&cloudwatch.PutMetricDataInput{
		// ...
	})
	// ...
	return err
}

Step 3: アプリケーション起動時にクライアントを一度だけ生成する

アプリケーションの初期化処理(main関数など)で、本物のCloudWatchClientを一度だけ生成し、MetricsMiddlewareに注入します。シングルトンパターンを利用すると、このクライアントをアプリケーション全体で安全に共有できます。

// ✅ 改善案:シングルトンパターンでAWSクライアントを一元管理
package main

import (
	"log"
	"your-project/middleware"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/cloudwatch"
)

var (
	cwClient *cloudwatch.CloudWatch
	// ... sync.Onceなどを使ったより堅牢な実装も可能
)

// init関数でアプリケーション起動時に一度だけクライアントを生成
func init() {
	sess, err := session.NewSession(&aws.Config{
		Region: aws.String("ap-northeast-1"),
	})
	if err != nil {
		log.Fatalf("Failed to create AWS session: %v", err)
	}
	cwClient = cloudwatch.New(sess)
	log.Println("AWS CloudWatch client initialized successfully.")
}

func main() {
	// 生成済みのクライアントをMetricsMiddlewareに注入
	metrics := middleware.NewMetricsMiddleware(
		"MyFunction",
		"MyApp/Lambda",
		cwClient, // 注入!
	)

	// あとはこのmetricsインスタンスを使い回すだけ
	// ...
}

DIがもたらす最大の恩恵:劇的に向上するテスト容易性

依存性をインターフェースで定義したことで、テストが驚くほど簡単になります。本物のAWSクライアントの代わりに、自作のモック(偽物)クライアントを注入できるようになったからです。

モッククライアントの実装

テストコード内に、CloudWatchClientインターフェースを満たすモックを実装します。

// ✅ テスト用のモッククライアント
type MockCloudWatchClient struct {
	// PutMetricDataが何回、どのような引数で呼ばれたかを記録
	PutMetricDataCalls int
	LastInput        *cloudwatch.PutMetricDataInput
	// テスト用にエラーを返すことも可能
	ReturnError error
}

func (m *MockCloudWatchClient) PutMetricData(input *cloudwatch.PutMetricDataInput) (*cloudwatch.PutMetricDataOutput, error) {
	m.PutMetricDataCalls++
	m.LastInput = input
	if m.ReturnError != nil {
		return nil, m.ReturnError
	}
	return &cloudwatch.PutMetricDataOutput{}, nil
}

モックを使った、高速で安定したテスト

このモックを使えば、AWSに一切接続することなく、MetricsMiddlewareのロジックだけを正確にテストできます。

// ✅ 改善後:モックによる完全なユニットテスト
func TestMetricsMiddleware_RecordMetric(t *testing.T) {
	// 1. モッククライアントを作成
	mockClient := &MockCloudWatchClient{}

	// 2. モックをMetricsMiddlewareに注入
	metrics := NewMetricsMiddleware("TestFunc", "Test/App", mockClient)

	// 3. テスト対象のメソッドを実行
	err := metrics.RecordMetric("TestMetric", 42.0)

	// 4. 結果を検証
	assert.NoError(t, err) // エラーがないことを確認

	// 5. モックの状態を検証(意図通りに呼び出されたか)
	assert.Equal(t, 1, mockClient.PutMetricDataCalls, "PutMetricData should be called once")
	assert.Equal(t, "TestMetric", *mockClient.LastInput.MetricData[0].MetricName)
	assert.Equal(t, 42.0, *mockClient.LastInput.MetricData[0].Value)
}

func TestMetricsMiddleware_ErrorHandling(t *testing.T) {
	// エラーを返すモックを準備
	mockClient := &MockCloudWatchClient{
		ReturnError: errors.New("mock CloudWatch API error"),
	}
	metrics := NewMetricsMiddleware("TestFunc", "Test/App", mockClient)

	err := metrics.RecordMetric("TestMetric", 1.0)

	// エラーが正しく伝播されることを確認
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "mock CloudWatch API error")
}

まとめ:DIで得られる3つのメリット

メリットBefore (DIなし)After (DIあり)
パフォーマンス毎回50-100msのオーバーヘッド初期化は一度きり。2回目以降はほぼ0ms。
テスト容易性外部APIに依存し、不安定で遅いモックを使え、安定して超高速なテストが可能。
保守性・柔軟性コードが密結合で、変更が困難依存関係が明確で、コンポーネントの交換や再利用が容易。

DIを実践するためのチェックリスト

  • 外部サービスへのクライアントやDBコネクションを、関数内で毎回生成していないか。
  • 依存するコンポーネントを、インターフェースで抽象化できないか。
  • テストを書くときに、外部APIへの接続が必要になっていないか。
  • アプリケーションの起動時に、共有すべきリソース(クライアント、設定など)を一度だけ初期化しているか。

依存性注入は、一見すると複雑に思えるかもしれませんが、その本質は「責務の分離」というシンプルな原則です。