Goで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への接続が必要になっていないか。
- アプリケーションの起動時に、共有すべきリソース(クライアント、設定など)を一度だけ初期化しているか。
依存性注入は、一見すると複雑に思えるかもしれませんが、その本質は「責務の分離」というシンプルな原則です。