moznion's tech blog

moznion's technical memo

Released go-json-ice: a code generator of JSON marshaler for tinygo

In tinygo, if the go code imports encoding/json package, it fails to compile. AFAIK, this is a known issue *1, so it seemed there is no standard way to marshal any struct to JSON string. There are some related issues like the following:

github.com

github.com

The point is if we would like to marshal a struct to JSON string, "serialize it by hand, carefully" is an only way for that, but it's a little too inconvenient so I made a code generator of JSON marshaler for that purpose; the generated marshaler doesn't have the dependency belongs to encoding/json.

github.com

This library parses the struct (i.e. its json custom struct tag) that to marshal beforehand and generates marshaler code according to the parsed result. There have been already similar implementations, such as mailru/easyjson, but they depend on encoding/json internally so it hasn't matched with my purpose.

For example, if you'd like to marshal the following struct, just put go:generate with that,

//go:generate json-ice --type=AwesomeStruct
type AwesomeStruct struct {
    Foo string `json:"foo"`
    Bar string `json:"bar,omitempty"`
}

then, it generates marshaler code as MarshalAwesomeStructAsJSON(s *AwesomeStruct) ([]byte, error), next you can marshal a struct to JSON with that:

marshaled, err := MarshalAwesomeStructAsJSON(&AwesomeStruct{
    Foo: "buz",
    Bar: "",
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("%s\n", marshaled) // => {"foo":"buz"}

Therefore, it no longer needed to use reflection for marshaling, so tinygo can marshal struct easily. And also it resolves fields of marshaling target struct in advance, thus the performance gets better. Of course, it has side effects. If the target field type was interface{}, it cannot marshal that because it can't resolve the actual type of the field dynamically. The type must be resolvable as static.

And as you know, tinygo can transpile to wasm, and the modules that are imported in wasm on running depends on what original code depends on. The generated code from this library is interested to keep that dependency as minimal as possible because there is the possibility to execute wasm in a super-restricted sandbox *2. As a result, the code only relies on strconv package.

I'm happy if you try this library!

FYI, this library only supports JSON marshaling. If you're looking for a way to unmarshal JSON in tinygo, I guess buger/jsonparser would help you for the purpose:

It does not rely on encoding/json, reflection or interface{}, the only real package dependency is bytes.

github.com


By the way,

//go:generate json-ice --type=DeepStruct
type DeepStruct struct {
    Deep []map[string]map[string]map[string]map[string]string `json:"deep"`
}

this library can generate the marshaler code for a struct that has deep and recursive field type like the above.

given := &DeepStruct{
    Deep: []map[string]map[string]map[string]map[string]string{
        {
            "foo": {
                "bar": {
                    "buz": {
                        "qux": "foobar",
                    },
                },
            },
        },
        {
            "foofoo": {
                "barbar": {
                    "buzbuz": {
                        "quxqux": "foobarfoobar",
                    },
                },
            },
        },
    },
}

marshaled, err := MarshalDeepStructAsJSON(given)
if err != nil {
    log.Fatal(err)
}

log.Printf("[debug] %s", marshaled) // => {"deep":[{"foo":{"bar":{"buz":{"qux":"foobar"}}}},{"foofoo":{"barbar":{"buzbuz":{"quxqux":"foobarfoobar"}}}}]}

The generated code for that is the following :)

import "github.com/moznion/go-json-ice/serializer"

func MarshalDeepStructAsJSON(s *DeepStruct) ([]byte, error) {
    buff := make([]byte, 1, 54)
    buff[0] = '{'
    if s.Deep == nil {
        buff = append(buff, "\"deep\":null,"...)
    } else {
        buff = append(buff, "\"deep\":"...)
        buff = append(buff, '[')
        for _, v := range s.Deep {
            if v == nil {
                buff = append(buff, "null"...)
            } else {
                buff = append(buff, '{')
                for mapKey, mapValue := range v {
                    buff = serializer.AppendSerializedString(buff, mapKey)
                    buff = append(buff, ':')
                    if mapValue == nil {
                        buff = append(buff, "null"...)
                    } else {
                        buff = append(buff, '{')
                        for mapKey, mapValue := range mapValue {
                            buff = serializer.AppendSerializedString(buff, mapKey)
                            buff = append(buff, ':')
                            if mapValue == nil {
                                buff = append(buff, "null"...)
                            } else {
                                buff = append(buff, '{')
                                for mapKey, mapValue := range mapValue {
                                    buff = serializer.AppendSerializedString(buff, mapKey)
                                    buff = append(buff, ':')
                                    if mapValue == nil {
                                        buff = append(buff, "null"...)
                                    } else {
                                        buff = append(buff, '{')
                                        for mapKey, mapValue := range mapValue {
                                            buff = serializer.AppendSerializedString(buff, mapKey)
                                            buff = append(buff, ':')
                                            buff = serializer.AppendSerializedString(buff, mapValue)
                                            buff = append(buff, ',')
                                        }
                                        if buff[len(buff)-1] == ',' {
                                            buff[len(buff)-1] = '}'
                                        } else {
                                            buff = append(buff, '}')
                                        }

                                    }
                                    buff = append(buff, ',')
                                }
                                if buff[len(buff)-1] == ',' {
                                    buff[len(buff)-1] = '}'
                                } else {
                                    buff = append(buff, '}')
                                }

                            }
                            buff = append(buff, ',')
                        }
                        if buff[len(buff)-1] == ',' {
                            buff[len(buff)-1] = '}'
                        } else {
                            buff = append(buff, '}')
                        }

                    }
                    buff = append(buff, ',')
                }
                if buff[len(buff)-1] == ',' {
                    buff[len(buff)-1] = '}'
                } else {
                    buff = append(buff, '}')
                }

            }
            buff = append(buff, ',')
        }
        if buff[len(buff)-1] == ',' {
            buff[len(buff)-1] = ']'
        } else {
            buff = append(buff, ']')
        }

        buff = append(buff, ',')
    }
    if buff[len(buff)-1] == ',' {
        buff[len(buff)-1] = '}'
    } else {
        buff = append(buff, '}')
    }
    return buff, nil
}

*1:because of https://tinygo.org/lang-support/stdlib/#encoding-json

*2:i.e. not a browser runtime