【Golang】意図しない巨大モデルのフィールド変更・フィールド変更に伴う実装漏れをテストで防ぎたい！

これは、[Wano Group Advent Calendar](https://qiita.com/advent-calendar/2024/wano-group) 2024の8日目の記事です。
7日目は、[@nokazn](https://qiita.com/nokazn)さんの、[郵便番号から住所が補完される入力フォームを作るときに考えること](https://qiita.com/nokazn/items/a458230e587c549a1c67)です。


# これまでの状況

現在、ユーザーによる登録情報の証跡（ログ）を、JSONとして保存しています。
これは、例えばある時点の登録情報と、最新の登録情報の差分を出すことなどに用いられます。

```go
/*
	証跡メタ、リソース情報をHogehogeResourcesJsonString型として格納します。
*/

const CURRENT_SCHEMA_VERSION = 1.0

type HogehogeEvidenceMeta struct {
	SchemaVersion              float64                    `json:"schema_version"`
	HogehogeResourcesJsonString HogehogeResourcesJsonString `json:"hogehoge_resources_json_string"`

// 巨大モデルをJSONとして扱うための型。HogehogeResourcesをJsonStringに変換したもの。
type HogehogeResourcesJsonString string

// 大量のフィールドを持つ構造体。
type HogehogeResources struct {
    Field1 int
    Field2 string
    Field3 []Fugafuga
    // etc...
}
```

# 起こっていた問題

HogehogeResourcesにフィールドが追加されたり、削除された時に
型の詰め替え等の中で必須フィールドの詰め替えが漏れるなど、デプロイ後に発生したエラーで気付く...ということがありました。

また、保存したJsonStringの差分を出すときに、前後でスキーマが意図せず変わっていると
ユーザー入力による登録情報がおかしかったのか、スキーマ変更による差分なのかが分からなくなってしまう恐れがあります。

この構造体は、実装上多くの箇所で用いられます。

他の箇所の都合でフィールドを追加されることがあるため、それが意図した変更かを確認したうえで、別の場所での修正を忘れないようにする必要がありました。

# やりたいこと

本来は、修正箇所が少なくなるような実装にリファクタできれば一番です。
ただし今回は、修正に使える期間と影響箇所の多さから、ひとまずの対応として自動テストで構造体の変化を検知することとしました。

今回のテストでは構造体に変更があった時、テスト実行時にそれを検知してエラーメッセージを出すことで、開発者に対して確認を促します。

# 実装したテスト

流れは以下の通りです。
1. 構造体の各フィールドを初期化 
2. 1をスナップショットとして文字列化
3. 2を元にハッシュを取得
4. 3と予想されるハッシュを比較して、異なればエラーとする 
5. エラーメッセージとして、修正の手順を出力することで開発者に対応を促す

```deep_struct_initializer.go
package util

import (
	"crypto/md5"
	"encoding/hex"
	"reflect"
)

// ハッシュ計算
func GetMd5(str string) string {
	hash := md5.New()
	defer hash.Reset()
	hash.Write([]byte(str))
	return hex.EncodeToString(hash.Sum(nil))
}

// ネストした構造体にnilのフィールドがある時、子の構造体がnil場合や、Sliceのフィールド、map、二重mapの場合も考慮して再帰的に初期化することでシリアライズ漏れを防ぐ
// ただし非公開フィールドは無視する
func DeepInitializeStruct(v interface{}) {
	val := reflect.ValueOf(v).Elem()

	for i := 0; i < val.NumField(); i++ {
		field := val.Field(i)
		fieldType := val.Type().Field(i)

		// 非公開フィールドにはアクセスしないようにする
		if !fieldType.IsExported() {
			continue
		}

		// ポインタ型でnilの場合、初期化
		if field.Kind() == reflect.Ptr && field.IsNil() {
			newField := reflect.New(fieldType.Type.Elem())
			field.Set(newField)
		}

		// スライス型でnilの場合、1つの要素を持つように初期化
		if field.Kind() == reflect.Slice && field.IsNil() {
			slice := reflect.MakeSlice(field.Type(), 1, 1)
			field.Set(slice)

			// スライスの要素が構造体の場合、初期化
			elem := field.Index(0)
			if elem.Kind() == reflect.Struct {
				DeepInitializeStruct(elem.Addr().Interface())
			}
		}

		// スライスや配列の各要素を再帰的に初期化
		if field.Kind() == reflect.Slice || field.Kind() == reflect.Array {
			for j := 0; j < field.Len(); j++ {
				elem := field.Index(j)
				if elem.Kind() == reflect.Ptr && elem.IsNil() {
					elem.Set(reflect.New(elem.Type().Elem()))
				}
				if elem.Kind() == reflect.Struct || (elem.Kind() == reflect.Ptr && elem.Elem().Kind() == reflect.Struct) {
					DeepInitializeStruct(elem.Addr().Interface())
				}
			}
		}

		// マップ型でnilの場合、1つのキー・値ペアを持つように初期化
		if field.Kind() == reflect.Map && field.IsNil() {
			newMap := reflect.MakeMap(field.Type())
			field.Set(newMap)

			// マップにダミーのキー・値を追加
			key := reflect.New(field.Type().Key()).Elem()
			value := reflect.New(field.Type().Elem()).Elem()

			// 2重mapの場合、ネストされたマップを初期化
			if value.Kind() == reflect.Map && value.IsNil() {
				newNestedMap := reflect.MakeMap(value.Type())
				value.Set(newNestedMap)

				// ネストされたマップにダミーのキー・値を追加
				nestedKey := reflect.New(value.Type().Key()).Elem()
				nestedValue := reflect.New(value.Type().Elem()).Elem()

				// 必要ならキー・値を初期化（構造体の場合）
				if nestedKey.Kind() == reflect.Struct {
					DeepInitializeStruct(nestedKey.Addr().Interface())
				}
				if nestedValue.Kind() == reflect.Struct {
					DeepInitializeStruct(nestedValue.Addr().Interface())
				}

				value.SetMapIndex(nestedKey, nestedValue)
			}

			// 必要ならキー・値を初期化（構造体の場合）
			if key.Kind() == reflect.Struct {
				DeepInitializeStruct(key.Addr().Interface())
			}
			if value.Kind() == reflect.Struct {
				DeepInitializeStruct(value.Addr().Interface())
			}

			// マップにキー・値をセット
			field.SetMapIndex(key, value)
		}

		// フィールドが構造体の場合、再帰的に処理
		if field.Kind() == reflect.Struct {
			DeepInitializeStruct(field.Addr().Interface())
		}
	}
}
```

```hogehoge_evidence_test.go

// HogehogeEvidence.LogJsonで利用しているhogehoge_resource_service.HogehogeResourcesの構造が変わった場合、
// それが意図しない変更ではないか確認を促し、問題なければevidenceのJSONのschema_versionを修正する必要がある。
// 開発者に対してここで通知を行う。
func TestHogehogeEvidenceMetaJsonStringSchemaChanged(t *testing.T) {

	currentSchemaVersion := hogehoge_registration_support_evidence.CURRENT_SCHEMA_VERSION

	hogehogeResources := hogehoge_resource_service.HogehogeResources{}
	util.DeepInitializeStruct(&hogehogeResources)
	data := litter.Sdump(hogehogeResources)
	// 期待されるハッシュ値
	expectedHash := "8a77f7a3b847b967146fdb210175ed20"
	// ハッシュ計算
	actualHash := util.GetMd5(data)

	// hashが変わっていたら、エラーメッセージを出して確認させる。
	// 確認してOKなら、schema_versionを修正するよう指示する。
	assert.Equal(t, expectedHash, actualHash, `現在のSchemaVersionは%vです。hogehoge_resource_service.HogehogeResourcesの構造が変更されているため、HogehogeEvidence.LogJsonの構造も変更されます。
このスキーマ変更が意図通りであれば以下の対応を実施してください。
1. このファイルのexpectedHashの値を「%v」に変更する。
2. /project/pkg/domain/hogehoge_evidence/log_json.goを確認し、CURRENT_SCHEMA_VERSIONの値を%vに更新する。`,
		currentSchemaVersion, actualHash, currentSchemaVersion+1)
}
```


# 人材募集

弊社グループでは一緒に働くメンバーを募集中です、ご応募お待ちしています！

https://herp.careers/v1/wano