Go言語でJSONをYAMLに変換するコマンド json2yaml を作りました!

JSONYAMLに変換するコマンド 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.Writerio.Readerを受け取る関数Convertを提供していて、任意の入力から出力に変換できるようになっています。 もし標準入力から読み込んで変換結果を標準出力に出すだけならば、json2yaml.Convert(os.Stdout, os.Stdin) だけで実装できるようになっています。 引数の順序はio.CopyTransformerに倣っています。 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の実装の話をしましょう。 YAMLJSONのスーパーセット、つまり全てのJSONYAMLでもあるので、入力をそのまま出力してもJSONからYAMLの変換と言えます。 しかしYAMLフォーマットとして我々が想像するのは、マッピングやシーケンスの括弧、文字列のクォートをできる限り使わずに書くフォーマットのことでしょう。

sample:
  string: Hello, world!
  sequence:
    - 0
    - null
    - boolean: true
    - string: This is a string!
  nested:
    mapping:
      and: |-
        #!/bin/sh
        echo "This is"
        echo "a multi-line"
        echo "string!"
    sequence:
      - - - - Deeply nested sequence!
  empty:
    - mapping: {}
    - sequence: []

こういう人間に優しいYAMLの出力を得るには、どのような実装が必要でしょうか。 まず、インデントのレベルの管理が必要ですね。 そして、マッピングやシーケンスの状態管理も必要になります。 これらのコレクションを閉じた次がマッピングのキーなのかシーケンスの要素なのか判別するために、スタックで管理します。 また、空マッピング {} と空シーケンス [] は特殊な処理が必要です

文字列をクォートせずにプレインスタイルで書いて良いかというのはなかなか複雑なルールが必要になります。 YAMLはさまざまな記号に意味がありますので、それらを含む場合はクォートが必要になります。 json2yamlでは、クォートが必要な文字列のパターンを正規表現にして判定するように実装しました。

- ""                   # 空文字はnullになるのでクォートが必要
- "~"                  # ~はnullになるのでクォートが必要
- ~~                   # ~~ならばクォートは不要
- "-"                  # -は[null]になるのでクォートが必要
- --                   # --ならばクォートは不要
- "false"              # booleanになるのでクォートが必要
- "y"                  # booleanになるのでクォートが必要 (YAML 1.1)
- "42.195"             # 数値になるのでクォートが必要
- 42.195.              # 数値にはならないのでクォートは不要
- ".nan"               # NaNになるのでクォートが必要
- "12:59:59"           # 60進数整数になるのでクォートが必要 (YAML 1.1)
- 12:59:60             # 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"          # BOMを含むのでクォートが必要
- "\uFFFFabc"          # Noncharacterを含むのでクォートが必要
---                    # document separator
"---"                  # document separatorと一致するのでクォートが必要
---                    # document separator
- ---                  # document separatorと一致するがシーケンスの要素なのでクォートは不要

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-outputPython版のjson2yamlNode版の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