Почти каждый сценарий обслуживания сервера начинался скромно: три переменных, один цикл и полсотни строк. Через полгода в файле десятки настроек с именами вроде APP_PORT_WEB и BACKUP_ROOT_2, а новый параметр приходится вписывать сразу в несколько мест, потому что перечень имён повторяется в каждой функции. У Bash есть инструмент посильнее одиночных переменных - массивы, и особенно их ассоциативная разновидность, превращающая набор параметров в словарь с произвольными ключами. Ниже разобраны устройство обоих видов, операции с парами, чтение файла настроек в словарь и одно ограничение, о котором вспоминают на отладке: массивы не уходят в дочерние процессы.

Два вида массивов в оболочке и их разное устройство

В Bash есть два вида массивов, и различие между ними принципиальное. Индексированный массив - упорядоченный список, где место элемента задаётся номером: первым, вторым, десятым. Ассоциативный массив - словарь, где значение ищется по строковому ключу вроде "sshd", "web1" или "/var/log". Ключом становится почти любое слово, нумерация элементов не нужна.

Ассоциативные массивы появились в Bash 4.0, поэтому перед переносом сценария на новый сервер выпуск оболочки проверяют заранее командой bash --version: в базовой поставке отдельных систем встречаются старые ветви, где флаг для словаря не поддержан.

Объявление делается командой declare с флагом -A, где A - заглавная латинская буква:

declare -A ports
ports[sshd]=22
declare -p ports

Вывод последней команды начинается словами declare -A ports, и заглавная буква в них подтверждает словарь. Строчной -a объявляется индексированный массив, и перепутать их легко: путаница работает молча.

Отдельная ловушка ждёт при присваивании без объявления. Строка port[nginx]=80 создаёт индексированный массив: содержимое квадратных скобок вычисляется как арифметическое выражение, переменная nginx не задана, её значением становится ноль, и порт попадает в нулевую ячейку вместо ключа "nginx":

port[nginx]=80
printf '%s\n' "${port[0]}"
declare -p port

Печать выведет 80, а declare -p покажет индексированный массив с одной ячейкой [0]. Такую ошибку находят по второму чтению кода, поэтому правило простое: перед первым присваиванием словарь объявляют явно.

Индексированные массивы для списков хостов, файлов и аргументов

Индексированным массивам место там, где важен порядок, а имена элементов сами по себе ничего не значат: список хостов, очередь каталогов, аргументы команды. Основные операции умещаются в один пример:

hosts=(web1 web2 db1 mon1 backup1)
printf '%s\n' "${hosts[0]}" "${#hosts[@]}"
hosts+=(restore1)
printf 'последний: %s\n' "${hosts[@]: -1}"
printf 'срез: %s\n' "${hosts[@]:1:2}"
for h in "${hosts[@]}"; do
    printf 'host %s\n' "$h"
done

Нумерация начинается с нуля, ${#hosts[@]} возвращает число элементов, оператор += дописывает элемент в конец, конструкция "${hosts[@]: -1}" отдаёт последний элемент, а "${hosts[@]:1:2}" - два элемента, начиная со второго. Пробел перед дефисом в срезе последнего элемента обязателен: без него срабатывает оператор значения по умолчанию. Перебор всегда ведут по кавычкам: "${hosts[@]}" отдаёт каждый элемент отдельно, а $hosts без скобок - только первый элемент. Отсюда частая ошибка: цикл for h in $hosts увидит одну web1 и пропустит остальных.

Объявление ассоциативного массива и операции с парами

Словарь живёт парами из ключа и значения: запись ports[nginx]=80, чтение ${ports[nginx]}. Ключ остаётся строкой, оболочка не вычисляет его как выражение, в отличие от индексированной разновидности. Базовый набор операций выглядит так:

declare -A ports=([sshd]=22 [nginx]=80 [postgres]=5432)
ports+=([redis]=6379)
printf '%s\n' "${ports[postgres]}"
printf '%s\n' "${#ports[@]}"
printf '%s\n' "${!ports[@]}"
printf '%s\n' "${ports[@]}"

Первая строка объявляет словарь и наполняет его одним выражением. Оператор += добавляет пару, ${#ports[@]} считает пары, ${!ports[@]} отдаёт ключи, ${ports[@]} - значения. Чтение по отсутствующему ключу возвращает пустую строку, и молчание здесь коварно: опечатка в имени службы не остановит сценарий, а просто оставит пустоту на её месте. Явная проверка делается оператором -v:

if [[ -v ports[nginx] ]]; then
    printf 'порт задан: %s\n' "${ports[nginx]}"
fi
[[ -v ports[memcached] ]] || printf 'memcached не задан\n'

Оператор -v работает в обе стороны: первая ветка сообщает о заданном порте, вторая ловит отсутствие ключа memcached. Удаление пары делается командой unset, и ключ в ней заключают в кавычки: unset 'ports[nginx]' защищён от неожиданностей, когда ключ содержит звёздочку или другой знак шаблона.

Один неочевидный факт: порядок перебора ключей словаря не гарантируется. Ключи приходят в порядке внутреннего хранилища, не по алфавиту и не по времени добавления, поэтому для отчётов порядок наводят сортировкой ключей, приём показан ниже, в разделе про файл настроек.

Соответствия между службами, портами и каталогами на сервере

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

declare -A service
service[web1]=443
service[db1]=5432
for name in "${!service[@]}"; do
    port="${service[$name]}"
    if timeout 2 bash -c "</dev/tcp/$name/$port" 2>/dev/null; then
        printf '%-8s порт %s принимает соединения\n' "$name" "$port"
    else
        printf '%-8s порт %s закрыт\n' "$name" "$port"
    fi
done

Ключ здесь - имя хоста, значение - порт. Псевдоустройство /dev/tcp открывает TCP-соединение силами самой оболочки, а timeout ограничивает ожидание двумя секундами.

Второй сценарий распределяет роли по хостам:

declare -A role=([web1]=frontend [db1]=database)
for host in "${!role[@]}"; do
    printf '%-8s %s\n' "$host" "${role[$host]}"
done

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

declare -A retention
retention[/var/log/nginx]=30
retention[/var/backups/mysql]=90
retention[/srv/cache]=7
for dir in "${!retention[@]}"; do
    days="${retention[$dir]}"
    find "$dir" -type f -mtime +"$days" -print
done

Ключом выступает путь, значением - число дней. Команда find печатает файлы старше срока, и её легко ужесточить до удаления, добавив ключ -delete в конец. Политика очистки целиком живёт в словаре: добавление каталога не трогает логику цикла.

Чтение файла настроек в словарь и отчёт по парам

Конфигурация чаще хранится не в коде сценария, а в отдельном файле вида "ключ равно значение". Пример файла /etc/myapp/settings.conf:

listen_port=8080
worker_count=4
log_dir=/var/log/myapp

Разбор в словарь делается циклом с read и настройкой разделителя:

declare -A cfg
while IFS='=' read -r key value; do
    [[ $key == "#"* || -z $key ]] && continue
    cfg[$key]="$value"
done < /etc/myapp/settings.conf

Строка делится по первому знаку равенства, а всё после него уходит в переменную value целиком, поэтому значение с собственными знаками равенства внутри сохраняется без искажений. Проверка [[ $key == "#"* ]] отбрасывает комментарии, а -z $key - пустые строки.

Дальше файлом работают как словарём. Обязательные параметры проверяют до старта:

for need in listen_port log_dir; do
    [[ -v cfg[$need] ]] || { printf 'нет параметра %s\n' "$need"; exit 1; }
done
listen="${cfg[listen_port]:-8080}"
workers="${cfg[worker_count]:-2}"

Подстановка ${cfg[listen_port]:-8080} подставляет значение по умолчанию, когда ключа нет. Форма с двоеточием и дефисом срабатывает и на пустое значение, форма с одним двоеточием заменяет только отсутствующий ключ - тонкая разница, которая экономит час отладки.

Отчёт по парам - обратная задача: словарь превращается в читаемую таблицу:

while read -r key; do
    printf '%-14s %s\n' "$key" "${cfg[$key]}"
done < <(printf '%s\n' "${!cfg[@]}" | sort)

Подстановка процесса < <(...) передаёт циклу отсортированный список ключей, и порядок строк становится предсказуемым. Словарь служит дважды: сначала собирает разрозненные строки файла в единый набор, потом отдаёт набор в удобном для глаза виде. Россыпь одиночных переменных потребовала бы ручного перечня имён, дублирующего файл настроек.

Словарь вместо одиночных переменных и передача набора в функцию

Одиночные переменные не виноваты в том, что сценарии разрастаются, но они делают рост болезненным: имена выдумывают, перечни имён копируют из функции в функцию, переименование настройки оборачивается правкой во многих местах. Словарь снимает эти проблемы, и выгода складывается из конкретных слагаемых:

  1. перебор всех пар одним циклом без ручного перечня имён;
  2. добавление настройки одной строкой без правки соседних участков кода;
  3. проверка наличия ключа оператором -v без вспомогательного флага;
  4. передача всего набора в функцию одним аргументом, именем словаря.

Передача набора в функцию решается по-разному в зависимости от выпуска оболочки. Свежие ветви Bash располагают ссылками nameref, и функция получает словарь по имени:

show_map() {
    local -n ref=$1
    local key
    for key in "${!ref[@]}"; do
        printf '%-12s %s\n' "$key" "${ref[$key]}"
    done
}
show_map ports

Объявление local -n ref=$1 создаёт ссылку на словарь, и внутри функции она читается как сам словарь. Тонкость одна: имя ссылки не должно совпадать с именем передаваемой переменной, иначе ссылка замыкается сама на себя. В старых выпусках те же задачи решают плоскими аргументами вида "ключ=значение": пара разбирается подстановками ${kv%%=} для ключа и ${kv#=} для значения, после чего пары складываются в локальный словарь внутри функции.

Ограничение экспорта массивов и гигиена ключей словаря

Теперь о загвоздке, о которой обычно узнают на отладке: команда export передаёт дочерним процессам только скалярные переменные. Массив, и индексированный, и ассоциативный, в окружение не попадает, и дочерний сценарий увидит пустоту там, где родитель хранил десяток пар. Обходов три, и выбор зависит от задачи. Первый - сериализация через declare -p:

declare -p ports > /run/myapp/ports.env
bash /usr/local/bin/worker.sh

Файл ports.env получает готовую команду объявления словаря, и дочерний сценарий воссоздаёт набор одной строкой source /run/myapp/ports.env. Второй обход - вызов с параметрами: родитель передаёт пары аргументами командной строки, дочерний сценарий разбирает их подстановками из предыдущего раздела. Третий - переменная окружения со склеенной строкой "ключ:значение;ключ:значение", которую дочерний процесс разрезает по разделителям. Всё, что потом исполняется через source или eval, годится только для доверенных настроек: файл с чужими правами записи вместо настройки превращается в произвольную команду.

Гигиена ключей держит словарь предсказуемым. Повторное присваивание по существующему ключу затирает прежнее значение без предупреждения, поэтому при слиянии двух списков действует запись из последнего присваивания:

declare -A owner
owner[web1]=ansible
owner[web1]=puppet
printf '%s\n' "${owner[web1]}"

Ключи с пробелами и спецсимволами заключают в кавычки в каждой позиции:

declare -A limits
limits["backup dir"]=14
printf '%s\n' "${limits[backup dir]}"
unset 'limits[backup dir]'

Пустые значения проверкам не мешают: оператор -v отличает отсутствующий ключ от ключа с пустой строкой, а подстановка ${limits[k]:-} даёт замену пустоте. При включённом режиме set -u обращение к пустому массиву в старых выпусках оболочки прерывало сценарий с сообщением про unbound variable, поэтому в чужих сценариях встречается страховочная конструкция ${args[@]+"${args[@]}"}, подставляющая элементы только когда массив непуст. Ещё одна тонкость в именах: массив и скаляр с одним именем не уживаются, и объявление массива молча затирает скаляр.

Там, где настроек две или три, одиночные переменные честнее: короче, нагляднее, без обвязки из declare. Граница проходит по перебору. Как только настройки требуется обойти циклом, передать в функцию целиком или сверить со списком обязательных имён, словарь окупает лишнюю строку объявления. Он же дисциплинирует формат файла настроек: ключи обязаны быть уникальными, значения - простыми строками, а весь набор - цельным объектом, а не десятком имён, связанных только памятью автора сценария.