Преобразование данных JSON с помощью jq

При работе с большими JSON-файлами иногда бывает трудно найти и обработать нужную информацию. Чтобы подсчитать итоговые значения, вы можете скопировать и вставить все соответствующие фрагменты вручную, но это сложный процесс, при котором часто возникают ошибки. Другой вариант — утилиты общего назначения для поиска и обработки информации. Сейчас все системы Linux поставляются с тремя установленными утилитами обработки текста: sed, awk и grep. Но эти команды полезны при работе со слабо структурированными данными, а для машиночитаемых форматов данных (например JSON) существуют другие решения.

jq — утилита обработки JSON из командной строки. Это отличный вариант для работы с машиночитаемыми форматами данных, а еще она особенно полезна в сценариях командной оболочки. jq поможет вам управлять данными. Например, если вы выполняете вызов curl к API JSON, то jq может извлечь нужную информацию из ответа сервера. Если вы управляете кластером Kubernetes, вы можете использовать JSON-вывод kubectl в качестве входного источника для jq, чтобы извлечь количество доступных реплик для конкретного развертывания.

Мы преобразуем данные с помощью фильтров и объединим части преобразованных данных в новую структуру. 

Требования

Для выполнения этого туториала вам потребуется:

  • jq — утилита для анализа и обработки JSON. Она доступна в репозиториях всех основных дистрибутивов Linux. Если у вас Ubuntu, для его установки запустите команду sudo apt install jq.
  • Понимание синтаксиса JSON. Вспомнить его поможет мануал Основы работы с JSON.

1: Первый запуск команды jq

Сейчас мы настроим образец входного файла и протестируем установку, выполнив команду jq для создания выходных данных. Команда jq может принимать данные из файла или из канала. Как вы уже поняли, мы будем работать с первым.

Начнем с создания файла-образца. Откройте новый файл seaCreatures.json в любом текстовом редакторе (мы работаем с nano):

nano seaCreatures.json

Скопируйте в этот файл следующее:

[
    { "name": "Sammy", "type": "shark", "clams": 5 },
    { "name": "Bubbles", "type": "orca", "clams": 3 },
    { "name": "Splish", "type": "dolphin", "clams": 2 },
    { "name": "Splash", "type": "dolphin", "clams": 2 }
]

Мы будем работать с этими данными до конца туториала. А в конце напишем однострочную команду jq, которая ответит на следующие вопросы об этих данных:

  • Как выглядит список name?
  • Сколько всего clams?
  • Сколько clams принадлежит типу dolphin?

Сохраните и закройте файл.

Помимо входного файла нам понадобится фильтр, описывающий точное преобразование, которое вы хотите выполнить. Фильтр . (точка) — оператор идентичности (identity operator), который без изменений передает входной JSON в качестве выходного.

С помощью оператора идентичности можно проверить, что все работает. Если вы видите какие-либо ошибки парсинга, убедитесь, что файл seaCreatures.json содержит корректный JSON.

Применим оператор идентичности к файлу JSON с помощью следующей команды:

jq '.' seaCreatures.json

При использовании jq с файлами вы всегда передаете фильтр, за которым следует входной файл. Рекомендуем заключать фильтр в одинарные кавычки, поскольку фильтры могут содержать пробелы и другие символы, которые имеют особое значение для оболочки. Это сообщит оболочке, что фильтр является параметром команды. Запуск jq не изменит исходный файл.

Получим следующий вывод:

[
  {
    "name": "Sammy",
    "type": "shark",
    "clams": 5
  },
  {
    "name": "Bubbles",
    "type": "orca",
    "clams": 3
  },
  {
    "name": "Splish",
    "type": "dolphin",
    "clams": 2
  },
  {
    "name": "Splash",
    "type": "dolphin",
    "clams": 2
  }
]

По умолчанию jq выводит результат в удобочитаемом формате. Он автоматически применяет отступы, добавляет новые строки после каждого значения и по возможности меняет цвет вывода. Цветной вывод может улучшить читаемость и поможет многим разработчикам при работе с данными JSON, которые созданы утилитами. Например, при отправке запроса curl в JSON API вы можете передать ответ JSON в jq ‘.’ для вывода результата в понятном формате.

Теперь jq запущен и работает. После настройки входного файла мы применим к данным фильтры, чтобы вычислить значения всех трех атрибутов: creatures, totalClams и totalDolphinClams. Далее мы найдем информацию из значения creatures.

2: Извлечение значения creatures

Сейчас мы создадим список creatures на основе значений name. В результате мы получим такой список:

[
  "Sammy",
  "Bubbles",
  "Splish",
  "Splash"
],

Чтобы создать этот список, нужно извлечь значения и затем объединить их в массив.

Сейчас мы должны доработать наш фильтр, чтобы получить все значения name и отбросить все остальное. Поскольку мы работаем с массивом, нужно указать jq, что вы хотите работать не с самим массивом, а с его значениями. Для этого есть итератор значений массива .[].

Запустите jq с другим фильтром:

jq '.[]' seaCreatures.json

Теперь каждое значение массива выводится отдельно:

{
  "name": "Sammy",
  "type": "shark",
  "clams": 5
}
{
  "name": "Bubbles",
  "type": "orca",
  "clams": 3
}
{
  "name": "Splish",
  "type": "dolphin",
  "clams": 2
}
{
  "name": "Splash",
  "type": "dolphin",
  "clams": 2
}

Вместо того, чтобы полностью выводить каждый элемент массива, мы выведем значение атрибута name и отбросим все остальное. С помощью оператора pipe (|) фильтр можно применить к каждому выводу. Этот паттерн покажется вам знакомым, если вы использовали find | xargs в командной строке для применения команды к каждому результату поиска.

Введите .name, чтобы получить доступ к свойству name объекта JSON. Объедините pipe с фильтром и выполните эту команду для seaCreatures.json:

jq '.[] | .name' seaCreatures.json

Другие атрибуты исчезли из вывода:

"Sammy"
"Bubbles"
"Splish"
"Splash"

По умолчанию jq выводит правильный JSON, поэтому строки будут отображаться в двойных кавычках (“”). Если вам кавычки не нужны, включите необработанный вывод с помощью флага -r:

jq -r '.[] | .name' seaCreatures.json

Кавычки исчезли:

Sammy
Bubbles
Splish
Splash

Теперь вы знаете, как извлечь нужную информацию из входного файла JSON. Мы будем работать с этой техникой для поиска информации на следующем этапе и для генерации значения creatures на последнем этапе.

3: Вычисление значения totalClams с помощью map и add

На этом этапе мы найдем общее значение clams, объединив несколько частей данных. После освоения jq вы поймете, что это быстрее ручного вычисления и при этом возникает меньше ошибок. Мы ожидаем получить в результате 12.

В пункте 2 мы извлекли определенные биты информации из списка элементов. Вы можете повторно применить эту технику для извлечения значений атрибута clams. Настройте фильтр для нового атрибута и выполните команду:

jq '.[] | .clams' seaCreatures.json

Будут выведены отдельные значения атрибута clams:

5
3
2
2

Чтобы найти сумму отдельных значений, понадобится фильтр add. Фильтр add работает с массивами. Но сейчас мы выводим значения массива, поэтому сначала их нужно обернуть в массив.

Окружим фильтр [] следующим образом:

jq '[.[] | .clams]' seaCreatures.json

Теперь значения выводятся в виде списка:

[
  5,
  3,
  2,
  2
]

Перед применением фильтра add можно улучшить читаемость команды с помощью функции map, что также упростит ее обслуживание. Итерацию по массиву, применение фильтра к каждому из этих элементов, а затем объединение результатов в массив можно выполнить с помощью одного вызова map. Получив массив элементов, map применит свой аргумент в качестве фильтра к каждому элементу. Например, если применить фильтр map (.name) к [{“name”: “Sammy”}, {“name”: “Bubbles”}], то полученный объект JSON будет [“Sammy”, “Bubbles” ].

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

jq 'map(.clams)' seaCreatures.json

Получим тот же вывод, что и раньше:

[
  5,
  3,
  2,
  2
]

Поскольку теперь у нас есть массив, можно передать его в фильтр add:

jq 'map(.clams) | add' seaCreatures.json

Получим сумму значений массива:

12

С помощью этого фильтра мы подсчитали общее количество clams, которое позже используем для создания значения totalClams. Мы написали фильтры для двух из трех вопросов. Осталось создать еще один фильтр, после чего можно будет сгенерировать конечный результат.

4: Вычисление значения totalDolphinClams с помощью фильтра add

Когда мы знаем точное значение clams, мы можем определить, сколько clams есть у dolphin. Ответ можно получить, складывая только те значения элементов массива, которые подходят под заданное условие. Ожидаемое значение в конце этого шага — 4, то есть общее количество clams, которые есть у dolphin. На последнем этапе полученное значение будет использоваться атрибутом totalDolphinClams.

Вместо того, чтобы складывать значения всех clams (как в пункте 3), мы будем считать только clams, которые есть у “dolphin”. С помощью функции select мы выберем условие: select(condition). Дальше передается любой ввод, для которого условие оценивается как true. Все остальные входные данные отбрасываются. Если, например, ваш входной JSON-файл — “dolphin”, а ваш фильтр — select(. == “dolphin”), то на выходе будет “dolphin”. Для ввода “Sammy” тот же фильтр ничего не выдаст.

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

В нашем случае мы хотим сохранить только те значения массива, значение типа которых равно “dolphin”. Полученный фильтр имеет следующий вид:

jq 'map(select(.type == "dolphin"))' seaCreatures.json

Фильтр не будет соответствовать Sammy — shark и Bubbles — orca, но будет соответствовать двум dolphin:

[
  {
    "name": "Splish",
    "type": "dolphin",
    "clams": 2
  },
  {
    "name": "Splash",
    "type": "dolphin",
    "clams": 2
  }
]

Чтобы сохранить только значение clams, можно добавить название поля в конец параметра map:

jq 'map(select(.type == "dolphin").clams)' seaCreatures.json

Функция map получает массив на вход и применяет фильтр map (переданный в качестве аргумента) к каждому элементу массива. В результате select вызывается четыре раза, по одному разу для каждого элемента. Функция select выводит данные для двух dolphin (поскольку они соответствуют условию) и пропускает остальные.

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

[
  2,
  2
]

Передайте значения массива в add:

jq 'map(select(.type == "dolphin").clams) | add' seaCreatures.json

В результате мы получим сумму значений clams для типа “dolphin”:

4

Мы успешно объединили map и select для доступа к данным и выбора соответствующих условию элементов. Можно применить эту стратегию для вычисления totalDolphinClams, что мы и сделаем на следующем этапе.

5: Преобразование данных в новую структуру

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

  • Как выглядит список name всех creatures?
  • Сколько всего clams?
  • Сколько clams принадлежит dolphin?

С помощью функции map: map(.name) мы вывели name всех элементов в виде списка. Чтобы узнать, сколько всего clams принадлежит creatures, мы передали значения всех clams в фильтр add: map(.clams) | add. А с помощью функции select с условием .type == “dolphin”: map(select(.type == “dolphin”).clams) | add мы узнали, сколько из clams принадлежит dolphin.

Мы объединим эти фильтры в одну команду jq, которая самостоятельно выполнит всю работу. Чтобы создать новую структуру данных, которая покажет нужную информацию, мы создадим новый объект JSON, который объединит три фильтра.

Напоминаем, что изначально файл JSON имел следующий вид:

[
    { "name": "Sammy", "type": "shark", "clams": 5 },
    { "name": "Bubbles", "type": "orca", "clams": 3 },
    { "name": "Splish", "type": "dolphin", "clams": 2 },
    { "name": "Splash", "type": "dolphin", "clams": 2 }
]

Обработанный вывод JSON сгенерирует следующее:

{
  "creatures": [
    "Sammy",
    "Bubbles",
    "Splish",
    "Splash"
  ],
  "totalClams": 12,
  "totalDolphinClams": 4
}

Полный синтаксис команды jq с пустыми входными значениями выглядит так:

jq '{ creatures: [], totalClams: 0, totalDolphinClams: 0 }' seaCreatures.json

С помощью этого фильтра создадим объект JSON, который содержит три атрибута:

{
  "creatures": [],
  "totalClams": 0,
  "totalDolphinClams": 0
}

Это уже похоже на конечный результат, но входные значения не являются правильными, потому что они не были извлечены из файла seaCreatures.json.

Замените жестко закодированные значения атрибутов фильтрами, которые мы создали на каждом предыдущем этапе:

jq '{ creatures: map(.name), totalClams: map(.clams) | add, totalDolphinClams: map(select(.type == "dolphin").clams) | add }' seaCreatures.json

Приведенный выше фильтр указывает jq создать объект JSON, который содержит:

  • Атрибут creatures, который содержит список значений name каждого creature.
  • Атрибут totalClams, содержащий сумму значений clams каждого creature.
  • Атрибут totalDolphinClams, в котором содержится сумма значений clams каждого creature, для которого тип равен “dolphin”.

Выполните команду, после чего вы получите такой вывод:

{
  "creatures": [
    "Sammy",
    "Bubbles",
    "Splish",
    "Splash"
  ],
  "totalClams": 12,
  "totalDolphinClams": 4
}

Теперь у вас есть один объект JSON, в котором содержатся нужные данные. Если набор данных изменится, то написанный jq-фильтр позволит вам в любой момент повторно применить преобразования.

Заключение

При работе с входными данными JSON функция jq может помочь вам выполнить широкий спектр обработки данных, которые было бы трудно выполнить с помощью инструментов работы с текстом (например sed). В этом туториале мы отфильтровали данные с помощью функции select, преобразовали элементы массива с помощью map, просуммировали массивы чисел с помощью фильтра add и научились объединять преобразования в новую структуру данных.

Чтобы узнать о возможностях jq больше, ознакомьтесь с документацией.

Tags:

Добавить комментарий