Формат данных YAML чрезвычайно усложнён. Он задумывался как удобный для человека, но в стремлении к этой цели сложность настолько увеличилась, что, по моему мнению, его разработчики достигли противоположного результата. В YAML есть куча возможностей выстрелить себе в ногу, а его дружелюбие обманчиво. В этом посте я хочу продемонстрировать это на примере.
Данный пост является нытьём и он более субъективен, чем мои обычные статьи.
YAML очень, очень сложен
JSON прост. Вся спецификация JSON состоит из шести синтаксических диаграмм. Это простой формат данных с простым синтаксисом, и больше в нём нет ничего. В отличие от него, YAML сложен. Настолько сложен, что его спецификация состоит из десяти глав с разделами, пронумерованными на четыре уровня в глубину, и отдельным списком ошибок.
Спецификация JSON не разделена на версии. В 2005 году в неё внесены два изменения (удаление комментариев и добавление научной записи для чисел), но с тех пор она заморожена (уже почти два десятка лет). Спецификация YAML имеет версии. Последняя версия довольно свежая, 1.2.2 от октября 2021 года. YAML 1.2 существенно отличается от 1.1: один и тот же документ в разных версиях YAML может парситься по-разному. Ниже мы увидим много примеров этого.
JSON настолько очевиден, что Дуглас Крокфорд заявляет, что он был открыт, как явление, а не придуман. Я не нашёл никакой информации о том, сколько времени ему понадобилось на создание спецификации, но это были скорее часы, чем недели. Переход YAML с версии 1.2.1 на 1.2.2 потребовал многолетних усилий команды специалистов:
Эта ревизия стала результатом нескольких лет работы новой команды разработки языка YAML. Каждый человек в этой команде обладает глубокими знаниями языка, написал и поддерживает важные опенсорсные фреймворки и инструменты YAML.
Более того, эта команда планирует активно развивать YAML, а не замораживать его.
При работе со столь сложным форматом, как YAML, сложно понимать все фичи и особенности его поведения. Существует целый веб-сайт, посвящённый выбору одного из 63 синтаксисов многострочных строк. Это значит, что человеку очень сложно предсказать, как спарсится конкретный документ. Чтобы подчеркнуть это, давайте рассмотрим пример.
YAML-документ из Ада
Рассмотрим следующий документ.
server_config:
port_mapping:
# Expose only ssh and http to the public internet.
- 22:22
- 80:80
- 443:443
serve:
- /robots.txt
- /favicon.ico
- *.html
- *.png
- !.git # Do not expose our Git repository to the entire world.
geoblock_regions:
# The legal team has not approved distribution in the Nordics yet.
- dk
- fi
- is
- no
- se
flush_cache:
on: [push, memory_pressure]
priority: background
allow_postgres_versions:
- 9.5.25
- 9.6.24
- 10.23
- 12.13
Проанализируем его по частям и посмотрим, как эти данные преобразуются в JSON.
Шестидесятеричные числа
Давайте начнём с того, что можно найти в конфигурации среды исполнения контейнера:
port_mapping:
- 22:22
- 80:80
- 443:443
{"port_mapping": [1342, "80:80", "443:443"]}
Ой, а что здесь произошло? Оказывается, числа от 0 до 59, разделённые двоеточиями — это шестидесятеричные (по основанию 60) числовые литералы. Эта загадочная фича присутствовала в YAML 1.1, но была незаметно удалена из YAML 1.2, поэтому в зависимости от версии, которую использует парсер, элемент списка спарсится как
1342
или как "22:22"
. Формату YAML 1.2 уже больше десяти лет, но если вы предположите, что он широко поддерживается, то ошибётесь: последняя версия libyaml на момент написания (которая, среди прочего, используется и в PyYAML) реализует YAML 1.1 и парсит 22:22
как 1342
.Якоря, псевдонимы и тэги
Следующий фрагмент на самом деле невалиден:
serve:
- /robots.txt
- /favicon.ico
- *.html
- *.png
- !.git
YAML позволяет создать якорь добавив
&
и имя перед значением, а позже вы сможете ссылаться на это значение при помощи псевдонима: *
, за которым следует имя. В этом случае якоря не заданы, поэтому псевдонимы недействительны. Давайте пока избавимся от них и посмотрим, что произойдёт.serve:
- /robots.txt
- /favicon.ico
- !.git
{"serve": ["/robots.txt", "/favicon.ico", ""]}
Теперь интерпретация зависит от используемого парсера. Элемент, начинающийся с
!
— это тэг. Эта фича предназначена для того, чтобы позволить парсеру преобразовывать довольно ограниченные типы данных YAML в расширенные типы, которые могут существовать на языке реализации. Тэг, начинающийся с !
, интерпретируется парсером по своему усмотрению, часто вызовом конструктора с соответствующим именем и передачей ему значения, следующего за тэгом. Это значит, что загрузка непроверенного YAML-документа в общем случае небезопасна, потому что может привести к исполнению произвольного кода. (В Python можно избежать этой проблемы, используя yaml.safe_load
вместо yaml.load
.) В приведённом выше случае PyYAML не удаётся загрузить документ, потому что она не знает тэг .git
. YAML-пакет языка Go менее строг, он возвращает пустую строку.Проблема Норвегии
Эта проблема настолько широко известна, что её назвали проблемой Норвегии:
geoblock_regions:
- dk
- fi
- is
- no
- se
{"geoblock_regions": ["dk", "fi", "is", false, "se"]}
Что здесь делает
false
? Литералы off
, no
и n
и их различные вариации с заглавным регистром (но не все!) в YAML 1.1 считаются false
, а on
, yes
и y
считаются true. В YAML 1.2 эти альтернативные написания булевых литералов больше не допускаются, но в реальном мире они встречаются столь часто, что соответствующий стандарту парсер будет испытывать трудности с чтением многих документов. Поэтому разработчики YAML-библиотеки языка Go приняли решение реализовать собственный вариант, находящийся примерно посередине между YAML 1.1 и 1.2; в зависимости от контекста парсер ведёт себя по-разному:YAML-пакет поддерживает бОльшую часть YAML 1.2, однако сохраняет часть поведения 1.1 для обратной совместимости. Булевы литералы YAML 1.1 (yes/no, on/off) поддерживаются, если они декодируются в типизированное значение bool. В противном случае они ведут себя как строка.
Стоит заметить, что библиотека ведёт себя так только с версии 3.0.0, выпущенной в мае 2022 года. Более ранние версии ведут себя иначе.
Ключи, не являющиеся строками
В JSON ключи всегда являются строками, однако в YAML они могут любыми значениями, в том числе и булевыми.
flush_cache:
on: [push, memory_pressure]
priority: background
{
"flush_cache": {
"True": ["push", "memory_pressure"],
"priority": "background"
}
}
В сочетании с ранее описанной особенностью интерпретации
on
как булева значения это приводит к тому, что одним из ключей словаря становится true
. Если это происходит, то результат зависит от языка, выполняющего преобразование в JSON. В Python это превращается в строку "True"
. В реальном мире ключ on
встречается часто, потому что используется в GitHub Actions. Мне было очень любопытно узнать, что ищет парсер GitHub Actions внутри, "on"
или true
.Случайные числа
Если оставлять строки без кавычек, они запросто могут превращаться в числа.
allow_postgres_versions:
- 9.5.25
- 9.6.24
- 10.23
- 12.13
{"allow_postgres_versions": ["9.5.25", "9.6.24", 10.23, 12.13]}
Возможно, список — это натянутый пример, но представьте, что нужно обновить файл конфигурации, в котором указано единственное значение 9.6.24, и нужно заменить его на 10.23. Вспомните ли вы, что нужно добавить кавычки? Проблема становится ещё более коварной из-за того, что многие приложения с динамической типизацией при необходимости преобразуют неявным образом число в строку, поэтому бОльшую часть времени документ будет работать нормально, однако в некоторых контекстах может ломаться. Например, следующий шаблон Jinja принимает и
version: "0.0"
, и version: 0.0
, но для первого варианта идёт только по ветви true.{% if version %}
Latest version: {{ version }}
{% else %}
Version not specified
{% endif %}
Другие примеры
Это всё, что я смог уместить в один вымышленный пример. Есть и другие загадочные поведения YAML, не вошедшие в него: директивы, целочисленные значения, начинающиеся с
0
и являющиеся восьмеричными литералами (но только в YAML 1.1), ~
как альтернативное написание null
и ?
, добавляющий сложный ключ преобразования.Подсветка синтаксиса вас не спасёт
Вероятно, вы заметили, что ни в одном из моих примеров не включена подсветка синтаксиса. Возможно, я несправедлив к YAML, потому что подсветка синтаксиса выделяет специальные конструкции, поэтому мы, по крайней мере, сможем видеть значения, а не обычные строки. Однако из-за множества используемых версий YAML и разных уровней изощрённости функций подсветки на это полагаться нельзя. И в данном случае я не пытаюсь придираться: Vim, генератор моего блога, GitHub и Codeberg на самом деле подсвечивали пример документа из поста каждый по-своему. Ни один из них не выделил одно и то же подмножество значений как строки!
Шаблонизация YAML — поистине ужасная идея
Надеюсь, теперь понятно, что работа с YAML, по меньшей мере, имеет свои тонкости. Ещё более тонкая тема — конкатенация и изолирование произвольных текстовых фрагментов таким образом, что результат становится валидным YAML-документом, но не тем, который вы ожидаете. Сюда же стоит добавить значимость для YAML пробелов (whitespace). В результате мы получаем формат, о сложностях шаблонизации которого создают мемы. Я искренне не понимаю, почему инструменты, основанные на такой подверженной ошибкам практике, получили столь большое внимание, несмотря на то, что существует более безопасная, простая и мощная альтернатива: генерация JSON.
Альтернативные форматы конфигураций
Я думаю, основная причина доминирования YAML, несмотря на его проблемы, заключается в том, что долгое время он был единственным жизнеспособным форматом конфигураций. Часто нам требуются списки и вложенные данные, поэтому «плоские» форматы наподобие ini исключаются. XML шумный и вручную его писать неудобно. Но самое важное, что нам нужны комментарии, а это исключает применение JSON. (Как мы говорили ранее, изначально в JSON существовали комментарии, но их удалили, потому что люди начали помещать в них директивы парсинга. Думаю, это подходящий выбор для формата сериализации, однако из-за этого JSON не подходит как язык конфигураций.) Итак, если на самом деле нам нужна модель данных JSON, только с синтаксисом, допускающим комментарии, то какие у нас есть варианты?
- TOML — TOML во многом похож на YAML: по большей мере в нём используются те же типы данных; синтаксис не такой многословный, как в JSON; также он позволяет использовать комментарии. В отличие от YAML, в нём не так много возможностей выстрелить в ногу, потому что строки всегда заключаются в кавычки, а значит, вы не получите значения, которые выглядят как строки, но ими не являются. TOML обладает широкой поддержкой, поэтому есть вероятность, что вы сможете найти парсер TOML для своего языка программирования. Он даже есть в стандартной библиотеке Python (в отличие от YAML!). Слабое место TOML заключается в глубоко вложенных данных.
- JSON с комментариями, JSON с запятыми и комментариями — существуют различные расширения JSON, расширяющие его как раз настолько, чтобы он стал удобным форматом конфигураций без добавления особой сложности. Наверно, самым распространённым является JSON с комментариями (JSON with comments), поскольку он используется как формат конфигураций в Visual Studio Code. Основной недостаток этих форматов в том, что они ещё не набрали популярность (пока!), потому что не так широко поддерживаются, как JSON и YAML.
- Простое подмножество YAML — многие из проблем YAML вызваны элементами без кавычек, которые походят на строки, но ведут себя иначе. Этого легко избежать: всегда заключайте в кавычки все строки. (Опытного YAML-разработчика можно узнать по тому, что он на всякий случай закавычивает все строки.) Мы можем принять решение всегда использовать
true
иfalse
вместоyes
иno
, держась подальше от неявных особенностей. Сложность здесь в том, что любая конструкция, не запрещённая явным образом, рано или поздно всё равно попадёт в кодовую базу, а я не знаю ни одного хорошего инструмента, способного принудительно обеспечивать применение безопасного подмножества YAML.
Генерация JSON как более совершенного YAML
Часто выбор формата зависит не от нас и приложение может принимать только YAML. Однако не всё потеряно, ведь YAML — это надмножество JSON, поэтому любой инструмент, способный создавать JSON, можно использовать для генерации YAML-документа.
Иногда изначально приложению требуется только один формат конфигурации, однако со временем может появиться много схожих строк файлов конфигурации и вам захочется передавать между ними части, а также абстрагировать повторения. Такое случается, например, с Kubernetes и GitHub Actions. Когда язык конфигураций не поддерживает абстракций, люди обычно обращаются к шаблонизации, а это плохая идея по описанным выше причинам. Лучше подойдут настоящие языки программирования, возможно, предназначенные для конкретной области. Мои любимые — это Nix и Python:
- Nix — это язык, используемый менеджером пакетов Nix. Он был создан для написания определений пакетов, однако достаточно хорошо работает в качестве формата конфигураций (и на самом деле используется для конфигурирования NixOS). Благодаря функциям, привязкам let и интерполяции строк он является мощным инструментом для абстрагирования повторяющейся конфигурации. Его синтаксис столь же легковесен, как и у TOML, и он может выполнять экспорт в JSON или XML. Например, он хорошо подходит для упрощения повторений в workflow-файле GitHub Actions.
- Python — документы JSON можно с минимальной адаптацией использовать как валидные литералы Python, к тому же Python поддерживает замыкающие запятые и комментарии. Он имеет переменные и функции, мощную интерполяцию строк и встроенный
json.dump
. Автономный файл Python, выводящий JSON на stdout, послужит вам добрую службу!
Наконец, в этой категории есть инструменты, которые я недостаточно много использовал, чтобы рекомендовать с уверенностью, но заслуживающие упоминания:
- Dhall — Dhall похож на Nix, но с типами. Он менее распространён, и лично мне имена его встроенных функций кажутся громоздкими.
- Cue — как и Dhall, Cue интегрирует информацию о типах/схемах в формат конфигурации. Cue — надмножество JSON, но несмотря на это, файлы, в которых используются функции Cue, кажутся мне незнакомыми. Я оставлю Cue для изучения в будущем, однако пока не встречал задач, в которых Cue мог бы быть самым подходящим решением.
- Hashicorp Configuration Language — я недостаточно долго пользовался HCL, чтобы сформировать о нём определённое мнение, но там, где я работал с ним, его потенциал абстрагирования казался более ограниченным, чем, например, у Nix.
Заключение
YAML задумывался как более дружественная для человека альтернатива JSON, но из-за всех своих особенностей он стал таким сложным форматом с таким множеством странных и неожиданных поведений, что людям сложно предсказать, как будет парситься конкретный YAML-документ. Если вам нужен формат конфигураций, то TOML — это дружественный формат без выстрелов в ногу, присущих YAML. В случаях, когда вы вынуждены пользоваться YAML, жизнеспособным решением будет генерация JSON из более подходящего языка. Генерация JSON также открывает возможности для абстрагирования и многократного использования в той степени, которую сложно безопасно достичь шаблонизацией YAML.