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

Естественно, что руки сами тянутся автоматизировать такое безобразие, но гугл быстро дал понять что не я один мечтаю о хорошем, только вот заветного оазиса пока никто не нашел. Нет, смержить два YAML файлика задача не трудная, но только если готов пожертвовать комментами (что для многих, как и для меня, недопустимо).

Как вы уже могли догадаться, тулзу я в итоге написал свою (java). Но рассказать я хочу не о том что она умеет, а о том что было после "да что я сам не сделаю что ли...".

Кода не будет, просто описание того с чем пришлось иметь дело и что в итоге пришлось сделать (что гораздо интереснее скучных циклов). Заранее извиняюсь за обилие англицизмов.

Начало

Для начала забавное наблюдение. Все разговоры в интернете про YAML парсеры начинаются с того какая спека сложная и как нетривиально написать YAML парсер (и это, конечно же, правда). А ведь понятно что для сохранения комментов свой парсер написать таки придется. И было не так легко перейти от мысли "куда ты лезешь, закопаешься!" к мысли "да мне нужен то лишь кусочек спеки, ничего страшного". Еще одна иллюстрация того что "все преграды в нашей голове".

Минимальный парсер, для случая одного дерева в файле (без ссылок) пишется за один присест. А вот финальная модель для него сложилась не сразу.

Парсинг

Очевидно что для построения дерева главное вычленить имя свойства и его отступ.

Правило для комментов тоже родилось сразу: "все что выше свойства это его коммент". Ну идеально же, так можно элементарно воссоздать файл 1-в-1 (а учитывая что чаще в апдейтах конфигов свойства добавляются без перемешивания остального, так вообще идеально). Для примера:

# Это все

# коммент для prop
prop:

  # Это коммент для sub (включая пустую строку сверху)
  sub: 1  
# А это коммент для sub2 (отступ не важен)  
  sub2: 2

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

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

prop: val # inline comment

Мультилайн

Первый раз YAML кольнул многострочными значениями: вы знали сколько опций настройки мультилайна в YAML? Я нет, но, тем не менее, эту часть пришлось честно реализовывать.

multiline: some very
  very very               # с комментарием
  
  long value

Собсвенно, мультилайны определили вид value для нод как List<String>. По большому счету, главной проблемой было лишь определение где мультилайн заканчивается: значение сохраняется 1-в-1 (для того чтобы можно было его записать обратно без изменений), но вот отделить его от нижеследующего коммента крайне важно.

Списки

Список может быть скалярным:

list:
	- one
  - two

Тут принцип одна строчка файла - одна нода ложится идеально.

А может содержать объекты:

list:
	- one: 1
    two: 2

И вот тут то правило "одна нода - одна строчка" осеклось. Нет, я честно уперся рогом и долго пытался его придерживаться - получилось мягко говоря не очень: one: 1 был рутовой нодой (обозначающей объект списка), а все что дальше его детьми.

Пример ниже окончательно зарубил такой подход:

list:
  - one: 1
      sub: s
    two: 2

(sub и two сливались в один список)

Пришлось вводить группирующую ноду (и сразу дышать стало легче). Так же это очень пригодилось когда вспомнил что листы могут записываться и так (spring boot любит такое написание):

list:
  - 
    one: 1
    two: 2

Естественно, большинство этих мутаций модели повылезали на этапе отладки мержера, но о нем отдельно.

Мержер

Казалось бы, есть два дерева - проще простого пробежаться да смержить модельки. Но, как всегда, не все так просто.

Во-первых, могут измениться отступы:

Было:

level:
  one: 1

Стало:

level:
    one: 1
    two: 2

Если мержить "в лоб", будет невалидный YAML:

level:
  one: 1
    two: 2

Значит нужно всегда переформатировать старые ноды согласно новым отступам (неважно в какую сторону). Причем крайне важно сдвигать все поддерево, а то можно порушить листы или мультилайны (которые очень завязаны на отступы). В рамках свойства обязательно должны "съезжать" коммент и значение. Например, если сдвинуть только свойство здесь:

prop:
  # комментарий
  multiline: long long
   long value

Получится некрасивый коммент и невалидное значение (ошибка синтаксиса):

prop:
  # комментарий
    multiline: long long
   long value

Дальше - порядок свойств. Что если в новом конфиге они были реорганизованы? Оставлять как в старом? Но тогда куда вставлять добавленные свойства?.

По-моему, новый файл должен быть примером во всем: если что-то поменяли местами, значит так лучше! П.э. берем за основу новый порядок (т.е. по-честному перетасовываем старые ноды).

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

# Очень большой 
# Заголовок

# Коммент
prop1: val

prop2: val

Если в новом конфиге свойства переставлены, а комментарии бы не обновлялись то вся шапка уехала бы в коммент к нижнему свойству (поскольку коммент это все что находится над свойством). И вот только подмена комментов и выручает в этом случае (возвращая заголовок на место, в шапку файла).

И снова списки

Как вы наверно уже догадались, списки я "люблю" больше всего. Кто бы мог подумать что это будет самой сложной частью.

Со скалярными списками все просто - это значение а значения мы не трогаем (т.е. оно просто переезжает со старого конфига, без перестановок):

list:
  - 1
  - 2  

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

  • Во-первых, может смениться стиль записи объекта с "начинаем после дэша" на "начинаем с новой строки после дэша" (или наоборот). Но это не сложно поддержать (буквально на уровне свойства модели).

  • Во-вторых, нужно добавлять новые свойства в объекты списка.

Второе не совсем очевидно: почему новые элементы списка мы не добавляем (текущее значение священно), а вот новые свойства добавить можем? Ну вот такое допущение - иногда списки играют скорее структурную роль (группируя конфигурации по смыслу):

networks:
  - name: TCP
    prop: 1
  - name: UDP     
    prop: 2

Кроме того, я не знаю контр-примеров когда бы такое поведение было не верным.

Именно тут вылазит самая интересная проблема: а как понять какой элемент нового списка соотносится со старым? Порядок элементов может измениться, и вообще элемент может быть удален в новом конфиге.

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

Ну а дальше остается только считать количество совпавших значений (учитывая все поддерево элемента) - у кого больше всего совпадений, тот и референсный элемент. Причем если находится больше одного элемента с тем же количеством совпадений, считаем что референс не найден - обновления элемента не будет.

Например, для элемента a:1 в новом списке нет однозначного совпадения:

list:
  - a: 1
    b: 2
  - a: 1
    b: 3

Оба элемента подходят (остальные новые свойства не важны), а значит нужный элемент не найден.

А что если свойство содержит лист? Для скалярного листа просто игнорируем. Например, если в новом конфиге:

list:
  - a: 1
    b: 
      - 1
      - 2
  - a: 2
    b: 
      - 3
      - 4

А оригинальный элемент был:

list:
  - a: 1
    b:
      - 8
      - 9

То первый элемент нового конфига считаем "совпавшим" (игнорируя листы).

Обоснования такому подходу, если честно, нет. Просто на уровне интуиции показалось так правильнее. Вполне возможно что в скором времени всплывет контрпример и придется переделывать.

Для объектного списка волевым решением выбрано правило: должен совпасть хоть один элемент. А совпасть это как? А это точно так же как описано выше (inception).

Даже по описанию видно, насколько вольные допущения использованы, но как можно еще угадать? Практика покажет насколько это все было правильно.Я сколько мог примеров напридумывал (эта часть переписывалась несколько раз).

Удаление

Выше все было про добавление нового, но свойства могут и удаляться. Автоматизировать такое никак нельзя (пойди разберись потом что он там навыкашивал), поэтому пришлось вводить простейший "YAML path" чтобы можно было удалять как "листики" так и целые поддеревья передавая при запуске список путей для удаления.

Например, в примере ниже level.oneи network[0] удалят:

level:
  one: 1        # del

network:
  - name: TCP   # del
  - name: UDP

Таким образом решается и проблема переопределения значения: просто удаляем старое и мержер вставит значение из нового конфига.

То же с листами: нужно обновить - удаляем старый, новый лист целиком его заменит.

Конечно это не покрывает все возможные случаи, но как наиболее сбалансированное должно подойти.

Надежность

Как все знают YAML крайне коварная штука - проще простого сделать что-то "не так". Например, мое любимое:

# айяй! нет пробела!
prop:value

И вот если бы я по честному писал парсер, то он раздулся бы в N раз, только благодаря валидации синтаксиса (не зря умные люди пугали).

Да и в любом случае, молодой парсер неизбежно содержит баги (сколько бы я его не вылизывал), чисто из-за малой базы протестированных ситуаций (как я, надеюсь, показал выше с YAML'ом непредвиденных ситуаций возникает много). И потому я решил считерить: взять надежный и проверенный временем парсер (snakeyaml) и валидировать им все файлики перед основным парсером.

Таким образом мой парсер всегда работает только с валидным YAML синтаксисом и может "не отвлекаться" на непредвиденные случаи.

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

Выше я упоминал что для сопоставления объектных списков используются значения свойств, при этом, парсер значениям уделяет мало внимания (важно сохранить как записано а не что). Поэтому при сравнении деревьев, точные значения из модели snakayaml проставляются в модель комментов, увеличивая точность сравнения списков.

Результат мержа так же читается snakeyaml'ом чтобы убедиться в корректности получившегося файла. Ну и раз точные деревья ДО и ПОСЛЕ уже подготовлены, полученное дерево валидируется на корректность: должно содержать все старое и все новое (тут сильно пригодился примитивный "YAML path" добавленный для удаления).

Не все полезно

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

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

Но вот хорошего примера зачем такое могло бы пригодиться найти не удалось - ну и усложнение пошло под нож.

Репорт

Необходимость репорта всплыла сразу после первого же релиза: очень оказалось неприятно смотреть на радостное "я все сделал!" с немым вопросом "а что ты сделал с моим конфигом?"

Так появился вот такой репорт:

Configuration: /var/somewhere/config.yml (185 bytes, 23 lines)
Updated from source of 497 bytes, 25 lines
Resulted in 351 bytes, 25 lines

	Added from new file:
		prop/three                               7  | three: 3                              # new property
		lists/obj[0]/three                       20 | three: 3                        # new value

Тут, кстати, интересный момент: почему в "YAML path" вместо точек разделителем идет / ? Да просто точка может запросто быть в имени свойства (например, te.st вполне допустимое имя - можете проверить, я не верил пока сам не убедился). И то что при удалении пути можно передавать с точкой просто упрощение для пользователей (иначе будут "детские" ошибки, сам попадался).

Отладка

Здесь хотелось бы вспомнить знаменитую цитату: "лучше день потерять, потом за 5 минут долететь". В который раз убеждаюсь что нет ничего лучше toString, написанного "для себя".

В модели деревьев toString выдает техническую структуру дерева: ох как же без этого было тяжко дебажить. Зато теперь в дебагере сразу видишь все дерево и все понятно (да еще и в тестах валидировать модель через тустринг сильно удобнее). Ну и, конечно, как приятно когда отдельные ноды в дебаггере сразу "говорят" о себе все что нужно.

В валидационные ексепшены врендерены куски сравниваемых поддеревьев:

Comments parser validation problem on line 0: 1 child nodes found but should be at least 2 (this is a parser bug, please report it!)     
      Comments parser subtree:    Structure parser subtree:
         2| one:                     2| one:
         3|   sub: s                 3|   sub: s
                                     4| two: 2

(рядом, для наглядности) - не передать сколько времени это сэкономило при отладке (и надеюсь еще сэкономит в дальнейшем).

Жаль что такие полезные штуки почти всегда не пишутся сразу, а появляются лишь в условиях крайней необходимости.

Заключение

Вот так вот простая задачка "на пару вечеров", обрастала деталями и растянулась на пару месяцев. Я не стал тут затрагивать переменных, бэкапы, добавления CLI, сконцентрировавшись на интересном, но это все конечно тоже отъело свою "часть пирога".

Главное, в итоге я получил что хотел. Если стало интересно посмотреть детальнее, то добро пожаловать на гитхаб.

И, конечно, буду рад услышать замечания, а возможно даже контрпримеры (сейчас этого сильно не хватает).

Комментарии (10)


  1. xonix
    26.09.2021 02:06

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


    1. xvk Автор
      26.09.2021 09:11
      +1

      Изначально все затевалось ради добавления новых свойств в существующие конфиги (и иногда удалений). Ручные обновления были крайне неудобны (вплоть до того что иногда тупо перезапустить сервис автоматом нельзя пока конфиг не поправят).

      Я по честному хотел найти существующее решение, но находил лишь вопросы людей ищущих решения как и я. Единственно что есть в этой области, это eo-yaml и один парсер на питоне которые частично умеют работать с комментами, но это не подходило.

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

      Вопросы, я думаю, вызывает мерж списков. Здесь я просто вспомнил что сталкивался с конфигами где списки были сложными и ветвистыми структурами (а не значениями). Собственно все связанное с ними построено на предположениях и сейчас не подтверждено практикой.

      Я специально писал статью без кода, описывая с чем сталкивался и как решал, чтобы мне могли указать на концептуальные проблемы. Естественно что меня, как любого программиста, иногда переклинивает и уносит в усложнения и я буду только рад услышать как можно было сделать проще.


  1. rrrad
    26.09.2021 14:10

    Может вам присмотреться к hocon'у вместо yaml-а? Если, конечно, конфиги нужны не для какой-то готовой экосистемы, наглухо прибитой к yaml?


    1. xvk Автор
      27.09.2021 11:15

      У меня dropwizard, который по дефолту на yaml конфигах, хотя, конечно же, не наглухо прибит. Hocon я смотрел, но мне совершенно не понравился синтаксис (перегруженный и технический). Он бы подошел для "технарей" на стороне пользователя, но в общем случае желательно что-то наглядное и простое (yaml сильно чище и читается лучше).

      С другой стороны, если бы он умел мержить я бы смирился. Помню прототип мержера я начинал делать именно на основе hocon'а (уж очень не хотелось делать свой парсер для yaml'а), но, к сожалению, не помню почему эта идея загнулась (во что-то уперся).


      1. rrrad
        28.09.2021 07:27

        Мне кажется, для реализации библиотеки Config (референсная реализация hocon на java) делали поддержку в т.ч. yaml-а, но майнтенерам проекта особо некогда её развивать (плюс у них эта либа лежит в основе целой экосистемы, поэтому они боятся поломать что-то), так что pool request, вроде-бы, до сих пор висит.

        Что касается поддержки мержа - она там прямо встроенная в синтаксис:

        a {
          xx = 1
        }
        b = ${a} {
          yy = true
        }
        c = ${a}
        c {
          zz = "my string"
        }
        d = ${a} ${b} ${c}
        d {
          additional = 0
        }

        на выходе получим

        d {
          xx = 1
          yy = true
          zz = "my string"
          additional = 0
        }

        p.s.: лично у меня сложилась обратное отношение, yaml кажется слишком хрупким, требующим обязательное применение IDE для правки


        1. xvk Автор
          28.09.2021 12:03

          Это мерж значений, а что с комментами? У меня тот прототип, к сожалению, не под рукой, не скажу наверняка что именно это и было камнем преткновения, но что-то там остановило железно (может, кстати, это был мерж листов: помню где-то он был безапиляционно неотключаемый). Хотя, конечно, допускаю что мог что-то упустить.

          "Проблема" с классическими парсерами в том что они прежде всего "читатели". У них нет задачи запоминать форматирование, соответственно и записать обратно "как было" не могут. Например, в случае с мультилайном, запросто может обратно записать его в одну строчку, что для человека уже сильно не удобно (например, это может быть шаблон автоответа). Часто инлайн комменты упускаются совсем (по-моему, eo-yaml как раз так делает).

          В моем случае, конфиги сильно ориентированны на чтение людьми "не в теме" (сильно смахивает на конфиг апача, нгинкса и т.д. когда значения "тонут" в документации). Конфиг форматируется для удобства чтения, включая форматирование списков. И нельзя чтобы его вдруг "изнахратили". Сложность и была, прежде всего, в "верни все как было".

          Конечно, я полностью согласен с не надежностью yaml: сам влетал не раз. И технически hocon естественно надежней. Но конкретно в моем случае yaml пугает людей меньше и читать его сильно удобнее. Ошибки допустимо редки (в силу характера правок).


          1. rrrad
            29.09.2021 11:06

            Тут суть в том, что мержить не надо. Там есть возможность включения файлов друг в друга, итоговый конфиг можно хранить именно в таком (разобранном) виде.

            Если же требуется получить смерженный итоговый конфиг - это тоже доступно (хотя на сколько "красив" будет итоговый файл - это дело вкурса, плюс текущая реализация сортирует ключи, для кого-то это может быть минусом), при чём коментарии сохраняются (и попутно можно добавить коментарий с источником, из которого появилась та или иная строка, хотя последняя функция работает не идеально).

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


            1. xvk Автор
              29.09.2021 13:11

              Вот насчет "мержить не надо", это была моя первая мысль: я практически сразу начал зашивать мастер конфиг в джарку и при старте на лету мержить с текущим конфигом (это буквально парой строк в snakeyaml делается). Таким образом сгладилась проблема добавлений: все новые свойства просто шли из мастер конфига с дефолтными значениями.

              Для админов же оказалось проблематично менять такие "фантомные" свойства, не обозначенные в основном конфиге (потому что доку по ручному обновлению конфига иногда пропускали и, соответственно, конфиг не всегда был актуальный, хоть все и продолжало работать).

              Конечно была специальная админская страничка показывающая полный конфиг (без комментов), но это было не сильно удобно. Можно было бы на старте добавить автоматический "отлов" устаревших конфигов. Но это все та же ручная миграция.

              Я согласен что для многих (очень многих) ситуаций Config бы прекрасно подошел. Но у меня, по сути, задача стояла заменить работу человека: внести правки так же аккуратно (при не допустимости некоторых компромиссов).

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


  1. zantor
    26.09.2021 17:22

    Зачем велосипед, если yq умеет это из коробки?

    $ tail -n +1 data{1,2}.yml
    ==> data1.yml <==
    # this is a comment 1
    a: simple
    # this is a comment 2
    b: [1, 2]
    
    ==> data2.yml <==
    # this is a comment 3
    a: other
    # this is a comment 4
    c:
      test: 1
    $ yq -V
    yq (https://github.com/mikefarah/yq/) version 4.13.0
    $ yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' data1.yml data2.yml
    # this is a comment 1
    # this is a comment 3
    a: other
    # this is a comment 2
    b: [1, 2]
    # this is a comment 4
    c:
      test: 1


    1. xvk Автор
      27.09.2021 11:27

      Я пытался с ним поиграться (я долго искал что есть прежде чем что-то делать). Не могу с ходу вспомнить в чем с ним была проблема, но даже из вашего примера: он у свойства 'a' тупо слепил два коммента, а у меня это будут две одинаковые портянки (как в конфигах апача) с полной докой (во что оно превратится после N апдейтов).

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