Массивы в JSON и YAML: вложенные списки, JSON Schema и валидация конфигов | AdminWiki

Массивы в JSON и YAML: вложенные списки, JSON Schema и валидация конфигов

28 сентября 2026 16 мин. чтения
Содержание статьи

Что мы разбираем и кому это сэкономит время

Массив в JSON записывается единственным способом: квадратные скобки и запятые между элементами. В YAML для того же списка есть блочная запись через дефисы и потоковая в квадратных скобках. Вложенность строится по одной логике: элемент массива может быть объектом, а внутри объекта снова лежит массив.

Дальше - рабочие примеры вложенных списков объектов для обоих форматов, минимальная JSON Schema для массива сервисов, таблица ошибок парсинга с симптомом и способом починки, команды для pre-commit и GitHub Actions, правила версионирования схемы. Всё можно копировать в свой репозиторий.

Материал для DevOps-инженеров, сисадминов и разработчиков, которые держат конфигурацию как код: манифесты Kubernetes, плейбуки Ansible, docker-compose.yml, конфиги CI. Если вы уже писали такие файлы и ловили mapping values are not allowed in this context посреди релиза, здесь найдёте причину и способ закрыть весь класс таких ошибок схемой и проверкой в пайплайне.

Если массивы лежат не в конфиге, а в колонке базы, критерии выбора другие: у нас есть отдельный разбор четырех способов хранения массивов в MySQL с индексацией и миграциями - хранение массивов в MySQL: JSON, сериализация и нормализация.

Массивы скаляров и вложенные списки объектов: примеры для JSON и YAML

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

Массив скаляров: порты, теги, хосты

Простейший случай: список чисел или строк без вложенности. JSON:

{
  "ports": [80, 443, 8080],
  "tags": ["web", "edge"],
  "upstream_hosts": ["10.0.0.11", "10.0.0.12"]
}

Тот же конфиг в YAML, блочная запись:

ports:
  - 80
  - 443
  - 8080
tags:
  - web
  - edge
upstream_hosts:
  - 10.0.0.11
  - 10.0.0.12

Потоковая запись короче и удобна для двух-трех значений: ports: [80, 443, 8080]. В манифестах Kubernetes и плейбуках Ansible чаще берут блочную: диффы в Git читаются построчно, а добавление элемента не переписывает всю строку.

Ключевая ловушка на этом уровне - кавычки. В YAML значения 80 и "80" различаются по типу: число и строка. Схема с type: integer отклонит второй вариант, а Nginx или Docker Compose могут молча привести строку к числу и создать расхождение между конфигом и результатом.

Живой пример роли простого флага в плоском конфиге: в плагине CyberLevels бета-функция включается одной строкой в config.yml, а не через вложенную секцию из трех уровней - описание плагина CyberLevels. Чем меньше уровней вложенности до булева значения, тем меньше шансов ошибиться в отступах.

Список объектов: сервисы, пользователи, маршруты

Повторяющиеся сущности с несколькими полями описывают массивом объектов. JSON:

{
  "services": [
    {"name": "api", "image": "registry.local/api:1.4.2", "ports": [8080], "env": ["LOG_LEVEL=info"]},
    {"name": "web", "image": "registry.local/web:1.4.2", "ports": [80, 443], "env": ["TZ=UTC"]}
  ]
}

YAML, тот же смысл:

services:
  - name: api
    image: registry.local/api:1.4.2
    ports:
      - 8080
    env:
      - LOG_LEVEL=info
  - name: web
    image: registry.local/web:1.4.2
    ports:
      - 80
      - 443
    env:
      - TZ=UTC

Правило отступов простое: дефис первого элемента стоит на одном уровне с ключом services, дефис второго элемента - ровно под первым дефисом, а поля элемента сдвинуты на два пробела от дефиса и выровнены между собой. Сдвиг хотя бы одной строки на один пробел меняет смысл документа или роняет парсер.

Список пользователей с ролями строится так же, только вместо image и ports идут name, role и ssh_keys. Список маршрутов Nginx удобно держать массивом объектов с полями path, upstream и weight: при добавлении маршрута достаточно дописать один элемент.

Для больших списков (больше сотни элементов) один файл становится неудобным: диффы разрастаются, ревью теряет смысл. Выносите такие списки в отдельный файл и подключайте его: в Ansible через include_vars, в Docker Compose через несколько флагов -f, в Kubernetes через kustomize. Якоря YAML с ключом слияния << тоже сокращают повторы, но раскрытие якорей зависит от инструмента. Перед деплоем проверьте, что ваш рендерер их поддерживает, и не полагайтесь на якоря в том, что читает сторонний API.

Вложенные массивы внутри объектов: когда это оправдано

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

Массив массивов без семантики - другое дело. Сравните два варианта:

[
  ["api", 8080],
  ["web", 80]
]
[
  {"name": "api", "port": 8080},
  {"name": "web", "port": 80}
]

В первом случае смысл позиций знает только тот, кто писал конфиг: где имя, где порт, что делать при добавлении третьего поля. Во втором поля названы, и массив объектов проверяется обычной схемой: type: array, items с required: [name, port].

Отдельная история - разнородные массивы, где первый элемент число, второй строка, третий объект. В JSON Schema такие позиции описывает prefixItems в драфте 2020-12 или массив схем в ключе items в draft-07. Схема получается длинной и хрупкой, поэтому разнородные наборы лучше сразу переводить в объект с именованными полями.

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

Валидация структуры через JSON Schema: массив объектов под контролем

JSON Schema описывает структуру данных, а не синтаксис файла. Один и тот же набор правил проверяет JSON-документ и YAML после разбора в объектную модель. Комментарии, отступы и способ записи массива схему не интересуют, ей важны типы, обязательность полей и допустимые значения.

Минимальная схема для массива объектов

{
  "$id": "schemas/services-v1.json",
  "type": "object",
  "required": ["schemaVersion", "services"],
  "additionalProperties": false,
  "properties": {
    "schemaVersion": {"type": "integer", "const": 1},
    "services": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["name", "image"],
        "additionalProperties": false,
        "properties": {
          "name": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,30}$"},
          "image": {"type": "string", "minLength": 1},
          "ports": {"type": "array", "items": {"type": "integer", "minimum": 1, "maximum": 65535}},
          "env": {"type": "array", "items": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*="}}
        }
      }
    }
  }
}

Разбираем по строкам. type: object на верхнем уровне говорит, что конфиг - маппинг, а не список. required перечисляет ключи, без которых документ невалиден. additionalProperties: false ловит опечатки в ключах: лишнее поле prot вместо ports не пройдет молча. minItems: 1 запрещает пустой массив сервисов, а items описывает схему каждого элемента.

Драфт зафиксируйте один раз: поле $schema принимает идентификатор 2020-12 для новых проектов или draft-07, если ваш валидатор новее не поддерживает. Смешивать драфты в одном проекте не стоит: ключи вроде prefixItems и items в старой и новой редакциях работают по-разному.

Конфиг, который проходит проверку:

{
  "schemaVersion": 1,
  "services": [
    {"name": "api", "image": "registry.local/api:1.4.2", "ports": [8080]}
  ]
}

Три варианта, которые схема отклонит, с реальными сообщениями:

  • {"name": "api", "image": "...", "prot": [8080]} - must NOT have additional properties, в параметрах ошибки будет additionalProperty: prot;
  • элемент без ключа name - must have required property 'name', путь указывает на конкретный индекс: /services/0;
  • "ports": ["8080"] - must be integer, строка не проходит проверку типа.

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

Проверка конфига в командной строке и CI

Быстрая проверка синтаксиса и структуры:

jq . config.json
ajv validate -s schemas/services-v1.json -d config.json --spec=draft2020
check-jsonschema --schemafile schemas/services-v1.json config/services.yaml
yamllint -c .yamllint.yml .
kubeconform -strict -summary manifests/

Инструменты дополняют друг друга: jq и yamllint проверяют синтаксис и форматирование, ajv и check-jsonschema - структуру по схеме, kubeconform - соответствие манифестов схемам Kubernetes с флагом -strict, который запрещает неизвестные поля.

Шаг пайплайна, который блокирует мерж при невалидном конфиге:

name: config-validate
on: [pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pipx install check-jsonschema yamllint
      - run: yamllint -c .yamllint.yml .
      - run: check-jsonschema --schemafile schemas/services-v1.json config/services.yaml

Мерж блокирует не сам шаг, а защита ветки: включите обязательную проверку в настройках репозитория, иначе красный статус останется советом. Тот же набор команд вешается на pre-commit через локальный hook с language: python и entry, ссылающимся на установленный check-jsonschema, чтобы ошибка ловилась до пуша, а не на ревью.

Валидация YAML: конвертация и подводные камни

YAML проверяется схемой не напрямую: файл сначала разбирается в объектную модель, и дальше к ней применяется тот же валидатор, что и к JSON. Значит, результат проверки зависит от парсера. PyYAML, go-yaml и js-yaml по-разному трактуют булевы значения, восьмеричные числа и даты, а от этого меняются типы, которые увидит схема.

Практический вывод: фиксируйте парсер и его версию в проекте (lock-файл, версия образа в CI), не полагайтесь на системный python или node. Дополнительно прогоняйте YAML через yamllint с включенным правилом duplicate-keys: часть парсеров молча оставляет последнее значение дубликата и не сообщает об ошибке.

Синтаксис манифестов с отступами, многострочными строками и проверкой в CI разобран отдельно в руководстве по синтаксису YAML для манифестов Kubernetes: там же готовые шаблоны Deployment и связка yamllint с kubeconform.

Типичные ошибки при парсинге вложенных структур

Таблица для быстрой диагностики: симптом из лога, причина, починка.

СимптомПричинаПочинка
Unexpected token } in JSON at position 128Лишняя запятая перед закрывающей скобкойУдалить запятую после последнего элемента, проверить через jq
Unexpected token ' в JSONОдинарные кавычки или комментарийДвойные кавычки, комментарии вынести в отдельный файл документации
Unexpected character при чтении файлаBOM в начале файлаСохранить как UTF-8 без BOM
found character '\t' that cannot start any tokenТаб в отступах YAMLТолько пробелы, правило indentation в yamllint
mapping values are not allowed in this contextДвоеточие в незакавыченном значенииВзять значение в кавычки: "web:8080"
bad indentation of a mapping entryДефис массива сдвинут относительно ключаВыровнять все дефисы одного списка по одной колонке
Часть конфига пропала без ошибкиДубликат ключа, парсер взял последнее значениеВключить duplicate-keys в yamllint

Ошибки JSON: запятые, кавычки, BOM

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

Комментарии в JSON не поддерживаются. Варианты JSON5 и JSONC существуют, но в конфигах продакшена они редко оправданы: инструменты вроде jq, ajv и клиентов API на них спотыкаются. Если нужны пояснения, держите их рядом в README или в поле description внутри конфига.

Самый частый однострочник для проверки синтаксиса - jq . config.json. Он выведет нормализованный документ или укажет строку и колонку с ошибкой. Пустой вывод при ненулевом exit code означает, что файл не разобрался.

Ошибки YAML: табы, отступы, дубликаты ключей

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

Дубликат ключа опаснее синтаксической ошибки, потому что не всегда виден. Поведение зависит от парсера: PyYAML оставляет последнее значение, js-yaml в строгом режиме может выдать ошибку. Результат одинаково плохой - часть конфига исчезает без предупреждения, а сервис стартует с неполными настройками.

Многострочные блоки с символами | и > ломаются о тот же отступ. Литеральный блок сохраняет переводы строк, folded склеивает их в одну строку с пробелами, и сдвиг отступа на один пробел меняет содержимое конфига целиком, а не отдельного поля.

null, пустой массив и отсутствующий ключ: три разных состояния

Три записи выглядят похоже, но означают разное:

{"services": []}
{"services": null}
{}

Пустой массив говорит "сервисов нет, это осознанный выбор". Null значит "значение не задано". Отсутствующий ключ - "поле не упомянуто вообще". В JSON Schema это три разные проверки: required: [services] отклонит третий вариант, type: array отклонит null, а minItems: 1 отклонит пустой массив.

Рекомендация: в схеме явно решите, допустим ли null (для этого тип пишется как type: [array, null]), и не полагайтесь на значения по умолчанию в парсере. Дефолт, подставленный библиотекой, невидим в конфиге и всплывает только на проде.

YAML-массивы в структурах приложения: где теряются типы

YAML - формат с богатой системой типов, и парсер приводит незакавыченные значения к типам автоматически. Именно здесь строка становится числом, а код страны превращается в boolean.

yes/no/on/off и норвежская проблема

В YAML 1.1 слова yes, no, on, off, y, n распознаются как булевы значения. Классический пример: country: NO разбирается как country: false, и массив кодов стран превращается в набор true и false. В YAML 1.2 булевыми остались только true и false.

Починка одна: кавычки. Запись country: "NO" гарантирует строку в любой версии спецификации. Режим страны в CI, список регионов, флаг окружения - всё, что похоже на короткое слово из двух-трех букв, берите в кавычки.

Поведение парсеров различается: PyYAML исторически следует YAML 1.1, js-yaml начиная с четвертой версии использует более строгую схему и не считает yes/no булевыми. Проверьте свой стек на тестовом конфиге, а не на предположении.

Числа, версии и даты: как не потерять строку

Пример с версией: version: 1.10 разбирается как число с плавающей точкой и превращается в 1.1. Если приложение сравнивает версии как строки, сравнение молча идет не туда. Решение - version: "1.10".

Восьмеричные числа в YAML 1.1 начинаются с нуля: mode: 0755 может прийти в приложение как 493. Для прав доступа это критично: файл получит неожиданный режим. Записывайте восьмеричные значения строкой или в явном виде 0o755, если парсер его поддерживает.

Даты тоже приводятся к объектам: date: 2026-01-01 в PyYAML становится объектом date, и обычная JSON-сериализация такого значения падает. Кавычки решают вопрос: "2026-01-01" остается строкой.

Как зафиксировать типы в схеме и в коде

Четыре действия закрывают тему типов системно:

  1. Зафиксировать парсер и его версию в lock-файле и в образе CI: разбор одного конфига двумя версиями библиотеки дает разные результаты.
  2. Указать type для каждого поля схемы. Поле без типа почти не защищает.
  3. Для критичных значений добавить pattern или enum: версия по шаблону ^[0-9]+\.[0-9]+\.[0-9]+$, список допустимых ролей, диапазон портов.
  4. Добавить в CI тест, который парсит конфиг и проверяет типы значений после разбора, а не до него.

Схема для поля версии сработает как предохранитель: при type: string незакавыченное значение 1.10 придет числом 1.1 и получит ошибку must be string. Ошибка появится на шаге валидации, а не в рантайме приложения.

Версионирование схемы конфигурации: как обновлять без боли

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

Где хранить $id и schemaVersion

Три рабочих варианта:

  • Отдельный файл схемы на версию: schemas/services-v1.json, schemas/services-v2.json. Плюс - обе схемы лежат рядом, старые конфиги проверяются без правок. Минус - больше файлов.
  • Один файл с версией в $id: идентификатор отличает редакции, а поле schemaVersion в конфиге подсказывает, какую редакцию применять.
  • Поле schemaVersion в самом конфиге: обязательный ключ с ограничением const на конкретную версию.

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

{
  "schemaVersion": 2,
  "services": [
    {"name": "api", "image": "registry.local/api:1.5.0", "scale": 3}
  ]
}

Правила обратной совместимости и deprecated-поля

Правила совместимости укладываются в три строки:

  • Добавили необязательное поле - это минорное изменение, старые конфиги валидны.
  • Добавили поле в required - это ломающее изменение, все существующие конфиги перестают проходить проверку.
  • Удалили, переименовали поле или сменили тип - мажорная версия с миграцией.

Устаревшие поля не удаляйте сразу. Помечайте их в схеме через deprecated: true и описание вида "используйте scale", оставляя поле валидным в течение одного мажорного цикла. Пример: поле replicas заменяется на scale, обе формы проходят валидацию, но код читает только новое поле, а в конфиг попадает предупреждение.

Миграции конфигов: скрипт вместо ручной правки

Алгоритм миграции повторяется от проекта к проекту:

  1. Снять бэкап рабочего конфига и положить рядом с результатом миграции.
  2. Прочитать schemaVersion и определить цепочку преобразований (v1 в v2, v2 в v3).
  3. Выполнить dry-run: записать результат во временный файл, не перезаписывая исходный.
  4. Проверить результат новой схемой, а исходный файл - старой: так видно, что миграция не сломала то, что работало.
  5. Показать дифф и задеплоить за флагом, оставив возможность отката.

Для JSON и YAML преобразования удобно делать через jq и yq: выражение вида .services[] |= (.scale = .replicas | del(.replicas)) переносит значение и удаляет старое поле. Прогоняйте такой скрипт на копии прод-конфига: статистика по реальным файлам вскрывает случаи, которых нет в тестовых примерах.

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

JSON или YAML для вашего конфига: таблица выбора

КритерийJSONYAML
КомментарииНет в стандартеДа
Многострочные строкиТолько через экранирование \nБлоки | и >, folded
Машинная генерацияПроще: сериализатор в любой библиотекеСложнее: чувствительность к кавычкам и типам
Человекочитаемость массива объектовСкобки и запятые на каждом элементеДефисы и отступы, меньше шума
Строгость типовСтрогая: строка всегда строкаСлабая: yes/no, 1.10 и даты приводятся к типам
Валидация структуройJSON Schema напрямуюJSON Schema после разбора в объект
Якоря и повторное использованиеНетЕсть, поддержка зависит от инструмента

Правило выбора простое. Машинная генерация, обмен данными между сервисами, ответы API, файлы, которые пишет и читает один и тот же инструмент, - JSON. Ручные конфиги с комментариями и повторяющимися блоками, манифесты Kubernetes, плейбуки Ansible, конфиги CI - YAML.

Пример машинного JSON рядом: при установке function kit конфигурационная информация записывается в firebase.json, то есть файл формирует инструмент, а не человек - практики миграции Firebase Extensions. Дополнительную причину держать схему конфига версионируемой дает расписание отключений у внешних сервисов: в той же документации указано, что сервис Extensions будет отключен 31 марта 2027 года, а ключевые функции управления перестанут работать после этой даты. Внешние сроки диктуют график миграции ваших конфигов.

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

Чек-лист перед коммитом конфига

  1. Синтаксис проверен: jq . config.json для JSON, yamllint -c .yamllint.yml . для YAML.
  2. Структура проверена схемой: check-jsonschema --schemafile schemas/services-v1.json config/services.yaml.
  3. Секреты не в репозитории: токены и пароли приходят из окружения или хранилища секретов, .env добавлен в .gitignore.
  4. Права на файлы конфигов ограничены (0640), в веб-сервере выключен autoindex, каталоги с конфигами не отдаются наружу.
  5. Ключ schemaVersion указан в каждом конфиге и совпадает с версией файла схемы.
  6. Миграция схемы протестирована на копии прод-конфига, результат проверен новой схемой.
  7. Валидация в CI запускается на pull request и блокирует мерж при ненулевом exit code.
  8. Для манифестов Kubernetes добавлен kubeconform -strict -summary manifests/.

Конфиг - это код. У него есть схема, версия, тесты и проверка в пайплайне. Начните с одного файла: опишите схему для самого болезненного конфига, повесьте проверку на pull request, а остальные подтянутся по мере правок.

Поделиться: