Introducing Mux
Note: This post is about code.soquee.net/mux
, an HTTP multiplexer for
Go.
This post applies to mux
version v0.0.4.
Because this version is pre-1.0, things may change by the time you read this.
The most up-to-date version of the mux
documentation can always be found at
pkg.go.dev/code.soquee.net/mux
.
There are a vast number of Go HTTP multiplexers on the market already, but every time I try a new one I find that it has one or more of the following problems:
- The package is no longer maintained
- The API isn’t idiomatic Go, or is very large and contains methods only tangentially related to routing, making it hard to learn to use the multiplexer
- The pattern syntax, or route registration allow ambiguous routes
- Handling route parameters other than strings requires lots of extra boilerplate when writing handlers or middleware
- The multiplexer requires a custom HTTP handler type instead of using the Go standard library types
- Routes can be added or mutated after the router is created meaning one of your coworkers will decide that it’s a good idea to do so and everything will break
- No support for route normalization, and no easy way to manually normalize routes in a safe way (eg. it’s hard to figure out what path component matches a specific route parameter, change the path component, then redirect to the new path)
To finally have a multiplexer that meets my requirements, I wrote the
code.soquee.net/mux
package.
Though I wrote and open sourced it quite a while ago, I never really promoted it
or tried to explain to anyone what it could do or why they might want to use it
over the many alternatives, so here it goes.
Basic Usage
The mux
package works in a broadly similar way to other Go HTTP multiplexers,
except that it makes a trade off that involves having an extra level of
indentation when creating the router to prevent routes from being added after
the router is created.
Creating a new router looks something like this:
serveMux := mux.New(
mux.HandleFunc(http.MethodGet, "/profile", http.RedirectHandler("/profile/me", http.StatusPermanentRedirect)),
mux.Handle(http.MethodGet, "/profile/me", profileHandler()),
mux.Handle(http.MethodPost, "/logout", logoutHandler()),
)
Use of the functional options pattern lets your users know that they shouldn’t mutate the multiplexer after it has been returned and ensures that our handlers and patterns are grouped into an easy to read list.
Route parameters
The multiplexer also has a simple pattern language for matching “route parameters” which are typed, and optionally named, variables that can appear as any route component.
mux.Handle(http.MethodGet, "/users/{username string}", userHandler()),
In this example there is a single route parameter with the name “username” and the type “string” which matches any single path component such as “/users/geoff” or “/users/123”. Other valid types are more or less restrictive and match multiple components, or only match if the path component can be parsed into a Go type. As of this writing, the full list of types is:
int
eg. -1, 1 (int64 in Go)uint
eg. 0, 1 (uint64 in Go)float
eg. 1, 1.123, NaN, +Inf, -Inf (float64 in Go)string
eg. anything ({string} is the same as {})path
eg. files/123.png (must be the last component in the route)
Currently the path
type must be the last comonent in the route and matches the
remainder of the route (regardless of how long it is).
This restriction may be lifted in the future.
There is also a special type: static
.
A static typed parameter must be named and does not store any value.
In fact, /foo
is just a shorthand for /{foo static}
.
You should never have to know this or use the latter syntax, it’s just an
interesting implementation detail.
When a route is matched, the value of each named path parameter is stored on the
request context.
To retrieve the value of named path parameters from within a handler, the
Param
function can be used.
pinfo := mux.Param(req, "username")
fmt.Println("Got username:", pinfo.Raw)
This returns a ParamInfo
struct containing the parsed (typed) and raw
(string) value of the parameter, the name of the component that was matched, the
type type of the route parameter (eg. “int” or “uint”), and the offset of the
matched component in the path.
Safety
Consider the following routes:
mux.New(
mux.Handle(http.MethodGet, "/users/{username string}", userHandler()),
mux.Handle(http.MethodGet, "/users/new", newUserHandler()),
)
If you try to build the code in this example and then run the application, you’ll see that it panics on startup:
panic: conflicting type found, {new static} in route “users/new” conflicts with existing registration of {username string}
This is because the static route, /profile/new
conflicts with the variable route parameter “username”.
What happens in this system if a user registers or already has the username
“new”?
We can’t know which route should be selected, and we might end up with a user
that cannot access their own profile, or the inability for new users to register
because the registration page redirects them to an existing users profile!
Instead of risking it, mux
disallows ambiguous routes using two rules:
- If a variable route parameter exists in the same position in two otherwise
identical routes, they must have the same name and type (ie. no
/{string}
and/{int}
or/{a string}
) - Variable and static route components may not appear in the same position in
otherwise identical routes (ie. no
/{string}
and/foo
or/{foo static}
)
Put another way, no two routes from the following may be registered together:
/user/{a int}/new
/user/{b int}/edit
/user/{float}/edit
/user/{b string}/edit
/user/me
When I first used a multiplexer that had similar rules I thought they were
cumbersome.
However, after a while I noticed that my APIs and websites that used these rules
often had much more logical and consistent routes, and no ambiguous routes
slipping through code review.
In the end, I decided to keep the rules for mux
strict to keep myself honest
and make products that use it better.
Normalization
Let’s imagine that we are building a web app that uses a GitHub like URL including a username and a repo. We register a route like the following:
mux.Handle(http.MethodGet, "/{user string}/{repo string}", repoHandler())
GitHub, unfortunately, allows any casing in the username meaning that there are
multiple resources that serve the same information, /soquee
and /Soquee
are
the same thing in GitHub’s mind.
We want to do better, so we use a case insensitive lookup in the database to find the canonical casing and then redirect to the correct place. To keep the examples simple, we’ll assume the canonical casing is always lowercase, instead of looking up the correct case in the database. Repos on the other hand are always case sensitive.
How do we handle this redirect? In most libraries, we’d either have to hard code the exact path component where the username is in our handler so that we can replace that component in the path and issue a redirect, or we’d have to just search and replace over the entire path for the username we want to replace. Both of these are potentially buggy. The first because we’ve introduced tight coupling in the code and if we make changes we might break our hard coded path. Alternatively, we’d need lots of boilerplate to generate the route and return the route and the path, which means we can’t see the route as a string literal in our list of routes. The second is buggy because the repo name might be the same as the username except for the case and we wouldn’t know which name to rewrite to the canonical case.
In other routers I’ve used, we can get the original route, iterate through its components, parse any path parameters and check if the names match, then find that same component in the path and replace it. This is a lot of work.
The mux
package handles this for you using the WithParam
function.
This returns a new copy of the request with the given route parameters replaced
on the context.
Because a new request (with a new context) is returned, nothing needs to be
mutated and any middleware already using the old request keeps its same view of
what the route looks like.
Anything using the new request can then call Path
to construct the updated
path.
The following handler normalizes just the username component of the path, resulting in a redirect to the canonical (in this case username case mapped) username:
mux.HandleFunc("GET", "/{username string}/{repo string}", func(w http.ResponseWriter, r *http.Request) {
username := mux.Param(r, "username")
normalized, err := precis.UsernameCaseMapped.String(username.Raw)
if err != nil {
…
}
// If the username was not canonical, redirect to the canonical username.
if normalized != username.Raw {
r = mux.WithParam(r, username.Name, normalized)
newPath, err := mux.Path(r)
if err != nil {
…
}
http.Redirect(w, r, newPath, http.StatusPermanentRedirect)
return
}
// Show the users profile.
fmt.Fprintf(w, "Profile for the user %q", username.Raw)
})
Conclusion
You may notice that I didn’t cover speed in this article.
That’s because I have not benchmarked mux
in any meaningful way
(microbenchmarks that ensure performance of individual parts of the code don’t
tell the whole story and aren’t worth mentioning here).
While I believe that it should compete with the fastest Go muxers out there, and
have tried to take care to avoid expensive operations when looking up routes,
speed just isn’t one of the priorities of mux
.
In general, even in the hot path of an HTTP API, mux should be fast enough for
everyone’s needs, and if not, I accept patches!
I hope this has given you a good sense of what’s possible with mux
.
To recap, the main features are:
- Typed route parameters
- Easy path component normalization
- Unambiguous routes
Soquee is an issue tracker written in Go and a collection of open source
(BSD-2-Clause) Go modules written to support it.
Open source code written for Soquee (and eventually the issue tracker itself)
can be found at code.soquee.net
.