json2goの作成といくつかの学び

元旦の手隙な時間にjson2goというJSONをGoの構造体に変換するツールを書いた。

github.com

$ echo '{"foo":"bar","zoo":[1,2,3],"baz":[{"hoge":10},{"fuga":20}]}' | json2go
struct {
    Baz []struct {
        Fuga int `json:"fuga"`
        Hoge int `json:"hoge"`
    } `json:"baz"`
    Foo string `json:"foo"`
    Zoo []int  `json:"zoo"`
}

オンラインで同様のサービスを提供するサイトはすでにいくつかあるが、業務のコードをWebサイトのフォームに貼り付けたくなかったのでCLIを作成した。ただ、よくよく見たら既存のCLIもそれなりにあった。

一応、特徴としては

  • オブジェクト・配列だけでなくプリミティブ型のルート値も変換できる
  • 無名の構造体として出力される
  • 数値は.があればfloat64、なければint
  • 複数の型が混じらない配列を[]anyにしない(e.g., [1,2,3][]int
  • オブジェクトの配列を和集合の配列にする
  • 数字始まりや記号のキーも変換する
  • 変換結果をさらにコンパイルしてjson.Unmarshal()するテストをしている
  • JSONの定義通りの順番で変換する

…といったところ。

テストケースを読めば仕様がわかると思う。

いくつかの学び

最初、JSONをany型にUnmarshalしたオブジェクトを再帰的にたどっていけば簡単に作れるだろうと思っていた。 実際、その通りの実装になっているがコーナーケースや仕様決めが必要な箇所が細々とあって、小さなツールの割にそれなりに考えて実装することになった。

※仕様についてはJSON-to-Goの振る舞いを踏襲している

  • numberを一律float64にしたくない
    • json.Numberという型があって使う側でfloat64 or int64を決められる。さらに元の文字列も保持している
    • func (*Decoder) UseNumberを呼ぶと、map[string]anyへのUnmarshalでもnumberをjson.Numberに変換してくれる
  • [1, 2, 3]という配列を[]anyにしたくない
    • →すべての型をなめてから型を決定
  • [{"foo":1},{"bar":2}]を和集合に変換
    • →最初、型が完全に一致しない場合は[]anyにしていたが、キーが省略されるパターンがありそうなので []struct{ Foo int ; Bar int }になるようにした
    • 和集合を作る際にメンバーの型が異なっていてもany[]anyに丸められるようにした
  • [[1],["str"]][][]anyにしない
    • →頑張ったらできそうな気もしたが[]int[]stringは違う型なので違和感を拭えなかった
  • map[string]anyでordered mapを使う手段を見つけられなかったので、あきらめてソートするようにした
  • 数値始まりや記号のキー
    • →構造体のフィールドしてvalidな名前に変換
  • テストで変換結果をコンパイルすることでGoのコードとしてvalidなことを保証している
  • Goコードのフォーマットにはformat.Sourceを利用