シェル・スクリプト内で実行したコマンドの出力を1行ごとに表示させたい
macOS や Linux のシェル・スクリプトで、解凍やダウンロードなどのコマンドを使う際、 verbose
(詳細な出力)時に複数行で出力されます。
進捗確認/プログレスバー的に動いていることが確認できればいいので、出力を改行させずに1行内にカウンターのように収めて表示させたいのです。「linux シェル 複数行 改行 削除 同一行」でググっても期待する結果が出なかったので、自分のググラビリティとして。(Ash/Bash/bourne/zsh シェル互換です)
- こんなイメージ 👇(従来の進捗表示と、1行で進捗表示)
TL; DR (今北産業)
-
\r
(キャリッジ・リターン、復帰コード)で、表示開始位置を先頭に戻してから表示する - コマンド出力をパイプで渡し、
read
で受け取りつつwhile
で各行ごとに改行の処理をしながら出力させる - 基本的な考え方
your_command | while read line; do printf '\r%*s\r' ${lenLine:-${#line}} printf "%s" "$line" lenLine=${#line} done echo
具体例と応用例
#!/bin/sh
apt install wget git openssl |
while read line; do
printf '\r%*s\r' ${lenLine:-${#line}}
printf "%s" "$line"
lenLine=${#line}
done
echo
# 全ての行を出力すると描画に時間がかかるため、n 行ごとに出力するとよい。TS; DR 参照
これを応用すれば、自分のスクリプト内で実行しているコマンドの実行結果をインデントさせたり prefix
を付けることができます。
# "composer install --dry-run" で失敗した場合のみ出力結果(エラー内容)をインデント表示する
indent=' '
result=$(composer install --dry-run 2>&1)
status=$?
echo "${result}" |
while read line; do
echo "${indent}${line}"
done
echo
exit $status
- 関連文献: bash の変数内の文字列をインデントして出力する @ Qiita
TS; DR
俺様シェル・スクリプト内で、アーカイブ・ファイルを解凍/展開する際、数十ギガバイトもあると動いているのかハングしているのか分からないことがあります。
そこで -v
(verbose
)で詳細出力させるも、今度は出力行数が多すぎるので困ったのです。カウンター表示のように1行内に表示できないか悩みました。
パイプ(|
)を使うことは想定できたのですが、sed
や tr
コマンドを使っても全行取得してから処理しているようで、うまく動きませんでした。
ググっても Perl/PHP/Python といったプログラムを使った方法ばかりで、普通にシェルのスクリプトで行いたかったのです。しかもローカルの macOS だけでなく Docker の Alpine Linux でもデフォルトで動くシェル・スクリプトとして。つまり bash
, zsh
や ash
シェルで動くタイプ。
答えは、「パイプで受け取るデータをループで回しながら受け取る」という方法でした。処理を考えてみればその通りなのですが、気づかなかったので備忘録として。
#!/bin/sh
# 出力行が多いコマンドの例。これを1行で表示させる
unzip megarchive.zip |
# パイプで受け取った標準出力を1行ごとに処理
while read line; do
# 前の行を削除(前の行と同じ文字数の空白文字に、キャリッジリターン(\r)を前後に付けて削除。未設定の場合は現在の行の文字数)
printf '\r%*s\r' ${lenLine:-${#line}}
# 1行ぶんのデータ表示
printf "%s" "$line"
# 次のループで行を削除できるように、行の長さを取得
lenLine=${#line}
done
echo
上記は表示量(行)が多い場合、本来の速度より遅くなります。これは再描画(出力した行を削除して出力)することによる CLI の制限です。そこで、全てを愚直に出力させずに、どうせ消されていく出力なので n 行ごとに表示すると、かなり軽量化/高速化できます。
#!/bin/sh
# (counter が interval の区切りになるごとに描画させる)
counter=0
interval=100
unzip megarchive.zip |
while read line; do
# counter と interval の剰余(割ったときの余り)が 0 のときのみ描画で遅延防止
if [ $((counter % interval )) -eq 0 ]; then
printf '\r%*s\r' ${lenLine:-${#line}}
printf "%s" "$line"
lenLine=${#line}
fi
# カウンターをカウントアップ
counter=$((counter + 1))
done
echo
いずれにしても、ここでのポイントは、1行ぶんの文字数を取得しておいて、次のループで消しているところです。これをしないと、次の行が前の行より短いと、前の行の一部の表示が残ってしまうのです。
実は、上記のスクリプトに落ち着くまで、以下のようにコンソール(ターミナル)の画面の文字幅を取得して消していました。これは、出力を1行でなく進捗バーも表示するなど、数行で表示したい場合に使われる手法です。しかし、Docker などの Alpine では tpup
を持たないため、tput cols
で画面幅(画面文字数)が取得できないことがありました。
#!/bin/sh
# tput cols でターミナルの横幅(1行ぶんの文字数)を取得
$(tput cols 2>/dev/null 1>/dev/null) && {
WIDTH_SCREEN=$(tput cols);
}
# Docker や CI など tput が使えない(画面幅を取得できない)場合は、WIDTH_SCREEN は
# 未定義になるので 80 文字をデフォルトでセット
WIDTH_SCREEN=${WIDTH_SCREEN:-80}
# 描画による処理遅延を抑えるための初期設定値
# (counter が interval の区切りになるごとに描画させる)
counter=0
interval=100
# your_command は複数行表示される任意のコマンド。tar/unzip/curl/wget などなんでもいい。
your_command |
# その実行結果をパイプ("|")で while コマンドに渡し、1行ごとに read コマンドで
# line 変数に読み込む
while read line; do
# counter と interval の剰余(割ったときの余り)が 0 のときのみ描画で遅延防止
if [ $((counter % interval )) -eq 0 ]; then
# 空行表示。
# "\r" でヘッダーを先頭に戻してから "%*s" で1行ぶん($WIDTH_SCREEN ぶん)
# の空白文字を出力し、"\r" で再度ヘッダを先頭に戻す
printf '\r%*s\r' $WIDTH_SCREEN
# "read" でキャッチした1行ぶんのデータ "line" を、改行を付けずに出力
printf "%s" "$line"
fi
# カウンターをカウントアップ
counter=$((counter + 1))
done
echo # 最後に改行させる
ポイント
- パイプで受け取ったデータを1行ごと(
\n
区切り)で処理したい場合はread
コマンドで取得しつつwhile
ループで処理する。 - 改行コード(
\n
)を付けずにキャリッジリターン・コード(\r
)で出力位置を行頭に戻してから出力すると、同じ行に表示できる。 - 空行(1行ぶんのスペースの文字列)を表示させてから出力すると、前の行のゴミが表示されない。
- 改行の「あり」「なし」が重要な場合、
printf
で出力させておくと、他のシェルでも安定して動く。 - 1行ごとに描画(
echo
printf
)すると処理がかなり遅くなるので、mod
(modulo
, 剰余算,%
)で描画にインターバル(間隔)を設ける。
参考文献
- How to add progress bar to a somearchive.tar.xz extract @ StackOverflow