フューチャー技術ブログ

Go 1.25リリース連載 encoding/json/v2(experimental)

はじめに

こんにちは。製造エネルギー事業部の後藤です。
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=jsonv2omitemptyの挙動は変わらないのか?

Go 1.25 のリリースノートには、GOEXPERIMENT=jsonv2 を有効にすると、既存の encoding/json パッケージが新しい実装に切り替わるものの「Marshal/Unmarshal の基本的な挙動は変わらない」と明記されています。

omitempty タグもそのまま機能するなら、既存コードへの影響は少ないはず」と思い以下のコードを使って確認しました。

package main

import (
"encoding/json"
"fmt"
"log"
)

// OmitEmptyBehaviorTestStruct 構造体は、様々な型とomitemptyタグの組み合わせを網羅し、
// GoのJSON Marshal挙動を検証するために使用します。
type OmitEmptyBehaviorTestStruct struct {
// --- omitemptyなしのフィールド ---

// プリミティブ型(ゼロ値)
NonOmitIntZero int `json:"nonOmitIntZero"` // 0
NonOmitFloatZero float64 `json:"nonOmitFloatZero"` // 0.0
NonOmitBoolZero bool `json:"nonOmitBoolZero"` // false
NonOmitStringEmpty string `json:"nonOmitStringEmpty"` // ""

// 参照型(nil)
NonOmitNilSlice []string `json:"nonOmitNilSlice"` // nilスライス
NonOmitNilMap map[string]string `json:"nonOmitNilMap"` // nilマップ
NonOmitNilPointer *int `json:"nonOmitNilPointer"` // nilポインタ

// 参照型(空値 - nilではないが要素がない)
NonOmitEmptySlice []string `json:"nonOmitEmptySlice"` // 空スライス []
NonOmitEmptyMap map[string]string `json:"nonOmitEmptyMap"` // 空マップ {}

// --- omitemptyありのフィールド ---

// プリミティブ型(ゼロ値)
OmitIntZero int `json:"omitIntZero,omitempty"` // 0
OmitFloatZero float64 `json:"omitFloatZero,omitempty"` // 0.0
OmitBoolZero bool `json:"omitBoolZero,omitempty"` // false
OmitStringEmpty string `json:"omitStringEmpty,omitempty"` // ""

// 特殊なプリミティブ型のエイリアス(ゼロ値)
OmitByteZero byte `json:"omitByteZero,omitempty"` // 0
OmitRuneZero rune `json:"omitRuneZero,omitempty"` // 0

// 参照型(nil)
OmitNilSlice []string `json:"omitNilSlice,omitempty"` // nilスライス
OmitNilMap map[string]string `json:"omitNilMap,omitempty"` // nilマップ
OmitNilPointer *int `json:"omitNilPointer,omitempty"` // nilポインタ

// 参照型(空値 - nilではないが要素がない)
OmitEmptyButNonNilSlice []string `json:"omitEmptyButNonNilSlice,omitempty"` // 空スライス []
OmitEmptyButNonNilMap map[string]string `json:"omitEmptyButNonNilMap,omitempty"` // 空マップ {}
}

func main() {
var nilIntPtr *int = nil // ポインタのゼロ値(nil)

testData := OmitEmptyBehaviorTestStruct{
// omitemptyなしフィールドにゼロ値/nil/空値を設定
NonOmitIntZero: 0,
NonOmitFloatZero: 0.0,
NonOmitBoolZero: false,
NonOmitStringEmpty: "",
NonOmitNilSlice: nil,
NonOmitNilMap: nil,
NonOmitNilPointer: nilIntPtr,
NonOmitEmptySlice: []string{},
NonOmitEmptyMap: map[string]string{},

// omitemptyありフィールドにゼロ値/nil/空値を設定
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) ---")

// GOEXPERIMENT=jsonv2あり/なしで実行
data, err := json.MarshalIndent(testData, "", " ") // Indentで整形

if err != nil {
log.Fatalf("Marshal: %v", err)
}
fmt.Printf("Marshal結果: %s\n", string(data))
// 期待出力:
// - omitemptyなしのフィールドは全て出力される。
// (nil参照型は"null"、空参照型は"[]"や"{}"として出力される)
// - omitemptyありのフィールドは全て省略される。
}

検証結果:変わりなし

このコードを Go 1.25 開発版環境で実行すると、以下の出力が得られます。

// --- 1. GOEXPERIMENT=jsonv2 なしの場合 ---
{
"nonOmitIntZero": 0,
"nonOmitFloatZero": 0,
"nonOmitBoolZero": false,
"nonOmitStringEmpty": "",
"nonOmitNilSlice": null,
"nonOmitNilMap": null,
"nonOmitNilPointer": null,
"nonOmitEmptySlice": [],
"nonOmitEmptyMap": {}
}

// --- 2. GOEXPERIMENT=jsonv2 ありの場合 ---
{
"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 でも同じように検証してみました。

先ほどのコード下記のように変更します。

--- main.go
+++ main.go
@@ -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": [], // v2ではnullでなく[]となる
"nonOmitNilMap": {}, // v2ではnullでなく{}となる
"nonOmitNilPointer": null,
"nonOmitEmptySlice": [],
"nonOmitEmptyMap": {},
"omitIntZero": 0, // v2では出力される
"omitFloatZero": 0, // v2では出力される
"omitBoolZero": false, // v2では出力される
"omitByteZero": 0, // v2では出力される
"omitRuneZero": 0 // v2では出力される
}

encoding/json と encoding/json/v2 のゼロ値の扱い

種別 omitemptyなし omitemptyあり
- encoding/json encoding/json/v2 encoding/json encoding/json/v2
文字列のゼロ値 ("") "" "" - -
数字のゼロ値 (0) 0 0 - 0
boolのゼロ値 (false) false false - false
ポインタがnil null null - -
スライス/マップがnil null [] / {} - -
スライス/マップが空値([]/{}) [] / {} [] / {} - -

encoding/json/v2 の omitemptyの挙動が変わった理由(#71497)

挙動変更の背景にある議論の中心は、GoのIssue#71497です。このIssueで議論された変更理由を要約します。

omitemptyの課題とv2での変更の意図

v1omitempty オプションは、「フィールドがGoのゼロ値(false0nilポインタ、nilインターフェース値、または空の配列、スライス、マップ、文字列)である場合にフィールドを省略する」と定義されていました。便利さの反面以下のような課題がありました。

  • omitemptyの省略ルールが、JSONの型システムではなくGoの型システムに密接に結びついている
  • JSONの文脈で「数値の0」と「値が存在しないnull」を区別したい場合や、「空の配列[]」と「存在しないことを示すnull」を区別したい場合に、omitemptyが強制的に省略してしまう

v2ではomitemptyの定義をよりJSONの型システムに沿ったものへと変更しています。そのためv2omitemptyは、v1とは異なります。

v2v1omitemptyが提供していた「あらゆるゼロ値を省略する」という機能を実現するためには、jsonv2.OmitZeroStructFields(true)を利用します。

性能検証

encoding/json/v2の導入目的の一つは、性能向上にあります。特にUnmarshal(JSONからGoの構造体への変換)において顕著な速度差が見られるとのことなので検証してみました。

Goの標準ベンチマーク機能を使って、encoding/jsonencoding/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 main

import (
"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 main

import (
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つの主要な指標で性能を示します。それぞれ、処理時間、メモリ使用量、メモリ確保回数を表し、値が低いほど高性能・高効率です。

ベンチマーク名 ns/op (処理時間) B/op (メモリ使用量) allocs/op (メモリ確保回数)
Unmarshal_V1 4315 512 26
Unmarshal_V1 (GOEXPERIMENT=jsonv2あり) 2161 112 8
Unmarshal_V2 2294 368 10
Marshal_V1 1166 672 11
Marshal_V1 (GOEXPERIMENT=jsonv2あり) 1489 576 9
Marshal_V2 1387 512 5

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.NewEncoderjsonv2.NewDecoderを通じて、メモリ効率の良いストリーミングでのMarshal/Unmarshalが可能に。io.Writerio.Readerへ直接JSONの読み書きで、大きなJSONデータでも性能改善が期待される

まとめ

encoding/json/v2の紹介と特に混乱しやすいomitemptyタグの挙動と性能に焦点を当て、深掘りしました。encoding/json/v2を理解し活用するための一助となれば幸いです。