はじめに こんにちは。製造エネルギー事業部の後藤です。Go 1.25 Release Notes の内容を紹介する「フューチャー技術ブログ Go 1.25 リリース連載 」記事です。encoding/json/v2 パッケージを取り上げます。
Go 1.25では、新しいJSON実装である「encoding/json/v2」が導入されました。これは、長らく使われてきたencoding/jsonパッケージの将来的な刷新を見据えたものです。現時点では従来のencoding/jsonが標準として使われており、v2は実験的な位置づけですが、今後の開発やフィードバックを経て、標準パッケージとして採用される可能性があります。
encoding/json/v2 の要点
GOEXPERIMENT=jsonv2を有効にすると、既存のencoding/jsonパッケージの内部実装が新しいものに置き換わる
新しいパッケージが2つ追加
encoding/json/v2: 従来のencoding/jsonを刷新した、高レベルなJSON処理パッケージ
encoding/json/jsontext: 低レベルでJSON構文を扱うための基盤パッケージ
基本的な挙動(Marshal/Unmarshal)は変わらないため、多くのコードはそのまま動作。エラーメッセージは変わる可能性があり、新しいオプションも追加
性能向上し、特にJSONからGoの構造体へのUnmarshalが大幅に高速化。Unmarshalも既存実装と同等かそれ以上の性能
GOEXPERIMENT=jsonv2でomitemptyの挙動は変わらないのか?Go 1.25 のリリースノートには、GOEXPERIMENT=jsonv2 を有効にすると、既存の encoding/json パッケージが新しい実装に切り替わるものの「Marshal/Unmarshal の基本的な挙動は変わらない」と明記されています。
「omitempty タグもそのまま機能するなら、既存コードへの影響は少ないはず」と思い以下のコードを使って確認しました。
package mainimport ( "encoding/json" "fmt" "log" ) type OmitEmptyBehaviorTestStruct struct { NonOmitIntZero int `json:"nonOmitIntZero"` NonOmitFloatZero float64 `json:"nonOmitFloatZero"` NonOmitBoolZero bool `json:"nonOmitBoolZero"` NonOmitStringEmpty string `json:"nonOmitStringEmpty"` NonOmitNilSlice []string `json:"nonOmitNilSlice"` NonOmitNilMap map [string ]string `json:"nonOmitNilMap"` NonOmitNilPointer *int `json:"nonOmitNilPointer"` NonOmitEmptySlice []string `json:"nonOmitEmptySlice"` NonOmitEmptyMap map [string ]string `json:"nonOmitEmptyMap"` OmitIntZero int `json:"omitIntZero,omitempty"` OmitFloatZero float64 `json:"omitFloatZero,omitempty"` OmitBoolZero bool `json:"omitBoolZero,omitempty"` OmitStringEmpty string `json:"omitStringEmpty,omitempty"` OmitByteZero byte `json:"omitByteZero,omitempty"` OmitRuneZero rune `json:"omitRuneZero,omitempty"` OmitNilSlice []string `json:"omitNilSlice,omitempty"` OmitNilMap map [string ]string `json:"omitNilMap,omitempty"` OmitNilPointer *int `json:"omitNilPointer,omitempty"` OmitEmptyButNonNilSlice []string `json:"omitEmptyButNonNilSlice,omitempty"` OmitEmptyButNonNilMap map [string ]string `json:"omitEmptyButNonNilMap,omitempty"` } func main () { var nilIntPtr *int = nil testData := OmitEmptyBehaviorTestStruct{ NonOmitIntZero: 0 , NonOmitFloatZero: 0.0 , NonOmitBoolZero: false , NonOmitStringEmpty: "" , NonOmitNilSlice: nil , NonOmitNilMap: nil , NonOmitNilPointer: nilIntPtr, NonOmitEmptySlice: []string {}, NonOmitEmptyMap: map [string ]string {}, OmitIntZero: 0 , OmitFloatZero: 0.0 , OmitBoolZero: false , OmitStringEmpty: "" , OmitByteZero: 0 , OmitRuneZero: 0 , OmitNilSlice: nil , OmitNilMap: nil , OmitNilPointer: nilIntPtr, OmitEmptyButNonNilSlice: []string {}, OmitEmptyButNonNilMap: map [string ]string {}, } fmt.Println("---Marshal 挙動確認 (encoding/json) ---" ) data, err := json.MarshalIndent(testData, "" , " " ) if err != nil { log.Fatalf("Marshal: %v" , err) } fmt.Printf("Marshal結果: %s\n" , string (data)) }
検証結果:変わりなし このコードを Go 1.25 開発版環境で実行すると、以下の出力が得られます。
{ "nonOmitIntZero" : 0 , "nonOmitFloatZero" : 0 , "nonOmitBoolZero" : false , "nonOmitStringEmpty" : "" , "nonOmitNilSlice" : null , "nonOmitNilMap" : null , "nonOmitNilPointer" : null , "nonOmitEmptySlice" : [ ] , "nonOmitEmptyMap" : { } } { "nonOmitIntZero" : 0 , "nonOmitFloatZero" : 0 , "nonOmitBoolZero" : false , "nonOmitStringEmpty" : "" , "nonOmitNilSlice" : null , "nonOmitNilMap" : null , "nonOmitNilPointer" : null , "nonOmitEmptySlice" : [ ] , "nonOmitEmptyMap" : { } }
既存のencoding/jsonを使っているプロジェクトは、コードを大きく変更することなく、将来的に GOEXPERIMENT=jsonv2の性能向上の恩恵を受けられそうです。
encoding/jsonとencoding/json/v2のomitemptyの挙動は違いあり 次にencoding/json/v2 でも同じように検証してみました。
先ほどのコード下記のように変更します。
@@ -3,7 +3,7 @@ // GOEXPERIMENT=jsonv2あり/なしで実行 import ( - "encoding/json" + jsonv2 "encoding/json/v2" "fmt" "log" ) @@ -62,7 +62,7 @@ fmt.Println("---Marshal 挙動確認 (encoding/json) ---") - // GOEXPERIMENT=jsonv2あり/なしで実行 - data, err := json.MarshalIndent(testData, "", " ") // Indentで整形 + // encoding/json/v2 を使用してマーシャリング + data, err := jsonv2.Marshal(testData) if err != nil { log.Fatalf("Marshal: %v", err)
※Marshalを利用しあとからアウトプットのフォーマットを整えました
検証結果:違いあり { "nonOmitIntZero" : 0 , "nonOmitFloatZero" : 0 , "nonOmitBoolZero" : false , "nonOmitStringEmpty" : "" , "nonOmitNilSlice" : [ ] , "nonOmitNilMap" : { } , "nonOmitNilPointer" : null , "nonOmitEmptySlice" : [ ] , "nonOmitEmptyMap" : { } , "omitIntZero" : 0 , "omitFloatZero" : 0 , "omitBoolZero" : false , "omitByteZero" : 0 , "omitRuneZero" : 0 }
encoding/json と encoding/json/v2 のゼロ値の扱い
encoding/json/v2 の omitemptyの挙動が変わった理由(#71497 ) 挙動変更の背景にある議論の中心は、GoのIssue#71497です。このIssueで議論された変更理由を要約します。
omitemptyの課題とv2での変更の意図 v1の omitempty オプションは、「フィールドがGoのゼロ値(false、0、nilポインタ、nilインターフェース値、または空の配列、スライス、マップ、文字列)である場合にフィールドを省略する」と定義されていました。便利さの反面以下のような課題がありました。
omitemptyの省略ルールが、JSONの型システムではなくGoの型システムに密接に結びついている
JSONの文脈で「数値の0」と「値が存在しないnull」を区別したい場合や、「空の配列[]」と「存在しないことを示すnull」を区別したい場合に、omitemptyが強制的に省略してしまう
v2ではomitemptyの定義をよりJSONの型システム に沿ったものへと変更しています。そのためv2のomitemptyは、v1とは異なります。
v2でv1のomitemptyが提供していた「あらゆるゼロ値を省略する」という機能を実現するためには、jsonv2.OmitZeroStructFields(true)を利用します。
性能検証 encoding/json/v2の導入目的の一つは、性能向上にあります。特にUnmarshal(JSONからGoの構造体への変換)において顕著な速度差が見られるとのことなので検証してみました。
Goの標準ベンチマーク機能を使って、encoding/jsonとencoding/json/v2のMarshal/Unmarshalの性能を比較しました。
検証コード:ベンチマークテスト テスト用データ type BenchmarkData struct { ID int `json:"id"` Name string `json:"name"` IsActive bool `json:"isActive"` Value float64 `json:"value"` Tags []string `json:"tags"` Metadata map [string ]string `json:"metadata"` SubData SubStruct `json:"subData"` } type SubStruct struct { Count int `json:"count"` Type string `json:"type"` } var benchmarkJSON = []byte (`{ "id": 12345, "name": "Sample Product Name For Benchmarking", "isActive": true, "value": 12345.6789, "tags": ["go", "json", "benchmark", "performance", "test"], "metadata": { "version": "1.0", "env": "production", "region": "us-east-1", "owner": "bench-team" }, "subData": { "count": 987, "type": "standard" } }` )var benchmarkData = BenchmarkData{ ID: 12345 , Name: "Sample Product Name For Benchmarking" , IsActive: true , Value: 12345.6789 , Tags: []string {"go" , "json" , "benchmark" , "performance" , "test" }, Metadata: map [string ]string { "version" : "1.0" , "env" : "production" , "owner" : "bench-team" , }, SubData: SubStruct{ Count: 987 , Type: "standard" , }, }
v1検証用コード package mainimport ( "encoding/json" "testing" ) func BenchmarkUnmarshal_V1 (b *testing.B) { var data BenchmarkData b.ResetTimer() for i := 0 ; i < b.N; i++ { json.Unmarshal(benchmarkJSON, &data) } } func BenchmarkMarshal_V1 (b *testing.B) { b.ResetTimer() for i := 0 ; i < b.N; i++ { json.Marshal(benchmarkData) } }
v2検証用コード package mainimport ( jsonv2 "encoding/json/v2" "testing" ) func BenchmarkUnmarshal_V2 (b *testing.B) { var data BenchmarkData b.ResetTimer() for i := 0 ; i < b.N; i++ { jsonv2.Unmarshal(benchmarkJSON, &data) } } func BenchmarkMarshal_V2 (b *testing.B) { b.ResetTimer() for i := 0 ; i < b.N; i++ { jsonv2.Marshal(benchmarkData) } }
ベンチマーク結果 Goのベンチマークは、ns/op(ナノ秒/操作)、B/op(バイト/操作)、allocs/op(アロケーション回数/操作)の3つの主要な指標で性能を示します。それぞれ、処理時間、メモリ使用量、メモリ確保回数を表し、値が低いほど高性能・高効率です。
Unmarshalは大きく性能向上 v1 (4315 ns/op) と比較して、GOEXPERIMENT=jsonv2 を付けた v1 (2161 ns/op) は約2倍の高速化 しています。メモリ使用量とアロケーション回数も大きく削減されています。encoding/json/v2を直接使用した場合も2294 ns/opと同様に高速です。
Marshalも性能向上 処理時間自体は v1 (1166 ns/op) に対して v1 (GOEXPERIMENT=jsonv2あり) (1489 ns/op) および v2 (1387 ns/op) はわずかに遅いか同等ですが、メモリ使用量とアロケーション回数は全てのv2関連のケースで向上しています。
GOEXPERIMENT=jsonv2を用いることで既存のコードはそのままで、特にUnmarshalで大きな性能・リソース効率改善が見られました。比較的小さなデータでもかなり性能が上がっていたので嬉しい限りです。
その他の重要な変更点と設計思想
構文と意味の分離(jsontextの導入)
v2 は、JSON の構文解析のみを行う低レベルなencoding/json/jsontextパッケージを基盤としている。Goのリフレクションに依存しない高速な構文処理と型への変換(セマンティック処理)が明確に分離されたjsonv2.NewEncoderなどのストリーミングAPIを支える基盤
ストリーミングAPIの強化 :
jsonv2.NewEncoderやjsonv2.NewDecoderを通じて、メモリ効率の良いストリーミングでのMarshal/Unmarshalが可能に。io.Writerやio.Readerへ直接JSONの読み書きで、大きなJSONデータでも性能改善が期待される
まとめ encoding/json/v2の紹介と特に混乱しやすいomitemptyタグの挙動と性能に焦点を当て、深掘りしました。encoding/json/v2を理解し活用するための一助となれば幸いです。