Jq&AwkコマンドでJSONファイル⇔CSVファイルに相互変換する方法を考察


※ 当ページには【広告/PR】を含む場合があります。
2023/06/28
【jqコマンド活用法】JSONオブジェクトを再構成〜フィールドの一部を書き換えてみる
【Awkでデータ解析のすゝめ】CSVで重複なしのユニークなリストを作る/検索結果から重複を取り除く
蛸壺の技術ブログ|Jq&AwkコマンドでJSONファイル⇔CSVファイルに相互変換する方法を考察

遡ることだいぶ前の話題で、特定のパターンのテキストデータから「Awkコマンド」だけでゴリゴリとJSON形式のファイルを作る方法を紹介したことがありました。

合同会社タコスキングダム|蛸壺の技術ブログ
Awkコマンドを使ってテキストからjsonを生成する実用例〜株価日足で利用する

Awkを使った実践例を紹介するコーナーです。今回は、株式チャートで利用される日足のテキスト生データをJSON形式へパースさせてみる手順を説明します。

その際にいろいろなAwkのテクニックを駆使して、ごりごりとJSONファイルへと変換していましたが、少し汎用性に欠ける方法かもしれません。

そこで、今回はもうちょっと使いどころが広がるように、JqとAwkコマンドで、
「CSVファイルとJSONファイルを行ったり来たりできるような相互変換」の概論的な内容でまとめていきます。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】シェルスクリプトをこれから学びたい人のためのオススメ書籍&教材特集

前置き〜Jqコマンドのテクニックの復習

本題に先立って、Jqコマンドでどちらかというとマイナーな機能である「to_entries」「from_entries」の使い方を復習しておきます。

Jqコマンドの「to_entries」

「to_entries」はJSON形式の文字列から、KEY-VALUEのエントリーパターンの配列に変換してくれるフィルター関数になっています。

            
            $ echo '{"a": 1, "b": 2}' | jq 'to_entries'
[
  {
    "key": "a",
    "value": 1
  },
  {
    "key": "b",
    "value": 2
  }
]

#👇入力が配列の場合、key値は0から始まる番号が割り当てられる
$ echo '[{"a": 1, "b": 2}]' | jq 'to_entries'
[
  {
    "key": 0,
    "value": {
      "a": 1,
      "b": 2
    }
  }
]
        

to_entiesを扱う注意点として、KEY-VALUEパターンとなるのは、入力したJSONの深さ1相当のフィールド(上の例だと、.a.b)のみです。

            
            #👇cのVALUEはJSONオブジェクトのまま維持される
$ echo '{"a": 1, "b": 2, "c": {"d": 3}}' | jq 'to_entries'
[
  {
    "key": "a",
    "value": 1
  },
  {
    "key": "b",
    "value": 2
  },
  {
    "key": "c",
    "value": {
      "d": 3
    }
  }
]
        
更に深い層のフィールドが、自動で回帰的にKEY-VALUEパターンに展開されることはないことに注意してください。

Jqコマンドの「from_entries」

「from_entries」は先程のto_entriesの逆変換にあたる機能で、KEY-VALUEのエントリーパターンの配列から、JSON形式の文字列へと変換してくれるフィルター関数になります。

            
            $ echo '[{"key":"a", "value":1}, {"key":"b", "value":2}]' | jq 'from_entries'
{
  "a": 1,
  "b": 2
}

$ echo '[{"key":"a", "value":1}, {"key":"b", "value":{"c": 3}}]' | jq 'from_entries'
{
  "a": 1,
  "b": {
    "c": 3
  }
}
        
入力の文字列に問題がなければ、KEY-VALUEパターン配列からJSON構造へと復元することができます。

from_entriesにも利用上の注意点があって、JSON配列に直接復元することができません。

            
            #👇[{"a":1}]をKEY-VALUE化したもの
$ echo '[{"key": 0, "value": {"a": 1}}]' | jq 'from_entries'
jq: error (at <stdin>:1): Cannot use number (0) as object key
        
やろうとしてもkey値には直接数字を指定できないエラーが発生しています。配列なのに、自由に数字を割り当てられては困るのでエラー扱いされているようです。

この場合、JSONとして配列化したい際には、

            
            $ echo '[{"key": "a", "value": 1}]' | jq 'from_entries | [.]'
[
  {
    "a": 1
  }
]
        
で戻すことができます。

また先程の
to_entiesと同様、KEY-VALUEパターンからJSONへと復元されるのは深さ1相当のフィールドのみです。

            
            #👇{"a":1, "b":{"c": 3}}とはならないことに注意
$ echo '[{"key":"a", "value":1}, {"key":"b", "value":[{"key":"c", "value":3}]}]' | jq 'from_entries'
{
  "a": 1,
  "b": [
    {
      "key": "c",
      "value": 3
    }
  ]
}
        

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】シェルスクリプトをこれから学びたい人のためのオススメ書籍&教材特集

CSVからJSONへ変換する

ここからはCSV形式のテキストからJSON形式へ変換する方法を考えてみます。

CSVから復元できるJSONオブジェクト

まずはCSVテキストをJSONに変換する上で意識しておきたいのは、CSVから抽出できるJSONオブジェクトは「単調なJSONオブジェクトの配列」になるということです。

CSVが保持している情報はどこまでいっても行と列の2次元配列ですので、そこからより複数なデータ構造を持つJSONオブジェクトとして取り出すには、複雑な変換ルールを設ける必要があります。

この記事では面倒なことが一切考えず、CSVからJSONへ変換するのに、シンプルなルールを与えることにします。

            
            1. CSVファイルの1行目(ヘッダ行)に変数名を定義する
2. データはCSVファイルの2行目以降から開始する
3. 空のデータのフィールドは除外し、NULLとは区別する
        
ということで、例えば、以下のようなCSVファイルがあったとして、

            
            hoge,piyo,fuga,moga,buyo
120,こんにちは,3.14,ごきげんだぜ,false
54,コンバンワ,-8.07,,true
-89,てやんでぇ,1.2E-3,さいあくだ,

👇JSON変換後

[
    {
        "hoge": 120,
        "piyo": "こんにちは",
        "fuga": 3.14,
        "moga": "ごきげんだぜ",
        "buyo": false
    },
    {
        "hoge": 54,
        "piyo": "コンバンワ",
        "fuga": -8.07,
        "buyo": true
    },
    {
        "hoge": -89,
        "piyo": "てやんでぇ",
        "fuga": 0.0012,
        "moga": "さいあくだ"
    }
]
        
になるような変換を目指します。

CSVからJSONへの変換スクリプトを実装する

ここでは完成版を一気に紹介します。

            
            $ CSV_TEXT=$(cat << EOF
hoge,piyo,fuga,moga,buyo
120,こんにちは,3.14,ごきげんだぜ,false
54,コンバンワ,-8.07,,true
-89,てやんでぇ,1.2E-3,さいあくだ,
EOF
)

#👇AwkスクリプトでKEY-VALUEパターンを構築してJSONへ変換させる
$ echo "$CSV_TEXT" | awk -F"," '
    #👇ヘッダ行からフィールド名(KEY)を取得
    NR == 1 {
        rowCount = 0;
        #👇keyという配列に値をセット
        for (i = 1; i <= NF; i++) {key[i] = $i;}
    }
    #👇データ行(2行目以降)からデータ値(VALUE)を取得
    NR > 1 {
        rowCount++;
        for (i = 1; i <= NF; i++) {
            if ($i != "") {
                if ($i ~ /^[+-]?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$/) {
                    #👇数値にマッチした場合
                    value[rowCount][i] = "{\"key\":\"" key[i] "\",\"value\":" $i "}";
                }
                else if (tolower($i) ~ /(true|false|null)/) {
                    #👇BooleanかNULLにマッチした場合
                    value[rowCount][i] = "{\"key\":\"" key[i] "\",\"value\":" $i "}";
                }
                else {
                    #👇その他はすべて文字列として認識
                    value[rowCount][i] = "{\"key\":\"" key[i] "\",\"value\":\"" $i "\"}";
                }
            }
        }
    }
    END {
        for (j=1;j<=rowCount;j++) {
            rslt = "[";
            for (i = 1; i <= NF; i++) {
                #👇VALUE配列の中身が空だったらスキップ
                if (value[j][i] == null) { continue; }
                else if (i == NF) { rslt = rslt value[j][i]; }
                else { rslt = rslt value[j][i] ","; }
            }
            #👇最後に不要なコンマが残っていた場合には除去
            sub(/,$/, "", rslt);
            print rslt "]";
        }
    }
' | jq 'from_entries' | jq -s '.'
#👇出力結果
[
  {
    "hoge": 120,
    "piyo": "こんにちは",
    "fuga": 3.14,
    "moga": "ごきげんだぜ",
    "buyo": false
  },
  {
    "hoge": 54,
    "piyo": "コンバンワ",
    "fuga": -8.07,
    "buyo": true
  },
  {
    "hoge": -89,
    "piyo": "てやんでぇ",
    "fuga": 0.0012,
    "moga": "さいあくだ"
  }
]
        
Awkスクリプトを駆使して、Jqのfrom_entriesに食わせるためのKEY-VALUEパターンを前処理させています。

Awkスクリプトの細かいポイントはコメントとして残していますので、そちらを参考に読み解いてみてください。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】シェルスクリプトをこれから学びたい人のためのオススメ書籍&教材特集

JSONからCSVへ変換

場合によっては、JSONからCSVに変換したい時があります。

後半では、JSONをCSVにするテクニックを考えてみましょう。

CSVに変換できるJSONデータの構造

こちらもJSONからCSVへ変換する前の約束事として、先ほどの節でも説明したように、全てのJSONオブジェクトがそのままCSVに変換できるわけではなく、「単調なJSONオブジェクトの配列」で表現できる時のみCSVに変換できます。

これはCSVが単なる2次元配列のデータでしかないので、より汎用性のあるJSON形式のデータの表現に制限が付くのも仕方ないことです。

もし複雑な構造をそのままデータとして保持したい場合にはJSON形式のままファイルとして保存すべきでしょう。

ここで定義するCSVデータに変換可能な「単調なJSONオブジェクトの配列」とは以下の性質を備えるものです。

            
            1. ルート構造が配列([...])であること
2. その配列のメンバーは同一の構造を持つJSONオブジェクトで構成されいること
3. さらにそのJSONオブジェクトの値(VALUE)はプリミティブ型(文字列/数値/Bool/Null)であること
        
です。

例えば、

            
            [
    {"hoge": 1, "piyo": true, "fuga": "ポヨ田 ムウ夫"},
    {"hoge": 4, "piyo": false, "fuga": "ガビ川 ムゲ彦"},
    {"hoge": 9, "piyo": false, "fuga": "ペケ山 ムモ子"}
]

👇CSV変換後

hoge,piyo,fuga
1,true,ポヨ田 ムウ夫
4,false,ガビ川 ムゲ彦
9,false,ペケ山 ムモ子
        
という感じです。

変換がうまくいくポイントは、JSONオブジェクトの構造が完全な2次元配列のデータとして成立していることにあります。

JSONをCSV形式に変換する方法

先程の話を踏まえて、JSON文字列からCSVデータへの変換を考えてみます。

            
            $ JSON_TEXT=$(cat << EOF
[
    {"hoge": 1, "piyo": true, "fuga": "ポヨ田 ムウ夫"},
    {"hoge": 4, "piyo": false, "fuga": "ガビ川 ムゲ彦"},
    {"hoge": 9, "piyo": false, "fuga": "ペケ山 ムモ子"}
]
EOF
)

$ echo "$JSON_TEXT" | jq -r '
    #①JSON配列からCSVへ変換する魔法の呪文
    (.[0]|to_entries|map(.key)),(.[]|[.[]])|@csv
' | awk -F"," '
    #③数字の3桁区切りを処理するオプション関数
    function add_(num) {
        count = split(num, arr, ".");
        integer = arr[1];
        if (count > 1) minority = arr[2];
        while (match(integer, /[0-9]+/) > 0) {
            if (RLENGTH <= 3) break;
            else {
                num1 = substr(integer, 1, RSTART+RLENGTH-4);
                num2 = substr(integer, RSTART+RLENGTH-3);
                integer = num1 "_" num2;
            }
        }
        return count > 1 ? integer "." minority : integer;
    }
    NR == 1 {
        #②「"」の処理
        gsub(/"/,"",$0);
        print $0;
    }
    NR > 1 {
        for (i = 1; i <= NF; i++) {
            if ($i ~ /^[+-]?([0-9]+[.]?[0-9]*|[.][0-9]+)([eE][+-]?[0-9]+)?$/) {
                #③3桁以上の数字にアンダースコアを追加(例: 10000 --> 10_000)
                printf add_($i);
            } else {
                #②「"」の処理
                gsub(/"/,"",$i);
                printf $i;
            }
            if (i < NF) { printf "," }
        }
        print ""
    }
'
#👇出力
1,true,ポヨ田 ムウ夫
4,false,ガビ川 ムゲ彦
9,false,ペケ山 ムモ子
        
まずJqスクリプトのの箇所に関してですが、何やら奇怪な呪文のようなものになっています。

これは、JSON配列からヘッダ付きのCSVデータを取り出すちょっとしたイディオムになっています。

以下のブログ記事にて一つ一つ何をしているのか解説していらっしゃるので、気になる方はそちらを参考にしてみてください。

参考|jqでjsonからCSVを生成する

このテクニックを使ってJSONからCSVに変換する際の注意点として、Jqコマンドの
「-rオプション」の理解は欠かせません。

合同会社タコスキングダム|蛸壺の技術ブログ
【jqコマンド実用編】押さえておきたいデータ入出力のためのjqのコマンドオプションまとめ

jqコマンドにおいて入出力値の制御で重要な役割をしているオプションのいくつかをピックアップし使い方をまとめます。

「-rオプション」に関しては以前の記事で取り上げたので、ここでは割愛します。

次にAwkコマンドのスクリプト内の
に関してですが、Jqコマンドの-rを使っても、Jqコマンドからの出力のうち、文字列は「"(ダブルクォーテーション)」で囲われた状態になっています。

            
            "hoge","piyo","fuga"
1,true,"ポヨ田 ムウ夫"
4,false,"ガビ川 ムゲ彦"
9,false,"ペケ山 ムモ子"
        
CSVファイルで保存する場合、「"」がいささか要らない場合が多いので、Awk内部で処理するならばgsub関数が手っ取り早いでしょう。

gsubさえも面倒であれば、Sedコマンドで根こそぎ削除するのもありです。

            
            $ echo "$JSON_TEXT" | jq -r '
    (.[0]|to_entries|map(.key)),(.[]|[.[]])|@csv
' | sed -r 's/"//g' | ...省略
        
で、だいたい話の趣旨としてここまでの内容で終わりなのですが、CSVファイルでの数字の表記法として、3桁ずつ「_(アンダースコア)」のような文字で区切ってパッとみて桁数が分かるようにしたい場合があります。

            
            2934 --> 2_934
42755903478709405 --> 42_755_903_478_709_405
        
アンダースコアに限らず、特定の区切り文字によって大きな桁数で変換しておきたい場合に、Awkスクリプト内に定義したのカスタム関数を参考にしてください。

特に数字の拡張表記が不要であれば、特に
で示した関数は省いてもらっても結構です。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】シェルスクリプトをこれから学びたい人のためのオススメ書籍&教材特集

まとめ

シェルスクリプトからJSONを扱うことに特化したJqコマンドと、CSVを細かく制御できるAwkコマンドを駆使することで、JSON⇔CSVの相互変換を自在に扱うための足がかり的な話をしてみました。

話のレベルとしては、JqコマンドもAwkコマンドも結構使い慣れていないと、すこし難しい内容に感じられてかも知れませんが、業務に積極的にシェルスクリプトを使ってみたい方はコツコツとこの2つのシェルコマンドのお勉強を継続してみましょう。