Skip to content

Commit b1d65e4

Browse files
authored
perf: optimize core hot paths (chain, context, binding, responses) (#3008)
Compile middleware chains once, zero-copy String/HTML/JSONP writes, reuse delayedStatusWriter + context store map, single-key QueryParam fast path, per-type bind metadata cache, precomputed HSTS header, pooled request-ID buffers. No public API changes. Also de-flakes TestStartConfig_WithListenerNetwork (ephemeral port).
1 parent a9ede66 commit b1d65e4

12 files changed

Lines changed: 693 additions & 52 deletions

bind.go

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"reflect"
1414
"strconv"
1515
"strings"
16+
"sync"
1617
"time"
1718
)
1819

@@ -136,6 +137,73 @@ func (b *DefaultBinder) Bind(c *Context, target any) error {
136137
return BindBody(c, target)
137138
}
138139

140+
// bindFieldMeta is the cached, type-level reflection metadata for a single struct field. Reading struct
141+
// tags (reflect.StructTag.Get) parses the tag string on every call, so for binding-heavy endpoints we
142+
// compute it once per struct type and reuse it across requests (see bindStructMeta). Only type-level data
143+
// is cached here; per-request, per-instance reflect.Value operations still happen in bindData.
144+
type bindFieldMeta struct {
145+
index int // field index within the struct
146+
// fieldKind is the DECLARED field kind (typeField.Type.Kind()), used only for unmarshal dispatch.
147+
// It is intentionally not the post-anonymous-pointer-deref live kind; bindData computes that
148+
// separately as structFieldKind where needed.
149+
fieldKind reflect.Kind
150+
anonymous bool // reflect.StructField.Anonymous
151+
formatTag string // value of the `format` struct tag
152+
// binding-source tag values. bindData is only ever called with one of these four tags (see the
153+
// callers BindPathValues/BindQueryParams/BindBody/BindHeaders). Keep these fields, the four
154+
// f.Tag.Get(...) lines in bindMetaFor, and the tagName switch in sync if a source is ever added.
155+
param, query, form, header string
156+
}
157+
158+
// tagName returns the field's tag value for the given binding source tag.
159+
// Keep in sync with the tag fields above and the f.Tag.Get calls in bindMetaFor.
160+
func (m *bindFieldMeta) tagName(tag string) string {
161+
switch tag {
162+
case "param":
163+
return m.param
164+
case "query":
165+
return m.query
166+
case "form":
167+
return m.form
168+
case "header":
169+
return m.header
170+
default:
171+
return ""
172+
}
173+
}
174+
175+
// bindStructMeta is the cached field metadata for a whole struct type, in declaration order.
176+
type bindStructMeta struct {
177+
fields []bindFieldMeta
178+
}
179+
180+
// bindStructCache memoizes bindStructMeta keyed by struct reflect.Type. Concurrent double-computation is
181+
// harmless because the result is deterministic and idempotent.
182+
var bindStructCache sync.Map // map[reflect.Type]*bindStructMeta
183+
184+
func bindMetaFor(typ reflect.Type) *bindStructMeta {
185+
if cached, ok := bindStructCache.Load(typ); ok {
186+
return cached.(*bindStructMeta)
187+
}
188+
n := typ.NumField()
189+
meta := &bindStructMeta{fields: make([]bindFieldMeta, n)}
190+
for i := 0; i < n; i++ {
191+
f := typ.Field(i)
192+
meta.fields[i] = bindFieldMeta{
193+
index: i,
194+
anonymous: f.Anonymous,
195+
fieldKind: f.Type.Kind(),
196+
formatTag: f.Tag.Get("format"),
197+
param: f.Tag.Get("param"),
198+
query: f.Tag.Get("query"),
199+
form: f.Tag.Get("form"),
200+
header: f.Tag.Get("header"),
201+
}
202+
}
203+
bindStructCache.Store(typ, meta)
204+
return meta
205+
}
206+
139207
// bindData will bind data ONLY fields in destination struct that have EXPLICIT tag
140208
func bindData(destination any, data map[string][]string, tag string, dataFiles map[string][]*multipart.FileHeader) error {
141209
if destination == nil || (len(data) == 0 && len(dataFiles) == 0) {
@@ -185,10 +253,11 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m
185253
return errors.New("binding element must be a struct")
186254
}
187255

188-
for i := 0; i < typ.NumField(); i++ { // iterate over all destination fields
189-
typeField := typ.Field(i)
190-
structField := val.Field(i)
191-
if typeField.Anonymous {
256+
meta := bindMetaFor(typ)
257+
for fi := range meta.fields { // iterate over all destination fields
258+
fm := &meta.fields[fi]
259+
structField := val.Field(fm.index)
260+
if fm.anonymous {
192261
if structField.Kind() == reflect.Pointer {
193262
structField = structField.Elem()
194263
}
@@ -197,8 +266,8 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m
197266
continue
198267
}
199268
structFieldKind := structField.Kind()
200-
inputFieldName := typeField.Tag.Get(tag)
201-
if typeField.Anonymous && structFieldKind == reflect.Struct && inputFieldName != "" {
269+
inputFieldName := fm.tagName(tag)
270+
if fm.anonymous && structFieldKind == reflect.Struct && inputFieldName != "" {
202271
// if anonymous struct with query/param/form tags, report an error
203272
return errors.New("query/param/form tags are not allowed with anonymous struct field")
204273
}
@@ -248,15 +317,14 @@ func bindData(destination any, data map[string][]string, tag string, dataFiles m
248317
// but it is smart enough to handle niche cases like `*int`,`*[]string`,`[]*int` .
249318

250319
// try unmarshalling first, in case we're dealing with an alias to an array type
251-
if ok, err := unmarshalInputsToField(typeField.Type.Kind(), inputValue, structField); ok {
320+
if ok, err := unmarshalInputsToField(fm.fieldKind, inputValue, structField); ok {
252321
if err != nil {
253322
return fmt.Errorf("%s: %w", inputFieldName, err)
254323
}
255324
continue
256325
}
257326

258-
formatTag := typeField.Tag.Get("format")
259-
if ok, err := unmarshalInputToField(typeField.Type.Kind(), inputValue[0], structField, formatTag); ok {
327+
if ok, err := unmarshalInputToField(fm.fieldKind, inputValue[0], structField, fm.formatTag); ok {
260328
if err != nil {
261329
return fmt.Errorf("%s: %w", inputFieldName, err)
262330
}

bind_cache_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package echo
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
// TestBindCachedMetaPreservesFieldNameError ensures the per-type bind metadata cache preserves the
15+
// field-name prefix in conversion errors on BOTH the cold (first) and warm (cached) bind of a type.
16+
// DTO is declared locally so its reflect.Type is independent of suite ordering, making the second
17+
// bind a deterministic cache hit (the bindMetaFor Load branch).
18+
func TestBindCachedMetaPreservesFieldNameError(t *testing.T) {
19+
type DTO struct {
20+
Number int `query:"number"`
21+
}
22+
bind := func() error {
23+
e := New()
24+
req := httptest.NewRequest(http.MethodGet, "/?number=10a", nil)
25+
var dto DTO
26+
return e.NewContext(req, httptest.NewRecorder()).Bind(&dto)
27+
}
28+
29+
assert.ErrorContains(t, bind(), "number", "cold cache: error must carry field name")
30+
assert.ErrorContains(t, bind(), "number", "warm cache: error must still carry field name")
31+
}

context.go

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,30 @@ import (
1919
"path/filepath"
2020
"strings"
2121
"sync"
22+
"unsafe"
23+
)
24+
25+
// stringToBytes returns a []byte view over s without copying, avoiding the allocation+copy of []byte(s)
26+
// on the response write path (the zero-copy technique used by fasthttp/fiber).
27+
//
28+
// Contract — all of the following must hold at every call site:
29+
// - The result is read-only: writing through it is undefined behaviour.
30+
// - The callee must NOT retain the slice beyond the call. It is only passed to the response
31+
// Writer's Write, whose io.Writer contract forbids retaining/mutating the argument. Note the
32+
// concrete writer may be a wrapping ResponseWriter (e.g. gzip); such writers must copy, not alias.
33+
// - s must stay reachable for as long as the slice is used: the slice aliases s's backing array.
34+
func stringToBytes(s string) []byte {
35+
if s == "" {
36+
return nil
37+
}
38+
return unsafe.Slice(unsafe.StringData(s), len(s))
39+
}
40+
41+
// jsonpOpen and jsonpClose are the constant byte wrappers for JSONP payloads, kept as package-level
42+
// slices to avoid allocating them on every JSONP response.
43+
var (
44+
jsonpOpen = []byte("(")
45+
jsonpClose = []byte(");")
2246
)
2347

2448
const (
@@ -49,6 +73,16 @@ type Context struct {
4973
route *RouteInfo
5074
pathValues *PathValues
5175

76+
// handler is the route handler resolved during routing. It is invoked by the terminal of the global
77+
// middleware chain (see Echo.buildRouterChains) so that the chain can be compiled once and reused.
78+
handler HandlerFunc
79+
80+
// dsw is reused by json() so that each JSON response does not heap-allocate a delayedStatusWriter.
81+
// It lives on the pooled Context; &c.dsw is a stable, allocation-free pointer. Only json() may point
82+
// the response at &c.dsw, and only via the nested-call guard there — aliasing it to itself (wrapping
83+
// &c.dsw around &c.dsw) would make the response writer reference itself.
84+
dsw delayedStatusWriter
85+
5286
store map[string]any
5387
echo *Echo
5488
logger *slog.Logger
@@ -73,9 +107,9 @@ func NewContext(r *http.Request, w http.ResponseWriter, opts ...any) *Context {
73107
}
74108

75109
func newContext(r *http.Request, w http.ResponseWriter, e *Echo) *Context {
110+
// store is created lazily by Set and cleared (not freed) by Reset, so we deliberately do not allocate a map here.
76111
c := &Context{
77112
pathValues: nil,
78-
store: make(map[string]any),
79113
echo: e,
80114
logger: nil,
81115
}
@@ -109,10 +143,14 @@ func (c *Context) Reset(r *http.Request, w http.ResponseWriter) {
109143
c.orgResponse.reset(w)
110144
c.response = c.orgResponse
111145
c.query = nil
112-
c.store = nil
146+
// clear (rather than nil) keeps the map allocated on the pooled Context so that requests using Set
147+
// do not allocate a fresh map each time. clear(nil) is a no-op.
148+
clear(c.store)
113149
c.logger = c.echo.Logger
114150

115151
c.route = nil
152+
c.handler = nil
153+
c.dsw = delayedStatusWriter{}
116154
c.path = ""
117155
// NOTE: empty by setting length to 0. PathValues has to have capacity of c.echo.contextPathParamAllocSize at all times
118156
*c.pathValues = (*c.pathValues)[:0]
@@ -297,10 +335,38 @@ func (c *Context) setPathValues(pv *PathValues) {
297335

298336
// QueryParam returns the query param for the provided name.
299337
func (c *Context) QueryParam(name string) string {
300-
if c.query == nil {
301-
c.query = c.request.URL.Query()
338+
// If the full query map was already built (e.g. by QueryParams), use it. Otherwise look the single
339+
// key up directly from the raw query, avoiding the url.Values map allocation for the common case of
340+
// reading only a few params. The result is identical to url.Values.Get on the parsed query.
341+
if c.query != nil {
342+
return c.query.Get(name)
343+
}
344+
return getRawQueryParam(c.request.URL.RawQuery, name)
345+
}
346+
347+
// getRawQueryParam returns the first value for name parsed directly from a raw URL query string. It
348+
// matches url.Values.Get over url.ParseQuery output: first match wins, '+' decodes to space, percent
349+
// escapes are decoded, segments containing ';' are skipped, and pairs whose key or value fail to
350+
// unescape are skipped. It avoids allocating the full url.Values map for single-key lookups.
351+
func getRawQueryParam(query, name string) string {
352+
for query != "" {
353+
var seg string
354+
seg, query, _ = strings.Cut(query, "&")
355+
if seg == "" || strings.Contains(seg, ";") {
356+
continue
357+
}
358+
key, value, _ := strings.Cut(seg, "=")
359+
k, err := url.QueryUnescape(key)
360+
if err != nil || k != name {
361+
continue
362+
}
363+
v, err := url.QueryUnescape(value)
364+
if err != nil {
365+
continue
366+
}
367+
return v
302368
}
303-
return c.query.Get(name)
369+
return ""
304370
}
305371

306372
// QueryParamOr returns the query param or default value for the provided name.
@@ -390,20 +456,21 @@ func (c *Context) Cookies() []*http.Cookie {
390456
// Get retrieves data from the context.
391457
// Method returns any(nil) when key does not exist which is different from typed nil (eg. []byte(nil)).
392458
func (c *Context) Get(key string) any {
459+
// Unlock without defer to avoid the deferred-call overhead on this hot path.
393460
c.lock.RLock()
394-
defer c.lock.RUnlock()
395-
return c.store[key]
461+
v := c.store[key]
462+
c.lock.RUnlock()
463+
return v
396464
}
397465

398466
// Set saves data in the context.
399467
func (c *Context) Set(key string, val any) {
400468
c.lock.Lock()
401-
defer c.lock.Unlock()
402-
403469
if c.store == nil {
404470
c.store = make(map[string]any)
405471
}
406472
c.store[key] = val
473+
c.lock.Unlock()
407474
}
408475

409476
// Bind binds path params, query params and the request body into provided type `i`. The default binder
@@ -445,7 +512,7 @@ func (c *Context) Render(code int, name string, data any) (err error) {
445512

446513
// HTML sends an HTTP response with status code.
447514
func (c *Context) HTML(code int, html string) (err error) {
448-
return c.HTMLBlob(code, []byte(html))
515+
return c.HTMLBlob(code, stringToBytes(html))
449516
}
450517

451518
// HTMLBlob sends an HTTP blob response with status code.
@@ -455,19 +522,22 @@ func (c *Context) HTMLBlob(code int, b []byte) (err error) {
455522

456523
// String sends a string response with status code.
457524
func (c *Context) String(code int, s string) (err error) {
458-
return c.Blob(code, MIMETextPlainCharsetUTF8, []byte(s))
525+
return c.Blob(code, MIMETextPlainCharsetUTF8, stringToBytes(s))
459526
}
460527

461528
func (c *Context) jsonPBlob(code int, callback string, i any) (err error) {
462529
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
463530
c.response.WriteHeader(code)
464-
if _, err = c.response.Write([]byte(callback + "(")); err != nil {
531+
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
532+
return
533+
}
534+
if _, err = c.response.Write(jsonpOpen); err != nil {
465535
return
466536
}
467537
if err = c.echo.JSONSerializer.Serialize(c, i, ""); err != nil {
468538
return
469539
}
470-
if _, err = c.response.Write([]byte(");")); err != nil {
540+
if _, err = c.response.Write(jsonpClose); err != nil {
471541
return
472542
}
473543
return
@@ -480,7 +550,16 @@ func (c *Context) json(code int, i any, indent string) error {
480550
// (global) error handler decides correct status code for the error to be sent to the client.
481551
// For that we need to use writer that can store the proposed status code until the first Write is called.
482552
resp := c.Response()
483-
c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code})
553+
// Reuse the Context-owned delayedStatusWriter to avoid heap-allocating one per JSON response.
554+
// If we are already nested inside a delayed write (rare: a serializer or handler calling c.JSON
555+
// re-entrantly), allocate a fresh writer so the outer call's writer (which is &c.dsw) is not
556+
// clobbered — reusing c.dsw here would make it reference itself.
557+
if _, nested := resp.(*delayedStatusWriter); nested {
558+
c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code})
559+
} else {
560+
c.dsw = delayedStatusWriter{ResponseWriter: resp, status: code}
561+
c.SetResponse(&c.dsw)
562+
}
484563
defer c.SetResponse(resp)
485564

486565
return c.echo.JSONSerializer.Serialize(c, i, indent)
@@ -512,13 +591,16 @@ func (c *Context) JSONP(code int, callback string, i any) (err error) {
512591
func (c *Context) JSONPBlob(code int, callback string, b []byte) (err error) {
513592
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
514593
c.response.WriteHeader(code)
515-
if _, err = c.response.Write([]byte(callback + "(")); err != nil {
594+
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
595+
return
596+
}
597+
if _, err = c.response.Write(jsonpOpen); err != nil {
516598
return
517599
}
518600
if _, err = c.response.Write(b); err != nil {
519601
return
520602
}
521-
_, err = c.response.Write([]byte(");"))
603+
_, err = c.response.Write(jsonpClose)
522604
return
523605
}
524606

0 commit comments

Comments
 (0)