Skip to content

Commit e488bce

Browse files
Minimize allocations parsing Content-Type (connectrpc#444)
For context, this function is run on every request within ServeHTTP, and the mime parsing and now lowercasing, is a marginal, but non trivial amount of memory allocations. The expensive bit is hitting the mime.FormatMediaType path, when we already have a canonical form. This removes the most expensive part over arguable the most common cases where there are no additional parameters on the Content-Type. ``` $ go test -bench '^BenchmarkCanonicalizeContentType$' -run '^$' . goos: darwin goarch: arm64 pkg: github.com/bufbuild/connect-go BenchmarkCanonicalizeContentType/simple-10 92344741 12.85 ns/op 0 B/op 0 allocs/op BenchmarkCanonicalizeContentType/with_charset-10 1744219 693.8 ns/op 424 B/op 6 allocs/op BenchmarkCanonicalizeContentType/with_other_param-10 1969113 614.4 ns/op 424 B/op 6 allocs/op PASS ok github.com/bufbuild/connect-go 5.800s ``` --------- Co-authored-by: Akshay Shah <[email protected]>
1 parent 4c2c9a9 commit e488bce

File tree

2 files changed

+52
-5
lines changed

2 files changed

+52
-5
lines changed

protocol.go

+27-5
Original file line numberDiff line numberDiff line change
@@ -315,12 +315,35 @@ func flushResponseWriter(w http.ResponseWriter) {
315315
}
316316
}
317317

318-
func canonicalizeContentType(ct string) string {
319-
base, params, err := mime.ParseMediaType(ct)
320-
if err != nil {
321-
return ct
318+
func canonicalizeContentType(contentType string) string {
319+
// Typically, clients send Content-Type in canonical form, without
320+
// parameters. In those cases, we'd like to avoid parsing and
321+
// canonicalization overhead.
322+
//
323+
// See https://www.rfc-editor.org/rfc/rfc2045.html#section-5.1 for a full
324+
// grammar.
325+
var slashes int
326+
for _, r := range contentType {
327+
switch {
328+
case r >= 'a' && r <= 'z':
329+
case r == '.' || r == '+' || r == '-':
330+
case r == '/':
331+
slashes++
332+
default:
333+
return canonicalizeContentTypeSlow(contentType)
334+
}
335+
}
336+
if slashes == 1 {
337+
return contentType
322338
}
339+
return canonicalizeContentTypeSlow(contentType)
340+
}
323341

342+
func canonicalizeContentTypeSlow(contentType string) string {
343+
base, params, err := mime.ParseMediaType(contentType)
344+
if err != nil {
345+
return contentType
346+
}
324347
// According to RFC 9110 Section 8.3.2, the charset parameter value should be treated as case-insensitive.
325348
// mime.FormatMediaType canonicalizes parameter names, but not parameter values,
326349
// because the case sensitivity of a parameter value depends on its semantics.
@@ -329,6 +352,5 @@ func canonicalizeContentType(ct string) string {
329352
if charset, ok := params["charset"]; ok {
330353
params["charset"] = strings.ToLower(charset)
331354
}
332-
333355
return mime.FormatMediaType(base, params)
334356
}

protocol_test.go

+25
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ func TestCanonicalizeContentType(t *testing.T) {
2727
arg string
2828
want string
2929
}{
30+
{name: "uppercase should be normalized", arg: "APPLICATION/json", want: "application/json"},
3031
{name: "charset param should be treated as lowercase", arg: "application/json; charset=UTF-8", want: "application/json; charset=utf-8"},
3132
{name: "non charset param should not be changed", arg: "multipart/form-data; boundary=fooBar", want: "multipart/form-data; boundary=fooBar"},
33+
{name: "no parameters should be normalized", arg: "APPLICATION/json; ", want: "application/json"},
3234
}
3335
for _, tt := range tests {
3436
tt := tt
@@ -38,3 +40,26 @@ func TestCanonicalizeContentType(t *testing.T) {
3840
})
3941
}
4042
}
43+
44+
func BenchmarkCanonicalizeContentType(b *testing.B) {
45+
b.Run("simple", func(b *testing.B) {
46+
for i := 0; i < b.N; i++ {
47+
_ = canonicalizeContentType("application/json")
48+
}
49+
b.ReportAllocs()
50+
})
51+
52+
b.Run("with charset", func(b *testing.B) {
53+
for i := 0; i < b.N; i++ {
54+
_ = canonicalizeContentType("application/json; charset=utf-8")
55+
}
56+
b.ReportAllocs()
57+
})
58+
59+
b.Run("with other param", func(b *testing.B) {
60+
for i := 0; i < b.N; i++ {
61+
_ = canonicalizeContentType("application/json; foo=utf-8")
62+
}
63+
b.ReportAllocs()
64+
})
65+
}

0 commit comments

Comments
 (0)