Added tests and benchmarks

This commit is contained in:
Eduard Urbach 2023-07-09 17:46:17 +02:00
parent 1357c04549
commit a05f4b2e36
Signed by: akyoto
GPG Key ID: C874F672B1AF20C0
10 changed files with 330 additions and 29 deletions

9
.editorconfig Normal file
View 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
View File

@ -3,8 +3,10 @@
# But not these files...
!/.gitignore
!/.editorconfig
!*.go
!*.txt
!go.sum
!go.mod

38
Benchmarks_test.go Normal file
View 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
View File

@ -0,0 +1,7 @@
package router
// Parameter is a URL parameter.
type Parameter struct {
Key string
Value string
}

View File

@ -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
```

View File

@ -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
View 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
View File

@ -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
View 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
View 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