📚

jq使えますと言うまでに読んでおきたい記事

2022/12/10に公開

この記事はDeNA 23新卒内定者 Advent Calendar 2022の 10日目の記事です。
https://qiita.com/advent-calendar/2022/dena-23-shinsotsu

こんにちはgari8です。
今回は内定先のAdvent Calendarに参加させていただくため久しぶりに筆を取りました。
DeNA 23新卒内定者 Advent Calendar 2022ではこの記事の公開日(2022/12/10)以降もさまざまなジャンルの技術に関する記事が更新されるのでぜひご覧ください!


さて、早速本題に入りたいと思いますが、jqという言葉を耳にしたことがあるでしょうか。
jQueryではなくjqです。(余談ですが、jqを検索する時はjQueryが入ってきてしまうことが稀によくあるので-jQueryを後ろに入れて弾くことをお勧めいたします。)

jqを使いこなすと、GitHubActionsやCircleCIで無双できる(個人的にはできていませんが…)ので知らなかった方はこの記事を見て興味を持っていただけると嬉しいです。

jqとは

jqはJSONをいい感じにパースしてくれる軽量言語で公式HPには以下のような説明がされています。

jq is like sed for JSON data - you can use it to slice and filter and map and transform structured data with the same ease that sedawkgrep and friends let you play with text.
(jqはJSONデータのsedのようなもので、sedやawk、grepなどがテキストで遊べるのと同じように、スライスやフィルタリング、マッピング、構造化データの変換に使うことができるんです。日本語訳:DeepL)

https://stedolan.github.io/jq/より引用(最終閲覧日: 2022年12月3日)

https://stedolan.github.io/jq/

必要な要素の抽出だけでなく、構造データや文章を作成したりと多くのことができとてもワクワクさせてくれます。
今回はそんなjqをこれから使ってみたいという方をターゲットに、実際に問題を解いていきながら基本的な使い方をおさえていきたいと思います。

資料や作業環境

問題を解く前に公式ドキュメントのマニュアルページをよく読みましょう。大体の情報はここにあるので困ったら戻ってくることになります。

次に作業環境ですが、jqplayというプレイグラウンドがあるのでこちらで動作確認しながらjqフィルターを組み立てていくと良いです。このページで作成したjqフィルターはCommand Lineの下に直接使えるコマンドとして吐き出されるのでとても便利です。
https://jqplay.org/

ちなみにページ下部にCheatsheetがあり各コマンドの右側のアイコンを押すと簡単にコマンドの動作を確認できます。

それでは、実際に問題を解いていきましょう!

ケーススタディ

case1. JSONをパースしてみる

問題: とあるAPIにリクエストを送ったところ以下の様なレスポンスが届きました。fruitsの値は配列で受け取りたかったのですが、,で区切られた文字列で返ってきてしまいました。このJSONをfruits部分だけ配列にしたJSONに変換するにはどのようなjqフィルターを実装すれば良いでしょうか。

[{
	"id": 1,
	"name": "A農園",
	"fruits": "りんご,みかん,ぶどう,いちご"
},{
	"id": 2,
	"name": "B農園",
	"fruits": "ざくろ,パイナップル,ラズベリー,ブルーベリー"
}]
解答例と解説

解答例

jq '[.[] | {id: .id, name: .name, fruits: .fruits | split(",")}]'
# or
jq '[.[] | . + {fruits: .fruits | split(",")}]'

https://jqplay.org/s/21FiDPcJzNd

解説

  1. この問題ではまず元のJSONが配列で渡ってきていることに着目します。
    前提としてjqでは以下のようにすると配列のそれぞれの要素にアクセスできるので、これを利用して配列内のオブジェクトを操作します。
jq '.[].name'
# or
jq '.[] | .name'
# 結果
# "A農園"
# "B農園"
  1. 配列の要素にアクセスできたところで、次に元の構造に近づけるために{id: 1, ...}のようなオブジェクトを作成します。
    オブジェクトの作成はとても簡単で{key: value}とするだけです。1と組み合わせると以下の様になります。
jq '.[] | {id: .id, name: .name, fruits: .fruits}'
# 結果
# {"id":1,"name":"A農園","fruits":"りんご,みかん,ぶどう,いちご"}
# {"id":2,"name":"B農園","fruits":"ざくろ,パイナップル,ラズベリー,ブルーベリー"}
  1. ここまで出来て、やっとfruitsを配列にする処理に移ります。jqにはいくつか組み込みのメソッドがあり、今回はその中の一つであるsplitを使用します。
jq '.[] | {id: .id, name: .name, fruits: .fruits | split(",")}'
# 結果
# {"id":1,"name":"A農園","fruits":["りんご","みかん","ぶどう","いちご"]}
# {"id":2,"name":"B農園","fruits":["ざくろ","パイナップル","ラズベリー","ブルーベリー"]}
  1. 今のままでは元のJSONと同じような配列構造ではなくなってしまっているため、配列に詰め込み直します。この処理はとてもシンプルで、フィルター全体を[]で囲むだけです。
    最終的な結果は以下のようになりました。
jq '[.[] | {id: .id, name: .name, fruits: .fruits | split(",")}]'
結果
[
  {
    "id": 1,
    "name": "A農園",
    "fruits": [
      "りんご",
      "みかん",
      "ぶどう",
      "いちご"
    ]
  },
  {
    "id": 2,
    "name": "B農園",
    "fruits": [
      "ざくろ",
      "パイナップル",
      "ラズベリー",
      "ブルーベリー"
    ]
  }
]

case2. テキストを作成する

問題: とある場所で徒競走のレースが行われています。実況席にはレースの結果が下記のようなJSONでリアルタイムに送られてきます。隣に座っている実況者がすぐに読み上げられるようにJSONデータを使って「今回のレースの結果は、優勝がゼッケン〇番の〇〇選手、準優勝はゼッケン△番の△△選手でした!」のような文章を作成するjqフィルターを作成してください。

[{
    "name": "John",
    "number": 79,
    "record": "2022-12-10 10:09:01 +0000"
},{
    "name": "Carl",
    "number": 11,
    "record": "2022-12-10 10:08:53 +0000"
},{
    "name": "Max",
    "number": 4,
    "record": "2022-12-10 10:09:11 +0000"
},{
    "name": "Oscar",
    "number": 21,
    "record": "2022-12-10 10:08:51 +0000"
},{
    "name": "Clive",
    "number": 5,
    "record": "2022-12-10 10:08:59 +0000"
}]
解答例と解説

解答例

jq 'sort_by(.record) | "今回のレースの結果は、優勝がゼッケン\(.[0].number)番の\(.[0].name)選手、準優勝はゼッケン\(.[1].number)番の\(.[1].name)選手でした!"'

https://jqplay.org/s/qD2ZIPn7zFz

解説

  1. 今回の問題ではrecordが早い順番に配列を並び替えて文字列を返すだけなので、まずは組み込みのsort_by関数で並び替えをします。
jq 'sort_by(.record)'
結果
[
  {
    "name": "Oscar",
    "number": 21,
    "record": "2022-12-10 10:08:51 +0000"
  },
  {
    "name": "Carl",
    "number": 11,
    "record": "2022-12-10 10:08:53 +0000"
  },
  {
    "name": "Clive",
    "number": 5,
    "record": "2022-12-10 10:08:59 +0000"
  },
  {
    "name": "John",
    "number": 79,
    "record": "2022-12-10 10:09:01 +0000"
  },
  {
    "name": "Max",
    "number": 4,
    "record": "2022-12-10 10:09:11 +0000"
  }
]
  1. jqでは\(値)を使うことで文字列中に変数展開のようにフィルターした値を組み込むことができます。これを利用して実況用の文章を返します。
jq 'sort_by(.record) | "今回のレースの結果は、優勝がゼッケン\(.[0].number)番の\(.[0].name)選手、準優勝はゼッケン\(.[1].number)番の\(.[1].name)選手でした!"'

# "今回のレースの結果は、優勝がゼッケン21番のOscar選手、準優勝はゼッケン11番のCarl選手でした!"

case3. CSVをJSONに変換する

問題: 以下のCSVをJSONに変換するjqフィルターを作成してください。

学籍番号,名前,国語,数学,英語
1,田中敦規,68,91,78
2,山本隆義,93,63,84
3,木本菜月,85,94,91
4,赤羽沙羅,89,87,83
5,三吉幸哉,92,67,72
6,高橋寿紀,81,100,100
7,高木実,82,74,81
8,石川豪,56,97,96
目標とするJSON出力
[
  {
    "学籍番号": 1,
    "名前": "田中敦規",
    "国語": 68,
    "数学": 91,
    "英語": 78
  },
  {
    "学籍番号": 2,
    "名前": "山本隆義",
    "国語": 93,
    "数学": 63,
    "英語": 84
  },
  {
    "学籍番号": 3,
    "名前": "木本菜月",
    "国語": 85,
    "数学": 94,
    "英語": 91
  },
  {
    "学籍番号": 4,
    "名前": "赤羽沙羅",
    "国語": 89,
    "数学": 87,
    "英語": 83
  },
  {
    "学籍番号": 5,
    "名前": "三吉幸哉",
    "国語": 92,
    "数学": 67,
    "英語": 72
  },
  {
    "学籍番号": 6,
    "名前": "高橋寿紀",
    "国語": 81,
    "数学": 100,
    "英語": 100
  },
  {
    "学籍番号": 7,
    "名前": "高木実",
    "国語": 82,
    "数学": 74,
    "英語": 81
  },
  {
    "学籍番号": 8,
    "名前": "石川豪",
    "国語": 56,
    "数学": 97,
    "英語": 96
  }
]
解答例と解説

解答例

jq --slurp --raw-input 'split("\n") | map(split(","))
| .[0] as $header | .[1:]
| map(. as $content | keys
| reduce .[] as $item ({}; .+({($header[$item]): (try ($content[$item] | tonumber) catch $content[$item])})))'

https://jqplay.org/s/eL3NzR_iWYI

解説

今回の問題は色々な解法があると思いますが、この解説では以下のステップで説明していこうと思います。

  • CSVを二重配列として読み込む
  • ヘッダーとデータ部分に分割する
  • map中でindexを取れるようにする
  • reduceを使って動的にオブジェクトを組み立てる

  1. CSVを二重配列として読み込む
    まずはJSON以外のフォーマットを呼び込むために--raw-inputを、このままだと改行毎に別のinputだと認識されてしまうため--slurpオプションを使用して二重配列で読み込みます。(参照)
jq --slurp --raw-input 'split("\n") | map(split(","))'
結果
[
  [
    "学籍番号",
    "名前",
    "国語",
    "数学",
    "英語"
  ],
  [
    "1",
    "田中敦規",
    "68",
    "91",
    "78"
  ],
  [
    "2",
    "山本隆義",
    "93",
    "63",
    "84"
  ],
  [
    "3",
    "木本菜月",
    "85",
    "94",
    "91"
  ],
  [
    "4",
    "赤羽沙羅",
    "89",
    "87",
    "83"
  ],
  [
    "5",
    "三吉幸哉",
    "92",
    "67",
    "72"
  ],
  [
    "6",
    "高橋寿紀",
    "81",
    "100",
    "100"
  ],
  [
    "7",
    "高木実",
    "82",
    "74",
    "81"
  ],
  [
    "8",
    "石川豪",
    "56",
    "97",
    "96"
  ]
]
  1. ヘッダーとデータ部分に分割する
    次に1で作成した二重配列をヘッダーとデータ部分に分けます。ただ、パイプを使って二つのデータを受け渡すのは大変なので、ここでローカル変数を使います。作成した$header変数はパイプで繋いだ後も使うことができます。
jq --slurp --raw-input 'split("\n") | map(split(",")) | .[0] as $header | .[1:]'
  1. map中でindexを取れるようにする
    最後にJSONを組み立てていきます。汎用性を持たせるため、今回の問題以外のCSVも受け取れるようにフィルターを実装しましょう。
    まずはmapだけではindexを受け取れないため、map(keys)を使ってデータを元にindex配列を作成します。
jq --slurp --raw-input 'split("\n") | map(split(",")) | .[0] as $header | .[1:] | map(. as $content | keys)'
# 結果 [[0,1,2,3,4],[0,1,2,3,4], ...]
  1. reduceを使って動的にオブジェクトを組み立てる
    jqで配列から動的にオブジェクトを生成するには組み込みのreduce関数を使うと良さそうです。
    reduce関数を使うことで空のオブジェクトに動的にキーバリューを足していくことができます。
...
reduce .[] as $item ({}; .+({($header[$item]): $content[$item]}))

これでCSV→JSONは完成です。

Tips

エラーハンドリングについて

try-catch

先ほどtry-catchを紹介しましたが、CIなどで使用する際はエラーハンドリングをしなければjqコマンドが失敗した際に、CIのJob自体もコケてしまいます。

input.json
{
    "id": 1,
    "items": [
        "a",
        "b"
    ]
}
cat input.json | jq '.content | try split(",") catch .'

上記のようにJSONに存在しないcontentを指定するとエラーを吐き出すので、しっかりエラーハンドリングしてあげましょう。

tryだけでcatchを握りつぶしたい時には?も使用できます。try (...) = (...)?

JSONのパースに問題がある場合

JSONのフォーマットがおかしい場合にはその部分をスキップする方法もあるみたいです。
https://github.com/stedolan/jq/issues/884#issuecomment-128437267


さらに使いやすく

自作関数

jqでは組み込み関数以外にも自分で関数を作成して使用することができます。
https://stedolan.github.io/jq/manual/#DefiningFunctions

# 例) def example(args): body

case2の問題を自作関数を使用して解いてみましょう。
実はcase2の解答ではレースの出場者が1人の場合エラーとなってしまいますが、以下のようにすれば1人でもエラーにならずにテキストが表示できます。

jq 'def textByData(data; rank): if rank == 1 then "優勝がゼッケン\(data.number)番の\(data.name)選手" elif rank == 2 then "準優勝がゼッケン\(data.number)番の\(data.name)選手" else "" end;
sort_by(.record) as $ordered | keys | map(textByData($ordered[.]; .+1)
| select(. != "")) | "今回のレースの結果は、" + join("、") + "でした!"'

その他

今回は割愛しますが、自作関数の他にもModuleを作ってファイルをimportしたりI/O機能があったりと極めると面白そうな機能がまだまだたくさんあるようなのでこれを機にどんどん使ってみてください!

おわりに

今回はjqを紹介させていただきました。問題数が少なくて申し訳なかったのですが最後まで読んでいただきありがとうございます。
ここまでできれば個人的にはjq使えますと名乗っても良さそうな気がします!
繰り返しになりますが、本日以降も面白い記事が更新されますのでDeNA 23新卒内定者 Advent Calendar 2022を引き続きよろしくお願いいたします!

https://qiita.com/advent-calendar/2022/dena-23-shinsotsu

Discussion