Skip to content

Commit 263619c

Browse files
authored
Merge pull request #4542 from gofiber/claude/fiber-performance-gofiber-utils-yv7xb4
⚡ perf: adopt gofiber/utils v2.2.0 helpers across hot paths
2 parents edf3969 + 927e0b0 commit 263619c

24 files changed

Lines changed: 498 additions & 134 deletions

binder/mapping.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,6 @@ func buildFieldInfo(t reflect.Type, aliasTag string) fieldInfo {
314314

315315
func equalFieldType(out any, kind reflect.Kind, key, aliasTag string) bool {
316316
typ := reflect.TypeOf(out).Elem()
317-
key = utilsstrings.ToLower(key)
318317

319318
if isStringKeyMap(typ) {
320319
return true
@@ -324,6 +323,10 @@ func equalFieldType(out any, kind reflect.Kind, key, aliasTag string) bool {
324323
return false
325324
}
326325

326+
// Lower the key only once a struct lookup is actually needed; the early
327+
// returns above never use it.
328+
key = utilsstrings.ToLower(key)
329+
327330
cache := getFieldCache(aliasTag)
328331
val, ok := cache.Load(typ)
329332
if !ok {

client/cookiejar.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -448,14 +448,18 @@ func pathMatch(reqPath, cookiePath []byte) bool {
448448
return len(reqPath) > len(cookiePath) && reqPath[len(cookiePath)] == '/'
449449
}
450450

451-
// domainMatch reports whether host domain-matches the given cookie domain.
451+
// domainMatch reports whether host domain-matches the given cookie domain
452+
// (RFC 6265 Section 5.1.3). The comparison itself is ASCII case-insensitive
453+
// and allocation-free, but callers still normalize hosts and domains to
454+
// lowercase: the jar's map keys and its exact-match checks (e.g. the
455+
// host-only comparison in cookiesForRequest) rely on it.
452456
func domainMatch(host, domain string) bool {
453-
host = utilsstrings.UnsafeToLower(host)
454-
455-
if host == domain {
457+
if utils.EqualFold(host, domain) {
456458
return true
457459
}
458-
return strings.HasSuffix(host, "."+domain)
460+
return len(host) > len(domain) &&
461+
host[len(host)-len(domain)-1] == '.' &&
462+
utils.HasSuffixFold(host, domain)
459463
}
460464

461465
// acceptCookieDomain enforces RFC 6265 response-domain acceptance. Trailing-dot,

client/cookiejar_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,3 +803,32 @@ func Test_CookieJar_PathMatch(t *testing.T) {
803803
require.NoError(t, uriNoMatch.Parse(nil, []byte("http://example.com/apiv1")))
804804
require.Empty(t, jar.Get(uriNoMatch))
805805
}
806+
807+
// Test_CookieJar_DomainMatchBoundary pins the RFC 6265 §5.1.3 label-boundary
808+
// semantics of domainMatch: a bare string suffix without a '.' separator must
809+
// never match, and the comparison is ASCII case-insensitive.
810+
func Test_CookieJar_DomainMatchBoundary(t *testing.T) {
811+
t.Parallel()
812+
813+
testCases := []struct {
814+
host, domain string
815+
want bool
816+
}{
817+
{"example.com", "example.com", true},
818+
{"sub.example.com", "example.com", true},
819+
{"deep.sub.example.com", "example.com", true},
820+
// Suffix overlap without a label boundary must not match.
821+
{"evilexample.com", "example.com", false},
822+
{"xample.com", "example.com", false},
823+
// Domain longer than host never matches.
824+
{"example.com", "sub.example.com", false},
825+
{"com", "example.com", false},
826+
// ASCII case-insensitive on both sides.
827+
{"EXAMPLE.com", "example.com", true},
828+
{"sub.EXAMPLE.com", "example.COM", true},
829+
{"evilEXAMPLE.com", "example.com", false},
830+
}
831+
for _, tc := range testCases {
832+
require.Equal(t, tc.want, domainMatch(tc.host, tc.domain), "domainMatch(%q, %q)", tc.host, tc.domain)
833+
}
834+
}

client/request.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,9 +1084,9 @@ func SetValWithStruct(p WithStruct, tagName string, v any) {
10841084
setVal = func(name string, val reflect.Value) {
10851085
switch val.Kind() {
10861086
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
1087-
p.Add(name, strconv.Itoa(int(val.Int())))
1087+
p.Add(name, utils.FormatInt(val.Int()))
10881088
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
1089-
p.Add(name, strconv.FormatUint(val.Uint(), 10))
1089+
p.Add(name, utils.FormatUint(val.Uint()))
10901090
case reflect.Float32, reflect.Float64:
10911091
p.Add(name, strconv.FormatFloat(val.Float(), 'f', -1, 64))
10921092
case reflect.Complex64, reflect.Complex128:

constraint.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@ func (c *Constraint) matchConstraint(param string) bool {
228228
type intConstraintType struct{}
229229

230230
func (intConstraintType) Name() string { return ConstraintInt }
231+
232+
// The int-family constraints (int, min, max, range) deliberately stay on
233+
// strconv.Atoi: utils.ParseNativeInt benchmarked slower for the short
234+
// (1-14 digit) params routes actually see, winning only near 19 digits.
231235
func (intConstraintType) Execute(param string, _ []any) bool {
232236
_, err := strconv.Atoi(param)
233237
return err == nil

ctx_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5482,6 +5482,10 @@ func Test_Ctx_Range(t *testing.T) {
54825482
testRange("bytes=-")
54835483
testRange("bytes=500-1000", RangeSet{Start: 500, End: 999})
54845484
testRange("bytes=500-700", RangeSet{Start: 500, End: 700})
5485+
// Range units are case-insensitive (RFC 9110 §14.1); the reported Type is
5486+
// still the canonical lowercase form.
5487+
testRange("Bytes=500-700", RangeSet{Start: 500, End: 700})
5488+
testRange("BYTES=500-700", RangeSet{Start: 500, End: 700})
54855489
testRange("bytes=0-0,2-1000", RangeSet{Start: 0, End: 0}, RangeSet{Start: 2, End: 999})
54865490
testRange("bytes=0-99,450-549,-100", RangeSet{Start: 0, End: 99}, RangeSet{Start: 450, End: 549}, RangeSet{Start: 900, End: 999})
54875491
testRange("bytes=500-700,601-999", RangeSet{Start: 500, End: 700}, RangeSet{Start: 601, End: 999})

helpers.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,7 @@ const (
329329
// of the same coarse class (RFC 9110 §12.5.1).
330330
func acceptsOffer(spec, offer string, _ headerParams) int {
331331
if len(spec) >= 1 && spec[len(spec)-1] == '*' {
332-
prefix := spec[:len(spec)-1]
333-
if len(offer) < len(prefix) {
334-
return 0
335-
}
336-
if utils.EqualFold(prefix, offer[:len(prefix)]) {
332+
if utils.HasPrefixFold(offer, spec[:len(spec)-1]) {
337333
return matchWildcard
338334
}
339335
return 0
@@ -365,7 +361,7 @@ func acceptsLanguageOfferBasic(spec, offer string, _ headerParams) int {
365361
return matchExact
366362
}
367363
if len(offer) > len(spec) &&
368-
utils.EqualFold(offer[:len(spec)], spec) &&
364+
utils.HasPrefixFold(offer, spec) &&
369365
offer[len(spec)] == '-' {
370366
return matchPrefix
371367
}

internal/schemehost/schemehost.go

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/url"
88
"strings"
99

10+
"github.com/gofiber/utils/v2"
1011
utilsstrings "github.com/gofiber/utils/v2/strings"
1112
)
1213

@@ -15,76 +16,120 @@ const (
1516
schemeHTTPS = "https"
1617
)
1718

19+
// schemePorts is the single source of truth for the schemes whose default
20+
// port is normalized away during origin comparison.
21+
var schemePorts = [...]struct {
22+
scheme string
23+
port string
24+
}{
25+
{schemeHTTP, "80"},
26+
{schemeHTTPS, "443"},
27+
}
28+
29+
// foldSchemePort resolves scheme against schemePorts, ASCII
30+
// case-insensitively, returning the canonical lowercase scheme and its
31+
// default port.
32+
func foldSchemePort(scheme string) (canonical, port string, known bool) { //nolint:nonamedreturns // names document the three results
33+
for _, e := range schemePorts {
34+
if utils.EqualFold(scheme, e.scheme) {
35+
return e.scheme, e.port, true
36+
}
37+
}
38+
return "", "", false
39+
}
40+
1841
// Match reports whether (schemeA, hostA) and (schemeB, hostB) denote the same
1942
// origin. Scheme comparison is case-insensitive and default ports (http:80,
2043
// https:443) are normalized so "example.com" and "example.com:443" match.
2144
func Match(schemeA, hostA, schemeB, hostB string) bool {
22-
normalizedSchemeA := utilsstrings.ToLower(schemeA)
23-
normalizedSchemeB := utilsstrings.ToLower(schemeB)
45+
if !utils.EqualFold(schemeA, schemeB) {
46+
return false
47+
}
48+
49+
// Identical host strings always normalize identically, so they denote the
50+
// same origin once the schemes match. This is the dominant same-origin
51+
// input (e.g. Origin-vs-Host on non-CORS requests).
52+
if hostA == hostB {
53+
return true
54+
}
2455

25-
normalizedHostA := normalizeSchemeHost(normalizedSchemeA, hostA)
26-
normalizedHostB := normalizeSchemeHost(normalizedSchemeB, hostB)
56+
scheme, defaultPort, known := foldSchemePort(schemeA)
57+
if !known {
58+
// Unknown schemes get no port normalization: the hosts must simply be
59+
// equal, ASCII case-insensitively.
60+
return utils.EqualFold(hostA, hostB)
61+
}
62+
63+
// Fast path for two clean "host" or "host:port" values (the common case):
64+
// compare the host parts case-insensitively and the effective ports
65+
// exactly, without allocating lowered or port-normalized copies.
66+
if hostOnlyA, portA, cleanA := splitCleanHostPort(hostA); cleanA {
67+
if hostOnlyB, portB, cleanB := splitCleanHostPort(hostB); cleanB {
68+
if portA == "" {
69+
portA = defaultPort
70+
}
71+
if portB == "" {
72+
portB = defaultPort
73+
}
74+
return portA == portB && utils.EqualFold(hostOnlyA, hostOnlyB)
75+
}
76+
}
2777

28-
return normalizedSchemeA == normalizedSchemeB && normalizedHostA == normalizedHostB
78+
// Anything unusual (userinfo, percent-encoding, bracketed IPv6, control
79+
// chars, invalid port, ...) takes the legacy normalize-and-compare path.
80+
return normalizeHostPort(scheme, hostA, defaultPort) == normalizeHostPort(scheme, hostB, defaultPort)
2981
}
3082

31-
func normalizeSchemeHost(scheme, host string) string {
83+
// normalizeHostPort lowercases host and appends defaultPort when no explicit
84+
// port is present. scheme is only used by the url.Parse fallback.
85+
func normalizeHostPort(scheme, host, defaultPort string) string {
3286
host = utilsstrings.ToLower(host)
3387

34-
var defaultPort string
35-
switch scheme {
36-
case schemeHTTP:
37-
defaultPort = "80"
38-
case schemeHTTPS:
39-
defaultPort = "443"
40-
default:
41-
return host
42-
}
43-
44-
// Fast path for a clean "host" or "host:port" value (the common case),
45-
// avoiding the url.Parse allocation. Anything unusual (userinfo, path,
46-
// percent-encoding, bracketed IPv6, control chars, empty/invalid port, ...)
47-
// falls back to the url.Parse path, which preserves the exact legacy behavior.
48-
if hasPort, clean := classifyHostPort(host); clean {
49-
if hasPort {
88+
// Clean "host" or "host:port" values (e.g. the clean side of a mixed
89+
// clean/unclean pair; Match handles the clean/clean case itself) avoid the
90+
// url.Parse allocation. Anything unusual (userinfo, path, percent-encoding,
91+
// bracketed IPv6, control chars, empty/invalid port, ...) falls back to the
92+
// url.Parse path, which preserves the exact legacy behavior.
93+
if _, port, clean := splitCleanHostPort(host); clean {
94+
if port != "" {
5095
return host
5196
}
5297
return host + ":" + defaultPort
5398
}
5499

55-
return normalizeSchemeHostViaParse(scheme, host, defaultPort)
100+
return normalizeHostPortViaParse(scheme, host, defaultPort)
56101
}
57102

58-
// classifyHostPort reports whether host is a plain "<reg-name-or-IPv4>" or
59-
// "<reg-name-or-IPv4>:<port>" value (clean) and, if so, whether it carries an
60-
// explicit numeric port. The accepted character set is deliberately narrow
61-
// (lowercase ASCII letters, digits, '.', '-', and a single ':'); anything else,
62-
// including bracketed IPv6 literals, returns clean=false and is handled by the
103+
// splitCleanHostPort splits a plain "<reg-name-or-IPv4>" or
104+
// "<reg-name-or-IPv4>:<port>" value (clean) into its host and port parts. The
105+
// accepted character set is deliberately narrow (ASCII letters, digits, '.',
106+
// '-', and a single ':' followed by digits); anything else, including
107+
// bracketed IPv6 literals, returns clean=false and is handled by the
63108
// url.Parse fallback so behavior stays identical to the legacy implementation.
64-
func classifyHostPort(host string) (hasPort, clean bool) { //nolint:nonamedreturns // names document the two booleans
109+
func splitCleanHostPort(s string) (host, port string, clean bool) { //nolint:nonamedreturns // names document the three results
65110
colon := -1
66-
for i := 0; i < len(host); i++ {
67-
c := host[i]
111+
for i := 0; i < len(s); i++ {
112+
c := s[i]
68113
switch {
69-
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '.', c == '-':
114+
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-':
70115
// safe reg-name / IPv4 character
71116
case c == ':':
72117
if colon >= 0 {
73-
return false, false // more than one colon -> not a clean host:port
118+
return "", "", false // more than one colon -> not a clean host:port
74119
}
75120
colon = i
76121
default:
77-
return false, false // brackets, control chars, anything else
122+
return "", "", false // brackets, control chars, anything else
78123
}
79124
}
80125

81126
if colon < 0 {
82-
return false, host != "" // no port; empty host falls back to url.Parse
127+
return s, "", s != "" // no port; empty host falls back to url.Parse
83128
}
84-
if !allDigits(host[colon+1:]) {
85-
return false, false // "host:" or "host:abc" -> let url.Parse decide
129+
if !allDigits(s[colon+1:]) {
130+
return "", "", false // "host:" or "host:abc" -> let url.Parse decide
86131
}
87-
return true, true
132+
return s[:colon], s[colon+1:], true
88133
}
89134

90135
// allDigits reports whether s is non-empty and all ASCII digits.
@@ -100,9 +145,9 @@ func allDigits(s string) bool {
100145
return true
101146
}
102147

103-
// normalizeSchemeHostViaParse is the url.Parse-based fallback. host is already
148+
// normalizeHostPortViaParse is the url.Parse-based fallback. host is already
104149
// lowercased and scheme is known to be http or https.
105-
func normalizeSchemeHostViaParse(scheme, host, defaultPort string) string {
150+
func normalizeHostPortViaParse(scheme, host, defaultPort string) string {
106151
parsedHost, err := url.Parse(scheme + "://" + host)
107152
if err != nil {
108153
return host

internal/schemehost/schemehost_test.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ import (
99
"github.com/stretchr/testify/assert"
1010
)
1111

12+
// normalizeSchemeHost is the legacy single-value normalize entry point, kept
13+
// test-side as the bridge between the production normalizeHostPort fallback
14+
// and the url.Parse reference below. The scheme is expected pre-lowered here
15+
// (exact match, so "HTTP" is unknown), matching the original implementation.
16+
func normalizeSchemeHost(scheme, host string) string {
17+
for _, e := range schemePorts {
18+
if scheme == e.scheme {
19+
return normalizeHostPort(scheme, host, e.port)
20+
}
21+
}
22+
return utilsstrings.ToLower(host)
23+
}
24+
1225
// refNormalizeSchemeHost is the original url.Parse-based implementation, kept as
1326
// a behavioral reference. The fast-path normalizeSchemeHost must produce
1427
// identical output for every input.
@@ -96,6 +109,35 @@ func Test_normalizeSchemeHost(t *testing.T) {
96109
}
97110
}
98111

112+
// refMatch is the original implementation of Match, kept as a behavioral
113+
// reference: lowercase both schemes, then compare the normalized scheme-host
114+
// strings. Match must return the same result for every input.
115+
func refMatch(schemeA, hostA, schemeB, hostB string) bool {
116+
normalizedSchemeA := utilsstrings.ToLower(schemeA)
117+
normalizedSchemeB := utilsstrings.ToLower(schemeB)
118+
return normalizedSchemeA == normalizedSchemeB &&
119+
refNormalizeSchemeHost(normalizedSchemeA, hostA) == refNormalizeSchemeHost(normalizedSchemeB, hostB)
120+
}
121+
122+
// Test_Match_matchesReference verifies the allocation-free fast path in Match
123+
// produces the same verdict as the reference implementation across the full
124+
// adversarial corpus.
125+
func Test_Match_matchesReference(t *testing.T) {
126+
t.Parallel()
127+
schemes := []string{"http", "https", "HTTP", "HTTPS", "ftp", ""}
128+
for _, schemeA := range schemes {
129+
for _, schemeB := range schemes {
130+
for _, hostA := range corpus {
131+
for _, hostB := range corpus {
132+
got := Match(schemeA, hostA, schemeB, hostB)
133+
want := refMatch(schemeA, hostA, schemeB, hostB)
134+
assert.Equal(t, want, got, "Match(%q,%q,%q,%q)", schemeA, hostA, schemeB, hostB)
135+
}
136+
}
137+
}
138+
}
139+
}
140+
99141
func Test_Match(t *testing.T) {
100142
t.Parallel()
101143
tests := []struct {
@@ -138,12 +180,25 @@ func Benchmark_normalizeSchemeHost(b *testing.B) {
138180
}
139181

140182
func Benchmark_Match(b *testing.B) {
141-
b.ReportAllocs()
142-
var ok bool
143-
for b.Loop() {
144-
ok = Match("https", "example.com", "https", "example.com")
183+
cases := []struct{ name, schemeA, hostA, schemeB, hostB string }{
184+
// Byte-identical hosts exit at the equality fast path.
185+
{"identical", "https", "example.com", "https", "example.com"},
186+
// Default-port normalization exercises splitCleanHostPort + foldSchemePort.
187+
{"defaultport", "https", "example.com", "https", "example.com:443"},
188+
{"mismatch", "https", "example.com", "https", "evil.example"},
189+
// Unclean host takes the legacy normalize fallback.
190+
{"fallback", "https", "[::1]", "https", "[::1]:443"},
191+
}
192+
for _, tc := range cases {
193+
b.Run(tc.name, func(b *testing.B) {
194+
b.ReportAllocs()
195+
var ok bool
196+
for b.Loop() {
197+
ok = Match(tc.schemeA, tc.hostA, tc.schemeB, tc.hostB)
198+
}
199+
_ = ok
200+
})
145201
}
146-
_ = ok
147202
}
148203

149204
// FuzzNormalizeSchemeHost asserts the fast path stays byte-for-byte equivalent

0 commit comments

Comments
 (0)