その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

tinygo 向けの JSON marshaler: go-json-ice を書いた

English article is here: Released go-json-ice: a code generator of JSON marshaler for tinygo - moznion's tech blog


tinygo では encoding/json を import するとコンパイルできなくなるという問題があり *1,なんらかの struct を JSON に marshal したい時に使える de facto な方法が無いように見えました.これに関しては例えば以下のような issue が立っています:

github.com

github.com

つまり tinygo 上で任意の struct を JSON にしたい時は「手で気を付けてシリアル化する」しか方法がなかったわけですが,まあそれだと何かと不便だったので表題の通り json-ice という encoding/json に依存しない JSON marshaler のコードジェネレータを作りました.

github.com

挙動としては,事前に marshaling 対象となる struct (の json カスタム struct タグ) を解釈して JSON に marshal するコードを吐き出す,という至ってシンプルなものとなります.似たような挙動をする先行実装に mailru/easyjson などがありますが,これらは内部的に encoding/json に依存しているようで,今回の用途にはマッチしませんでした.


例えば以下のような struct を marshal したい時には go:generate と一緒にコードを書いておくと

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

MarshalAwesomeStructAsJSON(s *AwesomeStruct) ([]byte, error) というコードが生成されるので,それを利用して struct を JSON に marshal することが可能です:

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

これにより実行時に reflection を使って動的に marshaling する必要がなくなるので,tinygo でも JSON marshaling が簡単に行えるようになります.また,動的な reflection の代わりに事前計算するので当然の結果ですがパフォーマンスも少し良くなります *2
もちろんその副作用として interface{} な値を持つ struct については動的な型の解決ができないため marshaling ができません.Marshaling するためには静的に型の解決ができる必要があります.

また tinygo は wasm を吐き出す機能も有しており,この wasm が実行時に import するモジュールは「元のコードが何に依存しているか」によって変化してきます.この実行時の依存をできる限りミニマムにしたい (例えばブラウザランタイム以外の強力な sandbox 環境で wasm を動かすというユースケースが考えられる) という動機があったので,生成コードが依存するパッケージは可能な限り最小限にとどめました.結果的に現時点では strconv にのみ依存するようになっています.ミニマル!


そんな感じのライブラリです.どうぞご利用ください!
もちろん tinygo ではなく通常の go の処理系でも利用できますが,それについてはもっと良い先行実装 (それこそ easyjson とか) があると思うので,そちらの利用の検討をおすすめします.


なお,この実装は JSON の marshaling のみをサポートするものですが,逆に tinygo で JSON unmarshaling するにはどうすればよいかと言うと,buger/jsonparser を利用すれば良いように思いました.

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

github.com




余談ですが

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

のような深く,再帰的(?)な型についてもちゃんとしたコード生成が可能です:

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"}}}}]}

生成コードはこんな感じ

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
}