Rewrite the router

This commit is contained in:
Sky Johnson 2025-02-15 21:57:18 -06:00
parent 1bd1a884c4
commit 5791042454
11 changed files with 566 additions and 954 deletions

View File

@ -1,9 +1,13 @@
# Router
A radix-tree based no-allocation router in Go. All credit to Eduard Urbach for his incredible work. This router sports
a fancy PATRICIA tree structure for efficient string lookups. It also has ***zero dependencies***!
A high-performance router for Go with support for path parameters and wildcards. Sports an incredibly simple API over the top of a prefix tree data structure. Minimal allocations and, dare we say, 🔥*blazingly* fast🔥.
The router has a generic data structure, so any kind of handler can be used.
## Features
- Zero dependencies
- Fast path matching with radix tree structure
- Support for path parameters (`[id]`) and wildcards (`*path`)
- Up to 15x faster than `http.ServeMux` for dynamic routes
- Zero allocations for static routes
## Installation
@ -14,50 +18,73 @@ go get git.sharkk.net/Go/Router
## Usage
```go
router := router.New[string]()
type Handler func(params []string)
router := router.New()
// Static routes
router.Add("GET", "/hello", "...")
router.Add("GET", "/world", "...")
router.Get("/", func(params []string) {
fmt.Println("Root handler")
})
router.Get("/users/all", func(params []string) {
fmt.Println("All users")
})
// Parameter routes
router.Add("GET", "/users/[id]", "...")
router.Add("GET", "/users/[id]/comments", "...")
router.Get("/users/[id]", func(params []string) {
userID := params[0]
fmt.Printf("User ID: %s\n", userID)
})
// Nested parameters
router.Get("/users/[id]/posts/[postId]", func(params []string) {
userID := params[0]
postID := params[1]
fmt.Printf("User %s, Post %s\n", userID, postID)
})
// Wildcard routes
router.Add("GET", "/images/*path", "...")
// Simple lookup
data, params := router.Lookup("GET", "/users/42")
fmt.Println(data, params)
// Efficient lookup
data := router.LookupNoAlloc("GET", "/users/42", func(key string, value string) {
fmt.Println(key, value)
router.Get("/files/*path", func(params []string) {
filePath := params[0]
fmt.Printf("File path: %s\n", filePath)
})
// Lookup routes
if handler, params, ok := router.Lookup("GET", "/users/123"); ok {
handler(params)
}
```
## Benchmarks
Benchmark results comparing our router to the standard `http.ServeMux` on AMD Ryzen 9 7950X:
```
cpu: AMD Ryzen 9 7950X 16-Core Processor
BenchmarkBlog/Len1-Params0-32 268391120 4.461 ns/op 0 B/op 0 allocs/op
BenchmarkBlog/Len1-Params0-NoAlloc-32 383737024 3.123 ns/op 0 B/op 0 allocs/op
BenchmarkBlog/Len1-Param1-32 23690427 47.85 ns/op 32 B/op 1 allocs/op
BenchmarkBlog/Len1-Param1-NoAlloc-32 248275540 4.913 ns/op 0 B/op 0 allocs/op
BenchmarkComparison/root_path
Router: 2.098 ns/op 0 B/op 0 allocs/op
ServeMux: 32.010 ns/op 0 B/op 0 allocs/op
BenchmarkGithub/Len7-Params0-32 148926122 8.043 ns/op 0 B/op 0 allocs/op
BenchmarkGithub/Len7-Params0-NoAlloc-32 168011829 7.153 ns/op 0 B/op 0 allocs/op
BenchmarkGithub/Len7-Params1-32 22188592 47.25 ns/op 32 B/op 1 allocs/op
BenchmarkGithub/Len7-Params1-NoAlloc-32 100000000 11.29 ns/op 0 B/op 0 allocs/op
BenchmarkGithub/Len7-Params3-32 10181496 111.3 ns/op 96 B/op 2 allocs/op
BenchmarkGithub/Len7-Params3-NoAlloc-32 46369563 25.54 ns/op 0 B/op 0 allocs/op
BenchmarkComparison/static_path
Router: 16.050 ns/op 0 B/op 0 allocs/op
ServeMux: 67.980 ns/op 0 B/op 0 allocs/op
BenchmarkComparison/dynamic_path
Router: 39.170 ns/op 16 B/op 1 allocs/op
ServeMux: 174.000 ns/op 48 B/op 3 allocs/op
BenchmarkComparison/not_found
Router: 10.580 ns/op 0 B/op 0 allocs/op
ServeMux: 178.100 ns/op 56 B/op 3 allocs/op
```
Key Performance Points:
- Root path lookups are 15x faster
- Static paths are 4x faster with zero allocations
- Dynamic paths are 4.4x faster with fewer allocations
- Not found paths are 16.8x faster with zero allocations
## License
Licensed under MIT. [Take a look!](LICENSE)
## Credit
All of the code here is built by [Eduard Urbach](https://git.akyoto.dev/go/router).

4
go.mod
View File

@ -1,3 +1,5 @@
module git.sharkk.net/Go/Router
go 1.23.0
go 1.23.5
require git.sharkk.net/Go/Assert v0.0.0-20250215225259-e80b22c45aa3

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
git.sharkk.net/Go/Assert v0.0.0-20250215225259-e80b22c45aa3 h1:zkadtphuR4rYrKqTKZlBfbJw9wtkhSIi5extZwbx1BY=
git.sharkk.net/Go/Assert v0.0.0-20250215225259-e80b22c45aa3/go.mod h1:7AMVm0RCtLlQfWsnKs6h/IdSfzj52/o0nR03rCW68gM=

286
node.go
View File

@ -1,286 +0,0 @@
package router
import "strings"
// Node types
const (
separator = '/'
parameter = '['
wildcard = '*'
)
// A node on our radix tree
type Node[T any] struct {
prefix string
data T
children []*Node[T]
parameter *Node[T]
wildcard *Node[T]
indexes []uint8
start uint8
end uint8
kind byte
}
// Splits the node at the given index and inserts a new child
// node with the given path and data. If path is empty, it will
// not create another child node and instead assign the data
// directly to the node.
func (node *Node[T]) split(index int, path string, data T) {
// Create split node with the remaining string
splitNode := node.clone(node.prefix[index:])
// The existing data must be removed
node.reset(node.prefix[:index])
// If the path is empty, it means we don't create a 2nd child node.
// Just assign the data for the existing node and store a single child node.
if path == "" {
node.data = data
node.addChild(splitNode)
return
}
node.addChild(splitNode)
// Create new nodes with the remaining path
node.append(path, data)
}
// Clone the node with a new prefix
func (node *Node[T]) clone(prefix string) *Node[T] {
return &Node[T]{
prefix: prefix,
data: node.data,
children: node.children,
parameter: node.parameter,
wildcard: node.wildcard,
indexes: node.indexes,
start: node.start,
end: node.end,
kind: node.kind,
}
}
// Reset the node, set the prefix
func (node *Node[T]) reset(prefix string) {
var empty T
node.prefix = prefix
node.data = empty
node.children = nil
node.parameter = nil
node.wildcard = nil
node.indexes = nil
node.start = 0
node.end = 0
node.kind = 0
}
// Append the given path to the tree
func (node *Node[T]) append(path string, data T) {
// At this point, all we know is that somewhere
// in the remaining string we have parameters.
// node: /user|
// path: /user|/:userid
for {
if path == "" {
node.data = data
return
}
paramStart := strings.IndexByte(path, parameter)
if paramStart == -1 {
paramStart = strings.IndexByte(path, wildcard)
}
// If it's a static route we are adding,
// just add the remainder as a normal node.
if paramStart == -1 {
// If the node itself doesn't have a prefix (root node),
// don't add a child and use the node itself.
if node.prefix == "" {
node.prefix = path
node.data = data
node.addTrailingSlash(data)
return
}
child := &Node[T]{
prefix: path,
data: data,
}
node.addChild(child)
child.addTrailingSlash(data)
return
}
// If we're directly in front of a parameter,
// add a parameter node.
if paramStart == 0 {
paramEnd := strings.IndexByte(path[1:], ']')
if paramEnd == -1 {
paramEnd = len(path)
} else {
paramEnd = paramEnd + 1 // Account for the offset from path[1:]
}
nextSep := strings.IndexByte(path[paramEnd+1:], separator)
if nextSep == -1 {
nextSep = len(path[paramEnd+1:])
}
child := &Node[T]{
prefix: path[1:paramEnd], // Exclude the opening '['
kind: parameter,
}
switch child.kind {
case parameter:
child.addTrailingSlash(data)
node.parameter = child
node = child
path = path[paramEnd+nextSep+1:]
continue
case wildcard:
child.data = data
node.wildcard = child
return
}
}
// We know there's a parameter, but not directly at the start.
// If the node itself doesn't have a prefix (root node),
// don't add a child and use the node itself.
if node.prefix == "" {
node.prefix = path[:paramStart]
path = path[paramStart:]
continue
}
// Add a normal node with the path before the parameter start.
child := &Node[T]{
prefix: path[:paramStart],
}
// Allow trailing slashes to return
// the same content as their parent node.
if child.prefix == "/" {
child.data = node.data
}
node.addChild(child)
node = child
path = path[paramStart:]
}
}
// Add a child tree node
func (node *Node[T]) addChild(child *Node[T]) {
if len(node.children) == 0 {
node.children = append(node.children, nil)
}
firstChar := child.prefix[0]
switch {
case node.start == 0:
node.start = firstChar
node.indexes = []uint8{0}
node.end = node.start + uint8(len(node.indexes))
case firstChar < node.start:
diff := node.start - firstChar
newindexes := make([]uint8, diff+uint8(len(node.indexes)))
copy(newindexes[diff:], node.indexes)
node.start = firstChar
node.indexes = newindexes
node.end = node.start + uint8(len(node.indexes))
case firstChar >= node.end:
diff := firstChar - node.end + 1
newindexes := make([]uint8, diff+uint8(len(node.indexes)))
copy(newindexes, node.indexes)
node.indexes = newindexes
node.end = node.start + uint8(len(node.indexes))
}
index := node.indexes[firstChar-node.start]
if index == 0 {
node.indexes[firstChar-node.start] = uint8(len(node.children))
node.children = append(node.children, child)
return
}
node.children[index] = child
}
func (node *Node[T]) addTrailingSlash(data T) {
if strings.HasSuffix(node.prefix, "/") || node.kind == wildcard || (separator >= node.start && separator < node.end && node.indexes[separator-node.start] != 0) {
return
}
node.addChild(&Node[T]{
prefix: "/",
data: data,
})
}
// Traverses the tree and calls the given function on every node.
func (node *Node[T]) each(callback func(*Node[T])) {
callback(node)
for _, child := range node.children {
if child == nil {
continue
}
child.each(callback)
}
if node.parameter != nil {
node.parameter.each(callback)
}
if node.wildcard != nil {
node.wildcard.each(callback)
}
}
// Called when the node was fully parsed and needs to decide the next control flow.
// finish is only called from `tree.Add`.
func (node *Node[T]) finish(path string, data T, i int, offset int) (*Node[T], int, Flow) {
char := path[i]
if char >= node.start && char < node.end {
index := node.indexes[char-node.start]
if index != 0 {
node = node.children[index]
offset = i
return node, offset, flowNext
}
}
// No fitting children found, does this node even contain a prefix yet?
// If no prefix is set, this is the starting node.
if node.prefix == "" {
node.append(path[i:], data)
return node, offset, flowStop
}
// node: /user/|:id
// path: /user/|:id/profile
if node.parameter != nil && path[i] == parameter {
node = node.parameter
offset = i
return node, offset, flowBegin
}
node.append(path[i:], data)
return node, offset, flowStop
}

270
router.go
View File

@ -1,96 +1,224 @@
package router
type Flow int
const (
flowStop Flow = iota
flowBegin
flowNext
import (
"fmt"
)
type Parameter struct {
Key string
Value string
// Handler processes HTTP requests with optional path parameters.
type Handler func(params []string)
// node represents a segment in the URL path and its handling logic.
type node struct {
segment string // the path segment this node matches
handler Handler // handler for this path, if it's an endpoint
children []*node // child nodes for subsequent path segments
isDynamic bool // true for param segments like [id]
isWildcard bool // true for catch-all segments like *filepath
maxParams uint8 // maximum number of parameters in paths under this node
}
type Router[T any] struct {
get Tree[T]
post Tree[T]
delete Tree[T]
put Tree[T]
patch Tree[T]
head Tree[T]
connect Tree[T]
trace Tree[T]
options Tree[T]
// Router routes HTTP requests by method and path.
// It supports static paths, path parameters, and wildcards.
type Router struct {
get *node
post *node
put *node
patch *node
delete *node
}
// Create a new Router containing trees for every HTTP method.
func New[T any]() *Router[T] {
return &Router[T]{}
// New creates a new Router instance.
func New() *Router {
return &Router{
get: &node{},
post: &node{},
put: &node{},
patch: &node{},
delete: &node{},
}
}
// Registers a new handler for the given method and path.
func (router *Router[T]) Add(method string, path string, handler T) {
tree := router.selectTree(method)
tree.Add(path, handler)
// HTTP method registration functions
// Routes can contain static segments (/users), parameters ([id]), and wildcards (*filepath)
// Get registers a handler for GET requests at the given path.
func (r *Router) Get(path string, handler Handler) error {
return r.addRoute(r.get, path, handler)
}
// Finds the handler and parameters for the given route.
func (router *Router[T]) Lookup(method string, path string) (T, []Parameter) {
if method[0] == 'G' {
return router.get.Lookup(path)
// Post registers a handler for POST requests at the given path.
func (r *Router) Post(path string, handler Handler) error {
return r.addRoute(r.post, path, handler)
}
tree := router.selectTree(method)
return tree.Lookup(path)
// Put registers a handler for PUT requests at the given path.
func (r *Router) Put(path string, handler Handler) error {
return r.addRoute(r.put, path, handler)
}
// Finds the handler and parameters for the given route without using any memory allocations.
func (router *Router[T]) LookupNoAlloc(method string, path string, addParameter func(string, string)) T {
if method[0] == 'G' {
return router.get.LookupNoAlloc(path, addParameter)
// Patch registers a handler for PATCH requests at the given path.
func (r *Router) Patch(path string, handler Handler) error {
return r.addRoute(r.patch, path, handler)
}
tree := router.selectTree(method)
return tree.LookupNoAlloc(path, addParameter)
// Delete registers a handler for DELETE requests at the given path.
func (r *Router) Delete(path string, handler Handler) error {
return r.addRoute(r.delete, path, handler)
}
// Traverses all trees and calls the given function on every node.
func (router *Router[T]) Map(transform func(T) T) {
router.get.Map(transform)
router.post.Map(transform)
router.delete.Map(transform)
router.put.Map(transform)
router.patch.Map(transform)
router.head.Map(transform)
router.connect.Map(transform)
router.trace.Map(transform)
router.options.Map(transform)
// readSegment extracts the next path segment starting at the given position.
// Returns the segment, the position after it, and whether there are more segments.
func readSegment(path string, start int) (segment string, end int, hasMore bool) {
if start >= len(path) {
return "", start, false
}
// Returns the tree of the given HTTP method.
func (router *Router[T]) selectTree(method string) *Tree[T] {
switch method {
case "GET":
return &router.get
case "POST":
return &router.post
case "DELETE":
return &router.delete
case "PUT":
return &router.put
case "PATCH":
return &router.patch
case "HEAD":
return &router.head
case "CONNECT":
return &router.connect
case "TRACE":
return &router.trace
case "OPTIONS":
return &router.options
default:
if path[start] == '/' {
start++
}
if start >= len(path) {
return "", start, false
}
end = start
for end < len(path) && path[end] != '/' {
end++
}
return path[start:end], end, end < len(path)
}
// addRoute adds a new route to the prefix tree.
// It validates wildcard positions and tracks parameter counts.
func (r *Router) addRoute(root *node, path string, handler Handler) error {
if path == "/" {
root.handler = handler
return nil
}
current := root
pos := 0
var lastWildcard bool
paramsCount := uint8(0)
for {
segment, newPos, hasMore := readSegment(path, pos)
if segment == "" {
break
}
isDynamic := len(segment) > 2 && segment[0] == '[' && segment[len(segment)-1] == ']'
isWildcard := len(segment) > 0 && segment[0] == '*'
if isWildcard {
if lastWildcard {
return fmt.Errorf("wildcard must be the last segment in the path")
}
if hasMore {
return fmt.Errorf("wildcard must be the last segment in the path")
}
lastWildcard = true
}
if isDynamic || isWildcard {
paramsCount++
}
var child *node
for _, n := range current.children {
if n.segment == segment {
child = n
break
}
}
if child == nil {
child = &node{
segment: segment,
isDynamic: isDynamic,
isWildcard: isWildcard,
}
current.children = append(current.children, child)
}
if child.maxParams < paramsCount {
child.maxParams = paramsCount
}
current = child
pos = newPos
}
current.handler = handler
return nil
}
// Lookup finds a handler matching the given method and path.
// Returns the handler, any captured parameters, and whether a match was found.
func (r *Router) Lookup(method, path string) (Handler, []string, bool) {
var root *node
switch method {
case "GET":
root = r.get
case "POST":
root = r.post
case "PUT":
root = r.put
case "PATCH":
root = r.patch
case "DELETE":
root = r.delete
default:
return nil, nil, false
}
if path == "/" {
return root.handler, nil, root.handler != nil
}
params := make([]string, 0, root.maxParams)
h, found := match(root, path, 0, &params)
if !found {
return nil, nil, false
}
return h, params, true
}
// match recursively traverses the prefix tree to find a matching handler.
// It populates params with any captured path parameters or wildcard matches.
func match(current *node, path string, start int, params *[]string) (Handler, bool) {
// Check for wildcard children first
for _, child := range current.children {
if child.isWildcard {
remaining := path[start:]
if len(remaining) > 0 && remaining[0] == '/' {
remaining = remaining[1:]
}
*params = append(*params, remaining)
return child.handler, child.handler != nil
}
}
// Read current segment
segment, pos, hasMore := readSegment(path, start)
if segment == "" {
return current.handler, current.handler != nil
}
// Try to match children
for _, child := range current.children {
if child.segment == segment || child.isDynamic {
if child.isDynamic {
*params = append(*params, segment)
}
if !hasMore {
return child.handler, child.handler != nil
}
if h, found := match(child, path, pos, params); found {
return h, true
}
}
}
return nil, false
}

299
router_test.go Normal file
View File

@ -0,0 +1,299 @@
package router
import (
"net/http"
"testing"
assert "git.sharkk.net/Go/Assert"
)
func handler(t *testing.T, expectedParams []string) Handler {
return func(params []string) {
assert.Equal(t, len(params), len(expectedParams))
for i, expected := range expectedParams {
assert.Equal(t, params[i], expected)
}
}
}
func TestRootPath(t *testing.T) {
r := New()
r.Get("/", handler(t, nil))
h, params, found := r.Lookup("GET", "/")
assert.True(t, found)
h(params)
}
func TestStaticPath(t *testing.T) {
r := New()
r.Get("/users/all", handler(t, nil))
h, params, found := r.Lookup("GET", "/users/all")
assert.True(t, found)
h(params)
}
func TestSingleParameter(t *testing.T) {
r := New()
r.Get("/users/[id]", handler(t, []string{"123"}))
h, params, found := r.Lookup("GET", "/users/123")
assert.True(t, found)
h(params)
}
func TestMultipleParameters(t *testing.T) {
r := New()
r.Get("/users/[id]/posts/[postId]", handler(t, []string{"123", "456"}))
h, params, found := r.Lookup("GET", "/users/123/posts/456")
assert.True(t, found)
h(params)
}
func TestNonExistentPath(t *testing.T) {
r := New()
r.Get("/users/[id]", handler(t, nil))
_, _, found := r.Lookup("GET", "/posts/123")
assert.False(t, found)
}
func TestWrongMethod(t *testing.T) {
r := New()
r.Get("/users/[id]", handler(t, nil))
_, _, found := r.Lookup("POST", "/users/123")
assert.False(t, found)
}
func TestTrailingSlash(t *testing.T) {
r := New()
r.Get("/users/[id]", handler(t, []string{"123"}))
h, params, found := r.Lookup("GET", "/users/123/")
assert.True(t, found)
h(params)
}
func TestDifferentMethods(t *testing.T) {
r := New()
r.Get("/test", handler(t, nil))
r.Post("/test", handler(t, nil))
r.Put("/test", handler(t, nil))
r.Patch("/test", handler(t, nil))
r.Delete("/test", handler(t, nil))
methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
_, _, found := r.Lookup(method, "/test")
assert.True(t, found)
})
}
}
func TestWildcardPath(t *testing.T) {
r := New()
t.Run("simple wildcard", func(t *testing.T) {
err := r.Get("/files/*path", handler(t, []string{"docs/report.pdf"}))
assert.Nil(t, err)
h, params, found := r.Lookup("GET", "/files/docs/report.pdf")
assert.True(t, found)
h(params)
})
t.Run("wildcard with empty path", func(t *testing.T) {
err := r.Get("/download/*filepath", handler(t, []string{""}))
assert.Nil(t, err)
h, params, found := r.Lookup("GET", "/download/")
assert.True(t, found)
h(params)
})
t.Run("wildcard with parameter", func(t *testing.T) {
err := r.Get("/users/[id]/*action", handler(t, []string{"123", "settings/profile/avatar"}))
assert.Nil(t, err)
h, params, found := r.Lookup("GET", "/users/123/settings/profile/avatar")
assert.True(t, found)
h(params)
})
t.Run("multiple wildcards not allowed", func(t *testing.T) {
err := r.Get("/api/*version/*path", handler(t, nil))
assert.NotNil(t, err)
})
t.Run("non-last wildcard not allowed", func(t *testing.T) {
err := r.Get("/api/*version/users", handler(t, nil))
assert.NotNil(t, err)
})
}
// Benchmarks
func BenchmarkRouterLookup(b *testing.B) {
r := New()
h := func(params []string) {}
// Setup routes for benchmarking
r.Get("/", h)
r.Get("/users/all", h)
r.Get("/users/[id]", h)
r.Get("/users/[id]/posts/[postId]", h)
b.Run("root", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/")
}
})
b.Run("static", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/users/all")
}
})
b.Run("single_param", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/users/123")
}
})
b.Run("multi_param", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/users/123/posts/456")
}
})
b.Run("not_found", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/nonexistent/path")
}
})
}
func BenchmarkParallelLookup(b *testing.B) {
r := New()
h := func(params []string) {}
r.Get("/users/[id]", h)
r.Get("/posts/[id]/comments", h)
r.Get("/products/[category]/[id]", h)
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
switch i % 3 {
case 0:
r.Lookup("GET", "/users/123")
case 1:
r.Lookup("GET", "/posts/456/comments")
case 2:
r.Lookup("GET", "/products/electronics/789")
}
i++
}
})
}
func BenchmarkWildcardLookup(b *testing.B) {
r := New()
h := func(params []string) {}
// Setup routes for benchmarking
r.Get("/files/*path", h)
r.Get("/users/[id]/*action", h)
b.Run("simple_wildcard", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/files/documents/reports/2024/q1.pdf")
}
})
b.Run("wildcard_with_param", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/users/123/settings/profile/avatar")
}
})
}
func BenchmarkComparison(b *testing.B) {
// Custom router setup
customRouter := New()
customRouter.Get("/", func(params []string) {})
customRouter.Get("/users/all", func(params []string) {})
customRouter.Get("/users/[id]", func(params []string) {})
customRouter.Get("/users/[id]/posts/[postId]", func(params []string) {})
// Standard mux setup
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {})
mux.HandleFunc("/users/all", func(w http.ResponseWriter, r *http.Request) {})
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {}) // Best equivalent for dynamic routes
// Root path
b.Run("root_path", func(b *testing.B) {
b.Run("custom", func(b *testing.B) {
for i := 0; i < b.N; i++ {
customRouter.Lookup("GET", "/")
}
})
b.Run("mux", func(b *testing.B) {
req, _ := http.NewRequest("GET", "/", nil)
for i := 0; i < b.N; i++ {
mux.ServeHTTP(nil, req)
}
})
})
// Static path
b.Run("static_path", func(b *testing.B) {
b.Run("custom", func(b *testing.B) {
for i := 0; i < b.N; i++ {
customRouter.Lookup("GET", "/users/all")
}
})
b.Run("mux", func(b *testing.B) {
req, _ := http.NewRequest("GET", "/users/all", nil)
for i := 0; i < b.N; i++ {
mux.ServeHTTP(nil, req)
}
})
})
// Dynamic path
b.Run("dynamic_path", func(b *testing.B) {
b.Run("custom", func(b *testing.B) {
for i := 0; i < b.N; i++ {
customRouter.Lookup("GET", "/users/123")
}
})
b.Run("mux", func(b *testing.B) {
req, _ := http.NewRequest("GET", "/users/123", nil)
for i := 0; i < b.N; i++ {
mux.ServeHTTP(nil, req)
}
})
})
// Not found
b.Run("not_found", func(b *testing.B) {
b.Run("custom", func(b *testing.B) {
for i := 0; i < b.N; i++ {
customRouter.Lookup("GET", "/nonexistent/path")
}
})
b.Run("mux", func(b *testing.B) {
req, _ := http.NewRequest("GET", "/nonexistent/path", nil)
for i := 0; i < b.N; i++ {
mux.ServeHTTP(nil, req)
}
})
})
}

View File

@ -1,136 +0,0 @@
package router_test
import (
"bufio"
"os"
"strings"
"testing"
router "git.sharkk.net/Go/Router"
)
func BenchmarkBlog(b *testing.B) {
routes := routes("blog.txt")
r := router.New[string]()
for _, route := range routes {
r.Add(route.Method, route.Path, "")
}
b.Run("Len1-Params0", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/")
}
})
b.Run("Len1-Params0-NoAlloc", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/", noop)
}
})
b.Run("Len1-Param1", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/:id")
}
})
b.Run("Len1-Param1-NoAlloc", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/:id", noop)
}
})
}
func BenchmarkGithub(b *testing.B) {
routes := routes("github.txt")
r := router.New[string]()
for _, route := range routes {
r.Add(route.Method, route.Path, "")
}
b.Run("Len7-Params0", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/issues")
}
})
b.Run("Len7-Params0-NoAlloc", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/issues", noop)
}
})
b.Run("Len7-Params1", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/gists/:id")
}
})
b.Run("Len7-Params1-NoAlloc", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/gists/:id", noop)
}
})
b.Run("Len7-Params3", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Lookup("GET", "/repos/:owner/:repo/issues")
}
})
b.Run("Len7-Params3-NoAlloc", func(b *testing.B) {
for i := 0; i < b.N; i++ {
r.LookupNoAlloc("GET", "/repos/:owner/:repo/issues", noop)
}
})
}
// Route represents a single line in the router test file.
type Route struct {
Method string
Path string
}
// Loads all routes from a text file.
func routes(fileName string) []Route {
var routes []Route
for line := range lines(fileName) {
line = strings.TrimSpace(line)
parts := strings.Split(line, " ")
routes = append(routes, Route{
Method: parts[0],
Path: parts[1],
})
}
return routes
}
// Easily read every line in a text file.
func lines(fileName string) <-chan string {
lines := make(chan string)
go func() {
defer close(lines)
file, err := os.Open(fileName)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines <- scanner.Text()
}
}()
return lines
}
// noop serves as an empty addParameter function.
func noop(string, string) {}

View File

@ -1,4 +0,0 @@
GET /
GET /[slug]
GET /tags
GET /tag/[tag]

View File

@ -1,203 +0,0 @@
GET /authorizations
GET /authorizations/[id]
POST /authorizations
DELETE /authorizations/[id]
GET /applications/[client_id]/tokens/[access_token]
DELETE /applications/[client_id]/tokens
DELETE /applications/[client_id]/tokens/[access_token]
GET /events
GET /repos/[owner]/[repo]/events
GET /networks/[owner]/[repo]/events
GET /orgs/[org]/events
GET /users/[user]/received_events
GET /users/[user]/received_events/public
GET /users/[user]/events
GET /users/[user]/events/public
GET /users/[user]/events/orgs/[org]
GET /feeds
GET /notifications
GET /repos/[owner]/[repo]/notifications
PUT /notifications
PUT /repos/[owner]/[repo]/notifications
GET /notifications/threads/[id]
GET /notifications/threads/[id]/subscription
PUT /notifications/threads/[id]/subscription
DELETE /notifications/threads/[id]/subscription
GET /repos/[owner]/[repo]/stargazers
GET /users/[user]/starred
GET /user/starred
GET /user/starred/[owner]/[repo]
PUT /user/starred/[owner]/[repo]
DELETE /user/starred/[owner]/[repo]
GET /repos/[owner]/[repo]/subscribers
GET /users/[user]/subscriptions
GET /user/subscriptions
GET /repos/[owner]/[repo]/subscription
PUT /repos/[owner]/[repo]/subscription
DELETE /repos/[owner]/[repo]/subscription
GET /user/subscriptions/[owner]/[repo]
PUT /user/subscriptions/[owner]/[repo]
DELETE /user/subscriptions/[owner]/[repo]
GET /users/[user]/gists
GET /gists
GET /gists/[id]
POST /gists
PUT /gists/[id]/star
DELETE /gists/[id]/star
GET /gists/[id]/star
POST /gists/[id]/forks
DELETE /gists/[id]
GET /repos/[owner]/[repo]/git/blobs/[sha]
POST /repos/[owner]/[repo]/git/blobs
GET /repos/[owner]/[repo]/git/commits/[sha]
POST /repos/[owner]/[repo]/git/commits
GET /repos/[owner]/[repo]/git/refs
POST /repos/[owner]/[repo]/git/refs
GET /repos/[owner]/[repo]/git/tags/[sha]
POST /repos/[owner]/[repo]/git/tags
GET /repos/[owner]/[repo]/git/trees/[sha]
POST /repos/[owner]/[repo]/git/trees
GET /issues
GET /user/issues
GET /orgs/[org]/issues
GET /repos/[owner]/[repo]/issues
GET /repos/[owner]/[repo]/issues/[number]
POST /repos/[owner]/[repo]/issues
GET /repos/[owner]/[repo]/assignees
GET /repos/[owner]/[repo]/assignees/[assignee]
GET /repos/[owner]/[repo]/issues/[number]/comments
POST /repos/[owner]/[repo]/issues/[number]/comments
GET /repos/[owner]/[repo]/issues/[number]/events
GET /repos/[owner]/[repo]/labels
GET /repos/[owner]/[repo]/labels/[name]
POST /repos/[owner]/[repo]/labels
DELETE /repos/[owner]/[repo]/labels/[name]
GET /repos/[owner]/[repo]/issues/[number]/labels
POST /repos/[owner]/[repo]/issues/[number]/labels
DELETE /repos/[owner]/[repo]/issues/[number]/labels/[name]
PUT /repos/[owner]/[repo]/issues/[number]/labels
DELETE /repos/[owner]/[repo]/issues/[number]/labels
GET /repos/[owner]/[repo]/milestones/[number]/labels
GET /repos/[owner]/[repo]/milestones
GET /repos/[owner]/[repo]/milestones/[number]
POST /repos/[owner]/[repo]/milestones
DELETE /repos/[owner]/[repo]/milestones/[number]
GET /emojis
GET /gitignore/templates
GET /gitignore/templates/[name]
POST /markdown
POST /markdown/raw
GET /meta
GET /rate_limit
GET /users/[user]/orgs
GET /user/orgs
GET /orgs/[org]
GET /orgs/[org]/members
GET /orgs/[org]/members/[user]
DELETE /orgs/[org]/members/[user]
GET /orgs/[org]/public_members
GET /orgs/[org]/public_members/[user]
PUT /orgs/[org]/public_members/[user]
DELETE /orgs/[org]/public_members/[user]
GET /orgs/[org]/teams
GET /teams/[id]
POST /orgs/[org]/teams
DELETE /teams/[id]
GET /teams/[id]/members
GET /teams/[id]/members/[user]
PUT /teams/[id]/members/[user]
DELETE /teams/[id]/members/[user]
GET /teams/[id]/repos
GET /teams/[id]/repos/[owner]/[repo]
PUT /teams/[id]/repos/[owner]/[repo]
DELETE /teams/[id]/repos/[owner]/[repo]
GET /user/teams
GET /repos/[owner]/[repo]/pulls
GET /repos/[owner]/[repo]/pulls/[number]
POST /repos/[owner]/[repo]/pulls
GET /repos/[owner]/[repo]/pulls/[number]/commits
GET /repos/[owner]/[repo]/pulls/[number]/files
GET /repos/[owner]/[repo]/pulls/[number]/merge
PUT /repos/[owner]/[repo]/pulls/[number]/merge
GET /repos/[owner]/[repo]/pulls/[number]/comments
PUT /repos/[owner]/[repo]/pulls/[number]/comments
GET /user/repos
GET /users/[user]/repos
GET /orgs/[org]/repos
GET /repositories
POST /user/repos
POST /orgs/[org]/repos
GET /repos/[owner]/[repo]
GET /repos/[owner]/[repo]/contributors
GET /repos/[owner]/[repo]/languages
GET /repos/[owner]/[repo]/teams
GET /repos/[owner]/[repo]/tags
GET /repos/[owner]/[repo]/branches
GET /repos/[owner]/[repo]/branches/[branch]
DELETE /repos/[owner]/[repo]
GET /repos/[owner]/[repo]/collaborators
GET /repos/[owner]/[repo]/collaborators/[user]
PUT /repos/[owner]/[repo]/collaborators/[user]
DELETE /repos/[owner]/[repo]/collaborators/[user]
GET /repos/[owner]/[repo]/comments
GET /repos/[owner]/[repo]/commits/[sha]/comments
POST /repos/[owner]/[repo]/commits/[sha]/comments
GET /repos/[owner]/[repo]/comments/[id]
DELETE /repos/[owner]/[repo]/comments/[id]
GET /repos/[owner]/[repo]/commits
GET /repos/[owner]/[repo]/commits/[sha]
GET /repos/[owner]/[repo]/readme
GET /repos/[owner]/[repo]/keys
GET /repos/[owner]/[repo]/keys/[id]
POST /repos/[owner]/[repo]/keys
DELETE /repos/[owner]/[repo]/keys/[id]
GET /repos/[owner]/[repo]/downloads
GET /repos/[owner]/[repo]/downloads/[id]
DELETE /repos/[owner]/[repo]/downloads/[id]
GET /repos/[owner]/[repo]/forks
POST /repos/[owner]/[repo]/forks
GET /repos/[owner]/[repo]/hooks
GET /repos/[owner]/[repo]/hooks/[id]
POST /repos/[owner]/[repo]/hooks
POST /repos/[owner]/[repo]/hooks/[id]/tests
DELETE /repos/[owner]/[repo]/hooks/[id]
POST /repos/[owner]/[repo]/merges
GET /repos/[owner]/[repo]/releases
GET /repos/[owner]/[repo]/releases/[id]
POST /repos/[owner]/[repo]/releases
DELETE /repos/[owner]/[repo]/releases/[id]
GET /repos/[owner]/[repo]/releases/[id]/assets
GET /repos/[owner]/[repo]/stats/contributors
GET /repos/[owner]/[repo]/stats/commit_activity
GET /repos/[owner]/[repo]/stats/code_frequency
GET /repos/[owner]/[repo]/stats/participation
GET /repos/[owner]/[repo]/stats/punch_card
GET /repos/[owner]/[repo]/statuses/[ref]
POST /repos/[owner]/[repo]/statuses/[ref]
GET /search/repositories
GET /search/code
GET /search/issues
GET /search/users
GET /legacy/issues/search/[owner]/[repository]/[state]/[keyword]
GET /legacy/repos/search/[keyword]
GET /legacy/user/search/[keyword]
GET /legacy/user/email/[email]
GET /users/[user]
GET /user
GET /users
GET /user/emails
POST /user/emails
DELETE /user/emails
GET /users/[user]/followers
GET /user/followers
GET /users/[user]/following
GET /user/following
GET /user/following/[user]
GET /users/[user]/following/[target_user]
PUT /user/following/[user]
DELETE /user/following/[user]
GET /users/[user]/keys
GET /user/keys
GET /user/keys/[id]
POST /user/keys
DELETE /user/keys/[id]

View File

@ -1,23 +0,0 @@
package router_test
import (
"testing"
router "git.sharkk.net/Go/Router"
)
func TestHello(t *testing.T) {
r := router.New[string]()
r.Add("GET", "/blog", "Blog")
r.Add("GET", "/blog/post", "Blog post")
data, params := r.Lookup("GET", "/blog")
if data != "Blog" || len(params) != 0 {
t.Error("Invalid data")
}
data, params = r.Lookup("GET", "/blog/post")
if data != "Blog post" || len(params) != 0 {
t.Error("Invalid data")
}
}

194
tree.go
View File

@ -1,194 +0,0 @@
package router
// Representation of a node tree
type Tree[T any] struct {
root Node[T]
}
// Adds a new path to the tree
func (tree *Tree[T]) Add(path string, data T) {
i := 0
offset := 0
node := &tree.root
for {
begin:
switch node.kind {
case parameter:
// This only occurs when the same parameter based route is added twice.
// node: /post/[id|
// path: /post/[id|
if i == len(path) {
node.data = data
return
}
// When we hit a separator, we'll search for a fitting child.
if path[i] == separator {
node, offset, _ = node.finish(path, data, i, offset)
goto next
}
default:
if i == len(path) {
// The path already exists.
// node: /blog|
// path: /blog|
if i-offset == len(node.prefix) {
node.data = data
return
}
// The path ended but the node prefix is longer.
// node: /blog|feed
// path: /blog|
node.split(i-offset, "", data)
return
}
// The node we just checked is entirely included in our path.
// node: /|
// path: /|blog
if i-offset == len(node.prefix) {
var control Flow
node, offset, control = node.finish(path, data, i, offset)
switch control {
case flowStop:
return
case flowBegin:
goto begin
case flowNext:
goto next
}
}
// We got a conflict.
// node: /b|ag
// path: /b|riefcase
if path[i] != node.prefix[i-offset] {
node.split(i-offset, path[i:], data)
return
}
}
next:
i++
}
}
// Lookup finds the data for the given path.
func (tree *Tree[T]) Lookup(path string) (T, []Parameter) {
var params []Parameter
data := tree.LookupNoAlloc(path, func(key string, value string) {
params = append(params, Parameter{key, value})
})
return data, params
}
// Finds the data for the given path without using any memory allocations.
func (tree *Tree[T]) LookupNoAlloc(path string, addParameter func(key string, value string)) T {
var (
i uint
wildcardPath string
wildcard *Node[T]
node = &tree.root
)
// Skip the first loop iteration if the starting characters are equal
if len(path) > 0 && len(node.prefix) > 0 && path[0] == node.prefix[0] {
i = 1
}
begin:
// Search tree for equal parts until we can no longer proceed
for i < uint(len(path)) {
// The node we just checked is entirely included in our path.
// node: /|
// path: /|blog
if i == uint(len(node.prefix)) {
if node.wildcard != nil {
wildcard = node.wildcard
wildcardPath = path[i:]
}
char := path[i]
if char >= node.start && char < node.end {
index := node.indexes[char-node.start]
if index != 0 {
node = node.children[index]
path = path[i:]
i = 1
continue
}
}
// node: /|:id
// path: /|blog
if node.parameter != nil {
node = node.parameter
path = path[i:]
i = 1
for i < uint(len(path)) {
// node: /:id|/posts
// path: /123|/posts
if path[i] == separator {
addParameter(node.prefix, path[:i])
index := node.indexes[separator-node.start]
node = node.children[index]
path = path[i:]
i = 1
goto begin
}
i++
}
addParameter(node.prefix, path[:i])
return node.data
}
// node: /|*any
// path: /|image.png
goto notFound
}
// We got a conflict.
// node: /b|ag
// path: /b|riefcase
if path[i] != node.prefix[i] {
goto notFound
}
i++
}
// node: /blog|
// path: /blog|
if i == uint(len(node.prefix)) {
return node.data
}
// node: /|*any
// path: /|image.png
notFound:
if wildcard != nil {
addParameter(wildcard.prefix, wildcardPath)
return wildcard.data
}
var empty T
return empty
}
// Binds all handlers to a new one provided by the callback.
func (tree *Tree[T]) Map(transform func(T) T) {
tree.root.each(func(node *Node[T]) {
node.data = transform(node.data)
})
}