Guide to 3rd Party Routers in Go

Go package net/http provides a lot of things so that we can build web apps without that much code. But the routing capabilities of http.Handle(pattern, handler) is very basic. So basic you can only have static routes. That's why we need to use a better (3rd party) router, but there's a lot of routers with different features and we can easily get lost and not able to choose which one to use.

This is the fourth article in the five-part series "Build Your Own Web Framework in Go":

What is the best router in Go?

When beginning in a new language, it's scary to see 10 different libraries doing the same thing. You don't really know the best practices and don't know which one to use. There's already a lot of routers in Go and we want one that would be fast, memory efficient and simple to use. Here's an overview of routers I think are representative of what Go developers use the most.

There's a benchmark comparing speed and memory consumption of different routers, you may want to look at it.

gorilla/mux: The full-fledged router

gorilla/mux is the most popular router, but it is also slow and memory hungry. It is also the most feature packed router. You can have URL params with regex constraints:

r := mux.NewRouter()
r.HandleFunc("/teas/{category}/", TeasCategoryHandler)
r.HandleFunc("/teas/{category}/{id:[0-9]+}", TeaHandler)

You can match a route with a method:

r.Methods("GET", "HEAD").HandleFunc("/teas/{category}/", TeasCategoryHandler)

But unlike other routers, there's also a lot of other built-in matchers. You can match routes with an host (like a subdomain), a prefix, a schema (http, https, etc), HTTP headers, query params, and if it doesn't suit you you can easily create a custom matcher like this:

r.MatcherFunc(func(r *http.Request, rm *RouteMatch) bool {
  return r.ProtoMajor == 0
})

Inside your handler, you can get the URL Params from mux.Vars(request) which is basically a context like gorilla/context that we saw in the previous part.

func myHandler(w http.ResponseWriter, r *http.Request) {
  vars := mux.Vars(r)
  category := vars["category"]
}

This solution is compatible with http.Handler interface and like I already said, it's important because the more apps we make the more likely we will need to share middlewares/handlers the more it has to follow the same principles.

Pros: Lots of features, you can create complex rules very easily. And it's compatible with http.Handler

Cons: Slow and memory hungry. Might not be suitable if what you need is speed.

httprouter: The fastest

httprouter is the fastest router and the benchmark provided in the note above has been made by the same developer. It is simpler than gorilla/mux, you can't make constraints nor regex in your routes. I think it's fine when making REST APIs, but for complex view routes the simplicity of this router can be limiting. It is not compatible with http.Handler, it defines a new interface taking a 3rd argument for the URL params.

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprint(w, "Welcome!\n")
}

func Hello(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    fmt.Fprintf(w, "hello, %s!\n", ps.ByName("name"))
}

func main() {
    router := httprouter.New()
    router.GET("/", Index)
    router.GET("/hello/:name", Hello)
}

This problem can be overcome by putting URL params into a context and using standard http.Handler (more on that later). We lose a bit of performance but it still will be the fastest router.

This router is becoming increasingly popular. For exemple, it is used in the framework Gin.

Pros: Fast.

Cons: Not compatible with http.Handler.

Pat

Pat is also famous and very simple too. The thing I like about it is that it is totally compatible with http.Handler but instead of using a mutexed context like gorilla/context to store URL params, it stores params into request query string URL (r.URL.Query()). In comparison, that is what Sinatra and Rails (and other libraries/frameworks in other languages too) are doing, mixing query string params with URL params because if you think of it, it is the same (we could totally do something like /posts?id=1 but it's not REST).

func Hello(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "hello, %s!\n", r.URL.Query().Get(":name"))
}

func main() {
  m := pat.New()
  m.Get("/hello/:name", http.HandlerFunc(Hello))
}

The problem is that r.URL.Query() is getting the raw query string and parses it each time it's called and it impacts the performances more than you would think of (especially if multiple middlewares will be calling r.URL.Query()). And pat is not a very fast router too (10x times slower at least than httprouter either for static routes than routes with params).

Pros: Compatible with http.Handle.

Cons: Kind of slow.

Which one to use?

I think gorilla/mux is the best router when doing traditional web apps with server-generated views because you often have complex routes. In addition, the performance hit of the router will be minor for apps generating HTML. For REST APIs however, a simple router like httprouter is more suitable. So this is the one we will use.

Integrate httprouter to our framework

The problem with httprouter is its non-compatibility with http.Handler. But we can make it compatible with a little glue code with our existing middlewares and contexts. To do this we wrap our middleware stack ā€“ implementing http.Handler ā€“ into a httprouter.Handler function.

func wrapHandler(h http.Handler) httprouter.Handle {
  return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    context.Set(r, "params", ps)
    h.ServeHTTP(w, r)
  }
}

func main() {
  db := sql.Open("postgres", "...")
  appC := appContext{db}
  commonHandlers := alice.New(context.ClearHandler, loggingHandler, recoverHandler)
  router := httprouter.New()
  router.GET("/admin", wrapHandler(commonHandlers.Append(appC.authHandler).ThenFunc(appC.adminHandler)))
  router.GET("/about", wrapHandler(commonHandlers.ThenFunc(aboutHandler)))
  router.GET("/", wrapHandler(commonHandlers.ThenFunc(indexHandler)))
  http.ListenAndServe(":8080", router)
}

There we have with just 6 lines of code a glue between our middlewares and httprouter. This way we keep the good performances of the router and the compatibility with http.Handler.

We could go the extra mile and wrap the router into a struct and redefine route methods (GET, POST, etc) to include wrapHandler into these methods. It would be nicer. I didn't put the example in the post but you can find the code in this gist.

We now have use URL params into our handlers. Let's make a new route.

router.GET("/teas/:id", wrapHandler(commonHandlers.ThenFunc(appC.teaHandler)))

Then we can make a teaHandler which will use the param id to fetch the right data.

func (c *appContext) teaHandler(w http.ResponseWriter, r *http.Request) {
  params := context.Get(r, "params").(httprouter.Params)
  tea := getTea(c.db, params.ByName("id"))
  json.NewEncoder(w).Encode(tea)
}

Wrapping Up

There's a lot of routers in Go. It can be a bit overwhelming. Performances vary greatly but features too. So we must understand that the fastest router may not be the most suitable for some projects. httprouter is great for simple routing like REST APIs vut gorilla/mux is more suitable for apps with server-generated views.

Some routers are not compatible with http.Handler but as we saw it can be wrapped into the standard interface pretty easily.

And finally routers have different ways to store URL params, so again relying on things we already have make the job easier. r.URL.Query() and contexts are the best ways to store URL params because we know it will be consistent across our eco-system of apps, services and packages.