TIL Using Custom Marshaling in Go
Not quite a Today I Learned, but today I did implement a custom marshaler for my project Homebox and thought I would share the process and why I think it can be a great tool for your API. First let’s dive into the requirements of the project.
Requirements
I’m building a home inventory system, one of the most requested features is the ability to generated predictable asset tags. The asset tags are effectively an auto incrementing field in the database.
You’ll often see these represented as 010-020
where you have a number represented as a string with a required amount of leading zeros to meet the length of the string. In this case, we want a string of length 6. This is a great visual representation, but what we really want on the backend is to just treat this like any other integer. That way we can leverage SQL and Go functionality that only works with integer types.
Building the Marshal/Unmarshal
First we need to declare a custom type alias to implement the json.Marshaler
and json.Unmarshaler
interfaces.
type AssetID int
Now we can implement the MarshalJSON
and UnmarshalJSON
receivers.
The MarshalJSON
method is pretty straight forward, we just need to format the integer into a string with the correct length and then add the dashes. We then return the string as a byte slice wrapped in quotes.
func (aid AssetID) MarshalJSON() ([]byte, error) {
aidStr := fmt.Sprintf("%06d", aid)
aidStr = fmt.Sprintf("%s-%s", aidStr[:3], aidStr[3:])
return []byte(fmt.Sprintf(`"%s"`, aidStr)), nil
}
The UnmarshalJSON
method is a little more complicated. We need to remove the quotes and dashes from the string, then convert the string to an integer and assign it to the pointer receiver.
func (aid *AssetID) UnmarshalJSON(d []byte) error {
d = bytes.Replace(d, []byte(`"`), []byte(``), -1)
d = bytes.Replace(d, []byte(`-`), []byte(``), -1)
aidInt, err := strconv.Atoi(string(d))
if err != nil {
return err
}
*aid = AssetID(aidInt)
return nil
}
There’s a couple things you want to keep in mind when implementing this method
- I’m using a pointer receiver for the
UnmarshalJSON
method. This is because we need to modify the value of the receiver, and we can’t do that with a value receiver. - I’m using the
bytes.Replace
method instead of thestrings.Replace
method. You first inclination may be to convert the byte slice to a string and then work with it there, but strings in Go are just pointers to an immutable byte array, so doing so would create a new string object every time you callstrings.Replace
. Does that matter? In most cases probably not, but I’d argue that if you’re already working with a byte slice, you might as well avoid the extra allocations and use thebytes
package instead. - I’m not stripping the leading zeros from the string.
strconv.Atoi
will do that for us, so we don’t need to worry about it.
bytes
package and get familiar with it’s methods. Using it over the strings package can improve perform and reduce unnecessary conversions between types.Summary
With the implementation above we can now easily convert between our “display” format and the integer format we want to use in the database. This is a great example of how you can use custom marshalers to make your API more user friendly without sacrificing the underlying data type or over complicating your code.
Full Code
package repo
import (
"bytes"
"fmt"
"strconv"
)
type AssetID int
func (aid AssetID) MarshalJSON() ([]byte, error) {
aidStr := fmt.Sprintf("%06d", aid)
aidStr = fmt.Sprintf("%s-%s", aidStr[:3], aidStr[3:])
return []byte(fmt.Sprintf(`"%s"`, aidStr)), nil
}
func (aid *AssetID) UnmarshalJSON(d []byte) error {
d = bytes.Replace(d, []byte(`"`), []byte(``), -1)
d = bytes.Replace(d, []byte(`-`), []byte(``), -1)
aidInt, err := strconv.Atoi(string(d))
if err != nil {
return err
}
*aid = AssetID(aidInt)
return nil
}