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の問題があります。