[シェル] 複数行の出力を同じ1行に出力させる。出力をキャッチしながら改行を削除して同一行出力の処理 [ash/bash/bourne/zsh シェル互換]


シェル・スクリプト内で実行したコマンドの出力を1行表示させたい

macOS や Linux のシェル・スクリプトで、解凍やダウンロードなどのコマンドを使う際、 verbose(詳細な出力)時に複数行で出力される。
進捗確認/プログレスバー的に動いていることが確認できればいいので、出力を改行させずに1行内にカウンターのように収めて表示させたい

  • 従来の進捗表示と、1行で進捗表示のイメージ


「linux シェル 複数行 改行 削除 同一行」でググっても期待する結果が出なかったので、自分のググラビリティとして。(Ash/Bash/bourne/zsh シェル互換です)

TL; DR

コマンド出力をパイプで渡し、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

TS; DR

アーカイブ・ファイルを解凍する際、数十ギガバイトもあると動いているのかハングしているのか分からないことがあります。

そこで -vverbose)で詳細出力させるも、今度は出力行数が多すぎるので困ったのです。カウンター表示のように1行内に表示できないか悩みました。

パイプを使うことは想定できたのですが、sedtr コマンドを使っても全行取得してから処理しているようで、うまく動きませんでした。

ググっても Perl/PHP/Python といったプログラムを使った方法ばかりで、普通にシェルのスクリプトで行いたかったのです。しかもローカルの macOS だけでなく Docker の Alpine Linux でもデフォルトで動くシェル・スクリプトとして。つまり bash, zshash シェルで動くタイプ。

答えは、「パイプで受け取るデータをループで回しながら受け取る」という方法でした。処理を考えてみればその通りなのですが、気づかなかったので備忘録として。

スクリプトの詳しい説明コメント付き
#!/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)すると処理がかなり遅くなるので、modmodulo, 剰余算, %)で描画にインターバル(間隔)を設ける。

参考文献