diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3c94c19 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false diff --git a/.gitignore b/.gitignore index 420c61b..c6b8d74 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,10 @@ # But not these files... !/.gitignore +!/.editorconfig !*.go +!*.txt !go.sum !go.mod diff --git a/Benchmarks_test.go b/Benchmarks_test.go new file mode 100644 index 0000000..4d8a7f5 --- /dev/null +++ b/Benchmarks_test.go @@ -0,0 +1,38 @@ +package router_test + +import ( + "testing" + + "git.akyoto.dev/go/router" +) + +func BenchmarkLookup(b *testing.B) { + tree := router.Tree[string]{} + routes := loadRoutes("testdata/github.txt") + + for _, route := range routes { + tree.Add(route, "") + } + + for i := 0; i < b.N; i++ { + for _, route := range routes { + tree.Lookup(route) + } + } +} + +func BenchmarkLookupNoAlloc(b *testing.B) { + tree := router.Tree[string]{} + routes := loadRoutes("testdata/github.txt") + addParameter := func(string, string) {} + + for _, route := range routes { + tree.Add(route, "") + } + + for i := 0; i < b.N; i++ { + for _, route := range routes { + tree.LookupNoAlloc(route, addParameter) + } + } +} diff --git a/Parameter.go b/Parameter.go new file mode 100644 index 0000000..b1c7cd2 --- /dev/null +++ b/Parameter.go @@ -0,0 +1,7 @@ +package router + +// Parameter is a URL parameter. +type Parameter struct { + Key string + Value string +} diff --git a/README.md b/README.md index b747837..00b70fe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ # router -Radix tree router. \ No newline at end of file +HTTP router based on radix trees. + +## Benchmarks + +Loading and requesting every single route in [github.txt](testdata/github.txt): + +``` +BenchmarkLookup-12 50715 23207 ns/op 11649 B/op 204 allocs/op +BenchmarkLookupNoAlloc-12 148033 7993 ns/op 0 B/op 0 allocs/op +``` \ No newline at end of file diff --git a/tree.go b/Tree.go similarity index 77% rename from tree.go rename to Tree.go index 18a7a32..7ed04b6 100644 --- a/tree.go +++ b/Tree.go @@ -14,15 +14,15 @@ const ( controlNext controlFlow = 2 ) -// tree represents a radix tree. -type tree[T comparable] struct { +// Tree represents a radix tree. +type Tree[T comparable] struct { root treeNode[T] static map[string]T canBeStatic [2048]bool } -// add adds a new element to the tree. -func (tree *tree[T]) add(path string, data T) { +// Add adds a new element to the tree. +func (tree *Tree[T]) Add(path string, data T) { if !strings.Contains(path, ":") && !strings.Contains(path, "*") { if tree.static == nil { tree.static = map[string]T{} @@ -113,14 +113,24 @@ func (tree *tree[T]) add(path string, data T) { } } -// find finds the data for the given path and assigns it to ctx.handler, if available. -func (tree *tree[T]) find(path string, ctx *context) { +// 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 +} + +// LookupNoAlloc 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 { if tree.canBeStatic[len(path)] { handler, found := tree.static[path] if found { - ctx.handler = handler - return + return handler } } @@ -129,6 +139,7 @@ func (tree *tree[T]) find(path string, ctx *context) { offset uint lastWildcardOffset uint lastWildcard *treeNode[T] + empty T node = &tree.root ) @@ -140,14 +151,12 @@ begin: // node: /blog| // path: /blog| if i-offset == uint(len(node.prefix)) { - ctx.handler = node.data - return + return node.data } // node: /blog|feed // path: /blog| - ctx.handler = nil - return + return empty } // The node we just checked is entirely included in our path. @@ -182,15 +191,14 @@ begin: for { // We reached the end. if i == uint(len(path)) { - ctx.addParameter(node.prefix, path[offset:i]) - ctx.handler = node.data - return + addParameter(node.prefix, path[offset:i]) + return node.data } // node: /:id|/posts // path: /123|/posts if path[i] == separator { - ctx.addParameter(node.prefix, path[offset:i]) + addParameter(node.prefix, path[offset:i]) index := node.indices[separator-node.startIndex] node = node.children[index] offset = i @@ -205,13 +213,11 @@ begin: // node: /|*any // path: /|image.png if node.wildcard != nil { - ctx.addParameter(node.wildcard.prefix, path[i:]) - ctx.handler = node.wildcard.data - return + addParameter(node.wildcard.prefix, path[i:]) + return node.wildcard.data } - ctx.handler = nil - return + return empty } // We got a conflict. @@ -219,21 +225,19 @@ begin: // path: /b|riefcase if path[i] != node.prefix[i-offset] { if lastWildcard != nil { - ctx.addParameter(lastWildcard.prefix, path[lastWildcardOffset:]) - ctx.handler = lastWildcard.data - return + addParameter(lastWildcard.prefix, path[lastWildcardOffset:]) + return lastWildcard.data } - ctx.handler = nil - return + return empty } i++ } } -// bind binds all handlers to a new one provided by the callback. -func (tree *tree[T]) bind(transform func(T) T) { +// Bind binds all handlers to a new one provided by the callback. +func (tree *Tree[T]) Bind(transform func(T) T) { var empty T tree.root.each(func(node *treeNode[T]) { diff --git a/Tree_test.go b/Tree_test.go new file mode 100644 index 0000000..b46ba36 --- /dev/null +++ b/Tree_test.go @@ -0,0 +1,97 @@ +package router_test + +import ( + "bufio" + "os" + "strings" + "testing" + + "git.akyoto.dev/go/assert" + "git.akyoto.dev/go/router" +) + +func TestHello(t *testing.T) { + tree := router.Tree[string]{} + + tree.Add("/hello", "Hello") + tree.Add("/world", "World") + + data, params := tree.Lookup("/hello") + assert.Equal(t, len(params), 0) + assert.Equal(t, data, "Hello") + + data, params = tree.Lookup("/world") + assert.Equal(t, len(params), 0) + assert.Equal(t, data, "World") + + data, params = tree.Lookup("/404") + assert.Equal(t, len(params), 0) + assert.Equal(t, data, "") +} + +func TestParams(t *testing.T) { + tree := router.Tree[string]{} + + tree.Add("/blog/:slug", "Blog post") + tree.Add("/blog/:slug/comments/:id", "Comment") + + data, params := tree.Lookup("/blog/hello-world") + assert.Equal(t, len(params), 1) + assert.Equal(t, params[0].Key, "slug") + assert.Equal(t, params[0].Value, "hello-world") + assert.Equal(t, data, "Blog post") + + data, params = tree.Lookup("/blog/hello-world/comments/123") + assert.Equal(t, len(params), 2) + assert.Equal(t, params[0].Key, "slug") + assert.Equal(t, params[0].Value, "hello-world") + assert.Equal(t, params[1].Key, "id") + assert.Equal(t, params[1].Value, "123") + assert.Equal(t, data, "Comment") +} + +func TestGitHub(t *testing.T) { + tree := router.Tree[string]{} + routes := loadRoutes("testdata/github.txt") + + for _, route := range routes { + tree.Add(route, "octocat") + } + + for _, route := range routes { + data, _ := tree.Lookup(route) + assert.Equal(t, data, "octocat") + } +} + +func loadRoutes(fileName string) []string { + var routes []string + + for route := range routesInFile(fileName) { + routes = append(routes, route) + } + + return routes +} + +func routesInFile(fileName string) <-chan string { + routes := make(chan string) + + go func() { + defer close(routes) + file, err := os.Open(fileName) + + if err != nil { + return + } + + defer file.Close() + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + routes <- strings.TrimSpace(scanner.Text()) + } + }() + + return routes +} diff --git a/go.mod b/go.mod index 4aecb6d..01c51df 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module git.akyoto.dev/go/router go 1.20 + +require git.akyoto.dev/go/assert v0.1.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b160cf2 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.akyoto.dev/go/assert v0.1.0 h1:+DRuXKk4QdNcqJYlQizQhOr5DKGi5/HQMNCOQUWxIDU= +git.akyoto.dev/go/assert v0.1.0/go.mod h1:Zr/UFuiqmqRmFFgpBGwF71jbzb6iYJfXFeePYHGtWsg= diff --git a/testdata/github.txt b/testdata/github.txt new file mode 100644 index 0000000..5dc1acd --- /dev/null +++ b/testdata/github.txt @@ -0,0 +1,131 @@ +/authorizations +/authorizations/:id +/applications/:client_id/tokens/:access_token +/events +/repos/:owner/:repo/events +/networks/:owner/:repo/events +/orgs/:org/events +/users/:user/received_events +/users/:user/received_events/public +/users/:user/events +/users/:user/events/public +/users/:user/events/orgs/:org +/feeds +/notifications +/repos/:owner/:repo/notifications +/notifications/threads/:id +/notifications/threads/:id/subscription +/repos/:owner/:repo/stargazers +/users/:user/starred +/user/starred +/user/starred/:owner/:repo +/repos/:owner/:repo/subscribers +/users/:user/subscriptions +/user/subscriptions +/repos/:owner/:repo/subscription +/user/subscriptions/:owner/:repo +/users/:user/gists +/gists +/gists/:id +/gists/:id/star +/repos/:owner/:repo/git/blobs/:sha +/repos/:owner/:repo/git/commits/:sha +/repos/:owner/:repo/git/refs +/repos/:owner/:repo/git/tags/:sha +/repos/:owner/:repo/git/trees/:sha +/issues +/user/issues +/orgs/:org/issues +/repos/:owner/:repo/issues +/repos/:owner/:repo/issues/:number +/repos/:owner/:repo/assignees +/repos/:owner/:repo/assignees/:assignee +/repos/:owner/:repo/issues/:number/comments +/repos/:owner/:repo/issues/:number/events +/repos/:owner/:repo/labels +/repos/:owner/:repo/labels/:name +/repos/:owner/:repo/issues/:number/labels +/repos/:owner/:repo/milestones/:number/labels +/repos/:owner/:repo/milestones +/repos/:owner/:repo/milestones/:number +/emojis +/gitignore/templates +/gitignore/templates/:name +/meta +/rate_limit +/users/:user/orgs +/user/orgs +/orgs/:org +/orgs/:org/members +/orgs/:org/members/:user +/orgs/:org/public_members +/orgs/:org/public_members/:user +/orgs/:org/teams +/teams/:id +/teams/:id/members +/teams/:id/members/:user +/teams/:id/repos +/teams/:id/repos/:owner/:repo +/user/teams +/repos/:owner/:repo/pulls +/repos/:owner/:repo/pulls/:number +/repos/:owner/:repo/pulls/:number/commits +/repos/:owner/:repo/pulls/:number/files +/repos/:owner/:repo/pulls/:number/merge +/repos/:owner/:repo/pulls/:number/comments +/user/repos +/users/:user/repos +/orgs/:org/repos +/repositories +/repos/:owner/:repo +/repos/:owner/:repo/contributors +/repos/:owner/:repo/languages +/repos/:owner/:repo/teams +/repos/:owner/:repo/tags +/repos/:owner/:repo/branches +/repos/:owner/:repo/branches/:branch +/repos/:owner/:repo/collaborators +/repos/:owner/:repo/collaborators/:user +/repos/:owner/:repo/comments +/repos/:owner/:repo/commits/:sha/comments +/repos/:owner/:repo/comments/:id +/repos/:owner/:repo/commits +/repos/:owner/:repo/commits/:sha +/repos/:owner/:repo/readme +/repos/:owner/:repo/keys +/repos/:owner/:repo/keys/:id +/repos/:owner/:repo/downloads +/repos/:owner/:repo/downloads/:id +/repos/:owner/:repo/forks +/repos/:owner/:repo/hooks +/repos/:owner/:repo/hooks/:id +/repos/:owner/:repo/releases +/repos/:owner/:repo/releases/:id +/repos/:owner/:repo/releases/:id/assets +/repos/:owner/:repo/stats/contributors +/repos/:owner/:repo/stats/commit_activity +/repos/:owner/:repo/stats/code_frequency +/repos/:owner/:repo/stats/participation +/repos/:owner/:repo/stats/punch_card +/repos/:owner/:repo/statuses/:ref +/search/repositories +/search/code +/search/issues +/search/users +/legacy/issues/search/:owner/:repository/:state/:keyword +/legacy/repos/search/:keyword +/legacy/user/search/:keyword +/legacy/user/email/:email +/users/:user +/user +/users +/user/emails +/users/:user/followers +/user/followers +/users/:user/following +/user/following +/user/following/:user +/users/:user/following/:target_user +/users/:user/keys +/user/keys +/user/keys/:id \ No newline at end of file