Goのstruct fieldでJSONのundefinedとnullを表現する
EDIT: 2024-07-12
もっと簡単な方法を見つけた(というか教えられた?)ので、新しい記事に書き直しました。
「Go json undefined
」でググるとトップにこの記事が来るので、更新したほうがいいかな~って思ったんですが、自分で自分の記事は何度も開いているのでターゲットされているだけかも
TL;DR
- Elasticsearch (の update API)のような JSON における
null
とundefined
(JSON に key がない)状態をうまく使い分けるシステムに送る JSON を struct を marshal するだけでいい感じに作りたい。 - std の
encoding/json
でうまいことやるのは無理そうだった。 -
Option[T]
を定義して、Option[Option[T]]
をundefined | null | T
を表現する型とした。 -
jsoniterの Extension を駆使して
undefined
のとき field を skip する Marshaler を実装した。
Overview
Go で JSON を扱うとき、Elasticsearch の update api に渡す JSON のような null
と undefined
をだし分けられるデータ構造や、それに対応する JSON marshaller が Go には std ではなく、軽く探したところ見つからなかったので、興味本位で作ってみました。
成果物はこちらです。
この記事では
- どういう事で困っていたのか
- 既存の方法にはどういうものがあったのか
- この話題に関連する今もってる知見をできる限り書いています。
- 実装を通じて
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 判定はここなどでおこなわれています(reflect
の IsZero などが使われていますね)
外部データ(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 がなかった)時の区別がつきません。
undefined
とnull
外部システムとやり取りする時の
undefined
とnull
を分けて扱われることがある
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 をかける事ができるライブラリはいくつかあります。
- https://github.com/xeipuuv/gojsonschema
- https://github.com/santhosh-tekuri/jsonschema
- https://github.com/qri-io/jsonschema
筆者はgithub.com/santhosh-tekuri/jsonschema
を echo の Binder の実装の中で使って validation をかけるようなことしたことがあります。
実装のサンプルは長くなるのでドロップダウンに隠しておきます。
json schema で validation をおこなう echo.Binder の実装サンプル
サンプルでecho.Context
をくみ上げる気が起きなかったので、その部分は単に compilation error が起きないことだけ見せています。
MustCompile はJSON Pointerを受け付ける仕様なので OpenAPI spec の yaml でも JSON byte に変換できればコンパイル可能です。
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 上ではundefined
とnull
とT
が区別が付かないため
- 入力値の
required
や、disallowNull
,disallowUndefined
などの入力ルールを実現できない- 少なくとも
required
は API にリクエストを飛ばすクライアントの実装が typo で key 名を取り違えているときのチェックのために欲しい。- DisallowUnknownFieldsによって余分なキーを許可しないことで対応可能ではあります。
-
map[string]any
へのデコードで実現できますが、最終的にデータのバインド先となる struct とフィールドと型が合わない場合でも一旦デコードを完了してしまうため非効率です。- この場合
DisallowUnknownFields
のようなことができないため非効率です。
- この場合
- 少なくとも
- JSON を送信するとき、相手システムが
null
とundefined
を分けて使う場合、だし分けるのに当該 struct 以上の追加のデータが必要であるため煩雑であること
ひとつ目の validation 周りの欲求はパフォーマンスにしか触れておらず、ベンチもとっていないので特に強く言えないですが、2 番目の欲求はちょっとリアルに困っているところです。
なので残った要求は
- 取り扱いやすい方法で
undefined | null | T
を struct field で表現する
ことです。
しかし後続セクションで述べる理由により、std 範疇では難しいことがわかります。
Go の standard library における JSON "null", "undefined" の取り扱い
まず解決方法を考える前に Go の standard library で JSON を取り扱うencoding/json
がnull
やundefined
をどのように処理するのか確認します。
基本: エンコード/デコード
この記事をここまで読んでいて知らない人がいるかはわかりませんが、基礎情報として、エンコード/デコードの方法について触れます。
Go で標準的な方法で JSON を取り扱うには standard library のencoding/json
を使います。json.Marshal
によってエンコード、json.Unmarshal
によってデコードを行います。
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
を実装している場合、そちらが優先して使われます。
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 を読むようですね。
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.
encoding/json
では引用の通り、json.Marshal は以下の条件でnull
を出力します
- pointer type で nil のとき。
- フィールドの型が
*T | []T | map[T]U | interface
で値がnil
であるとき
- フィールドの型が
- もしくは json.Marshaler(
MarshalJSON
メソッド) で[]byte("null")
を返したとき。
([]byte
が base64 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.
逆に json.Unmarshal では
- non pointer type
T
に対してnull
は代入しない - pointer type
*T
に対してnull
はnil
を代入
という挙動と記述されています。
type がjson.Unmarshaler
を実装する場合、JSON byte がnull
であるときでも呼び出されるため、[]byte("null")
を好きに変換することができます。
若干厄介な null リテラルの取り扱い
null
が入力値であり対象が non-pointer type であるとき単に代入しない動きであるので null
リテラルが入力であればデコード自体が完全にスキップされ、エラーになりません。
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.
- 設定時 Marshal でフィールドがスキップされるような処理になります
-
empty であるかの条件はここで網羅されています
- 条件の通り struct は決して empty と判定されることはありません。
-
この記述からわかるように、
MarshalJSON
メソッドで返すことが許されるのは、有効な JSON 文字列のみです。- つまりここで empty な値を返して、フィールドをスキップしてもらうようなことはできません。
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
に入ることはないようですが
にある通り、v2
のencoding/json
に値に基づいてフィールドをスキップするような挙動が追加されるかもしれません。
とにかくしばらくはなさそうです。
解決方法: "undefined | null | T"を表現できる type を作る
"go JSON undefined"などでググってみましたがこれを実現しているライブラリーは見つかりませんでした。おそらくはある気がしますが、気が向いたしすぐ作れる気がするので作ってみます。
-
undefined | null | T
を表現できる構造体を定義することとします。-
**T
のようなポインタータイプを base type とする defined type はメソッドを持てませんので struct にします。 - struct は omiempty によるスキップがぜったいに起こりませんので専用の marshaller が必要です。
-
type を作る
問題: **T はメソッドを持てない
Go では「データがない状態」を表現することに通常は*T
を利用します。
*T
がundefined | T
もしくはnull | T
を表現するならば、単純に**T
でundefined | 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
そのため、便利な helper method の定義ができません。
-
**T
を引数にとる関数を定義すると煩雑です。 - method set が定義できないなら
any
な値がUndefined
型であるのかを判別するのが煩雑になります。-
interface { IsUndefined() bool }
のような interface に対する type assertion を行うこともできません。 - reflect を使う方法は取れると思いますが、reflect.ValueOf は(現状は)かならず heap にデータを escape するので避けられるなら避けたほうがいいでしょう。
-
encoding/json
もreflect
を多用しますのでこれを気にせずに使えるようなアプリならば気になるのはメソッドが定義できない煩雑さの方でしょう。
解法: boolean flag で defined を表現する。
ところで、std のdatabase/sql
には以下のようなデータ構造が定義されています。
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]
を定義して
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]
とほぼ等価ですが、以下の定義はできません
-
type Nullable[T] = Option[T]
- type param のある type alias は spec で定義されていない
-
type Nullable[T] Option[T]
- Defined type は method set を継承しないため。
そのため embedded で行きます。
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 を登録する方法はAPIのRegisterExtension
か、RegisterExtension、RegisterFieldEncoder、RegisterTypeEncoderなどです。
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 であることに気付きました。つまり、
という感じで、interface { IsUndefined() bool }
を実装している全てのフィールドは常に ,omitempty
オプションがあるかのようにふるまいます。少々ハッキーですがこれで思った通りの挙動をになります。
jsoniter も encoding/json と動作が一致しないという話。
上記のエッジケースを入力すると jsoniter も encoding/json と一致しない結果を返しました。よりにもよって一番考慮されないエッジケースを選んでしまったようです。
さらに、,string
オプションがnull
や struct など対応していない型も quote していまいます。
issue にもしておきました。
,string
オプションはあまりにも挙動が違うのでサポートしたほうがいいかもですが、他は考慮するほどでもないですかね?
これらの問題を修正する PR を出しておきましたが、非活発的なようなのでほっとかれるかもしれません。
まあでもこれらの問題に遭遇した人はこういう感じの修正をすればいいとわかるのでフォークして似たような修正を加えれば問題ないでしょう!
github.com/ngicks/und として公開しておいた
色々綺麗にしてパッケージとして公開しておきました。これで私自身も会社でこのコードを使えるというワケです。
効果
-
JSON
のundefined
とnull
をうまく使い分けるシステム(例えば、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.Time
でt.IsZero() == true
のとき empty 扱いするなども同様にできますので、そういった extension を作ってもいいでしょう。
-
- 普通はこういうお困りごとはどうやって解決されているのかを調べる。
- 今回は見つからなかったですが、似たようなことしてる人いっぱいいると思うんですよね。
などでしょうか。
以上です。ありがとうございました。
Discussion
以下のような方法もあるみたいです。
大分なるほどって感じです。
この方法は記事中で述べたような面倒なことをしなくてもomitemptyが効くので、
自然と
undefined
風なことをできるはずです。代わりに、
T
がcomparableでもNullable[T]
はuncomparableの問題があります。