🕌

【Go言語】定義型に対するjson.Marshalではomitemptyが効かない件

2022/12/06に公開

これは ZOZO Advent Calendar 2022 カレンダーVol.5の6日目の記事です。

まとめ

  • 定義型[1]に対するJSONタグでomitemptyを付与しても、json.Marshalでフィールドがエンコードされてしまう
  • omitemptyを効かせたい場合は、ポインタ型を使うか埋め込み型でJSONタグを上書きする

omitemptyの仕組み

以下の構造体をjson.Marshal()する時を例に解説します。

type myStruct struct {
	Hoge string `json:"hoge,omitempty"`
}

func main() {
	bytes, _ := json.Marshal(myStruct{Hoge: ""})
	fmt.Println(string(bytes))
}

json.Marshal()を呼び出すとjson.structEncoderのインスタンスが作られますが、この構造体の中にはstructの各フィールドの情報が保持されています。
https://github.com/golang/go/blob/go1.19.3/src/encoding/json/encode.go#L1173-L1189

JSONタグにomitemptyを付与した場合、このfield構造体のomitemptyフィールドがtrueになります。
json.Marshal(myStruct{Hoge: ""})を呼び出した時のfieldインスタンスをdebuggerで見ると、意図通りomitEmptyフィールドがtrueになっています。

{
	name:"hoge",
	nameNonEsc:"\"hoge\":",
	nameNonHTML:"\"hoge\":",
	tag:true,
	omitEmpty:true
}

この後にencoding/jsonパッケージでは、omitemptyフィールドがtrueであり値が空であると判定できるフィールドのエンコードはスキップします。
https://github.com/golang/go/blob/go1.19.3/src/encoding/json/encode.go#L749-L751

このスキップ処理の判定で使われているjson.isEmptyValue()では、対象のvalueの型を見てから空値の判定をしています。
https://github.com/golang/go/blob/go1.19.3/src/encoding/json/encode.go#L340-L356

今回json.Marshal()に渡している引数myStruct{Hoge: ""}Hogeフィールドでは、v.Kind() == reflect.Stringv.Len() == 0を満たすので、空値であると判定されてエンコードはスキップされます。

$ go run main.go
{}

もうお気づきかと思いますがjson.isEmptyValud()で空値と判定される型は予め定義されたいくつかの型に限られており、それ以外の型ではいかなる場合も空値と判定させることは出来なそうです。
試しにHogeフィールドをtime.Time型のフィールドに変えて再実行してみます。

type myStruct struct {
	Timestamp time.Time `json:"timestamp,omitempty"`
}

func main() {
	var t time.Time
	fmt.Println(t.IsZero())

	bytes, _ := json.Marshal(myStruct{Timestamp: t})
	fmt.Println(string(bytes))
}
$ go run main.go
true
{"timestamp":"0001-01-01T00:00:00Z"}

フィールドの値を(time.Time).IsZero()trueになるような値にした場合もjsonではtimestampプロパティがエンコードされてしまっています。
上記で説明した内容を知っていれば当たり前ですが、time.Time型はreflect.Structでありjson.isEmptyValueの結果がいかなる場合もfalseになります。よって、time.Time型のような定義型はomitemtpyを効かせることが出来ません。

定義型でomitemptyを使う

定義型ではomitemptyが効かない理由を説明しましたが、どうにかして適用させたい場合があるかと思います。
その場合は以下のような方法でomitemptyの挙動を再現できそうです。

  • ポインタ型を使う
  • 埋め込み型でJSONタグを上書きする

ポインタ型を使う

json.isEmptyValudではreflect.Structに対する空値判定はしていませんが、reflect.Pointerに対する空値判定はしています。
なのでtime.Time型の代わりに、*time.Time型を使うことでomitemptyを適用することが出来ます。

type myStruct struct {
	Timestamp *time.Time `json:"timestamp,omitempty"`
}

func main() {
	bytes, _ := json.Marshal(myStruct{Timestamp: nil})
	fmt.Println(string(bytes))
}
$ go run main.go
{}

埋め込み型でJSONタグを上書きする

Goには埋め込み型(Embedding Type)があります。埋め込み型は構造体やインターフェースに他の型を埋め込んで実装の一部を借用する機能です。
埋め込み型を使うと埋め込み先と埋め込み元の型で名前競合が発生する可能性がありますが、常にネストが浅い方の名前を優先することで名前解決を行なっています。

https://go.dev/doc/effective_go#embedding

Embedding types introduces the problem of name conflicts but the rules to resolve them are simple. First, a field or method X hides any other item X in a more deeply nested part of the type. If log.Logger contained a field or method called Command, the Command field of Job would dominate it.

JSONタグにもこの名前解決のルールが適用されるため、それを利用してomitemptyが効くような型のフィールドで上書きすることが出来ます。
呼び出し元でハンドリングしてあげる必要がありますが、以下のようにしてomitemtpyの挙動を再現できます。

type myStruct struct {
	Timestamp time.Time `json:"timestamp"`
}

func main() {
	var t time.Time
	m := myStruct{Timestamp: t}
	var bytes []byte
	if m.Timestamp.IsZero() {
		bytes, _ = json.Marshal(struct {
			*myStruct
			T json.RawMessage `json:"timestamp,omitempty"`
		}{myStruct: &m})
	} else {
		bytes, _ = json.Marshal(m)
	}
	fmt.Println(string(bytes))
}
$ go run main.go
{}

myStructTimestamp型がゼロ値の場合は、同じJSONタグを持ったフィールドがあるstructにmyStructを埋め込んであげることで、myStructの方のtimestampプロパティを無視しています。

脚注
  1. 正確には、json.isEmptyValueがいかなる場合もfalseになってしまうような型です ↩︎

Discussion