LoginSignup
47
49

詳細解説 jqコマンドとシェルスクリプトの正しい使い方と考え方 〜 データの流れを制するUNIX哲学流シェルプログラミング

Last updated at Posted at 2022-10-21

はじめに

シェルスクリプトから JSON データを処理する時に良く使われるのが jq コマンドです。しかしほとんどの人は jq コマンドとシェルスクリプトのつなぎ方を間違えています。jq コマンドの使い方が間違っているというより、シェルスクリプトの設計思想や考え方を正しく理解していないために、間違ったつなぎ方をしていると言った方がより正確でしょう。「シェルスクリプトは正しい書き方をすれば簡単になる」このことをこの記事では明らかにしています。

追記jqコマンドとシェルスクリプトの上手い速い使い方」に要約版を書きました。この記事は長すぎた…。

タイトルの「UNIX 哲学流」とは jq コマンドをフィルタして使い、JSON データを行指向のストリーミングデータとして扱うという意味です。jq コマンドは移植性が高く (jq is written in portable C, and it has zero runtime dependencies)、UNIX 哲学との相性も良く、JSON データを UNIX 哲学流に上手く変換できるように設計されている優れたコマンドです。jq コマンドは既存の UNIX コマンドが対応できない部分を補うためのツールです。正しく理解していれば jq コマンドは UNIX 哲学の考え方の良い手本となるコマンドであることがわかるはずですが、誤った理解のためにその真価を発揮できないでいます。

つなぎ方を間違えていることによる実害は大きく二つあります。一つはシェルスクリプトが複雑で読みにくいものとなることです。jq コマンドを何度も実行し、パイプでいくつものコマンドをつないだ可読性の低い文字列処理は、後でシェルスクリプトを読んだ時に何をやっているのかさっぱりわからなくなります。もう一つの問題は何度も jq コマンドを呼び出すことによる速度低下です。特にループの中で何度も jq コマンドを呼び出すと無視できないレベルの劇的な速度低下が起こってしまいます。これらの問題は jq コマンドが悪いのではなく、jq コマンドの使い方が悪いから起きていることです。

一つ先に言っておきましょう。この記事には頭を使いすぎる難解で奇妙な可読性の低いシェルスクリプトは出てきません。UNIX 哲学の「明確性の原則:巧妙になるより明確であれ」を実践しています。また jq 言語を駆使した難しいコードも登場しません。jq で使われている言語(以下 jq 言語)って難しいですよね? 言語としては素晴らしいのですが、多くの人にとって馴染みの薄いであろう関数型言語であり、JSON データを扱うために jq でしか使わない汎用性の低い言語なので、一度理解してもすぐに忘れてしまいます。私は UNIX 哲学流にやるにはどうすれば良いかは数年前からわかっていましたが、この記事を出せなかったのは jq 言語を使いこなせなかったからです。今でも使いこなせる気がしません。そういうわけで jq 言語の書き方に関してはもっと良い方法があるかもしれません。

この記事は jq コマンドとシェルスクリプトをつなげる具体的なコードを提示していますが、それ以上に UNIX 哲学流の考え方を理解してもらうのが真の目的です。そのために詳しい解説をしています。まあ大抵の人は飽きると思うので、シェルスクリプトや UNIX 哲学の基礎的な考え方ぐらい知っているよという方は「さまざまな課題とそれを解決する実装技術」へジャンプしてください。もっと直接的にさっさとコードの書き方を教えろという方は「最終コード(完成版)」や「発展: 階層構造や複雑なデータ構造を処理する」へジャンプしてください。話を引っ張りたいわけではありません。ネタバラシをすると要点は JSON から @tsv を使って TSV データに変換するだけです。

補足 この記事のコードには一切の権利を主張しません。ご自由に(自己責任で)ご使用ください。

注意 いつもどおりこの記事の趣旨を理解してないような人が見受けられるのでこうやって注意書きを追記しなければなりませんが、まずシェルスクリプトを理解せずに Python や他の自分の知ってる言語に逃げるのはやめましょう。私はすべてをシェルスクリプトで書けとは言っていません。そのことはこの記事でも書いています。この記事はシェルスクリプトの使い方の考え方を伝えているだけです。シェルスクリプト流のやり方ではなく、他の言語のやり方でシェルスクリプトを使っておいて、シェルスクリプトは使いづらいというのは筋違いですし「自分の知ってるやり方じゃない」から「可読性が低い」という考えは短絡的です。シェルスクリプトは他の言語と設計思想が異なる言語で考え方も全く違います。技術者として一つの考え方しか知らないというのははっきり言って未熟です。自分の知ってる言語だけを使おうとせず、他の言語(正確には他の思想の言語)も勉強しましょう。

お前らの jq コマンドの使い方は間違っている!

何度も jq コマンドを呼びださずに一回で終わらせろ

まず、最初によく見かける間違った jq コマンドの使い方を見てみましょう。それは次のようなものです。

json='{ "items": [{ "id": 1, "value": "v1" }, { "id": 2, "value": "v2" }] }'

# echo "$json" | jq -c '.items[]' の出力
#   {"id":1,"value":"v1"}
#   {"id":2,"value":"v2"}

for item in $(echo "$json" | jq -c '.items[]'); do
  id=$(echo "$item" | jq -r '.id')
  value=$(echo "$item" | jq -r '.value')
  echo "$id: $value"
done

出力

1: v1
2: v2

最初に jq コマンドで items 配列の中身を JSON 文字列として取得し、それを一つ一つループの中で、再度 jq コマンドを使って取り出しています。この程度であればまだ読めるのですが、取得するキーや値が多くなったり、より深い階層のデータを取得しようとするとコードは冗長で読みづらいものとなります。

またこの処理はとても遅く items の数を増やして実行時間を計測した所、わずか 100 件(ループの実行回数が 100 回)で 7 秒程度の時間がかかってしまいました。単純計算で 1000 件で 1 分、1 万件で 10 分もかかってしまいます。

別のパターンでは、最初にループする回数を jq コマンドで取得し、同様にループの中で値を取得するようなものも見かけましたが、jq 命令の書き方が少々異なるだけで基本的には同じです。jq コマンドに限らずループの中で何度も外部コマンドを呼び出す事はアンチパターンです。

jq は変換フィルタであり値を取り出すコマンドではない

jq とは JSON データを変換するフィルタコマンドです。入力したデータに「抽出」「置換」「削除」「挿入」といったいくつかの処理を加えて、新たなデータを構築して出力するコマンドです。歴史的な UNIX コマンドで言えば awksed と言ったコマンドが同様の機能を提供しています。これらの UNIX コマンドも入力したデータに「抽出」「置換」「削除」「挿入」と言ったいくつかの処理を加えて出力するコマンドです。違いは jq の入力データがシンプルなテキスト行ではなく JSON データであるということだけです。

jq の間違った使い方の発想は「データを変換するフィルタコマンド」ではなく「特定のキーから値を取得するコマンド」として使っています。例えば value=$(cat data.json | jq -r '.items[1].value') のように指定したキーの値を変数に入れたり、時として JSON データの部分的な断片を変数に入れています。これは他の言語でよく見られる「データを連想配列と配列からなる複雑な階層データ構造から値を取得する」という発想です。

他の言語であれば、JSON データを最初にパースし、ほぼ同じ構造のまま、その言語が扱える複雑なデータ構造で、メモリ上の変数に保持しています。これなら特定のキーから値を取得するという方法でも問題ありません。しかし複雑なデータ構造を扱うことが出来ないシェルスクリプトでは同じ方法を取ることは出来ません。したがって、多くの人は JSON 文字列でメモリ上の変数に保持しており、何度も jq コマンドを使って JSON データをパースしして値を取り出さなければならなくなっています。

これは jq コマンドの本来のフィルタコマンドの使い方から見ると間違った使い方です。フィルタコマンドなのですから、シェルスクリプトで扱いづらい JSON 形式から、扱いやすい行指向のストリーミングデータ形式に変換するというのが正しい使い方です。それも一回の jq コマンドの呼び出しでです。そうすればパフォーマンスも上がりますし、シェルスクリプトや他の UNIX コマンドにパイプで渡して処理することもできるようになります。

行指向のストリーミング処理のために TSV 形式を使え

jq コマンドは一般的には JSON データから JSON データへの変換フィルタコマンドと考えられており、入力も出力も JSON 形式です。しかしこれは飽くまでデフォルトの動作であり、別の形式で出力することが出来ます。(入力にも別の形式が使えるようですが、詳しく調べていません)

jq は UNIX 哲学に従っており、さまざまコマンドと組み合わせて使えるように設計されています。さまざまなコマンドと組み合わせて使えるように JSON 形式だけではなく HTML/XML 形式や CSV 形式での出力に対応しています。

JSON 形式の入力に対応したコマンドであれば、JSON 形式のまま、そのコマンドにデータを渡すことが出来ます。しかしシェルスクリプトや UNIX コマンドと連携するためには行指向のストリーミングデータで出力する必要があります。これには TSV 形式が一番適しています。JSON データを jq コマンドで TSV 形式に変換することでシェルスクリプトから容易にデータ処理することが可能になります。

echo $json | jq ... にはあと二つの罠が潜んでいる

この話は jq コマンドの話とは直接関係ないのですが、jq コマンドを使っているコードでこのような間違いをよく見かけるので、そのことについての説明です。

一つ目の罠です。以下のコードの実行結果を御覧ください。

items=$(cat <<'HERE' | jq -c '.items[]'
{
    "items": [
        { "value": "FOO * BAR" }
    ]
}
HERE
)
# items の中身 : {"value":"FOO * BAR"}

echo $items | jq -r ".value" # => FOO test.sh data.json out.txt BAR

最後の出力結果「FOO test.sh data.json out.txt BAR」とは一体何が出力されているのでしょうか?これはカレントディレクトリにあるファイルの一覧です。ls -al * の場合と同じように items 変数の中に含まれている * がファイル名にグロブ展開されたのです。

この手のミスは多すぎます。シェルスクリプトに詳しくないのであれば ShellCheck を必ず使ってください。変数は(ほぼ)必ずダブルクォートで括る必要があります。ShellCheck を使えばこのような些細なミスを指摘してくれます。

# 間違い
# echo $items | jq -r ".value"

# 正しい
echo "$items" | jq -r ".value"

二つ目の罠です。以下の JSON データは JSON 形式の仕様通りのエスケープを行っている正しいデータであり、実行結果は希望通りの動作をしています。

$ json='{ "value": "FOO\nBAR" }'
$ echo "$json" | jq ".value"
"FOO\nBAR"

しかし、上記のコードを Debian / Ubuntu の /bin/sh (dash) で実行すると以下のようにエラーになってしまいます。

$ json='{ "value": "FOO\nBAR" }'
$ echo "$json" | jq ".value"
parse error: Invalid string: control characters from
U+0000 through U+001F must be escaped at line 2, column 4

この理由は、echo コマンドはシェルによってエスケープシーケンスを解釈するかどうかが異なっているからです。

$ echo 'FOO\nBAR' # bash はエスケープシーケンスを解釈しない
FOO\nBAR

$ echo 'FOO\nBAR' # dash はエスケープシーケンスを解釈する
FOO
BAR

これはバグではありません。歴史的に echo コマンドはオプション (-n-e など)や引数(文字列)にバックスラッシュが含まれる時の解釈がシェルによって異なり、POSIX シェルの標準規格でも、動作が異なる(異なっていても良い)ことが明記されており、いずれの動作でも POSIX 準拠の要件を満たします。

この話は jq に限りません。出力する文字列にエスケープシーケンスが含まれる可能性がある場合、echo コマンドを使うと移植性の問題が発生してしまいます。もちろんシェルを限定すればよい(シバンに #!/bin/sh ではなく #!/bin/bash など、具体的なシェルを指定する) のですが、複数のシェルで動かすことを想定する場合は printf コマンドを使用するようにしてください。

$ printf '%s\n' 'FOO\nBAR' # jq にわたす場合は %s\n の末尾の \n は無くても良い
FOO\nBAR

$ json='{ "value": "FOO\nBAR" }'
$ printf '%s' "$json" | jq ".value" # dash でも期待通りに動く
"FOO\nBAR"

$ jq ".value" <<< $json # bash など一部のシェルではこのような書き方もできる

データの流れを制せよ!(UNIX 哲学流の考え方とは)

UNIX 哲学流の行指向のストリーミングデータとは?

ストリーミングデータとは流しそうめんのようなものです。上流からデータ(そうめん)が流れてきて、それを取り出して処理します。もし目の前のそうめんを掴みそこなったら、データは下流へと流れていき、そのデータを取り出すことは出来ません。データの流れは上流から下流へと一方向です。

ストリーミングデータはただデータが流れてくるだけですが、UNIX 哲学流となるとそこにさらに行指向という性質が加わります。シェルスクリプトおよび標準的な UNIX コマンドはデータが行指向であることを前提としています。行指向というのは一行に関連するデータが含まれているということです。これは言い換えるとデータの中に改行文字をそのまま含めることが出来ないことを意味しており、データとして改行文字を扱う場合には何らかのエスケープが必要であることを意味しています。ただし、困ったことにシェルスクリプトおよび標準的な UNIX コマンドでデータとして改行文字を扱うための行指向の標準形式はありません。

行指向に思えて、そうでないデータが CSV です。CSV 形式は元々標準規格というものはなく、2005 年 10月 というかなり後の時代に RFC 4180日本語訳)という標準規格が策定されました。これは現実の実装を元に策定されており、Excel が対応している CSV 形式の仕様が強く反映されています。CSV が最初に用いられたのはどうやら Fortran 関係のようです。その時にはデータの中にカンマや改行は含まれないという前提で、使用できない文字があるものの行指向のデータでした。元々 Fortran は科学技術計算用の言語であり、文字列を(うまく)扱えない言語であったため、数値のみの CSV で必要十分でした。しかし、それが表計算ソフト(Excel ではない)が扱える基本的な形式として利用され、次第に使用できない文字が含まれるのが不便だということで、RFC 4180 で定義されるような、データの中に改行文字が含まれる行指向ではない形式となったと思われます。

# CSV は単純な値だけなら行指向なのだが、改行や特殊な文字が
# 含まれる場合はそうではない。以下は論理的に「2 行」の CSV データ
100,200,300
foo,"bar
2","baz 1"

シェルスクリプトや 歴史的な UNIX コマンドが扱える行指向のデータは Fortlan 時代の初期の CSV と同じく、データの中に改行や特殊文字が入っていないという古い時代の前提に基づいています。時代的に仕方ないことですが、特殊文字がデータの中に平気で含まれるという現在のデータに十分に対応することが出来ていません。したがって JSON のような行指向ではないデータをシェルスクリプトで扱うには、何かしらのエスケープ処理を行い行指向なストリーミングデータに変換する必要があります。

余談ですが CSV は一部の国にとっては扱いづらいデータ形式です。というのは、一部の国は小数点の記号として . (ドット)ではなく , (カンマ)を使うからです。123,456 はカンマ区切りの 12 万ちょっとの値ではなく、123 ちょっとの値です。また日本では 3 桁ごとにカンマを入れて読みやすく(?)しますが、国によっては最初だけ 3 桁、以降は 2 桁ごとにカンマを入れたり桁区切りにスペースやアポストロフィを使ったりと様々です。カンマは国依存の記号であり、国外でも使われることを想定しているツールで、日本のカンマ表記にしか対応してないようでは使い物になりません。ツールの国際化(多言語化)というのは大変な作業です。

jq は「一つのことをうまくやる」フィルタコマンド

UNIX 哲学には個々のプログラムは「一つのことをしっかりやるようにせよ」という考え方があります。これは現代的な「新しい UNIX 哲学」的に言うと「与えられた役割をしっかりこなすようにせよ」という意味です。jq に与えられた役割とは JSON データの変換です。

古い UNIX 哲学の「一つのことをしっかりやるようにせよ」というのはとても不明瞭な言葉です。この言葉の本当の意味は「抽出」や「置換」といった一つの操作のことではありません。なぜなら一つの操作が正しいと仮定すると、ほとんどの UNIX コマンドは「一つのことをやっていない」コマンドということになってしまうからです。

例えば sed コマンドは、文字列の置換コマンドだと思いこんでいる人が多いのではないかと思いますが、それはあくまで sed コマンドが持っている s/.../.../ コマンドという一つの操作に過ぎません。sed コマンドの「一つのこと」とは実際にはテキストエディタ機能であり「置換」だけではなく「抽出」「削除」「挿入」などの操作を行うコマンドです。具体的に見てみましょう。

$ cat data.txt
foo
bar
baz

$ sed '
    # 1 行目を削除する
    1d

    # b で始まる行の a を A に置換する
    /^b/s/a/A/

    # 3行目の前に @@@ を挿入する
    3i\
@@@
' < data.txt
bAr
@@@
bAz

何だこの sed の書き方は?と思った人もいるんじゃないかと思うのですが、実は sed はある意味プログラミング言語です。複数行で書けますし、コメントを書くことも出来ます。ここには書いていませんが変数(ホールドスペース)やラベルやループもあります。上記の sed コマンドは、1 行目を「削除」し、b で始まる行を「抽出」し、a を A に「置換」し、最後の行の行頭に @@@ を「挿入」しています。ほらね? sed コマンドは多くのことをしているコマンドじゃないですか。

他にも tr コマンドは文字を「変換」するコマンドかと思いきや -d で文字を「削除」できますし、test コマンドは「数値の比較」「文字列の比較」「ファイルの存在確認」など、別のコマンドに分離できたはずの複数の操作が一つのコマンドにまとめられています。jq コマンドに最も近いものは awk コマンドでしょう。入力データが JSON データなのかテキスト行なのかの違いだけで、どちらもプログラミング言語を使ってデータを加工するという点でほとんど同じ事をしています。

まあね、つまりね、「一つのことをしっかりやるようにせよ」という言葉は「一つの操作をせよ」という意味では最初からなかったんです。言葉だけしか見ておらず本質を読み取れない人たちのただの思いこみです。もちろんコマンドを一つの操作にまで分けるという考え方もありでしょう。しかし分けてしまうことで出来なくなる(非効率になる)事もあります。awk や jq がやっていることがそれです。どちらが正しいかではなくユースケースの違いで両方とも必要なのです。単機能のコマンドがあれば、多機能なコマンドがいらなくなるほど世の中は単純ではありません。コマンドを単機能にした結果、使う方が組み合わせるのに苦労するのでは本末転倒です。

jq コマンドは JSON データの変換フィルタという「一つのことをしっかりやっている」コマンドです。「一つのこと」は不明瞭な言葉なので訂正しましょう。「新しい UNIX 哲学」の定義の通り jq コマンドは変換フィルタという「与えられた役割をしっかりこなしている」コマンドです。

jq コマンドを使い行指向のデータに正規化する

jq コマンドの正しい使い方とは UNIX 哲学の流儀に合わせるためのフィルタとして使うということです。具体的に言えば以下のように jq コマンドを使って TSV 形式に変換せよということです。

{
    "items": [
        {
            "name": "apple",
            "price": 110,
            "count": 3
        },
        {
            "name": "orange",
            "price": 120,
            "count": 2
        },
        {
            "name": "banana",
            "price": 100,
            "count": 4
        }
    ]
}

# ↓ jq コマンドを使って、JSON 形式を TSV 形式に変換する

apple  110 3
orange 120 2
banana 100 4

考え方そのものは TSV 形式である必要はないのですが、jq コマンドが対応している形式の中では TSV 形式がもっともシェルスクリプトから扱いやすい形式です。ここではわかりやすく TSV 形式と書いていますが、フィールド数が固定の二次元の表という意味ではなく、行毎にフィールド数や意味が変わってもよい、一行の複数の値がタブで区切られているというだけのデータ形式です。

シェルスクリプトが扱えるデータというのは行指向のストリーミングデータです。行指向というのは一行が一つのデータになっている形式です。この時、一行に関連するデータ(属性)が詰め込まれていてもかまいませんし、詰め込まれていた方がデータとして扱いやすくなります。

ストリーミングデータというのはデータの流れが一直線で、受け取ったデータを受け取った順番に処理することが前提となっているデータです。シェルスクリプトおよび、多くの UNIX コマンドはパイプの性質からも明らかなようにストリーミングデータを扱うように設計されており、そうでないデータを扱うのは少し大変です。

jq コマンドを使って TSV に変換すると書きましたが、ここで重要なのは「JSON データを前から順に TSV 形式に変換する」のではなく「前から処理できる順番にデータを変換する」ということです。言い換えると「ストリーミング処理で変換する」のではなく「ストリーミング処理できる形に変換する」ということです。よりわかりやすく言えば「データを使う順番に並び替えましょう」ということです。なぜこの事にこだわっているのかは次項を読めばわかります。

JSON データは本質的にストリーミング処理が不可能

JSON データというのは階層構造のデータとなっており、行指向のデータを扱うのが基本のシェルスクリプトや UNIX コマンドではでうまく扱うことが出来ません。さらに重要なことに JSON データはストリーミング処理を行うことが出来ません。例えば以下の JSON データは「ストリーミング処理が可能であるかのように思える」データです。

{
    "name": "両津勘吉"
    "address": "東京都葛飾区亀有公園前派出所",
    "items": [
        { "name": "リンゴ",   "price": 110, "count": 3 },
        { "name": "オレンジ", "price": 120, "count": 2 },
        { "name": "バナナ",   "price": 100, "count": 4 }
    ]
}

# ↓ 以下のように出力したいとする(これは可能)

名前: 両津勘吉 様
住所: 東京都葛飾区亀有公園前派出所

** 購入商品一覧 **

| 品名     | 値段   | 個数 |
| -------- | ------ | ---- |
| リンゴ   | 110 円 | 3 個 |
| オレンジ | 120 円 | 2 個 |
| バナナ   | 100 円 | 4 個 |

しかし、ここでの問題は「JSON データはキーがどの順番で出力されるかが不定」だということです。もしアルファベット順にキーが出力されたとしたらどうなるでしょうか?

{
    "address": "東京都葛飾区亀有公園前派出所",
    "items": [
        { "name": "リンゴ",   "price": 110, "count": 3 },
        { "name": "オレンジ", "price": 120, "count": 2 },
        { "name": "バナナ",   "price": 100, "count": 4 }
    ],
    "name": "両津勘吉"
}

# ↓ ストリーミングで(前から)データを処理すると以下のようになる

住所: 東京都葛飾区亀有公園前派出所

** 購入商品一覧 **

| 品名     | 値段   | 個数 |
| -------- | ------ | ---- |
| リンゴ   | 110 円 | 3 個 |
| オレンジ | 120 円 | 2 個 |
| バナナ   | 100 円 | 4 個 |

名前: 両津勘吉 様

JSON データの場合、キーの並び順は意味を持ちません。キー名でソートされているかもしれませんし、されていないかもしれません。JSON データの生成プログラムのアップデートで変わってしまうかもしれません。キーの出現順が不定であるため多くの人は以下のようなコードを書いて出力順を制御しています。

json='{ ... }'
name=$(printf '%s' "$json" | jq -r .name)
address=$(printf '%s' "$json" | jq -r .address)

echo "$name"
echo "$address"
for item in $(echo "$json" | jq -c '.items[]'); do
  name=$(printf '%s' "$item" | jq -r .name)
  price=$(printf '%s' "$item" | jq -r .price)
  count=$(printf '%s' "$item" | jq -r .count)
  echo "$name $price $count"
done

何度も jq コマンドを呼び出して JSON 文字列のパースしているのはこれが原因です。しかし本来このような処理は、jq コマンドをフィルタとして使って「データの並び替え」を行えばよいのです。例えば jq '[ .name, .address, .items ]' のように書くだけで、指定した順番にデータを並び替えることが出来ます。データが必要な順番に並んでいれば、あとはそれを前から単純に処理していくだけです。

jq コマンドを使うとデータを並び替えることは出来ますが、もう一つ本質的な問題が残っています。それは JSON データはすべてのデータを受け取らなければ処理を開始することができないということです。例えば上記の例では、運良く先に name が登場したならば長い items リストを出力する前に、name を出力することが可能ですが、運悪く name がデータの最後にある場合は、すべての items のデータを受け取ってからでなければ name を出力することが出来ません。つまり JSON データはデータが完全に揃ってからでなければ、ストリーミングデータとして前から処理することが出来ないわけです。これは JSON データが採用しているデータ構造が本質的に抱えている問題であり、どのようなプログラムを作ったとしても解決することは出来ません。(補足 JSON Lines はこの問題を限定的に解決した形式です)

かっこいい言い方をすると、階層データ構造の JSON データと行指向のストリーミングデータには、データ構造のインピーダンスミスマッチがあるということです。似たような話としてオブジェクト指向(階層構造)とリレーショナルデータベースの間にもこのような構造の違いがあり、O/R マッパーなどの仕組みでその問題を解決しています。jq コマンドというのは、そのデータ構造のインピーダンスミスマッチを解決するシェルスクリプトのための優れた道具(フィルタ)というわけです。

gron にはデータを並び替える機能が欠けている

jq とは異なる発想で JSON データを扱うためのコマンドに gron というものがあります。About に「Make JSON greppable!」と書いてあるように、JSON データを grep 可能な形に変換するコマンドです。一見良さそうに思えるかもしれませんが、正直言って私は JSON データを grep コマンドで「抽出」したいとは思わないんですよね。「検索」はまあ良いと思います。したいと思わないのは「抽出」です。

gron に先程の JSON データを通すと以下のような出力が得られます。確かに grep するには適していますが、データを抽出して処理を行うには使いづらい出力です。

json = {};
json.address = "東京都葛飾区亀有公園前派出所";
json.items = [];
json.items[0] = {};
json.items[0].count = 3;
json.items[0].name = "りんご";
json.items[0].price = 110;
json.items[1] = {};
json.items[1].count = 2;
json.items[1].name = "オレンジ";
json.items[1].price = 120;
json.items[2] = {};
json.items[2].count = 4;
json.items[2].name = "バナナ";
json.items[2].price = 100;
json.name = "両津勘吉";

なぜ上記の出力が使いづらいのかと言うと、このデータから以下のような出力を行うにはどうすれば良いかを考えればわかるでしょう。

名前: 両津勘吉 様
住所: 東京都葛飾区亀有公園前派出所

** 購入商品一覧 **

| 品名     | 値段   | 個数 |
| -------- | ------ | ---- |
| リンゴ   | 110 円 | 3 個 |
| オレンジ | 120 円 | 2 個 |
| バナナ   | 100 円 | 4 個 |

gron の出力形式の問題の一つは、データが以下のような形式ではないことです。

リンゴ   110 3
オレンジ 120 2
バナナ   100 4

gron の出力形式は、TSV 形式のような一行に複数の値(属性)が詰め込まれたデータではなく、一つの値が一行になっています。したがって gron の出力をパースして「一行のデータ」に相当するデータを作らねばなりません。もっと複雑な形式の場合はもっと複雑なパース処理が必要になるでしょう。gron によって JSON 形式のパースは不要になったかもしれませんが、代わりに gron の出力形式をパースしなければならないわけで、手間は対して減っていません。まあ JSON 形式をパースするよりかは簡単だと思いますが。

gron のもう一つの問題点はデータを使用する順番に並べ替える機能がないということなのです。gron の出力は JSON のキーの出現順であるため、最後にでてくる name を最初に出力したいと思った時、gron ではそれが出来ません。sort コマンドを使えば出来るのではないか?と思うかもしれませんが、sort コマンドはなにかの大小の順番で並べるコマンドであるため、そう単純には行きません。もし無理やり sort コマンドでやろうとするならば、データを使用する順番に、どうにかして「順番キー」を追加しなければならなくなります。やっぱり手間ですよね。

結局 gron を使ったとしても、データの出現順が不定であるために、何度も grepなどのコマンドを呼び出して必要な順番に一つ一つデータを抽出していかなければならないわけです。

つまり jq は JSON データをデータとして処理しやすい形に変換し、さらに並び替えまで行うことができるフィルタですが、gron は JSON データをキーと値のリストに変換するだけのフィルタだと言うことです。したがって gron ではその「キーと値のリストから処理しやすいデータに加工するプログラム」を別に書く必要があります。正直言ってあまり使い勝手のいいものではありません。

それでは gron は一体どういう時に使えばいいのでしょうか?その答えは README.md に書かれています。

gron's primary purpose is to make it easy to find the path to a value in a deeply nested JSON blob when you don't already know the structure; much of jq's power is unlocked only once you know that structure.

つまり構造がよくわかっていない JSON データの構造がどんな感じになっているかな?と調べる時に使うのです。確かに構造を調べるだけなら便利だと思います。そして書いてあるように JSON データの構造を知っており特定の値からデータを生成する場合(それが一般的でしょう?)には、jq の方が遥かに適しています。

gron はただ単純に grep したいときには便利だと思います、しかし実はシェルスクリプトから使う場合には jq コマンドがあれば十分です。なぜなら jq コマンドの --stream オプションでほぼ同じ事が簡単に出来てしまうからです。詳細は「番外編: キーと値のペアをストリーミングで出力する」を参照してください。

さまざまな課題とそれを解決する実装技術

シェルスクリプトと jq を組み合わせる基本的な考え方はここまでで説明しました。しかしそれで問題が解決したかと言えばそんな事はありません。実装上の問題がいくつかあります。

基本コード(いくつかの問題あり)

ここまでずっと説明ばかりだったので、それで実際どのようなコードを書けばいいのだ?説明が長いから、コードは複雑なんじゃないのか?と思うかもしれません。実際のコードは簡単です。お見せしましょう。(以下のコードは、全体の基本イメージを掴んでもらうためのものであり、いくつかの問題が残っているので注意してください。ちなみに POSIX シェル対応であり bash 依存はありません)

{
    "address": "東京都葛飾区亀有公園前派出所",
    "items": [
        { "name": "りんご",   "price": 110, "count": 3 },
        { "name": "オレンジ", "price": 120, "count": 2 },
        { "name": "バナナ",   "price": 100, "count": 4 }
    ],
    "name": "両津勘吉"
}

補足: jq コマンドで以下のような TSV 形式に変換している

両津勘吉	東京都葛飾区亀有公園前派出所
りんご	110	3
オレンジ	120	2
バナナ	1000	14
#!/bin/sh

TAB=$(printf "\t")

# Markdown テーブル形式に整形する関数(面倒なことをするんじゃなかった…)
md_table() {
  sed "s/|/$TAB|$TAB/g" | column -t -s "$TAB" \
    | sed 's/ |/|/g; s/| /|/g; 2{y/ /-/; s/|-/| /g; s/-|/ |/g;}'
}

jq -r '
    [.name, .address],
    (.items[] | [.name, .price, .count])
    | @tsv
' data.json | {
  read IFS="$TAB" -r name address
  echo "名前: $name"
  echo "住所: $address"
  echo
  echo "**購入商品一覧**"
  echo
  {
    echo "|品名|値段|個数|"
    echo "|----|----|----|"
    while IFS="$TAB" read -r name price count; do
      printf "|%s|%5s 円|%3s 個|\n" "$name" "$price" "$count"
    done
  } | md_table
}

上記のコードでシェルスクリプトに慣れていない人が悩む(または逆に気づかずにスルーしてしまう)ポイントはおそらく以下の部分でしょう。

jq ... data.json | {
    read IFS="$TAB" -r name address
    ...
    while IFS="$TAB" read -r name price count; do
        ...
    done
}

jq コマンドの出力をパイプで渡す所は普通ですが、別のコマンドへのパイプではなく { ... } グループへのパイプとなっています。そしてグループ内部で二種類の read コマンドを使用しています。このようにする理由は明白だと思います。jq コマンドが出力するデータが、単純な二次元の表ではなく、一行目と二行目以降で意味が異なっているからです。一行目を読み込んでから、二行目以降を処理する。ロジックとしては当たり前の処理ですが、このように { ... } でグループ化してから read コマンドを使わなければうまくいきません。考え方としては jq コマンドの出力を { ... } グループ内にパイプで渡しているということです。

ここで使用している read コマンドはシェルのビルトインコマンドですが、実は外部コマンドを使って一行読み込もうとしても、おそらくうまくいきません。なぜなら(一般的に)外部コマンドはストリーミングデータを読みすぎてしまうからです。その理由はパフォーマンスのためで、一バイトずつ読み込むのではなくある程度のサイズを一度に読み込んでいます。ストリーミングデータは一旦読んでしまったら、目の前を通り過ぎてしまった流しそうめんのそうめんのように、後から取り戻すことは出来ません。例えば以下のコマンドはおそらく一行しか出力されないはずです。これは head コマンドが「ストリーミングデータを読み込みすぎてしまう」ので cat が入出力するデータが無くなってしまうからです。

# head -n は 1 行だけを出力するが、1 行だけを読み込んでいるのではない
$ seq 5 | { head -n 1; cat; }
1

# 上記と同等(少し異なる書き方)
$ seq 5 | { line=$(head -n 1); echo "[$line]"; cat; }
[1]


# read なら問題ない
$ seq 5 | { read line; echo "[$line]"; cat; }
[1]
2
3
4
5

# read ならこのように書いても問題ない
$ seq 5 | { line=$(read line; echo "$line"); echo "[$line]"; cat; }
同上

これ長々とした説明がいるようなものではなく、単にこのように書けばいいんだよというだけの話だと思うのですが、意外と使っている例を見かけない書き方であり、ちゃんと説明している所もあまりありません。この書き方を知らなければ、データを二度読み込むために変数や一時ファイルを使ったり、tee コマンドとプロセス置換を使ってデータを複製するというような、よりわかりにくくパフォーマンスも悪い回避策を編み出してしまいかねません。今回の例のようにパターンの異なるデータの読み込みが複数必要な場合のシェルスクリプトの書き方を知っておくことは重要です。

md_table 関数はこの記事の話の本質とは関係ないので気にしないでください。単にこれまでの解説の出力例で、日本語文字が含まれるデータを Markdown のテーブル形式に整形してしまったので、それを実装する必要が出てきてしまっただけです。Unicode は同じ 1 文字であっても、全角幅、半角幅、曖昧幅と幅が異なり、日本語が 2 バイトとは限らないので、単純な計算では位置調整できないんですよね……。仕方ないので columnsed を使って半ば無理やり実装しています。ただ、ここでも { ... } グループを使い、その範囲の出力をまとめて md_table 関数にわたすというサンプルになったので、これはこれで良しとします。

jq からの出力に TSV 形式を使う理由

TSV 形式を使っている理由は、シェルスクリプトからそれを扱うのが簡単だからなのですが、より具体的に言うと jq が出力する TSV 形式は値に含まれるタブや改行をシェルスクリプトにとって都合の良い形でエスケープし、必ず一行のデータとして出力されるからです。

JSON というのは値に改行文字を入れることが出来ます。しかしシェルスクリプトは一行ごとに読み取るため、改行文字がエスケープされずにそのまま出力されてしまうと、うまく扱うことが出来ません。以下の例では一つの値であるにも関わらず、シェルスクリプトから単純に読み込むと、foo と bar の二つのデータとして認識されてしまいます。

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r .value
foo
bar

# TSV 形式なら一行になる
$ printf '%s' '{ "value": "foo\nbar" }' | jq -r '[.value] | @tsv'
foo\nbar

シェルスクリプトからうまく扱うためには、何かしらのエスケープを行って改行が値に含まれていても一行で出力するようにする必要があります。jq 1.6 は @text, @json, @html, @uri, @csv, @tsv, @sh, @base64, @base64d といった出力用のフィルタを持っています。最後の @base64d は base64 データをデコードするためのものなので省きます。残りのフィルタのうちデフォルトの @text, @html, @csv, @sh は改行をそのまま出力するため使うことが出来ません。

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r ".value | @text"
foo
bar

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r ".value | @html"
foo
bar

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r "[.value] | @csv"
"foo
bar"

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r "[.value] | @sh"
'foo
bar'

残った @json, @uri, @tsv, @base64 のみが改行が含まれている値を一行で出力することが出来ます。

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r ".value | @json"
"foo\nbar"

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r ".value | @uri"
foo%0Abar

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r "[.value] | @tsv"
foo\nbar

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r "[.value] | @base64"
WyJmb29cbmJhciJd

そして上記の中でシェルスクリプトからもっとも簡単に扱うことが出来る形式は @tsv です。@tsv が行うエスケープ文字は \n, \r, \t, \\ だけなのでシェルスクリプトの printf コマンドを使うだけで簡単にアンエスケープすることが出来ます。その他の形式は面倒なアンエスケープの処理を書かなければなりません。

なお、TSV 形式には特殊文字の扱いについての仕様はありません。IANA の定義では値にタブ文字を含めることが出来ない程度のことしか書かれておらずエスケープの仕様はありません。Linear TSV にはエスケープの仕様が書かれていますが、これはそれほど権威がある標準規格ではなさそうですし、おそらくこれは「Linear TSV」という TSV ベースの拡張形式です。Linear TSV は NULL 値(空文字)に \N を使用することになっており、このような仕様はデータベース関連の TSV で使われている例があるものの、TSV 形式としては一般的ではないと私は考えています。

値を取得するだけならシェルエスケープでよい

jq が持っているフィルタに @sh というものがあります。POSIX シェルの入力に適した形式に変換するフィルタであるため、これを使うのが良いのではないのか?と思うかもしれませんが、なかなかに使いづらい形式です。なぜなら改行文字を改行文字としてそのまま出力するからです。

$ printf '%s' '{ "value": "foo\nbar" }' | jq -r "[.value] | @sh"
'foo
bar'

ちゃんとパースすれば、どこまでが一行のデータであるかは判別可能ですが、例えば以下のような「一つの値」を表現することも可能であるため、これを正しく扱うのは大変です。

VAR='foo
A=abc  # 変数への代入形式のように見える
       # 空行を入れることもできる

"      # ダブルクォーテーションや
'\'"   # シングルクォーテーションだって入れることが出来る
bar"

ただし行指向のデータとして扱わないのであれば @sh を使うことが出来ます。これは一度の jq コマンドの呼び出しで、複数の値を一度に取得する時に使うことが出来ます。例えば以下のように eval を使います。

$ json='{ "v1": "foo bar", "v2": 123 }'
$ printf '%s' "$json" | jq -r '@sh "v1=\(.v1) v2=\(.v2)"'
v1='foo bar' v2=123

$ eval "$(printf '%s' "$json" | jq -r '@sh "v1=\(.v1) v2=\(.v2)"')"
$ echo "$v1, $v2"
foo bar, 123

少し変わった使い方として任意の関数を呼び出すことも出来ます。JSONP と似たような仕組みです。

$ json='{ "v1": "foo bar", "v2": 123 }'
$ printf '%s' "$json" | jq -r '@sh "echo \(.v1) \(.v2)"'
echo 'foo bar' 123

$ eval "$(printf '%s' "$json" | jq -r '@sh "echo \(.v1) \(.v2)"')"
foo bar 123

また、一部のデータを JSON 文字列として変数に入れることも可能です。

 json='{
    "v1": "foo bar",
    "v2": 123,
    "items": [
        { "id": 1, "value": "A"},
        { "id": 2, "value": "B"}
    ]
}'

$ eval "$(printf '%s' "$json" | jq -r '@sh "v1=\(.v1) items=\(.items | @json)"')"
$ printf '%s\n' "$items"
[{"id":1,"value":"A"},{"id":2,"value":"B"}]

$ printf '%s' "$items" | jq -r ".[] | [.id, .value] | @tsv"
1	A
2	B

# 元の JSON データから取得することも可能なのであまり必要ないかもしれない
$ printf '%s' "$json" | jq -r ".items[] | [.id, .value] | @tsv"
1	A
2	B

リスト形式になっているデータを処理するときには使えませんが、単純に JSON データから値を取得したいだけという場合には便利です。

NULL 値(空文字)を扱えない問題を解決する

ここまで単純に TSV 形式で出力すれば良いと書いていましたが、シェルスクリプトで TSV 形式を扱う場合、一つ大きな問題があります。それはフィールドの値が NULL 値(空文字)のデータを扱うことが出来ないという問題です。例えば以下のようなプログラムを実行します。(タブ区切りのデータはヒアドキュメントで与えています)

TAB=$(printf "\t")

while IFS="$TAB" read -r f1 f2 f3; do
    printf "F1:%-9s F2:%-9s F3:%-9s\n" "$f1" "$f2" "$f3"
done << HERE
foo${TAB}bar${TAB}baz
FOO${TAB}${TAB}BAZ
field 1${TAB}${TAB}${TAB}${TAB}field 5
HERE

この時出力は以下のようになります。2 行目と 3 行目に注目してください。

F1:foo       F2:bar       F3:baz
F1:FOO       F2:BAZ       F3:
F1:field 1   F2:field 5   F3:

シェルスクリプトでは複数の連続するタブ文字は一つであるかのように扱われます。したがってフィールドの値が NULL 値(空文字)の場合、次のフィールドの値にずれてしまうのです。タブ区切りのデータを read コマンドで単純に読み込んでいる限りこの問題を解決する方法はありません。

一応、一行全体をそのまま読み込んで(IFS= read -r line) 単語展開を用いてシェルスクリプトでタブ文字で区切っていくことで正しくパースするという解決方法はあります(以下参照)。しかしこの方法は特に文字列の長さが長くなった時のパフォーマンスが悪く、ループの中で処理するには適切ではありません。

TAB=$(printf "\t")
line="FOO${TAB}${TAB}BAZ"

line="${line}${TAB}"
while [ "$line" ]; do
  field="${line%%"${TAB}"*}" # 最初のタブ文字以下を削除する
  line="${line#*"${TAB}"}"   # 最初のタブ文字までを削除する
  echo "$field"
done

シェルスクリプトはどうしてこのような仕様になってしまったのか謎ですが、おそらく単語分割(元々はシェルスクリプトのソースコードに書かれた単語を解釈するルールのはず)をそのまま read コマンドの行の解釈に再利用してしまったからではないかと私は考えています。

この連続する複数のタブ文字を一つとして扱って単語を分割するというシェルスクリプトの言語の挙動は、タブ文字に加えスペースや改行でも起こります。逆にそれ以外の文字、カンマなどでは起こりません。じゃあ CSV 形式で出力すればよいのか?と思うかもしれませんが、すでに書いたように CSV では値の中に改行文字が含まれて論理的な一行が複数行で表現されるためシェルスクリプトで扱うのは困難です。

前述の Linear TSV では \N を NULL 値として利用するのでこの問題を回避することが出来ます。しかしシェルスクリプトの printf では当然サポートされていません。また \0\c をつかうというアイデアもあったのですが、zsh は \0 を変数に代入できますし、\c はそこで文字の出力をそこで終了する(printf '%b' 'AB\cCD' を実行すると意味がわかります)という移植性のあるエスケープシーケンスなのですが、やはり変数に文字が入ってしまう = 意味としては空文字なのに空文字ではないということになって混乱の元になるので却下しました。

jq の TSV 形式では CR 文字が \r にエスケープされ、CR 文字が使われることがないため、CR 区切りを使おうかと考えたのですが、bash などシェルによってはタブやスペースと同じように連続する CR を一つと解釈することが判明したので、悩んだ末 US 文字区切り(以下 USV 形式)を使うことにしました。

US とは ASCII コードの 制御文字#Field_separators の文字の一つです。最も小さな項目を区切る時の文字なので今回の用途にはぴったりだと言えるでしょう。ちなみにデータを区切るための文字には他にも以下のようなものがあります。

文字 OCT HEX 名前 意味
FS 034 0x1C File Separator ファイル分離
GS 035 0x1D Group Separator グループ分離
RS 036 0x1E Record Separator レコード分離
US 037 0x1F Unit Separator ユニット分離

これらの文字を使う行指向の標準規格があれば良かったのですが、残念ながらそのようなものはないと思います。余談ですが ASCII コードが規格化されたのは 1963 年で UNIX 誕生よりも前の時代です。元々はプリンタを操作したり磁気テープなどにメタ情報を提供するためのコードでした。

jq コマンドは USV 形式で直接出力することは出来ないので TSV 形式で出力し、それを USV 形式に変換する必要があります。これには sed コマンドを使うことにします。データの中に US 文字が含まれることは殆どないと思いますが、US → \037、TAB → US の変換を行います。したがってコードは以下のようになります。

TAB=$(printf '\t')
US="$(printf '\037')"
{
  sed "s/$US/\\\\037/g; s/$TAB/$US/g" | while IFS="$US" read -r f1 f2 f3 f4; do
    printf "F1:%-9s F2:%-9s F3:%-9s\n" "$f1" "$f2" "$f3"
  done
} <<HERE
foo${TAB}bar${TAB}baz
FOO${TAB}${TAB}BAZ
field 1${TAB}${TAB}${TAB}${TAB}field 2
HERE

TSV から USV への変換コードは可読性が悪いので後のコードでは関数にしています。

awk に渡す場合はタブ区切りのままで良い

前項の TAB を US 変換する必要があるのはシェルスクリプトで処理する場合の話です。awk はデフォルトでは連続する空白またはタブの繰り返し(正規表現で言えば [ \t]+)を区切り文字とするのでシェルスクリプトと同じ動作をするのですが、区切り文字を \t にすれば一つのタブ文字を区切り文字とすることが出来ます。

TAB=$(printf '\t')
awk -F '\t' '{printf "F1:%-9s F2:%-9s F3:%-9s\n",$1,$2,$3}' <<HERE
foo${TAB}bar${TAB}baz
FOO${TAB}${TAB}BAZ
field 1${TAB}${TAB}${TAB}${TAB}field 2
HERE

じゃあ全部 awk を使えばいいのでは?と思うかもしれません。今回の例のように入力データを整形してテキストとして出力するだけなら問題ありませんし、文字列処理は awk の方が速いでしょう。その場合「jq と awk の正しいつなげ方」という話に変わりますが、jq で行指向のストリーミングデータに並び替えるという話の本質に違いはありません。シェルスクリプトという言語で処理するか、awk という言語で処理するか、はたまた別の言語で処理するかは大した違いではありません。ただ JSON のデータを別のコマンドで処理したりなどと間で色々と処理したい場合にはシェルスクリプトが便利です。使う必要がないのであればわざわざ別言語 (awk) をつかう必要はないというのが私の考えです。

エスケープシーケンスをアンエスケープする

jq の TSV 形式から読み込んだデータは \t\n などがエスケープされています。これを本来の文字として「出力」するときは単に printf を使えばよいのですが、出力せずに変数に値として代入したいときもあります。おそらく最初にぱっと思いつく方法はコマンド置換を使う方法だと思います。

value=$(printf "${value}_") && value="${value%_}"

# または

value=$(printf '%b_' "$value") && value="${value%_}"

後ろの value="${value%_}" はなんだ?と思ったかもしれませんが、コマンド置換には末尾が一つ以上の改行で終わった場合、その改行が全て削除されてしまうという罠があります。そのため末尾にダミーの一文字を加えてから変数展開で削除するという回避策が必要になります。もう一つの問題点はコマンド置換は遅いということです。アンエスケープ処理をループの中で行うことを考えるとこれは無視できない問題になります。

bash、ksh93u+m、zsh であれば、もっと良い解決方法があります。それが printf コマンドの -v オプションを使う方法です。printf と言う割に出力するのではなく変数に代入するというのがややこしい所です。

printf -v value "$value"

# または

printf -v value '%b' "$value"

これらのシェルでは遅いコマンド置換を使う必要はなく、末尾の改行が削除されるという問題もなく、変数に直接文字列を代入することが出来ます。この方法が他のシェルにも普及して POSIX で標準化されればよいのですが、現状一部のシェルでしか使うことが出来ません。(ほぼすべてのシェルで printf はすでにシェルビルトインですし、実装も難しくなさそうだから標準化させられるかなぁ?)

コマンド置換は無視できないほど遅く printf -v は一部のシェルしか使えない。ということで、この問題を解決するために POSIX 準拠のシェルの機能だけを使ってアンエスケープ処理を実装しました。POSIX に厳密に準拠したい場合は以下の関数を使用すれば OK です。ただし printf とは異なり(パフォーマンスのために)必要最小限のアンエスケープしか実装してないので注意してください。パフォーマンスもそんなに良くはないので長い文字列ではコマンド置換を使った場合と速度が逆転することも考えられます。

eval "$(printf 'LF="\n" CR="\r" TAB="\t" US="\037"')"

unescape() {
    set -- "$1" "$2\\" ""
    while set -- "$1" "${2#*\\}" "${3}${2%%\\*}" && [ "$2" ]; do
        case $2 in
            "$US"*) set -- "$1" "${2#?}" "${3}${US}" ;;
            'n'*) set -- "$1" "${2#?}" "${3}${LF}" ;;
            'r'*) set -- "$1" "${2#?}" "${3}${CR}" ;;
            't'*) set -- "$1" "${2#?}" "${3}${TAB}" ;;
            '\'*) set -- "$1" "${2#?}" "${3}\\" ;;
            *) set -- "$1" "${2#?}" "${3}\\${2%"${2#?}"}" ;;
        esac
    done
    eval "$1=\$3"
}

value="AB\nCD\tEF\rGH\\\\IJ"
unescape value "$value"

awk でアンエスケープしたい場合は以下の関数を使用してください。

function unescape(s,  p, c, r) {
  s = s "\\"
  while (length(s)) {
    p = index(s, "\\")
    if (length(c = substr(s, p + 1, 1))) {
      c = (c == "n") ? c ="\n" : \
          (c == "r") ? c = "\r": \
          (c == "t") ? c = "\t" : \
          (c == "\\") ? c = "\\" : c = "\\" c
    }
    r = r substr(s, 1, p - 1) c
    s = substr(s, p + 2)
  }
  return r
}

ストリーミングデータと複数の値を両方取得する

ここまでで JSON データを処理する二つの手法を紹介しました。一つは @tsv を使ったストリーミングデータを処理する方法で、もう一つは @sh を使った複数の値を変数に代入する方法です。しかし場合によってはストリーミングデータと複数の値の両方を処理したい場合もあるでしょう。

実はすでにそれに近いことはやっています。それは以下のコードです。

jq ... data.json | {
    read IFS="$TAB" -r name address
    ...
    while IFS="$TAB" read -r name price count; do
        ...
    done
}

上記のコードは二つの read コマンドを使い nameaddress という複数の値を変数に代入し、そして items をストリーミングで処理しています。そのために jq で以下のように JSON データを TSV 形式に変換していました。(スペースは実際にはタブです)

{
    "address": "東京都葛飾区亀有公園前派出所",
    "items": [
        { "name": "りんご",   "price": 110, "count": 3 },
        { "name": "オレンジ", "price": 120, "count": 2 },
        { "name": "バナナ",   "price": 100, "count": 4 }
    ],
    "name": "両津勘吉"
}

# ↓ 上記の JSON データを以下のような TSV 形式に変換していた

両津勘吉 東京都葛飾区亀有公園前派出所
りんご 110 3
オレンジ 120 2
バナナ 100 4

ここで紹介する方法はこの発展形です。上記の方法の問題点は、1 行目(複数の値)と 2 行目以降(ストリーミングデータ)の区別がしづらいということです。これを使い改良して次のように変換するともっと使いやすくなります。

name: 両津勘吉
address: 東京都葛飾区亀有公園前派出所

りんご 110 3
オレンジ 120 2
バナナ 100 4

最初の空行までが「複数の値」、その値には名前(変数名)が付いています。そして空行の後がストリーミングデータです。この形式、どこかで見たことがないでしょうか? そうです。HTTP のヘッダとボディを参考にしています。nameaddress の末尾の : は人がデータを見た時に見やすくするための飾りなので、実は無くても構いません。この形式を使うことで TSV 形式での出力も読みやすくなり、またシェルスクリプトのコードもよりシンプルにすることが出来ます。具体的なコードは次項を参照してください。

最終コード(完成版)

すべての問題を解決した UNIX 哲学流の jq とシェルスクリプトのつなげ方は以下のようになります。どうです?可読性、メンテナンス性も高く、シンプルになったでしょう?正しい考え方を知っていれば、シェルスクリプトは複雑にはならないのです。そのためには行指向のストリーミングデータ、つまりデータの流れを意識することが重要です。

#!/bin/sh

set -eu

eval "$(printf 'LF="\n" CR="\r" TAB="\t" US="\037"')"

# Markdown テーブル形式に整形(本質的なコードではないので無視してください)
md_table() {
  sed "s/|/$TAB|$TAB/g" | column -t -s "$TAB" |
    sed 's/ |/|/g; s/| /|/g; 2{y/ /-/; s/|-/| /g; s/-|/ |/g;}'
}

# TSV 形式から USV 形式(US 文字区切り)への変換
tsv_to_usv() {
  sed "s/$US/\\\\037/g; s/$TAB/$US/g"
}

jq -r '
  ["name:", .name],
  ["address:", .address],
  [],
  (.items[] | [.name, .price, .count])
  | @tsv' data.json | tsv_to_usv |
{
  name='' address=''
  while IFS="$US" read -r key value && [ "${key%:}" ]; do
    printf -v "${key%:}" '%b' "$value"

    # POSIX シェル用: unescape の実装はこの記事で提示済み
    # unescape "${key%:}" "$value"
  done
  echo "名前: $name"
  echo "住所: $address"
  echo
  echo "**購入商品一覧**"
  echo
  {
    echo "|品名|値段|個数|"
    echo "|----|----|----|"
    while IFS="$US" read -r name price count; do
      printf "|%s|%5s 円|%3s 個|\n" "$name" "$price" "$count"
    done
  } | md_table
}

発展: 階層構造や複雑なデータ構造を処理する

ここまでは、単純なループ一つで処理できるような単純な JSON データでした。現実の JSON データはもっと複雑で、ネストしたループが必要となるような階層構造を持っていることはよくあります。あまりにも複雑なデータ構造をシェルスクリプトで扱うのはおすすめしませんが、このような JSON データであってもデータの流れに着目すればシェルスクリプトで扱うことは可能です。

階層構造も行指向のデータに変換できる

行指向のストリーミングデータはフラットな二次元のデータだけしか表現できないわけではありません。以下はここまでの例よりも複雑な階層構造を持った JSON データを処理する例です。

{
    "year": "2022",
    "users": [
        {
          "name": "木之本桜",
          "gender": "女",
          "tests": [
            { "name": "一学期", "scores": {
              "japanese": 74, "math": 52, "pe": 89 }},
            { "name": "二学期", "scores": {
              "japanese": 81, "math": 60, "pe": 90 }},
            { "name": "三学期", "scores": {
              "japanese": 83, "math": 76, "pe": 92 }}
          ]
        },
        {
          "name": "大道寺知世",
          "gender": "女",
          "tests": [
            { "name": "一学期", "scores": {
              "japanese": 98, "math": 90, "pe": 83 }},
            { "name": "二学期", "scores": {
              "japanese": 100, "math": 92, "pe": 70 }},
            { "name": "三学期", "scores": {
              "japanese": 97, "math": 94, "pe": 81 }}
          ]
        },
        {
          "name": "李小狼",
          "gender": "男",
          "tests": [
            { "name": "一学期", "scores": {
              "japanese": 70, "math": 83, "pe": 94 }},
            { "name": "二学期", "scores": {
              "japanese": 68, "math": 84, "pe": 92 }},
            { "name": "三学期", "scores": {
              "japanese": 72, "math": 81, "pe": 96 }}
          ]
        }
    ]
}

上記の JSON データをデータを使用する時の流れに着目して以下のように TSV に変換しています。(スペースは実際にはタブです)

year: 2022

木之本桜 女
一学期 74 52 89
二学期 81 60 90
三学期 83 76 92

大道寺知世 女
一学期 98 90 83
二学期 100 92 70
三学期 97 94 81

李小狼 男
一学期 70 83 94
二学期 68 84 92
三学期 72 81 96

実装コードは次のようになります。

#!/bin/sh

set -eu

eval "$(printf 'LF="\n" CR="\r" TAB="\t" US="\037"')"

tsv_to_usv() {
  sed "s/$US/\\\\037/g; s/$TAB/$US/g"
}

jq -c -r '
  ["year:", .year],
  [],
  (
    .users[] | (
      [.name, .gender],
      (.tests[] | [.name, (.scores | .japanese, .math, .pe)]),
      []
    )
  )
  | @tsv' seiseki.json | tsv_to_usv |
{
  year=''
  while IFS="$US" read -r key value && [ "${key%:}" ]; do
    printf -v "${key%:}" '%b' "$value"
  done
  echo "成績一覧: $year 年"
  echo
  while IFS="$US" read -r name gender; do
    echo "$name (性別: ${gender})"
    while IFS="$US" read -r title japanese math pe && [ "$title" ]; do
      printf '[%s] 国語: %3d 点, 算数: %3d 点, 体育: %3d 点\n' \
        "$title" "$japanese" "$math" "$pe"
    done
    echo
  done
}

上記コードの出力結果です。

成績一覧: 2022 年

木之本桜 (性別: 女)
[一学期] 国語:  74 点, 算数:  52 点, 体育:  89 点
[二学期] 国語:  81 点, 算数:  60 点, 体育:  90 点
[三学期] 国語:  83 点, 算数:  76 点, 体育:  92 点

大道寺知世 (性別: 女)
[一学期] 国語:  98 点, 算数:  90 点, 体育:  83 点
[二学期] 国語: 100 点, 算数:  92 点, 体育:  70 点
[三学期] 国語:  97 点, 算数:  94 点, 体育:  81 点

李小狼 (性別: 男)
[一学期] 国語:  70 点, 算数:  83 点, 体育:  94 点
[二学期] 国語:  68 点, 算数:  84 点, 体育:  92 点
[三学期] 国語:  72 点, 算数:  81 点, 体育:  96 点

重要な考え方は、どのような形の行指向のストリーミングデータに変換するかです。基本的にデータを使用する順番に、並び替えていく必要があります。この作業で意外と頭を悩ませるのはデータの区切りをどのように検出するかでしょう。今回の例では空行によってデータの区切りを検出しています。行の先頭にデータの意味を表すマーカーなどを付けて区別する方法もあると思います。

この作業を行うには jq 言語力が必要になってきます。jq はソートなども行えるのでかなりいろんな事ができると思うのですが、そこまで私は jq に詳しくありません。この作業を行う時の TIPS を一つ、最後の | @tsv は削除して jq 言語を書いたほうが良いです。そうすると以下のような出力を得られます。行ごとに配列になっていれば、それを TSV 形式に変換することが出来ますが、jq 言語に慣れていないとうまく配列にすることが出来ず | @tsv でエラーになってしまいます。

["year:","2022"]
[]
["木之本桜","女"]
["一学期",74,52,89]
["二学期",81,60,90]
["三学期",83,76,92]
[]
["大道寺知世","女"]
["一学期",98,90,83]
["二学期",100,92,70]
["三学期",97,94,81]
[]
["李小狼","男"]
["一学期",70,83,94]
["二学期",68,84,92]
["三学期",72,81,96]
[]

さて、このシェルスクリプトのコードを見てどう思いました?十分わかりやすいでしょう?この処理を間違った jq コマンドの使い方で行おうとすると、コードはかなり複雑になります。データの流れを制すれば、ここまでシェルスクリプトはシンプルになるのです。

ジャクソン構造化プログラミング (JSP)

この言葉を知ってる人にとっては、ジャクソン構造化プログラミング(JSP,ジャクソン法)とは、また懐かしい名前が登場したなと思われるのではないでしょうか? 私がこれを知ったのはかなり前の情報処理技術者試験です。今はあまり聞かなくなった用語だと思いますが無くなったと言うよりも当たり前の設計手法の一つとして、取り立てて語られなくなっただけです。構造化プログラミング自体はダイクストラが提唱したものですが、ジャクソン構造化プログラミングはその発展型というか、少し異なる視点から、データの構造に基づいて構造化プログラミングを行うというものです。

前項の階層データ構造を扱うシェルスクリプトのコードがわかりやすいのは、データの流れとプログラムの構造が一致しているからです。以下のコードのネストしたループ構造がキモです。データ構造がネストしており、その構造に合わせるのがジャクソン構造化プログラミングです。

  echo "成績一覧: $year 年"
  echo
  while IFS="$US" read -r name gender; do
    echo "$name (性別: ${gender})"
    while IFS="$US" read -r title japanese math pe && [ "$title" ]; do
      printf '[%s] 国語: %3d 点, 算数: %3d 点, 体育: %3d 点\n' \
        "$title" "$japanese" "$math" "$pe"
    done
    echo
  done

Wikipedia の Jackson structured programming にも JSP-style として Java で書かれた同様のネストしたループ構造のコードが出ています。(注意 日本語版 wikipedia の「ジャクソン構造化プログラミング」には書かれていません。日本語版の内容は不足しすぎていてよくわからないので英語版を参照してください)

String line;
int numberOfLinesInGroup;

line = in.readLine();
// begin outer loop: process 1 group
while (line != null) {
    numberOfLinesInGroup = 0;
    String firstLineOfGroup = line;

    // begin inner loop: process 1 record in the group
    while (line != null && line.equals(firstLineOfGroup)) {
        numberOfLinesInGroup++;
        line = in.readLine();
    }
    System.out.println(firstLineOfGroup + " " + numberOfLinesInGroup);
}

一方で JSP の考案者である Michael A. Jackson が間違っていると主張したのが(当時の話として)従来使われていた単一のメインループを使った構造です。

String line;
int count = 0;
String firstLineOfGroup = null;

// begin single main loop
while ((line = in.readLine()) != null) {
    if (firstLineOfGroup == null || !line.equals(firstLineOfGroup)) {
        if (firstLineOfGroup != null) {
            System.out.println(firstLineOfGroup + " " + count);
        }
        count = 0;
        firstLineOfGroup = line;
    }
    count++;
}
if (firstLineOfGroup != null) {
    System.out.println(firstLineOfGroup + " " + count);
}

ジャクソン構造化プログラミングが開発されたのは 1975 年頃のようです。UNIX の誕生は 1969 年、Version 7 Unix と Bourne シェルの登場は 1979 年なので、UNIX 開発の初期の時代に生まれた考え方です。単一のメインループを使った構造というのは、UNIX コマンド、なかでも awk コマンドがそれに近い構造を持っています。awk は(明示的にループ処理書かない限り)単一のメインループの中で処理が行われます。これは階層構造のデータを UNIX コマンドが扱いづらい理由でもあるのです。awk は手続き型言語でもあり暗黙のメインループを使わずに例えば BEGIN { ... }whilegetline を使ってネストしたループを記述することが出来ますが、sed あたりだとかなりきついでしょう。

wikipedia によるとジャクソン構造化プログラミングは(磁気)テープに保存されたシーケンシャルファイルを扱うための手法であり、COBOL バッチファイル処理プログラムの変更と保守を容易にするのが目的でした。磁気テープというのは、その物理的な特性からランダムアクセスが基本的にできません。テープの早送りや巻き戻しが必要になるからです。磁気テープに保存されたデータを前から順番に処理していくというのが、当時の一般的なプログラミング手法でした。そして COBOL はデータを一レコードずつ読み込んで処理をします。何かに似ていませんか?そうです。この記事で何度も説明した行指向のストリーミングデータの処理です。ストリーミング処理はデータを前からシーケンシャルに処理していくものです。

ジャクソン構造化プログラミングは、単一のメインループの問題点を解決するために考案された構造化プログラミングだという事実は、単一のメインループが主体の歴史的な UNIX コマンドにもプログラミング上の問題点があるということを意味しています。データ構造が単一のメインループで処理できる構造であるなら UNIX コマンドで処理するのは簡単です。例えばテキストファイルを行の繰り返しとみなして sed コマンドを処理する簡単です。しかしそうでないデータ構造を扱う場合、歴史的な UNIX コマンドで扱うのはメンテナンス性の点から難しいことが、ジャクソン構造化プログラミングで間接的に指摘されているわけです。ジャクソン構造化プログラミング(だけではなく構造化プログラミング)の考えは、シェルスクリプトにも通じるものです。

UNIX 哲学本である「The Art of UNIX Programming」には「多様性の原則:「唯一の正しい方法」とするすべての主張を信用するな」という原則があります。

1.6.16 多様性の原則:「唯一の正しい方法」とするすべての主張を信用するな

最良のソフトウェアツールでさえ、設計者の想像力が貧困なために限界を示すものだ。人間の能力には限りがあり、すべての面で最適なものを作れる人も、ソフトウェアのすべての用途を予測できない。広い世界の他者と向き合おうとしない厳密で閉じたソフトウェアを作ろうとすることは、尊大で不健全な態度といわざるをえない。

そこで、Unixは、ソフトウェアの設計や実装に対して「唯一の正しい方法」があるというアプローチに対する健全な不信感を育てて伝統の1つとした。Unixは、複数の言語、オープンで拡張性の高いシステム、あちこちにカスタマイズのフックを付けたシステムを支持する。

この記事では、jq とシェルスクリプトをつなげる「正しい考え方」を解説しました。これが唯一の方法だと主張するつもりはありませんが、実際良いコードでしょう? シェルスクリプトはコマンドをパイプでつなげる書き方が「唯一の正しい方法」というわけでもないのです。階層構造を持つデータを扱う場合、UNIX コマンドをパイプでつなげるやり方ではおそらくうまく行かない(メンテナンス性が悪くなる)でしょう。私は別にパイプでコマンドをつなげることを否定しているわけではありません。実際この記事でもパイプでコマンドをつないでいますよね? ただそれが全てじゃない。適切な所に適切な道具を使っているだけです。

番外編: キーと値のペアをストリーミングで出力する

もうちっとだけ続くんじゃということで番外編です。jq にはキーと値のペアをストリーミングで出力する機能があります。巨大な JSON データをどうしてもストリーミングで処理しなければならない場合に、この方法が使えるかもしれません。ただしデータとしてはとても使いづらいです。

gron と同等の形式を jq --stream で出力する

ここで解説している「キーと値のペアをストリーミングで出力する」というのは、gron コマンドと同等のことを jq コマンドで簡単にできるということを意味しています。

まず、単純に実行するとこのようになります。一行の配列の第一要素にキーが配列形式で、第二要素にキーの値が入っています。

$ jq -c -r --stream '.' data.json
[["address"],"東京都葛飾区亀有公園前派出所"]
[["items",0,"name"],"りんご"]
[["items",0,"price"],110]
[["items",0,"count"],3]
[["items",0,"count"]]
[["items",1,"name"],"オレンジ"]
[["items",1,"price"],120]
[["items",1,"count"],2]
[["items",1,"count"]]
[["items",2,"name"],"バナナ"]
[["items",2,"price"],1000]
[["items",2,"count"],14]
[["items",2,"count"]]
[["items",2]]
[["name"],"両津勘吉"]
[["name"]]

これをシェルスクリプトから扱いやすように少々加工して TSV 形式で出力するとこのようになります。

$ jq -r --stream '[(.[0] | join(".")), .[1]] | @tsv' data.json
address	東京都葛飾区亀有公園前派出所
items.0.name	りんご
items.0.price	110
items.0.count	3
items.0.count
items.1.name	オレンジ
items.1.price	120
items.1.count	2
items.1.count
items.2.name	バナナ
items.2.price	1000
items.2.count	14
items.2.count
items.2
name	両津勘吉
name

簡単ですね?

ストリーミング処理について、もう少し詳しく調べましたのでこちらもどうぞ

jqコマンドのストリーミング処理 (--stream) をパイプでawkにつなぐ方法を調べてみた

奇妙な「配列や連想配列の終端データ」

上記の出力には少し奇妙なところがあってよく見ると「値がないデータ」が出力されています。これはどうやら配列や連想配列(オブジェクト)の終端を示す jq の仕様のようです。詳細は man jq の STREAMING の項目を参照してください。

値のないデータを取り除くのは簡単です。以下のように select を使うだけです。これはキーの形式が異なるだけで gron の出力と同質のものです。

$ jq -r --stream 'select(.[1]) | [(.[0] | join(".")), .[1]] | @tsv' data.json
address	東京都葛飾区亀有公園前派出所
items.0.name	りんご
items.0.price	110
items.0.count	3
items.1.name	オレンジ
items.1.price	120
items.1.count	2
items.2.name	バナナ
items.2.price	1000
items.2.count	14
name	両津勘吉

このデータをシェルスクリプトから扱いたい場合は、以下のようなコードになるでしょう。

TAB=$(printf '\t')
jq -r --stream --unbuffered \
    'select(.[1]) | [(.[0] | join(".")), .[1]] | @tsv' data.json | \
{
    previouns_id='' name='' price='' count=''
    while IFS="$TAB" read -r key value; do
        case $key in
            items.*)
                key=${key#items.}
                current_id=${key%%.*}
                if [ "$previous_id" ] && [ "$previous_id" != "$current_id" ]; then
                    echo "$name $price $count"
                    name='' price='' count=''
                fi
                previous_id="$current_id"
                case $key in
                    *.name) name=$value ;;
                    *.price) price=$value ;;
                    *.count) count=$value ;;
                esac
        esac
    done
    if [ "$previous_id" ]; then
        echo "$name $price $count"
    fi
}

出力

りんご 110 3
オレンジ 120 2
バナナ 1000 14

実に複雑ですね。上記にさらに name や address を付け加えるとしたらもっと大変です。これが gron の問題点です。キーの出現順や一連の配列の終端(items[0].* が終わった時に一連のデータを出力したい)がわからないからこのようにするしか無いのです。

さてここで「配列の終端」という言葉が出てきました。これは先程 select で消した「値のないデータ」のことです。実はここでコードが複雑になったのは gron のようにデータとしては一見不要に思える終端データを取り除いてしまったからなのです。

終端データを利用してシンプルに実装する

前項では奇妙なデータだからと select を使って「値がないデータ」=「終端データ」を取り除きましたが、そのようなことをしなければかなりマシになります。なお以下で type を追加しているのは null と 空文字を区別するためです。

TAB=$(printf '\t')
jq -r --stream --unbuffered \
    '[(.[0] | join(".")), (.[1] | type), .[1]] | @tsv' data.json | \
{
    while IFS="$TAB" read -r key type value; do
        case "$key:$type" in
            items.*.*:null)
                echo "$name $price $count"
                name='' price='' count=''
                ;;
            items.*.name:*) name=$value ;;
            items.*.price:*) price=$value ;;
            items.*.count:*) count=$value ;;
        esac
    done
}

一行に関連するデータ (name, price, count) が詰め込まれるわけではないため、どうしても変数に保持してから処理するというコードが必要になってきます。数が多くなれば面倒です。とは言え最初のコードに比べればかなりマシです。

JSON のデータで配列や連想配列の終わりに出力やなにかの処理をしたいということはよくある話です。データとしては意味がないものであって、シェルスクリプトから使うことを考えると、終端データは重要な情報だと言うことがわかります。

ただ一言言うならば配列の終端データのキーが配列の採集データのキー(下記の例では count)であるのはおかしいと思います。

items.2.name	バナナ
items.2.price	1000
items.2.count	14
items.2.count
items.2

私だったら終端データだけではなく開始データも付け加えて、以下のような形式にします。(というか、まだ公開してなかったと思いますが、某コードではこのような形式を採用しています)

items.[
    ...
items.2.{
items.2.name	バナナ
items.2.price	1000
items.2.count	14
items.2.}
items.]

ちなみに、この出力に name や address を付け加えるとしたらどうするか?という問題は残ったままです。面倒なだけなのでやりませんが、gron のような出力では JSON データを扱う時の問題が半分(JSON データのパースだけ)も解決されていないということがわかるはずです。データの並び替えや gron 出力形式のパースが未解決です。

jq --stream はデータをリアルタイムに出力できる

jq --stream はデータをキーと値のペアで出力するだけではなく、受け取ったそばからリアルタイムで出力することが出来ます。これは巨大な JSON データ全体をすべてメモリに読み込む前や、ネットワークなどの遅い通信経路で取得する際に全てのデータが揃う前に、キーと値のペアを出力することで、何かしらの処理を早い段階で開始することが可能であることを意味しています。

jq --stream が、ストリーミングデータをリアルタイムで出力していることを確認するために、次のコードを実行してみましょう。

cat data.json | while read line; do echo "$line"; sleep 0.5; done | \
    jq -r --stream '[(.[0] | join(".")), .[1]] | @tsv'

while の部分で出力が遅延するようにしていますが、jq コマンドは入力をすべて読み終えてから出力するのではなく、情報が確定した時点でその都度出力していることがわかると思います。

一方 gron はどうなっているのかを調べてみたのですが、どうやらデータが全て揃ってから出ないと出力を行わないようです。仕組み的には gron でもリアルタイムにデータを出力できるはずなので、これは少し残念なポイントですね。

データが揃わずに出力するというのは、言い換えると後半のデータが JSON データとして壊れていたとしても、先行する処理は行われてしまうということを意味しています。そのため必ずしもリアルタイムに出力されることが良いとは限らないのですが、jq はこのような用途にもしっかりと対応しているということがわかります。

バッファリングを抑制する --unbuffered について

さて、先のコードでこっそりつけていた jq コマンドの --unbuffered について説明しておきましょう。これは jq のコマンドの出力がターミナル以外、ようするに別にコマンドに出力する時にバッファリングをしないようにするためのものです。

前項で以下のコードはリアルタイムで出力されるということを示しました。

cat data.json | while read line; do echo "$line"; sleep 0.5; done | \
    jq -r --stream '[(.[0] | join(".")), .[1]] | @tsv'

しかし、次のようにその出力を別のコマンドにパイプでつなげると、データをすべて受け取ってから出力しているかのような動作を行います。以下のコマンドは最後に cat コマンドにパイプでつなげています。それだけでリアルタイムでの出力が行われません。

cat data.json | while read line; do echo "$line"; sleep 0.5; done | \
    jq -r --stream '[(.[0] | join(".")), .[1]] | @tsv' | cat

正確にはデータをすべて受け取ってから出力しているのではなく、一定のバッファサイズを受け取ってから出力します。これはパフォーマンス上の理由です。大きなサイズのデータが出力される場合はバッファリングを行った方がパフォーマンスが良いのですが、今回のようにリアルタイムでデータを出力して欲しい場合には困ります。そこで --unbuffered をつけることでバッファリングが行われないようにしています。

cat data.json | while read line; do echo "$line"; sleep 0.5; done | \
    jq -r --stream --unbuffered '[(.[0] | join(".")), .[1]] | @tsv' | cat

このような挙動、別のコマンドにパイプつなげるとバッファリングが行われ、リアルタイムで出力されないように見えてしまうという問題は、多くのコマンドに共通する問題です。そのため sed コマンドなどいくつかのコマンドには -u, --unbuffered というオプションが実装されています。awk の場合はバッファをフラッシュするために fflush() 関数を使用します。fflush() 関数は POSIX Issue 7 では標準化されていませんが、現在一般的に使われている awk の実装にはすでに fflush() 関数が実装されており、POSIX Issue 8 でも標準化される事になっているので、すでにどこでも使える関数です。

バッファリングが行われるのはターミナル(画面)以外への出力の場合であることに注意してください。ターミナルで実行してリアルタイムで出力されているから問題ないと思い込み、シェルスクリプトで他のコマンドにパイプでつなげると、実はバッファリングされていてリアルタイムに出力されてないということが起こりえます。

おわりに

毎度のことながら説明が長くて申し訳ありません。しかし説明が長いだけで「最終コード」が大したことがないのは、コードを読んでもらえばわかると思います。ようは UNIX 哲学流では、このようにしてデータを行指向のストリーミングデータに変換して扱うんだねということを知っているかどうかです。知ってさえいれば、コードを書くのは簡単です。しかし知らない人が結構多く、そのせいで無駄に複雑で遅いシェルスクリプトを書いてしまいます。

この記事は jq コマンドとシェルスクリプトをどうやってつなぐか? JSON データをどのようにしてシェルスクリプトで処理するか? という話でしたが、これは YAML や XML にも当てはまると話であることに気づいた方も多いと思います。これらの階層構造を持ったデータはシェルスクリプトで扱うには適していません。データを処理する順番に並び替える必要があります。並び替えるにはそれに適した別のコマンドをつかう必要があります。jq コマンドはその並び替えをとてもシンプルに行える優れたコマンドです。これと同じ事を YAML や XML でも行えるコマンドがあるのかは(まだ)詳しく調べていません。

YAML は jq のように使える yq が TSV 形式にも対応しているようです。XML は xmllint という名前をよく見かけますが、これは単に値をとってくるだけのような気がします。必要なのは値をとってくるコマンドではなく変換フィルタコマンドです。xslt のようなものが必要です。xsltproc を使えばうまくいくような気がしますが、個人的に xslt って嫌いなんですよね。手続きを XML で記述するというのが冗長すぎて受け入れられません。まあ、いずれにしろ便利なコマンドがあればそれを使えばよいですし、ないのであれば作ればよいだけです。作る時には jq コマンドが良い手本となります。

あまりに複雑なデータ構造をシェルスクリプトで処理するのはおすすめしませんが、そこまで複雑ではない JSON データの処理をシェルスクリプトで書いていてメンテナンス性が悪いというのであれば、それは UNIX 哲学流の考え方を正しく理解していないからです。シェルスクリプトは簡単な言語ですが、その設計思想を理解せずに使える言語ではありません。多くの人がシェルスクリプトが難しいと言ってるのは、シェルスクリプトの文法が悪いからではなく、シェルスクリプトの設計思想を理解してないという、人の問題です。他の言語の考え方でシェルスクリプトを使うということは、関数型言語をオブジェクト指向言語の発想で使うようなものです。データの流れをデータを使う順番で行指向のストリーミングデータに整える。これだけでシェルスクリプトは単純になりメンテナンス性もパフォーマンスも大きく向上します。

この記事で jq とシェルスクリプトの正しいつなぎ方の普及とともに、UNIX 哲学流シェルプログラミングというのがどういうものかが伝われば幸いです。

47
49
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
49