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

Go言語で学ぶDRY原則:コピペコードが招く保守性の悪夢と統一化による解決策

タグ: 🏷 Golang ,DRY原則 ,JWT

はじめに:「このコード、前にも書いたな…」

開発を進めていると、「この処理、前に書いたものと似ているな。コピーして少しだけ修正すれば動くだろう」と考えてしまう瞬間はありませんか。その「少しの修正」が積み重なり、気づけばアプリケーションのあちこちに、よく似たコードが散乱している…。

これは、DRY (Don’t Repeat Yourself) 原則に反する典型的なアンチパターンであり、将来のバグやメンテナンスコスト増大の温床となります。

今回は、実際のプロダクションコードで発生したJWTトークン抽出処理の重複を例に、なぜコードの重複が問題なのか、そしてDRY原則に則ってどのように解決すべきかを具体的に解説します。

問題のコード:2人の開発者、2つのよく似た実装

あるプロジェクトで、2つの異なるパッケージに、ほぼ同じJWTトークン抽出処理が実装されていました。

ファイル1: pkg/utils/auth.go (開発者Aが、APIロジックのために実装)

// ❌ 悪い例:1つ目のJWTトークン抽出処理
package utils

import "strings"

func ExtractToken(authHeader string) string {
	if authHeader == "" {
		return ""
	}
	parts := strings.Split(authHeader, " ")
	if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
		return ""
	}
	return parts[1]
}

ファイル2: pkg/middleware/auth.go (開発者Bが、認証ミドルウェアのために実装)

// ❌ 悪い例:2つ目のJWTトークン抽出処理(ほぼ同じだが、エラー処理が異なる)
package middleware

import (
	"errors"
	"net/http"
	"strings"
)

func validateAuthHeader(header string) (string, error) {
	if header == "" {
		return "", errors.New("missing authorization header")
	}
	parts := strings.Split(header, " ")
	if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
		return "", errors.New("invalid authorization header format")
	}
	return parts[1], nil
}

なぜこの重複は起きたのか。

  • チーム内の連携不足: 開発者AとBが、互いの実装を知らずに同じ機能を作ってしまった。
  • 微妙な要件の違い: 片方はエラー時に空文字列を返し、もう片方はerrorオブジェクトを返す。この「微妙な違い」が、別々の実装を正当化してしまった。
  • 発見の困難さ: 関数名 (ExtractToken vs validateAuthHeader) やパッケージが異なり、コードレビューでも見落とされた。

コード重複の隠れたコスト

問題点Before (重複あり)After (DRY)
保守性バグ修正や仕様変更を2箇所以上で行う必要があり、修正漏れのリスクが高い。修正は1箇所で済み、全機能に即時反映される。
一貫性同じ入力でも、実装によって挙動が異なる(空文字列 vs エラー)。どこから呼び出しても、常に同じ動作とエラーハンドリングが保証される。
テストほぼ同じ内容のテストを2セット書く必要があり、コストが2倍になる。テストは1セットで済み、網羅的で信頼性の高いテストを1箇所で管理できる。
セキュリティstrings.Split は連続した空白に弱く、両方の実装に脆弱性が存在する。strings.Fields を使った安全な実装に統一し、セキュリティを一度に改善できる。

解決策:処理を共通化し、「単一の真実の源」を作る

Step 1: 要件を整理し、統一仕様を決定する

まず、2つの実装の要件を洗い出し、両方を満たす統一仕様を定義します。

  • 入力: Authorizationヘッダー文字列
  • 出力: 抽出したトークン文字列と、詳細なerrorオブジェクト
  • 検証項目: 空チェック、Bearerキーワードの有無(大文字小文字無視)、トークン自体の空チェック

Step 2: 統一された共通関数を実装する

決定した仕様に基づき、堅牢な共通関数をpkg/utils/auth.goに作成します。

// ✅ 改善案:pkg/utils/auth.go に統一された実装を作成
package utils

import (
	"errors"
	"fmt"
	"strings"
)

// ExtractBearerToken はAuthorizationヘッダーからJWTトークンを安全に抽出します。
// この関数が「単一の真実の源」となります。
func ExtractBearerToken(authHeader string) (string, error) {
	if authHeader == "" {
		return "", errors.New("authorization header is missing")
	}

	// 連続した空白にも対応できる `strings.Fields` を使用
	parts := strings.Fields(authHeader)
	if len(parts) != 2 {
		return "", fmt.Errorf("invalid authorization header format: expected 'Bearer <token>', but got %d parts", len(parts))
	}

	if !strings.EqualFold(parts[0], "bearer") {
		return "", errors.New("authorization header must start with 'Bearer'")
	}

	token := parts[1]
	if token == "" {
		return "", errors.New("bearer token is empty")
	}

	return token, nil
}

Step 3: 既存コードをリファクタリングし、共通関数を利用する

新しい共通関数を使うように、既存のコードを修正します。

pkg/middleware/auth.goの修正

// ✅ 改善案:ミドルウェアで共通関数を利用
package middleware

import (
	"net/http"
	"your-project/pkg/utils" // 共通処理をインポート
)

func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")

		// 🚨 統一された共通関数を呼び出す
		token, err := utils.ExtractBearerToken(authHeader)
		if err != nil {
			http.Error(w, fmt.Sprintf("Authentication failed: %s", err.Error()), http.StatusUnauthorized)
			return
		}
		// ... トークン検証処理 ...
		next.ServeHTTP(w, r)
	})
}

Step 4: 後方互換性を保つためのラッパー関数(推奨)

utils.ExtractTokenのように、エラーを返さず空文字列を返す古い実装に依存している箇所が多数ある場合、いきなり全てを修正するのは大変です。そこで、後方互換性を保つためのラッパー関数を用意します。

// ✅ pkg/utils/auth.go にラッパー関数を追加

// ExtractTokenは後方互換性のために残されたラッパー関数です。
// 新しいコードではExtractBearerTokenの使用を推奨します。
// @deprecated
func ExtractToken(authHeader string) string {
	token, err := ExtractBearerToken(authHeader)
	if err != nil {
		return "" // 既存の挙動(エラー時に空文字列を返す)を維持
	}
	return token
}

これにより、既存のコードを壊すことなく、段階的に新しい関数へ移行できます。

テストもDRYに。網羅的なテストを1箇所で管理

重複が解消されたことで、テストも1箇所に集約し、より網羅的に記述できます。

// ✅ pkg/utils/auth_test.go - 統一されたテスト
func TestExtractBearerToken(t *testing.T) {
	testCases := []struct {
		name       string
		authHeader string
		wantToken  string
		wantErrMsg string
	}{
		{"valid token", "Bearer eyJhbGci...", "eyJhbGci...", ""},
		{"lowercase bearer", "bearer token123", "token123", ""},
		{"extra spaces", "Bearer   token123  ", "token123", ""},
		{"empty header", "", "", "authorization header is missing"},
		{"missing bearer keyword", "Token token123", "", "must start with 'Bearer'"},
		{"empty token", "Bearer ", "", "bearer token is empty"},
		{"malformed header", "Bearer t1 t2", "", "invalid authorization header format"},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			token, err := ExtractBearerToken(tc.authHeader)
			assert.Equal(t, tc.wantToken, token)
			if tc.wantErrMsg != "" {
				assert.Error(t, err)
				assert.Contains(t, err.Error(), tc.wantErrMsg)
			} else {
				assert.NoError(t, err)
			}
		})
	}
}

まとめ:DRY原則を実践し、未来の自分を助けよう

DRY原則は単なる理論ではありません。コードの健全性を保ち、将来の開発効率を大きく向上させるための実践的な指針です。

DRY原則を実践するためのチェックリスト

  • 書く前に探す: 似た処理を実装する前に、既存コードを検索する習慣をつけよう。
  • 違いを疑う: 「少し仕様が違うから」と安易にコピペせず、本当に別の実装が必要か検討しよう。違いはパラメータやオプションで吸収できないか。
  • 一箇所にまとめる: 共通のロジックは、明確な名前をつけた関数として抽出し、単一の場所に配置しよう。
  • 段階的に移行する: 影響範囲が広い場合は、ラッパー関数を用意して後方互換性を保ちながら、安全にリファクタリングを進めよう。

「同じことは二度書かない」。このシンプルな原則を心に留めておくことで、あなた自身とチームを未来のバグや無駄な作業から救うことができるのです。