JSON Encoding
Let’s move on to something a bit more exciting and look at how to encode native Go objects (like maps, structs and slices) to JSON.
At a high-level, Go’s encoding/json
package provides two options for encoding things to JSON. You can either call the json.Marshal()
function, or you can declare and use a json.Encoder
type.
We’ll explain how both approaches work in this chapter, but — for the purpose of sending JSON in a HTTP response — using json.Marshal()
is generally the better choice. So let’s start with that.
The way that json.Marshal()
works is conceptually quite simple — you pass a native Go object to it as a parameter, and it returns a JSON representation of that object in a []byte
slice. The function signature looks like this:
Let’s jump in and update our healthcheckHandler
so that it uses json.Marshal()
to generate a JSON response directly from a Go map — instead of using a fixed-format string like we were before. Like so:
If you restart the API and visit localhost:4000/v1/healthcheck
in your browser, you should now get a response similar to this:
That’s looking good — we can see that the map has automatically been encoded to a JSON object for us, with the key/value pairs in the map appearing as alphabetically sorted key/value pairs in the JSON object.
Creating a writeJSON helper method
As our API grows we’re going to be sending a lot of JSON responses, so it makes sense to move some of this logic into a reusable writeJSON()
helper method.
As well as creating and sending the JSON, we want to design this helper so that we can include arbitrary headers in successful responses later, such as a Location
header after creating a new movie in our system.
If you’re coding-along, open the cmd/api/helpers.go
file again and create the following writeJSON()
method:
Now that the writeJSON()
helper is in place, we can significantly simplify the code in healthcheckHandler
, like so
If you run the application again now, everything will compile correctly and a request to the GET /v1/healthcheck
endpoint should result in the same HTTP response as before.
Additional Information
How different Go types are encoded
In this chapter we’ve been encoding a map[string]string
type to JSON, which resulted in a JSON object with JSON strings as the values in the key/value pairs. But Go supports encoding many other native types too.
The following table summarizes how different Go types are mapped to JSON data types during encoding:
Go type | ⇒ | JSON type |
---|---|---|
bool |
⇒ | JSON boolean |
string |
⇒ | JSON string |
int* , uint* , float* , rune |
⇒ | JSON number |
array , slice |
⇒ | JSON array |
struct , map |
⇒ | JSON object |
nil pointers, interface values, slices, maps, etc. |
⇒ | JSON null |
chan , func , complex* |
⇒ | Not supported |
time.Time |
⇒ | RFC3339-format JSON string |
[]byte |
⇒ | Base64-encoded JSON string |
The last two of these are special cases which deserve a bit more explanation:
Go
time.Time
values (which are actually a struct behind the scenes) will be encoded as a JSON string in RFC 3339 format like"2020-11-08T06:27:59+01:00"
, rather than as a JSON object.A
[]byte
slice will be encoded as a base64-encoded JSON string, rather than as a JSON array. So, for example, a byte slice of[]byte{'h','e','l','l','o'}
would appear as"aGVsbG8="
in the JSON output. The base64 encoding uses padding and the standard character set.
A few other important things to mention:
Encoding of nested objects is supported. So, for example, if you have a slice of structs in Go that will encode to an array of objects in JSON.
Channels, functions and
complex
number types cannot be encoded. If you try to do so, you’ll get ajson.UnsupportedTypeError
error at runtime.Any pointer values will encode as the value pointed to.
Using json.Encoder
At the start of this chapter I mentioned that it’s also possible to use Go’s json.Encoder
type to perform the encoding. This allows you to encode an object to JSON and write that JSON to an output stream in a single step.
For example, you could use it in a handler like this:
This pattern works, and it’s very neat and elegant, but if you consider it carefully you might notice a slight problem…
When we call json.NewEncoder(w).Encode(data)
the JSON is created and written to the http.ResponseWriter
in a single step, which means there’s no opportunity to set HTTP response headers conditionally based on whether the Encode()
method returns an error or not.
Imagine, for example, that you want to set a Cache-Control
header on a successful response, but not set a Cache-Control
header if the JSON encoding fails and you have to return an error response.
Implementing that cleanly while using the json.Encoder
pattern is quite difficult.
You could set the Cache-Control
header and then delete it from the header map again in the event of an error — but that’s pretty hacky.
Another option is to write the JSON to an interim bytes.Buffer
instead of directly to the http.ResponseWriter
. You can then check for any errors, before setting the Cache-Control
header and copying the JSON from the bytes.Buffer
to http.ResponseWriter
. But once you start doing that, it’s simpler and cleaner (as well as slightly faster) to use the alternative json.Marshal()
approach instead.
Performance of json.Encoder and json.Marshal
Talking of speed, you might be wondering if there’s any performance difference between using json.Encoder
and json.Marshal()
. The short answer to that is yes… but the difference is small and in most cases you should not worry about it.
The following benchmarks demonstrate the performance of the two approaches using the code in this gist (note that each benchmark test is repeated three times):
In these results we can see that json.Marshal()
requires ever so slightly more memory (B/op) than json.Encoder
, and also makes one extra heap memory allocation (allocs/op).
There’s no obvious observable difference in the average runtime (ns/op) between the two approaches. Perhaps with a larger benchmark sample or a larger data set a difference might become clear, but it’s likely to be in the order of microseconds, rather than anything larger.
Additional JSON encoding nuances
Encoding things to JSON in Go is mostly quite intuitive. But there are a handful of behaviors which might either catch you out or surprise you when you first encounter them.
We’ve already mentioned a couple of these in this chapter (in particular — map entries being sorted alphabetically and byte slices being base-64 encoded), but I’ve included a full list in this appendix.