encoding/json/v2でGoのJSONはこう変わる—omitempty再定義

encoding/json/v2でGoのJSONはこう変わる—omitempty再定義 | mohablog

JSONを返すGoのAPIで、ある日からレスポンスに "active":false が混ざるようになる。原因はアプリのコードではなく、ビルド時に渡した GOEXPERIMENT=jsonv2 でした。Go 1.25で実験導入された encoding/json/v2 は、omitempty の意味を静かに書き換えます。

目次

v1のJSONで起きていた取りこぼし

従来の encoding/json(以下v1)の omitemptyGoのゼロ値を基準にしていました。公式ドキュメントの定義はこうです。

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 array, slice, map, or string of length zero.

falseや0も「空」として消える

この定義だと false0 も省略対象。意図的に false を返したいフィールドが、JSONから丸ごと抜け落ちます。受け取る側は「キーが無い = 未設定」と「値がfalse」を区別できません。

time.Timeにomitemptyが効かない

逆に、ゼロ値が「長さゼロ」に当てはまらない型は消えません。time.Time が典型です。

type Event struct {
	Name string    `json:"name"`
	At   time.Time `json:"at,omitempty"`
}

b, _ := json.Marshal(Event{Name: "deploy"}) // v1: encoding/json
fmt.Println(string(b))
{"name":"deploy","at":"0001-01-01T00:00:00Z"}

ゼロ値の日時 0001-01-01T00:00:00Z がそのまま出ます。time.Time は構造体で「長さゼロの値」ではないため、omitempty の網にかからない。v1のJSONを書いたことがあれば、一度は踏む挙動です。

GOEXPERIMENT=jsonv2で切り替える

v2はまだ実験段階です。Go 1.26.0時点でもデフォルトでは見えず、環境変数で明示的に有効化します。

importはencoding/json/v2

明示的に使うなら import "encoding/json/v2"。ビルド時に GOEXPERIMENT=jsonv2 を付けて実行します。

GOEXPERIMENT=jsonv2 go run .

フラグを外すとビルドが通らない

v2パッケージはビルドタグで隔離されています。フラグ無しで encoding/json/v2 をimportすると、コンパイル自体が止まります。

imports encoding/json/v2: build constraints exclude all Go files in
/usr/local/go/src/encoding/json/v2

公式ドキュメントも “Most users should use encoding/json” と明記しています。本番投入の前提ではなく、挙動を試して移行を見積もるための段階です。

omitemptyの基準がJSONの空値に変わった

v2の最大の挙動差は omitempty の定義変更です。基準がGoのゼロ値からJSONとしての空値(null / "" / [] / {})に変わりました。同じ構造体タグでも、出てくるJSONが別物になります。

同じ構造体で出力が変わる

type User struct {
	Name   string   `json:"name"`
	Age    int      `json:"age,omitempty"`
	Active bool     `json:"active,omitempty"`
	Tags   []string `json:"tags"`
}

b, _ := json.Marshal(User{Name: "moha"})
fmt.Println(string(b))

v1とv2で、まったく同じ入力からこう分かれます。

パッケージ出力
v1 (encoding/json){"name":"moha","tags":null}
v2 (encoding/json/v2){"name":"moha","age":0,"active":false,"tags":[]}

v1は ageactive を消し、tagsnull に。v2は age:0active:false を残し、nilスライスを [] にします。フィールドの数すら違う。

falseと0が消えなくなる

v2の omitempty にとって 0false も「空のJSON値」ではありません。数値の 0 やbooleanの false は、れっきとした値だからです。実際にレスポンス差分を調べたとき、フロント側で active の有無を見て分岐していた箇所が、GOEXPERIMENT=jsonv2 を入れた瞬間に常時 false を受け取るようになり、表示が反転しました。タグ文字列は1文字も変えていないのに、です。

nilスライスは[]になる

v2はnilスライス・nilマップを null ではなく [] / {} として書きます。null を期待するクライアントには破壊的変更です。元の挙動に戻すなら、マーシャル時に json.FormatNilSliceAsNull(true)json.FormatNilMapAsNull(true) をオプションで渡します。

bool・数値はomitzeroに寄せる

「falseや0を消したい」という元の意図は、別タグで表現します。omitzero です。

omitzeroはGo 1.24で入っている

勘違いしやすい点ですが、omitzero はv2専用ではありません。Go 1.24から通常の encoding/json に入っています。GOEXPERIMENT 無しで使えます。基準はGoのゼロ値、または型が持つ IsZero() bool メソッドです。

type Event struct {
	Name string    `json:"name"`
	At   time.Time `json:"at,omitzero"` // IsZero() を見る
	N    int       `json:"n,omitzero"`
}

b, _ := json.Marshal(Event{Name: "deploy"}) // v1のまま
fmt.Println(string(b))
{"name":"deploy"}

さきほど omitempty では消えなかった time.Time のゼロ値が、omitzero なら消えます。IsZero() を見るためです。

移行の指針

v1で bool や数値、ポインタに omitempty を付けていた箇所は、意味を保つなら omitzero に置き換えます。

// Before: v2では false/0 が消えなくなる
Active bool `json:"active,omitempty"`

// After: v1・v2どちらでも false/0 を省略
Active bool `json:"active,omitzero"`

omitzero はv1とv2で同じ意味なので、先に置き換えておけばv2へ切り替えても挙動が変わりません。タグ設計の基礎はGo structタグを読み解く—omitempty・json・reflectの仕組みにまとめています。

厳格化で動かなくなる3つのデフォルト

挙動差は omitempty だけではありません。v2はパースを厳しくしています。緩いv1のJSONを投げると、これまで通らなかったエラーで弾かれます。

大文字小文字を区別する

v1はキー名を緩く照合し、大文字小文字を無視していました。v2は既定で厳密一致です。

type T struct{ Name string `json:"name"` }
var t T
json.Unmarshal([]byte(`{"NAME":"x"}`), &t)
fmt.Printf("%q\n", t.Name) // v2: "" (マッチしない)

var t2 struct{ Name string `json:"name,case:ignore"` }
json.Unmarshal([]byte(`{"NAME":"x"}`), &t2)
fmt.Printf("%q\n", t2.Name) // "x"
strict: ""
ignore: "x"

大文字小文字の揺れを許したいフィールドには、明示的に case:ignore を付けます。値は ignorestrict の2択です。

重複キーとUTF-8を拒否する

v1が許容していた重複キーを、v2は拒否します。

var t struct{ X int `json:"x"` }
err := json.Unmarshal([]byte(`{"x":1,"x":2}`), &t)
fmt.Println(err)
jsontext: duplicate object member name "x"

不正なUTF-8も同様に既定で拒否します(v1は置換文字に差し替えていた)。緩い入力をどうしても受けたい場合は、戻すためのオプションが用意されています。

v2の既定v1相当に戻すオプション
大文字小文字を区別タグに case:ignore
重複キーを拒否jsontext.AllowDuplicateNames(true)
不正UTF-8を拒否jsontext.AllowInvalidUTF8(true)

新APIとjsontextの2層構造

v2は関数も増えました。io.Reader / io.Writer を直接受ける版が標準で生えています。

io.Reader/Writerを直接扱う

v1で json.NewEncoder(w).Encode(v) と書いていた処理は、v2では MarshalWrite 一発です。ひとつ違うのは、Marshal 系が末尾に改行を足さないこと。

json.MarshalWrite(os.Stdout, map[string]int{"a": 1})
os.Stdout.WriteString("")
{"a":1}

{"a":1} の直後に改行が無いまま <EOF> が続きます。読み込み側は UnmarshalRead(r, &v)io.Reader から直接デコードできます。

構文と意味を分ける設計

v2は内部を2層に割っています。公式ブログの “Building on encoding/json/jsontext” セクションが説明する通り、下層の jsontext がJSON文法だけを扱い(リフレクション非依存)、上層の json/v2 がGoの値との対応付けを担います。jsontext という名前はRFC 8259の “JSON-text” 由来です。先ほどの重複キーエラーが jsontext: から始まっていたのは、構文層が弾いているからです。

速度はUnmarshalで効く

v2は性能改善も狙っています。ただし「全体的に速い」ではありません。公式ブログによると、Unmarshalは条件次第で最大10倍速くなる一方、Marshalはv1と同等(わずかに速い/遅いが入り混じる)です。

大きな効果が出るのはデコード側。Kubernetesの kube-openapi で UnmarshalJSON から UnmarshalJSONFrom へ移したところ、桁違いの改善が報告されています。マーシャル中心のワークロードなら、速度目的でv2に飛びつく理由は薄いです。

まとめ

  • encoding/json/v2 はGo 1.26.0でも実験段階。GOEXPERIMENT=jsonv2 でのみ有効になる
  • v2の omitempty はJSONの空値基準。false0 は消えず、nilスライスは [] になる
  • false / 0 を省略したい意図は omitzero へ。Go 1.24からv1でも使え、先に寄せておけば移行が滑らかになる
  • 大文字小文字の区別・重複キー拒否・不正UTF-8拒否が既定。緩い入力は明示オプションで戻す
  • MarshalWrite / UnmarshalReadio.Writer / io.Reader を直接扱える。Marshalは改行を足さない
  • 速度はUnmarshalで効き、Marshalは横ばい

v2が正式化するまでに、bool・数値の omitemptyomitzero へ寄せておく。これだけで切り替え当日に false0 周りで壊れる箇所を先回りで潰せます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次