Есть счастливые люди, которые могут себе позволить просто перезаписывать 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, сконцентрировавшись на интересном, но это все конечно тоже отъело свою "часть пирога".
Главное, в итоге я получил что хотел. Если стало интересно посмотреть детальнее, то добро пожаловать на гитхаб.
И, конечно, буду рад услышать замечания, а возможно даже контрпримеры (сейчас этого сильно не хватает).
Автор: Vyacheslav