Skip to content

Conversation

@1911860538
Copy link
Contributor

@1911860538 1911860538 commented Oct 30, 2025

Replace regex operations with custom functions (sanitizePathChars and removeRepeatedChar) to improve performance.

@1911860538
Copy link
Contributor Author

1911860538 commented Oct 30, 2025

sanitizePathChars test code

package redirectslash

import (
	"regexp"
	"strings"
	"testing"
)

var regSafePrefix = regexp.MustCompile("[^a-zA-Z0-9/-]+")

func filterSafeCharsRegexp(s string) string {
	return regSafePrefix.ReplaceAllString(s, "")
}

func filterSafeCharsMap(s string) string {
	return strings.Map(func(r rune) rune {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '/' || r == '-' {
			return r
		}
		return -1
	}, s)
}

func filterSafeCharsCustom(s string) string {
	var sb strings.Builder
	sb.Grow(len(s))
	for _, r := range s {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '/' || r == '-' {
			sb.WriteRune(r)
		}
	}
	return sb.String()
}

func benchmarkFilterChars(b *testing.B, prefix string) {
	testCases := []struct {
		name string
		fn   func(string) string
	}{
		{
			name: "regexp",
			fn:   filterSafeCharsRegexp,
		},
		{
			name: "map",
			fn:   filterSafeCharsMap,
		},
		{
			name: "custom",
			fn:   filterSafeCharsCustom,
		},
	}

	for _, tc := range testCases {
		b.Run(prefix+" "+tc.name, func(b *testing.B) {
			b.ResetTimer()
			b.ReportAllocs()
			for b.Loop() {
				tc.fn(prefix)
			}
		})
	}

}

func BenchmarkSafe(b *testing.B) {
	benchmarkFilterChars(b, "safe-PREFIX/123")
}

func BenchmarkUnsafe(b *testing.B) {
	benchmarkFilterChars(b, "?unsafe-PREFIX/123@_++__")
}

sanitizePathChars benchmark output

GOROOT=/Users/huangzhiwen/go/go1.25.1 #gosetup
GOPATH=/Users/huangzhiwen/about_code/golang_code/GOPATH #gosetup
GOPROXY=https://goproxy.cn,direct #gosetup
/Users/huangzhiwen/go/go1.25.1/bin/go test -c -o /Users/huangzhiwen/Library/Caches/JetBrains/GoLand2025.1/tmp/GoLand/___gobench_testgolang_gin_redirectslash.test testgolang/gin/redirectslash #gosetup
/Users/huangzhiwen/Library/Caches/JetBrains/GoLand2025.1/tmp/GoLand/___gobench_testgolang_gin_redirectslash.test -test.v -test.paniconexit0 -test.bench . -test.run ^$ #gosetup
goos: darwin
goarch: amd64
pkg: testgolang/gin/redirectslash
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkSafe
BenchmarkSafe/safe-PREFIX/123_regexp
BenchmarkSafe/safe-PREFIX/123_regexp-8         	 2849829	       410.2 ns/op	      48 B/op	       3 allocs/op
BenchmarkSafe/safe-PREFIX/123_map
BenchmarkSafe/safe-PREFIX/123_map-8            	27569689	        38.61 ns/op	       0 B/op	       0 allocs/op
BenchmarkSafe/safe-PREFIX/123_custom
BenchmarkSafe/safe-PREFIX/123_custom-8         	15801300	        70.53 ns/op	       16 B/op	       0 allocs/op
BenchmarkUnsafe
BenchmarkUnsafe/?unsafe-PREFIX/123@_++___regexp
BenchmarkUnsafe/?unsafe-PREFIX/123@_++___regexp-8         	 1647514	       724.4 ns/op	      64 B/op	       3 allocs/op
BenchmarkUnsafe/?unsafe-PREFIX/123@_++___map
BenchmarkUnsafe/?unsafe-PREFIX/123@_++___map-8            	10601641	       104.0 ns/op	      32 B/op	       1 allocs/op
BenchmarkUnsafe/?unsafe-PREFIX/123@_++___custom
BenchmarkUnsafe/?unsafe-PREFIX/123@_++___custom-8         	10906867	       105.6 ns/op	      24 B/op	       1 allocs/op
PASS

Process finished with the exit code 0

@codecov
Copy link

codecov bot commented Oct 30, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.04%. Comparing base (3dc1cd6) to head (d661e32).
⚠️ Report is 197 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4414      +/-   ##
==========================================
- Coverage   99.21%   99.04%   -0.18%     
==========================================
  Files          42       44       +2     
  Lines        3182     2928     -254     
==========================================
- Hits         3157     2900     -257     
  Misses         17       17              
- Partials        8       11       +3     
Flag Coverage Δ
?
--ldflags="-checklinkname=0" -tags sonic 99.03% <100.00%> (?)
-tags go_json 98.89% <100.00%> (?)
-tags nomsgpack 98.96% <100.00%> (?)
go-1.18 ?
go-1.19 ?
go-1.20 ?
go-1.21 ?
go-1.24 98.97% <100.00%> (?)
go-1.25 99.04% <100.00%> (?)
macos-latest 99.04% <100.00%> (-0.18%) ⬇️
ubuntu-latest 98.97% <100.00%> (-0.24%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@1911860538
Copy link
Contributor Author

1911860538 commented Oct 30, 2025

removeRepeatedSlash test code

package redirectslash

import (
	"regexp"
	"strings"
	"testing"
)

var regRemoveRepeatedChar = regexp.MustCompile("/{2,}")

func removeRepeatedSlashRegexp(s string) string {
	return regRemoveRepeatedChar.ReplaceAllString(s, "/")
}

func removeRepeatedSlashLoopReplace(s string) string {
	for strings.Contains(s, "//") {
		s = strings.ReplaceAll(s, "//", "/")
	}
	return s
}

func removeRepeatedSlashStringBuilder(s string) string {
	if !strings.Contains(s, "//") {
		return s
	}

	var sb strings.Builder
	sb.Grow(len(s) - 1)
	prevChar := rune(0)

	for _, r := range s {
		if r == '/' && prevChar == '/' {
			continue
		}
		sb.WriteRune(r)
		prevChar = r
	}

	return sb.String()
}

// removeRepeatedSlash removes multiple consecutive slashes from a string.
func removeRepeatedSlash(s string) string {
	return removeRepeatedChar(s, '/')
}

// removeRepeatedChar removes multiple consecutive 'char's from a string.
// if s == "/a//b///c////" && char == '/', the func returns "/a/b/c/"
func removeRepeatedChar(s string, char byte) string {
	// Check if there are any consecutive chars
	hasRepeatedChar := false
	for i := 1; i < len(s); i++ {
		if s[i] == char && s[i-1] == char {
			hasRepeatedChar = true
			break
		}
	}
	if !hasRepeatedChar {
		return s
	}

	const stackBufSize = 128

	// Reasonably sized buffer on stack to avoid allocations in the common case.
	buf := make([]byte, 0, stackBufSize)

	// Invariants:
	//      reading from s; r is index of next byte to process.
	//      writing to buf; w is index of next byte to write.
	r := 0
	w := 0

	for n := len(s); r < n; {
		if s[r] == char {
			// Write the first char
			bufApp(&buf, s, w, char)
			w++
			r++

			// Skip all consecutive chars
			for r < n && s[r] == char {
				r++
			}
		} else {
			// Copy non-char character
			bufApp(&buf, s, w, s[r])
			w++
			r++
		}
	}

	// If the original string was not modified (or only shortened at the end),
	// return the respective substring of the original string.
	// Otherwise, return a new string from the buffer.
	if len(buf) == 0 {
		return s[:w]
	}
	return string(buf[:w])
}

func bufApp(buf *[]byte, s string, w int, c byte) {
	b := *buf
	if len(b) == 0 {
		if s[w] == c {
			return
		}

		length := len(s)
		if length > cap(b) {
			*buf = make([]byte, length)
		} else {
			*buf = (*buf)[:length]
		}
		b = *buf

		copy(b, s[:w])
	}
	b[w] = c
}

func benchmarkRemoveRepeatedSlash(b *testing.B, prefix string) {
	testCases := []struct {
		name string
		fn   func(string) string
	}{
		{
			name: "regexp",
			fn:   removeRepeatedSlashRegexp,
		},
		{
			name: "loopReplace",
			fn:   removeRepeatedSlashLoopReplace,
		},
		{
			name: "stringBuilder",
			fn:   removeRepeatedSlashStringBuilder,
		},
		{
			name: "buff",
			fn:   removeRepeatedSlash,
		},
	}

	for _, tc := range testCases {
		b.Run(prefix+" "+tc.name, func(b *testing.B) {
			b.ResetTimer()
			b.ReportAllocs()
			for b.Loop() {
				tc.fn(prefix)
			}
		})
	}
}

func BenchmarkRemoveRepeatedSlash_MultipleSlashes(b *testing.B) {
	prefix := "/somePrefix/more//text///more////"
	benchmarkRemoveRepeatedSlash(b, prefix)
}

func BenchmarkRemoveRepeatedSlash_TwoSlashes(b *testing.B) {
	prefix := "/somePrefix/more//"
	benchmarkRemoveRepeatedSlash(b, prefix)
}

func BenchmarkRemoveNoRepeatedSlash(b *testing.B) {
	prefix := "/somePrefix/more/text/"
	benchmarkRemoveRepeatedSlash(b, prefix)
}

func BenchmarkRemoveNoSlash(b *testing.B) {
	prefix := "/somePrefixmoretext"
	benchmarkRemoveRepeatedSlash(b, prefix)
}

removeRepeatedSlash benchmark output

goos: darwin
goarch: amd64
pkg: testgolang/gin/redirectslash
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkRemoveRepeatedSlash_MultipleSlashes
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_regexp
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_regexp-8         	 1820608	       631.6 ns/op	      96 B/op	       4 allocs/op
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_loopReplace
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_loopReplace-8    	 5324178	       218.0 ns/op	      64 B/op	       2 allocs/op
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_stringBuilder
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_stringBuilder-8  	 8451607	       139.5 ns/op	      32 B/op	       1 allocs/op
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_buff
BenchmarkRemoveRepeatedSlash_MultipleSlashes//somePrefix/more//text///more////_buff-8           	14338978	        79.26 ns/op	      32 B/op	       1 allocs/op
BenchmarkRemoveRepeatedSlash_TwoSlashes
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_regexp
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_regexp-8                             	 4360737	       269.2 ns/op	      88 B/op	       4 allocs/op
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_loopReplace
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_loopReplace-8                        	15092016	        76.86 ns/op	      24 B/op	       1 allocs/op
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_stringBuilder
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_stringBuilder-8                      	11847382	        96.74 ns/op	      24 B/op	       1 allocs/op
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_buff
BenchmarkRemoveRepeatedSlash_TwoSlashes//somePrefix/more//_buff-8                               	33539736	        33.75 ns/op	       0 B/op	       0 allocs/op
BenchmarkRemoveNoRepeatedSlash
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_regexp
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_regexp-8                                  	 9032924	       127.7 ns/op	      64 B/op	       3 allocs/op
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_loopReplace
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_loopReplace-8                             	145263895	         8.230 ns/op	       0 B/op	       0 allocs/op
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_stringBuilder
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_stringBuilder-8                           	143057104	         8.299 ns/op	       0 B/op	       0 allocs/op
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_buff
BenchmarkRemoveNoRepeatedSlash//somePrefix/more/text/_buff-8                                    	98410974	        11.26 ns/op	       0 B/op	       0 allocs/op
BenchmarkRemoveNoSlash
BenchmarkRemoveNoSlash//somePrefixmoretext_regexp
BenchmarkRemoveNoSlash//somePrefixmoretext_regexp-8                                             	 9027658	       127.7 ns/op	      64 B/op	       3 allocs/op
BenchmarkRemoveNoSlash//somePrefixmoretext_loopReplace
BenchmarkRemoveNoSlash//somePrefixmoretext_loopReplace-8                                        	144839606	         8.237 ns/op	       0 B/op	       0 allocs/op
BenchmarkRemoveNoSlash//somePrefixmoretext_stringBuilder
BenchmarkRemoveNoSlash//somePrefixmoretext_stringBuilder-8                                      	146553392	         8.171 ns/op	       0 B/op	       0 allocs/op
BenchmarkRemoveNoSlash//somePrefixmoretext_buff
BenchmarkRemoveNoSlash//somePrefixmoretext_buff-8                                               	141872106	         8.425 ns/op	       0 B/op	       0 allocs/op
PASS

Process finished with the exit code 0

@1911860538 1911860538 marked this pull request as draft October 31, 2025 07:08
@1911860538 1911860538 marked this pull request as ready for review October 31, 2025 13:25
@appleboy
Copy link
Member

Can you provide the final benchmark report?

@1911860538
Copy link
Contributor Author

Can you provide the final benchmark report?

For this pull request, the previous two comments actually represent the most recently updated benchmark results.
I will test the HTTP redirection using hey tomorrow and provide the corresponding test results.

@1911860538
Copy link
Contributor Author

I just fixed the sanitizePathChars function. Previously, the regular expression was regexp.MustCompile("[^a-zA-Z0-9/-]+") and using unicode.IsLetter(r) and unicode.IsDigit(r) was incorrect.

@1911860538
Copy link
Contributor Author

The following are performance tests conducted using the hey tool.

server code

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	router := gin.Default()
	gin.SetMode(gin.ReleaseMode)

	router.GET("/api/users/", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{})
	})

	if err := router.Run(":8080"); err != nil {
		panic(err)
	}
}

hey command (simple X-Forwarded-Prefix)

hey -n 100000 -c 100 -H "X-Forwarded-Prefix: api/v1" http://localhost:8080/api/users

hey output (before optimization)

Summary:
  Total:        4.6750 secs
  Slowest:      0.0531 secs
  Fastest:      0.0002 secs
  Average:      0.0046 secs
  Requests/sec: 21390.5336
  
  Total data:   1800000 bytes
  Size/request: 18 bytes

Response time histogram:
  0.000 [1]     |
  0.006 [72307] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.011 [20884] |■■■■■■■■■■■■
  0.016 [4877]  |■■■
  0.021 [1328]  |■
  0.027 [330]   |
  0.032 [161]   |
  0.037 [47]    |
  0.042 [49]    |
  0.048 [9]     |
  0.053 [7]     |


Latency distribution:
  10% in 0.0013 secs
  25% in 0.0021 secs
  50% in 0.0034 secs
  75% in 0.0059 secs
  90% in 0.0093 secs
  95% in 0.0121 secs
  99% in 0.0189 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0000 secs, 0.0002 secs, 0.0531 secs
  DNS-lookup:   0.0000 secs, 0.0000 secs, 0.0077 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0319 secs
  resp wait:    0.0020 secs, 0.0001 secs, 0.0463 secs
  resp read:    0.0002 secs, 0.0000 secs, 0.0446 secs

Status code distribution:
  [404] 100000 responses

hey output (after optimization)

Summary:
  Total:        4.4728 secs
  Slowest:      0.0542 secs
  Fastest:      0.0002 secs
  Average:      0.0044 secs
  Requests/sec: 22357.3566
  
  Total data:   1800000 bytes
  Size/request: 18 bytes

Response time histogram:
  0.000 [1]     |
  0.006 [73335] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.011 [21843] |■■■■■■■■■■■■
  0.016 [3805]  |■■
  0.022 [762]   |
  0.027 [193]   |
  0.033 [38]    |
  0.038 [17]    |
  0.043 [3]     |
  0.049 [2]     |
  0.054 [1]     |


Latency distribution:
  10% in 0.0012 secs
  25% in 0.0021 secs
  50% in 0.0035 secs
  75% in 0.0058 secs
  90% in 0.0087 secs
  95% in 0.0109 secs
  99% in 0.0165 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0000 secs, 0.0002 secs, 0.0542 secs
  DNS-lookup:   0.0000 secs, 0.0000 secs, 0.0052 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0177 secs
  resp wait:    0.0019 secs, 0.0001 secs, 0.0342 secs
  resp read:    0.0002 secs, 0.0000 secs, 0.0206 secs

Status code distribution:
  [404] 100000 responses

hey command (complex X-Forwarded-Prefix)

hey -n 100000 -c 100 -H "X-Forwarded-Prefix: api;v1//users///test=123" http://localhost:8080/api/users

hey output (before optimization)

Summary:
  Total:        4.5465 secs
  Slowest:      0.0365 secs
  Fastest:      0.0002 secs
  Average:      0.0045 secs
  Requests/sec: 21995.0219
  
  Total data:   1800000 bytes
  Size/request: 18 bytes

Response time histogram:
  0.000 [1]     |
  0.004 [52760] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.007 [31371] |■■■■■■■■■■■■■■■■■■■■■■■■
  0.011 [11718] |■■■■■■■■■
  0.015 [2951]  |■■
  0.018 [840]   |■
  0.022 [254]   |
  0.026 [77]    |
  0.029 [20]    |
  0.033 [5]     |
  0.036 [3]     |


Latency distribution:
  10% in 0.0012 secs
  25% in 0.0021 secs
  50% in 0.0036 secs
  75% in 0.0061 secs
  90% in 0.0087 secs
  95% in 0.0106 secs
  99% in 0.0152 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0000 secs, 0.0002 secs, 0.0365 secs
  DNS-lookup:   0.0000 secs, 0.0000 secs, 0.0038 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0123 secs
  resp wait:    0.0020 secs, 0.0001 secs, 0.0248 secs
  resp read:    0.0002 secs, 0.0000 secs, 0.0221 secs

Status code distribution:
  [404] 100000 responses

hey output (after optimization)

Summary:
  Total:        4.3727 secs
  Slowest:      0.0339 secs
  Fastest:      0.0002 secs
  Average:      0.0043 secs
  Requests/sec: 22869.1870
  
  Total data:   1800000 bytes
  Size/request: 18 bytes

Response time histogram:
  0.000 [1]     |
  0.004 [52136] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.007 [30845] |■■■■■■■■■■■■■■■■■■■■■■■■
  0.010 [11981] |■■■■■■■■■
  0.014 [3356]  |■■■
  0.017 [1178]  |■
  0.020 [367]   |
  0.024 [100]   |
  0.027 [25]    |
  0.031 [7]     |
  0.034 [4]     |


Latency distribution:
  10% in 0.0012 secs
  25% in 0.0020 secs
  50% in 0.0034 secs
  75% in 0.0058 secs
  90% in 0.0084 secs
  95% in 0.0104 secs
  99% in 0.0151 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0000 secs, 0.0002 secs, 0.0339 secs
  DNS-lookup:   0.0000 secs, 0.0000 secs, 0.0054 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0151 secs
  resp wait:    0.0019 secs, 0.0001 secs, 0.0318 secs
  resp read:    0.0002 secs, 0.0000 secs, 0.0176 secs

Status code distribution:
  [404] 100000 responses

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants