Skip to content

Commit 34b33be

Browse files
vishrclaude
andcommitted
perf: optimize core hot paths (chain, context, binding, responses)
- echo: compile global/pre middleware chains once instead of per request, eliminating per-request closure allocations (5 mw: 101ns/5allocs -> 34ns/0allocs) - context: zero-copy String/HTML/JSONP writes, reuse delayedStatusWriter (guarded against re-entrant c.JSON) and the store map across requests, drop deferred unlock on Get/Set, single-key QueryParam fast path (199ns/4allocs -> 41ns/0allocs) - bind: cache per-type struct field metadata (bindData -48%, query Bind -28%) - add hot-path benchmark suite and pooling/dispatch regression tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9ede66 commit 34b33be

10 files changed

Lines changed: 651 additions & 46 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
}

context.go

Lines changed: 96 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,14 @@ 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.
82+
dsw delayedStatusWriter
83+
5284
store map[string]any
5385
echo *Echo
5486
logger *slog.Logger
@@ -73,9 +105,9 @@ func NewContext(r *http.Request, w http.ResponseWriter, opts ...any) *Context {
73105
}
74106

75107
func newContext(r *http.Request, w http.ResponseWriter, e *Echo) *Context {
108+
// store is created lazily by Set (and reset to nil by Reset), so we deliberately do not allocate a map here.
76109
c := &Context{
77110
pathValues: nil,
78-
store: make(map[string]any),
79111
echo: e,
80112
logger: nil,
81113
}
@@ -109,10 +141,14 @@ func (c *Context) Reset(r *http.Request, w http.ResponseWriter) {
109141
c.orgResponse.reset(w)
110142
c.response = c.orgResponse
111143
c.query = nil
112-
c.store = nil
144+
// clear (rather than nil) keeps the map allocated on the pooled Context so that requests using Set
145+
// do not allocate a fresh map each time. clear(nil) is a no-op.
146+
clear(c.store)
113147
c.logger = c.echo.Logger
114148

115149
c.route = nil
150+
c.handler = nil
151+
c.dsw = delayedStatusWriter{}
116152
c.path = ""
117153
// NOTE: empty by setting length to 0. PathValues has to have capacity of c.echo.contextPathParamAllocSize at all times
118154
*c.pathValues = (*c.pathValues)[:0]
@@ -297,10 +333,38 @@ func (c *Context) setPathValues(pv *PathValues) {
297333

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

306370
// QueryParamOr returns the query param or default value for the provided name.
@@ -390,20 +454,21 @@ func (c *Context) Cookies() []*http.Cookie {
390454
// Get retrieves data from the context.
391455
// Method returns any(nil) when key does not exist which is different from typed nil (eg. []byte(nil)).
392456
func (c *Context) Get(key string) any {
457+
// Unlock without defer to avoid the deferred-call overhead on this hot path.
393458
c.lock.RLock()
394-
defer c.lock.RUnlock()
395-
return c.store[key]
459+
v := c.store[key]
460+
c.lock.RUnlock()
461+
return v
396462
}
397463

398464
// Set saves data in the context.
399465
func (c *Context) Set(key string, val any) {
400466
c.lock.Lock()
401-
defer c.lock.Unlock()
402-
403467
if c.store == nil {
404468
c.store = make(map[string]any)
405469
}
406470
c.store[key] = val
471+
c.lock.Unlock()
407472
}
408473

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

446511
// HTML sends an HTTP response with status code.
447512
func (c *Context) HTML(code int, html string) (err error) {
448-
return c.HTMLBlob(code, []byte(html))
513+
return c.HTMLBlob(code, stringToBytes(html))
449514
}
450515

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

456521
// String sends a string response with status code.
457522
func (c *Context) String(code int, s string) (err error) {
458-
return c.Blob(code, MIMETextPlainCharsetUTF8, []byte(s))
523+
return c.Blob(code, MIMETextPlainCharsetUTF8, stringToBytes(s))
459524
}
460525

461526
func (c *Context) jsonPBlob(code int, callback string, i any) (err error) {
462527
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
463528
c.response.WriteHeader(code)
464-
if _, err = c.response.Write([]byte(callback + "(")); err != nil {
529+
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
530+
return
531+
}
532+
if _, err = c.response.Write(jsonpOpen); err != nil {
465533
return
466534
}
467535
if err = c.echo.JSONSerializer.Serialize(c, i, ""); err != nil {
468536
return
469537
}
470-
if _, err = c.response.Write([]byte(");")); err != nil {
538+
if _, err = c.response.Write(jsonpClose); err != nil {
471539
return
472540
}
473541
return
@@ -480,7 +548,16 @@ func (c *Context) json(code int, i any, indent string) error {
480548
// (global) error handler decides correct status code for the error to be sent to the client.
481549
// For that we need to use writer that can store the proposed status code until the first Write is called.
482550
resp := c.Response()
483-
c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code})
551+
// Reuse the Context-owned delayedStatusWriter to avoid heap-allocating one per JSON response.
552+
// If we are already nested inside a delayed write (rare: a serializer or handler calling c.JSON
553+
// re-entrantly), allocate a fresh writer so the outer call's writer (which is &c.dsw) is not
554+
// clobbered — reusing c.dsw here would make it reference itself.
555+
if _, nested := resp.(*delayedStatusWriter); nested {
556+
c.SetResponse(&delayedStatusWriter{ResponseWriter: resp, status: code})
557+
} else {
558+
c.dsw = delayedStatusWriter{ResponseWriter: resp, status: code}
559+
c.SetResponse(&c.dsw)
560+
}
484561
defer c.SetResponse(resp)
485562

486563
return c.echo.JSONSerializer.Serialize(c, i, indent)
@@ -512,13 +589,16 @@ func (c *Context) JSONP(code int, callback string, i any) (err error) {
512589
func (c *Context) JSONPBlob(code int, callback string, b []byte) (err error) {
513590
c.writeContentType(MIMEApplicationJavaScriptCharsetUTF8)
514591
c.response.WriteHeader(code)
515-
if _, err = c.response.Write([]byte(callback + "(")); err != nil {
592+
if _, err = c.response.Write(stringToBytes(callback)); err != nil {
593+
return
594+
}
595+
if _, err = c.response.Write(jsonpOpen); err != nil {
516596
return
517597
}
518598
if _, err = c.response.Write(b); err != nil {
519599
return
520600
}
521-
_, err = c.response.Write([]byte(");"))
601+
_, err = c.response.Write(jsonpClose)
522602
return
523603
}
524604

0 commit comments

Comments
 (0)