[Перевод] YAML из Ада

dgzgh4st6jy2dnkch4j-ugkjm60.jpeg


Формат данных 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.

© Habrahabr.ru