GoのJSONのT | null | undefinedは[]Option[T]で表現できる
GoのT | null | undefinedは[]Option[T]でよかった
-
github.com/oapi-codegen/nullableが
map[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
におけるnull
とundefined
(JSON
にフィールドがない)状態をうまく使い分けるシステムに送るJSON
を structをmarshalするだけでいい感じに作りたい。 -
encoding/json
の挙動を利用し、map[K]V
もしくは[]T
をベースとする型を工夫することで可能なことが分かった。 - some(present)/none(absent)を表現できる型として
Option[T]
を定義して、[]Option[T]
をT | null | undefined
を表現する型とした。-
github.com/oapi-codegen/nullableは
map[bool]T
として利用するが、[]T
のほうが動作が速いんじゃないかという仮説があった
-
github.com/oapi-codegen/nullableは
やること
-
Go
でT | 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の状態のこと |
JSON
はRFC8259の定義を用います。
おさらい: 時たま困る「データがない状態」の扱い
JSONの特殊性
RFC8259によればJSON
はJavaScript Object Notation
(javascriptのobjectの記法)の略語であり、ほとんどのケースでJSON
は有効なjavascriptです。
そのためjavascript
の事情を多分に含んでいます。
javascript
には「データがない状態」の表現がnull
とundefined
という二通り存在します。
javascript
を書いているとき、取り扱うほとんどの値がObject
・・・Go
で言うとmap[string]any
のようなもの・・・ですので、フィールドがない(undefined
)のとnull
(Go
でいうとnil
)が含まれていることは自然と表現できます。
実はundefined
という値や型が存在するため「フィールドがない」だけでなく「フィールドにundefined
がセットされている」というさらに別の状態も存在します。
RFC8259上undefined
は存在しないのでシリアライズされるときにJSON value
のオブジェクト上にフィールドが出現しない挙動となります。
筆者の知る限りこのような状態を自然に表現できるようにしてあるプログラミング言語はあまりないため、大抵の場合undefined
とnull
を同一扱いするか、optional
あるいはnullable
と言われるようなdata containerを入れ子にしたoptional<optional<T>>
にして対応していることがほとんどだと思います。
Go
は普通に書くとundefined
とnull
を同一扱いするような形になると思います(後述)。
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/gob
はzero 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
が「ない」として扱われます(実際に発行されるSELECT
のWHERE
句に値を指定していないフィールドが出現していません。)。
zero value
判定はここなどでおこなわれています(reflect
のIsZeroなどが使われていますね)
外部データからGo valueへのデコード時のzero valueは曖昧
外部データからGo
valueへのデコード後にあるフィールドがzero value
であった時、フィールドに対応するデータがなかったのか、zero value
に対応した値が存在していたのか判別が付きません。
以下のsnippetで示される通り、入力値が""
, 0
などのzero value
であるときと{}
のようなフィールドが空だった時どちらも(JSON
の場合はnull
も同様に)unmarshal後の値が同じになります。
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/xml
はnil
なフィールドは単にオミットする挙動なので、とりあえずencoding/xml
の範疇では*T
としておくだけでフィールドが存在していなかったのか、していたのかが判別できます
xmlのnil
ここなどを参照すると特定のnamespaceでxsi:nil="true"というattributeをつけることでnilを表現可能なようですが、encoding/xml
はとりあえずそれらの存在ありきの実装にはなっていませんのでユーザーが特別にxsi:nil
を見分けるようなUnmarshalXML
を実装する必要があります。
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を例とします。
ElasticsearchはApache Luceneをベースとした全文検索エンジンでJSON
でドキュメントのストア、検索その他もろもろができます。
そもそも筆者がT | null | undefined
をうまいことGo
で取り扱いたかったのはElasticsearch
と相互にやり取りするアプリをGo
に移植できるか検討していたからなんでした。
例えば以下のようなmapping.json(SQLでいうところのCREATE TABLE
みたいなもの)でindexを作成(index
はSQLでいうところのtableみたいなもの)すると
{
"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を行うライブラリは普通、JSON
をany
(map[string]any
)へとデコードして、そこを介してフィールドの有り無しなどをチェックします。
例えばJSON schema
からGo
typeを作成するライブラリgithub.com/omissis/go-jsonschemaは、UnmarshalJSON
を以下のように生成します。
見てのとおり、map[string]any
とPlain
それぞれに対してjson.Unmarshal
を呼び出します。
map[string]any
のほうを使ってフィールドのあるなしとnull
であるかをチェックし、Plain
の各フィールド型のUnmarshalJSON
実装の中で値のバリデーションを行って、結果としてreceiverに代入します。
type Plain ObjectMyObject
はmethod set
を引き継がないがデータ構造は同じな型を定義するためにこうしています。
他の例を挙げると、OpenAPI specを用いてJSONのvalidationを行うライブラリのgithub.com/getkin/kin-openapiも同様に、JSON
をany
にデコードし、これを利用してvalidationを行います。
ちなみにこのライブラリは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とJSON
のnull
との相互変換となります。
パパっと読むと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
の受け側には十分なれます。
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"
ならば暗黙的に無視される」と載ることになり、すさまじくわかりにくいうえにコードジェネレーターその他と相性が悪くなるのでやらないほうがいいでしょうね。
T | null | undefined
を表現する型を作る
Genericsを利用して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はコンパイルできません。
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
メソッドを持てないため、UnmarshalJSON
を実装できません。UnmarshalJSON
を実装できないと、デコード時にundefined | null
を判別できない(後述)ため、この方法は用いることができません。
できるパターン: boolean flag で defined を表現する。
ところで、std のdatabase/sql
には以下のようなデータ構造が定義されています。
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/json
はUnmarshal
時、JSON
フィールドに値がない(undefined
)時に何も代入をおこなわず、null
だった場合、*T
相手にはnil
を代入し、non-pointer type T
のフィールドには何も代入しません。
また、json.Unmarshal
はUnmarshalJSON
を型が実装する場合、それを呼び出すことで型レベルで挙動の変更をサポートします。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の判定式は以下で行われます
ここに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
により)期待されています。
関連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実装で、JSON
とYAML
のMarshal
/Unmarshal
に対応しているのですが、
という感じで、json.Marshal(pair.Value)
を呼び出しています。Value
の型V
に直接MarshalJSON
が実装してあって、その中でサードパーティのエンコーダーを呼び出していればよいことになります。
すべての型にMarshalJSON
を実装する必要が出てきますね。結構煩雑です。
今回のようなケースに限っては無関係ですが、MarshalJSON
を実装した型をstructにembedすると、embedされた側の型のMarshalJSON
としてexportされてしまうため、embedが必要なケースでうまいこと動作しなくなります。これが決め手となり没となりました。
できればstdのencoding/json
のmarshalerの中で事足りる方法であってほしいということです。
特定の値をスキップするMarshalJSONを実装する
当然これは可能です。つまり以下のような感じです。
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をとって何かのデータ構造に変換をかけるタイプの処理を実装したことがある方はわかるかもしれませんが、Go
はstruct fieldのembeddingが可能で、encoding/json
のembed
されたフィールドの取り扱いは
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周まではエンコードされるがその後は無視される挙動のようですね。
どのフィールドを優先するかのルールは以下で記述されています。
- nameは出力先の
JSON
上でのフィールドの名前です。(つまり、Go
のstruct field名かjson:"name"
で付けられた名前) - indexは
[]int
で表現されるフィールドのソースコード順の出現順序です。len
がstruct fieldのembedの深さを表現し、embedされている数だけappendされます。 - tagはstruct tagでつけられた
json:"name"
があったかどうかです。
dominantField
は以下のように実装されます。不可思議に感じた上記のエッジケースの挙動はこれによって起きています。
型的な再帰が起きていた場合の挙動は以下のコードによって律せられています
こんなエッジケースはわざわざ探すまで体感することはなかったので、stdはさすが、よく叩かれてよく作られていると感じます。
解決法1: map[T]U, []Tはomitemptyでskip可能
encoding/json
のemptyの判別は以下で行われます。
map
, slice
はlen(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
をそのまま使っても大丈夫です。
以前の記事を書いていた時点ではこの方法に全く気付いていませんでした。なんで気付かなかったんだろう・・・
github.com/oapi-codegen/nullable
map[bool]Tを使う実装: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
を返す時、エンコーダーがそのフィールドをオミットする挙動があります。これを利用すれば[]T
やmap[bool]T
を利用せずとも任意の値をオミットすることができます。
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/jsonのMarshal
を呼び出せばundefined
フィールドのオミットは実現できます。
ただしその場合はMarshal
に渡せるOptionのバケツリレーが途切れます。
v2
に正式になった暁にはこちらの実装を使うほうが良いことになるかもしれませんね。
実装
実装物は以下で管理されます
以前の記事で述べたrepositoryと同じです。色々事情が変わったので破壊的変更を行い、jsoniter
への依存などが完全になくなるようになっています。
encoding/json/v2
が実装されるまでgithub.com/go-json-experiment/jsonに依存し、v2
の実装に伴ってその依存を取り消すことでv1.0.0
となる予定です。
今後は破壊的変更はない予定です。
Option[T]
Option[T]
型を実装します。と言ってもこれは前述したものと全く一緒です。
MarshalJSON
, UnmarshalJSON
,MarshalJSONV2
, UnmarshalJSONV2
が実装してあり、None
はnull
に変換されます。
(// same as bytes.Clone.
っていうコメントは消し忘れなので、なんの意味もないです)
MarshalJSONV2
向けにIsZero
が実装してあります
このOption[T]
実装はRust
のstd::Option<T>
をミミックしていますが、Go
には借用など概念がなく、値はすべてzero value
で初期化されるわけですから、内部の値を取り出すのはもっと単純な仕組みでよいことになります。
T
がcomparableならOption[T]
もcomparableですが、time.Time
のような一部の型はEqual
メソッドによる比較を必要としますから、Equal
も実装しておきます。
記事の主題とは全く関係ないですが、Option[T]
にはRust
のstd::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を実装します。
そのほかにもxml.Marshaler,xml.Unmarshaler,slog.LogValuerを実装しておきます。
Und[T] []Option[T]
本題である[]Option[T]
ベースのomitempty
でオミット可能なT | null | undefined
を表現する型です。
以前の記事ではUndefinedable
という名前にしていましたが型名が長すぎると画面がうるさいのでUnd[T]
まで短縮しました。
len(u) == 0
のときをundefined
とし、u[0]
がnoneならnull
, someならT
であるとしています。
Option[T]
と同じくMarshalJSON
, UnmarshalJSON
,MarshalJSONV2
, UnmarshalJSONV2
を実装しています。
non-zero valueに対してUnmarshalJSON
が呼ばれるケースもあることを考慮してlen(u) != 0
の場合、index 0に代入するような考慮がされています。
これまた記事の主題とは無関係ですが以下のようにUnd[T]
もxml.Marshaler,xml.Unmarshaler,slog.LogValuerを実装してあります。
下記の通り、実際に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パターンのベンチマークをとって比較してみます。
github.com/go-json-experiment/jsonのMarshal
は入力が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の略語です。[]byte
をUnmarshal
してMarshal
する1回のラウンドトリップのパフォーマンスをとります。
各テストのprefixはそれぞれ以下を意味します
- Nullable: github.com/oapi-codegen/nullableの
Nullable[T]
型 - Map: 自家版
map[bool]T
実装(なくていいんですがNullable[T]
とほぼ同じ実装なので、nullable
に依存せずにベンチで比較するために作ってありました) - Slice:
[]Option[T]
ベースのUnd[T]
- NonSlice:
Option[Option[T]]
ベースのUnd[T]
V1
, V2
はsuffixそれぞれ以下を意味します。
- V1:
encoding/json
+,omitempty
オプション - V2: github.com/go-json-experiment/json+
,omitzero
オプション
ベンチとるまで気づきませんでしたがこのぐらいのサイズの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
のケースでNullableV2
とMapV2
で差がついてるのは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)[]
を表現できる型も作成済みです。
しかし残念ながら、筆者はそういったアプリを実際に移植することはなさそうなのであまりこの成果を生かせなさそうです。
Discussion