jqコマンドとシェルスクリプトの上手い速い使い方
はじめに
シェルスクリプトから jq
コマンドを使う記事はいくつも見かけますが、あまりにも面倒でよくない書き方ばかりが見つかるのでベストプラクティスをまとめました。
この記事は「詳細解説 jqコマンドとシェルスクリプトの簡単で正しい使い方 〜 データの流れを制するUNIX哲学流シェルプログラミング」の要約版です。詳しい解説やもう少し高度な使い方を知りたい方、シェルスクリプトの考え方についてはリンク先を参照してください。リンク先は長すぎたので、こちらはとりあえず使いたい人用に簡潔にまとめました。(あと、いつも qiita を使っているので zenn を使ってみたかった)
👎 ダメな書き方
よく見かける書き方ですが、コードの見通しが悪く、メンテナンス性が低く、パフォーマンスが(かなり)悪く、特定の場合に不具合が発生する書き方です。
for item in $(jq -c '.items[]' data.json); do
name=$(echo "$item" | jq -r '.name')
price=$(echo "$item" | jq -r '.price')
count=$(echo "$item" | jq -r '.count')
printf "name: %-10s price: %5d count: %3d\n" "$name" "$price" "$count"
done
# 参考: jq -c '.items[]' の出力結果
# {"name":"apple","price":110,"count":3}
# {"name":"orange","price":120,"count":2}
# {"name":"banana","price":1000,"count":4}
{
"items": [
{ "name": "apple", "price": 110, "count": 3 },
{ "name": "orange", "price": 120, "count": 2 },
{ "name": "banana", "price": 1000, "count": 4 }
]
}
Q. これの何がダメなのか?
❌ jq コマンドを値を取得するコマンドとして使ってはいけない!
-
何度も
jq
コマンドを呼び出してはいけない- 多数のコマンド呼び出しと JSON のパースで、パフォーマンスが非常に悪い
-
JSON 文字列を変数に入れてはいけない
- 結果、何度も
jq
コマンドを呼び出すことにつながる
- 結果、何度も
-
ループの中でコマンド置換 (
$(...)
) を使ってはいけない- コマンド置換はサブシェル生成につながり、パフォーマンスが悪くなる
- コマンド置換は末尾の連続する改行が消えてしまうという問題がある
-
ループの中でパイプ (
|
) を使ってはいけない- パイプの使用はサブシェル生成につながり、パフォーマンスが悪くなる
-
jq
コマンドの出力をfor ... in
に渡してはいけない- 値にスペースが入っている時に単語分割されておかしくなる
- どうしてもしたいなら
IFS
を改行だけ(もしくは改行とタブ)にすること
-
echo
コマンドを使ってはいけない- エスケープシーケンスが解釈されたりされなかったり、シェル依存が激しい
- 今回の場合、値に
\t
や\n
などのエスケープシーケンスが含まれる可能性がある - エスケープシーケンスが入る可能性がある場合は
printf "%s"
を使う
⭕ jq コマンドは変換フィルタとして使いましょう!
jq
コマンドは「JSON データから値をとってくる」という考え方で使うものではなく、シェルスクリプトで処理しやすい形に変換するためのフィルタとして使うものです。
👍 良い書き方
シンプルでパフォーマンスも良い書き方です。データの数が多い場合に実行速度は劇的に向上します。
@tsv でタブ区切りに変換する
jq
コマンドをフィルタとして使い、TSV 形式に変換します。
TAB=$(printf '\t')
jq -r '.items[] | [.name, .price, .count] | @tsv' data.json | {
while IFS="$TAB" read -r name price count; do
printf "name: %-10s price: %5d count: %3d\n" "$name" "$price" "$count"
done
}
# 参考: jq -r -c '.items[] | [.id, .value]' の出力結果 (注 @tsv なし)
# ["apple",110,3]
# ["orange",120,2]
# ["banana",1000,14]
Q. パイプ使ってるじゃん? → A. 使ってはいけない(避けたほうがいい)のはループの中の話
空文字が入る場合は US 区切りに変換
項目に空文字(または null)が入っている場合は、位置がずれてしまいます。(連続するタブは一つとみなされるシェルスクリプトの仕様)
{
"items": [
{ "name": "apple", "price": 110, "count": 3 },
{ "name": "orange", "price": null, "count": 2 },
{ "name": "banana", "price": 1000, "count": 4 }
]
}
name: apple price: 110 count: 3
name: orange price: 2 count: 0
name: banana price: 1000 count: 4
このような場合に対応するには TSV 形式から US 区切りに変換します(US 文字である必要はありませんが、US 文字が一番適切でしょう)。jq では直接 US 区切りで出力できないため sed
コマンドで置換します。データの中に US 文字が含まれている場合は \037
にエスケープされます。
TAB=$(printf '\t')
US="$(printf "\037")"
tsv_to_usv() {
sed "s/$US/\\\\037/g; s/$TAB/$US/g"
}
jq -r '.items[] | [.name, .price, .count] | @tsv' data.json | tsv_to_usv | {
while IFS="$US" read -r name price count; do
printf "name: %-10s price: %5d count: %3d\n" "$name" "$price" "$count"
done
}
name: apple price: 110 count: 3
name: orange price: 0 count: 2
name: banana price: 1000 count: 4
awk に繋げる場合は US 区切りに変更しなくて良い
awk につなげる場合はデフォルトの [ \t]+
区切りから \t
区切りに変更するだけです。
jq -r '.items[] | [.name, .price, .count] | @tsv' new.json | {
awk -F '\t' '
{ printf "name: %-10s price: %5d count: %3d\n", $1, $2, $3 }
'
}
タブや改行が \t \n になるんだけど?
jq の @tsv
を使うと、改行、ラインフィード、タブ、バックスラッシュが、それぞれ \n
, \r
, \t
, \\
にエスケープされます。前項の方法で US 区切りにした場合は、加えて US 文字が \037
にエスケープされます。これを元の文字に戻すには printf
コマンドを使用します。
value='FOO\tBAR'
printf "$value"
# または
printf "%b" "$value"
変数に入れる場合は、printf -v
(bash, zsh, ksh93u+m のみ) を使うか、以下の unescape
関数 (POSIX シェル準拠)を使用してください。
value='FOO\tBAR'
printf -v value "$value"
# または
printf -v value "%b" "$value"
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='FOO\tBAR'
unescape value "$value"
POSIX シェル準拠の方法として printf
の出力をコマンド置換で変数に入れる方法もありますが、コマンド置換はサブシェルを生成し遅くなるので、特にループの中で使うのは避けたほうが良いでしょう(実行回数が少ない場合は使っても良いと思いますが)。また末尾の連続する改行が消えてしまうという問題もあります。
# 遅いので(特にループの中で使うのは)非推奨
value='foo\n\n\n' # 末尾に連続する改行がある
value=$(printf "%b" "$value") # コマンド置換は末尾の連続する改行が消えてしまう
value=$(printf "%b_" "$value") && 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
}
値を取得したいだけなら @sh が便利
ループが不要で値を取得したいだけの場合には @sh
を使うと簡単です。
{
"user": {
"name": "my name",
"email": "my@example.com",
"create_at": "2022-10-22T12:34:56Z"
}
}
eval "$(jq -r '@sh "name=\(.user.name) email=\(.user.email)"' user.json)"
# または
eval "$(jq -r '.user | @sh "name=\(.name) email=\(.email)"' user.json)"
echo "$name" # => my name
echo "$email" # => my@example.com
階層構造を扱ったり条件で絞り込む
この方法で扱えるのはフィールド数が固定の単純な表形式のデータだけではありません。データの区切りを上手く表現することで階層構造などのデータを扱うことも出来ます。ポイントは「データを使う順番に並べる(並び替える)」ということです。例えば、次のような 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 }}
]
}
]
}
最終的に次のような二重ループ構造で出力する場合、
(ユーザーの繰り返しの中に学期の繰り返しがある)
成績一覧: 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 点
内部的に JSON データを次のようなタブ区切りの形式に変換して処理するために、
(行ごとに意味やフィールド数が異なっており、空行でデータが区切られている所がポイント)
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 -r '
["year:", .year],
[],
(
.users[] | (
[.name, .gender],
(.tests[] | [.name, (.scores | .japanese, .math, .pe)]),
[]
)
)
| @tsv' data.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
}
-
jq
コマンドで、データを使う順番に一括でタブ区切り形式に並び替える -
{ ... }
の中で複数のwhile ... do ... done
で、適切な形式でread
する
このようにデータとして使う順番に並べられたタブ区切りのデータを一行ずつ読み込んで処理するという構造化されたロジックを組むことが出来ます。このような書き方をすることで、パフォーマンスを落とすことなく複雑なデータ構造を処理することが可能です。階層構造を持った JSON データをシェルスクリプトが処理しやすいストリーミングデータに変換し、それを順番に処理していくという考え方です。
他にも jq
コマンド部分を以下のように変更すると条件によるフィルタリングを行ったりすることが出来ます。条件の値は --arg
で与えるのがポイントです。jq のフィルタリング文字列に直接値を埋め込む(jq 命令を文字列で組み立てる)とエスケープが必要になってしまい、下手すると SQL インジェクションのようなことになりかねません。
jq -r --arg gender '女' '
["year:", .year],
[],
(
.users[] | select(.gender == $gender) | (
[.name, .gender],
(.tests[] | [.name, (.scores | .japanese, .math, .pe)]),
[]
)
)
| @tsv' data.json | tsv_to_usv |
さいごに
もっと楽な書き方を知って、もっと楽になろうぜ!
Discussion