Added tests and benchmarks
This commit is contained in:
parent
1357c04549
commit
a05f4b2e36
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -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
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,8 +3,10 @@
|
||||
|
||||
# But not these files...
|
||||
!/.gitignore
|
||||
!/.editorconfig
|
||||
|
||||
!*.go
|
||||
!*.txt
|
||||
!go.sum
|
||||
!go.mod
|
||||
|
||||
|
38
Benchmarks_test.go
Normal file
38
Benchmarks_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
7
Parameter.go
Normal file
7
Parameter.go
Normal file
@ -0,0 +1,7 @@
|
||||
package router
|
||||
|
||||
// Parameter is a URL parameter.
|
||||
type Parameter struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
11
README.md
11
README.md
@ -1,3 +1,12 @@
|
||||
# router
|
||||
|
||||
Radix tree router.
|
||||
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
|
||||
```
|
@ -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]) {
|
97
Tree_test.go
Normal file
97
Tree_test.go
Normal file
@ -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
|
||||
}
|
2
go.mod
2
go.mod
@ -1,3 +1,5 @@
|
||||
module git.akyoto.dev/go/router
|
||||
|
||||
go 1.20
|
||||
|
||||
require git.akyoto.dev/go/assert v0.1.0
|
||||
|
2
go.sum
Normal file
2
go.sum
Normal file
@ -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=
|
131
testdata/github.txt
vendored
Normal file
131
testdata/github.txt
vendored
Normal file
@ -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
|
Loading…
Reference in New Issue
Block a user