10 Commits

Author SHA1 Message Date
d7eb25ddff README 2025-11-25 18:27:01 +01:00
788a736e06 Fix pooled params correctness and add context copy benchmark 2025-11-25 15:59:31 +01:00
3b94964068 Update README.md 2025-10-28 00:46:37 +01:00
1dba802590 Update README.md 2025-10-28 00:17:59 +01:00
045ac8b5a3 Implement performance optimizations v1.1.0
- Replace map-based Params with slice-based structure for 25-40% reduction in allocations
- Implement zero-allocation path parsing with pre-allocation
- Add enhanced memory pooling for parameters, segments, and string builders
- Optimize wildcard parameter handling with string builders
- Add comprehensive performance benchmarks
- Achieve 14-49% performance improvements across all route types
- Maintain full API compatibility

Performance improvements:
- Static routes: 14.6% faster (682.1 vs 798.5 ns/op)
- Parameter routes: 24.7% faster (868.9 vs 1154 ns/op)
- Wildcard routes: 41.6% faster (979.3 vs 1676 ns/op)
- Multiple parameters: 41.5% faster (1026 vs 1753 ns/op)
- Middleware: 49.0% faster (1930 vs 3782 ns/op)
- Route groups: 49.5% faster (1442 vs 2855 ns/op)
- Large router: 24.5% faster (833.0 vs 1103 ns/op)
2025-10-26 18:06:15 +01:00
db42316b49 Optimize tree structure with hash map for static children 2025-10-26 12:59:45 +01:00
152460aed6 added comparison 2025-10-26 12:29:15 +01:00
6f87960f3a support for parameters, middleware, and route groups. 2025-10-26 12:25:18 +01:00
6c318a988c update go version 2025-10-26 12:11:18 +01:00
36512affab add gitignore 2025-10-26 12:10:01 +01:00
7 changed files with 1386 additions and 195 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea

188
README.md
View File

@@ -1,130 +1,120 @@
# Sux # sux
Static route http router that considers the request method An allocation-conscious, middleware-capable HTTP router for Go with support for static routes, parameters (`:id`), wildcards (`*path`), route groups, and configurable 404/405 handling.
Useful for serving server-side rendered content. ## Installation
## How ```bash
go get code.icod.de/dalu/sux
```
## Quick start
```go ```go
package main package main
import ( import (
"code.icod.de/dalu/sux"
"io"
"net/http" "net/http"
"code.icod.de/dalu/sux"
) )
func main() { func main() {
r := sux.New() r := sux.New()
r.GET("/", Hello)
r.GET("/simple", Simple) r.GET("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
r.GET("/users/:id", func(w http.ResponseWriter, r *http.Request) {
params := sux.ParamsFromContext(r)
w.Write([]byte("user " + params.Get("id")))
})
http.ListenAndServe(":8080", r) http.ListenAndServe(":8080", r)
} }
```
func Hello(w http.ResponseWriter, r *http.Request) { ## Middleware
io.WriteString(w, "hello")
Middleware wraps handlers and can be applied globally or per route group.
```go
logger := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// log request here
next.ServeHTTP(w, r)
})
} }
func Simple(w http.ResponseWriter, r *http.Request) { r := sux.New()
name := r.URL.Query().Get("name") r.Use(logger) // global
io.WriteString(w, "hello "+name)
} r.GET("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
``` ```
### Performance ## Route groups
``` Groups share a prefix and middleware.
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/
Running 30s test @ http://inuc:8080/
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 7.08ms 3.02ms 64.38ms 89.40%
Req/Sec 18.24k 2.11k 24.06k 73.88%
4260394 requests in 30.00s, 491.63MB read
Requests/sec: 142025.60
Transfer/sec: 16.39MB
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/simple?name=Darko
Running 30s test @ http://inuc:8080/simple?name=Darko
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 8.11ms 3.84ms 243.61ms 89.96%
Req/Sec 16.01k 2.20k 23.88k 72.87%
3723315 requests in 30.00s, 454.51MB read
Requests/sec: 124116.94
Transfer/sec: 15.15MB
```go
api := r.Group("/api", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-API-Version", "v1")
next.ServeHTTP(w, r)
})
})
api.GET("/users", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("users"))
})
``` ```
Compared to https://github.com/julienschmidt/httprouter ## Parameters and wildcards
```
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/ ```go
Running 30s test @ http://inuc:8080/ r.GET("/posts/:postId", func(w http.ResponseWriter, r *http.Request) {
8 threads and 1000 connections p := sux.ParamsFromContext(r)
Thread Stats Avg Stdev Max +/- Stdev w.Write([]byte(p.Get("postId")))
Latency 7.14ms 3.13ms 74.06ms 89.99% })
Req/Sec 18.18k 2.19k 23.31k 75.88%
4224358 requests in 30.00s, 487.47MB read r.GET("/files/*path", func(w http.ResponseWriter, r *http.Request) {
Requests/sec: 140826.15 p := sux.ParamsFromContext(r)
Transfer/sec: 16.25MB w.Write([]byte("file: " + p.Get("path")))
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/simple/Darko })
Running 30s test @ http://inuc:8080/simple/Darko
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 7.78ms 3.40ms 60.03ms 88.79%
Req/Sec 16.55k 1.86k 19.95k 66.95%
3873629 requests in 30.00s, 472.86MB read
Requests/sec: 129131.98
Transfer/sec: 15.76MB
``` ```
Compared to https://github.com/gocraft/web ## 404/405 handlers
```
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/ ```go
Running 30s test @ http://inuc:8080/ r.NotFound(func(w http.ResponseWriter, r *http.Request) {
8 threads and 1000 connections http.Error(w, "custom 404", http.StatusNotFound)
Thread Stats Avg Stdev Max +/- Stdev })
Latency 9.07ms 4.58ms 420.89ms 91.40%
Req/Sec 14.42k 2.28k 25.00k 80.03% r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
3309152 requests in 30.00s, 378.70MB read http.Error(w, "custom 405", http.StatusMethodNotAllowed)
Requests/sec: 110315.36 })
Transfer/sec: 12.62MB
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/simple // Disable cross-method probing if you prefer 404s instead of 405 checks
Running 30s test @ http://inuc:8080/simple r.EnableMethodNotAllowedCheck(false)
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 9.86ms 4.00ms 66.98ms 87.79%
Req/Sec 13.05k 1.65k 17.52k 69.71%
3055899 requests in 30.00s, 375.95MB read
Requests/sec: 101874.46
Transfer/sec: 12.53MB
``` ```
Compared to https://github.com/gomango/mux ## Performance notes
```
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/ - Internally pools parsed segments and parameter storage to reduce per-request allocations.
Running 30s test @ http://inuc:8080/ - Parameters are copied into the request context so they remain valid even after handlers return (important for async users of the context).
8 threads and 1000 connections - For raw numbers, run `go test -bench . -benchmem`.
Thread Stats Avg Stdev Max +/- Stdev
Latency 9.22ms 12.73ms 235.88ms 84.36% ## Testing
Req/Sec 13.78k 1.77k 25.52k 74.17%
3242004 requests in 30.00s, 377.20MB read ```bash
Socket errors: connect 0, read 0, write 0, timeout 10 go test ./...
Requests/sec: 108078.84
Transfer/sec: 12.57MB
darko@arch ~ $ wrk -c1000 -t8 -d30s http://inuc:8080/simple
Running 30s test @ http://inuc:8080/simple
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 16.24ms 14.39ms 150.41ms 56.30%
Req/Sec 7.77k 593.31 9.78k 68.20%
1841839 requests in 30.00s, 226.59MB read
Requests/sec: 61402.97
Transfer/sec: 7.55MB
``` ```
Apples and Oranges but not quite Benchmarks:
Hardware ```bash
go test -bench . -benchmem
http://www.intel.com/content/www/us/en/nuc/nuc-kit-d54250wykh.html ```

212
benchmark_test.go Normal file
View File

@@ -0,0 +1,212 @@
package sux
import (
"net/http"
"net/http/httptest"
"testing"
)
// BenchmarkStaticRoute benchmarks basic static route performance
func BenchmarkStaticRoute(b *testing.B) {
router := New()
router.GET("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("home"))
})
req := httptest.NewRequest("GET", "/", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkParameterRoute benchmarks parameter route performance
func BenchmarkParameterRoute(b *testing.B) {
router := New()
router.GET("/users/:id", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("id") // Use the parameter
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/users/123", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkWildcardRoute benchmarks wildcard route performance
func BenchmarkWildcardRoute(b *testing.B) {
router := New()
router.GET("/files/*path", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("path") // Use the parameter
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/files/docs/readme.txt", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkMultipleParameters benchmarks multiple parameter route performance
func BenchmarkMultipleParameters(b *testing.B) {
router := New()
router.GET("/users/:userId/posts/:postId/comments/:commentId", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("userId") + params.Get("postId") + params.Get("commentId") // Use parameters
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/users/123/posts/456/comments/789", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkMiddleware benchmarks middleware performance
func BenchmarkMiddleware(b *testing.B) {
router := New()
// Add multiple middleware layers
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Middleware-1", "1")
next.ServeHTTP(w, r)
})
})
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Middleware-2", "2")
next.ServeHTTP(w, r)
})
})
router.GET("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkRouteGroups benchmarks route group performance
func BenchmarkRouteGroups(b *testing.B) {
router := New()
api := router.Group("/api", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-API-Version", "v1")
next.ServeHTTP(w, r)
})
})
api.GET("/users", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/api/users", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkNotFound benchmarks 404 performance
func BenchmarkNotFound(b *testing.B) {
router := New()
router.GET("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/nonexistent", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkMethodNotAllowed benchmarks 405 performance
func BenchmarkMethodNotAllowed(b *testing.B) {
router := New()
router.GET("/resource", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("POST", "/resource", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkLargeRouter benchmarks performance with many routes
func BenchmarkLargeRouter(b *testing.B) {
router := New()
// Add 1000 routes
for i := 0; i < 1000; i++ {
path := "/resource/" + string(rune(i))
router.GET(path, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
}
// Test the last route
req := httptest.NewRequest("GET", "/resource/999", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}

2
go.mod
View File

@@ -1,3 +1,3 @@
module code.icod.de/dalu/sux module code.icod.de/dalu/sux
go 1.17 go 1.25.3

220
performance_test.go Normal file
View File

@@ -0,0 +1,220 @@
package sux
import (
"net/http"
"net/http/httptest"
"runtime"
"testing"
)
// BenchmarkParameterOperations benchmarks the new parameter handling
func BenchmarkParameterOperations(b *testing.B) {
params := Params{
keys: make([]string, 0, 4),
values: make([]string, 0, 4),
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
params.Set("id", "123")
params.Set("name", "test")
_ = params.Get("id")
_ = params.Get("name")
params.Reset()
}
}
// BenchmarkPathParsing compares old vs new path parsing
func BenchmarkPathParsingOld(b *testing.B) {
path := "/users/123/posts/456/comments/789"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = parsePath(path)
}
}
func BenchmarkPathParsingNew(b *testing.B) {
path := "/users/123/posts/456/comments/789"
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = parsePathZeroAlloc(path)
}
}
// BenchmarkMemoryUsage tests memory efficiency under load
func BenchmarkMemoryUsage(b *testing.B) {
router := New()
// Add routes with different patterns
router.GET("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
router.GET("/users/:id", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("id")
w.WriteHeader(http.StatusOK)
})
router.GET("/files/*path", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("path")
w.WriteHeader(http.StatusOK)
})
requests := []struct {
method string
path string
}{
{"GET", "/"},
{"GET", "/users/123"},
{"GET", "/files/docs/readme.txt"},
}
var reqs []*http.Request
for _, req := range requests {
for i := 0; i < 100; i++ {
reqs = append(reqs, httptest.NewRequest(req.method, req.path, nil))
}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, reqs[i%len(reqs)])
}
}
// BenchmarkConcurrentAccess tests performance under high concurrency
func BenchmarkConcurrentAccess(b *testing.B) {
router := New()
router.GET("/test/:id", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("id")
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/test/123", nil)
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
})
}
// BenchmarkGCPressure measures GC pressure from allocations
func BenchmarkGCPressure(b *testing.B) {
router := New()
router.GET("/users/:id/posts/:postId", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("id") + params.Get("postId")
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/users/123/posts/456", nil)
// Force GC before benchmark
runtime.GC()
var m1, m2 runtime.MemStats
runtime.ReadMemStats(&m1)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
b.StopTimer()
runtime.ReadMemStats(&m2)
b.Logf("Allocs: %d, TotalAlloc: %d bytes", m2.Mallocs-m1.Mallocs, m2.TotalAlloc-m1.TotalAlloc)
}
// BenchmarkWildcardOptimization tests the wildcard parameter optimization
func BenchmarkWildcardOptimization(b *testing.B) {
router := New()
router.GET("/files/*path", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("path")
w.WriteHeader(http.StatusOK)
})
// Test with varying path lengths
paths := []string{
"/files/file.txt",
"/files/docs/readme.txt",
"/files/docs/api/v1/users.json",
"/files/very/deep/nested/path/with/many/segments/file.txt",
}
var reqs []*http.Request
for _, path := range paths {
for i := 0; i < 25; i++ {
reqs = append(reqs, httptest.NewRequest("GET", path, nil))
}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, reqs[i%len(reqs)])
}
}
// BenchmarkPoolEfficiency tests the efficiency of our pooling strategy
func BenchmarkPoolEfficiency(b *testing.B) {
router := New()
router.GET("/test/:param", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("param")
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/test/value", nil)
b.ResetTimer()
b.ReportAllocs()
// This should show minimal allocations due to pooling
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}
// BenchmarkContextParamCopy measures overhead of copying params into context
func BenchmarkContextParamCopy(b *testing.B) {
router := New()
router.GET("/users/:id/posts/:postId", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
_ = params.Get("id") + params.Get("postId")
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/users/123/posts/456", nil)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
}
}

605
sux.go
View File

@@ -1,128 +1,567 @@
package sux package sux
import ( import (
"context"
"net/http" "net/http"
"sync"
) )
const ( const (
nomatch uint8 = iota static nodeType = iota
static param
wildcard
) )
type ( type (
node struct { nodeType uint8
term byte
ntype uint8 // Params holds route parameters extracted from the URL
handler http.HandlerFunc // Optimized to reduce allocations compared to map[string]string
child []*node Params struct {
keys []string
values []string
} }
// MiddlewareFunc represents a middleware function
MiddlewareFunc func(http.Handler) http.Handler
node struct {
path string
nodeType nodeType
handler http.HandlerFunc
children map[string]*node // Hash map for O(1) static child lookup
paramChild *node // For :param routes
wildcardChild *node // For *param routes
paramName string // Name of the parameter
middleware []MiddlewareFunc
}
Router struct { Router struct {
route map[string]*node trees map[string]*node // One tree per HTTP method
middleware []MiddlewareFunc
notFoundHandler http.HandlerFunc
methodNotAllowedHandler http.HandlerFunc
paramsPool *sync.Pool // Use pointer to avoid copying
segmentsPool *sync.Pool // Pool for path segments
prefix string // For route groups
checkMethodNotAllowed bool // Whether to probe other methods on 404
} }
) )
var root *Router // Context key for storing route parameters
type contextKey string
func makenode(term byte) *node { const paramsKey contextKey = "params"
// ParamsFromContext extracts route parameters from the request context
func ParamsFromContext(r *http.Request) Params {
switch params := r.Context().Value(paramsKey).(type) {
case Params:
return params
case *Params:
if params != nil {
return *params
}
}
return Params{}
}
// Get returns the value for the given key
func (p Params) Get(key string) string {
for i, k := range p.keys {
if k == key {
return p.values[i]
}
}
return ""
}
// Set sets the value for the given key
func (p *Params) Set(key, value string) {
// Check if key already exists
for i, k := range p.keys {
if k == key {
p.values[i] = value
return
}
}
// Add new key-value pair
p.keys = append(p.keys, key)
p.values = append(p.values, value)
}
// Reset clears all parameters for reuse
func (p *Params) Reset() {
p.keys = p.keys[:0]
p.values = p.values[:0]
}
// Len returns the number of parameters
func (p Params) Len() int {
return len(p.keys)
}
// makeNode creates a new node with the given path
func makeNode(path string) *node {
return &node{ return &node{
term: term, path: path,
child: make([]*node, 0), children: make(map[string]*node),
} }
} }
func (n *node) addchild(term byte) *node { // addChild adds a child node for the given path segment
if fn := n.findchild(term); fn == nil { func (n *node) addChild(path string) *node {
nn := makenode(term) if child := n.findChild(path); child == nil {
n.child = append(n.child, nn) newNode := makeNode(path)
return nn n.children[path] = newNode
return newNode
} else { } else {
return fn return child
} }
} }
func (n *node) maketree(word []byte, handler http.HandlerFunc) { // addParamChild adds a parameter child node
m := n func (n *node) addParamChild(paramName string) *node {
for i, l := 1, len(word); i < l; i++ { if n.paramChild == nil {
m = m.addchild(word[i]) n.paramChild = makeNode(":" + paramName)
n.paramChild.nodeType = param
n.paramChild.paramName = paramName
} }
m.ntype = static return n.paramChild
m.handler = handler
} }
func (n *node) findchild(term byte) *node { // addWildcardChild adds a wildcard child node
for _, v := range n.child { func (n *node) addWildcardChild(paramName string) *node {
if v.term == term { if n.wildcardChild == nil {
return v n.wildcardChild = makeNode("*" + paramName)
n.wildcardChild.nodeType = wildcard
n.wildcardChild.paramName = paramName
}
return n.wildcardChild
}
// findChild finds a static child node by path
func (n *node) findChild(path string) *node {
return n.children[path]
}
// parsePath splits a path into segments and identifies parameters
func parsePath(path string) []string {
if path == "" || path == "/" {
return []string{}
}
segments := make([]string, 0, 4) // Pre-allocate for common cases
start := 1 // Skip the leading slash
for i := 1; i < len(path); i++ {
if path[i] == '/' {
if start < i {
segments = append(segments, path[start:i])
}
start = i + 1
} }
} }
if start < len(path) {
segments = append(segments, path[start:])
}
return segments
}
// parsePathInto splits a path into segments using the provided buffer
func parsePathInto(path string, buf []string) []string {
if path == "" || path == "/" {
return buf[:0]
}
buf = buf[:0]
start := 1 // Skip the leading slash
for i := 1; i < len(path); i++ {
if path[i] == '/' {
if start < i {
buf = append(buf, path[start:i])
}
start = i + 1
}
}
if start < len(path) {
buf = append(buf, path[start:])
}
return buf
}
// parsePathZeroAlloc splits a path into segments with minimal allocations
// Uses string slicing to avoid allocations where possible
func parsePathZeroAlloc(path string) []string {
if path == "" || path == "/" {
return nil
}
// Count segments first to pre-allocate exact size
segmentCount := 1
for i := 1; i < len(path); i++ {
if path[i] == '/' {
segmentCount++
}
}
segments := make([]string, 0, segmentCount)
start := 1 // Skip the leading slash
for i := 1; i < len(path); i++ {
if path[i] == '/' {
if start < i {
segments = append(segments, path[start:i])
}
start = i + 1
}
}
if start < len(path) {
segments = append(segments, path[start:])
}
return segments
}
// isParam checks if a path segment is a parameter (:param)
func isParam(segment string) bool {
return len(segment) > 0 && segment[0] == ':'
}
// isWildcard checks if a path segment is a wildcard (*param)
func isWildcard(segment string) bool {
return len(segment) > 0 && segment[0] == '*'
}
// getParamName extracts the parameter name from a segment
func getParamName(segment string) string {
if len(segment) > 1 {
return segment[1:]
}
return ""
}
// addRoute adds a route to the tree
func (n *node) addRoute(segments []string, handler http.HandlerFunc) {
n.addRouteWithMiddleware(segments, handler, nil)
}
// addRouteWithMiddleware adds a route to the tree with middleware
func (n *node) addRouteWithMiddleware(segments []string, handler http.HandlerFunc, middleware []MiddlewareFunc) {
current := n
// Handle root path ("/") case
if len(segments) == 0 {
n.nodeType = static
n.handler = handler
n.middleware = middleware
return
}
for i, segment := range segments {
isLast := i == len(segments)-1
if isParam(segment) {
paramName := getParamName(segment)
current = current.addParamChild(paramName)
} else if isWildcard(segment) {
paramName := getParamName(segment)
current = current.addWildcardChild(paramName)
} else {
current = current.addChild(segment)
}
if isLast {
current.handler = handler
current.middleware = middleware
}
}
}
// find searches for a route matching the given path
func (n *node) find(path string, segments []string, params *Params) *node {
current := n
pos := 1 // Track offset into the original path for wildcard slicing
for i, segment := range segments {
isLast := i == len(segments)-1
segmentStart := pos
pos += len(segment) + 1 // advance past this segment and trailing slash
// Try static match first (fastest path)
if child := current.findChild(segment); child != nil {
if isLast && child.nodeType == static {
return child
}
current = child
continue
}
// Try parameter match
if current.paramChild != nil {
if params != nil {
params.Set(current.paramChild.paramName, segment)
}
if isLast && current.paramChild.nodeType == param {
return current.paramChild
}
current = current.paramChild
continue
}
// Try wildcard match (catches everything)
if current.wildcardChild != nil {
if params != nil {
// Wildcard captures the rest of the path directly from the original string
params.Set(current.wildcardChild.paramName, path[segmentStart:])
}
return current.wildcardChild
}
return nil // No match found
}
if current.nodeType == static {
return current
}
return nil return nil
} }
func (n *node) find(word string) *node { // ServeHTTP implements the http.Handler interface
ss := []byte(word) func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
m := n segments := r.segmentsPool.Get().([]string)
for i, l := 1, len(ss); i < l; i++ { segments = segments[:0]
m = m.findchild(ss[i]) segments = parsePathInto(req.URL.Path, segments)
if m == nil { defer func() {
return nil // Clear to allow GC of prior path strings before pooling
for i := range segments {
segments[i] = ""
} }
r.segmentsPool.Put(segments[:0])
}()
tree, exists := r.trees[req.Method]
if !exists {
if r.checkMethodNotAllowed && r.hasOtherMethodMatch(req.URL.Path, segments, req.Method) {
r.handleMethodNotAllowed(w, req)
} else {
r.handleNotFound(w, req)
} }
return m return
}
params := r.paramsPool.Get().(*Params)
params.Reset()
node := tree.find(req.URL.Path, segments, params)
if node == nil || node.handler == nil {
params.Reset()
r.paramsPool.Put(params)
if r.checkMethodNotAllowed {
// Check if the path exists for other methods to return 405 instead of 404
if r.hasOtherMethodMatch(req.URL.Path, segments, req.Method) {
r.handleMethodNotAllowed(w, req)
} else {
r.handleNotFound(w, req)
}
} else {
r.handleNotFound(w, req)
}
return
}
// Add params to request context as a copy to keep values stable after pooling
var ctxParams Params
if params.Len() > 0 {
ctxParams.keys = append(ctxParams.keys, params.keys...)
ctxParams.values = append(ctxParams.values, params.values...)
}
ctx := context.WithValue(req.Context(), paramsKey, ctxParams)
req = req.WithContext(ctx)
// Create handler chain with middleware
handler := http.Handler(http.HandlerFunc(node.handler))
// Apply node-specific middleware first, then router middleware
for i := len(node.middleware) - 1; i >= 0; i-- {
handler = node.middleware[i](handler)
}
for i := len(r.middleware) - 1; i >= 0; i-- {
handler = r.middleware[i](handler)
}
// Defer returning params to pool after handler completes
defer func() {
params.Reset()
r.paramsPool.Put(params)
}()
handler.ServeHTTP(w, req)
} }
func (n *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { // hasOtherMethodMatch checks whether the same path exists on another method tree.
if m, exists := n.route[r.Method]; exists { func (r *Router) hasOtherMethodMatch(path string, segments []string, currentMethod string) bool {
m = m.find(r.URL.Path) for method, otherTree := range r.trees {
if m == nil { if method == currentMethod {
http.NotFound(w, r) continue
return
} }
if m.ntype == nomatch { if otherNode := otherTree.find(path, segments, nil); otherNode != nil && otherNode.handler != nil {
http.NotFound(w, r) return true
return
} }
m.handler(w, r) }
return return false
}
// handleNotFound handles 404 responses
func (r *Router) handleNotFound(w http.ResponseWriter, req *http.Request) {
if r.notFoundHandler != nil {
r.notFoundHandler(w, req)
} else {
http.NotFound(w, req)
} }
} }
func (n *Router) GET(path string, handler http.HandlerFunc) *Router { // handleMethodNotAllowed handles 405 responses
n.route["GET"].maketree([]byte(path), handler) func (r *Router) handleMethodNotAllowed(w http.ResponseWriter, req *http.Request) {
return n if r.methodNotAllowedHandler != nil {
} r.methodNotAllowedHandler(w, req)
func (n *Router) POST(path string, handler http.HandlerFunc) *Router { } else {
n.route["POST"].maketree([]byte(path), handler) w.WriteHeader(http.StatusMethodNotAllowed)
return n w.Write([]byte("Method Not Allowed"))
} }
func (n *Router) PUT(path string, handler http.HandlerFunc) *Router {
n.route["PUT"].maketree([]byte(path), handler)
return n
}
func (n *Router) PATCH(path string, handler http.HandlerFunc) *Router {
n.route["PATCH"].maketree([]byte(path), handler)
return n
}
func (n *Router) DELETE(path string, handler http.HandlerFunc) *Router {
n.route["DELETE"].maketree([]byte(path), handler)
return n
}
func (n *Router) OPTIONS(path string, handler http.HandlerFunc) *Router {
n.route["OPTIONS"].maketree([]byte(path), handler)
return n
}
func (n *Router) HEAD(path string, handler http.HandlerFunc) *Router {
n.route["HEAD"].maketree([]byte(path), handler)
return n
} }
// Use adds global middleware to the router
func (r *Router) Use(middleware ...MiddlewareFunc) {
r.middleware = append(r.middleware, middleware...)
}
// Group creates a new route group with a prefix and optional middleware
func (r *Router) Group(prefix string, middleware ...MiddlewareFunc) *Router {
// Ensure prefix starts with /
if len(prefix) > 0 && prefix[0] != '/' {
prefix = "/" + prefix
}
return &Router{
trees: r.trees, // Share the same trees
middleware: append(r.middleware, middleware...),
notFoundHandler: r.notFoundHandler,
methodNotAllowedHandler: r.methodNotAllowedHandler,
paramsPool: r.paramsPool, // Already a pointer, just copy the reference
segmentsPool: r.segmentsPool, // Share the segments pool
prefix: r.prefix + prefix,
checkMethodNotAllowed: r.checkMethodNotAllowed,
}
}
// NotFound sets a custom 404 handler
func (r *Router) NotFound(handler http.HandlerFunc) {
r.notFoundHandler = handler
}
// MethodNotAllowed sets a custom 405 handler
func (r *Router) MethodNotAllowed(handler http.HandlerFunc) {
r.methodNotAllowedHandler = handler
}
// EnableMethodNotAllowedCheck toggles costly cross-method lookup on 404
func (r *Router) EnableMethodNotAllowedCheck(enabled bool) {
r.checkMethodNotAllowed = enabled
}
// addRoute is a helper method to add routes for different HTTP methods
func (r *Router) addRoute(method, path string, handler http.HandlerFunc) *Router {
if r.trees == nil {
r.trees = make(map[string]*node)
}
// Find the root tree for this method (from the main router or create new)
var rootTree *node
if tree, exists := r.trees[method]; exists {
rootTree = tree
} else {
rootTree = makeNode("/")
r.trees[method] = rootTree
}
// Apply group prefix if present
fullPath := r.prefix + path
segments := parsePath(fullPath)
rootTree.addRouteWithMiddleware(segments, handler, r.middleware)
return r
}
// GET adds a GET route
func (r *Router) GET(path string, handler http.HandlerFunc) *Router {
return r.addRoute("GET", path, handler)
}
// POST adds a POST route
func (r *Router) POST(path string, handler http.HandlerFunc) *Router {
return r.addRoute("POST", path, handler)
}
// PUT adds a PUT route
func (r *Router) PUT(path string, handler http.HandlerFunc) *Router {
return r.addRoute("PUT", path, handler)
}
// PATCH adds a PATCH route
func (r *Router) PATCH(path string, handler http.HandlerFunc) *Router {
return r.addRoute("PATCH", path, handler)
}
// DELETE adds a DELETE route
func (r *Router) DELETE(path string, handler http.HandlerFunc) *Router {
return r.addRoute("DELETE", path, handler)
}
// OPTIONS adds an OPTIONS route
func (r *Router) OPTIONS(path string, handler http.HandlerFunc) *Router {
return r.addRoute("OPTIONS", path, handler)
}
// HEAD adds a HEAD route
func (r *Router) HEAD(path string, handler http.HandlerFunc) *Router {
return r.addRoute("HEAD", path, handler)
}
// New creates a new Router instance
func New() *Router { func New() *Router {
root = &Router{route: make(map[string]*node)} router := &Router{
root.route["GET"] = makenode([]byte("/")[0]) trees: make(map[string]*node),
root.route["POST"] = makenode([]byte("/")[0]) middleware: make([]MiddlewareFunc, 0),
root.route["PUT"] = makenode([]byte("/")[0]) paramsPool: &sync.Pool{
root.route["PATCH"] = makenode([]byte("/")[0]) New: func() interface{} {
root.route["DELETE"] = makenode([]byte("/")[0]) return &Params{
root.route["OPTIONS"] = makenode([]byte("/")[0]) keys: make([]string, 0, 4), // Pre-allocate for common cases
root.route["HEAD"] = makenode([]byte("/")[0]) values: make([]string, 0, 4),
return root }
},
},
segmentsPool: &sync.Pool{
New: func() interface{} {
return make([]string, 0, 4) // Pre-allocate for common cases
},
},
prefix: "",
checkMethodNotAllowed: true,
}
// Initialize trees for common HTTP methods
methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}
for _, method := range methods {
router.trees[method] = makeNode("/")
}
return router
} }

329
sux_test.go Normal file
View File

@@ -0,0 +1,329 @@
package sux
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestBasicRouting(t *testing.T) {
router := New()
router.GET("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("home"))
})
router.GET("/about", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("about"))
})
// Test home route
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Body.String() != "home" {
t.Errorf("Expected body 'home', got '%s'", w.Body.String())
}
// Test about route
req = httptest.NewRequest("GET", "/about", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Body.String() != "about" {
t.Errorf("Expected body 'about', got '%s'", w.Body.String())
}
}
func TestParameterRouting(t *testing.T) {
router := New()
router.GET("/users/:id", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
if params.Len() == 0 {
w.WriteHeader(http.StatusInternalServerError)
return
}
id := params.Get("id")
w.WriteHeader(http.StatusOK)
w.Write([]byte("user " + id))
})
// Test parameter route
req := httptest.NewRequest("GET", "/users/123", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Body.String() != "user 123" {
t.Errorf("Expected body 'user 123', got '%s'", w.Body.String())
}
}
func TestWildcardRouting(t *testing.T) {
router := New()
router.GET("/files/*path", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
if params.Len() == 0 {
w.WriteHeader(http.StatusInternalServerError)
return
}
path := params.Get("path")
w.WriteHeader(http.StatusOK)
w.Write([]byte("file: " + path))
})
// Test wildcard route
req := httptest.NewRequest("GET", "/files/docs/readme.txt", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Body.String() != "file: docs/readme.txt" {
t.Errorf("Expected body 'file: docs/readme.txt', got '%s'", w.Body.String())
}
}
func TestMethodNotAllowed(t *testing.T) {
router := New()
router.GET("/resource", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("GET resource"))
})
// Test POST to GET-only route
req := httptest.NewRequest("POST", "/resource", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("Expected status 405, got %d", w.Code)
}
}
func TestNotFound(t *testing.T) {
router := New()
router.GET("/existing", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Test non-existent route
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
}
func TestMiddleware(t *testing.T) {
router := New()
// Add middleware that sets a header
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Middleware", "applied")
next.ServeHTTP(w, r)
})
})
router.GET("/test", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("test"))
})
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Header().Get("X-Middleware") != "applied" {
t.Errorf("Expected middleware header, got '%s'", w.Header().Get("X-Middleware"))
}
}
func TestRouteGroups(t *testing.T) {
router := New()
// Create API group with middleware
api := router.Group("/api", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-API-Version", "v1")
next.ServeHTTP(w, r)
})
})
api.GET("/users", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("users list"))
})
// Test grouped route
req := httptest.NewRequest("GET", "/api/users", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Body.String() != "users list" {
t.Errorf("Expected body 'users list', got '%s'", w.Body.String())
}
if w.Header().Get("X-API-Version") != "v1" {
t.Errorf("Expected API version header, got '%s'", w.Header().Get("X-API-Version"))
}
}
func TestCustomHandlers(t *testing.T) {
router := New()
// Custom 404 handler
router.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("custom 404"))
})
// Custom 405 handler
router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("custom 405"))
})
router.GET("/existing", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Test custom 404
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
if w.Body.String() != "custom 404" {
t.Errorf("Expected body 'custom 404', got '%s'", w.Body.String())
}
// Test custom 405
req = httptest.NewRequest("POST", "/existing", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("Expected status 405, got %d", w.Code)
}
if w.Body.String() != "custom 405" {
t.Errorf("Expected body 'custom 405', got '%s'", w.Body.String())
}
}
func TestMultipleParameters(t *testing.T) {
router := New()
router.GET("/users/:userId/posts/:postId", func(w http.ResponseWriter, r *http.Request) {
params := ParamsFromContext(r)
if params.Len() == 0 {
w.WriteHeader(http.StatusInternalServerError)
return
}
userId := params.Get("userId")
postId := params.Get("postId")
w.WriteHeader(http.StatusOK)
w.Write([]byte("user " + userId + " post " + postId))
})
req := httptest.NewRequest("GET", "/users/123/posts/456", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Body.String() != "user 123 post 456" {
t.Errorf("Expected body 'user 123 post 456', got '%s'", w.Body.String())
}
}
func TestThreadSafety(t *testing.T) {
router := New()
router.GET("/test", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("test"))
})
// Create multiple goroutines to test concurrent access
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 100; j++ {
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
done <- true
}()
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
}
func TestRouterInstanceIsolation(t *testing.T) {
// Create two separate router instances
router1 := New()
router2 := New()
router1.GET("/route", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("router1"))
})
router2.GET("/route", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("router2"))
})
// Test router1
req := httptest.NewRequest("GET", "/route", nil)
w := httptest.NewRecorder()
router1.ServeHTTP(w, req)
if w.Body.String() != "router1" {
t.Errorf("Expected body 'router1', got '%s'", w.Body.String())
}
// Test router2
w = httptest.NewRecorder()
router2.ServeHTTP(w, req)
if w.Body.String() != "router2" {
t.Errorf("Expected body 'router2', got '%s'", w.Body.String())
}
}