Skip to content

Simple, idiomatic and fast 161 loc http router for golang

License

Notifications You must be signed in to change notification settings

DATA-DOG/fastroute

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Build Status GoDoc codecov.io

FastRoute

Insanely simple, idiomatic and fast - 200 loc http router for golang. Uses standard http.Handler and has no limitations to path matching compared to routers derived from Trie (radix) tree based solutions.

Less is exponentially more

fastroute.Router interface extends http.Handler with one extra method - Route in order to route http.Request to http.Handler allowing to chain routes until one is matched.

Go is about composition

The gravest problem all routers have - is the central structure holding all the context.

fastroute is extremely flexible, because it has only static, unbounded functions. Allows unlimited ways to compose router.

See the following example:

package main

import (
	"fmt"
	"net/http"

	fr "github.com/DATA-DOG/fastroute"
)

var routes = map[string]fr.Router{
	"GET": fr.Chain(
		fr.New("/", handler),
		fr.New("/hello/:name/:surname", handler),
		fr.New("/hello/:name", handler),
	),
	"POST": fr.Chain(
		fr.New("/users", handler),
		fr.New("/users/:id", handler),
	),
}

var router = fr.RouterFunc(func(req *http.Request) http.Handler {
	return routes[req.Method] // fastroute.Router is also http.Handler
})

func main() {
	http.ListenAndServe(":8080", router)
}

func handler(w http.ResponseWriter, req *http.Request) {
	fmt.Fprintln(w, fmt.Sprintf(
		`%s "%s", pattern: "%s", parameters: "%v"`,
		req.Method,
		req.URL.Path,
		fr.Pattern(req),
		fr.Parameters(req),
	))
}

In overall, it is not all in one router, it is the same http.Handler with do it yourself style, but with zero allocations path pattern matching. Feel free to just copy it and adapt to your needs.

It deserves a quote from Rob Pike:

Fancy algorithms are slow when n is small, and n is usually small. Fancy algorithms have big constants. Until you know that n is frequently going to be big, don't get fancy.

The trade off this router makes is the size of n. Instead it provides orthogonal building blocks, just like http.Handler does, in order to build customized routers.

Guides

Here are some common usage guidelines:

Custom Not Found handler

Since fastroute.Router returns nil if request is not matched, we can easily extend it and create middleware for it at as many levels as we like.

package main

import (
	"fmt"
	"net/http"

	"github.com/DATA-DOG/fastroute"
)

func main() {
	notFoundHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		w.WriteHeader(404)
		fmt.Fprintln(w, "Ooops, looks like you mistyped the URL:", req.URL.Path)
	})

	router := fastroute.New("/users/:id", func(w http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(w, "user:", fastroute.Parameters(req).ByName("id"))
	})

	http.ListenAndServe(":8080", fastroute.RouterFunc(func(req *http.Request) http.Handler {
		if h := router.Route(req); h != nil {
			return h
		}
		return notFoundHandler
	}))
}

This way, it is possible to extend fastroute.Router with various middleware, including:

  • Method not found handler.
  • Fixed path or trailing slash redirects. Based on your chosen route layout.
  • Options or CORS.

Hit counting frequently accessed routes

In cases where n number of routes is very high and it is unknown what routes would be most frequently accessed or it changes during runtime, in order to highly improve performance, you can use hit count based reordering middleware.

package main

import (
	"fmt"
	"net/http"
	"sort"

	fr "github.com/DATA-DOG/fastroute"
)

var routes = map[string]fr.Router{
	"GET": fr.Chain(
		// here follows frequently accessed routes
		HitCountingOrderedChain(
			fr.New("/", handler),
			fr.New("/health", handler),
			fr.New("/status", handler),
		),
		// less frequently accessed routes
		fr.New("/hello/:name/:surname", handler),
		fr.New("/hello/:name", handler),
	),
	"POST": fr.Chain(
		fr.New("/users", handler),
		fr.New("/users/:id", handler),
	),
}

// serves routes by request method
var router = fr.RouterFunc(func(req *http.Request) http.Handler {
	return routes[req.Method] // fastroute.Router is also http.Handler
})

func main() {
	http.ListenAndServe(":8080", router)
}

func HitCountingOrderedChain(routes ...fr.Router) fr.Router {
	type HitCounter struct {
		fr.Router
		hits int64
	}

	hitRoutes := make([]*HitCounter, len(routes))
	for i, r := range routes {
		hitRoutes[i] = &HitCounter{Router: r}
	}

	return fr.RouterFunc(func(req *http.Request) http.Handler {
		for i, r := range hitRoutes {
			if h := r.Route(req); h != nil {
				r.hits++
				// reorder route hit is behind one third of routes
				if i > len(hitRoutes)*30/100 {
					sort.Slice(hitRoutes, func(i, j int) bool {
						return hitRoutes[i].hits > hitRoutes[j].hits
					})
				}
				return h
			}
		}
		return nil
	})
}

func handler(w http.ResponseWriter, req *http.Request) {
	fmt.Fprintln(w, fmt.Sprintf(
		`%s "%s", pattern: "%s", parameters: "%v"`,
		req.Method,
		req.URL.Path,
		fr.Pattern(req),
		fr.Parameters(req),
	))
}

Method not found support

Fastroute provides way to check whether request can be served, not only serve it. Though, the parameters then must be recycled in order to prevent leaking. When a routed request is served, it automatically recycles.

package main

import (
	"fmt"
	"net/http"
	"strings"

	fr "github.com/DATA-DOG/fastroute"
)

var routes = map[string]fr.Router{
	"GET":    fr.New("/users", handler),
	"POST":   fr.New("/users/:id", handler),
	"PUT":    fr.New("/users/:id", handler),
	"DELETE": fr.New("/users/:id", handler),
}

var router = fr.RouterFunc(func(req *http.Request) http.Handler {
	return routes[req.Method] // fastroute.Router is also http.Handler
})

var app = fr.RouterFunc(func(req *http.Request) http.Handler {
	if h := router.Route(req); h != nil {
		return h // routed and can be served
	}

	var allows []string
	for method, routes := range routes {
		if h := routes.Route(req); h != nil {
			allows = append(allows, method)
			fr.Recycle(req) // we will not serve it, need to recycle
		}
	}

	if len(allows) == 0 {
		return nil
	}

	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		w.Header().Set("Allow", strings.Join(allows, ","))
		w.WriteHeader(http.StatusMethodNotAllowed)
		fmt.Fprintln(w, http.StatusText(http.StatusMethodNotAllowed))
	})
})

func main() {
	http.ListenAndServe(":8080", app)
}

func handler(w http.ResponseWriter, req *http.Request) {
	fmt.Fprintln(w, fmt.Sprintf(
		`%s "%s", pattern: "%s", parameters: "%v"`,
		req.Method,
		req.URL.Path,
		fr.Pattern(req),
		fr.Parameters(req),
	))
}

If we make a request: curl -i http://localhost:8080/users/1, we will get:

HTTP/1.1 405 Method Not Allowed
Allow: PUT,DELETE,POST
Date: Fri, 19 May 2017 06:09:56 GMT
Content-Length: 19
Content-Type: text/plain; charset=utf-8

Method Not Allowed

Combining static routes

The best and fastest way to match static routes - is to have a map of path -> handler pairs.

package main

import (
	"fmt"
	"net/http"

	"github.com/DATA-DOG/fastroute"
)

func main() {
	handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(w, req.URL.Path, fastroute.Parameters(req))
	})

	static := map[string]http.Handler{
		"/status":      handler,
		"/users/roles": handler,
	}

	staticRoutes := fastroute.RouterFunc(func(req *http.Request) http.Handler {
		return static[req.URL.Path]
	})

	dynamicRoutes := fastroute.Chain(
		fastroute.New("/users/:id", handler),
		fastroute.New("/users/:id/roles", handler),
	)

	http.ListenAndServe(":8080", fastroute.Chain(staticRoutes, dynamicRoutes))
}

Trailing slash or fixed path redirects

In cases when your API faces public, it might be a good idea to redirect with corrected request URL if user makes a simple mistake.

This fixes trailing slash, case mismatch and cleaned path all at once. Note, we should follow some specific rule, how we build our path patterns in order to be able to fix them. In this case we follow all lowercase rule for static segments, parameters may match any case.

package main

import (
	"fmt"
	"net/http"
	"path"
	"strings"

	"github.com/DATA-DOG/fastroute"
)

func main() {
	handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		fmt.Fprintln(w, req.URL.Path, fastroute.Parameters(req))
	})

	// we follow the lowercase rule for static segments
	router := fastroute.Chain(
		fastroute.New("/status", handler),
		fastroute.New("/users/:id", handler),
		fastroute.New("/users/:id/roles/", handler), // one with trailing slash
	)

	http.ListenAndServe(":8080", redirectTrailingOrFixedPath(router))

	// requesting: http://localhost:8080/Users/5/Roles
	// redirects: http://localhost:8080/users/5/roles/
}

func redirectTrailingOrFixedPath(router fastroute.Router) fastroute.Router {
	return fastroute.RouterFunc(func(req *http.Request) http.Handler {
		if h := router.Route(req); h != nil {
			return h // has matched, no need for fixing
		}

		p := strings.ToLower(path.Clean(req.URL.Path)) // first clean path and lowercase
		attempts := []string{p}                        // first variant with cleaned path
		if p[len(p)-1] == '/' {
			attempts = append(attempts, p[:len(p)-1]) // without trailing slash
		} else {
			attempts = append(attempts, p+"/") // with trailing slash
		}

		try, _ := http.NewRequest(req.Method, "/", nil) // make request for all attempts
		for _, attempt := range attempts {
			try.URL.Path = attempt
			if h := router.Route(try); h != nil {
				// matched, resolve fixed path and redirect
				pat, params := fastroute.Pattern(try), fastroute.Parameters(try)
				var fixed []string
				var nextParam int
				for _, segment := range strings.Split(pat, "/") {
					if strings.IndexAny(segment, ":*") != -1 {
						fixed = append(fixed, params[nextParam].Value)
						nextParam++
					} else {
						fixed = append(fixed, segment)
					}
				}
				fastroute.Recycle(try)
				return redirect(strings.Join(fixed, "/"))
			}
		}
		return nil // could not fix path
	})
}

func redirect(fixedPath string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		req.URL.Path = fixedPath
		http.Redirect(w, req, req.URL.String(), http.StatusPermanentRedirect)
	})
}

Named routes

This is trivial to implement a package inside your project, where all your routes used may be named. And later paths built by these named routes from anywhere within your application.

package routes

import (
	"fmt"
	"strings"

	"github.com/DATA-DOG/fastroute"
)

var all = make(map[string]string)

func Named(name, path string, handler interface{}) fastroute.Router {
	if p, dup := all[name]; dup {
		panic(fmt.Sprintf(`route: "%s" at path: "%s" was already registered for path: "%s"`, name, path, p))
	}
	all[name] = path
	return fastroute.New(path, handler)
}

func Get(name string, params fastroute.Params) string {
	p, ok := all[name]
	if !ok {
		panic(fmt.Sprintf(`route: "%s" was never registered`, name))
	}
	for _, param := range params {
		if key := ":" + param.Key; strings.Index(p, key) != -1 {
			p = strings.Replace(p, key, param.Value, 1)
		} else if key = "*" + param.Key; strings.Index(p, key) != -1 {
			p = strings.Replace(p, key, param.Value, 1)
		}
	}

	if strings.IndexAny(p, ":*") != -1 {
		panic(fmt.Sprintf(`not all parameters were set: "%s" for route: "%s"`, p, name))
	}
	return p
}

Then the usage is obvious:

package main

import (
	"fmt"
	"net/http"

	"github.com/DATA-DOG/fastroute"
	"github.com/DATA-DOG/fastroute/routes" // should be somewhere in your project
)

func main() {
	router := fastroute.Chain(
		routes.Named("home", "/", handler),
		routes.Named("hello-full", "/hello/:name/:surname", handler),
	)

	fmt.Println(routes.Get("hello-full", fastroute.Params{
		{"name", "John"},
		{"surname", "Doe"},
	}))

	http.ListenAndServe(":8080", router)
}

func handler(w http.ResponseWriter, req *http.Request) {
	fmt.Fprintln(w, fmt.Sprintf(`%s "%s"`, req.Method, req.URL.Path))
}

Benchmarks

The benchmarks can be found here.

Benchmark type repeats cpu time op mem op mem allocs op
Gin_Param 20000000 70.9 ns/op 0 B/op 0 allocs/op
GorillaMux_Param 500000 3116 ns/op 1056 B/op 11 allocs/op
HttpRouter_Param 20000000 117 ns/op 32 B/op 1 allocs/op
FastRoute_Param 20000000 105 ns/op 0 B/op 0 allocs/op
Pat_Param 1000000 1910 ns/op 648 B/op 12 allocs/op
Gin_Param5 20000000 117 ns/op 0 B/op 0 allocs/op
GorillaMux_Param5 300000 4597 ns/op 1184 B/op 11 allocs/op
HttpRouter_Param5 3000000 487 ns/op 160 B/op 1 allocs/op
FastRoute_Param5 20000000 117 ns/op 0 B/op 0 allocs/op
Pat_Param5 300000 4717 ns/op 964 B/op 32 allocs/op
Gin_Param20 5000000 280 ns/op 0 B/op 0 allocs/op
GorillaMux_Param20 200000 11062 ns/op 3547 B/op 13 allocs/op
HttpRouter_Param20 1000000 1670 ns/op 640 B/op 1 allocs/op
FastRoute_Param20 10000000 197 ns/op 0 B/op 0 allocs/op
Pat_Param20 100000 20735 ns/op 4687 B/op 111 allocs/op
Gin_ParamWrite 10000000 170 ns/op 0 B/op 0 allocs/op
GorillaMux_ParamWrite 500000 3136 ns/op 1064 B/op 12 allocs/op
HttpRouter_ParamWrite 10000000 156 ns/op 32 B/op 1 allocs/op
FastRoute_ParamWrite 10000000 162 ns/op 0 B/op 0 allocs/op
Pat_ParamWrite 500000 3196 ns/op 1072 B/op 17 allocs/op
Gin_GithubStatic 20000000 87.3 ns/op 0 B/op 0 allocs/op
GorillaMux_GithubStatic 100000 15215 ns/op 736 B/op 10 allocs/op
HttpRouter_GithubStatic 30000000 49.7 ns/op 0 B/op 0 allocs/op
FastRoute_GithubStatic 20000000 60.3 ns/op 0 B/op 0 allocs/op
Pat_GithubStatic 200000 10970 ns/op 3648 B/op 76 allocs/op
Gin_GithubParam 10000000 142 ns/op 0 B/op 0 allocs/op
GorillaMux_GithubParam 200000 9998 ns/op 1088 B/op 11 allocs/op
HttpRouter_GithubParam 5000000 301 ns/op 96 B/op 1 allocs/op
FastRoute_GithubParam 5000000 387 ns/op 0 B/op 0 allocs/op
Pat_GithubParam 200000 7083 ns/op 2464 B/op 48 allocs/op
Gin_GithubAll 50000 28162 ns/op 0 B/op 0 allocs/op
GorillaMux_GithubAll 300 5731742 ns/op 211840 B/op 2272 allocs/op
HttpRouter_GithubAll 30000 49198 ns/op 13792 B/op 167 allocs/op
FastRoute_GithubAll 10000 179753 ns/op 5 B/op 0 allocs/op
Pat_GithubAll 300 4388066 ns/op 1499571 B/op 27435 allocs/op
Gin_GPlusStatic 20000000 73.3 ns/op 0 B/op 0 allocs/op
GorillaMux_GPlusStatic 1000000 2015 ns/op 736 B/op 10 allocs/op
HttpRouter_GPlusStatic 30000000 34.0 ns/op 0 B/op 0 allocs/op
FastRoute_GPlusStatic 50000000 37.3 ns/op 0 B/op 0 allocs/op
Pat_GPlusStatic 5000000 330 ns/op 96 B/op 2 allocs/op
Gin_GPlusParam 20000000 96.9 ns/op 0 B/op 0 allocs/op
GorillaMux_GPlusParam 300000 4334 ns/op 1056 B/op 11 allocs/op
HttpRouter_GPlusParam 10000000 212 ns/op 64 B/op 1 allocs/op
FastRoute_GPlusParam 10000000 145 ns/op 0 B/op 0 allocs/op
Pat_GPlusParam 1000000 2142 ns/op 688 B/op 12 allocs/op
Gin_GPlus2Params 10000000 121 ns/op 0 B/op 0 allocs/op
GorillaMux_GPlus2Params 200000 8264 ns/op 1088 B/op 11 allocs/op
HttpRouter_GPlus2Params 10000000 232 ns/op 64 B/op 1 allocs/op
FastRoute_GPlus2Params 5000000 351 ns/op 0 B/op 0 allocs/op
Pat_GPlus2Params 200000 6557 ns/op 2256 B/op 34 allocs/op
Gin_GPlusAll 1000000 1279 ns/op 0 B/op 0 allocs/op
GorillaMux_GPlusAll 20000 66580 ns/op 13296 B/op 142 allocs/op
HttpRouter_GPlusAll 1000000 2358 ns/op 640 B/op 11 allocs/op
FastRoute_GPlusAll 500000 2546 ns/op 0 B/op 0 allocs/op
Pat_GPlusAll 30000 47673 ns/op 16576 B/op 298 allocs/op
Gin_ParseStatic 20000000 71.2 ns/op 0 B/op 0 allocs/op
GorillaMux_ParseStatic 500000 2971 ns/op 752 B/op 11 allocs/op
HttpRouter_ParseStatic 50000000 32.1 ns/op 0 B/op 0 allocs/op
FastRoute_ParseStatic 30000000 42.3 ns/op 0 B/op 0 allocs/op
Pat_ParseStatic 2000000 781 ns/op 240 B/op 5 allocs/op
Gin_ParseParam 20000000 79.2 ns/op 0 B/op 0 allocs/op
GorillaMux_ParseParam 500000 3710 ns/op 1088 B/op 12 allocs/op
HttpRouter_ParseParam 10000000 181 ns/op 64 B/op 1 allocs/op
FastRoute_ParseParam 10000000 184 ns/op 0 B/op 0 allocs/op
Pat_ParseParam 500000 3165 ns/op 1120 B/op 17 allocs/op
Gin_Parse2Params 20000000 91.5 ns/op 0 B/op 0 allocs/op
GorillaMux_Parse2Params 500000 3916 ns/op 1088 B/op 11 allocs/op
HttpRouter_Parse2Params 10000000 212 ns/op 64 B/op 1 allocs/op
FastRoute_Parse2Params 10000000 147 ns/op 0 B/op 0 allocs/op
Pat_Parse2Params 500000 2980 ns/op 832 B/op 17 allocs/op
Gin_ParseAll 1000000 2264 ns/op 0 B/op 0 allocs/op
GorillaMux_ParseAll 10000 125569 ns/op 24864 B/op 292 allocs/op
HttpRouter_ParseAll 500000 3124 ns/op 640 B/op 16 allocs/op
FastRoute_ParseAll 500000 3324 ns/op 0 B/op 0 allocs/op
Pat_ParseAll 30000 56328 ns/op 17264 B/op 343 allocs/op
Gin_StaticAll 100000 19064 ns/op 0 B/op 0 allocs/op
GorillaMux_StaticAll 1000 1536755 ns/op 115648 B/op 1578 allocs/op
FastRoute_StaticAll 200000 9149 ns/op 0 B/op 0 allocs/op
HttpRouter_StaticAll 200000 10824 ns/op 0 B/op 0 allocs/op
Pat_StaticAll 1000 1597577 ns/op 533904 B/op 11123 allocs/op

We can see that FastRoute outperforms fastest routers in some of the cases:

  • Number of routes is small.
  • Routes are static and served from a map.
  • There are many named parameters.

FastRoute was easily adapted for this benchmark. Where static routes are served, nothing is better or faster than a static path map. FastRoute allows to build any kind of router, depending on an use case. By default it targets smaller number of routes and the weakest link is large set of dynamic routes, because these are matched one by one in order.

It always boils down to targeted case implementation. It is a general purpose router of 200 lines of source code in one file, which can be copied, understood and adapted in separate projects.

Contributions

Feel free to open a pull request. Note, if you wish to contribute an extension to public (exported methods or types) - please open an issue before to discuss whether these changes can be accepted. All backward incompatible changes are and will be treated cautiously.

License

FastRoute is licensed under the three clause BSD license

About

Simple, idiomatic and fast 161 loc http router for golang

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages