5

Как добавить индикатор прогресса в shell-скрипт?

11

Проблема: Добавление индикатора прогресса в скрипты оболочки

Когда я пишу скрипты на bash или любой другой оболочке в *NIX, возникает необходимость отображать индикатор прогресса при выполнении команд, которые занимают более нескольких секунд.

Например, это может быть при копировании большого файла или открытии большого tar-архива.

Какие способы добавления индикаторов прогресса в оболочные скрипты вы можете порекомендовать?

5 ответ(ов)

8

Вы можете реализовать это, перезаписывая строку. Используйте \r, чтобы вернуться в начало строки без записи \n в терминал.

Для завершения записи используйте \n, чтобы перейти на новую строку.

Используйте echo -ne, чтобы:

  1. не выводить \n, и
  2. распознавать управляющие последовательности, такие как \r.

Вот пример:

echo -ne '#####                     (33%)\r'
sleep 1
echo -ne '#############             (66%)\r'
sleep 1
echo -ne '#######################   (100%)\r'
echo -ne '\n'

В комментарии ниже пользователь упоминает, что это "не срабатывает", если вы начинаете с длинной строки, а затем хотите записать короткую строку: в этом случае вам нужно перезаписать длину длинной строки (например, с помощью пробелов).

0

Вы можете использовать следующую простую функцию для отображения индикатора процесса, которую я написал на днях:

#!/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'

Вы также можете загрузить его с этой ссылки.

0

Не видел ничего подобного, и все пользовательские функции здесь, кажется, сосредоточены только на визуализации. Поэтому предлагаю очень простое решение, совместимое с 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 до 20
  • echo — вывод в терминал (т.е. в stdout)
  • echo -n — вывод без перехода на новую строку в конце
  • echo -e — интерпретирует специальные символы при выводе
  • "\r" — возврат каретки, специальный символ, который возвращает курсор в начало строки

Вы можете сделать его рендеринг любой информации с любой скоростью, поэтому этот метод очень универсален, часто используется для визуализации "взлома" в смешных фильмах, не шучу.

Полный ответ (от нуля до рабочего примера)

Суть проблемы заключается в том, как определить значение $i, т.е. сколько из прогресс-бара следует отобразить. В приведенном примере я просто позволяю ему увеличиваться в цикле for, чтобы проиллюстрировать принцип, но в реальном приложении будет использоваться бесконечный цикл с расчетом переменной $i на каждой итерации. Для произведения таких расчетов нужны следующие компоненты:

  1. сколько работы нужно выполнить
  2. сколько работы выполнено на данный момент

В случае 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, чтобы он мог угадать, как рассчитывать работу, которую нужно выполнить, и работу, которая была выполнена, — как бы ни было это дело.

Примечания

Меня чаще всего спрашивают о двух вещах:

  1. ${}: в приведенных выше примерах я использую ${foo:A:B}. Технический термин для этого синтаксиса — Расширение параметра, встроенная функция оболочки, позволяющая манипулировать переменной (параметром), например, обрезать строку с помощью : и также выполнять другие операции — это не запускает подпроцесс. Самое весомое описание расширения параметра, которое я могу привести (хотя оно не полностью совместимо с POSIX, но позволяет читателю хорошо понять концепцию) можно найти в разделе man bash.
  2. $(): в приведенных выше примерах я использую 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
0

Вот простой метод, который работает на моей системе с использованием утилиты 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.

0

Вот как это может выглядеть

Загрузка файла

[##################################################] 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"
Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь