📦

GoのJSONのT | null | undefinedは[]Option[T]で表現できる

2024/07/10に公開

GoのT | null | undefinedは[]Option[T]でよかった

  • github.com/oapi-codegen/nullablemap[bool]Tをベースにencoding/jsonにオミットされることが可能なT | null | undefinedな型を定義していた
  • type Option[T any] struct{valid bool; v T}を定義してtype undefinedable []Option[T]としたほうがパフォーマンスが出るんじゃないかと思って試した。
    • ほんのちょっぴりパフォーマンスがよかった。

はじめに

JSONを相互に送りあうシステムではたびたびT(フィールドにある型の値がある, defined, specified, present)、null(nullがフィールドにセットされている)、undefined(フィールドが存在しない, undefined, unspecified, absent)を使い分けることがあります。これはGoのstdが敷く「structとバイト列と相互変換する」というデータ変換の様式の中で表現するのが難しく、特別な努力を要していました。

以前の記事で同じテーマについていろいろ述べて解決法までを示しました。それから1年ほどたって知見が筆者の脳内で整理できたり、そもそも大がかりなこと(エンコーダーの用意とか)をしなくても[]Option[T]を利用すればencoding/jsonにオミットされることが可能かつT | null | undefinedを表現できる型を定義できることに気付きました。

この記事は以前の記事の置き換えを意図しており、それをobsoleteにするものとして書いています。そのためこの記事だけを読めばいいだけになるようにします。
この目的から以前の記事とこの記事は大部分が重複し、一部を追加し後半の大分部分を削除するような記事になります。
(ただし前半部分も文章をリファインしてxmlの話を含めるようにしたなどのアップデートをしてあります)
必要に応じて読者には記事をスキップしてほしいと思います。

Overview(TL;DR)

  • Elasticsearch(のupdate API)のようなJSONにおけるnullundefined(JSONにフィールドがない)状態をうまく使い分けるシステムに送るJSONを structをmarshalするだけでいい感じに作りたい。
  • encoding/jsonの挙動を利用し、map[K]Vもしくは[]Tをベースとする型を工夫することで可能なことが分かった。
  • some(present)/none(absent)を表現できる型としてOption[T]を定義して、[]Option[T]T | null | undefinedを表現する型とした。

やること

  • GoT | null | undefinedがなぜ表現しにくいかについて説明します
  • T | null | undefinedのユースケースとしてElasticsearchのpartial updateを説明します
  • 普通、フィールドのあるなしをどうやってチェックするかなどを、広く使われるライブラリの実装を例に説明します
  • 解決法を二つ紹介します。
    • encoding/jsonとすでに互換性のある方法([]Option[T])
    • encoding/json/v2のみで使えるもっと効率的な方法(Option[Option[T]])

前提知識

  • Go programming languageの細かい説明はしてますが全体の説明はしないので、ある程度知っている人じゃないと意味が分からないかもしれません。
  • JSONがどのようなフォーマットであるか。

環境

ドキュメントはすべてGo1.22.5のものを参照します。

筆者環境は以下のようにGo1.22.0のままですが、リリースノートを見る限り記事中で言及する挙動に関する変更はなさそうなので特に影響はありません。

# go version
go version go1.22.0 linux/amd64

Go 1.18で追加されたgenericsを用いる以外はバージョン依存な要素はないはずなので、Go 1.18ではおおむね通じる話をします。

対象読者

  • Goのstruct fieldでT | null | undefinedを表現する方法がわからない人
  • Go で JSON を受け取る API を組むときに validation などで悩んでいる人
  • encoding/jsonのポイントを知りたい人

言葉の定義

本投稿では以下のように用語を定めます

用語 説明
encode Go valueからバイト列([]byte)への変換
decode バイト列([]byte)からGo valueへの変換
Marshal/Unmarshal encode/decodeとほぼ同義。この記事においては違いは気にされない
undefined encode先/decode元のJSON Objectのフィールドが存在しないこと、およびそれを出力しないようなGo valueの状態のこと
null encode先/decode元のJSON Objectのフィールドがnull literalであること、およびそれを出力するGo valueの状態のこと
T encode先/decode元のJSON Objectのフィールドがある型Tの値に対応すること、およびそれを出力するGo valueの状態のこと

JSONRFC8259の定義を用います。

おさらい: 時たま困る「データがない状態」の扱い

JSONの特殊性

RFC8259によればJSONJavaScript Object Notation(javascriptのobjectの記法)の略語であり、ほとんどのケースでJSONは有効なjavascriptです。
そのためjavascriptの事情を多分に含んでいます。

javascriptには「データがない状態」の表現がnullundefinedという二通り存在します。
javascriptを書いているとき、取り扱うほとんどの値がObject・・・Goで言うとmap[string]anyのようなもの・・・ですので、フィールドがない(undefined)のとnull(Goでいうとnil)が含まれていることは自然と表現できます。

実はundefinedという値や型が存在するため「フィールドがない」だけでなく「フィールドにundefinedがセットされている」というさらに別の状態も存在します。
RFC8259undefinedは存在しないのでシリアライズされるときにJSON valueのオブジェクト上にフィールドが出現しない挙動となります。

筆者の知る限りこのような状態を自然に表現できるようにしてあるプログラミング言語はあまりないため、大抵の場合undefinednullを同一扱いするか、optionalあるいはnullableと言われるようなdata containerを入れ子にしたoptional<optional<T>>にして対応していることがほとんどだと思います。

Goは普通に書くとundefinednullを同一扱いするような形になると思います(後述)。

Goのzero valueとデータ相互変換

Goではすべての変数はzero valueに初期化される

GoにはThe zero valueの概念があるため、

... a variable or value is set to the zero value for its type: false for booleans, 0 for numeric types, "" for strings, and nil for pointers, functions, interfaces, slices, channels, and maps.

とある通り、Goではあらゆる変数が型に対応したzero valueに初期化されます

Go valueからのエンコード時、「フィールドがない」の表現にzero valueが使われることがある

zero valueはstructの「フィールドがない」という表現にたびたび使われます。

structと他の表現の相互変換機能でそういった機能がよくつかわれます。

encoding/gobzero valueをオミット(=出力先のデータに出現しなくなる)する挙動があります。

https://pkg.go.dev/encoding/gob@go1.22.5#hdr-Encoding_Details

If a field has the zero value for its type (except for arrays; see above), it is omitted from the transmission.

encoding/json, encoding/xmlはstruct tagで,omitemptyを指定するとzero value(厳密にいうと違うが)がオミットされます。

https://pkg.go.dev/encoding/json@go1.22.5#Marshal

The "omitempty" option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any empty array, slice, map, or string.

https://pkg.go.dev/encoding/xml@go1.22.5#Marshal

a field with a tag including the "omitempty" option is omitted if the field value is empty. The empty values are false, 0, any nil pointer or interface value, and any array, slice, map, or string of length zero.

この様式に影響されているからなのか、サードパーティのライブラリでもstructと他の表現の相互変換時に「ない」をzero valueで表現するものがあります。

例えばGo valueとurlのquery paramの相互変換を行うgithub.com/pasztorpisti/qsにも同様に,omitemptyオプションがあります。

https://pkg.go.dev/github.com/pasztorpisti/qs#Marshal

When a field is marshaled with the omitempty option then the field is skipped if it has the zero value of its type.

他にはGORMというORMの場合、

// https://gorm.io/docs/query.html

type User struct {
  ID           uint
  Name         string
  Email        *string
  Age          uint8
  Birthday     *time.Time
  MemberNumber sql.NullString
  ActivatedAt  sql.NullTime
  CreatedAt    time.Time
  UpdatedAt    time.Time
}

// Struct
db.Where(&User{Name: "jinzhu", Age: 20}).First(&user)
// SELECT \* FROM users WHERE name = "jinzhu" AND age = 20 ORDER BY id LIMIT 1;

という風に、zero valueが「ない」として扱われます(実際に発行されるSELECTWHERE句に値を指定していないフィールドが出現していません。)。
zero value判定はここなどでおこなわれています(reflectIsZeroなどが使われていますね)

外部データからGo valueへのデコード時のzero valueは曖昧

外部データからGo valueへのデコード後にあるフィールドがzero valueであった時、フィールドに対応するデータがなかったのか、zero valueに対応した値が存在していたのか判別が付きません。

以下のsnippetで示される通り、入力値が"", 0などのzero valueであるときと{}のようなフィールドが空だった時どちらも(JSONの場合はnullも同様に)unmarshal後の値が同じになります。

playground

type Sample struct {
	XMLName xml.Name `xml:"sample"`
	Foo     string
	Bar     int
}

func main() {
	for _, input := range []string{
		`{"Foo": "foo", "Bar": 123}`,
		`{"Foo": "", "Bar": 0}`,
		`{"Foo": null, "Bar": null}`,
		`{}`,
	} {
		var s Sample
		err := json.Unmarshal([]byte(input), &s)
		if err != nil {
			panic(err)
		}
		fmt.Printf("input = %s,\nunmarshaled = %#v\n\n", input, s)
		/*
			input = {"Foo": "foo", "Bar": 123},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:"foo", Bar:123}

			input = {"Foo": "", "Bar": 0},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:"", Bar:0}

			input = {"Foo": null, "Bar": null},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:"", Bar:0}

			input = {},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:"", Bar:0}
		*/
	}
	for _, input := range []string{
		`<sample><Foo>foo</Foo><Bar>123</Bar></sample>`,
		`<sample><Foo></Foo><Bar>0</Bar></sample>`,
		`<sample></sample>`,
	} {
		var s Sample
		err := xml.Unmarshal([]byte(input), &s)
		if err != nil {
			panic(err)
		}
		fmt.Printf("input = %s,\nunmarshaled = %#v\n\n", input, s)
		/*
			input = <sample><Foo>foo</Foo><Bar>123</Bar></sample>,
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:"sample"}, Foo:"foo", Bar:123}

			input = <sample><Foo></Foo><Bar>0</Bar></sample>,
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:"sample"}, Foo:"", Bar:0}

			input = <sample></sample>,
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:"sample"}, Foo:"", Bar:0}
		*/
	}
}

そのため、普通はそういった""0がデータとしてありうるケースでは代わりにポインタータイプ*T(*string*int)などをフィールドの型として指定します。

ただし、この場合でもJSONのデコードではUnmarshal後のGo structのフィールドの値がnilだったことから、入力がnullだったのか、undefinedだったのか(フィールドが存在しなかった)のかは判別がつきません。

encoding/xmlnilなフィールドは単にオミットする挙動なので、とりあえずencoding/xmlの範疇では*Tとしておくだけでフィールドが存在していなかったのか、していたのかが判別できます

xmlのnil

ここなどを参照すると特定のnamespaceでxsi:nil="true"というattributeをつけることでnilを表現可能なようですが、encoding/xmlはとりあえずそれらの存在ありきの実装にはなっていませんのでユーザーが特別にxsi:nilを見分けるようなUnmarshalXMLを実装する必要があります。

playground

type Sample struct {
	XMLName xml.Name `xml:"sample"`
	Foo     *string
	Bar     *int
}

func main() {
	for _, input := range []string{
		`{"Foo": "foo", "Bar": 123}`,
		`{"Foo": "", "Bar": 0}`,
		`{"Foo": null, "Bar": null}`,
		`{}`,
	} {
		var s Sample
		err := json.Unmarshal([]byte(input), &s)
		if err != nil {
			panic(err)
		}
		fmt.Printf("input = %s,\nunmarshaled = %#v\n\n", input, s)
		/*
			input = {"Foo": "foo", "Bar": 123},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:(*string)(0xc0000141c0), Bar:(*int)(0xc0000121f0)}

			input = {"Foo": "", "Bar": 0},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:(*string)(0xc0000141f0), Bar:(*int)(0xc000012230)}

			input = {"Foo": null, "Bar": null},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:(*string)(nil), Bar:(*int)(nil)}

			input = {},
			unmarshaled = main.Sample{XMLName:xml.Name{Space:"", Local:""}, Foo:(*string)(nil), Bar:(*int)(nil)}
		*/
	}
}

T | null | undefined、あるいはpartial JSONのユースケース

T | null | undefinedを使い分けたいユースケースにJSON documentを受け付けるAPIのpartial updateがあります。

JSONをやり取りするAPIはたびたびpartial JSON documentを送りあい、存在する(undefinedでない)フィールドだけ更新し、Tであればその値に、nullであればデータを空に更新するようなプラクティスが普通に存在します。

ここではその具体例としてElasticsearchという全文検索データストアのUpdate part of a documentを例とします。

ElasticsearchApache Luceneをベースとした全文検索エンジンでJSONでドキュメントのストア、検索その他もろもろができます。

そもそも筆者がT | null | undefinedをうまいことGoで取り扱いたかったのはElasticsearchと相互にやり取りするアプリをGoに移植できるか検討していたからなんでした。

例えば以下のようなmapping.json(SQLでいうところのCREATE TABLEみたいなもの)でindexを作成(indexはSQLでいうところのtableみたいなもの)すると

mapping.json
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "kwd": {
        "type": "keyword"
      },
      "long": {
        "type": "long"
      },
      "text": {
        "type": "text"
      }
    }
  }
}

以下のGo structと相互変換できるJSON documentをindexに格納し、検索することができます。

type Example struct {
	Kwd  []string `json:"kwd"`
	Long []int64  `json:"long"`
	Text []string `json:"text"`
}

ドキュメントは以下のように、JSONでpartial documentを送ると当該のフィールドだけを更新することができます。

curl -X POST "localhost:9200/test/_update/1?pretty"\
    -H 'Content-Type: application/json'\
    -d '{"doc": {"kwd": "new keyword"}}'

さらに、以下のようにフィールドにnullを指定すると当該のフィールドをnullに更新することができます。この場合、null | undefined | []はドキュメントにフィールドがないと同じ扱いなので、フィールドを空にすることができます。

curl -X POST "localhost:9200/test/_update/1?pretty"\
    -H 'Content-Type: application/json'\
    -d '{"doc": {"kwd": null}}'

普通の方法: anyを介したvalidation

JSONなどのフィールドがある(Tである)/ない(undefined)/nullであるとかのvalidationを行うライブラリは普通、JSONany(map[string]any)へとデコードして、そこを介してフィールドの有り無しなどをチェックします。

例えばJSON schemaからGo typeを作成するライブラリgithub.com/omissis/go-jsonschemaは、UnmarshalJSONを以下のように生成します。

https://github.com/omissis/go-jsonschema/blob/main/tests/data/core/object/object.go

見てのとおり、map[string]anyPlainそれぞれに対してjson.Unmarshalを呼び出します。
map[string]anyのほうを使ってフィールドのあるなしとnullであるかをチェックし、Plainの各フィールド型のUnmarshalJSON実装の中で値のバリデーションを行って、結果としてreceiverに代入します。
type Plain ObjectMyObjectmethod setを引き継がないがデータ構造は同じな型を定義するためにこうしています。

他の例を挙げると、OpenAPI specを用いてJSONのvalidationを行うライブラリのgithub.com/getkin/kin-openapiも同様に、JSONanyにデコードし、これを利用してvalidationを行います。

https://github.com/getkin/kin-openapi/blob/2692f43ba21c89366b2a221a86be520b87539352/openapi3filter/validate_request.go#L275

https://github.com/getkin/kin-openapi/blob/2692f43ba21c89366b2a221a86be520b87539352/openapi3filter/req_resp_decoder.go#L1284-L1292

ちなみにこのライブラリはgithub.com/oapi-codegen/echo-middlewareなどを経由してgithub.com/oapi-codegen/oapi-codegenで生成されたサーバーのmiddlewareとして利用できます。

wacky valueを用いればPartial JSONの受け側にはなれる

https://pkg.go.dev/encoding/json@go1.22.5#Marshal

... Array and slice values encode as JSON arrays, except that []byte encodes as a base64-encoded string, and a nil slice encodes as the null JSON value.

... Pointer values encode as the value pointed to. A nil pointer encodes as the null JSON value.

... Interface values encode as the value contained in the interface. A nil interface value encodes as the null JSON value.

上記より、[]byte以外の[]T(slice), any(interface), map[K]V(map)、*T(ポインター型)はzero valueとJSONnullとの相互変換となります。

パパっと読むとmap[K]V(nil)の相互変換のしかたはいまいち書かれていない気がしますが実際nullへ変換されます。(別言語で作ったクライアントとGoで作ったサーバーとのやり取りでnullを受けたクライアントがクラッシュしたことが何度もある)

https://pkg.go.dev/encoding/json@go1.22.5#Unmarshal

...Unmarshal first handles the case of the JSON being the JSON literal null. In that case, Unmarshal sets the pointer to nil.

Because null is often used in JSON to mean “not present,” unmarshaling a JSON null into any other Go type has no effect on the value and produces no error.

上記より、JSONにフィールドが存在しない場合、Goフィールドにはなにも代入しません。
JSONフィールドにnullがセットされている場合、pointer type *Tに対してはnilを代入し、non-pointer type Tに対しては何も代入しない挙動になります

この挙動より、wacky valueを用いればPartial JSONの受け側には十分なれます。

playground


type Sample struct {
	Foo *string
	Bar *int
}

func (s Sample) GoString() string {
	foo := "<nil>"
	if s.Foo != nil {
		foo = fmt.Sprintf("%q", *s.Foo)
	}
	var bar any
	if s.Bar != nil {
		bar = *s.Bar
	}
	return fmt.Sprintf(`{Foo:%s,Bar:%v}`, foo, bar)
}

func same[T comparable](a T, b *T) bool {
	if b == nil {
		return false
	}
	return a == (*b)
}

func main() {
	for _, input := range []string{
		`{}`,
		`{"Bar": null}`,
		`{"Foo": ""}`,
		`{"Foo": "wacky", "Bar": -9999999}`,
	} {
		var (
			wackyStr = "wacky"
			wackyInt = -9999999
		)
		s := Sample{
			Foo: &wackyStr,
			Bar: &wackyInt,
		}
		err := json.Unmarshal([]byte(input), &s)
		if err != nil {
			panic(err)
		}
		fmt.Printf("input = %s,\n", input)
		fmt.Printf("unmarshaled = %#v\n", s)
		fmt.Printf("foo was present = %t, bar was present = %t\n", !same("wacky", s.Foo), !same(-9999999, s.Bar))
		fmt.Println()
		/*
			input = {},
			unmarshaled = {Foo:"wacky",Bar:-9999999}
			foo was present = false, bar was present = false

			input = {"Bar": null},
			unmarshaled = {Foo:"wacky",Bar:<nil>}
			foo was present = false, bar was present = true

			input = {"Foo": ""},
			unmarshaled = {Foo:"",Bar:-9999999}
			foo was present = true, bar was present = false

			input = {"Foo": "wacky", "Bar": -9999999},
			unmarshaled = {Foo:"wacky",Bar:-9999999}
			foo was present = false, bar was present = false
		*/
	}
}

json.Unmarshalは挙動上、引数のフィールドがnilの場合新しい値をallocateしポインターに入力JSONの値を代入する挙動となっています。
そのため上記の通り、wacky valueと同値の入力を判別できないのでmap[string]anyにいったんデコードする方法より筋がいいとはいい難い面があります。

こういう設計をやるとドキュメント上に「"wacky"ならば暗黙的に無視される」と載ることになり、すさまじくわかりにくいうえにコードジェネレーターその他と相性が悪くなるのでやらないほうがいいでしょうね。

Genericsを利用してT | null | undefinedを表現する型を作る

T | null | undefinedはつまり1つの型で3つのステートを表現したいわけです。こういったdata container的な型はGo 1.18以前では表現しにくかったのですが、以降はgenericsが導入されたので容易に可能となりました。

できないパターン: **T

一般的にpresent(T)| absent*Tで表現されるわけですから、単純な話T | null | undefinedには**Tを用いればいいわけです。実際**T自体はOpenSSLなどのAPIで見たことがあります。

ただし以下のように**Tをreceiverに指定したmethodはコンパイルできません。

playground

type Undefined[T any] **T

// ./prog.go:9:7: invalid receiver type Undefined[T] (pointer or interface type)
func (u Undefined[T]) IsUndefined() bool {
	return u == nil
}

// ./prog.go:13:7: invalid receiver type Undefined[T] (pointer or interface type)
func (u Undefined[T]) IsNull() bool {
	if u == nil {
		return false
	}
	return *u == nil
}

これは Go の言語仕様で明確に禁止されています。

A receiver base type cannot be a pointer or interface type

https://go.dev/ref/spec#Method_declarations

メソッドを持てないため、UnmarshalJSONを実装できません。UnmarshalJSONを実装できないと、デコード時にundefined | nullを判別できない(後述)ため、この方法は用いることができません。

できるパターン: boolean flag で defined を表現する。

ところで、std のdatabase/sqlには以下のようなデータ構造が定義されています。

https://pkg.go.dev/database/sql@go1.22.5#Null

type Null[T any] struct {
	V     T
	Valid bool
}

これを2段重ねにするだけ目的を達成できそうですね。単純な解法ですが、*Tに引っ張られて見落としていました。

ということでRust風なOption[T]型を以下のように定義します。

// Option represents an optional value.
type Option[T any] struct {
	some bool
	v    T
}

func Some[T any](v T) Option[T] {
	return Option[T]{
		some: true,
		v:    v,
	}
}

func None[T any]() Option[T] {
	return Option[T]{}
}

func (o Option[T]) IsSome() bool {
	return o.some
}

func (o Option[T]) IsNone() bool {
	return !o.IsSome()
}

func (o Option[T]) MarshalJSON() ([]byte, error) {
	if !o.some {
		return []byte(`null`), nil
	}
	return json.Marshal(o.v)
}

func (o *Option[T]) UnmarshalJSON(data []byte) error {
	if string(data) == "null" {
		o.some = false
		var zero T
		o.v = zero
		return nil
	}

	var v T
	err := json.Unmarshal(data, &v)
	if err != nil {
		return err
	}
	o.some = true
	o.v = v
	return nil
}

そしてこれを2段重ねすることでT | null | undefinedを表現できる型とします。

type Und[T any] struct {
	opt option.Option[option.Option[T]]
}

func Defined[T any](t T) Und[T] {
	return Und[T]{
		opt: option.Some(option.Some(t)),
	}
}

func Null[T any]() Und[T] {
	return Und[T]{
		opt: option.Some(option.None[T]()),
	}
}

func Undefined[T any]() Und[T] {
	return Und[T]{}
}

func (u Und[T]) IsDefined() bool {
	return u.opt.IsSome() && u.opt.Value().IsSome()
}

func (u Und[T]) IsNull() bool {
	return u.opt.IsSome() && u.opt.Value().IsNone()
}

func (u Und[T]) IsUndefined() bool {
	return u.opt.IsNone()
}

func (u Und[T]) MarshalJSON() ([]byte, error) {
	if !u.IsDefined() {
		return []byte(`null`), nil
	}
	return json.Marshal(u.opt.Value().Value())
}

func (u *Und[T]) UnmarshalJSON(data []byte) error {
	if string(data) == "null" {
		*u = Null[T]()
		return nil
	}

	var t T
	err := json.Unmarshal(data, &t)
	if err != nil {
		return err
	}

	*u = Defined(t)
	return nil
}
github.com/samber/moを使わない理由

github.com/samber/moにも似たようなOption[T]型が実装されているが、
それを使わなかったのは

  • メソッド名をRust風にしたかった
    • まるきりbikeshed
  • MarshalText/UnmarshalTextが実装されているが、内部でjson.Marshal/json.Unmarshalに処理が移譲されている
    • noneの時のテキスト表現に意見を持ちたくなかった
  • MarshalBinary/UnmarshalBinaryが実装されている
    • noneの時のバイナリ表現に意見を持ちたくなかった
  • UnmarshalJSONの中身でjson.Unmarshal(b, &o.value)のように呼んでいる。
    • エラー時にhalf-bakedな値が代入されうるが、none扱いのままになる可能性がある。
    • 上記の実装だと、同じ変数を引数に何度もjson.Unmarshalを呼び出すケースをサポートできなくなるが、half-bakedになることはない
`*Option[T]`を使わない理由
  • なるだけcomparableでcopy-by-assignが起きるようにしたい
  • nilが一種validな値として取り扱われるのは他のGoのconventionと会わない、と思う。
  • Option[T]に実装した色々なmethodにnilガードをつけるとして、nilはnoneなのだろうか?
  • nilガードをつけないのならば、(*Option[T])(nil)はinvalidな値だ
  • ややこしい。*Option[T]の存在は忘れよう。

課題: encoding/jsonはstructをオミットしない

stdでJSONとバイト列([]byte)の相互変換を行うにはencoding/jsonを利用します。

UnmarshalJSONメソッド(json.Unmarshaler)を実装することでT | null | undefinedを判別できる

encoding/jsonUnmarshal時、JSONフィールドに値がない(undefined)時に何も代入をおこなわず、nullだった場合、*T相手にはnilを代入し、non-pointer type Tのフィールドには何も代入しません。
また、json.UnmarshalUnmarshalJSONを型が実装する場合、それを呼び出すことで型レベルで挙動の変更をサポートします。JSONフィールドの値がnullだった場合は、UnmarshalJSON[]byte("null")を引数に呼び出します。
Unmarshalに関しては、型がUnmarshalJSONさえ実装しており、json.Unmarshalに渡す引数structのフィールドを毎回zero valueに初期化することでT | null | undefinedを判別することが可能です。

Marshal時、structはオミットされない(=undefinedが表現できない)

Marshal時にはencoding/jsonはstruct tagを参照し、jsonタグにomitemptyオプションが設定されているとzero value(厳密にはempty valueであってzeroではない)であるフィールドをオミット(=出力先データにフィールドが出現しない)する挙動がありますが、これはフィールドの型がstructであるときには起きません。

つまり、Marshal時にいかにしてかundefinedな値をオミットさせる方法が課題となります。

emptyの判定式は以下で行われます

https://github.com/golang/go/blob/go1.22.5/src/encoding/json/encode.go#L306-L318

ここにstructが含まれていないことから、上記がわかると思います。

,omitemptyがオミットするのは厳密にはzero valueではないと書いていたのはこれで、structのzero valueはemptyでないとされるし、slice, mapの場合はlen(v) == 0の時にemptyとされます。これはzero valueを含みます(len([]int(nil))は0を返す)が、lenが0なnon-zero slice / mapもemptyと判定されます。

また、型レベルなmarshal/unmarshalの挙動の差し替え方法に、型にMarshalJSON(json.Marshaler)/UnmarsahalJSON(json.Unmarshaler)を実装するというものがありますが

https://pkg.go.dev/encoding/json@go1.22.5#Marshal

...If an encountered value implements Marshaler and is not a nil pointer, Marshal calls [Marshaler.MarshalJSON] to produce JSON. ...

という記述から、少しわかりにくいですがreceiverがnilの時にフィールドをオミットさせるようなことをMarshalJSONの実装の中でコントロールさせる方法がありません。

実際上、下記のencoding/jsonのコードを参照するとわかる通り、MarshalJSONの呼び出しの時点ですでにフィールド名は書き込まれていますし、MarshalJSONは1つの有効なJSON valueを返すことが(appendCompactにより)期待されています。

https://github.com/golang/go/blob/go1.22.5/src/encoding/json/encode.go#L698-L704

https://github.com/golang/go/blob/go1.22.5/src/encoding/json/encode.go#L430-L450

関連issue

これらの問題はgolang/goのissueにも上がっており、色々な提案がされています。筆者が見たいくつかを以下で述べます。

encoding/jsonを改善したい系

  • https://github.com/golang/go/issues/5901
    • json.Marshaler/json.Unmarshalerで型レベルではどのようにバイト列([]byte)と相互変換されるかを定義できますが、per-encoder / per-decoderレベルで変更できたほうが便利じゃないですかという提案。
  • https://github.com/golang/go/issues/11939
    • structのzero value時にomitemptyを動作させましょうという提案
    • time.Timeのように、IsZeroを実装するものはこのメソッドの返り値を見て判別したらよいじゃないかという提案

ただしencoding/jsonは長い歴史があって些細な変更が大きな影響を持つ破壊的変更となってしまうので取り込むのも大変みたいです。

encoding/json/v2

encoding/jsonにもコミット履歴があるdsnet氏(今(2024/07)はgithub.com/tailscale/tailscaleにコミットとPRレビューをたくさんしているのでTailscale所属なんでしょうか?)の立てたdiscussionで、encoding/jsonのもろもろの欠点と、互換性を保ったままそれらを修正するのが難しい(JSONに関するRFCが時間とともに厳密になっていったのに追従してデフォルトの挙動をより厳密にしたほうが良いだろう)という経緯の説明、さらにv2のAPIの提案とexperimental実装(github.com/go-json-experiment/json)の紹介がなされています。

この中でtime.TimeのようなIsZeroを実装する型に対してはこれがtrueを返す時オミットする,omitzeroオプションを含むように提案されています。

これはまだproposalにもなっていない段階で、さらに変更が大きいのでレビューも時間がかかることが予測されます。
とりあえず当面、工夫なしにT | null | undefinedをstruct fieldで表現する手段は提供されなさそうですので、後述のような方法が必要なようです。

没解法

先に没になった解放と没にした理由を述べます。

structのzero valueをomitするjson encoder/decoder実装を用いる

以下のようなサードパーティのjson encoder/decoder実装はstructのzero valueをオミットする機能を有しています。

以前の記事ではgithub.com/json-iterator/goのほうを採用して、これのExtensionを駆使して何とかしました。
ただし、このライブラリはencoding/jsonといくつか挙動が違っていたり(筆者自身もいくつか見つけました#657)して少し不安になります。

github.com/clarketm/jsonのほうは使ったことがないので何ともですが、どちらに対しても言えるのは、json.Marshalerを実装する型が内部でjson.Marshalを呼び出すとそこ以後でstructをオミットする挙動が起きなくなるので、genericsの導入よって可能になった種々のdata container系の型がネストしたときに不整合が起きます。

data container系の型の例としてgithub.com/wk8/go-ordered-map/v2を引き合いに出しましょう。
その名の通り、ordered-map実装で、JSONYAMLMarshal/Unmarshalに対応しているのですが、

https://github.com/wk8/go-ordered-map/blob/85ca4a2b29d3241fa4513f82be3d38fe2392a791/json.go#L20-L87

という感じで、json.Marshal(pair.Value)を呼び出しています。Valueの型Vに直接MarshalJSONが実装してあって、その中でサードパーティのエンコーダーを呼び出していればよいことになります。
すべての型にMarshalJSONを実装する必要が出てきますね。結構煩雑です。

今回のようなケースに限っては無関係ですが、MarshalJSONを実装した型をstructにembedすると、embedされた側の型のMarshalJSONとしてexportされてしまうため、embedが必要なケースでうまいこと動作しなくなります。これが決め手となり没となりました。

できればstdのencoding/jsonのmarshalerの中で事足りる方法であってほしいということです。

特定の値をスキップするMarshalJSONを実装する

当然これは可能です。つまり以下のような感じです。

playground

type zeroStr struct {
	valid bool
	s     string
}

func (s zeroStr) IsZero() bool {
	return !s.valid
}

func (s zeroStr) S() string {
	if !s.valid {
		return ""
	}
	return s.s
}

type Sample struct {
	Foo string
	Bar zeroStr
	Baz int
}

func (s Sample) MarshalJSON() ([]byte, error) {
	var (
		b   bytes.Buffer
		bin []byte
		err error
	)
	b.WriteByte('{')
	b.WriteString("\"foo\":")
	bin, err = json.Marshal(s.Foo)
	if err != nil {
		return nil, err
	}
	b.Write(bin)
	if !s.Bar.IsZero() {
		b.WriteByte(',')
		b.WriteString("\"bar\":")
		bin, err = json.Marshal(s.Bar.S())
		if err != nil {
			return nil, err
		}
		b.Write(bin)
	}
	b.WriteByte(',')
	b.WriteString("\"baz\":")
	bin, err = json.Marshal(s.Baz)
	if err != nil {
		return nil, err
	}
	b.Write(bin)
	b.WriteByte('}')
	return b.Bytes(), nil
}

func main() {
	for _, s := range []Sample{
		{},
		{Bar: zeroStr{valid: true}},
		{Bar: zeroStr{valid: true, s: "bar"}},
	} {
		bin, err := json.Marshal(s)
		if err != nil {
			panic(err)
		}
		fmt.Printf("marshaled = %s\n", bin)
		/*
			marshaled = {"foo":"","baz":0}
			marshaled = {"foo":"","bar":"","baz":0}
			marshaled = {"foo":"","bar":"bar","baz":0}
		*/

	}
}

上記のようなコードはすでに煩雑ですので、必要なすべての型に対してそういったMarshalJSONを実装するのは手間です。
現実的にはreflectパッケージによって動的にこのような処理を行うか、code generatorを実装し、このようなMarshalJSONを生成することになると思います。

没になった理由はそこで、reflectでやろうにもcode generatorを実装しようにもencoding/jsonの挙動はなかなか複雑なので、そもそも実装が煩雑で難しいというのがハードルとなっています。

こういったstructをとって何かのデータ構造に変換をかけるタイプの処理を実装したことがある方はわかるかもしれませんが、Gostruct fieldのembeddingが可能で、encoding/jsonembedされたフィールドの取り扱いは

https://pkg.go.dev/encoding/json@go1.22.5#Marshal

Embedded struct fields are usually marshaled as if their inner exported fields were fields in the outer struct, subject to the usual Go visibility rules amended as described in the next paragraph. An anonymous struct field with a name given in its JSON tag is treated as having that name, rather than being anonymous. An anonymous struct field of interface type is treated the same as having that type as its name, rather than being anonymous.

という感じになります。
embedされたフィールドの型がstructかつ、json struct tagが付いていない場合、そのstructのフィールドは親structのフィールドであるかのように出現します。
二つ以上embedされたフィールドがあってそれぞれに同名フィールドがあったときどちらが優先されるか、などなど微妙で面倒でわかりにくくて不具合になりそうな要素がたくさんあります。

さらに面倒なのが、struct fieldのembedで型的な再帰を行うことが許されているんですね。

playground(実行してコンパイルエラーが起きないことを確かめることができる)

type RecursiveEmbedding struct{
	Foo string
	Recursive
}

type Recursive struct {
	Bar string
	Recursive2
}

type Recursive2 struct {
	*RecursiveEmbedding
}

これは、Treeを定義するために以下のような型は普通にありえるので許されれているのだと思います。

type Tree[T any] struct {
	node *node[T]
}

type node[T any] struct {
	left, right *node[T] // type recursion
	value T
}

さらに、encoding/jsonはstruct tagを使ってJSON Objectのフィールド名とGo structのフィールドの対応付けを定義できますので、ここでフィールド名の被りは当然起きえますし、実はjson.Unmarshal時のフィールド名の比較はcase-insensitiveだったりしてかぶってないつもりで被ってたりもありまえます。

つまり以下のようなエッジケースが存在します。

type OverlappingKey1 struct {
	Foo string
	Bar string `json:"Baz"`
	Baz string
}
// OverlappingKey1{Foo: "foo", Bar: "bar", Baz: "baz"},
// ↓
// {"Foo":"foo","Baz":"bar"}
// tagが優先

type OverlappingKey2 struct {
	Foo string
	Bar string `json:"Bar"`
	Baz string `json:"Bar"`
}
// OverlappingKey2{Foo: "foo", Bar: "bar", Baz: "baz"}
// ↓
// {"Foo":"foo"}
// 同名のtagはどちらも削除

type OverlappingKey3 struct {
	Foo string
	Bar string `json:"Baz"`
	Baz string
	Qux string `json:"Baz"`
}
// OverlappingKey3{Foo: "foo", Bar: "bar", Baz: "baz", Qux: "qux"}
// ↓
// {"Foo":"foo"}
// tag名で被り+元のstruct field名で被りの場合でも全部まとめて消されますね。

type Sub1 struct {
	Foo string
	Bar string `json:"Bar"`
}

type OverlappingKey4 struct {
	Foo string
	Bar string
	Baz string
	Sub1
}
// OverlappingKey4{Foo: "foo", Bar: "bar", Baz: "baz", Sub1: Sub1{Foo: "foofoo", Bar: "barbar"}}
// ↓
// {"Foo":"foo","Bar":"bar","Baz":"baz"}
// Embeddedの場合、上の階層にあるほうが優先。

type Recursive1 struct {
	R string `json:"r"`
	Recursive2
}

type Recursive2 struct {
	R  string `json:"r"`
	RR string `json:"rr"`
	*OverlappingKey5
}

type OverlappingKey5 struct {
	Foo string
	Recursive1
}
// OverlappingKey5{Foo: "foo", Recursive1: Recursive1{R: "r", Recursive2: Recursive2{R: "r2", RR: "rr"}}},
// ↓
// {"Foo":"foo","r":"r","rr":"rr"}
// 型の再帰が起きた時、1周まではエンコードされるがその後は無視される挙動のようですね。

どのフィールドを優先するかのルールは以下で記述されています。

https://github.com/golang/go/blob/go1.22.5/src/encoding/json/encode.go#L1184-L1244

  • nameは出力先のJSON上でのフィールドの名前です。(つまり、Goのstruct field名かjson:"name"で付けられた名前)
  • indexは[]intで表現されるフィールドのソースコード順の出現順序です。lenがstruct fieldのembedの深さを表現し、embedされている数だけappendされます。
  • tagはstruct tagでつけられたjson:"name"があったかどうかです。

dominantFieldは以下のように実装されます。不可思議に感じた上記のエッジケースの挙動はこれによって起きています。

https://github.com/golang/go/blob/go1.22.5/src/encoding/json/encode.go#L1248-L1262

型的な再帰が起きていた場合の挙動は以下のコードによって律せられています

https://github.com/golang/go/blob/go1.22.5/src/encoding/json/encode.go#L1075-L1092

こんなエッジケースはわざわざ探すまで体感することはなかったので、stdはさすが、よく叩かれてよく作られていると感じます。

解決法1: map[T]U, []Tはomitemptyでskip可能

encoding/jsonのemptyの判別は以下で行われます。

https://github.com/golang/go/blob/go1.22.5/src/encoding/json/encode.go#L306-L318

map, slicelen(v) == 0時にスキップされるようになっていますね。

ということはmap[T]Kあるいは[]Tベースで例えば以下のように

type undefinedableMap map[bool]T

type undefinedableSlice []T

こういう型を定義すればencoding/jsonにオミットされうる型を定義できますね(もちろんomitemptyオプションは必要です)

https://pkg.go.dev/builtin@go1.22.5#len

... Slice, or map: the number of elements in v; if v is nil, len(v) is zero.

とある通り、lenにnilを渡すとpanicするとかはないので、zero valueをそのまま使っても大丈夫です。

以前の記事を書いていた時点ではこの方法に全く気付いていませんでした。なんで気付かなかったんだろう・・・

map[bool]Tを使う実装: github.com/oapi-codegen/nullable

OpenAPI specからGoのserver/clientを生成するライブラリのソースを管理するoapi-codegenオーガナイゼーション以下で、
map[bool]TをベースとしたT | null | undefinedを表現できる型が実装されています。

以下がそれぞれの状態に対応します

  • undefined: len(m) == 0
  • null: _, ok := m[false]; ok == true
  • T: _, ok := m[true]; ok == true

boolをkey用いれば表現できる状態の数が「キーがない」、「true」、「false」、「true/false」の4種のみになります。「true/false」は未使用とし、他3つをつかってT | null | undefinedとすればよいわけです。

[]Option[T]も使える

同様に、[]Tをベースとした実装もできます。こっちはmap[bool]Tと違ってとれる状態の数を制限するような方法はありません。

ただ[]Tにしたいのは任意のmethod setを持ちながらencoding/jsonにオミットされたいからなだけなので、undefinedを表現する以外の用途ではTの実装に工夫をするほうが違和感がないと思います。

なので、

// 前述したOption型。
// Option represents an optional value.
type Option[T any] struct {
	some bool
	v    T
}

type Undefinedable[T any] []Option[T]

とします。

解決法2: encoding/json/v2(の候補版を使う)

github.com/go-json-experiment/jsonを使うとstruct fieldにjson:",omitzero"オプションがついていて、なおかつIsZeroメソッドがtrueを返す時、エンコーダーがそのフィールドをオミットする挙動があります。これを利用すれば[]Tmap[bool]Tを利用せずとも任意の値をオミットすることができます。

playground

type Sample struct {
	Padding1 int      `json:",omitzero"`
	V        NonEmpty `json:",omitzero"`
	Padding2 int      `json:",omitzero"`
}

type NonEmpty struct {
	Foo string
}

func (z NonEmpty) IsZero() bool {
	return z.Foo == "foo"
}

func main() {
	var (
		bin []byte
		err error
	)
	bin, err = jsonv2.Marshal(Sample{})
	if err != nil {
		panic(err)
	}
	fmt.Printf("zero = %s\n", bin) // zero = {"V":{"Foo":""}}

	bin, err = jsonv2.Marshal(Sample{V: NonEmpty{Foo: "foo"}})
	if err != nil {
		panic(err)
	}
	fmt.Printf("foo = %s\n", bin) // foo = {}
}

そのため、以下のようにOption[Option[T]]IsZeroさえ実装していれば同様にundefined時にオミットされることが可能です。

type Und[T any] struct {
	opt option.Option[option.Option[T]]
}

func (u Und[T]) IsZero() bool {
	return u.IsUndefined()
}

option.Option[option.Option[T]]option.Option[T]Tがcomparableである限りcomparableだし、slice/mapであることにかかる色々な処理をバイパスして取り扱えますからおそらくこちらのほうがメモリ的、処理時間的に効率的な実装であると思われます。

ただしこの場合でもdata container系の型がMarshalJSONを実装していて内部でjson.Marshalを呼び出している場合、ここでIsZero() == trueの時オミットされる挙動が引き継がれなくなります。
ただv2と正式になれば十分な権威がありますから、メンテされているライブラリはMarshalJSONV2を実装してくると十分予測はできます。
そうでなくても、そういったdata containerに渡す型すべてにMarshalJSONを実装して、その中でgithub.com/go-json-experiment/jsonMarshalを呼び出せばundefinedフィールドのオミットは実現できます。
ただしその場合はMarshalに渡せるOptionのバケツリレーが途切れます。

v2に正式になった暁にはこちらの実装を使うほうが良いことになるかもしれませんね。

実装

実装物は以下で管理されます

https://github.com/ngicks/und/tree/main

以前の記事で述べたrepositoryと同じです。色々事情が変わったので破壊的変更を行い、jsoniterへの依存などが完全になくなるようになっています。

encoding/json/v2が実装されるまでgithub.com/go-json-experiment/jsonに依存し、v2の実装に伴ってその依存を取り消すことでv1.0.0となる予定です。
今後は破壊的変更はない予定です。

Option[T]

Option[T]型を実装します。と言ってもこれは前述したものと全く一緒です。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/option/opt.go#L40-L55

MarshalJSON, UnmarshalJSON,MarshalJSONV2, UnmarshalJSONV2が実装してあり、Nonenullに変換されます。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/option/opt.go#L171-L223

(// same as bytes.Clone.っていうコメントは消し忘れなので、なんの意味もないです)

MarshalJSONV2向けにIsZeroが実装してあります

https://github.com/ngicks/und/blob/v1.0.0-alpha3/option/opt.go#L64-L66

このOption[T]実装はRuststd::Option<T>をミミックしていますが、Goには借用など概念がなく、値はすべてzero valueで初期化されるわけですから、内部の値を取り出すのはもっと単純な仕組みでよいことになります。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/option/opt.go#L85-L89

TがcomparableならOption[T]もcomparableですが、time.Timeのような一部の型はEqualメソッドによる比較を必要としますから、Equalも実装しておきます。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/option/opt.go#L122-L146

記事の主題とは全く関係ないですが、Option[T]にはRuststd::Option<T>をまねたメソッド群が実装されます

func (o Option[T]) And(u Option[T]) Option[T]
func (o Option[T]) AndThen(f func(x T) Option[T]) Option[T]
func (o Option[T]) Filter(pred func(t T) bool) Option[T]
func FlattenOption[T any](o Option[Option[T]]) Option[T]
func (o Option[T]) IsNone() bool
func (o Option[T]) IsSome() bool
func (o Option[T]) IsSomeAnd(f func(T) bool) bool
func MapOption[T, U any](o Option[T], f func(T) U) Option[U]
func (o Option[T]) Map(f func(v T) T) Option[T]
func MapOrOption[T, U any](o Option[T], defaultValue U, f func(T) U) U
func (o Option[T]) MapOr(defaultValue T, f func(T) T) T
func MapOrElseOption[T, U any](o Option[T], defaultFn func() U, f func(T) U) U
func (o Option[T]) MapOrElse(defaultFn func() T, f func(T) T) T
func (o Option[T]) Or(u Option[T]) Option[T]
func (o Option[T]) OrElse(f func() Option[T]) Option[T]
func (o Option[T]) Xor(u Option[T]) Option[T]

これらがあると便利です。(というか複数の*Tを相手に「このポインターがnilなら~」みたいな処理を何度も書いていて煩雑に思ったからOption[T]を実装したかったのです)

この手の型はsql.Scannerを実装するかが(体感上)気にされやすいです。そのためシンプルなラッパーでsql.Scannerおよびdriver.Driverを実装します。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/option/sql_null.go#L13-L74

そのほかにもxml.Marshaler,xml.Unmarshaler,slog.LogValuerを実装しておきます。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/option/opt.go#L15-L23

Und[T] []Option[T]

本題である[]Option[T]ベースのomitemptyでオミット可能なT | null | undefinedを表現する型です。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/sliceund/slice.go#L48-L63

以前の記事ではUndefinedableという名前にしていましたが型名が長すぎると画面がうるさいのでUnd[T]まで短縮しました。

len(u) == 0のときをundefinedとし、u[0]がnoneならnull, someならTであるとしています。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/sliceund/slice.go#L105-L119

Option[T]と同じくMarshalJSON, UnmarshalJSON,MarshalJSONV2, UnmarshalJSONV2を実装しています。
non-zero valueに対してUnmarshalJSONが呼ばれるケースもあることを考慮してlen(u) != 0の場合、index 0に代入するような考慮がされています。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/sliceund/slice.go#L130-L197

これまた記事の主題とは無関係ですが以下のようにUnd[T]xml.Marshaler,xml.Unmarshaler,slog.LogValuerを実装してあります。

https://github.com/ngicks/und/blob/v1.0.0-alpha3/sliceund/slice.go#L16-L28

下記の通り、実際にjson.Marshalによってundefined時にフィールドがオミットされる挙動が見れます。

playground(go playgroundでサードパーティのライブラリ取得を行うのは時と場合によってはタイムアウトします)

type sliceUnd struct {
	Padding1 int                  `json:",omitempty"`
	V        sliceund.Und[string] `json:",omitempty"`
	Padding2 int                  `json:",omitempty"`
}

func main() {
	for _, input := range []string{
		`{"Padding1":10,"Padding2":20}`,
		`{"Padding1":10,"V":null,"Padding2":20}`,
		`{"Padding1":10,"V":"foo","Padding2":20}`,
	} {
		var s sliceUnd
		err := json.Unmarshal([]byte(input), &s)
		if err != nil {
			panic(err)
		}
		fmt.Printf("unmarshaled = %#v\n", s)
		bin, err := json.Marshal(s)
		if err != nil {
			panic(err)
		}
		fmt.Printf("marshaled = %s\n", bin)
		fmt.Println()
		/*
			unmarshaled = main.sliceUnd{Padding1:10, V:sliceund.Und[string](nil), Padding2:20}
			marshaled = {"Padding1":10,"Padding2":20}

			unmarshaled = main.sliceUnd{Padding1:10, V:sliceund.Und[string]{option.Option[string]{some:false, v:""}}, Padding2:20}
			marshaled = {"Padding1":10,"V":null,"Padding2":20}

			unmarshaled = main.sliceUnd{Padding1:10, V:sliceund.Und[string]{option.Option[string]{some:true, v:"foo"}}, Padding2:20}
			marshaled = {"Padding1":10,"V":"foo","Padding2":20}
		*/

	}
}

ベンチマーク

3パターンの入力(フィールドがない/null/ある)をMarshalする/Unmarshalする/UnmarshalしてMarshalするの3パターンのベンチマークをとって比較してみます。

https://github.com/ngicks/und/blob/a4291a4a0126836a8be44d11e4d4651fc080a1a2/internal/bench/beanch_test.go

github.com/go-json-experiment/jsonMarshalは入力がaddressable valueでないとき(=ポインターではないとき)にaddressableになるようにallocationしますので、Marshalにはポインターを渡すようにします。

このベンチを筆者環境で実行します。Docker Desktopで構築されたwsl2上のdocker containerです。
ソースを読まなくても結果の意味が分かるように、ベンチ結果の後に各テストケースのネーミングについて説明します。

# go version
go version go1.22.0 linux/amd64
# go test -bench . -benchmem
goos: linux
goarch: amd64
pkg: github.com/ngicks/und/internal/bench
cpu: AMD Ryzen 9 7900X 12-Core Processor
BenchmarkUnd_Marshal/NullableV1-24               2611189               458.7 ns/op           144 B/op          8 allocs/op
BenchmarkUnd_Marshal/MapV1-24                    2612364               459.0 ns/op           144 B/op          8 allocs/op
BenchmarkUnd_Marshal/SliceV1-24                  2743136               442.2 ns/op           216 B/op          8 allocs/op
BenchmarkUnd_Marshal/NullableV2-24               2255996               529.2 ns/op           144 B/op          8 allocs/op
BenchmarkUnd_Marshal/MapV2-24                    2213857               542.6 ns/op           136 B/op          7 allocs/op
BenchmarkUnd_Marshal/SliceV2-24                  2064949               581.9 ns/op           280 B/op         10 allocs/op
BenchmarkUnd_Marshal/NonSliceV2-24               2155774               555.6 ns/op           280 B/op         10 allocs/op
BenchmarkUnd_Unmarshal/NullableV1-24              947925              1158 ns/op            1216 B/op         24 allocs/op
BenchmarkUnd_Unmarshal/MapV1-24                   910396              1148 ns/op            1216 B/op         24 allocs/op
BenchmarkUnd_Unmarshal/SliceV1-24                1000000              1008 ns/op            1032 B/op         22 allocs/op
BenchmarkUnd_Unmarshal/NullableV2-24             1563200               781.3 ns/op           568 B/op         12 allocs/op
BenchmarkUnd_Unmarshal/MapV2-24                  1786958               669.0 ns/op           424 B/op         11 allocs/op
BenchmarkUnd_Unmarshal/SliceV2-24                2058493               583.4 ns/op           240 B/op          9 allocs/op
BenchmarkUnd_Unmarshal/NonSliceV2-24             2303660               522.3 ns/op           208 B/op          7 allocs/op
BenchmarkUnd_Serde/NullableV1-24                  629516              1864 ns/op            1362 B/op         32 allocs/op
BenchmarkUnd_Serde/MapV1-24                       604058              1875 ns/op            1362 B/op         32 allocs/op
BenchmarkUnd_Serde/SliceV1-24                     667900              1719 ns/op            1250 B/op         30 allocs/op
BenchmarkUnd_Serde/NullableV2-24                  756122              1560 ns/op             641 B/op         17 allocs/op
BenchmarkUnd_Serde/MapV2-24                       802083              1405 ns/op             489 B/op         15 allocs/op
BenchmarkUnd_Serde/SliceV2-24                     844695              1357 ns/op             377 B/op         16 allocs/op
BenchmarkUnd_Serde/NonSliceV2-24                  911815              1277 ns/op             345 B/op         14 allocs/op
PASS
ok      github.com/ngicks/und/internal/bench    30.814s

Marshal, Unmarshalは言葉のとおりそれをします。Serdeはrustのserde crateからとった語で、SERialize DEserializeの略語です。[]byteUnmarshalしてMarshalする1回のラウンドトリップのパフォーマンスをとります。

各テストのprefixはそれぞれ以下を意味します

  • Nullable: github.com/oapi-codegen/nullableNullable[T]
  • Map: 自家版map[bool]T実装(なくていいんですがNullable[T]とほぼ同じ実装なので、nullableに依存せずにベンチで比較するために作ってありました)
  • Slice: []Option[T]ベースのUnd[T]
  • NonSlice: Option[Option[T]]ベースのUnd[T]

V1, V2はsuffixそれぞれ以下を意味します。

ベンチとるまで気づきませんでしたがこのぐらいのサイズのstructをMarshalするときgithub.com/go-json-experiment/jsonのほうがencoding/jsonに比べて遅いんですね。
とは言え差は100nsecないので気にならないケースのほうが多いと思います。

Marshal+V2の時のみNullable実装が最速です。それ以外は予測通り、Nullable > Slice > NonSliceです。
ただ現実的なアプリが気にする必要がある差にも思いません。他の重い処理をすればほとんどノイズレベルの差でしかなさそうに思います。

Option[Option[T]]ベースのUnd[T]が最もパフォーマントなのはまあ想像に難くないです。各種slice向けの処理を通らないから[]Option[T]よりも早くて当然だといえます。
他の結果も予測どおりです。Unmarshal+V2のケースでNullableV2MapV2で差がついてるのはNullable[T]UnmarshalJSONV2を実装しないからかもしれません。

おわりに

JSONがたびたび持つT | null | undefinedを表現したいユースケースと、なぜGoではそれが表現しづらいかについて述べました。
その後、可能だがとらなかった方法について述べ、[]Option[T]をベースとする実装のしかたについて説明し、
最後にベンチマークをとって[]Option[T]map[bool]Tに比べて若干パフォーマントであることを示しました。

まあパフォーマンスのいかんは些細な問題で、今回こういう風に実装したのはOption[T]に実装を寄せたかったからなので筆者はこの実装を使っていくと思います。

筆者がNode.jsで書いていたElasticsearchの前に立つサーバーアプリケーションをどうやってGoに移植すればよいのだろうか疑問からから始まった探索でしたが、
現実的で扱える方法が見つかったことで、このテーマは一旦終わりにできそうです。
記事中では特に触れていなかったですが、Elasticsearchに格納するJSON向けのundefined | null | T | (null | T)[]を表現できる型も作成済みです。
しかし残念ながら、筆者はそういったアプリを実際に移植することはなさそうなのであまりこの成果を生かせなさそうです。

GitHubで編集を提案

Discussion