REST Servers in Go: Part 3 - using a web framework



This is the third post in a series about writing REST servers in Go. Here is a list of posts in the series:

In this part we're going to re-implement our REST server using one of the most popular web frameworks in Go - Gin. The full code for this part is available here.

Selecting a web framework

Go has several popular web frameworks these days, and I'm sure they all have their merits. My goal is not to engage in a lengthy comparison and discussion of these frameworks; rather, I'm interested in examining how code using a framework compares to code that doesn't.

I picked Gin because it's one of the most popular projects (judging by GitHub star counts), and it seems minimal and easy to pick up and use. Gin's documentation leaves much to be desired, but the framework is so intuitive that I found it pretty easy to pick up regardless [1].

What's nice about Gin is that it doesn't force any particular style of application development on you (e.g. MVC). Using Gin almost feels like writing code without a framework, except that you get a lot of tools and goodies to achieve your goals with less code.

Routing with Gin

Our main function sets up a new Gin router and registers the routes:

router := gin.Default()
server := NewTaskServer()

router.POST("/task/", server.createTaskHandler)
router.GET("/task/", server.getAllTasksHandler)
router.DELETE("/task/", server.deleteAllTasksHandler)
router.GET("/task/:id", server.getTaskHandler)
router.DELETE("/task/:id", server.deleteTaskHandler)
router.GET("/tag/:tag", server.tagHandler)
router.GET("/due/:year/:month/:day", server.dueHandler)

The call to gin.Default() returns a default engine, which is Gin's main type that acts as a router and provides other functionality. Specifically, Default only registers the basic middleware for crash recovery and logging. More on middleware later.

The route registration should look familiar by now. It's slightly similar to the gorilla version, with some slight differences:

  1. Instead of selecting the HTTP method as an additional (Go) method call on the route, it's encoded in the name of the registration. E.g. router.POST instead of router.HandleFunc(...).Methods("POST").
  2. While Gorilla supports regexp matching in the routes, Gin doesn't. This is a limitation that we'll come back to later.

Handlers

Let's take a look at some handlers with Gin. Starting from the simplest ones, here's getAllTasksHandler:

func (ts *taskServer) getAllTasksHandler(c *gin.Context) {
  allTasks := ts.store.GetAllTasks()
  c.JSON(http.StatusOK, allTasks)
}

There are a few interesting things to note here:

  1. Handlers with Gin don't have the standard Go HTTP handler signature; instead, they simply take a gin.Context, which can be used to analyze the request and construct the response. Gin does have ways to interact with standard handlers, by means of the gin.WrapF and gin.WrapF helper functions.
  2. In contrast with the earlier versions of our server, there's no need to manually log each request because Gin's default logging middleware already does this (with all kinds of cool doodads like terminal colors and reporting the processing time of each request).
  3. We also don't have to implement the renderJSON helper any more, since Gin has its own Context.JSON to render JSON as the response.

Now let's examine a slightly more sophisticated handler that has parameters:

func (ts *taskServer) getTaskHandler(c *gin.Context) {
  id, err := strconv.Atoi(c.Params.ByName("id"))
  if err != nil {
    c.String(http.StatusBadRequest, err.Error())
    return
  }

  task, err := ts.store.GetTask(id)
  if err != nil {
    c.String(http.StatusNotFound, err.Error())
    return
  }

  c.JSON(http.StatusOK, task)
}

The interesting part to note here is the parameter handling. Gin provides access to route parameters (the parts of the route starting with a colon, like :id) through Context.Params.

Unlike Gorilla, however, Gin doesn't support regexps in its routes (I believe this is due to performance concerns, since Gin prides itself on fast routing). Therefore, we have to handle the integer parsing of the id parameter.

Bindings

The final handler we're going to examine in detail is createTaskHandler; it handles a request that carries non-trivial data, so it's an interesting case study:

func (ts *taskServer) createTaskHandler(c *gin.Context) {
  type RequestTask struct {
    Text string    `json:"text"`
    Tags []string  `json:"tags"`
    Due  time.Time `json:"due"`
  }

  var rt RequestTask
  if err := c.ShouldBindJSON(&rt); err != nil {
    c.String(http.StatusBadRequest, err.Error())
  }

  id := ts.store.CreateTask(rt.Text, rt.Tags, rt.Due)
  c.JSON(http.StatusOK, gin.H{"Id": id})
}

Gin has significant infrastructure for binding requests to Go data. Binding in this context means parsing the contents of requests (which could be in JSON, YAML or other formats), validating them and assigning their values to Go structs. Here we use a very rudimentary form of binding for our RequestTask without any validation, but it's worth checking out the more advanced options Gin offers.

You'll notice that the Gin version of createTaskHandler is quite a bit shorter than our earlier versions because of the work ShouldBindJSON is doing for parsing JSON from the request.

An additional thing to note is that we don't need a one-shot struct for the response ID now. Instead we use gin.H, which is just an alias for map[string]interface{}; so simple, and yet very effective to construct responses with minimal typing and syntax.

Additional features of Gin

In this example we've only examined a small sliver of what Gin offers to web application developers. Gin comes with many additional features pre-packaged, like commonly used middleware, authentication and helpers for rendering HTML templates. Neither of these are hard to implement without a framework, but using Gin will certainly make it quicker and with far less code, at least for the simple cases.

Right now the focus is only on the basics of routing requests and parsing/responding with JSON, but I'll return to some of these topics in future parts of this series.

Limitations

The flip side of the convenience of web frameworks is the limitations and stylistic mismatches one may experience when using them. We already ran into one limitation in our simple example - lack of regexp support in Gin routes, which means that nontrivial route matching requires more code to parse and validate.

Any package and tool may have limitations, but frameworks make limitations more significant by their very pervasiveness.

Imagine we'd discover a limitation in Gorilla's mux which would be a blocker for our app. We could then replace it by another router package! While there would undoubtedly be some cost to the transition, its effects would be localized - only the routing configuration is affected.

In contrast, imagine we have a large web app written with Gin and we suddenly discover that the no-regexp limitation is critical [2]. We can't just easily replace Gin by another framework since our whole application is built upon it. The change cost is much higher.

None of this is fatal; my goal is not to persuade or dissuade folks from using web frameworks. I'm trying to present the objective reality and describe some of the real-life challenges programmers run into when using these packages and frameworks.


[1]A slightly philosophical detour: well designed and minimal frameworks have the privilege of being easy to use even without top-notch docs. Contrast this to the more complex frameworks (looking at you, BeeGo), which also have sub-par documentation and are convoluted and over-abstracted at the same time. Such frameworks really raise the barrier of entry by not investing sufficiently in great documentation.
[2]This is fairly unlikely, but serves as a good example regardless.

Recent posts

2021.01.21: REST Servers in Go: Part 2 - using a router package
2021.01.14: REST Servers in Go: Part 1 - standard library
2020.12.31: Summary of reading: October - December 2020
2020.12.18: Computing the Chinese Remainder Theorem
2020.12.05: Unmarshaling Time values from JSON
2020.09.30: Summary of reading: July - September 2020
2020.09.25: GitHub Actions: first impressions
2020.09.12: Writing multi-package analysis tools for Go
2020.08.19: Embedding in Go: Part 3 - interfaces in structs
2020.08.17: Embedding in Go: Part 2 - interfaces in interfaces

See Archives for a full list.