From 5791042454d85089cfd91efdb23b6f1ed63c8828 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Sat, 15 Feb 2025 21:57:18 -0600 Subject: [PATCH] Rewrite the router --- README.md | 91 +++++++----- go.mod | 4 +- go.sum | 2 + node.go | 286 -------------------------------------- router.go | 278 +++++++++++++++++++++++++++---------- router_test.go | 299 ++++++++++++++++++++++++++++++++++++++++ tests/benchmark_test.go | 136 ------------------ tests/blog.txt | 4 - tests/github.txt | 203 --------------------------- tests/router_test.go | 23 ---- tree.go | 194 -------------------------- 11 files changed, 566 insertions(+), 954 deletions(-) create mode 100644 go.sum delete mode 100644 node.go create mode 100644 router_test.go delete mode 100644 tests/benchmark_test.go delete mode 100644 tests/blog.txt delete mode 100644 tests/github.txt delete mode 100644 tests/router_test.go delete mode 100644 tree.go diff --git a/README.md b/README.md index 0fae7c4..a7130e9 100644 --- a/README.md +++ b/README.md @@ -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). +Licensed under MIT. [Take a look!](LICENSE) \ No newline at end of file diff --git a/go.mod b/go.mod index e71e483..4096eba 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e7c5641 --- /dev/null +++ b/go.sum @@ -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= diff --git a/node.go b/node.go deleted file mode 100644 index 5055739..0000000 --- a/node.go +++ /dev/null @@ -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 -} diff --git a/router.go b/router.go index c834fe4..9415317 100644 --- a/router.go +++ b/router.go @@ -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) +} + +// 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) +} + +// 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) +} + +// 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) +} + +// 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 } - tree := router.selectTree(method) - return tree.Lookup(path) -} - -// 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) + if path[start] == '/' { + start++ } - tree := router.selectTree(method) - return tree.LookupNoAlloc(path, addParameter) + if start >= len(path) { + return "", start, false + } + + end = start + for end < len(path) && path[end] != '/' { + end++ + } + + return path[start:end], end, end < len(path) } -// 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) -} - -// 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: +// 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, ¶ms) + 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 } diff --git a/router_test.go b/router_test.go new file mode 100644 index 0000000..71974c4 --- /dev/null +++ b/router_test.go @@ -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) + } + }) + }) +} diff --git a/tests/benchmark_test.go b/tests/benchmark_test.go deleted file mode 100644 index fa22e7f..0000000 --- a/tests/benchmark_test.go +++ /dev/null @@ -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) {} diff --git a/tests/blog.txt b/tests/blog.txt deleted file mode 100644 index f0d928f..0000000 --- a/tests/blog.txt +++ /dev/null @@ -1,4 +0,0 @@ -GET / -GET /[slug] -GET /tags -GET /tag/[tag] \ No newline at end of file diff --git a/tests/github.txt b/tests/github.txt deleted file mode 100644 index d22ed36..0000000 --- a/tests/github.txt +++ /dev/null @@ -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] \ No newline at end of file diff --git a/tests/router_test.go b/tests/router_test.go deleted file mode 100644 index 8c5abdc..0000000 --- a/tests/router_test.go +++ /dev/null @@ -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") - } -} diff --git a/tree.go b/tree.go deleted file mode 100644 index 652fc7f..0000000 --- a/tree.go +++ /dev/null @@ -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) - }) -}