Как добавить индикатор прогресса в shell-скрипт?
Проблема: Добавление индикатора прогресса в скрипты оболочки
Когда я пишу скрипты на bash или любой другой оболочке в *NIX, возникает необходимость отображать индикатор прогресса при выполнении команд, которые занимают более нескольких секунд.
Например, это может быть при копировании большого файла или открытии большого tar-архива.
Какие способы добавления индикаторов прогресса в оболочные скрипты вы можете порекомендовать?
5 ответ(ов)
Вы можете реализовать это, перезаписывая строку. Используйте \r
, чтобы вернуться в начало строки без записи \n
в терминал.
Для завершения записи используйте \n
, чтобы перейти на новую строку.
Используйте echo -ne
, чтобы:
- не выводить
\n
, и - распознавать управляющие последовательности, такие как
\r
.
Вот пример:
echo -ne '##### (33%)\r'
sleep 1
echo -ne '############# (66%)\r'
sleep 1
echo -ne '####################### (100%)\r'
echo -ne '\n'
В комментарии ниже пользователь упоминает, что это "не срабатывает", если вы начинаете с длинной строки, а затем хотите записать короткую строку: в этом случае вам нужно перезаписать длину длинной строки (например, с помощью пробелов).
Вы можете использовать следующую простую функцию для отображения индикатора процесса, которую я написал на днях:
#!/bin/bash
# 1. Создание функции ProgressBar
# 1.1 Входные параметры: currentState($1) и totalState($2)
function ProgressBar {
# Обработка данных
let _progress=(${1}*100/${2}*100)/100
let _done=(${_progress}*4)/10
let _left=40-$_done
# Формирование строк для индикатора прогресса
_fill=$(printf "%${_done}s")
_empty=$(printf "%${_left}s")
# 1.2 Формирование строк для индикатора и вывод строки индикатора
# 1.2.1 Пример результата:
# 1.2.1.1 Progress : [########################################] 100%
printf "\rProgress : [${_fill// /#}${_empty// /-}] ${_progress}%%"
}
# Переменные
_start=1
# Это значение используется как переменная "totalState" для функции ProgressBar
_end=100
# Доказательство концепции
for number in $(seq ${_start} ${_end})
do
sleep 0.1
ProgressBar ${number} ${_end}
done
printf '\nFinished!\n'
Вы также можете загрузить его с этой ссылки.
Не видел ничего подобного, и все пользовательские функции здесь, кажется, сосредоточены только на визуализации. Поэтому предлагаю очень простое решение, совместимое с POSIX, с пошаговыми объяснениями, так как этот вопрос не тривиален.
Кратко
Рендеринг прогресс-бара очень прост. Оценка того, сколько его нужно отобразить, — это совсем другое дело. Вот как рендерить (анимировать) прогресс-бар — вы можете скопировать и вставить этот пример в файл и запустить его:
#!/bin/sh
BAR='####################' # это полный бар, например, 20 символов
for i in {1..20}; do
echo -ne "\r${BAR:0:$i}" # выводим $i символов из $BAR с позиции 0
sleep .1 # ждём 100 мс между "кадрами"
done
{1..20}
— значения от 1 до 20echo
— вывод в терминал (т.е. вstdout
)echo -n
— вывод без перехода на новую строку в концеecho -e
— интерпретирует специальные символы при выводе"\r"
— возврат каретки, специальный символ, который возвращает курсор в начало строки
Вы можете сделать его рендеринг любой информации с любой скоростью, поэтому этот метод очень универсален, часто используется для визуализации "взлома" в смешных фильмах, не шучу.
Полный ответ (от нуля до рабочего примера)
Суть проблемы заключается в том, как определить значение $i
, т.е. сколько из прогресс-бара следует отобразить. В приведенном примере я просто позволяю ему увеличиваться в цикле for
, чтобы проиллюстрировать принцип, но в реальном приложении будет использоваться бесконечный цикл с расчетом переменной $i
на каждой итерации. Для произведения таких расчетов нужны следующие компоненты:
- сколько работы нужно выполнить
- сколько работы выполнено на данный момент
В случае cp
нужны размер исходного файла и размер целевого файла:
#!/bin/sh
src="/path/to/source/file"
tgt="/path/to/target/file"
cp "$src" "$tgt" & # амперсанд запускает процесс `cp`, так что
# остальная часть кода выполняется без ожидания (асинхронно)
BAR='####################'
src_size=$(stat -c%s "$src") # сколько работы нужно сделать
while true; do
tgt_size=$(stat -c%s "$tgt") # сколько работы сделано на данный момент
i=$(( $tgt_size * 20 / $src_size ))
echo -ne "\r${BAR:0:$i}"
if [ $tgt_size == $src_size ]; then
echo "" # добавляем новую строку в конце
break; # выходим из цикла
fi
sleep .1
done
foo=$(bar)
— выполняемbar
в подпроцессе и сохраняем егоstdout
в$foo
stat
— выводит статистику файла вstdout
stat -c
— выводит отформатированное значение%s
— формат для общего размера
В случае операций, таких как распаковка файлов, расчет размера исходного файла немного сложнее, но все равно так же просто, как получение размера некомпрессированного файла:
#!/bin/sh
src_size=$(gzip -l "$src" | tail -n1 | tr -s ' ' | cut -d' ' -f3)
gzip -l
— выводит информацию о zip-архивеtail -n1
— работает с одной строкой снизуtr -s ' '
— заменяет несколько пробелов на один ("сжимает" их)cut -d' ' -f3
— извлекает 3-й разделенный пробелом элемент (столбец)
Вот и суть проблемы, о которой я упоминал ранее. Это решение становится все более специфичным. Все расчеты фактического прогресса жестко привязаны к области, которую вы пытаетесь визуализировать: одно операция с файлом, обратный отсчет таймера, увеличивающееся число файлов в директории, работа с несколькими файлами и т.д., поэтому его нельзя повторно использовать. Единственная воспроизводимая часть — это рендеринг прогресс-бара. Для того чтобы использовать его повторно, вам нужно абстрагировать его и сохранить в файл (например, /usr/lib/progress_bar.sh
), затем определить функции, которые рассчитывают входные значения, специфичные для вашей области. Вот как может выглядеть обобщенный код (я также сделал $BAR
динамическим, потому что спрашивали об этом; остальное должно быть понятно на данный момент):
#!/bin/bash
BAR_length=50
BAR_character='#'
BAR=$(printf %${BAR_length}s | tr ' ' $BAR_character)
work_todo=$(get_work_todo) # сколько работы нужно сделать
while true; do
work_done=$(get_work_done) # сколько работы сделано на данный момент
i=$(( $work_done * $BAR_length / $work_todo ))
echo -ne "\r${BAR:0:$i}"
if [ $work_done == $work_todo ]; then
echo ""
break;
fi
sleep .1
done
printf
— встроенная команда для вывода информации в заданном форматеprintf %50s
— ничего не печатает, но заполняет пробелами 50 символовtr ' ' '#'
— заменяет каждый пробел на знак решетки
А вот как вы могли бы его использовать:
#!/bin/bash
src="/path/to/source/file"
tgt="/path/to/target/file"
function get_work_todo() {
echo $(stat -c%s "$src")
}
function get_work_done() {
[ -e "$tgt" ] && # если целевой файл существует
echo $(stat -c%s "$tgt") || # вывести его размер, иначе
echo 0 # вывести ноль
}
cp "$src" "$tgt" & # копируем в фоновом режиме
source /usr/lib/progress_bar.sh # запускаем прогресс-бар
Очевидно, вы можете завернуть это в функцию, переписать, чтобы работать с конвейерными потоками, захватить идентификатор создаваемого процесса с помощью $!
и передать его progress_bar.sh
, чтобы он мог угадать, как рассчитывать работу, которую нужно выполнить, и работу, которая была выполнена, — как бы ни было это дело.
Примечания
Меня чаще всего спрашивают о двух вещах:
${}
: в приведенных выше примерах я использую${foo:A:B}
. Технический термин для этого синтаксиса — Расширение параметра, встроенная функция оболочки, позволяющая манипулировать переменной (параметром), например, обрезать строку с помощью:
и также выполнять другие операции — это не запускает подпроцесс. Самое весомое описание расширения параметра, которое я могу привести (хотя оно не полностью совместимо с POSIX, но позволяет читателю хорошо понять концепцию) можно найти в разделеman bash
.$()
: в приведенных выше примерах я используюfoo=$(bar)
. Это запускает отдельную оболочку в подпроцессе (также известную как подоболочка), выполняет командуbar
в ней и присваивает ее стандартный вывод переменной$foo
. Это не то же самое, что Подстановка процесса, и это совершенно другое, чем конвейер (|
). Самое главное, это работает. Некоторые говорят, что это следует избегать, потому что это медленно. Я утверждаю, что это приемлемо, потому что то, что этот код пытается визуализировать, длится достаточно долго, чтобы оправдать использование прогресс-бара. Другими словами, подпроцессы не являются узким местом. Вызов подпроцесса также избавляет меня от необходимости объяснять, почемуreturn
не то, что большинство людей думает, что это, что такое Статус выхода и почему передача значений из функций в шеллы не является тем, для чего функции шеллов полезны в основном. Чтобы узнать больше обо всем этом, еще раз рекомендую страницуman bash
.
Устранение неполадок
Если ваша оболочка на самом деле запускает sh вместо bash или очень старую версию bash, как в стандартном OSX, она может выдать ошибку Bad substitution
в случае echo -ne "\r${BAR:0:$i}"
. Если это произойдет, по комментарию, вы можете вместо этого использовать echo -ne "\r$(expr "x$name" : "x.\{0,$num_skip\}\(.\{0,$num_keep\}\)")"
для более переносимого совместимого с POSIX / менее читабельного сопоставления подстрок.
Полный работающий пример для /bin/sh
:
#!/bin/sh
src=100
tgt=0
get_work_todo() {
echo $src
}
do_work() {
echo "$(( $1 + 1 ))"
}
BAR_length=50
BAR_character='#'
BAR=$(printf %${BAR_length}s | tr ' ' $BAR_character)
work_todo=$(get_work_todo) # сколько работы нужно сделать
work_done=0
while true; do
work_done="$(do_work $work_done)"
i=$(( $work_done * $BAR_length / $work_todo ))
n=$(( $BAR_length - $i ))
printf "\r$(expr "x$BAR" : "x.\{0,$n\}\(.\{0,$i\}\)")"
if [ $work_done = $work_todo ]; then
echo "\n"
break;
fi
sleep .1
done
Вот простой метод, который работает на моей системе с использованием утилиты pipeview (pv):
srcdir=$1
outfile=$2
tar -Ocf - $srcdir | pv -i 1 -w 50 -berps $(du -bs $srcdir | awk '{print $1}') | 7za a -si $outfile
В данном коде мы сначала создаем архив с помощью tar
, который выводит данные в стандартный поток. Затем, используя pv
, мы можем отслеживать прогресс архивации, задавая интервал обновления (в данном случае 1 секунда) и ширину прогресс-бара (50 символов). du -bs
определяет размер исходной директории, который передается в pv
для корректного отображения прогресса. Наконец, 7za
используется для упаковки полученного потока данных в файл с именем $outfile
.
Вот как это может выглядеть
Загрузка файла
[##################################################] 100% (137921 / 137921 байт)
Ожидание завершения задачи
[######################### ] 50% (15 / 30 секунд)
Простая функция, реализующая это
Вы можете просто скопировать и вставить этот код в ваш скрипт. Он не требует ничего другого для работы.
PROGRESS_BAR_WIDTH=50 # длина полосы прогресса в символах
draw_progress_bar() {
# Аргументы: текущее значение, максимальное значение, единица измерения (необязательно)
local __value=$1
local __max=$2
local __unit=${3:-""} # если единица не указана, не отображать её
# Расчет процента
if (( $__max < 1 )); then __max=1; fi # защита от деления на ноль
local __percentage=$(( 100 - ($__max*100 - $__value*100) / $__max ))
# Масштабирование полосы в соответствии с шириной полосы прогресса
local __num_bar=$(( $__percentage * $PROGRESS_BAR_WIDTH / 100 ))
# Рисуем полосу прогресса
printf "["
for b in $(seq 1 $__num_bar); do printf "#"; done
for s in $(seq 1 $(( $PROGRESS_BAR_WIDTH - $__num_bar ))); do printf " "; done
printf "] $__percentage%% ($__value / $__max $__unit)\r"
}
Пример использования
Здесь мы загружаем файл и перерисовываем полосу прогресса на каждой итерации. Не имеет значения, какая именно работа выполняется, главное, чтобы мы могли получить 2 значения: максимальное значение и текущее значение.
В приведенном ниже примере максимальное значение — это file_size
, а текущее значение предоставляется некоторой функцией и называется uploaded_bytes
.
# Загрузка файла
file_size=137921
while true; do
# Получаем текущее значение загруженных байтов
uploaded_bytes=$(some_function_that_reports_progress)
# Рисуем полосу прогресса
draw_progress_bar $uploaded_bytes $file_size "байт"
# Проверяем, достигли ли мы 100%
if [ $uploaded_bytes == $file_size ]; then break; fi
sleep 1 # Ждем перед перерисовкой
done
# Переходим на новую строку в конце загрузки
printf "\n"
Как выводить команды оболочки по мере их выполнения
Назначение значений по умолчанию для переменных оболочки одной командой в bash
Как указать приватный SSH-ключ для выполнения команды shell в Git?
Как узнать, в какой интерактивной оболочке я нахожусь (командная строка)
Расширение переменных внутри одинарных кавычек в команде Bash