【Jqコマンド応用編】xargsをwhile/forループの代わりとして使う場合の勘所


2021/05/19
蛸壺の技術ブログ|xargsをwhile/forループの代わりとして使う場合の勘所

一般的にシェルスクリプトは外部コマンドを組み合わせて実装されるプログラミングスタイルをとることが多いため、処理パフォーマンスを気にせす思いのままにプログラムを組んでいくと、最終的に仕上がったものは非常に処理が重く使いものにならないことがあります。

そんなときには、
外部コマンドの呼び出しの箇所・回数を考慮したリファクタリングを行うことでパフォーマンスの改善が可能です。

今回はそんなリファクタリングの代名詞的な、
『処理のパイプ化』する話に関連して、jqコマンドとxargsコマンドとの組み合わせを考えてみます。


Jqは重いコマンド

JqはシェルコマンドでJSONを扱うには非常に強力で便利過ぎるユーティリティなのですが、だからといってやたらめったらスクリプト中に呼び出してしまうと、過ぎたるは及ばざるが如し、完成したプログラムのレスポンスの遅さにびっくりするときがあります...

ということでJqは良く
リファクタリングのやり玉に挙げられる話を目にしますが、今回はどのくらい遅くなるのかを検証するのがこの記事の内容です。

先に結論を挙げておくと、

            
            1. リファクタリングの観点から、Jqコマンドを含む重めの外部コマンドのループ処理は、
    可能な限りパイプライン化したほうがパフォーマンスは向上する。
2. ただし、そもそもjqコマンドをループさせる手法がパフォーマンス的によろしくない。
    大幅な高速化を求めるのではあれば、JqではなくAwkやCなどの言語で作成した
    コマンドツールなどでスクリプトそのものを見直す。
        
というお話を以降で具体例で確認していきます。


jqでのループ処理

jqでの配列の要素を編集するのにもっともお手軽なものはwhileかforを使ったスクリプトだと思います。

処理速度の計測用にまずは以下のようなJSON形式のデータセットを用意しておきます。

            
            employee=$(cat << EOF
[
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"本社","勤続":"3年"},
    {"社員":"島田フガ子","部署":"経理部","事業所":"名古屋支部","勤続":"15年"},
    {"社員":"岡田ピポ太","部署":"製造部","事業所":"山口工場","勤続":"8年"},
    {"社員":"沢口モフ代","部署":"人事部","事業所":"本社","勤続":"4年"},
    {"社員":"銭形ガメ吉","部署":"海外部","事業所":"メキシコ支部","勤続":"11年"},
    {"社員":"上岡ムメ美","部署":"営業部","事業所":"本社","勤続":"23年"},
    {"社員":"京谷マハ次","部署":"製造部","事業所":"山口工場","勤続":"3年"},
    {"社員":"園田フマ由","部署":"人事部","事業所":"本社","勤続":"17年"},
    {"社員":"田川ポゥ子","部署":"製造部","事業所":"ベトナム工場","勤続":"12年"},
    {"社員":"満田クタ郎","部署":"営業部","事業所":"本社","勤続":"2年"},
    {"社員":"島寺ルン大","部署":"営業部","事業所":"本社","勤続":"18年"},
    {"社員":"香下ウル蔵","部署":"製造部","事業所":"山口工場","勤続":"5年"},
    {"社員":"蒲田ウオ奈","部署":"海外部","事業所":"メキシコ支部","勤続":"9年"},
    {"社員":"郷田ポポ生","部署":"営業部","事業所":"名古屋支部","勤続":"4年"},
    {"社員":"梅岡ボル伍","部署":"経理部","事業所":"本社","勤続":"25年"},
    {"社員":"亀川ヲル士","部署":"製造部","事業所":"山口工場","勤続":"14年"}
]
EOF
)
        
まずはjqをループ処理に使ってしまうある意味最悪のケースについて考えてみます。

            
            #!/bin/bash

employee=$(cat << EOF
[
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"本社","勤続":"3年"},
    #中略...上記で説明したデータセット
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"埼玉支部","勤続":"3年"},
]
EOF
)

employee_alt='[]'
for person in $(echo "$employee" | jq -c '.[]'); do
    person=$(
        echo $person | jq -c --arg division "営業部" --arg branch "埼玉支店" '
        (.["部署"] |= $division) | (.["事業所"] |= $branch)
    ')
    employee_alt=$(echo "${employee_alt}" | jq -s -c --argjson person "$person" '.[] + [ $person ]')
done

echo "${employee_alt}" | jq -c '.'
        
このスクリプトのパフォーマンスを計測してみると手元のパソコン上では以下のようになります。

            
            $ hyperfine 'bash 1.sh'
Benchmark #1: bash 1.sh
  Time (mean ± σ):      1.084 s ±  0.014 s    [User: 1.044 s, System: 0.054 s]
  Range (min … max):    1.068 s …  1.117 s    10 runs
        
たったJSON要素16項目をさばくのに平均で1084msかかっているようです。

正直これでは100項目もあるJSON要素で処理させたら一体何分かかるのか...

xargsで処理パイプ化

先ほどの例ではjqを使ってJSON形式の配列の要素を評価するために、forループで処理を回してきたわけですが、一般的にループ内での外部コマンド呼び出しは処理負荷が大きいことが分かります。

そこでループ処理の代わりに、
xargsコマンドの力を借りてパイプで処理することである程度の処理速度の向上が見込まれます。

参照先の記事の方にxargsの基本的な用法が解説してあるので、パイプ化の理屈の方は詳しくはそちらを確認していただくことにして、要は、

            
            $ [CMD1] | xargs -n1 bash -c '[CMD2 $0]'
        
というような感じで配列のmapメソッドのように扱えるのがミソになります。

早速例として以下のスクリプトを評価してみます。

            
            #!/bin/bash

employee=$(cat << EOF
[
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"本社","勤続":"3年"},
    #中略...上記で説明したデータセット
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"埼玉支部","勤続":"3年"},
]
EOF
)

employee=$(
echo "$employee" | jq -c '.[]' | sed -e 's/"/\\"/g' | xargs -n1 bash -c '
    echo "$0" | jq -c "(.[\"部署\"] |= \"営業部\") | (.[\"事業所\"] |= \"埼玉支店\")"
')

echo "["${employee//$'\n'/,}"]" | jq -c '.'
        
このスクリプトの性質上、bash -cを使う副作用として、パイプで送られる標準出力のダブルクォーテーション記号"が文字エスケープなしでは消えてしまうので、sed -e 's/"/\\"/g'を挟んでエスケープするように気を使うのも難点です。

ベンチマークを行うと、

            
            $ hyperfine 'bash 2.sh'
Benchmark #1: bash 2.sh
  Time (mean ± σ):     589.0 ms ±   9.8 ms    [User: 561.7 ms, System: 35.4 ms]
  Range (min … max):   578.2 ms … 614.0 ms    10 runs
        
半減とは行かないまでも、割と処理がスリムになりました。

このようにxargsを使うケースだと、
bash -c '[CMD2 $0]'という作法でコマンドを文字列へ無理に押し込むような形でスクリプトを実行する必要があり、肝心の処理コードがとても書きにくくなります。

そこで別に処理をさせるために外部で定義した関数を呼び出せるようにするには、
export -fでサブシェルでも扱えるようにしておく必要があります。

以下のように少しだけ先程のスクリプトを修正してみます。

            
            #!/bin/bash

employee=$(cat << EOF
[
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"本社","勤続":"3年"},
    #中略...上記で説明したデータセット
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"埼玉支部","勤続":"3年"},
]
EOF
)

function mod() {
    local division='営業部'
    local branch='埼玉支店'
    echo "$1" | jq -c '. | (.["部署"] |= "'$division'") | (.["事業所"] |= "'$branch'")'
}
export -f mod

employee=$(
echo "$employee" | jq -c '.[]' | sed -e 's/"/\\"/g' | xargs -n1 bash -c '
    mod "$0"
')

echo "["${employee//$'\n'/,}"]" | jq -c '.'
        
このスクリプトの処理速度も計測すると、

            
            $ hyperfine 'bash 3.sh'
Benchmark #1: bash 3.sh
  Time (mean ± σ):     591.0 ms ±   7.1 ms    [User: 563.1 ms, System: 36.3 ms]
  Range (min … max):   579.8 ms … 603.0 ms    10 runs
        
となり、外部定義の関数を利用してもパフォーマンスにはほぼ影響が出ないことが分かります。

Jqネイティブのmap関数

今回の例のようにJSON配列を使って複雑な処理をさせるのでなければ、そもそもmap関数で事足りる場合もあります。

            
            #!/bin/bash

employee=$(cat << EOF
[
    {"社員":"山下モゲ雄","部署":"営業部","事業所":"本社","勤続":"3年"},
    #中略...上記で説明したデータセット
    {"社員":"亀川ヲル士","部署":"製造部","事業所":"山口工場","勤続":"14年"}
]
EOF
)

echo "$employee" | jq -c --arg division "営業部" --arg branch "埼玉支店" 'map((.["部署"] |= $division) | (.["事業所"] |= $branch))'
        
これを計測してみると、以下のようにメチャ速になります。

            
            hyperfine 'bash 4.sh'
Benchmark #1: bash 4.sh
  Time (mean ± σ):      34.3 ms ±   3.3 ms    [User: 33.0 ms, System: 1.8 ms]
  Range (min … max):    29.5 ms …  47.5 ms    62 runs
        
簡単なJSON配列の操作であれば、ビルドインのmap関数を使ったほうが良いでしょう。


番外編〜Awkで

そもそもjqが便利なのは他のデータフォーマットからJSON形式への変換であって、内部要素のごとの細かな計算処理を作り込むには不向きな面があります。

jqを用いたプログラムの処理速度が重くなって、期待通りに動かないのあれば、いっそのことJSON形式でのデータ構造を見直し、繰り返し処理などにjqを使わないのも手です。

その点、Awkは素朴なテキスト形式のデータ構造しか扱えませんが、内部で高度な計算処理を高速で行ってくれますので、今回の場合のリファクタリングにはうってつけです。

入力データはAwkで読み込ませる前提の以下のようなCsv形式で与えておきます。

            
            employee=$(cat <<EOF
山下モゲ雄,営業部,本社,3年
島田フガ子,経理部,名古屋支部,15年
岡田ピポ太,製造部,山口工場,8年
沢口モフ代,人事部,本社,4年
銭形ガメ吉,海外部,メキシコ支部,11年
上岡ムメ美,営業部,本社,23年
京谷マハ次,製造部,山口工場,3年
園田フマ由,人事部,本社,17年
田川ポゥ子,製造部,ベトナム工場,12年
満田クタ郎,営業部,本社,2年
島寺ルン大,営業部,本社,18年
香下ウル蔵,製造部,山口工場,5年
蒲田ウオ奈,海外部,メキシコ支部,9年
郷田ポポ生,営業部,名古屋支部,4年
梅岡ボル伍,経理部,本社,25年
亀川ヲル士,製造部,山口工場,14年
EOF
)
        
なお、CsvデータをJSON形式に変換するやり方は以前の記事で解説したので気になるかたはそちらを参照ください。

処理時間を計測するスクリプトは以下の通りに修正します。

            
            #!/bin/bash

employee=$(cat <<EOF
山下モゲ雄,営業部,本社,3年
#中略...上記で説明したデータ
亀川ヲル士,製造部,山口工場,14年
EOF
)

echo "$employee" | awk -F"," '
    BEGIN{OFS=","}
    {print $1,"営業部","埼玉支部",$4}
' | jq -c -s -R '
[split("\n")[] | select(length > 0) | split(",")]
    | map({"社員":.[0],"部署":.[1],"事業所":.[2],"勤続":.[3]})
'
        
これを実行するとループ内やパイプラインでjqを使うよりも以下のように圧倒的に早く処理できることが分かります。

            
            $ hyperfine 'bash 5.sh'
Benchmark #1: bash 5.sh
  Time (mean ± σ):      34.3 ms ±   2.9 ms    [User: 33.5 ms, System: 2.0 ms]
  Range (min … max):    30.2 ms …  44.7 ms    65 runs
        

まとめ

では今回の考察で得られたエッセンスの繰り返しになりますが、

            
            1. リファクタリングの観点から、Jqコマンドを含む重めの外部コマンドのループ処理は、
    可能な限りパイプライン化したほうがパフォーマンスは向上する。
2. ただし、そもそもjqコマンドをループさせる手法がパフォーマンス的によろしくない。
    大幅な高速化を求めるのではあれば、JqではなくAwkやCなどの言語で作成した
    コマンドツールなどでスクリプトそのものを見直す。
        
以上です。

参考サイト

xargsでサクッと書く

xargs で function を呼び出す話。

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。