世界を動かす技術を、日本語で。

新しい実験的なGoのJSON用API

概要

  • Go 1.25で新たに導入された encoding/json/v2encoding/json/jsontext パッケージの紹介
  • 既存の encoding/json パッケージの課題とその解決策の提案
  • jsontext パッケージの役割とAPI概要
  • v2パッケージの開発背景とコミュニティ主導の経緯
  • 新APIの利用方法と今後の展望

GoのJSON対応の進化:encoding/json/v2とjsontextの登場

  • JSON はインターネット上で最も広く使われるデータフォーマットであり、Go言語でも多く利用される現状
  • 既存の encoding/json (v1)は柔軟性が高い一方、標準化やセキュリティ、パフォーマンス面で多くの課題
    • 無効なUTF-8の許容や、重複メンバー名の処理不備
    • nilスライス・マップの扱い、ケース非依存のフィールド解決
    • メソッド呼び出しの不整合、APIの使い勝手の悪さ
    • ストリーミング処理の非効率性やメモリ消費
  • 既存APIの後方互換性維持のため、直接の修正が困難
    • 新バージョンとして encoding/json/v2 の開発を決断
  • v2の計画は5年以上にわたり、Goコミュニティ主導で進行
    • Daniel MartíやJoe Tsaiらによる設計・実装
    • GopherConでの発表や公開ミーティング、提案文書を経てGo 1.25で実験的導入

jsontextパッケージの基礎とAPI概要

  • jsontext はJSONの構文レベルの処理専用パッケージ
    • 構文(エンコード/デコード)と意味(マシャル/アンマシャル)を明確に分離
  • 主な型と関数
    • Encoder/Decoder :io.Writer/io.Readerベースでストリーミング処理
      • NewEncoder(io.Writer, ...Options) *Encoder
      • NewDecoder(io.Reader, ...Options) *Decoder
    • Value 型:JSON値の[]byte表現(v1のRawMessageと同等)
    • Token 型:JSONトークンの効率的な表現(アロケーション削減設計)
  • オプション による動作カスタマイズが可能
  • v1とは異なり、構文と意味付けを混同せず、純粋なストリーミング処理を実現

v2 APIの特徴と改善点

  • MarshalJSONTo/UnmarshalJSONFrom インターフェースでストリーミング指向の拡張
    • これによりバリデーションやフォーマット調整の責任をEncoder/Decoderに分離
  • v1の既存問題(パフォーマンス、セキュリティ、カスタマイズ性)を根本から解決
  • v1からv2への移行も容易に設計、既存ユーザーへの配慮

今後の展望とコミュニティへの呼びかけ

  • encoding/json/v2jsontext は実験的な段階であり、API変更の可能性あり
  • 広範なユーザーテストとフィードバックを通じて安定版へ発展予定
  • Google以外の開発者主導で進化するGoエコシステムの象徴例

Go 1.25以降、 encoding/json/v2jsontext の活用により、より安全で高速・柔軟なJSON処理が可能となる。今後もコミュニティの参加とフィードバックが、GoのJSONエコシステムの進化を後押しする。

Hackerたちの意見

ベンチマーク分析:Sonic vs Standard JSON vs JSON v2 in Go https://github.com/centralci/go-benchmarks/tree/b647c45272c7...

確か、sonicはJITやインラインアセンブリ(GitHubによると41%)を使ってて、かなり大きいんだよね。監査するのは無理だと思う。もしJSONパーサーから毎秒のCPUサイクルを絞り出す必要がなければ(ほとんどの人はそうだし、そもそもGoはそんなパフォーマンスを求める選択肢じゃない)、もっとシンプルな実装にした方がいいと思うよ。

その数字、goccyに似てるね。昔使ってたけど、Kubernetesでも直接依存してるし、でも問題がかなり積み重なってきたから、もう信頼できない。どうやら両方ともGoの限界で動いてるみたい。個人的には、JSONはGoのコアにあって、SIMD最適化されたCコードで実装されるべきだと思う。今のウェブではJSONがすごく重要な部分だから、もっと丁寧に扱われるべきだよね。

まず第一に、それはJSON v2を全く使ってないと思う。第二に、Sonicはバイトスライスを文字列に(unsafeに)キャストするのにunsafeを使ってるみたいで、もちろんそれは正しくやるよりも速いけど、正しくやることと比べると全然比較にならないよね。ほとんどのベンチマークデータがHNに投稿されてるけど、あれは信頼できないから無視して。

Sonicの「最先端」最適化でも、基本的な使い方ではarm64のstd Jsonより遅いんだよね。JITやSIMD、低レベルのコードは全プラットフォームのメンテナンスコストがかかることを示してる。

null != nil !!! この問題に対する部分的な解決策が見られて嬉しい。ほとんどの言語でこの問題に悩まされてて、ちょっとした曖昧さを生んでるから、いつかトラブルになるよね。皮肉なことに、JavaScriptは面白いnullundefinedがあって、この問題はないんだよね。ほとんどの言語のJSONパーサーやエミッターは、「JSON null」のために特別な値を使うべきだと思う。

1976年にMLで修正され、2005年にEiffelでもフォローされたけど、残念ながらまだ一般的にはなってないね。

nullとundefinedは、空っぽや欠けてる意味合いでいいと思うよ(特に、==で比較することが多いから)。undefinedなキーと未定義なキーが似てるのに違うってのが、もっと大きな問題だな。obj['key']=undefinedがdelete obj['key']と同じだったら、むしろそっちの方がいいかも。

時間が経つにつれて、パッケージはユーザーのニーズに応じて進化し、encoding/jsonも例外ではありません。 いや、これは例外だよ。最初からデザインが悪かったんだ。人々のJSONのニーズ(ほとんど変わってない)を超えてしまったわけじゃないんだよ。

悪いデザインだからって、君が引用した文が無効になるわけじゃないよ。時間が経つにつれて、JSONパッケージがユーザーのニーズを満たしていないことが明らかになって、その結果としてパッケージは進化したんだ。進化の大きさは関係ない。

まあ、ほとんどの人はソフトウェアを使ったり作ったりすることで欠点を学んで、新しいバージョンを作ることが多いよね。君の場合、v1は毎回完璧みたいだね。

誰か、これを高レベルでざっくり説明してくれない?私はgodevじゃないから。GoのJSONライブラリがネイティブのGo構造体をJSONにエンコードするサポートがあるみたいで、クールだけど、もしかしたらそれが悪かったのかな?それで合ってる?

GoにはすでにJSONパーサーとシリアライザーがあるよ。JSのAPIに似てて、いくつかのオブジェクトをJSON.stringifyに渡すと、シリアライズしてくれる。逆に、文字列を渡すとJSON.parseからオブジェクト(または文字列など)を得られる。型自体も、自分のJSON変換コードをカスタマイズする方法があるんだ。構造体が自分自身を文字列にシリアライズしたり、配列が変なことをしたり、何でもできる。JSONモジュールは、カスタム実装があればそれを呼び出すんだけど、今のやり方はクソだね。シリアライズをカスタマイズしたいなら、基本的にJSON文字列を返さなきゃいけない。その後、シリアライザーはちゃんとしたものが返されたか確認しなきゃいけないし、JSONオプションがあったかどうかもわからない。インデント設定とかがあるかもしれないし。いや、バイト配列を返すだけ。デシリアライズもクソで、a) またオプションがない。b) パーサーが解析するためのバイト配列を送ってくる。これ、JSON文字列があるんだけど、解析してくれ。もしそのJSON文字列が100MBあったら、残念だけど、全部読み込んで再割り当てしないといけない。解析できるのはバイト配列だけだから。新しいAPIはこれを改善してるよ。デコーダーやエンコーダーを提供してくれて、上からオプションを持ってるし、データをストリーミングできる。例えば、10GBの配列を値ごとにシリアライズしながら、基盤のライターがディスクに書き込むことができる。古いAPIが最初にメモリに全部割り当てるのを強制するのとは違ってね。他にも改善点はあるけど、投稿は主にこれに焦点を当ててるから、そこからの情報だよ(ちなみに新しいAPIは試してないから、間違ってるところがあるかも)。

いや、既存の実装は結構いい感じなんだけど、全てのユースケースに対応してるわけじゃないし、修正が難しい欠陥もある。でも、多くのユースケースではすごくうまく機能してるよ。で、古いライブラリの構造的な問題を解決する新しい実装が出たんだ(主に大きなJSONドキュメントのストリーミングが問題だった)。

主な問題はBehavior differencesにあるよ。最大の問題は、Golangにおけるnilの扱いや、何をJSONに変換するか、その逆についてだった。* v2では、無効なUTF-8文字に対してエラーを投げるようになった(以前は黙って受け入れてた)。これにより、サーバーに送信する前にJSONを前処理または再処理する必要があった。* Golangのnilは、JSONの空の配列またはマップに変換される(各タイプごとに)。以前はJSONのnullに変換されてた。* JSONのフィールド名は、Golangの名前にケースセンシティブで変換される。以前はケースに敏感じゃなくて、小文字にされてたから、フィールドが衝突することが多かった(例えば、JSONにbankNameとbanknameがある場合)。* omitemptyは問題があった。例えば、Golangのamount: nilは、JSONでは{}としてフィールドを省略することを意味してたけど、amount: 0も{ amount: 0 }として省略されるのは驚きだった。新しいomitemptyはnilと空の配列/hashmapに対してのみ機能するようになって、0やfalseにはもう適用されない。新しいomitzeroタグがそれ用にあるよ。

encoding/jsonはnilスライスやマップをJSONのnullとしてマシュアルするけど、それがv1のデザインにどうやって入ったの?

ある人とその挙動を変えたくないってやり取りをしたんだけど、彼の理由は、空のマップやスライスを作って提供できるから、マシュアラーがそれをやる必要はないし、その挙動を無効にする方法も必要ないっていうのが、余計な複雑さだってことだった。

nilマップがnullじゃないってどういうこと?確かに、ゼロ値のマップではないし、それは{}だよ。

まあ、それは違うことだよね?空のスライスやマップはnilとは違うから、nil = nullで、[]string{} = []ってのはすごく理にかなってるし、両方使えるオプションがあるのも納得できる。ただ、Goで作業すると、APIがほとんど同じように扱うから(append、len、[])、そうなるとあまり意味がなくなってくる。だから、こうなった理由はそんな感じかな。あと、nilマップが空のオブジェクトになったから、カスタムマシュアラーがないnil構造体にもそれが適用されるべきじゃない?結局、nilじゃなければオブジェクトになるんだから…。

なんでそうならないの?nilはnullだし、空の配列は空の配列で、全然違うオブジェクトだよ。

これが通ったら、LLMがまだv1 APIしか持ってない中で、どんな採用状況になるのか気になるな。

エラーが出始めたら、みんなドキュメントがあることを思い出して参照してくれるといいな。

これだけは言いたいし、真実だと思う。GoでJSONを扱うのは面倒くさい。JSONを書けるべきで、マシュリングやアンマシュリングを気にしなくていいはずなんだ。これはserde rustの動作や、他のほとんどの言語でも同じで、複数のライターがいるときにこの挙動を管理するのが複雑になるんだよね。

serde rust それはかなりクリーンに見えるね。昨日Golangでこれについて愚痴ってたところなんだ(yamlだけど、実質的には同じ問題)。

意味のあるstdlibの改善が見られて嬉しい!GOEXPERIMENT=jsonv2で何千ものユニットテストを一通り実行したけど、全部パスしたよ。(まあ、一つだけエラーメッセージが変わったせいで失敗したけど、それはこっちの問題)特に、構文部分をjsontextパッケージに分けたのが好きだな。すごく理にかなってるし、それを基にいくつかのパーサーを実装して、パフォーマンスを向上させることができると思う。できれば、この機会にomitemptyをやめて、新しく追加されたomitzero(IsZero()でカスタマイズ可能)に切り替えてほしいな。すぐにコードを全部切り替えるつもりだから。二つのタグが似すぎてて、選ぶのが面倒なんだよね。

ちょっと反対意見で熱くなってるかも。GoのJSONライブラリを使い始めてからもう10年以上経つけど、Go 1.0の前からだから、v1は基本的には問題ないと思ってる。二つの不満があるんだけど、デコーダーがちょっと遅いんだよね。PHPのデコーダーには全然敵わない。それに、定義してないアイテムを構造体に追加できる簡単な「キャッチオール」マップがあればいいなと思う。その他の「解決」された問題は、私には一度も問題じゃなかったし、ここでの「解決策」はすごく複雑なAPIになってる。正直、v2を作るのは無駄だと思う。みんなが求めてることは、既存のシステムの動作を変える構造体タグで解決できるはずだし、後方互換性も保てると思う。私の考えはこんな感じ。構造体/スライスのマージ問題?そもそも、汚れた構造体やスライスにデコードするべきじゃないと思う。それはサポート外、未定義の動作だって宣言して、さっさと進めばいい。期間を文字列で?なんで?それはただ気持ち悪い。デフォルトで大文字小文字を区別する?まあ、構造体タグで大文字小文字を区別するのを追加すればいいだけだし。v1で簡単に修正できる。部分デコード?これはニッチすぎて、第三者のライブラリに任せるべきだと思う。基本的に、すべてが後方互換性を持たせた方法でできたはずだ。ロブ・パイクはこれを全然好まないだろうし、すごくGoらしくない感じがする。Goの「悪い方が良い」っていう考えに反してるよね。