JSON давно стал рабочим языком серверной среды: ответы интерфейсов приложений, конфигурационные файлы, построчные логи, экспорт метрик. Когда нужное поле прячется на четвёртом уровне вложенности, руки тянутся к grep и sed, и здесь ждёт сюрприз: текстовым утилитам всё равно, где стоит слово "error" - в имени поля, в его значении или в элементе двумя ветками ниже, а кавычки и запятые превращают каждый шаблон в хрупкую конструкцию. Jq подходит к задаче честнее. Утилита разбирает документ в дерево и даёт миниатюрный язык фильтров: по дереву можно ходить, выбирать ветви, пересобирать объекты, печатать результат в компактном или читаемом виде. Пакет с таким именем лежит в репозиториях большинства дистрибутивов. Ниже базовая механика и сценарии, где jq экономит реальные минуты: разбор ответов интерфейсов, проверка конфигурации, преобразование вывода для соседних утилит и сборка JSON с нуля.

Базовые фильтры jq и путь к нужному полю внутри дерева документа

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

jq . response.json

Имя поля после точки извлекает значение: .hostname вернёт строку, .port число, .enabled логический признак. Ветвистая структура проходится цепочкой: .server.network.address спускается по вложенным объектам шаг за шагом. Если поля нет, jq не останавливается с ошибкой, а отвечает null: скрипт получает предсказуемое значение вместо аварийного сообщения.

Массивы обходятся квадратными скобками. Индекс достаёт один элемент, счёт ведётся с нуля, отрицательный индекс считается с конца: .disks[0] вернёт первый диск, .disks[-1] последний. Пустые скобки запускают перебор: .items[] выдаёт каждый элемент списка отдельным результатом. С этого приёма начинается почти любой нетривиальный запрос: вместо одного документа появляется поток значений. Если тип элементов заранее неизвестен, спасает суффикс вопроса: .items[]? пропустит значения, к которым перебор неприменим.

Условная выборка оформляется функцией select. Выражение .items[] | select(.state == "running") пропускает дальше только работающие узлы, остальные тихо отбрасываются. Функция map применяет выражение к каждому элементу массива и собирает результаты обратно в массив: map(.price * 1.2) пересчитает цены. Число элементов возвращает length, причём для объекта это количество полей, для строки её длина, для null ноль. Соседи не менее полезны: keys печатает отсортированный список имён полей, has("port") проверяет наличие поля, type называет тип значения строкой "object", "array", "string", "number", "boolean" или "null".

Конвейер из фильтров и сборка новых объектов и массивов из выбранных полей

Вертикальная линия работает знакомо по shell: результат левого фильтра становится входом правого. Цепочка читается слева направо и заменяет десятки строк sed и awk. Перебрать список, отфильтровать по условию, вытащить поля и тут же упаковать их в новый объект:

jq '.items[] | select(.cpu > 80) | {id, name, cpu}' nodes.json

Фигурные скобки собирают новый объект. Полная запись {id: .id, name: .name, state: .state} допускает любые выражения в значениях, краткая {id, name, state} берёт поля с совпадающими именами и экономит нажатия клавиш. Квадратные скобки собирают массив: [.items[] | .name] превратит поток имён обратно в один список.

Пары "имя - значение" разворачиваются функцией to_entries в массив записей с полями key и value, from_entries собирает объект обратно, а with_entries применяет преобразование к каждой паре сразу. Так переименовывают поля, чистят конфигурации и упорядочивают пары по алфавиту.

Агрегация встроена в язык. map(.qty) | add просуммирует числа, group_by(.state) разложит элементы на группы, unique_by(.name) уберёт повторы, sort_by(.ts) упорядочит по времени, min_by и max_by найдут крайние элементы. Для свёртки произвольной сложности есть reduce: reduce .items[] as $item (0; . + $item.qty) копит сумму по всем элементам. Если значение отсутствует, выручает оператор альтернативы: .error // "нет ошибок" вернёт правую часть, когда левая оказалась null или false. Интерполяция строк собирает человекочитаемые подписи без ручной конкатенации: "узел \(.name), порт \(.port)".

Служебные ключи jq для сырого вывода, компактной печати и переменных

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

  1. -r печатает строки без кавычек, чтобы результат сразу уходил в cut, sort и grep;
  2. -c выводит каждый документ одной строкой;
  3. -s собирает весь вход в один массив и запускает фильтр единожды;
  4. -n игнорирует вход и подставляет фильтру null, это основа генерации документов;
  5. -e переводит результат в код завершения: false и null дают 1, пустой результат даёт 4;
  6. --arg имя значение передаёт строку в переменную с этим именем внутри фильтра;
  7. --argjson имя значение делает то же самое, но разбирает значение как JSON.

У ключа -r есть нюанс: кавычки снимаются только со строк, числа, объекты и массивы по-прежнему печатаются как JSON. Ключ -j похож на -r, но не добавляет перевод строки после результата. В связке -r с перебором получается готовый поток значений для конвейера:

jq -r '.items[] | select(.state == "running") | .name' nodes.json

Значения из shell попадают в фильтр без склейки текста. Фильтр пишется в одинарных кавычках, оболочка внутри них ничего не раскрывает, поэтому $host остаётся переменной jq, а $NODE_HOST снаружи раскрывается до запуска утилиты:

jq -n --arg host "$NODE_HOST" --argjson port 8080 \
  '{server: {host: $host, port: $port}}'

Реже, но выручают и другие ключи. -R читает вход как обычные строки, без разбора JSON, а вместе с -s весь вход приходит одним куском. --rawfile кладёт содержимое текстового файла в строковую переменную, --slurpfile разбирает файл и складывает все его документы в массив. -S сортирует имена полей на выходе, -f берёт фильтр из отдельного файла, а --unbuffered отключает буферизацию для длинных потоков.

Разбор ответов интерфейсов приложений в конвейере с curl

Знакомая сцена: от интерфейса приложения пришёл ответ на пару тысяч строк, а нужны три поля у каждого элемента. Связка curl и jq закрывает её двумя командами. Заголовок Accept подсказывает серверу желаемый формат, фильтр оставляет только нужные поля:

curl -s -H "Accept: application/json" "$HOST/api/v1/nodes" \
  | jq '.items[] | {id, name, state}'

Дальше сценарии расходятся. Быстрая проверка порога нагрузки по всем узлам:

curl -s "$HOST/api/v1/nodes" | jq -r '.items[] | select(.cpu > 80) | .id'

Ответ с ошибкой распознаётся по структуре: jq -r '.error // empty' напечатает описание, если поле есть, и промолчит иначе. Счёт элементов для скрипта собирается двумя строками:

resp=$(curl -s "$HOST/api/v1/nodes")
echo "$resp" | jq '.items | length'

При постраничной выдаче страницы сохраняют в файлы и сливают через -s: конкатенация списков .[0].items + .[1].items собирает полный набор:

jq -s '{total: (.[0].items + .[1].items | length)}' page1.json page2.json

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

jq -c '{id, ts, level}' events.jsonl

Проверка структуры документа и защита скрипта от повреждённого JSON

Как убедиться, что конфигурация корректна, до того как её прочитает служба? Самая дешёвая проверка - одна команда:

jq empty config.json

Фильтр empty не выводит ничего, вся суть в коде завершения. На корректном документе утилита возвращает ноль, на сломанном печатает позицию ошибки в stderr. Синтаксическая ошибка в самом фильтре даёт код 3, повреждённый вход завершается иным ненулевым кодом, и по этим значениям скрипт ветвится без разбора текста сообщений:

jq empty config.json || echo "конфигурация повреждена"

Содержательный контроль строят на has, type и ключе -e. Перед перезапуском службы полезно проверить и наличие секции, и тип значения:

jq -e 'has("server") and (.server.port | type == "number")' config.json \
  && systemctl reload app

Проверка каждого элемента списка укладывается в одну строку: jq -e '.items | map(.id != null) | all'. Функция all возвращает true, только когда каждый элемент массива истинен. Поиск полей с одним именем на любой глубине делает рекурсивный спуск: [.. | .version? // empty] | unique соберёт все встреченные значения. Ошибку, которая не должна останавливать запрос, перехватывает try: try (.tags[0] | tonumber) catch 0 подставит ноль на нечисловом значении.

Преобразование JSON в строки и таблицы для следующего звена конвейера

Соседним утилитам нужен не JSON, а строки и колонки. Формат @tsv превращает массив в строку с табуляцией между значениями и экранирует внутри строк сами табуляции и переводы строк, чтобы колонки не разъезжались:

jq -r '.items[] | [.id, .name, .cpu] | @tsv' nodes.json | column -t

Формат @csv действует похоже, но берёт запятую и кавычки по правилам CSV, @sh экранирует строки под требования shell, @json упаковывает любое значение в JSON-текст. Подсчёт состояний по журналу собирается связкой с sort и uniq:

jq -r '.level' events.jsonl | sort | uniq -c | sort -rn

Поле, внутри которого лежит ещё один JSON в виде текста, разворачивает fromjson: jq -r '.payload | fromjson | .event'. Обратное превращение делает tojson. Строковые функции устроены ожидаемым образом: split режет по разделителю, join собирает обратно, ltrimstr и rtrimstr срезают префикс и суффикс, test и gsub работают с регулярными выражениями. В них прячется типовая ловушка: регулярное выражение живёт внутри jq-строки, поэтому обратный слеш удваивается. gsub("\\s+"; " ") схлопнет подряд идущие пробелы, а одинарный слеш в "\s" вызовет ошибку компиляции фильтра ещё до чтения входа.

Экспорт переменных окружения из конфигурации собирается интерполяцией строк, а @sh бережёт значения с пробелами и спецсимволами:

jq -r '.config | to_entries[] | "export \(.key)=\(.value | tostring | @sh)"' app.json

Генерация JSON документа с нуля и подстановка переменных

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

jq -n '{job: "nightly-backup", ttl: 3600, paths: ["/var/log", "/srv"], enabled: true}'

Подстановка идёт через --arg и --argjson: первый передаёт строку как есть, второй разбирает JSON, поэтому числа, логические значения и вложенные объекты доходят без искажений. Переменные окружения читает встроенный объект env, отдельный ключ для них не нужен:

jq -n --arg user "$BACKUP_USER" \
  '{owner: $user, home: env.HOME, shell: env.SHELL, tags: ["nightly", "full"]}'

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

jq -n --rawfile body notice.txt '{subject: "restart", body: $body}'

Базовый файл конфигурации хранит общие настройки, файл окружения содержит переопределения, и оператор * объединяет их рекурсивно: вложенные объекты сливаются на любой глубине, а при конфликте побеждает правый аргумент. Для плоской схемы достаточно сложения через +:

jq -s '.[0] * .[1]' defaults.json production.json > app.json

Документ - это дерево, запрос читается слева направо, и каждая ступень либо сужает выборку, либо меняет форму результата. От точки и имени поля администратор быстро доходит до select, map и reduce, любой фрагмент будущей конфигурации проверяется тем же jq -e, а готовый файл печатается в читаемом виде той же утилитой. Один инструмент закрывает цикл от сырого ответа интерфейса до аккуратного файла конфигурации, и именно за эту цельность jq держат в шпаргалке рядом с grep и awk.