Apr 26, 2019

Choosing the Right Go Web Framework

Admittedly, there's a lot of libraries and frameworks to build modern web services in Go, suitable for pretty much every situation you'll ever run into. But sometimes you might just want to use a minimal foundation to build upon, not the full-blown system with all the numerous extensions and features you probably won't ever use for your project. Looking at the ecosystem, I've reduced the number of libraries to compare down to four key players: gin, echo, chi and finally mux.


The Contenders

  • gin: As a baseline, I like to describe gin as the express of Go. Being one of the most popular Go web frameworks, it builds on top of a router & middleware-based approach, where you define request handlers attached to specific routes. Due to its strong focus on extensibility, you can build a fitting solution for almost every web application scenario. If you need a tool for all purposes, gin does a stunning job at this. Including helpful features like parameter paths, JSON-to-struct binding and more, it contains nearly everything you need for most use cases.
  • echo: Echo exposes a surprisingly similar structure compared to gin, providing a huge number of available middleware functions like JWT auth support, CORS handling, etc. One big difference is the routing logic: Whereas gin builds on top of the fasthttprouter library with all its benefits and drawbacks, echo handles its routing using a self-contained module, no surprises there.
  • chi: Discarding all bells and whistles from the libraries above, chi is one of the barebones solutions I highly recommend using for projects where you want to reap the benefits of great route-handling logic but still prefer to stick to the low-level APIs for everything else in the request lifecycle. As with the other libraries, middleware functions are a first-class citizen as well, so you're not forced to build that by yourself. I think of chi as a perfect blend of the mux library I'll describe in a second and solutions like gin or echo.
  • mux: If you only need a fast and simple routing utility that focuses on fine-grained path-matching, mux is a perfect choice! Built on the multiplexer (hence the name) design pattern which basically describes a linking layer between routing logic and actual request handlers, it really only handles selecting the appropriate request handler to run. In some cases, this simplicity can be a real benefit!

Building a sample RESTful application

To really spot the differences between the library implementations, we are going to create a simple RESTful web service with some basic endpoints. Because we are focusing on details about route-matching and request-handling, there won't be any real business logic included, but rather only request-response lifecycle functionality.

Starting with a simple GET endpoint

As a first task, I've decided to build a completely simple GET endpoint for details of a specific user, the name of which is supplied as a route parameter. The response will be a simple JSON object (struct below) that is typed the same way for every implementation.

// This is the response struct that will be
// serialized and sent back
type StatusResponse struct {
  Status string `json:"status"`
  User   string `json:"user"`
}

Gin

func UserGetHandler(c *gin.Context) {
  // Create response object
  body := &StatusResponse{
    Status: "Hello world from gin!",
    User:   c.Param("user"),
  }

  // Send it off to the client
  c.JSON(http.StatusOK, body)
}

func main() {
  // Create gin router
  router := gin.Default()

  // Set up GET endpoint
  // for route /users/<username>
  router.GET(
    "/users/:user",
    UserGetHandler,
  )

  // Launch Gin and
  // handle potential error
  err := router.Run(":8003")
  if err != nil {
    panic(err)
  }
}

Echo

// In addition to echo request handlers
// using a special context including
// all kinds of utilities, generated errors
// can be returned to handle them easily
func UserGetHandler(e echo.Context) error {
  // Create response object
  body := &StatusResponse{
    Status: "Hello world from echo!",
    User:   e.Param("user"),
  }

  // In this case we can return the
  // JSON function with our body
  // as errors thrown here will
  // be handled accordingly
  return e.JSON(http.StatusOK, body)
}

func main() {
  // Create echo instance
  e := echo.New()

  // Add endpoint route
  // for /users/<username>
  e.GET("/users/:user", UserGetHandler)

  // Start echo and handle errors
  e.Logger.Fatal(e.Start(":8002"))
}

chi

func UserGetHandler(
  w http.ResponseWriter,
  r *http.Request,
) {
  // Add Content-Type header
  // to indicate JSON response
  w.Header().Set(
    "Content-Type",
    "application/json",
  )

  // create status response
  body := StatusResponse{
    Status: "Hello world from chi!",
    User:   chi.URLParam(r, "user"),
  }

  serializedBody, _ := json.Marshal(body)
  _, _ = w.Write(serializedBody)
}

func main() {
  r := chi.NewRouter()

  r.Get("/users/{user}", UserGetHandler)

  log.Println("Listening on :8001")
  log.Fatal(http.ListenAndServe(":8001", r))
}

mux

func UserGetHandler(
  w http.ResponseWriter,
  r *http.Request,
) {
  vars := mux.Vars(r)

  w.Header().Set(
    "Content-Type",
    "application/json",
  )

  body := StatusResponse{
    Status: "Hello world from mux!",
    User:   vars["user"],
  }

  serializedBody, _ := json.Marshal(body)
  _, _ = w.Write(serializedBody)
}

func main() {
  r := mux.NewRouter()

  r.HandleFunc("/users/{user}", UserGetHandler)
    .Methods("GET")

  log.Println("Listening on :8004")
  log.Fatal(http.ListenAndServe(":8004", r))
}

The complete code for all examples above is available 🔗 here.

Looking at the code, you can already see the extent of features exposed by each library: Whereas echo and gin both use their own context structure and provide helpers for formatting responses, chi and mux utilize standard request handlers you would use for most bare-metal go web applications instead, meaning that you would have to either use other dependencies out there or build your own functionality around this, as I did in the example. Basic helper utilities like extracting route parameters are included in every library though. Let's get a bit more in-depth now, what if we want to process JSON request bodies?

Handling request bodies

The request body will be the same structure for every implementation and is added directly below for reference:

// This simple struct will be deserialized
// and processed in the request handler
type RequestBody struct {
  Name string `json:"name"`
}

Gin

func UserPostHandler(c *gin.Context) {
  // Create empty request body
  // struct used to bind actual body into
  requestBody := &RequestBody{}

  // Bind JSON content of request body to
  // struct created above
  err := c.BindJSON(requestBody)
  if err != nil {
    // Gin automatically returns an error
    // response when the BindJSON operation
    // fails, we simply have to stop this
    // function from continuing to execute
    return
  }

  // Create response object
  body := &StatusResponse{
    Status: "Hello world from echo!",
    User:   requestBody.Name,
  }

  // And send it off to the requesting client
  c.JSON(http.StatusOK, body)
}

func main() {
  router := gin.Default()

  router.POST("/users", UserPostHandler)

  err := router.Run(":8003")
  if err != nil {
    panic(err)
  }
}

Echo

func UserPostHandler(e echo.Context) error {
  // Similar to the gin implementation,
  // we start off by creating an
  // empty request body struct
  requestBody := &RequestBody{}

  // Bind body to the request body
  // struct and check for potential
  // errors
  err := e.Bind(requestBody)
  if err != nil {
    // If an error was created by the
    // Bind operation, we can utilize
    // echo's request handler structure
    // and simply return the error so
    // it gets handled accordingly
    return err
  }

  // Create status response
  body := &StatusResponse{
    Status: "Hello world from echo!",
    User:   requestBody.Name,
  }

  // Simply return error
  return e.JSON(http.StatusOK, body)
}

func main() {
  e := echo.New()

  e.POST("/users", UserPostHandler)

  e.Logger.Fatal(e.Start(":8002"))
}

chi

func UserPostHandler(w http.ResponseWriter, r *http.Request) {
  // Read complete request body
  rawRequestBody, err := ioutil.ReadAll(r.Body)
  if err != nil {
    w.WriteHeader(http.StatusBadRequest)
    return
  }

  // Transform into RequestBody struct
  requestBody := &RequestBody{}
  err = json.Unmarshal(rawRequestBody, requestBody)
  if err != nil {
    w.WriteHeader(http.StatusBadRequest)
    return
  }

  w.WriteHeader(http.StatusOK)
  w.Header().Set("Content-Type", "application/json")

  body := StatusResponse{
    Status: "Hello world from chi!",
    User:   requestBody.Name,
  }

  serializedBody, _ := json.Marshal(body)
  _, _ = w.Write(serializedBody)
}

func main() {
  r := chi.NewRouter()

  r.Post("/users", UserPostHandler)

  log.Println("Listening on :8001")
  log.Fatal(http.ListenAndServe(":8001", r))
}

mux

func UserPostHandler(w http.ResponseWriter, r *http.Request) {
  // Read complete request body
  rawRequestBody, err := ioutil.ReadAll(r.Body)
  if err != nil {
    w.WriteHeader(http.StatusBadRequest)
    return
  }

  // Transform into RequestBody struct
  requestBody := &RequestBody{}
  err = json.Unmarshal(rawRequestBody, requestBody)
  if err != nil {
    w.WriteHeader(http.StatusBadRequest)
    return
  }

  w.WriteHeader(http.StatusOK)
  w.Header().Set("Content-Type", "application/json")

  body := StatusResponse{
    Status: "Hello world from mux!",
    User:   requestBody.Name,
  }

  serializedBody, _ := json.Marshal(body)
  _, _ = w.Write(serializedBody)
}

func main() {
  r := mux.NewRouter()

  r.HandleFunc("/users", UserPostHandler).Methods("POST")

  log.Println("Listening on :8004")
  log.Fatal(http.ListenAndServe(":8004", r))
}

The complete code for all examples above is available 🔗 here

And once again we can see the differences between rather minimalist libraries and fully-featured ones. Especially for binding request body data to structs, it is a huge overhead to manually read the complete input and unmarshal it in comparison to the one-liner both gin and echo provide.

I could go on and on, providing examples for more sophisticated use cases, but I think by now you might have noticed a recurring theme in each implementation: Using full-fledged libraries like gin and echo, you gain plentiful benefits to write functionality in fewer lines of code, and in return you sacrifice some flexibility and in some cases performance. Choosing a very lightweight routing helper library like chi or mux can help you build really fast web applications, but you have to spend additional time on implementing or searching up the missing functionality for parsing request bodies, handling responses, etc.

Ultimately it's up to you and your project's needs though, so maybe it helps to decide by looking at the facts and figures. For the record, each and every library I've chosen for this post is hugely popular and actively maintained by a big pool of contributors, so you're not picking a tool that's already in its end-of-life stage.

I'm interested in your pick! If you've got any questions, suggestions or other feedback, don't hesitate to send a mail.