Some Time to Iterate: Go Interfaces edition

One of the major benefits of taking time off work to play around with technology is the freedom to dig in deeply enough into something, making sure I really understand the principles, rather than just getting it working "well enough" and moving on. Being able to guiltlessly spend time experimenting and trying different approaches is refreshing — as nice as it might be to lean over and just ask someone for a sanity check, I'm really enjoying being able to leisurely return to the same solution, minutes or hours or days apart, to tweak and incrementally tighten up my code.

A related advantage of using side projects to learn is to gain the experience of putting something together from the ground up. While I worked with phenomenal teammates at Parse+Facebook, others often laid the groundwork — and working around a project with most of its bones in place is dramatically different than figuring out the skeleton (and the tradeoffs of early choices) on your own.

So! Onward, to a nicely simple illustration of the flexibility of Golang's interfaces.

The Setup

We have a Go server that is capable of doing a bunch of specialized work, but should by default just serve an index.html. We can assume that all requests that hit a particular subpath — /s/, for example — should be handled by the more complicated logic, and everything else should fall through to being served statically. If a file doesn't exist to be served statically, we should serve some visually appealing 404 instead (without changing the URL in one's browser).

Fortunately for us, Go's net/http module has a nice FileServer function that'll serve static files from the filesystem for me. The current (naive) server flow looks something like:

http.Handle("/s/", &SomeCustomHandler{})
http.Handle("/", http.FileServer(http.Dir("static/")))

This handles almost everything we want — requests to /s/ get handed off to our custom handler whereas everything else falls through to statically served files — except the default FileServer 404 behavior is to return a boring and aesthetically displeasing text "404 page not found" response.

The Solution

net/http's standard Handlers are themselves an interface: meaning, any struct that implements the required methods can be used wherever a Handler is expected. In this case, the interface is defined as:

type Handler interface {
  ServeHTTP(ResponseWriter, *Request)
}

Great. Pretty easy to define our own StaticHandler, and to define our own ServeHTTP function. But we can't stop there — we want to make sure that we actually identify a 404, intercept it, and return our custom error page instead. So in addition to implementing the http.Handler interface, we'll implement the http.ResponseWriter interface, which is also fairly straightforward:

type ResponseWriter interface {
  Header() Header
  Write([]byte) (int, error)
  WriteHeader(int)
}

Here, though, we want our custom response wrapper to be able to capture the values the standard file server retrieves, then allow us to access or modify them as needed. So we do the bare minimum necessary to expose that data:

type response struct {
  buffer bytes.Buffer // Satisfies io.Writer/io.Reader interfaces, to capture and forward the payload
  status int          // The HTTP Status Code written by the FileServer
  header http.Header  // We have to expose a normal http.Header for modification by a client
}
func (w *response) Write(p []byte) (int, error) {
  return w.buffer.Write(p) // Take the write and save it to our buffer
}
func (w *response) WriteHeader(status int) {
  w.status = status
}
func (w *response) Header() http.Header {
  return w.header
}

With this response struct in my pocket, my StaticHandler can now contain the sort of bait-and-switch-with-a-404 logic I've been hoping for:

type StaticHandler struct {
  fileServer http.Handler // Initialized via http.FileServer(http.Dir("static/"))x
}

func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { wrapped := &response{header: w.Header()}

// Ask FileServer to serve with our ResponseWriter shim so that we can // intercept a 404 status code h.fileServer.ServeHTTP(wrapped, r)

if wrapped.status == http.StatusNotFound { w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeFile(w, r, "static/404.html") return }

w.WriteHeader(wrapped.status) // Copy that status over io.Copy(w, &wrapped.buffer) // And copy that file payload }

Which gets me what I was hoping for: my custom 404 page rendered on Not Founds, without changing the path.

The Alternatives

A quick rundown of other ways this could've gone wrong we could've gotten this working:

  • Use one of the many existing Go web frameworks. Not strictly necessary for my use case at the moment and would've obscured my goal of better understanding net/http.
  • Instead of wrapping an http.ResponseWriter, manually check whether a given filepath exists or not in the filesystem before asking the file server to serve the file. Ugly and potentially annoying to handle common situations like "/" redirecting to "/index.html" — just let the file server do its job and determine whether it's a 404 or not.
  • Read the file from the filesystem on StaticHandler initialization and pass it as a []byte to the handler. Not bad but there's no real reason to cache the page like that: serving an HTML page isn't expensive enough to justify dirtying up the code.
  • Read the file from the filesystem and .Write() it yourself to the http.ResponseWriter. Why would you, when http.ServeFile() handles that correctly for you?
  • Rewrite r.URL.Path to point at "static/404.html" and call ServeHTTP again on the modified http.Request. "Explicitly overwrite" should be sending up all sorts of red flags, and it feels dirty to have to call fileServer.ServeHTTP twice.
  • Use a http.Redirect instead of http.ServeFile. Loses the URL fragment the user requested (and getting a 3xx status code doesn't seem quite right from the client's perspective either).

... though hopefully any of these approaches would've sent up some warning signs to any sort of code reviewer. In any case — play! Explore! See how many times you can revisit working code to make it feel as nice as possible.

Takeaway

There's never one absolute way to do things, but trying a handful of different methods out lets us circle until we find the behavior that feels the best. (It's also nice finding that this is the approach that bradfitz recommended, instead of yet another way to solve the problem — changing the behavior of http.FileServer itself.)

Let me know what you think on Twitter.