Go's net/http package vs gin

Filed under golang on March 24, 2020

UPDATE Feb 20, 2021

I checked my Google search console and realised that most of my traffic comes in through here. Here’s the summary for you:

  • Golang’s net/http and Gin follow much the same philosophy
  • Gin has wrappers around route registration, which means less boilerplate
  • Gin offers a few more convenience wrappers around deserialisation that’ll mean you don’t have to worry about the boilerplate around the json.Unmarshal function
  • Gin uses httprouter, which is apparently faster
  • Middleware works in much the same way as vanilla go

Personally, I still prefer Goa or using OpenAPI to generate my plumbing for me, but if you’re deadset on using a code first approach, Gin is a decent enough choice. Just be aware of the dependencies you’re pulling in.

Original article

If you want to read more of my waffling down below, go ahead.

In previous roles, I’ve used languages like Java, Python and C# where it’s customary to use an all in one framework to write your services. Probably the one I’m most familiar with is Java’s Spring, which does allow easy set up of web services, but there’s a huge amount of magic under the hood and it can be difficult to decipher the gigantic stack traces it generates.

Golang, being purpose built for modern applications, has a built in net/http package which seeks to encourage sleek, fast applications with as little bloat as possible. In spite of this, there are frameworks that exist in golang to try and provide further abstraction but I’ve increasingly been growing curious about what some of these provide that makes them worth using.

I’ve had experience generating code straight from an OpenAPI specification using go-swagger, but as anyone that’s tried to write one can tell you Swagger YAML is an absolute pain to write. As a result, I use Goa pretty much entirely for its specification DSL, which is easy to read and easy to write. When working in a team where you can potentially have two or three people working on a service at a time, I find that spec first development limits confusion and accelerates communication, so I’m happy to throw my lot in with it.

On the other end, we have code first frameworks like gin, buffalo, and gorilla. I’ve used gin a little bit here and there, and am currently maintaining some services at work written using it, and it works pretty well for the most part, but the community consensus seems to lie squarely on using the net/http. Let’s have a look at how they both work

Simple Example

HTTP using the net/http package is dead easy. We set up a mux, then tell it where to route various paths.

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/ping", func(writer http.ResponseWriter, request *http.Request) {
		switch request.Method {
		case "GET":
			writer.WriteHeader(http.StatusOK)
			fmt.Fprint(writer, "pong")
		default:
			http.NotFound(writer, request)
		}
	})
	s := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	log.Fatal(s.ListenAndServe())
}

There’s a little bit of boilerplate there, but if you’re using a generator like I do to wire it up, it’s not too burdensome.

Let’s have a look at Gin next

package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	s := gin.New()
	s.GET("/ping", func(c *gin.Context) {
		c.String(http.StatusOK, "pong")
	})
	log.Fatal(http.ListenAndServe(":8080", s))
}

Admittedly a bit cleaner. We don’t need a switch statement to check the method of the request, and the gin.Context object abstracts away from the request/response quite nicely.

My issue here is how much this abstraction brings in

gin depedencies

I guess if you’re prototyping and playing around it takes a lot of the forethought out of having to design your API before writing any code. This could also be handy if you have a service with a large number of endpoints.

Path Parameters

Query parameters are much the same in gin and net/http, so we’ll instead have a look at path parameters

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/ping", func(writer http.ResponseWriter, request *http.Request) {
		switch request.Method {
		case "GET":
			writer.WriteHeader(http.StatusOK)
			fmt.Fprint(writer, "pong")
		default:
			http.NotFound(writer, request)
		}
	})
	mux.HandleFunc("/market/", func(writer http.ResponseWriter, request *http.Request) {
		itemId := strings.TrimPrefix(request.URL.Path, "/market/")
		switch request.Method {
		case "GET":
			writer.WriteHeader(http.StatusOK)
			fmt.Fprintf(writer, "You asked to get item %s", itemId)
		case "PUT":
			writer.WriteHeader(http.StatusAccepted)
			fmt.Fprintf(writer, "You edited item %s", itemId)
		default:
			http.NotFound(writer, request)
		}
	})
	s := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	log.Fatal(s.ListenAndServe())
}

We have to handle this ourselves using the built in http package, and it could get messy with some more complicated paths, but if you’re looking to keep it simple again it’s not going to be too bad. Again, this is something smoothed over nicely by the likes of go-swagger, so it’s not something I’ve had to deal with in a while.

Gin makes this pretty easy, too, introducing tokens with : in paths to make this work

package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	s := gin.New()
	s.GET("/ping", func(c *gin.Context) {
		c.String(http.StatusOK, "pong")
	})
	s.GET("/market/:id", func(c *gin.Context) {
		c.String(http.StatusOK, "You asked to get item %s", c.Param("id"))
	})
	s.PUT("/market/:id", func(c *gin.Context) {
		c.String(http.StatusAccepted, "You asked to edit item %s", c.Param("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", s))
}

Nice bit of sugar there. In the past, problems emerged if you tried do something like this

package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	s := gin.New()
	s.GET("/market/:id", func(c *gin.Context) {
		c.String(http.StatusOK, "You asked to get item %s", c.Param("id"))
	})
	s.GET("/market/:id/:subid", func(c *gin.Context) {
		c.String(http.StatusAccepted, "You asked to get the sub-id %s of %s", c.Param("subid"), c.Param("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", s))
}

Looks like it’s been fixed now, which is certainly a nice surprise since I last used it.

Middleware

Middleware is a vital component in weaving in functionality which isn’t really a part of handler functions, such as authentication and identity or access logging.

Because of the way golang handles HTTP requests natively, all you need to do is intercept the request with a wrapper function and either keep chaining it or short circuit and return early if you need to

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"
)

func superSecretHeader(handler http.HandlerFunc) http.HandlerFunc {
	return func(writer http.ResponseWriter, request *http.Request) {
		h := request.Header.Get("x-secret-header")
		if h != "please" {
			http.Error(writer, "Nuh uh uh, you didn't say the magic word", http.StatusForbidden)
		} else {
			handler(writer, request.WithContext(context.WithValue(request.Context(), "User", "Dave")))
		}
	}
}

func logRequest(handler http.HandlerFunc) http.HandlerFunc {
	return func(writer http.ResponseWriter, request *http.Request) {
		log.Printf("%s: %s to endpoint %s", time.Now().Format(time.RFC3339), request.Method, request.URL.Path)
		handler(writer, request)
	}
}

func pingHandler(writer http.ResponseWriter, request *http.Request) {
	switch request.Method {
	case "GET":
		user := request.Context().Value("User")
		writer.WriteHeader(http.StatusOK)
		fmt.Fprintf(writer, "Hi %s. pong", user)
	default:
		http.NotFound(writer, request)
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/ping", logRequest(superSecretHeader(pingHandler)))
	s := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	log.Fatal(s.ListenAndServe())
}

Gin helpfully uses our context object as the request and response object, saving us some boilerplate

package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	s := gin.New()
	s.Use(superSecretHeader).GET("/ping", func(c *gin.Context) {
		user, _ := c.Get("User")
		c.String(http.StatusOK, "Hi %s. pong", user)
	})
	log.Fatal(http.ListenAndServe(":8080", s))
}

func superSecretHeader(context *gin.Context) {
	h := context.GetHeader("x-secret-header")
	if h == "please" {
		context.Set("User", "Dave")
	} else if h == "imnotasking" {
		context.Set("User", "Han")
	} else {
		context.String(http.StatusForbidden, "User not recognised")
		context.Abort()
	}
}

Conclusion

I think in the course of writing this I realised that gin isn’t really for me at all any more. I like to front load my thinking around APIs and testing where a lot of developers will tend to just want to start writing code. Were I to write a service where I didn’t want to pull in many depedencies I’d use go-swagger, while normally I’d use Goa.

I can see the appeal of gin, though, but I think in 2020 we as engineering professionals should be moving toward generative tools to do our HTTP route wiring. It’s too easy to fat finger hardcoded strings, and while there’s benefit in rapid prototyping we should be aiming to communicate clearly with our team members above everything else.

However, if you want to generate your gin wiring, I did find gin-swagger which will generate a gin service from an OpenAPI file. I’m thinking maybe that’s the way to start winning people around to my way of thinking, it is slow going though, habits are hard to break, especially if your current way of working is good enough.


Stephen Gream

Written by Stephen Gream who lives and works in Melbourne, Australia. You should follow him on Minds