🐚

jqコマンドとシェルスクリプトの上手い速い使い方

2022/10/23に公開

はじめに

シェルスクリプトから jq コマンドを使う記事はいくつも見かけますが、あまりにも面倒でよくない書き方ばかりが見つかるのでベストプラクティスをまとめました。

この記事は「詳細解説 jqコマンドとシェルスクリプトの簡単で正しい使い方 〜 データの流れを制するUNIX哲学流シェルプログラミング」の要約版です。詳しい解説やもう少し高度な使い方を知りたい方、シェルスクリプトの考え方についてはリンク先を参照してください。リンク先は長すぎたので、こちらはとりあえず使いたい人用に簡潔にまとめました。(あと、いつも qiita を使っているので zenn を使ってみたかった)

👎 ダメな書き方

よく見かける書き方ですが、コードの見通しが悪く、メンテナンス性が低く、パフォーマンスが(かなり)悪く、特定の場合に不具合が発生する書き方です。

script.sh
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}
data.json
{
  "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 形式に変換します。

script.sh
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)が入っている場合は、位置がずれてしまいます。(連続するタブは一つとみなされるシェルスクリプトの仕様)

data.json(2 個目の price が null になっている)
{
  "items": [
    { "name": "apple", "price": 110, "count": 3 },
    { "name": "orange", "price": null, "count": 2 },
    { "name": "banana", "price": 1000, "count": 4 }
  ]
}
出力結果(2 行目の price がずれている)
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 にエスケープされます。

script.sh
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
}
出力結果(2 行目の price が正しく出力される)
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 を使用する場合は以下の関数を使用してください。

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.json
{
  "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 データを、

data.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
}
  1. jq コマンドで、データを使う順番に一括でタブ区切り形式に並び替える
  2. { ... } の中で複数の 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