jq(1) の力を借りて Bash スクリプト内で JSON を操作する

最近、AWS の CLI を使う Bash スクリプトを書いていると、何かと jq(1) コマンドで JSON をイジりまわすことが多くなってきたので、jq(1) による JSON 操作 with Bash スクリプトのまとめ。

Bash でやらずに Python あたりででも書けばいいじゃないかって? そりゃそうなんですが、CLI の aws コマンドだと色々なことをアドホックに試すのが楽だし、その延長として Bash スクリプトを書き始めたら何やら大きなスクリプトになってきてしまって複雑なデータ構造を処理するのが大変になってきた、ってよくあるじゃないですか(私にゃよくある)。そんな時、ブロック指向の構造化テキストフォーマットである JSON であれば、基本的に文字列しか格納できない Bash の変数内へデータをシリアライズして格納することで、構造化データを保持できるので助かるわけです。

Bash にも配列があるって? あんなの使い方を覚えていられませんよ、記号がいっぱいで。

Note: XML もブロック指向なので、xmlstarlet(1) でも使えば同じようなことができるでしょうね、こんどやってみよう。もし YAML が飛んできたとしたら、YAML は行指向なのでそのままでは処理がしづらいので、一旦 JSON 化してから jq(1) で処理して、最後に YAML に戻すのが良いと思う。

Note: XML 版を書きました。 ∥ xmlstarlet(1) の力を借りて Bash スクリプト内で XML を操作する - Qiita

私も jq(1) の機能を網羅的に全部知っているわけではありませんので、「それ、もっとエレガントに書けるよ?」という点があったら教えてください。あと、「こういう処理も多用すんじゃね?」というのもあればご指摘ください。

ループしつつ参照

こんな感じが、一番よくある使い方だと思う。要点としては、

  • set -o nounset -o errexit -o pipefail について。nounset (-u) と errexit (-e) はよく見かける(よく見る set -eu)が、pipefail が必要。次のような、json を取得して整形するが、成否も判定したいような処理を書きたいのだが、pipefail が無いと、常に「成功」になってしまう。

      if member=$(get-member --id 123 | jq -c .)
      then
    
  • リテラルの JSON を members へ代入する際に、一度 jq -c を通して --compact-output している。必要ではないのだが、Bash で処理する以上はワンラインの JSON の処理であることを強調しておいた方が良いと思うのでこうしている

  • そんなわけで、jq は基本的に、処理中はコンパクトに -c'、最終出力時には裸のかたちにするために -r` (raw)、以上 2 つのいずれかのオプションを付与して使うことになる

  • $first ではなく first() なのは、if first とか書けるようにするためで、単なる好みの問題

  • ループしつつ read で読む際に -r オプションを付けている。これが無いと、JSON 内のダブルクォート等が \ でエスケープされていると、それを read が解釈して食ってしまう。それを防ぐため

  • さて、ループを回す際に前方からのパイプではなく、process substitution (<()) で後方からリダイレクトで入れている理由である。これは、ループ内でグローバル変数の更新(ここでは $count のインクリメント)をしたいから。ご存知のとおり、シェルスクリプトにおいては同一プロセスのコンテキストで走っているのは左端のプロセスのみであり、パイプで繋いだ以降はサブプロセスになってしまい、そこでの変数への代入は、親プロセスへは反映されない

  • この方法だと、前段処理を後ろに書かなければならない点がやや気持ち悪い。その点を解消するために以下のような手法もあるのだが、やや複雑になるので、あまりお勧めしない:

    • 名前付きパイプを用いる(終わったら消さなければならないので面倒。TERM シグナルをキャッチして finally 的に書けば確実に消すことはできるが、面倒なのは同じ)
    • 別途、ファイルデスクリプタを用意してやる(Bash >= 4.0 だとスッキリ書けるが、Mac の Bash がいつまでも 3.* なので、この場合、空きの FD を把握しておかないといけなくて気持ち悪い)
    • Bash の array で切り分けることもできるが、Bash の配列って使い方が面倒で好きじゃない
  • here document (<<<) で JSON を stdin へ入れる際に、<<<"$members" と、ダブルクォートで括っている。これを忘れると、JSON 内の連続スペースは、Bash によって展開されて一つのスペースになってしまうので注意。

  • echo する際のダブルクォートも同様

といったところか。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail
members=$(jq -c . <<"EOF"
[
  {
    "id": 123,
    "name": "Alice ALICE",
    "phone": "123-456"
  },
  {
    "id": 456,
    "name": "Bob BOB",
    "phone": "456-789"
  },
  {
    "id": 789,
    "name": "Charlie CHARLIE",
    "phone": "789-000"
  }
]
EOF
)
first() { true; }
count=0
while read -r member
do
  first || echo "--------"
  echo ID: "$(jq -r ".id" <<<"$member")"
  echo Name: "$(jq -r ".name" <<<"$member")"
  echo Phone: "$(jq -r ".phone" <<<"$member")"
  first() { false; }
  count=$(($count + 1))
done < <(jq -c ".[]" <<<"$members")
echo "--------"
echo Count: $count

実行結果:

$ ./jqloop
ID: 123
Name: Alice ALICE
Phone: 123-456
--------
ID: 456
Name: Bob BOB
Phone: 456-789
--------
ID: 789
Name: Charlie CHARLIE
Phone: 789-000
--------
Count: 3

検索

やっていることは、前項と大差ない。map を使っているが、このへんはごく普通の jq の使い方。結果が無かった時に null で判定しているあたりが Bash 特有かな。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail
members=$(jq -c . <<"EOF"
[
  {
    "id": 123,
    "name": "Alice    ALICE",
    "phone": "123-456"
  },
  {
    "id": 456,
    "name": "Bob BOB",
    "phone": "456-789"
  },
  {
    "id": 789,
    "name": "Charlie CHARLIE",
    "phone": "789-000"
  }
]
EOF
)

searchFirstMatchedAndPut() {
  id=$1
  member=$(jq -c ". | map(select(.id == $id)) | .[0]" <<<"$members")
  if test "$member" != "null"
  then
    echo ID: "$(jq -r ".id" <<<"$member")"
    echo Name: "$(jq -r ".name" <<<"$member")"
    echo Phone: "$(jq -r ".phone" <<<"$member")"
  else
    echo ID: $id not found.
  fi
}

first() { true; }
for id in 456 999
do
  first || echo "--------"
  searchFirstMatchedAndPut $id
  first() { false; }
done

実行結果:

$ ./jqsearch
ID: 456
Name: Bob BOB
Phone: 456-789
--------
ID: 999 not found.

Web API のエラー対応

JSON を返してくる特定の Web API にアクセスしたが、そのリソースが無くてエラーが返った場合の処理。

はい、ここで先述した pipefail オプションが効いてきます。このオプションが無いと、member=$(getResource $id 2> /dev/null | jq -c .)getResource が失敗しても全体としては成功してしまいますが、pipefail を指定しておけば、全てのサブプロセスが成功しない限り全体として成功ではなくなります。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail

getResource() {
  id=$1
  rc=0
  case $id in
    456 )
      cat <<EOF
{
  "id": 456,
  "name": "Bob BOB",
  "phone": "456-789"
}
EOF
      ;;
    * )
      echo "The resource not found." >&2
      rc=1
      ;;
  esac
  return $rc
}

putMember() {
  member="$1"
  echo ID: "$(jq -r ".id" <<<"$member")"
  echo Name: "$(jq -r ".name" <<<"$member")"
  echo Phone: "$(jq -r ".phone" <<<"$member")"
}

first() { true; }
for id in 999 456
do
  first || echo "--------"
  if member=$(getResource $id 2> /dev/null | jq -c .)
  then
    putMember "$member"
  else
    echo Something wrong with ID: $id. >&2
  fi
  first() { false; }
done

実行:

$ ./jqapi
Something wrong with ID: 999.
--------
ID: 456
Name: Bob BOB
Phone: 456-789

JSON の構築

さて、そろそろ「向いていない感」が出てきますね。

JSON の配列に、新しい要素を足して行きます。追加する要素が単純な内容であれば jq(1) のフィルタ文字列内で指定しても良いのですが、JSON の断片を追加する場合などは --argjson オプションで指定した方が良いと思います。それと、どうしてもフィルタ文字列内の文字列はダブルクォートでくくらないといけないので、フィルタ文字列はシングルクォートでくくり、渡す引数は --argjson で渡す、と決めてしまっても良いかも知れませんね。

見てのとおり、要素を追加するたびに JSON まるごと作りなおしているわけですから、性能は出ません。あまり大きな JSON の構築はやめましょう(やらないと思いますが)。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail

members=$(jq -c . <<"EOF"
[
  {
    "id": 123,
    "name": "Alice ALICE",
    "phone": "123-456"
  }
]
EOF
)

members_new=$(jq -c . <<"EOF"
[
  {
    "id": 456,
    "name": "Bob BOB",
    "phone": "456-789"
  },
  {
    "id": 789,
    "name": "Charlie CHARLIE",
    "phone": "789-000"
  }
]
EOF
)

while read -r member
do
  members=$(jq -c --argjson member "$member" '. |= . + [$member]' <<<"$members")
done < <(jq -c ".[]" <<<"$members_new")

jq . <<<"$members"

実行結果:

 ./jqbuild
[
  {
    "id": 123,
    "name": "Alice ALICE",
    "phone": "123-456"
  },
  {
    "id": 456,
    "name": "Bob BOB",
    "phone": "456-789"
  },
  {
    "id": 789,
    "name": "Charlie CHARLIE",
    "phone": "789-000"
  }
]

更新

あーあ、if then else とか出てきちゃったよ。これも JSON まるごと作りなおしですので、要注意です。お前は Haskell かと。

#!/bin/bash
# -*- coding: utf-8 -*-
set -o nounset -o errexit -o pipefail
members=$(jq -c . <<"EOF"
[
  {
    "id": 123,
    "name": "Alice ALICE",
    "phone": "123-456"
  },
  {
    "id": 456,
    "name": "Bob BOB",
    "phone": "456-789"
  },
  {
    "id": 789,
    "name": "Charlie CHARLIE",
    "phone": "789-000"
  }
]
EOF
)

members=$(jq -c \
  --argjson id 456 \
  --arg phone_new "XXX-XXX" \
  'map(if .id == $id then . + {"phone": $phone_new} else . end)' \
  <<<"$members" )

members=$(jq -c \
  --argjson id 789 \
  --arg phone_new "YYY-YYY" \
  'map(if .id == $id then . + {"phone": $phone_new} else . end)' \
  <<<"$members" )

jq . <<<"$members"

実行結果:

$ ./jqupdate
[
  {
    "id": 123,
    "name": "Alice ALICE",
    "phone": "123-456"
  },
  {
    "id": 456,
    "name": "Bob BOB",
    "phone": "XXX-XXX"
  },
  {
    "id": 789,
    "name": "Charlie CHARLIE",
    "phone": "YYY-YYY"
  }
]

おわりに

いやー、JSON が扱えるようになると、とたんに Bash スクリクトもモダンな印象になりますね。これでまた Bash が延命されてしまいます。F-4EJ に XASM-3 搭載、みたいな感覚です。