Routing as a bottleneck

In the first part of this series, we built a task server using only the Go standard library. The handlers themselves became compact once JSON rendering was factored into a helper. What remained awkward was path routing: the logic for matching URLs to handlers was scattered across the setup code, and extending it meant adding more conditional branches.

This is a common friction point for server code that avoids third-party dependencies. For a handful of routes, hand-rolled matching is fine. As the route table grows, though, the verbosity becomes a maintenance problem.

A working example from the previous part, with the route table for the task API, looked like this:

POST   /task/              :  create a task, returns ID
GET    /task/<taskid>      :  returns a single task by ID
GET    /task/              :  returns all tasks
DELETE /task/<taskid>      :  delete a task by ID
GET    /tag/<tagname>      :  returns list of tasks with this tag
GET    /due/<yy>/<mm>/<dd> :  returns list of tasks due by this date

What a router should do

For our task server, a more ergonomic router would ideally offer three capabilities:

  1. Assign different handlers to different HTTP methods on the same path, e.g. POST /task/ vs. GET /task/.
  2. Support deeper path matching, so /task/ and /task/<taskid> (where the ID is numeric) can be handled separately.
  3. Extract the ID from the path and hand it to the handler in a convenient way.

Writing a custom router in Go is straightforward, given how composable HTTP handlers are. For this post, though, we'll leave the custom implementation aside and look at a popular third-party package: gorilla/mux.

Task server with gorilla/mux

gorilla/mux is one of the older players in the Go routing space. The name "mux" stands for "HTTP request multiplexer", the same term used in the standard library. Its scope is narrow, and its API is correspondingly direct.

Here's how route definitions look with gorilla/mux for the same task server:

router := mux.NewRouter()
router.StrictSlash(true)
server := NewTaskServer()

router.HandleFunc("/task/", server.createTaskHandler).Methods("POST")
router.HandleFunc("/task/", server.getAllTasksHandler).Methods("GET")
router.HandleFunc("/task/", server.deleteAllTasksHandler).Methods("DELETE")
router.HandleFunc("/task/{id:[0-9]+}/", server.getTaskHandler).Methods("GET")
router.HandleFunc("/task/{id:[0-9]+}/", server.deleteTaskHandler).Methods("DELETE")
router.HandleFunc("/tag/{tag}/", server.tagHandler).Methods("GET")
router.HandleFunc("/due/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}/", server.dueHandler).Methods("GET")

These definitions address the first two items on the wishlist above directly. Appending a Methods call routes different HTTP methods on the same path to different handlers. The regexp-based pattern matching in the path distinguishes /task/ from /task/<taskid> right in the route configuration.

In the route /task/{id:[0-9]+}/, the pattern names the captured segment "id". Handlers retrieve it via mux.Vars. This is how the third wishlist item—variable extraction—is handled:

func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {
  log.Printf("handling get task at %s\n", req.URL.Path)

  // Here and elsewhere, not checking error of Atoi because the router only
  // matches the [0-9]+ regex.
  id, _ := strconv.Atoi(mux.Vars(req)["id"])
  ts.Lock()
  task, err := ts.store.GetTask(id)
  ts.Unlock()

  if err != nil {
    http.Error(w, err.Error(), http.StatusNotFound)
    return
  }

  renderJSON(w, task)
}

Comparing code paths

To see the practical difference, trace how a request for GET /task/<taskid> is processed in each version. In the original standard-library server, understanding the dispatch meant following this flow:

HTTP route handler path without custom router

With gorilla/mux, the equivalent path is shorter:

HTTP route handler path with gorilla/mux router

The router version has fewer moving parts to hold in your head. Route definitions are short and explicit, and the entire route table is visible at a glance in a single place—it looks much closer to the informal REST API description we started with.

A precision tool

One appealing property of gorilla/mux is that it does one thing well without leaking into the rest of the program. In the codebase for this post, the portions that depend on the package are confined to a small number of lines. If the project later hits a limitation of gorilla/mux, swapping in another router—or a hand-rolled one—should be a localized change rather than a rewrite.

[1]It's stored by gorilla/mux in the context of each request, and mux.Vars is a convenience function to fetch it from there.