Skip to content

Commit cc07fa9

Browse files
vishrclaude
andauthored
perf(json): pooled-buffer JSON deserialize (#3023)
* perf(json): reuse pooled buffer for JSON deserialization DefaultJSONSerializer.Deserialize used json.NewDecoder(body).Decode(), which allocates a decoder and its internal read buffer on every JSON request. Read the body into a capped pooled buffer and decode with json.Unmarshal instead; Unmarshal does not retain the input slice, so the buffer is safe to reuse. The pool drops oversized buffers (>64 KiB) so a large body cannot pin memory. BenchmarkBind_JSON: 1008 B -> 312 B/op (-69%), 9 -> 7 allocs, ~12% faster. Behavioral note: json.Unmarshal is stricter than Decode — it rejects trailing data after the JSON value and reports "unexpected end of JSON input" for truncated bodies (both still 400 Bad Request). Two bind tests asserting the old "unexpected EOF" message are updated to match. Also add a general ServeHTTP/JSON benchmark suite (perf_bench_test.go): BenchmarkBind_JSON now builds the request once and resets a reusable body instead of calling httptest.NewRequest inside the loop (which dominated the old measurement), plus BenchmarkJSONSerialize/Deserialize and the routing benchmarks used to measure both this and the companion router PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(json): cover pooled-buffer Deserialize behaviors Add correctness coverage for the new pooled-buffer JSON deserializer, addressing gaps surfaced in review: - RejectsTrailingData: documents that json.Unmarshal rejects content after the first top-level value (a behavior change from streaming json.Decoder). - PooledBufferReuse: long body followed by a short one must not leak stale bytes through the reused buffer. - PooledBufferConcurrent: many goroutines decoding distinct bodies; under -race this catches any aliasing/missing-reset regression (data bleed). - LargeBodyThenNormal: exercises the >64 KiB buffer-cap path and that the oversized buffer does not corrupt the next request. - BodyReadError: a failing body read surfaces as 400, matching prior behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 78c3d95 commit cc07fa9

4 files changed

Lines changed: 212 additions & 7 deletions

File tree

bind_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ func TestDefaultBinder_BindToStructFromMixedSources(t *testing.T) {
834834
givenURL: "/api/real_node/endpoint?node=xxx",
835835
givenContent: strings.NewReader(`{`),
836836
expect: &Opts{ID: 0, Node: "node_from_path"}, // query binding has already modified bind target
837-
expectError: "code=400, message=Bad Request, err=unexpected EOF",
837+
expectError: "code=400, message=Bad Request, err=unexpected end of JSON input",
838838
},
839839
{
840840
name: "nok, GET with body bind failure when types are not convertible",
@@ -1004,7 +1004,7 @@ func TestDefaultBinder_BindBody(t *testing.T) {
10041004
givenContentType: MIMEApplicationJSON,
10051005
givenContent: strings.NewReader(`{`),
10061006
expect: &Node{ID: 0, Node: ""},
1007-
expectError: "code=400, message=Bad Request, err=unexpected EOF",
1007+
expectError: "code=400, message=Bad Request, err=unexpected end of JSON input",
10081008
},
10091009
{
10101010
name: "ok, XML POST bind to struct with: path + query + empty body",

json.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,25 @@
44
package echo
55

66
import (
7+
"bytes"
78
"encoding/json"
9+
"sync"
810
)
911

1012
// DefaultJSONSerializer implements JSON encoding using encoding/json.
1113
type DefaultJSONSerializer struct{}
1214

15+
// jsonBufPool reuses buffers for reading request bodies during JSON
16+
// deserialization, avoiding the per-request decoder and its internal read
17+
// buffer that json.NewDecoder allocates.
18+
var jsonBufPool = sync.Pool{New: newJSONBuf}
19+
20+
func newJSONBuf() any { return new(bytes.Buffer) }
21+
22+
// maxPooledJSONBuf caps the capacity of buffers returned to jsonBufPool so a
23+
// single large request body cannot pin an oversized buffer in the pool.
24+
const maxPooledJSONBuf = 1 << 16 // 64 KiB
25+
1326
// Serialize converts an interface into a json and writes it to the response.
1427
// You can optionally use the indent parameter to produce pretty JSONs.
1528
func (d DefaultJSONSerializer) Serialize(c *Context, target any, indent string) error {
@@ -21,8 +34,29 @@ func (d DefaultJSONSerializer) Serialize(c *Context, target any, indent string)
2134
}
2235

2336
// Deserialize reads a JSON from a request body and converts it into an interface.
37+
//
38+
// The body is read into a pooled buffer and decoded with json.Unmarshal rather
39+
// than streaming through json.NewDecoder. This avoids allocating a decoder and
40+
// its internal read buffer on every request. json.Unmarshal does not retain a
41+
// reference to the input slice, so the buffer is safe to reuse afterwards.
42+
//
43+
// Note: the full request body is read into memory before decoding. As with any
44+
// JSON parser, decoding untrusted input can allocate large amounts of memory;
45+
// guard such endpoints with middleware.BodyLimit (or http.MaxBytesReader),
46+
// which makes the body read here fail fast once the limit is exceeded.
2447
func (d DefaultJSONSerializer) Deserialize(c *Context, target any) error {
25-
if err := json.NewDecoder(c.Request().Body).Decode(target); err != nil {
48+
buf := jsonBufPool.Get().(*bytes.Buffer)
49+
buf.Reset()
50+
defer func() {
51+
// Do not return oversized buffers to the pool — they would pin memory.
52+
if buf.Cap() <= maxPooledJSONBuf {
53+
jsonBufPool.Put(buf)
54+
}
55+
}()
56+
if _, err := buf.ReadFrom(c.Request().Body); err != nil {
57+
return ErrBadRequest.Wrap(err)
58+
}
59+
if err := json.Unmarshal(buf.Bytes(), target); err != nil {
2660
return ErrBadRequest.Wrap(err)
2761
}
2862
return nil

json_test.go

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,26 @@
44
package echo
55

66
import (
7-
"github.com/stretchr/testify/assert"
7+
"errors"
8+
"fmt"
89
"net/http"
910
"net/http/httptest"
1011
"strings"
12+
"sync"
1113
"testing"
14+
15+
"github.com/stretchr/testify/assert"
1216
)
1317

18+
// deserializeJSON decodes body into target via the default serializer using a
19+
// fresh context. It does not touch *testing.T so it is safe to call from
20+
// goroutines (used by the concurrency test).
21+
func deserializeJSON(e *Echo, body string, target any) error {
22+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
23+
c := e.NewContext(req, httptest.NewRecorder())
24+
return DefaultJSONSerializer{}.Deserialize(c, target)
25+
}
26+
1427
// Note this test is deliberately simple as there's not a lot to test.
1528
// Just need to ensure it writes JSONs. The heavy work is done by the context methods.
1629
func TestDefaultJSONCodec_Encode(t *testing.T) {
@@ -98,3 +111,106 @@ func TestDefaultJSONCodec_Decode(t *testing.T) {
98111
assert.EqualError(t, err, "code=400, message=Bad Request, err=json: cannot unmarshal number into Go struct field .id of type string")
99112

100113
}
114+
115+
// TestDefaultJSONCodec_Decode_RejectsTrailingData documents an intentional
116+
// behavior change: Deserialize uses json.Unmarshal, which (unlike a streaming
117+
// json.Decoder) rejects any content after the first top-level JSON value.
118+
func TestDefaultJSONCodec_Decode_RejectsTrailingData(t *testing.T) {
119+
e := New()
120+
for _, body := range []string{
121+
userJSON + `{"id":2,"name":"second"}`, // a second JSON object
122+
userJSON + ` trailing garbage`, // trailing non-JSON
123+
userJSON + `,`, // trailing token
124+
} {
125+
var u user
126+
err := deserializeJSON(e, body, &u)
127+
if assert.Error(t, err, "body %q should be rejected", body) {
128+
assert.IsType(t, &HTTPError{}, err)
129+
assert.Equal(t, http.StatusBadRequest, err.(*HTTPError).Code)
130+
}
131+
}
132+
}
133+
134+
// TestDefaultJSONCodec_Decode_PooledBufferReuse guards against stale bytes
135+
// bleeding between requests through the reused pooled buffer: a long body
136+
// followed by a short one must each decode to exactly their own input.
137+
func TestDefaultJSONCodec_Decode_PooledBufferReuse(t *testing.T) {
138+
e := New()
139+
for i := 0; i < 50; i++ {
140+
longName := strings.Repeat("x", 1000+i)
141+
var long user
142+
err := deserializeJSON(e, fmt.Sprintf(`{"id":%d,"name":%q}`, i, longName), &long)
143+
assert.NoError(t, err)
144+
assert.Equal(t, user{ID: i, Name: longName}, long)
145+
146+
var short user
147+
err = deserializeJSON(e, `{"id":7,"name":"a"}`, &short)
148+
assert.NoError(t, err)
149+
assert.Equal(t, user{ID: 7, Name: "a"}, short)
150+
}
151+
}
152+
153+
// TestDefaultJSONCodec_Decode_PooledBufferConcurrent exercises the pooled
154+
// buffer from many goroutines at once; run under -race it catches any aliasing
155+
// or missing-reset regression that would let one request's body corrupt another.
156+
func TestDefaultJSONCodec_Decode_PooledBufferConcurrent(t *testing.T) {
157+
e := New()
158+
const n = 64
159+
var wg sync.WaitGroup
160+
errs := make([]error, n)
161+
got := make([]user, n)
162+
for i := 0; i < n; i++ {
163+
wg.Add(1)
164+
go func(i int) {
165+
defer wg.Done()
166+
body := fmt.Sprintf(`{"id":%d,"name":%q}`, i, strings.Repeat("n", i+1))
167+
errs[i] = deserializeJSON(e, body, &got[i])
168+
}(i)
169+
}
170+
wg.Wait()
171+
for i := 0; i < n; i++ {
172+
assert.NoError(t, errs[i])
173+
assert.Equal(t, user{ID: i, Name: strings.Repeat("n", i+1)}, got[i])
174+
}
175+
}
176+
177+
// TestDefaultJSONCodec_Decode_LargeBodyThenNormal covers the buffer-cap path: a
178+
// body larger than maxPooledJSONBuf must decode correctly, and its oversized
179+
// buffer (dropped from the pool rather than retained) must not affect the next
180+
// normal-sized request.
181+
func TestDefaultJSONCodec_Decode_LargeBodyThenNormal(t *testing.T) {
182+
e := New()
183+
bigName := strings.Repeat("z", 100*1024) // 100 KiB > 64 KiB cap
184+
var big user
185+
err := deserializeJSON(e, fmt.Sprintf(`{"id":1,"name":%q}`, bigName), &big)
186+
assert.NoError(t, err)
187+
assert.Equal(t, user{ID: 1, Name: bigName}, big)
188+
189+
var small user
190+
err = deserializeJSON(e, userJSON, &small)
191+
assert.NoError(t, err)
192+
assert.Equal(t, user{ID: 1, Name: "Jon Snow"}, small)
193+
}
194+
195+
// errReader is an io.ReadCloser whose Read always fails, used to exercise the
196+
// body-read error branch of Deserialize.
197+
type errReader struct{}
198+
199+
func (errReader) Read([]byte) (int, error) { return 0, errors.New("read failed") }
200+
func (errReader) Close() error { return nil }
201+
202+
// TestDefaultJSONCodec_Decode_BodyReadError verifies a failing request body read
203+
// surfaces as a 400, matching the pre-existing decoder behavior.
204+
func TestDefaultJSONCodec_Decode_BodyReadError(t *testing.T) {
205+
e := New()
206+
req := httptest.NewRequest(http.MethodPost, "/", http.NoBody)
207+
req.Body = errReader{}
208+
c := e.NewContext(req, httptest.NewRecorder())
209+
210+
var u user
211+
err := DefaultJSONSerializer{}.Deserialize(c, &u)
212+
if assert.Error(t, err) {
213+
assert.IsType(t, &HTTPError{}, err)
214+
assert.Equal(t, http.StatusBadRequest, err.(*HTTPError).Code)
215+
}
216+
}

perf_bench_test.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,24 +111,79 @@ type bindTarget struct {
111111
Active bool `json:"active" query:"active"`
112112
}
113113

114+
// nopReadCloser adapts a Reader to a ReadCloser without allocating, so the
115+
// request body can be reset and reused across benchmark iterations instead of
116+
// rebuilding the request (and its httptest machinery) inside the loop.
117+
type nopReadCloser struct{ r *strings.Reader }
118+
119+
func (n nopReadCloser) Read(p []byte) (int, error) { return n.r.Read(p) }
120+
func (nopReadCloser) Close() error { return nil }
121+
114122
func BenchmarkBind_JSON(b *testing.B) {
115123
e := New()
116124
body := `{"id":1,"name":"Jon Snow","email":"jon@winterfell.north","age":24,"active":true}`
117125
e.POST("/", func(c *Context) error {
118126
var t bindTarget
119127
return c.Bind(&t)
120128
})
129+
// Build the request once and reset its body each iteration so the benchmark
130+
// measures Echo's routing+binding cost, not httptest.NewRequest allocations.
131+
r := strings.NewReader(body)
132+
req := httptest.NewRequest(http.MethodPost, "/", r)
133+
req.Header.Set(HeaderContentType, MIMEApplicationJSON)
134+
req.Body = nopReadCloser{r}
135+
w := &nopResponseWriter{}
121136
b.ReportAllocs()
122137
b.ResetTimer()
123-
w := &nopResponseWriter{}
124138
for i := 0; i < b.N; i++ {
125139
w.h = nil
126-
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
127-
req.Header.Set(HeaderContentType, MIMEApplicationJSON)
140+
r.Reset(body)
128141
e.ServeHTTP(w, req)
129142
}
130143
}
131144

145+
// BenchmarkJSONSerialize/Deserialize exercise the DefaultJSONSerializer directly,
146+
// isolating JSON encode/decode cost from routing and request construction.
147+
func BenchmarkJSONDeserialize(b *testing.B) {
148+
e := New()
149+
body := `{"id":1,"name":"Jon Snow","email":"jon@winterfell.north","age":24,"active":true}`
150+
r := strings.NewReader(body)
151+
req := httptest.NewRequest(http.MethodPost, "/", r)
152+
req.Body = nopReadCloser{r}
153+
c := e.NewContext(req, &nopResponseWriter{})
154+
s := DefaultJSONSerializer{}
155+
b.ReportAllocs()
156+
b.ResetTimer()
157+
for i := 0; i < b.N; i++ {
158+
r.Reset(body)
159+
var t bindTarget
160+
if err := s.Deserialize(c, &t); err != nil {
161+
b.Fatal(err)
162+
}
163+
}
164+
}
165+
166+
func BenchmarkJSONSerialize(b *testing.B) {
167+
e := New()
168+
type payload struct {
169+
ID int `json:"id"`
170+
Name string `json:"name"`
171+
Tags []string
172+
}
173+
p := payload{ID: 1, Name: "Jon Snow", Tags: []string{"a", "b", "c"}}
174+
w := &nopResponseWriter{}
175+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), w)
176+
s := DefaultJSONSerializer{}
177+
b.ReportAllocs()
178+
b.ResetTimer()
179+
for i := 0; i < b.N; i++ {
180+
w.h = nil
181+
if err := s.Serialize(c, p, ""); err != nil {
182+
b.Fatal(err)
183+
}
184+
}
185+
}
186+
132187
func BenchmarkBind_Query(b *testing.B) {
133188
e := New()
134189
e.GET("/", func(c *Context) error {

0 commit comments

Comments
 (0)