JSONをYAMLに変換するコマンド json2yaml
を、Go言語で作ってみました。
他の言語も含めると同じようなコマンドラインツールは無数に作られていますが、
Goのライブラリとして組み込みたかったというのが最初のモチベーションです。
また、JSONをストリーミング的にYAMLに変換できるのかという疑問を以前から抱いていて、ここに答えを得たかったというところもありました。
github.com
go install github.com/itchyny/json2yaml/cmd/json2yaml@latest
Homebrewでもインストールできます。
brew install itchyny/tap/json2yaml
JSONファイルを引数にしてYAMLを出力したり、
$ cat sample.json
{
"Sample": "JSON"
}
$ json2yaml sample.json
Sample: JSON
標準入力を変換したりできます。
$ curl -sSf "https://httpbin.org/json" | json2yaml
slideshow:
author: Yours Truly
date: date of publication
slides:
- title: Wake up to WonderWidgets!
type: all
- items:
- Why <em>WonderWidgets</em> are great
- Who <em>buys</em> WonderWidgets
title: Overview
type: all
title: Sample Slide Show
REST APIの結果をlessで見たり、Jsonnetのビルド結果をKustomizeに持ってきたいときなどに便利そうですね。
ライブラリとしてはio.Writer
とio.Reader
を受け取る関数Convert
を提供していて、任意の入力から出力に変換できるようになっています。
もし標準入力から読み込んで変換結果を標準出力に出すだけならば、json2yaml.Convert(os.Stdout, os.Stdin)
だけで実装できるようになっています。
引数の順序はio.Copy
やTransformer
に倣っています。
Goの標準パッケージのみに依存しているので、他のツールにも組み込みやすいと思います。
元々はgojo
コマンドでYAML出力を対応してほしいという要望を受けたのが、このツールを作ったきっかけでした。
gojo
コマンドはjo
コマンドのGo実装で、JSONを簡単に組み立て出力できるツールです。
$ gojo -p res[foo][][id]=10 res[foo][][id]=20 res[cnt]=2
{
"res": {
"foo": [
{
"id": 10
},
{
"id": 20
}
],
"cnt": 2
}
}
$ gojo -y res[foo][][id]=10 res[foo][][id]=20 res[cnt]=2
res:
foo:
- id: 10
- id: 20
cnt: 2
要望を上げていただいた方は、gojo
の出力をgojq --yaml-input
で変換しているとおっしゃっていて、自分のツールを組み合わせて使っている方からこのようにフィードバックが来るのはOSSをやっていてとても嬉しいですね。
itchyny.hatenablog.com
itchyny.hatenablog.com
\広告です/
さて、json2yamlの実装の話をしましょう。
YAMLはJSONのスーパーセット、つまり全てのJSONはYAMLでもあるので、入力をそのまま出力してもJSONからYAMLの変換と言えます。
しかしYAMLフォーマットとして我々が想像するのは、マッピングやシーケンスの括弧、文字列のクォートをできる限り使わずに書くフォーマットのことでしょう。
sample:
string: Hello, world!
sequence:
- 0
- null
- boolean: true
- string: This is a string!
nested:
mapping:
and: |-
echo "This is"
echo "a multi-line"
echo "string!"
sequence:
- - - - Deeply nested sequence!
empty:
- mapping: {}
- sequence: []
こういう人間に優しいYAMLの出力を得るには、どのような実装が必要でしょうか。
まず、インデントのレベルの管理が必要ですね。
そして、マッピングやシーケンスの状態管理も必要になります。
これらのコレクションを閉じた次がマッピングのキーなのかシーケンスの要素なのか判別するために、スタックで管理します。
また、空マッピング {}
と空シーケンス []
は特殊な処理が必要です
文字列をクォートせずにプレインスタイルで書いて良いかというのはなかなか複雑なルールが必要になります。
YAMLはさまざまな記号に意味がありますので、それらを含む場合はクォートが必要になります。
json2yamlでは、クォートが必要な文字列のパターンを正規表現にして判定するように実装しました。
- ""
- "~"
- ~~
- "-"
- --
- "false"
- "y"
- "42.195"
- 42.195.
- ".nan"
- "12:59:59"
- 12:59:60
- "2022-08-29"
- "a #b"
- a#b#c
- "&test"
- "*test"
- "* test"
- x * y
- "!test"
- "a: 0"
- a:0
- https://example.com/
- "\"quote"
- q"u"o"t"e"
- "[null]"
- "-100"
- -100E
- "- 100E"
- " spaces "
- "\uFEFFabc"
- "\uFFFFabc"
---
"---"
---
- ---
JSONの文字列はYAMLの文字列でもあるので、クォートするときは基本的にJSONと同じエスケープをすれば大丈夫です。
ただし、YAMLパーサーにはクォート内のNoncharacterを解釈できないものがあるので、仕様からするとパーサーのバグですが (パーサーを名乗るなら仕様に沿って実装して欲しい)、json2yamlはNoncharacterもエスケープシーケンスで出力します。
文字列を扱うプログラムはNoncharacterが渡されたときの挙動に気をつけたいですね。
YAMLの複数行文字列は、制御コード・BOM・Noncharacter以外は基本的になんでも使えます。
- |
This is
a multi-line
string.
- |-
Chomp final
line breaks.
- |+
Keep final
line breaks.
- ? |
mapping
key
: |
mapping
value
- |2
Leading white space
can be represented
by indentation indicator.
|
|-
|+
は、文字列最後の改行の扱い方が変わります。
あまり遭遇することはありませんが、マッピングのキーにも複数行文字列を使えます。
文字列がスペースから始まる場合は、インデントの数を指定しないといけません (指定しないと、最初の行がインデントの基準になります)。
ただ、この構文をサポートしていないYAMLパーサーがあるので (パーサーを名乗るなら以下略)、
json2yamlは空白文字から始まる複数行文字列はクォートして出力しています。
json2yamlはJSON全体をメモリーに読み込まず、JSONの字句 (トークン) ごとにYAMLに変換していきます。
この実装により、map[string]any
や[]any
のメモリー確保を避けられます。
あまり現実的な例ではありませんが、以下のような巨大なシーケンスでも処理できます。
$ (echo "["; yes "0," | head -n 100000000; echo "0]") | json2yaml
- 0
- 0
- 0
- 0
- 0
- 0
- 0
^C
ストリーミング処理だとマッピングのキー重複の扱いが気になる人もいるでしょう。
JSONではキーの重複は許されていますが (非推奨ではあります)、YAMLでは明確に許されていません。
ただしこれは仕様上の話であって、実際にはYAMLでもキー重複をエラーにしないパーサーはあるようです。
json2yamlはキー重複をエラーにしたり上書きにしたりはせずに、重複のまま出力しています。
ストリーミング処理ならば速そうということでパフォーマンスが気になるところだと思いますが、ファイルサイズに依存してだいぶ結果が変わるので、あくまで一例として見ていただければと思います。
gojq --yaml-output
、Python版のjson2yaml、Node版のjson2yaml、そしてyqを比較しました。
$ export file=/tmp/package-lock.json
$ file "$file"
/tmp/package-lock.json: JSON data
$ stat -f %z "$file"
135843
$ hyperfine --warmup 10 'json2yaml "$file"' 'gojq --yaml-output . "$file"' 'py/json2yaml "$file"' 'js/json2yaml "$file"' 'yq "$file"'
Benchmark 1: json2yaml "$file"
Time (mean ± σ): 8.6 ms ± 0.3 ms [User: 7.7 ms, System: 0.6 ms]
Range (min … max): 7.9 ms … 9.4 ms 276 runs
Benchmark 2: gojq --yaml-output . "$file"
Time (mean ± σ): 9.4 ms ± 0.4 ms [User: 10.2 ms, System: 2.3 ms]
Range (min … max): 8.5 ms … 11.1 ms 260 runs
Benchmark 3: py/json2yaml "$file"
Time (mean ± σ): 103.8 ms ± 0.5 ms [User: 98.1 ms, System: 4.4 ms]
Range (min … max): 102.9 ms … 104.9 ms 28 runs
Benchmark 4: js/json2yaml "$file"
Time (mean ± σ): 43.4 ms ± 0.7 ms [User: 37.3 ms, System: 4.9 ms]
Range (min … max): 42.0 ms … 44.7 ms 64 runs
Benchmark 5: yq "$file"
Time (mean ± σ): 110.1 ms ± 1.7 ms [User: 365.9 ms, System: 32.4 ms]
Range (min … max): 106.2 ms … 113.0 ms 26 runs
Summary
'json2yaml "$file"' ran
1.09 ± 0.06 times faster than 'gojq --yaml-output . "$file"'
5.06 ± 0.17 times faster than 'js/json2yaml "$file"'
12.10 ± 0.37 times faster than 'py/json2yaml "$file"'
12.83 ± 0.43 times faster than 'yq "$file"'
繰り返しになりますがこれは一例であって、必ずこの順位になるということではありません。
特に、Node版のjson2yamlはパフォーマンスが良くて、JSONファイルが大きくなるほど優秀でした (JSON.parse
が圧倒的に速いんだと思います… 500MBを超えると負けました)。
ただし、マッピングのキーが*
や&
から始まるとYAMLとして不正な出力になったり、キーが#
から始まると出力行がコメントになったり、60文字を超える文字列の場合に先頭と末尾に改行が入ってしまったりしていて (JSONに戻すと値が変わる)、とても残念な出力でした。
yq
はGoで書かれていますが、なぜここまで遅いのかはよく分かりませんでした。
YAMLはフロースタイルとブロックスタイルという文脈に応じて使える文字が変わるため、仕様の構文定義が複雑で読みとくのに骨が折れました。
また、仕様のゆるいYAML 1.1を実装しているパーサーもこの世にはまだあるため、json2yamlでは厳しめに文字列をクォート出力しています。
手元の開発マシンにはわりとたくさんJSONファイルがある (主にnode_modules
) ので、これを使って変換処理をテストしました。
BOMがついていたり、C1制御コードが含まれていたり、だいぶ良いテストデータになりました。
Go版 json2yaml コマンドとライブラリ、作ってまだ間もないツールですが、完成度はわりと高いと思うので、ぜひ使ってみてください。
github.com