A common Go opinion (idiom, warcry, take your pick) is “don’t use frameworks”. Certainly, when building a simple http service with one or two endpoints, it doesn’t feel like one is necessary.
When your requirements get more complicated, it may feel like you are doing a lot of hard work that a framework can help you with. That is an understandable opinion - the Go standard library in general is very powerful, but the documentation is more “reference” than “guide”.
So this post (and maybe more) is to demonstrate how you get some of the “quality of life” style changes, without leaning on a framework.
As an aside, I’ve used many frameworks before, in both Go and other languages. My opinion is that they work great until the day they do not. When your requirements extend past what the framework offers, you don’t just have to unpick your own code to cope with it, you need to understand the frameworks code too. This is no longer a time saving measure.
The code for all these examples can be found at https://github.com/tardisx/go-mux
By “path values” I mean URLs like:
/user/4567/view
The “4567” in this case is a user id.
Let’s look at the naive approach firstly. For the sake of simplifying all these examples, there is a separate package called “db” which I am using to load a user based on an id.
func main() {
http.HandleFunc("/user/{userId}/view", viewUser)
slog.Info("starting web service on :8080")
panic(http.ListenAndServe(":8080", nil))
}
func viewUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
userIDint, err := strconv.Atoi(userID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("bad user id"))
return
}
user, err := db.LoadUser(userIDint)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf("could not load user: %s", err.Error())))
return
}
slog.Info("loaded user")
// show the user id
w.Write([]byte(fmt.Sprintf("loaded user %s", user)))
}
In main
we setup a route to handle viewing users based on their ID. In the
route handler viewUser
we must first extract the value for the placeholder
{userId} for the incoming request.
It’s a string, so we have to turn it into an integer (including checking for and returning errors).
Then we load the user from the database (again, checking for errors and showing them to the requester if they occur).
Finally we can do something with the user we loaded (in this case just dumping it in the response).
This is a lot of boilerplate. The boilerplate outweighs the “interesting”
parts of the code, where we use the user
struct to show something to the user.
It’s perhaps not too bad for one single route, but there are no doubt going to
be many, like /user/4567/view
, /user/4567/details
, user/4567/edit
, maybe
with GET and POST variants. So lets do a simple refactor:
(showing only changed code)
func viewUser(w http.ResponseWriter, r *http.Request) {
user, err := loadUserFromRequest(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf("could not load user: %s", err.Error())))
return
}
slog.Info("loaded user")
// show the user id
w.Write([]byte(fmt.Sprintf("loaded user %s", user)))
}
func loadUserFromRequest(r *http.Request) (db.User, error) {
userID := r.PathValue("userId")
userIDint, err := strconv.Atoi(userID)
if err != nil {
return db.User{}, err
}
user, err := db.LoadUser(userIDint)
if err != nil {
return db.User{}, err
}
return user, nil
}
Nothing too earth shattering here - we have a helper function to load the user,
so we’ve somewhat reduced the boilerplate in the viewUser
handler, but it’s
still not ideal. We still need to check for errors in the viewUser
handler.
We can do better - with a middleware.
var userContextKey = "user"
func main() {
http.Handle("/user/{userId}/view", getUserFromPath(http.HandlerFunc(viewUser)))
slog.Info("starting web service on :8080")
panic(http.ListenAndServe(":8080", nil))
}
func viewUser(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userContextKey).(db.User)
slog.Info("loaded user")
// show the user id
w.Write([]byte(fmt.Sprintf("loaded user %s", user)))
}
func getUserFromPath(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
userIDint, err := strconv.Atoi(userID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("bad user id"))
return
}
user, err := db.LoadUser(userIDint)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf("could not load user: %s", err.Error())))
return
}
newContext := context.WithValue(r.Context(), userContextKey, user)
r = r.Clone(newContext)
next.ServeHTTP(w, r)
})
}
The first thing to note is that the route setup uses “Handle” instead of
“HandleFunc”, and we wrap our call to viewUser
with a http.HandlerFunc
to
preserve that signature.
However these are wrapped in a call to getUserFromPath
which acts as a
middleware, to extract the user id from the path, load the user into the request
context or return a HTTP error where something goes wrong.
Let’s break it down:
http.Handle("/user/{userId}/view", getUserFromPath(http.HandlerFunc(viewUser)))
/user/{userId}/view
comes ingetUserFromPath
to fetch the appropriate user from the databaseviewUser
with our loaded user in the contextThe code in the middleware is almost identical to what we saw before. If we don’t successfully load the user for some reason, we return an appropriate HTTP status code and some sort of textual error.
The last three lines are the key here:
newContext := context.WithValue(r.Context(), userContextKey, user)
r = r.Clone(newContext)
next.ServeHTTP(w, r)
First we create a new context, based on the existing context, using a known key
and the value is our newly loaded user struct. Note that r.Context()
gives you
the context for this single HTTP request.
Secondly, we have to clone the request using the new context. This is necessary so that we have an updated request struct to pass further down the chain, with our new context.
Lastly, we call next.ServeHTTP
to allow the ServeMux to call the next handler
in our chain (which we setup in the initial http.Handle("/user....
call).
It’s important to note the other exit paths in this function (that simply return
without calling next.ServeHTTP(w, r)
) will terminate the chain immediately, our viewUser
function will never get called at all.
With all that done, our actual viewUser
route has a single line of code to get
the user struct:
user := r.Context().Value(userContextKey).(db.User)
Note that using a type assertion like this here is safe, as there is no way our viewUser route could even be executed if the middleware failed to load the user into our context.
Other common uses for this same technique are:
Note that there is no requirement to call next.ServeHTTP
at the end of the
middleware function. For instance, in the case of the logging scenario, you might
do something like this:
t0 := time.Now()
next.ServeHTTP(w, r)
slog.Info(fmt.Sprintf("request took %.3fs", time.Since(t0).Seconds()))
This captures the time at the start of the request, calls the rest of the
http.Handler
chain, and eventually returns to this middleware to log the
total time taken.