JSON向けの新しい実験的Go API

A new experimental Go API for JSON by Joe Tsai, Daniel Martí, Johan Brandhorst-Satzkorn, Roger Peppe, Chris Hines, and Damien Neil

はじめに

JavaScript Object Notation (JSON)はシンプルなデータ交換フォーマットです。 約15年前、私たちはGoにおけるJSONのサポートについて書き、Goの型とJSONデータの間でシリアライズ・デシリアライズする機能を導入しました。 それ以来、JSONはインターネットで最も普及したデータフォーマットになりました。 Goプログラムによって広く読み書きされており、encoding/jsonは今やGoパッケージの中でインポート数が5番目に多いパッケージにランクされています。

パッケージは利用者のニーズに合わせて時間とともに進化していくものであり、encoding/jsonも例外ではありません。 この記事では、Go 1.25で導入された実験的なencoding/json/v2とencoding/json/jsontextパッケージについて取り上げます。 これらは長らく待ち望まれていた改善と修正をもたらすものです。 この記事では、新しいメジャーAPIバージョンが必要である理由を説明し、新しいパッケージの概要を示し、それをどのように利用できるかを解説します。 実験的なパッケージはデフォルトでは公開されておらず、今後APIが変更される可能性があります。

encoding/jsonの問題点

全体として、encoding/jsonはこれまでうまく機能してきました。 任意のGoの型を、あるデフォルトの表現でJSONとの間でマーシャル・アンマーシャルするという考え方は、その表現をカスタマイズできる能力と組み合わさることで、非常に柔軟であることが証明されています。 しかし、導入から数年が経ち、さまざまなユーザーが数多くの欠点を指摘してきました。

振る舞いの欠陥

encoding/jsonにはさまざまな振る舞い上の欠陥があります。

  • JSON構文の扱いが不正確:長年にわたり、プログラム同士が正しく通信できるようにするため、JSONの標準化が進んできました。一般的に、デコーダーは曖昧な入力を拒否する方向でより厳格になり、2つの実装が同一のJSON値に対して異なる(それぞれ成功した)解釈をしてしまう可能性を減らそうとしています。
    • encoding/jsonは現在、不正なUTF-8を受け入れていますが、最新のインターネット標準(RFC 8259)は有効なUTF-8を要求しています。デフォルトの振る舞いとしては、サイレントなデータ破損(後段で問題を引き起こしかねません)を招く代わりに、不正なUTF-8が存在する場合にはエラーを報告するべきです。
    • encoding/jsonは現在、重複したメンバー名を持つオブジェクトを受け入れています。RFC 8259は重複した名前の扱いを規定していないため、実装は任意の値を選ぶ、値をマージする、値を破棄する、あるいはエラーを報告するといった振る舞いを自由に選べます。重複した名前が存在するJSON値には、普遍的に合意された意味がありません。これは攻撃者によってセキュリティアプリケーションで悪用される可能性があり、実際に過去にも悪用されたことがあります(CVE-2017-12635のように)。デフォルトの振る舞いは安全性を優先し、重複した名前を拒否するべきです。
  • スライスとマップのnil性の漏出:JSONは、JSONの配列やオブジェクトが期待されるデータ型にnullをアンマーシャルすることを許さないJSON実装を持つプログラムとの通信によく使われます。encoding/jsonはnilのスライスやマップをJSONのnullとしてマーシャルするため、他の実装でアンマーシャルする際にエラーを引き起こすことがあります。ある調査によれば、多くのGoユーザーはnilのスライスやマップがデフォルトで空のJSON配列やオブジェクトとしてマーシャルされることを望んでいます。
  • 大文字小文字を区別しないアンマーシャル:アンマーシャル時、JSONオブジェクトのメンバー名は大文字小文字を区別しないマッチングによってGoの構造体フィールド名に解決されます。これは驚くべきデフォルトの挙動であり、潜在的なセキュリティ上の脆弱性であり、パフォーマンス上の制約でもあります。
  • 一貫性のないメソッド呼び出し:実装上の詳細により、ポインタレシーバーで宣言されたMarshalJSONメソッドはencoding/jsonによって一貫性なく呼び出されます。これはバグとみなされていますが、あまりに多くのアプリケーションが現在の振る舞いに依存しているため修正できません。

APIの不備

encoding/jsonのAPIは扱いにくい、あるいは制約が多い場合があります。

  • io.Readerから正しくアンマーシャルするのは困難です。ユーザーはしばしば json.NewDecoder(r).Decode(v) と書きますが、これでは入力の末尾にある余分なデータを拒否できません。
  • オプションはEncoderやDecoder型に設定できますが、MarshalやUnmarshal関数では使えません。同様に、MarshalerやUnmarshalerインターフェースを実装する型はオプションを利用できず、呼び出しスタックの下方にオプションを伝える方法もありません。たとえば、Decoder.DisallowUnknownFieldsオプションは、カスタムのUnmarshalJSONメソッドを呼び出す際にはその効果を失います。
  • Compact、Indent、HTMLEscape関数は、[]byteやio.Writerのような柔軟なものではなく、bytes.Bufferに書き込みます。これはこれらの関数の使い勝手を制限しています。

パフォーマンスの制限

内部の実装詳細を脇に置いても、公開APIそのものが一定のパフォーマンス上の制約を課しています。

  • MarshalJSON:MarshalJSONインターフェースメソッドは、実装が返す[]byteを必ずアロケートすることを強います。また、この仕様上、encoding/jsonはその結果が有効なJSONであることを検証し、指定されたインデントに合わせて再フォーマットする必要があります。
  • UnmarshalJSON:UnmarshalJSONインターフェースメソッドは、(後続のデータを含まない)完全なJSON値が渡されることを要求します。このため、encoding/jsonはUnmarshalJSONを呼び出す前に、アンマーシャル対象のJSON値がどこで終わるかを判断するため、その全体を解析しなければなりません。その後、UnmarshalJSONメソッド自体が、渡されたJSON値をもう一度解析することになります。
  • ストリーミングの欠如:EncoderとDecoder型はio.Writerやio.Readerに対して動作しますが、JSON値全体をメモリ上にバッファリングします。個々のトークンを読み取るDecoder.Tokenメソッドは多くのアロケーションを伴い、トークンを書き込むための対応するAPIは存在しません。

さらに、MarshalJSONやUnmarshalJSONメソッドの実装がMarshalやUnmarshal関数を再帰的に呼び出す場合、パフォーマンスは二次関数的に悪化します。

encoding/jsonを直接修正する試み

パッケージの非互換な新しいメジャーバージョンを導入することは、重い決断です。可能であれば、既存のパッケージを修正することを試みるべきです。

新しい機能を追加するのは比較的簡単ですが、既存の機能を変更するのは困難です。残念ながら、これらの問題は既存のAPIに内在するものであり、Go 1互換性の約束の範囲内で修正することは事実上不可能です。

原理的には、MarshalV2やUnmarshalV2のような別名を宣言することもできますが、それは同じパッケージの中に並行した名前空間を作るようなものです。そこで私たちはencoding/json/v2(以降v2と呼びます)にたどり着きました。ここでは、encoding/json(以降v1と呼びます)とは別のv2という名前空間の中でこうした変更を行えます。

encoding/json/v2の計画

encoding/jsonの新しいメジャーバージョンの計画には、何年もの歳月がかかりました。2020年後半、既存パッケージの問題を修正できないことに突き動かされ、encoding/jsonのメンテナーの一人であるDaniel Martíが最初に、仮想的なv2パッケージがどのようなものであるべきかについての考えを書き起こしました。それとは別に、Protocol Buffers向けのGo APIに取り組んだ後、Joe Tsaiはprotojsonパッケージが独自のJSON実装を必要とすることに落胆していました。というのも、encoding/jsonは、Protocol Buffer仕様が要求するより厳格なJSON標準に準拠することも、ストリーミング方式で効率的にJSONをシリアライズすることも、どちらもできなかったからです。

JSONにとってより明るい未来は有益であり、実現可能でもあると信じ、DanielとJoeは力を合わせてv2の構想を練り、プロトタイプの構築を始めました(最初のコードは、GoのprotobufモジュールにあったJSONシリアライズロジックを磨き上げたものでした)。その後、何人か(Roger Peppe、Chris Hines、Johan Brandhorst-Satzkorn、Damien Neil)が、設計レビュー、コードレビュー、回帰テストの提供という形でこの取り組みに加わりました。初期の議論の多くは、私たちの録画された会議会議メモで公開されています。

この取り組みは当初から公開されており、私たちは徐々により広いGoコミュニティを巻き込んでいきました。まずGopherConでの講演2023年後半に投稿されたディスカッション2025年前半に投稿された正式な提案、そして最近では、より広い規模ですべてのGoユーザーにテストしてもらうためにencoding/json/v2をGoの実験機能として採用しました(Go 1.25で利用可能です)。

v2の取り組みは5年間続いており、多くの貢献者からのフィードバックを取り込み、また実運用環境での利用から貴重な経験的知見も得てきました。

特筆すべきは、これが主にGoogleに雇用されていない人々によって開発・推進されてきたという点です。これはGoプロジェクトが、Goのエコシステムを改善することに専念する活気あるグローバルコミュニティを伴う、協働的な取り組みであることを示しています。

encoding/json/jsontextという基盤

v2のAPIについて議論する前に、まずはGoにおけるJSONの今後の改善の土台となる、実験的なencoding/json/jsontextパッケージを紹介します。

GoにおけるJSONのシリアライズは、大きく2つの主要な要素に分解できます。

  • JSONをその文法に基づいて処理することに関心を持つ 構文的機能
  • JSON値とGoの値との関係を定義する 意味的機能

私たちは、構文的機能を説明するのに「エンコード」「デコード」という言葉を、意味的機能を説明するのに「マーシャル」「アンマーシャル」という言葉を使います。純粋にエンコードに関する機能と、マーシャルに関する機能とを明確に区別することを目指しています。

v1、jsontext、json/v2の関係を示すAPI概観図

この図はこの分離の概要を示しています。紫のブロックは型を、青のブロックは関数やメソッドを表しています。矢印の向きはおおよそデータの流れを表しています。図の下半分はjsontextパッケージによって実装されており、構文のみに関わる機能を含んでいます。一方、上半分はjson/v2パッケージによって実装されており、下半分が扱う構文的なデータに意味を割り当てる機能を含んでいます。

jsontextの基本的なAPIは次の通りです。

package jsontext

type Encoder struct { ... }
func NewEncoder(io.Writer, ...Options) *Encoder
func (*Encoder) WriteValue(Value) error
func (*Encoder) WriteToken(Token) error

type Decoder struct { ... }
func NewDecoder(io.Reader, ...Options) *Decoder
func (*Decoder) ReadValue() (Value, error)
func (*Decoder) ReadToken() (Token, error)

type Kind byte
type Value []byte
func (Value) Kind() Kind
type Token struct { ... }
func (Token) Kind() Kind

jsontextパッケージは、構文レベルでJSONを操作するための機能を提供しており、その名前はRFC 8259のセクション2に由来しています。そこではJSONデータの文法が文字通りJSON-textと呼ばれています。構文レベルでのみJSONを扱うため、Goのリフレクションに依存しません。

EncoderDecoderは、JSONの値とトークンをエンコード・デコードするサポートを提供します。コンストラクタは可変長のオプションを受け取り、エンコードとデコードの特定の振る舞いに影響を与えます。v1で宣言されたEncoderやDecoder型とは異なり、jsontext内の新しい型は構文と意味の区別を曖昧にすることなく、真にストリーミングの形で動作します。

JSON値はデータの完全な単位であり、Goでは名前付きの[]byteとして表現されます。これはv1におけるRawMessageと同一のものです。JSON値は構文的に、1つ以上のJSONトークンから構成されています。JSONトークンは、コンストラクタとアクセサメソッドを備えた不透明なToken型としてGoで表現されます。これはv1におけるTokenと類似していますが、アロケーションなしで任意のJSONトークンを表現できるように設計されています。

MarshalJSONやUnmarshalJSONインターフェースメソッドの根本的なパフォーマンス上の問題を解決するには、トークンと値のストリーミングシーケンスとしてJSONを効率的にエンコード・デコードする方法が必要です。v2では、EncoderやDecoderに対して動作するMarshalJSONToおよびUnmarshalJSONFromインターフェースメソッドを導入し、これらのメソッドの実装が純粋にストリーミングの形でJSONを処理できるようにしています。したがって、jsonパッケージはMarshalJSONが返すJSON値の検証やフォーマットに責任を持つ必要がなくなり、UnmarshalJSONに渡されるJSON値の境界を判断する責任を持つ必要もなくなります。これらの責務はEncoderとDecoderに属することになります。

encoding/json/v2の紹介

jsontextパッケージを基盤として、ここで実験的なencoding/json/v2パッケージを紹介します。これは前述の問題を修正するために設計されていますが、v1パッケージのユーザーにとってなじみ深いものであり続けます。私たちの目標は、v1の使い方をそのままv2に移行しても おおむね 同じように動作することです。

この記事では主にv2の高レベルAPIを取り上げます。使い方の例については、v2パッケージ内の例を参照するか、Anton Zhiyanov氏によるこのトピックを扱ったブログ記事を読むことをお勧めします。

v2の基本的なAPIは次の通りです。

package json

func Marshal(in any, opts ...Options) (out []byte, err error)
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error

func Unmarshal(in []byte, out any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

MarshalUnmarshal関数はv1と似たシグネチャを持ちますが、その振る舞いを設定するオプションを受け取ります。MarshalWriteUnmarshalRead関数はio.Writerやio.Readerに対して直接動作し、そうした型に書き込む・読み込むためだけにEncoderやDecoderを一時的に構築する必要をなくします。MarshalEncodeUnmarshalDecode関数はjsontext.Encoderとjsontext.Decoderに対して動作し、実は前述の関数群の内部実装そのものになっています。v1とは異なり、オプションはマーシャル・アンマーシャル関数それぞれの第一級の引数となっており、v2の柔軟性と設定可能性を大きく拡張しています。v2にはいくつものオプションが用意されていますが、この記事では取り上げません。

型で指定するカスタマイズ

v1と同様に、v2でも型は特定のインターフェースを満たすことで独自のJSON表現を定義できます。

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}
type MarshalerTo interface {
    MarshalJSONTo(*jsontext.Encoder) error
}

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}
type UnmarshalerFrom interface {
    UnmarshalJSONFrom(*jsontext.Decoder) error
}

MarshalerUnmarshalerインターフェースはv1のものと同一です。新しいMarshalerToUnmarshalerFromインターフェースは、型がjsontext.Encoderやjsontext.Decoderを使って自身をJSONとして表現できるようにします。これにより、オプションはEncoderやDecoderのOptionsアクセサメソッド経由で取得できるため、呼び出しスタックの下方にオプションを伝えることが可能になります。

JSONオブジェクトのメンバーの順序を保持するカスタム型を実装する方法については、OrderedObjectの例を参照してください。

呼び出し側で指定するカスタマイズ

v2では、MarshalとUnmarshalの呼び出し側も、任意の型に対してカスタムのJSON表現を指定できます。呼び出し側が指定した関数は、型で定義されたメソッドや、その型のデフォルトの表現よりも優先されます。

func WithMarshalers(*Marshalers) Options

type Marshalers struct { ... }
func MarshalFunc[T any](fn func(T) ([]byte, error)) *Marshalers
func MarshalToFunc[T any](fn func(*jsontext.Encoder, T) error) *Marshalers

func WithUnmarshalers(*Unmarshalers) Options

type Unmarshalers struct { ... }
func UnmarshalFunc[T any](fn func([]byte, T) error) *Unmarshalers
func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers

MarshalFuncMarshalToFuncは、WithMarshalersを使ってMarshalの呼び出しに渡せるカスタムマーシャラーを構築し、特定の型のマーシャルを上書きします。同様に、UnmarshalFuncUnmarshalFromFuncは、Unmarshalに対して同様のカスタマイズをサポートします。

ProtoJSONの例は、この機能によってproto.Messageのすべての型のシリアライズをprotojsonパッケージで処理できるようになる様子を示しています。

振る舞いの違い

v2はv1と おおむね 同じように振る舞うことを目指していますが、v1の問題に対処するために、その振る舞いはいくつかの点で変更されています。中でも特に顕著な点は次の通りです。

  • v2は不正なUTF-8が存在する場合にエラーを報告します。
  • v2はJSONオブジェクトに重複した名前が含まれる場合にエラーを報告します。
  • v2はnilのGoのスライスやマップを、それぞれ空のJSON配列やJSONオブジェクトとしてマーシャルします。
  • v2はJSONオブジェクトをGoの構造体にアンマーシャルする際、JSONのメンバー名とGoのフィールド名との間で大文字小文字を区別したマッチングを使用します。
  • v2はomitemptyタグオプションを再定義し、フィールドが「空」のJSON値(null""[]{}のいずれか)としてエンコードされる場合にそのフィールドを省略するようにします。
  • v2は、現在デフォルトの表現を持たないtime.Durationをシリアライズしようとするとエラーを報告しますが、呼び出し側がそれを決定できるオプションを提供します。

ほとんどの振る舞いの変更については、v1あるいはv2のセマンティクスで動作するように、あるいはそれ以外の呼び出し側が決定した振る舞いで動作するように設定できる構造体タグオプションや呼び出し側指定のオプションが存在します。詳しくは「v2への移行」を参照してください。

パフォーマンスの最適化

v2のMarshalのパフォーマンスは、おおむねv1と同等です。v2の方がわずかに速い場合もあれば、わずかに遅い場合もあります。v2のUnmarshalのパフォーマンスはv1よりも大幅に高速で、ベンチマークでは最大10倍の改善が確認されています。

さらなるパフォーマンス向上を得るためには、既存のMarshalerUnmarshalerの実装を、MarshalerToUnmarshalerFromも実装するように移行し、純粋にストリーミングの形でJSONを処理できるようにする必要があります。たとえば、KubernetesのあるサービスではUnmarshalJSONメソッド内でOpenAPI仕様を再帰的にパースすることがパフォーマンスを著しく悪化させていましたが(kubernetes/kube-openapi#315参照)、UnmarshalJSONFromに切り替えることでパフォーマンスは桁違いに改善しました。

詳細については、go-json-experiment/jsonbenchリポジトリを参照してください。

encoding/jsonをさかのぼって改善する

私たちはGoの標準ライブラリに2つの別々のJSON実装を持つことを避けたいと考えているため、内部的にv1がv2を用いて実装されていることが重要です。

このアプローチには複数の利点があります。

  1. 段階的な移行:v1あるいはv2のMarshalとUnmarshal関数は、v1あるいはv2のセマンティクスに従って動作する一連のデフォルトの振る舞いを表しています。オプションを指定することで、MarshalやUnmarshalを、完全にv1、大部分がv1で一部v2、v1とv2の混合、大部分がv2で一部v1、あるいは完全にv2のセマンティクスで動作するように設定できます。これにより、2つのバージョンのデフォルトの振る舞いの間を段階的に移行できます。
  2. 機能の継承:後方互換性のある機能がv2に追加されると、それらは自動的にv1でも利用できるようになります。たとえば、v2はinlineやformatといったいくつかの新しい構造体タグオプションのサポートや、より高性能かつ柔軟なMarshalJSONToおよびUnmarshalJSONFromインターフェースメソッドのサポートを追加しています。v1がv2を用いて実装されることで、v1もこれらの機能のサポートを継承します。
  3. メンテナンスの削減:広く使われているパッケージのメンテナンスには多大な労力が必要です。v1とv2が同じ実装を使うことで、メンテナンスの負担が軽減されます。一般に、1つの変更で両方のバージョンのバグ修正、パフォーマンス改善、機能追加が行われます。v2の変更に対応するv1の変更を別途バックポートする必要はありません。

v1の一部が将来的に非推奨になる可能性はありますが(v2が実験段階を卒業した場合の話です)、パッケージ全体が非推奨になることは決してありません。v2への移行は推奨されますが、必須ではありません。Goプロジェクトはv1のサポートを打ち切ることはありません。

jsonv2を試す

encoding/json/jsontextとencoding/json/v2パッケージの新しいAPIは、デフォルトでは公開されていません。これらを使うには、環境変数にGOEXPERIMENT=jsonv2を設定するか、goexperiment.jsonv2ビルドタグを付けてコードをビルドしてください。実験機能である以上、APIは不安定で将来変更される可能性がありますが、実装自体の品質は高く、いくつかの主要なプロジェクトですでに実運用で使われ実績を積んでいます。

v1がv2を用いて実装されているという事実は、jsonv2実験の下でビルドした場合、v1の内部実装がまったく異なるものになることを意味します。コードを変更することなく、jsonv2の下でテストを実行でき、理論上は新たに失敗するテストはないはずです。

GOEXPERIMENT=jsonv2 go test ./...

v2を用いたv1の再実装は、Go 1互換性の約束の範囲内で同一の振る舞いを提供することを目指していますが、エラーメッセージの正確な文言など、一部の違いが観測される可能性があります。jsonv2の下でテストを実行し、回帰があればissue trackerで報告していただくことを推奨します。

Go 1.25で実験機能になったことは、encoding/json/jsontextとencoding/json/v2を正式に標準ライブラリへ採用するための道のりにおける重要なマイルストーンです。しかし、jsonv2実験の目的は、より広範な経験を得ることにあります。皆さんのフィードバックが私たちの次のステップを左右し、この実験の結果は、この取り組みの断念から、Go 1.26での安定パッケージとしての採用まで、さまざまな可能性があります。ぜひgo.dev/issue/71497で皆さんの経験を共有し、Goの未来を決める手助けをしてください。

By Joe Tsai, Daniel Martí, Johan Brandhorst-Satzkorn, Roger Peppe, Chris Hines, and Damien Neil