😀

Golangでタグに応じた構造体→Mapの変換

2023/08/30に公開
2

背景

弊社では現在Firestoreを用いて開発をしているのですが、Firebase admin SDKを利用してデータベースとやり取りする際、Createは構造体を用いてタグの内容に沿って実行されますが、Set時、firebase.MergeAllを利用する時はMapへの変換を求められ、Map変換すると色んな不具合が起こったため、タグの内容に沿ったMapを作成できる関数を作りたいと思いました。

結論

下記になります。

package main

import (
	"fmt"
	"reflect"
	"strings"
	"time"
)

func StructToMap(val interface{}) (map[string]interface{}, error) {
	return structToMapRecursive(val)
}

func structToMapRecursive(val interface{}) (map[string]interface{}, error) {
	value := reflect.ValueOf(val)
	typ := reflect.TypeOf(val)

	if value.Kind() != reflect.Struct {
		return nil, fmt.Errorf("only structs are supported")
	}

	mapVal := make(map[string]interface{})
	for i := 0; i < value.NumField(); i++ {
		field := value.Field(i)
		fieldType := typ.Field(i)

		firestoreTag := fieldType.Tag.Get("firestore")
		if firestoreTag == "-" {
			continue
		}

		jsonTag := fieldType.Tag.Get("json")
		if jsonTag != "" {
			jsonTag = strings.Split(jsonTag, ",")[0]
		} else {
			jsonTag = fieldType.Name
		}

		switch field.Kind() {
		case reflect.String:
			if strVal := field.String(); strVal != "" {
				mapVal[jsonTag] = strVal
			}
		case reflect.Int, reflect.Int64, reflect.Int32:
			if intVal := field.Int(); intVal != 0 {
				mapVal[jsonTag] = intVal
			}
		case reflect.Struct:
			if field.Type() == reflect.TypeOf(time.Time{}) {
				if timeVal := field.Interface().(time.Time); !timeVal.IsZero() {
					mapVal[jsonTag] = timeVal
				}
			} else {
				nestedMap, err := structToMapRecursive(field.Interface())
				if err != nil {
					return nil, err
				}
				if len(nestedMap) > 0 {
					mapVal[jsonTag] = nestedMap
				}
			}
		case reflect.Slice, reflect.Array:
			len := field.Len()
			slice := make([]interface{}, len)
			for i := 0; i < len; i++ {
				elem := field.Index(i)
				if elem.Kind() == reflect.Struct {
					mappedElem, err := structToMapRecursive(elem.Interface())
					if err != nil {
						return nil, err
					}
					slice[i] = mappedElem
				} else {
					slice[i] = elem.Interface()
				}
			}
			mapVal[jsonTag] = slice
		case reflect.Bool:
			boolVal := field.Bool()
			mapVal[jsonTag] = boolVal

		case reflect.Float32, reflect.Float64:
			floatVal := field.Float()
			if floatVal != 0.0 {
				mapVal[jsonTag] = floatVal
			}
		case reflect.Ptr:
			if !field.IsNil() {
				elem := field.Elem()
				nestedMap, err := structToMapRecursive(elem.Interface())
				if err != nil {
					return nil, err
				}
				if len(nestedMap) > 0 {
					mapVal[jsonTag] = nestedMap
				}
			}
		}

	}

	return mapVal, nil
}

お試し用コード

package main

import (
	"fmt"
	"reflect"
	"strings"
	"time"
)

type Address struct {
	City  string `json:"city" firestore:"-"`
	State string `json:"state" firestore:"state"`
}

type Person struct {
	Name    string    `json:"name" firestore:"name"`
	Age     int       `json:"age" firestore:"age"`
	Date    time.Time `json:"date" firestore:"date"`
	Address Address   `json:"address" firestore:"-"`
	Hide    string    `json:"hide" firestore:"-"`
}

func StructToMap(val interface{}) (map[string]interface{}, error) {
	return structToMapRecursive(val)
}

func structToMapRecursive(val interface{}) (map[string]interface{}, error) {
	value := reflect.ValueOf(val)
	typ := reflect.TypeOf(val)

	if value.Kind() != reflect.Struct {
		return nil, fmt.Errorf("only structs are supported")
	}

	mapVal := make(map[string]interface{})
	for i := 0; i < value.NumField(); i++ {
		field := value.Field(i)
		fieldType := typ.Field(i)

		firestoreTag := fieldType.Tag.Get("firestore")
		if firestoreTag == "-" {
			continue
		}

		jsonTag := fieldType.Tag.Get("json")
		if jsonTag != "" {
			jsonTag = strings.Split(jsonTag, ",")[0]
		} else {
			jsonTag = fieldType.Name
		}

		switch field.Kind() {
		case reflect.String:
			if strVal := field.String(); strVal != "" {
				mapVal[jsonTag] = strVal
			}
		case reflect.Int, reflect.Int64, reflect.Int32:
			if intVal := field.Int(); intVal != 0 {
				mapVal[jsonTag] = intVal
			}
		case reflect.Struct:
			if field.Type() == reflect.TypeOf(time.Time{}) {
				if timeVal := field.Interface().(time.Time); !timeVal.IsZero() {
					mapVal[jsonTag] = timeVal
				}
			} else {
				nestedMap, err := structToMapRecursive(field.Interface())
				if err != nil {
					return nil, err
				}
				if len(nestedMap) > 0 {
					mapVal[jsonTag] = nestedMap
				}
			}
		}
	}

	return mapVal, nil
}

func main() {
	addr := Address{City: "NY", State: "NY"}
	p := Person{Name: "", Age: 30, Date: time.Now(), Address: addr, Hide: "hidden"}
	result, err := StructToMap(p)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println(result)
}

これのおかげで、不要なフィールドがドキュメントに作られることなく、変更したい値だけをセットした構造体でFirestoreの上書きができるようになりました。(まだ検証甘いためバグはあるかもです。。。)

余談

今更の話かもですが、ChatGPTで作成したコードをオンライン実行環境で一旦使ってみること大事だなと思いました。個人的に、GoはGo Playgroundがおすすめ。

間違いやもっと良い方法あったら是非教えて下さい。

追記

  • 配列、スライス、float、bool、ポインタに対応できていなかったのでその部分を追加しました。

Discussion

ngicksngicks

私も似たようなコードを書いたことがあって、同様に似たような見落としをしたことがあるのですがembedされたstructのハンドリングがされてないですね。

type Person struct {
	Name string    `json:"name" model:"name"`
	Age  int       `json:"age" model:"age"`
	Date time.Time `json:"date" model:"date"`
+	Address
-	Address Address   `json:"address" firestore:"-"`
	Hide string `json:"hide" model:"-"`
}
// map[Address:map[state:NY] age:30 date:2009-11-10 23:00:00 +0000 UTC m=+0.000000001]

この辺をどうするかはencoding/jsonの中身を読むと大変複雑なことがわかります。

githubを適当に検索するといろいろ出てきますが、go-modelがぱっと見うまく動きそうです

https://go.dev/play/p/5iTxAtCJLcE

package main

import (
	"fmt"
	"time"

	"gopkg.in/jeevatkm/go-model.v1"
)

var (
	fakeCurrent time.Time
)

func init() {
	fakeCurrent, _ = time.Parse(time.RFC3339Nano, "2024-04-03T02:01:00.000000000Z")
}

type Address struct {
	City  string `json:"city" model:"-"`
	State string `json:"state" model:"state"`
}

type Person struct {
	Name string    `json:"name" model:"name"`
	Age  int       `json:"age" model:"age"`
	Date time.Time `json:"date" model:"date"`
	Address
	Hide string `json:"hide" model:"-"`
}

func main() {
	addr := Address{City: "NY", State: "NY"}
	p := Person{Name: "", Age: 30, Date: fakeCurrent, Address: addr, Hide: "hidden"}

	encoded, err := model.Map(p)
	if err != nil {
		panic(err)
	}

	fmt.Println(encoded)
}
// map[age:30 date:2024-04-03 02:01:00 +0000 UTC name: state:NY]

map[string]anyからstructureへの値のコピーはmapstructureが人気なのですが、試してみるとstructからmap[string]anyへの変換時にtime.Timeが空のmap(map[])になるなどうまく取り扱えないようです(issue)。

参考になれば幸いです。

TKNRTKNR

@ngicks
こちら確認が遅くなってしまいすみません。
ご指摘ありがとうございます!
大変勉強になりました。
確かにembedについて完全に考慮漏れしていました。
go-model、非常に便利そうですね!