12 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
6fce569a0a migration to code.icod.de 2022-10-18 12:26:16 +02:00
89e366d9d0 initial 2021-10-31 09:06:30 +01:00
8 changed files with 1407 additions and 196 deletions

1
.gitignore vendored Normal file
View File

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

20
LICENSE Normal file
View File

@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2014 Darko Luketic
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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 (
"git.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)
}
}

4
go.mod
View File

@@ -1,3 +1,3 @@
module git.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())
}
}