From 091584830bc3fcf4e9a62e15f73165782476ea28 Mon Sep 17 00:00:00 2001 From: Pete Steyert-Woods Date: Tue, 1 Sep 2026 12:52:38 +0100 Subject: [PATCH] feat(codec/json): add opt-in encoding/json/v2 codec behind jsonv2 tag As an experiment, with a thought for how Gin might eventually migrate to and use features from jsonv2, I have put together a new json/codec for json/v2. --- Selected with -tags jsonv2, and constrained to go1.27 && goexperiment.jsonv2 since encoding/json/v2 is still gated by the experiment in 1.27 and GOEXPERIMENT=nojsonv2 makes it unimportable. json.go negates that entire expression, mirroring how it already negates sonic's OS constraint, so -tags jsonv2 on Go 1.25/1.26 falls back to encoding/json rather than leaving API nil. Uses json.DefaultOptionsV1() to keep behaviour identical to the v1 codec: v2's native defaults would render nil slices/maps as []/{} and match member names case-sensitively, silently zeroing fields that previously bound. Fills three v1 gaps v2's API doesn't cover directly: UseNumber has no exported option, so it's reproduced with a custom *any unmarshaler; MarshalWrite omits Encoder.Encode's trailing newline; MarshalIndent becomes jsontext.WithIndentPrefix + WithIndent. --- .github/workflows/gin.yml | 3 +- Makefile | 2 +- codec/json/json.go | 2 +- codec/json/json_benchmark_test.go | 117 +++++++++++++++++++++++ codec/json/json_test.go | 152 ++++++++++++++++++++++++++++++ codec/json/jsonv2.go | 80 ++++++++++++++++ 6 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 codec/json/json_benchmark_test.go create mode 100644 codec/json/json_test.go create mode 100644 codec/json/jsonv2.go diff --git a/.github/workflows/gin.yml b/.github/workflows/gin.yml index 072312dd..87557a4e 100644 --- a/.github/workflows/gin.yml +++ b/.github/workflows/gin.yml @@ -33,13 +33,14 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - go: ["1.25", "1.26"] + go: ["1.25", "1.26", "1.27"] test-tags: [ "", "-tags nomsgpack", '--ldflags="-checklinkname=0" -tags sonic', "-tags go_json", + "-tags jsonv2", "-race", ] include: diff --git a/Makefile b/Makefile index 3c2da3b1..e16d9e79 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ GO_VERSION=$(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2) PACKAGES ?= $(shell $(GO) list ./...) VETPACKAGES ?= $(shell $(GO) list ./... | grep -v /examples/) GOFILES := $(shell find . -name "*.go") -TESTFOLDER := $(shell $(GO) list ./... | grep -E 'gin$$|ginS$$|binding$$|render$$' | grep -v examples) +TESTFOLDER := $(shell $(GO) list ./... | grep -E 'gin$$|ginS$$|binding$$|render$$|codec/json$$' | grep -v examples) TESTTAGS ?= "" .PHONY: test diff --git a/codec/json/json.go b/codec/json/json.go index 2971f42f..7f930cec 100644 --- a/codec/json/json.go +++ b/codec/json/json.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a MIT style // license that can be found in the LICENSE file. -//go:build !jsoniter && !go_json && !(sonic && (linux || windows || darwin)) +//go:build !(jsonv2 && go1.27 && goexperiment.jsonv2) && !jsoniter && !go_json && !(sonic && (linux || windows || darwin)) package json diff --git a/codec/json/json_benchmark_test.go b/codec/json/json_benchmark_test.go new file mode 100644 index 00000000..ddf8a363 --- /dev/null +++ b/codec/json/json_benchmark_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package json_test + +import ( + "io" + "strings" + "testing" + + "github.com/gin-gonic/gin/codec/json" +) + +// These benchmarks compare codecs, and on go1.27 also compare the engine behind +// encoding/json: the jsonv2 GOEXPERIMENT is on by default there, so v1 is itself +// implemented over encoding/json/v2. To see a codec or engine difference, run +// the same benchmark under each configuration and compare with benchstat, e.g. +// +// go test -bench . -count 10 ./codec/json/ > new.txt +// GOEXPERIMENT=nojsonv2 go test -bench . -count 10 ./codec/json/ > old.txt +// benchstat old.txt new.txt +// +// Marshal and Unmarshal do not necessarily move in the same direction, so read +// them separately rather than as one number. + +type benchPayload struct { + ID int `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags"` + Meta map[string]string `json:"meta"` + Nested []benchPayload `json:"nested,omitempty"` +} + +var nested = benchPayload{ + ID: 1, + Name: "gin ", + Tags: []string{"a", "b", "c"}, + Meta: map[string]string{"k1": "v1", "k2": "v2"}, + Nested: []benchPayload{ + {ID: 2, Name: "x", Tags: []string{"z"}}, + {ID: 3, Name: "y", Meta: map[string]string{"q": "r"}}, + }, +} + +// flat carries no map, so it isolates struct and slice encoding from the key +// sorting that marshaling a map requires. +type benchFlat struct { + ID int `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags"` + N float64 `json:"n"` + OK bool `json:"ok"` +} + +var flat = []benchFlat{ + {1, "alpha ", []string{"x", "y"}, 1.5, true}, + {2, "beta", []string{"z"}, 2.25, false}, + {3, "gamma", nil, 3.75, true}, +} + +// Safe at package scope: this is an external test package, so codec/json is +// fully initialised and API is installed before these run. +var nestedJSON, _ = json.API.Marshal(nested) + +func BenchmarkMarshal(b *testing.B) { + for b.Loop() { + if _, err := json.API.Marshal(nested); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkMarshalFlat(b *testing.B) { + for b.Loop() { + if _, err := json.API.Marshal(flat); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkMarshalIndent(b *testing.B) { + for b.Loop() { + if _, err := json.API.MarshalIndent(nested, "", " "); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkUnmarshal(b *testing.B) { + for b.Loop() { + var out benchPayload + if err := json.API.Unmarshal(nestedJSON, &out); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkEncoder(b *testing.B) { + for b.Loop() { + if err := json.API.NewEncoder(io.Discard).Encode(nested); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkDecoder(b *testing.B) { + r := strings.NewReader("") + for b.Loop() { + r.Reset(string(nestedJSON)) + + var out benchPayload + if err := json.API.NewDecoder(r).Decode(&out); err != nil { + b.Fatal(err) + } + } +} diff --git a/codec/json/json_test.go b/codec/json/json_test.go new file mode 100644 index 00000000..3bddbcea --- /dev/null +++ b/codec/json/json_test.go @@ -0,0 +1,152 @@ +// Copyright 2026 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +// Package json_test is an external test package on purpose. Each codec +// installs API from an init function, and package-level variables of an +// in-package test would be initialised before that init runs, leaving API +// nil. An importing package is guaranteed to observe a fully initialised +// codec/json, so tests here see API exactly as the rest of gin does. +package json_test + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gin-gonic/gin/codec/json" +) + +// TestAPIInstalled guards the build constraints across the codec files. Every +// supported combination of tags, toolchain and GOEXPERIMENT must leave exactly +// one codec selected; if the constraints ever fail to overlap, no init runs and +// API stays nil, which builds cleanly and only panics at the first render. +func TestAPIInstalled(t *testing.T) { + require.NotNil(t, json.API, "no codec init ran: build constraints do not cover this configuration") + assert.NotEmpty(t, json.Package) +} + +type payload struct { + Name string `json:"name"` + N int `json:"n"` +} + +func TestMarshal(t *testing.T) { + got, err := json.API.Marshal(payload{Name: "gin", N: 1}) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"gin","n":1}`, string(got)) +} + +// TestMarshalEscapesHTML pins the HTML escaping that Context.JSON relies on to +// stay safe when a response is embedded in a document. PureJSON is the only +// render that opts out, via Encoder.SetEscapeHTML. +func TestMarshalEscapesHTML(t *testing.T) { + got, err := json.API.Marshal(payload{Name: "