mirror of
https://github.com/gin-gonic/gin.git
synced 2026-09-04 22:53:34 +08:00
Merge 091584830bc3fcf4e9a62e15f73165782476ea28 into dcaa4296d111981ffb31ac3eba90bb63e1eb5ab9
This commit is contained in:
commit
c19f798f07
3
.github/workflows/gin.yml
vendored
3
.github/workflows/gin.yml
vendored
@ -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:
|
||||
|
||||
2
Makefile
2
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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
117
codec/json/json_benchmark_test.go
Normal file
117
codec/json/json_benchmark_test.go
Normal file
@ -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 <framework>",
|
||||
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 <a>", []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)
|
||||
}
|
||||
}
|
||||
}
|
||||
152
codec/json/json_test.go
Normal file
152
codec/json/json_test.go
Normal file
@ -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: "<script>&"})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), `\u003cscript\u003e\u0026`)
|
||||
assert.NotContains(t, string(got), "<script>")
|
||||
}
|
||||
|
||||
func TestUnmarshal(t *testing.T) {
|
||||
var got payload
|
||||
require.NoError(t, json.API.Unmarshal([]byte(`{"name":"gin","n":1}`), &got))
|
||||
assert.Equal(t, payload{Name: "gin", N: 1}, got)
|
||||
}
|
||||
|
||||
// TestUnmarshalMatchesNamesCaseInsensitively pins v1's case-insensitive member
|
||||
// matching. encoding/json/v2 matches case-sensitively by default, which would
|
||||
// silently leave fields zeroed with a nil error rather than reporting a problem.
|
||||
func TestUnmarshalMatchesNamesCaseInsensitively(t *testing.T) {
|
||||
var got payload
|
||||
require.NoError(t, json.API.Unmarshal([]byte(`{"NAME":"gin","N":1}`), &got))
|
||||
assert.Equal(t, payload{Name: "gin", N: 1}, got)
|
||||
}
|
||||
|
||||
// TestMarshalNilSliceAndMapAsNull pins v1's rendering of nil slices and maps.
|
||||
// encoding/json/v2 renders them as [] and {} by default, a visible change to
|
||||
// every response body carrying an unset slice or map.
|
||||
func TestMarshalNilSliceAndMapAsNull(t *testing.T) {
|
||||
got, err := json.API.Marshal(struct {
|
||||
S []string `json:"s"`
|
||||
M map[string]string `json:"m"`
|
||||
}{})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"s":null,"m":null}`, string(got))
|
||||
}
|
||||
|
||||
// TestMarshalIndent asserts the exact indentation bytes, split into lines so
|
||||
// the comparison is about layout rather than JSON equality.
|
||||
func TestMarshalIndent(t *testing.T) {
|
||||
got, err := json.API.MarshalIndent(payload{Name: "gin", N: 1}, "", " ")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"{",
|
||||
` "name": "gin",`,
|
||||
` "n": 1`,
|
||||
"}",
|
||||
}, strings.Split(string(got), "\n"))
|
||||
}
|
||||
|
||||
// TestEncoder covers what render.PureJSON depends on: a trailing newline, and
|
||||
// SetEscapeHTML(false) actually disabling escaping.
|
||||
func TestEncoder(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
escapeHTML bool
|
||||
want string
|
||||
}{
|
||||
{"escaped", true, "{\"name\":\"\\u003ca\\u003e\",\"n\":0}\n"},
|
||||
{"unescaped", false, "{\"name\":\"<a>\",\"n\":0}\n"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.API.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(tt.escapeHTML)
|
||||
require.NoError(t, enc.Encode(payload{Name: "<a>"}))
|
||||
assert.Equal(t, tt.want, buf.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoder(t *testing.T) {
|
||||
var got payload
|
||||
require.NoError(t, json.API.NewDecoder(strings.NewReader(`{"name":"gin","n":1}`)).Decode(&got))
|
||||
assert.Equal(t, payload{Name: "gin", N: 1}, got)
|
||||
}
|
||||
|
||||
// TestDecoderDecodesStream pins that consecutive values can be read from one
|
||||
// decoder, which binding relies on for request bodies that are not exhausted
|
||||
// by a single value.
|
||||
func TestDecoderDecodesStream(t *testing.T) {
|
||||
dec := json.API.NewDecoder(strings.NewReader(`{"n":1}{"n":2}`))
|
||||
for _, want := range []int{1, 2} {
|
||||
var got payload
|
||||
require.NoError(t, dec.Decode(&got))
|
||||
assert.Equal(t, want, got.N)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecoderUseNumber backs binding.EnableDecoderUseNumber. Asserted through
|
||||
// the textual form rather than a concrete type so it holds for every codec: a
|
||||
// float64 would render as 1.5 and lose the trailing zero.
|
||||
func TestDecoderUseNumber(t *testing.T) {
|
||||
dec := json.API.NewDecoder(strings.NewReader(`{"n":1.50}`))
|
||||
dec.UseNumber()
|
||||
|
||||
var got map[string]any
|
||||
require.NoError(t, dec.Decode(&got))
|
||||
assert.Equal(t, "1.50", fmt.Sprint(got["n"]))
|
||||
}
|
||||
|
||||
// TestDecoderDisallowUnknownFields backs binding.EnableDecoderDisallowUnknownFields.
|
||||
func TestDecoderDisallowUnknownFields(t *testing.T) {
|
||||
dec := json.API.NewDecoder(strings.NewReader(`{"name":"gin","nope":1}`))
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
var got payload
|
||||
assert.Error(t, dec.Decode(&got))
|
||||
}
|
||||
80
codec/json/jsonv2.go
Normal file
80
codec/json/jsonv2.go
Normal file
@ -0,0 +1,80 @@
|
||||
// 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.
|
||||
|
||||
//go:build jsonv2 && go1.27 && goexperiment.jsonv2 && !jsoniter && !go_json && !(sonic && (linux || windows || darwin))
|
||||
|
||||
package json
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
jsonv2 "encoding/json/v2"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Package indicates what library is being used for JSON encoding.
|
||||
const Package = "encoding/json/v2"
|
||||
|
||||
func init() {
|
||||
API = jsonv2Api{}
|
||||
}
|
||||
|
||||
// v1Compat matches the observable behavior of encoding/json v1: HTML escaping
|
||||
// on, case-insensitive member matching, nil slices and maps as null, and the
|
||||
// legacy omitempty and error semantics.
|
||||
var v1Compat = json.DefaultOptionsV1()
|
||||
|
||||
// newline is written after each streamed value; a package-level slice keeps
|
||||
// Encode from allocating one per call.
|
||||
var newline = []byte{'\n'}
|
||||
|
||||
type jsonv2Api struct{}
|
||||
|
||||
func (jsonv2Api) Marshal(v any) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, v1Compat)
|
||||
}
|
||||
|
||||
func (jsonv2Api) Unmarshal(data []byte, v any) error {
|
||||
return jsonv2.Unmarshal(data, v, v1Compat)
|
||||
}
|
||||
|
||||
func (jsonv2Api) MarshalIndent(v any, prefix, indent string) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, v1Compat,
|
||||
jsontext.WithIndentPrefix(prefix), jsontext.WithIndent(indent))
|
||||
}
|
||||
|
||||
// NewEncoder wraps MarshalWrite rather than returning v1's encoder, which
|
||||
// marshals into an intermediate buffer so that SetIndent can reformat the
|
||||
// result. Gin never indents a stream, so writing straight to the io.Writer
|
||||
// avoids that buffer. Re-measure BenchmarkEncoder before replacing this with
|
||||
// json.NewEncoder.
|
||||
func (jsonv2Api) NewEncoder(writer io.Writer) Encoder {
|
||||
// v1Compat already escapes HTML, matching v1's default.
|
||||
return &v2Encoder{writer: writer, opts: v1Compat}
|
||||
}
|
||||
|
||||
// NewDecoder returns v1's decoder. It satisfies Decoder as-is, implements
|
||||
// UseNumber natively (v2 exposes no option for it), and measures the same as a
|
||||
// wrapper over jsontext.Decoder, so there is nothing to gain by wrapping.
|
||||
func (jsonv2Api) NewDecoder(reader io.Reader) Decoder {
|
||||
return json.NewDecoder(reader)
|
||||
}
|
||||
|
||||
type v2Encoder struct {
|
||||
writer io.Writer
|
||||
opts jsonv2.Options
|
||||
}
|
||||
|
||||
func (e *v2Encoder) SetEscapeHTML(on bool) {
|
||||
e.opts = jsonv2.JoinOptions(v1Compat, jsontext.EscapeForHTML(on))
|
||||
}
|
||||
|
||||
func (e *v2Encoder) Encode(v any) error {
|
||||
// MarshalWrite omits the trailing newline that v1's Encoder.Encode writes.
|
||||
if err := jsonv2.MarshalWrite(e.writer, v, e.opts); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := e.writer.Write(newline)
|
||||
return err
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user