DEV Community

UponTheSky
UponTheSky

Posted on

[Go, Opinion] How can we write truly "middle"ware with Go's net/http?

TL; DR

  • A common middleware pattern with the net/http package cannot handle outgoing responses
  • A delicate approach to manipulating Handler is necessary, but it is still fairly limited

So, we all know how to write middleware with Go's net/http?

After learning and playing around with the net/http package for a while (of course, with some help from books and other resources), I was pretty satisfied with how I came up with a way to write middleware in Go. It was elegant and very straightforward, as follows:

func AddSomeMiddleware(
    handler http.Handler,
    someFunc func(r *http.Request),
) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // do something right before the handler handles the request
        someFunc(r)

        // the handler that this middleware wraps around
        handler.ServeHTTP(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

The function above takes a handler and a function to be invoked before the handler, and returns a new handler by simply composing them with http.HandlerFunc.

If you search for how to write middleware in Go, I would say about 7 or 8 out of 10 results will show exactly the same pattern as above. I thought this pattern serves exactly what we need from middleware when writing server applications.

Middleware of net/http is only half middleware

However, I ran into a problem this simple pattern cannot handle. Did you notice? Yes, this middleware cannot touch the response part! Take a look at the code again:

func AddSomeMiddleware(
    handler http.Handler,
    someFunc func(r *http.Request),
) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        someFunc(r)
        handler.ServeHTTP(w, r)

        // what about afterwards?
        // we want something like postProcess(w)
    })
}
Enter fullscreen mode Exit fullscreen mode

Of course AddSomeMiddlware can still mess with http.ResponseWriter even after handler.ServeHTTP(w, r) is invoked. But what if handler has already written a body that you want to postprocess?

The problem gets trickier when it comes to writing headers, as once ResponseWriter.WriteHeader has been called:

Changing the header map after a call to ResponseWriter.WriteHeader has no effect unless the HTTP status code was of the 1xx class or the modified headers are trailers.

Since ResponseWriter.Write calls ResponseWriter.WriteHeader if it has not been explicitly invoked beforehand, you cannot add, remove, or modify the headers (try yourself!). In short, you lose control over the response part once you hand over http.ResponseWriter to the handler. net/http expects http.Handler to finish handling an entire HTTP request. This extraordinary feature of the net/http package makes our middleware as half middleware.

Wrapping another http.Handler is not a solution

We might trust an http.Handler not to write the response, and wrap it with middleware:

func AddSomeMiddleware(
    handler http.Handler,
    someFunc func(r *http.Request),
    postProcess func(w http.ResponseWriter),
) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        someFunc(r)
        handler.ServeHTTP(w, r)

        // this postProcess knows nothing about the handler above
        postProcess(w)
    })
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this doesn't resolve the problem at all, since postProcess knows nothing about what's happening inside handler.ServeHTTP. What are the headers? What is the status code? Without that information, we cannot send back a response to the client. Also, what about early return? If there is an error inside handler.ServeHTTP, writing a response afterward is meaningless.

Moreover, there are several useful functions that help write an HTTP response directly to http.ResponseWriter. For example, http.Error is very useful to write a succinct error response, and the encode/json package has Encoder.Encode that enables writing an application/json response in a single line.

if err := json.NewEncoder(w).Encode(jsonBody); err != nil {
    log.Println(err)
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
}
Enter fullscreen mode Exit fullscreen mode

Therefore, it is very tempting to write the response inside handler.ServeHTTP.

How about writing custom "handlers"?

One way of resolving this issue would be to write our own handlers that don't expose themselves to the raw *http.Request and http.ResponseWriter. A mock example could be as follows:

// Controller handles low-level ResponseWriter and Request
func SomeController(w http.ResponseWriter, r *http.Request) {
    // parse the raw request
    request := parseRequest(r)

    // middleware for the incoming request
    request = requestMiddleware(request)

    response, err := SomeService.Handle(request)

    if err != nil {
        // early return
        log.Println(err)
        http.Error(w, "some error message", someErrorCode)
        return
    }

    // middleware for the outgoing response
    response = responseMiddlware(response)

    // write the response finally
    writeResponse(w, response)
}

// custom request
type Request struct {
    // ...
}

// custom response
type Response struct {
    // ...
}

// custom handler
type Handler interface {
    Handle(r Request) Response
}
Enter fullscreen mode Exit fullscreen mode

This may comes at a cost. It could be overwhelming to redesign the Request and Response if we want to do it properly. However, once we know what we're doing, it doesn't have to be difficult. You might not use a brand new Request at all but instead use the plain *http.Request. Furthermore, the response type could be this simple:

type Response struct {
    headers map[string]string
    cookies []*http.Cookie
    body    any

    // additional necessary fields
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Then what about middleware? One strategy is to divide middleware into two categories:

  1. The first category of middleware directly interact with *http.Request, such as http.TimeoutHandler, or the middleware pattern we introduced early in this article. This category provides low-level functionality, such as logging the basic information about the request.

  2. The second category is about the service level of the application, such as requestMiddleware or responseMiddleware functions inside SomeController in the example code. As long as the service logic functions maintain the functional form of func Handler(Request) (Response, error), then we can easily chain those functions where wrapping functions work as middleware.

func WrappingServiceHandler(r Request) (Response, error) {
    r = beforeMiddleware(r)

    response, err := WrappedServiceHandler(r)

    if err != nil {
        // early return
        return nil, err
    }

    response = afterMiddleware(response)

    return response, nil
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Unless we write our own web framework in Go, this half middleware pattern is not easy to overcome. In this article, we suggested a pattern with an abstraction cost. Of course, it doesn't resolve all our problems and might incur several edge case problems.

If we know what we are doing, maybe it is more sensible to repeat the post processing logic right before writing the response inside each of handler.ServeHTTP. Yes, as usual, it depends...

Top comments (1)

Collapse
 
gkoos profile image
Gabor Koos

Hi,
Great article, thanks for writing this! It really clicked with me because I’m planning to rewrite my chaos-proxy project in Go for better performance and I ran into the same problem: without copying the response left and right, there is no way to tamper with the response with net/http.

I’ve been looking at Fiber since it makes the middleware pattern feel more natural. For example:

func MyMiddleware() fiber.Handler {
    return func(c *fiber.Ctx) error {
        // pre-processing
        fmt.Println("before")

        // let the next handler run
        if err := c.Next(); err != nil {
            return err
        }

        // post-processing
        fmt.Println("after")
        return nil
    }
}
Enter fullscreen mode Exit fullscreen mode

A cleaner way to hook into both sides of the lifecycle.