💬

Goのstruct fieldでJSONのundefinedとnullを表現する

2023/03/02に公開

TL;DR

  • Elasticsearch (の update API)のような JSON におけるnullundefined(JSON に key がない)状態をうまく使い分けるシステムに送る JSON を struct を marshal するだけでいい感じに作りたい。
  • std のencoding/jsonでうまいことやるのは無理そうだった。
  • Option[T]を定義して、Option[Option[T]]undefined | null | Tを表現する型とした。
  • jsoniterExtension を駆使してundefinedのとき field を skip する Marshaler を実装した。

Overview

Go で JSON を扱うとき、Elasticsearch の update api に渡す JSON のような nullundefined をだし分けられるデータ構造や、それに対応する JSON marshaller が Go には std ではなく、軽く探したところ見つからなかったので、興味本位で作ってみました。

成果物はこちらです。

https://github.com/ngicks/und

この記事では

  • どういう事で困っていたのか
  • 既存の方法にはどういうものがあったのか
    • この話題に関連する今もってる知見をできる限り書いています。
  • 実装を通じてencoding/jsonについて得られた知見

などを書いていきます。

前提知識

  • Go programming language の細かい説明はしてますが全体の説明はしないので、ある程度知っている人じゃないと意味が分からないかもしれません。
  • 本投稿のごく一部で何の説明もなしに TypeScript の型表記がでてきます。知っている人か、でなければなんとなくで読んでください。

環境

> go version
go version go1.20 linux/amd64.

ドキュメント、ソースコードは全てGo 1.20のものを参照していますが、ドキュメントそのものはしばらく変わっていませんのでそれより以前のバージョンでも同様であると予想します。また、Go 1.18 で追加された generics を利用したソースコードを書きますので、記事中のサンプルコードは Go 1.18 以降でのみ動きます。

対象読者

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

言葉の定義

本投稿では以後 JSON の key が:

  • ないことを undefined
  • null であることを null
  • ある型 T であること T
  • アプリによって必須であると決められていることをrequired

と呼びます。

背景: 時たま困る「データがない状態」の扱い

Go の言語設計のせいでは全くないのですが・・・

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.

とある通り、変数も struct field も型に対応する zero value に初期化されます。

zero value はフィールドがない判定に使われることがある

この zero value は、「データがない状態」の判定に使われることがあり、

例えばGORM という ORM の場合,

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;

https://gorm.io/docs/query.html より引用

という風に、zero value であるフィールドはデータがセットされていないこととして扱う API が存在します。該当の zero value 判定はここなどでおこなわれています(reflectIsZero などが使われていますね)

外部データ(JSON)をバインドするときの zero value

同じように、JSON など外部データからのデコード時、struct にデータをバインドする際に、

type Sample struct {
	Foo string
	Bar int
}

のような struct があったとして、

{"Foo": "", "Bar": 0}
{"Foo": null, "Bar": null}
{}

を入力した場合、バインドされた struct field の値はいずれの場合も zero value でありますので、入力がなんであったかという区別がつきません。

外部 API として""(空白 string) や数値型で 0 があり得ない場合のみ、上記の Sample struct にデータをバインドするだけでいいことになります。

""がありえない API はそこまで珍しくない気がしますが、 0 がありえないケースはそこそこ珍しいかなと思います。そこで基本的に以下のようにメンバーをポインターにすることになるのが普通かと思います。

type Sample struct {
	Foo *string
	Bar *int
}

この場合、入力値が「データがない状態」を示すとき、対応する field の値はnilになります。しかしこの場合でも、フィールドがnullであった時と、undefined(JSON に key がなかった)時の区別がつきません。

外部システムとやり取りする時のundefinednull

undefinednullを分けて扱われることがある

HTTP で JSON を送る PUT や PATCH method において、undefined(=キーが存在しない)のとき field をスキップ、nullのとき field をクリアするかnullで上書き、Tの時Tで上書き、という挙動をさせる API があります。筆者もそういった API を書くことがあります。

広く使われている実例としては、Elasticsearch があります。Elasticsearch の update api では partial document を送ることでドキュメントの各 field を更新でき、null をセットすることで field を null で上書きできます。

既存の方法: required の強制、validation について

map[string]any

もっとも単純な発想はmap[string]anyに JSON のあらゆる値をいったんデコードして、key の存在チェックや型の整合性などを取ることです。

Go のハッシュマップはmap[T]Uの記法で表現されます。Tが key、Uが value の型です。

anyは Go1.18 で追加されたinterface{}のエイリアスです。Go の interface は MethodElem の集合であり、それを実装するあらゆる型が代入可能です。anyは何のメソッドも指定されていないためどのような値でも代入可能です。

JSON は TypeScript でいうところの

type JSONLit = string | number | boolean | null | JSONArray | JSONObject;

interface JSONArray extends Array<JSONLit> {}

interface JSONObject {
  [key: string]: JSONLit;
}

であるので厳密にはmap[string]anyではないんですが、Array 以外の primitive 値を送ることなんかめったにないと思いますし、API の後方互換性を保ったまま情報を増やすのがもっとも容易なのは結局 Object なのでそれ以外の場合の考慮はいったん外しましょう。

map[string]anyにデコードすれば key のあるなし、型の一致などの判定をおこなえます。

JSON schema

JSON のバイト列が意識される段階では validation そのものは JSON schema などで行うことができます。

JSON schema を読み込んで validation をかける事ができるライブラリはいくつかあります。

筆者はgithub.com/santhosh-tekuri/jsonschemaecho の Binder の実装の中で使って validation をかけるようなことしたことがあります。

実装のサンプルは長くなるのでドロップダウンに隠しておきます。

json schema で validation をおこなう echo.Binder の実装サンプル

サンプルでecho.Contextをくみ上げる気が起きなかったので、その部分は単に compilation error が起きないことだけ見せています。

MustCompile はJSON Pointerを受け付ける仕様なので OpenAPI spec の yaml でも JSON byte に変換できればコンパイル可能です。

こんな感じ.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"strings"

	"github.com/labstack/echo/v4"
	"github.com/mitchellh/mapstructure"
	"github.com/santhosh-tekuri/jsonschema/v5"
)

type SampleValidator struct {
	Foo string
}

var schema *jsonschema.Schema

func init() {
	cmp := jsonschema.NewCompiler()
	err := cmp.AddResource("foo.json", strings.NewReader(`{
		"type": "object",
		"properties": {
			"Foo": {
				"type": "string"
			}
		},
		"required": ["Foo"]
	}`))
	if err != nil {
		panic(err)
	}

	schema = cmp.MustCompile("foo.json")
}

func (*SampleValidator) Validate(data any) error {
	return schema.Validate(data)
}

var _ echo.Binder = &ValidatingBinder{}

type ValidatingBinder struct{}

// Bind binds body to i. It ignores path params and query params.
func (b *ValidatingBinder) Bind(i interface{}, c echo.Context) (err error) {
	return bindValidating(c.Request().Body, i)
}

func bindValidating(r io.Reader, i any) error {
	// encodingを見てdecoderをさらに噛ませた方がいいんですが省略です。
	dec := json.NewDecoder(r)
	dec.UseNumber()

	if closer, ok := r.(io.Closer); ok {
		defer closer.Close()
	}

	var jsonVal any
	if err := dec.Decode(&jsonVal); err != nil {
		return err
	}

	// 省略:
	// token, err := dec.Token()
	// // input stream has additional chars and it is not a valid json token.
	// if err != nil { /*どうする?*/ };
	// // input stream has another json.
	// if token != nil { /*どうする?*/ }

	if validator, ok := any(i).(interface {
		Validate(data any) error
	}); ok {
		err := validator.Validate(jsonVal)
		if err != nil {
			return err
		}
	}

	err := mapstructure.Decode(jsonVal, i)
	if err != nil {
		return err
	}
	return nil
}

func main() {
	var (
		err error
		sv  SampleValidator
	)
	// 内部でjson.Decoderを使うので後続行にさらにjsonがあっても問題ありません。
	err = bindValidating(strings.NewReader("{\"Foo\":\"foo\"}{\"Foo\":\"bar\"}"), &sv)
	fmt.Printf("%+v\n", err) // <nil>
	fmt.Printf("%+v\n", sv)  // {Foo:foo}

	sv = SampleValidator{}
	err = bindValidating(strings.NewReader(`{}`), &sv)
	fmt.Printf("%+v\n", err) // jsonschema: '' does not validate with file:///<path to cwd>/foo.json#/required: missing properties: 'Foo'
	fmt.Printf("%+v\n", sv)  // {Foo:}

	sv = SampleValidator{}
	err = bindValidating(strings.NewReader(`null`), &sv)
	fmt.Printf("%+v\n", err) // jsonschema: '' does not validate with file:///<path to cwd>/foo.json#/type: expected object, but got null
	fmt.Printf("%+v\n", sv)  // {Foo:}
}

null リテラルもしっかりチェックしてくれますね!

Partial JSON の受け側にはなれる

  • 後のセクションでも触れますが、json.Unmarshalはキーがないとき単に何も代入しない動きをするため、「キーがあるときだけ上書き」の挙動自体は容易に実現可能です。
    • 上記の validation と合わせると堅牢な update 処理を行えます。
  • JSON をmap[string]anyにデコードしてそれを上書きしてもいいでしょう。
type Sample struct {
	Foo   string
	Bar   int
	Baz   float64
	Inner Inner
}

type Inner struct {
	Qux  string
	Quux string
}

// Update creates a updated Sample.
func (s Sample) Update(jsonBytes []byte) (Sample, error) {
	err := json.Unmarshal(jsonBytes, &s)
	if err != nil {
		return Sample{}, err
	}
	return s, nil
}

func main() {
	var s Sample

	s, _ = s.Update([]byte(`{"Foo":"foo","Bar":10,"Baz":10.15,"Inner":{"Qux":"qux","Quux":"quux"}}`))
	fmt.Printf("%+v\n", s)
	s, _ = s.Update([]byte(`{"Foo":"foo?","Inner":{"Quux":"q"}}`))
	fmt.Printf("%+v\n", s)
	s, _ = s.Update([]byte(`{"Bar":-20,"Baz":10.24}`))
	fmt.Printf("%+v\n", s)
}

/*
	{Foo:foo Bar:10 Baz:10.15 Inner:{Qux:qux Quux:quux}}
	{Foo:foo? Bar:10 Baz:10.15 Inner:{Qux:qux Quux:q}}
	{Foo:foo? Bar:-20 Baz:10.24 Inner:{Qux:qux Quux:q}}
*/

struct field がポインターである場合、nilの場合のみ新しいポインターを allocate するので今回のように immutable な実装にしたい時はポインターを使わない方がいいでしょう。

課題

Go の struct に JSON からエンコード/デコードを行うときに struct 上ではundefinednullTが区別が付かないため

  • 入力値のrequiredや、disallowNull, disallowUndefinedなどの入力ルールを実現できない
    • 少なくともrequiredは API にリクエストを飛ばすクライアントの実装が typo で key 名を取り違えているときのチェックのために欲しい。
      • DisallowUnknownFieldsによって余分なキーを許可しないことで対応可能ではあります。
    • map[string]anyへのデコードで実現できますが、最終的にデータのバインド先となる struct とフィールドと型が合わない場合でも一旦デコードを完了してしまうため非効率です。
      • この場合 DisallowUnknownFields のようなことができないため非効率です。
  • JSON を送信するとき、相手システムが nullundefined を分けて使う場合、だし分けるのに当該 struct 以上の追加のデータが必要であるため煩雑であること

ひとつ目の validation 周りの欲求はパフォーマンスにしか触れておらず、ベンチもとっていないので特に強く言えないですが、2 番目の欲求はちょっとリアルに困っているところです。

なので残った要求は

  • 取り扱いやすい方法でundefined | null | Tを struct field で表現する

ことです。

しかし後続セクションで述べる理由により、std 範疇では難しいことがわかります。

Go の standard library における JSON "null", "undefined" の取り扱い

まず解決方法を考える前に Go の standard library で JSON を取り扱うencoding/jsonnullundefinedをどのように処理するのか確認します。

基本: エンコード/デコード

この記事をここまで読んでいて知らない人がいるかはわかりませんが、基礎情報として、エンコード/デコードの方法について触れます。

Go で標準的な方法で JSON を取り扱うには standard library のencoding/jsonを使います。json.Marshalによってエンコード、json.Unmarshalによってデコードを行います。

playground

type Sample struct {
	Foo string
	Bar int
}

func main() {
	// []byte, errorを返す。
	bin, err := json.Marshal(Sample{"foo", 123})
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s\n", string(bin)) // {"Foo":"foo","Bar":123}

	var s Sample
	err = json.Unmarshal(bin, &s)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%+v\n", s) // {Foo:foo Bar:123}
}

対象の type がjson.Marshaler, json.Unmarshalerを実装している場合、そちらが優先して使われます。

playground

type Sample2 struct {
	Foo string
	Bar int
}

func (s Sample2) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf("{\"foo\":\"f_%s\",\"bar\":\"%d\"}", s.Foo, s.Bar)), nil
}

func (s *Sample2) UnmarshalJSON(data []byte) error {
	mm := make(map[string]any, 2)
	err := json.Unmarshal(data, &mm)
	if err != nil {
		return err
	}

	s.Foo = strings.TrimLeft(strings.TrimLeft(mm["foo"].(string), "f"), "_")
	num, _ := strconv.ParseInt(mm["bar"].(string), 10, 64)
	s.Bar = int(num)
	return nil
}

func main() {
	bin, err := json.Marshal(Sample2{Foo: "foo", Bar: 123})
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s\n", string(bin)) // {"foo":"f_foo","bar":"123"}

	var s2 Sample2
	err = json.Unmarshal(bin, &s2)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%+v\n", s2) // {Foo:foo Bar:123}
}

stream でも処理が可能です。Decoder は最低 512 バイトずつ読みこみながらJSON literal が終わるまで reader を読むようですね。

playground

type Sample struct {
	Foo string
	Bar int
}

func main() {
	buf := new(bytes.Buffer)
	encoder := json.NewEncoder(buf)

	err := encoder.Encode(Sample{Foo: "foo", Bar: 123})
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s", buf.String()) // {Foo:foo Bar:123}

	decoder := json.NewDecoder(buf)

	var s Sample
	err = decoder.Decode(&s)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%+v\n", s) // {"Foo":"foo","Bar":123}
}

JSON null

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.

https://pkg.go.dev/encoding/json@go1.20.0

encoding/jsonでは引用の通り、json.Marshal は以下の条件でnullを出力します

  • pointer type で nil のとき。
    • フィールドの型が*T | []T | map[T]U | interfaceで値がnilであるとき
  • もしくは json.Marshaler(MarshalJSONメソッド) で[]byte("null")を返したとき。

([]bytebase64 string になるのがちょっとびっくりしますね。)

...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.

https://pkg.go.dev/encoding/json@go1.20.0

逆に json.Unmarshal では

  • non pointer type Tに対して null は代入しない
  • pointer type *Tに対して nullnil を代入

という挙動と記述されています。

type がjson.Unmarshalerを実装する場合、JSON byte がnullであるときでも呼び出されるため、[]byte("null")を好きに変換することができます。

若干厄介な null リテラルの取り扱い

null が入力値であり対象が non-pointer type であるとき単に代入しない動きであるので null リテラルが入力であればデコード自体が完全にスキップされエラーになりません

playground

package main

import (
	"encoding/json"
	"fmt"
)

type Sample struct {
	Foo string
	Bar int
}

func main() {
	var s Sample
	err := json.Unmarshal([]byte(`null`), &s)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%+v\n", s) // {Foo: Bar:0}
}

var jsonVal anyにデコードするか、でなければ null リテラルはデコード前に判定する必要があります。

JSON undefined

JSON(JavaScript Object Notation)はその名前の通り JavaScript が元となっているため、JavaScript の事情を多分に含んでいます。

JavaScript にはnullとは別にundefinedという「データがない状態」の表現が存在します。
JSON の定義undefinedは存在しないのでシリアライズされるときにキーが消える挙動となります。

json.Marshalでは、omitempty という struct tag で当該 struct field のスキップを行います。

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/json@go1.20.0

json.Unmarshalでは、

  • JSON バイト列の中に対応する key がない場合、単に代入しない挙動となります。
    • zero value のまま置かれると思ってもいいでしょう。

encoding/jsonでは"undefined | null | T"のだし分けできない

前記の通り、型のデータをundefined(=フィールドをスキップする)とするかnullとするかはデータではなくメタデータとして実装されています。

type Sample3 struct {
	Foo string  `json:",omitempty"`
	Bar *string `json:",omitempty"`
	Baz *string
}


func main() {
	var emptyStr string
	bar := "bar"
	baz := "baz"
	must := func(v []byte, err error) []byte {
		return v
	}
	fmt.Printf("%s\n", string(must(json.Marshal(Sample3{Foo: "foo", Bar: &bar, Baz: &baz}))))
	fmt.Printf("%s\n", string(must(json.Marshal(Sample3{Foo: "", Bar: &emptyStr, Baz: &emptyStr}))))
	fmt.Printf("%s\n", string(must(json.Marshal(Sample3{Foo: "", Bar: nil, Baz: nil}))))
}
/*
	{"Foo":"foo","Bar":"bar","Baz":"baz"}
	{"Bar":"","Baz":""}
	{"Baz":null}
*/

こういう挙動です。

type のみ(= json.Marshaler / json.Unmarshaler のみ)によってundefined / nullを表現し分けることは、std の範疇ではできなさそうなことは確認が取れました。

関連 issue

似たような悩みに基づく issue が出ています。色々理由があってencoding/jsonに入ることはないようですが

https://github.com/golang/go/issues/5901#issuecomment-907696904

にある通り、v2encoding/jsonに値に基づいてフィールドをスキップするような挙動が追加されるかもしれません。

とにかくしばらくはなさそうです。

解決方法: "undefined | null | T"を表現できる type を作る

"go JSON undefined"などでググってみましたがこれを実現しているライブラリーは見つかりませんでした。おそらくはある気がしますが、気が向いたしすぐ作れる気がするので作ってみます。

  • undefined | null | Tを表現できる構造体を定義することとします。
    • **Tのようなポインタータイプを base type とする defined type はメソッドを持てませんので struct にします。
    • struct は omiempty によるスキップがぜったいに起こりませんので専用の marshaller が必要です。

type を作る

問題: **T はメソッドを持てない

Go では「データがない状態」を表現することに通常は*T を利用します。

*Tundefined | Tもしくはnull | Tを表現するならば、単純に**Tundefined | null | Tを表現できます。

ただし、以下のようなメソッドの実装はできません。

type Undefined[T any] **T

func (u Undefined[T]) IsUndefined() bool {
	return u == nil
}

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

そのため、便利な helper method の定義ができません。

  • **Tを引数にとる関数を定義すると煩雑です。
  • method set が定義できないならanyな値がUndefined型であるのかを判別するのが煩雑になります。

encoding/jsonreflectを多用しますのでこれを気にせずに使えるようなアプリならば気になるのはメソッドが定義できない煩雑さの方でしょう。

解法: boolean flag で defined を表現する。

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

https://pkg.go.dev/database/sql@go1.20#NullBool

type NullBool struct {
	Bool  bool
	Valid bool // Valid is true if Bool is not NULL
}

Go 1.18 で追加された Generics を利用すればこれをあらゆる type Tについて定義できますし、これを 2 段重ねにするだけ目的を達成できそうですね。単純な解法ですが、*Tに引っ張られて見落としていました。

ということで定義は以下のようになります。

まずOption[T]を定義して

option.go
type Option[T any] struct {
	some bool
	v    T
}

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

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

func (o Option[T]) Value() T {
	return o.v
}

var nullByte = []byte(`null`)

func (o Option[T]) MarshalJSON() ([]byte, error) {
	if o.IsNone() {
		return nullByte, nil
	}
	return json.Marshal(o.v)
}

func (o *Option[T]) UnmarshalJSON(data []byte) error {
	if string(data) == string(nullByte) {
		o.some = false
		return nil
	}

	o.some = true
	return json.Unmarshal(data, &o.v)
}

Nullable[T]Option[T]とほぼ等価ですが、以下の定義はできません

そのため embedded で行きます。

field.go
type Nullable[T any] struct {
	Option[T]
}

func (n Nullable[T]) IsNull() bool {
	return n.IsNone()
}

func (n Nullable[T]) IsNonNull() bool {
	return n.IsSome()
}

type Undefinedable[T any] struct {
	Option[Option[T]]
}

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

func (u Undefinedable[T]) IsDefined() bool {
	return u.IsSome()
}

func (u Undefinedable[T]) IsNull() bool {
	if u.IsUndefined() {
		return false
	}
	return u.v.IsNone()
}

func (u Undefinedable[T]) IsNonNull() bool {
	if u.IsUndefined() {
		return false
	}
	return u.v.IsSome()
}

func (f Undefinedable[T]) Value() T {
	return f.v.Value()
}

特化した Marshaller を作る

上記のUndefinedable[T]は struct であるので、前記の通り omitempty によるスキップ動作が起きません。そこで、専用の Marshaller を作成してundefined時に skip 可能にします。

考慮すべきエッジケース

特化したものを作るのですが、特化の範囲はあくまで入力が struct のみと想定するのみです。そこ以外はある程度encoding/jsonの挙動に寄せないと呼び出し側に不要な気遣いを生じさせ、後々困る可能性があります。

json.Marshalは struct tag によってフィールド名が指定できる都合上、被ったフィールドをまとめて全部消す動作になっています。この辺のエッジケースがどういう風に動作するかをまず確認しましょう。

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周まではエンコードされるがその後は無視される挙動のようですね。

encoding/jsonのソースコードをよくよく読んでると優先ルールはここで記述されていますね。index は struct 中での定義順のことで、len(index) > 1 の時 embed された struct であることがわかります。なので、優先ルールは、

  • 階層の浅さ(embed されているものが優先されない)
  • tag されているか
  • 定義順

で決められており、同名のフィールドが複数ある場合はlen(fields[0].index) == len(fields[1].index) && fields[0].tag == fields[1].tagの場合、その名前は消す、という挙動ですね。

再帰に関してはここやここなど合わせ技でなっているのだと思われます。

std はさすが、あらゆるエッジケースが考慮されていますね。

jsoniter の Extension で何とかする

さすがに上記のエッジケースを埋めるものを個人で完全にメンテし続ける自信がなくなってきたので、なんとか他の方法でできないか探します。

encoding/jsonは部分的なロジックを取り出せるつくりにはなっておらず(不用意に露出させれば変更の自由が損なわれるのだから当然です。)、サードパーティのライブラリを当てにするしかありません。

幸いにも json 関連のライブラリは数多く存在しており、github.com/json-iterator/go(以後 jsoniter と呼びます)が内部の挙動を Extension の仕組みによってカスタマイズ可能かつ interface 的にはencoding/jsonと互換なようですのでこちらを使うことにします。

jsoniter はValEncoderという interface で型に対する encoder を定義しています。この interface は IsEmpty という今回使いたいドンピシャの機能を露出していますのでこれを利用します。

この ValEncoder を登録する方法はAPIRegisterExtensionか、RegisterExtensionRegisterFieldEncoderRegisterTypeEncoderなどです。

jsoniter.Register...は jsoniter のパッケージ内で定義されたマップに対する代入の動作ですので data race も起きますし、他のコードに影響してしまいます。そういった理由でAPI.RegisterExtensionを使います。

Extensionは Encoder/Decoder をスワップするなどするための method の集合です。以下のようなコードでinterface { IsUndefined() bool }を実装するすべてのフィールドの IsEmpty を内部的にInUndefinedへの呼び出しに変換できます。

var config = jsoniter.Config{ // `encoding/json`互換の設定
	EscapeHTML:             true,
	SortMapKeys:            true,
	ValidateJsonRawMessage: true,
}.Froze()

func init() {
	config.RegisterExtension(&UndefinedableExtension{})
}

type IsUndefineder interface {
	IsUndefined() bool
}

var undefinedableTy = reflect2.TypeOfPtr((*IsUndefineder)(nil)).Elem()

// undefinedableEncoder fakes Encoder so that
// the undefined Undefinedable fields are considered to be empty.
type undefinedableEncoder struct {
	ty  reflect2.Type
	org jsoniter.ValEncoder
}

func (e undefinedableEncoder) IsEmpty(ptr unsafe.Pointer) bool {
	val := e.ty.UnsafeIndirect(ptr)
	return val.(IsUndefineder).IsUndefined()
}

func (e undefinedableEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
	e.org.Encode(ptr, stream)
}

// UndefinedableExtension is the extension for jsoniter.API.
// This forces jsoniter.API to skip undefined Undefinedable[T] when marshalling.
type UndefinedableExtension struct {
}

func (extension *UndefinedableExtension) UpdateStructDescriptor(structDescriptor *jsoniter.StructDescriptor) {
	if structDescriptor.Type.Implements(undefinedableTy) {
		return
	}

	for _, binding := range structDescriptor.Fields {
		if binding.Field.Type().Implements(undefinedableTy) {
			enc := binding.Encoder
			binding.Encoder = undefinedableEncoder{ty: binding.Field.Type(), org: enc}
		}
	}
}

// ... rest of interface ...

さて、IsEmpty を内部的に IsUndefined に差し替えることができました。ただ、これだけではまだ目的をかなえるには足りません; フィールドがスキップされるには,omitemptyオプションが struct tag として必要なままです。

ここで、reflect2.StructField が interface であることに気付きました。つまり、

https://github.com/ngicks/und/blob/4a6d66fdb317eb77c551987b44815492941f217d/serde/tag.go#L99-L138

https://github.com/ngicks/und/blob/4a6d66fdb317eb77c551987b44815492941f217d/serde/serde.go#L53-L81

という感じで、interface { IsUndefined() bool }を実装している全てのフィールドは常に ,omitempty オプションがあるかのようにふるまいます。少々ハッキーですがこれで思った通りの挙動をになります。

jsoniter も encoding/json と動作が一致しないという話。

上記のエッジケースを入力すると jsoniter も encoding/json と一致しない結果を返しました。よりにもよって一番考慮されないエッジケースを選んでしまったようです。

さらに、,stringオプションがnullや struct など対応していない型も quote していまいます。

issue にもしておきました。

https://github.com/json-iterator/go/issues/657

,stringオプションはあまりにも挙動が違うのでサポートしたほうがいいかもですが、他は考慮するほどでもないですかね?

https://github.com/json-iterator/go/pull/659
https://github.com/json-iterator/go/pull/660

これらの問題を修正する PR を出しておきましたが、非活発的なようなのでほっとかれるかもしれません。
まあでもこれらの問題に遭遇した人はこういう感じの修正をすればいいとわかるのでフォークして似たような修正を加えれば問題ないでしょう!

github.com/ngicks/und として公開しておいた

https://github.com/ngicks/und

色々綺麗にしてパッケージとして公開しておきました。これで私自身も会社でこのコードを使えるというワケです。

https://github.com/ngicks/und/blob/4a6d66fdb317eb77c551987b44815492941f217d/example/main.go#L1-L46

効果

  • JSONundefinednull をうまく使い分けるシステム(例えば、Elasticsearch) の前に立って、update 用の partial document を受け取って validation をかけたり加工したり素通ししたりする API が自然に書けるようになる(はず)
  • Elasticsearch の_source フィールドで Elasticsearch から得られるドキュメントのフィールド名を絞った際、自然に指定しなかったフィールドがundefinedなままで表現できるようになるはず
    • ただしこれは document が null 埋めされてるとか初期値がついている前提。

Elasticsearch は実のところあらゆるフィールドの値が undefined | (T | null) | (T | null)[]と定義されているので、もう少し努力が必要です。これは別の機会に実装しようと思っています。

おわりに

いかかがでしたか?私はこの実装や調査を非常に楽しみました。
想像よりも数段encoding/jsonの挙動が奥が深く、周辺ライブラリの多さや調査項目の多さに結構な時間を持っていかれました。なんだか結果的に jsoniter の便利さを伝えるだけの記事になってしまったような気がします。

今後の課題は

  • 今回の成果物を Elasticsearch を相手にするシステムで使ってみて改善する。
  • jsoniter の Extension 部分をもうちょっと一般的にして使いやすくする
    • time.Timet.IsZero() == trueのとき empty 扱いするなども同様にできますので、そういった extension を作ってもいいでしょう。
  • 普通はこういうお困りごとはどうやって解決されているのかを調べる。
    • 今回は見つからなかったですが、似たようなことしてる人いっぱいいると思うんですよね。

などでしょうか。

以上です。ありがとうございました。

GitHubで編集を提案

Discussion