Go custom JSON serialization

Posted by eojlin on Tue, 22 Feb 2022 02:12:43 +0100

By using structure tags, adding whitespace and encapsulating response data, we have been able to add a lot of custom information to JSON responses. But what happens when you need more freedom to customize JSON when that's not enough?

To answer this question, we first need to talk about some theories of how Go handles JSON serialization. The key to understanding is:

When go serializes a special type into JSON, it first checks whether the corresponding type implements the MarshalJSON() method. If implemented, go will call this method to determine the JSON encoding format.

It's a little vague. Let's be more precise. Strictly speaking, when Go encodes a specific type as JSON, it will check whether the type satisfies JSON Marshaler interface, which is as follows:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

If the type does satisfy the interface, Go will call its MarshalJSON() method and use the [] byte slice it returns as the JSON encoded value.

If the type does not have a MarshalJSON() method, Go will return an attempt to encode it as JSON according to its own internal rules.

Therefore, if we want to customize the encoding method of some types, we only need to implement the MarshalJSON() method on it, which returns the customized JSON content as [] byte.

Tip: if you view time Time type source code, you can see this. time.Time is actually a structure, but it has a MarshalJSON() method that outputs JSON objects in RFC3339 format. When time When the time value is serialized as a JSON object, the MarshalJSON() method is called.

1. Customize JSON serialization of Runtime fields

To illustrate this, let's look at a specific example in the application.

When our Movie structure is encoded as JSON, the Runtime field (which is an int32 type) is encoded as a JSON number. Now let's change it and encode it as a string of "< Runtime > mins". Like this:

{
    "id": 123,
    "title": "Casablanca",
    "runtime": "102 mins",
    "genres":
    [
        "drama",
        "romance",
        "war"
    ],
    "version": 1
}

There are several ways to do this, but a simple way is to create a custom type for the Runtime field and implement the MarshalJSON() method on this type.

To prevent internal / data / movie The go file will not be too messy. We create a new file to handle the runtime type serialization logic:

 $ touch internal/data/runtime.go

Then continue to add the following code:

package data

import (
    "fmt"
    "strconv"
)

//Declare the Runtime type, and the underlying type is int32 (the same as the field in movie)
type Runtime int32

//Implement the MarshalJSON() method, which implements JSON Marshaler interface.
func (r Runtime) MarshalJSON() ([]byte, error) {
    //Generates a string containing the duration of the movie
    jsonValue := fmt.Sprintf("%d mins", r)

    //Use strconv The quote() function encapsulates double quotes. In order to output as a string object in JSON, you need to use double quotation marks.
    quotedJSONValue := strconv.Quote(jsonValue)
    //Convert string to [] byte and return
    return []byte(quotedJSONValue),nil
}

Here I would like to emphasize two points:

  • If your JSON method returns a string in double quotation marks, it must return a JSON value. Otherwise, it will not be interpreted as a JSON string, and you will receive a runtime error similar to this:
    json: error calling MarshalJSON for type data.Runtime: invalid character 'm' after top-level value
    
  • We deliberately use the value receiver instead of the pointer receiver func (r *Runtime) MarshalJSON() for the MarshalJSON() method. This gives us more flexibility because it means that custom JSON coding will work for both Runtime value objects and pointer objects. As Effective Go mentioned:

If you're not sure about the difference between a pointer and a value receiver, this article Blog Provides a good summary.

OK, now that you have a custom Runtime type, open internal / data / movies Go file and update Movie structure:

File: internal/data/movies.go

package data

import (
    "time"
)

type Movie struct {
    ID       int64     `json:"id"`
    CreateAt time.Time `json:"-"`
    Title    string    `json:"title"`
    Year     int32     `json:"year,omitempty"`
        //Use the Runtime type instead of int32. Note that the omitempty can still take effect
    Runtime  Runtime   `json:"runtime,omitempty,string"`
    Genres   []string  `json:"genres,omitempty"`
    Version  int32     `json:"version"`
}

Restart the service and make a request for the GET /v1/movies/:id interface. You should see a response with a custom runtime value in the format of "xx mins", similar to the following:

$ curl localhost:4000/v1/movies/123
{
    "movie":
    {
        "id": 123,
        "title": "Casablanca",
        "runtime": "102 mins",
        "genres":
        [
            "drama",
            "romance",
            "war"
        ],
        "version": 1
    }
}

In short, this is a good way to customize JSON serialization. Our code is concise and clear, and we have a custom Runtime type that can be used anytime, anywhere.

But there are also disadvantages. When integrating code with other packages, using custom types can sometimes be awkward. You may need to perform type conversion to convert custom types to values understood and acceptable by other packages.

Topics: Go JSON RESTful