Всем привет! Я уже больше 6 лет занимаюсь разработкой микросервисов, и хочу рассказать про наш опыт работы с liquibase.

Неважно почему, но иногда может появиться желание заняться рефакторингом ваших скриптов liquibase. В моём случае постоянно возникали конфликты в общем файле журнала изменений, количество скриптов превратилось в ужасно длинный список, а в самих скриптах невозможно было ориентироваться, поскольку они содержали по 1–2 команды, а в названии файла были только дата и действие. Долго это терпел, долго взвешивал плюсы и минусы, и всё время боролся с желанием всё отрефачить. И в какой-то момент дошёл до точки, когда желание взяло верх.

Решение принято: рефакторингу быть! Сразу скажу, приступать было страшно, но сейчас я очень доволен результатом. «Идеальную» структуру мы не получили, пришлось идти на компромиссы и заплатить свою цену, зато в новой структуре удалось вылечить все проблемы. Теперь в ней удобно ориентироваться и читать код, конфликты создаются очень редко, а все скрипты автоматически детектируются liquibase-ом. Но только это конец истории. А вначале было вообще непонятно, как рефакторить журнал изменений, да так, чтобы в существующие базы данных он смог пролиться, и ничего не поломал при этом!

При написании статьи использовались следующие версии инструментов:

  • openjdk 17.0.13;

  • liquibase 4.30.0;

  • postgresql 16.5.

Примеры кода из статьи можно найти по ссылке.

А в чём проблема?

Разработчики ежедневно занимаются рефакторингом своих приложений, это такая же рядовая деятельность, как и создание нового функционала. Почему бы к рефакторингу скриптов liquibase не относиться так же, как к рефакторингу обычного приложения?

При разработке программ, написанных на любых языках программирования, будь то java, c++, javascript или любой другой, есть только одно состояние программы, которое хранится в системе контроля версий. Да, кто-то может поспорить, что состояние даже в каждой ветке может отличаться, но тем не менее обычно существует главный репозиторий кода, в котором есть главная ветка. Если конфликты возникают, то у разработчика всегда есть доступ к репозиторию и коду для решения конфликта.

Когда речь заходит о разработке схемы базы данных (БД), в том числе с использованием liquibase, помимо состояния кода в системе контроля версий появляется неограниченное количество состояний в различных БД. При этом состояния БД могут отличаться друг от друга, и в общем случае у разработчика не будет возможности вручную исправлять конфликты. Соответственно, при изменении схемы нужно учитывать, что изменённый код должен корректно примениться к любому из возможных состояний любой БД. Именно необходимость согласования изменений кода с существующими состояниями БД осложняет рефакторинг скриптов liquibase.

Варианты рефакторинга

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

Можно выделить следующие виды действий:

  1. форматирование набора изменений;

  2. перемещение или переименование файла с наборами изменений;

  3. перемещение набора изменений в другой файл;

  4. редактирование набора изменений;

  5. удаление набора изменений;

  6. объединение наборов изменений;

  7. разделение набора изменений.

Liquibase поддерживает несколько форматов файлов журнала изменений. В большинстве случаев необходимые действия для успешного рефакторинга не зависят от типа файла. Поэтому практически все примеры будут приводиться для формата sql. Случаи, когда изменение нельзя выполнить в формате sql, или, наоборот, можно выполнить только в формате sql, будут указаны отдельно.

В этих терминах задачу рефакторинга можно сформулировать следующим образом: какие противодействия нужны для указанных выше действий, чтобы обеспечить тождественность схемы БД и максимальную эквивалентность журнала изменений?

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

Команды liquibase

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

Проверять корректность наших изменений можно при помощи следующих команд:

liquibase validate

Команда validate проверяет наборы изменений на ошибки и сравнивает контрольные суммы с текущей БД. Изменения нельзя будет применить к БД до тех пор, пока все ошибки не будут исправлены. Сама по себе эта команда не так полезна, поскольку практически все остальные команды liquibase также выполняют валидацию скриптов перед основным действием.

liquibase status --verbose

Команда status показывает наборы изменений, который ещё не применены к нашей БД, но есть в нашем коде. Флаг --verbose добавит в вывод список ключей (о них далее) новых наборов изменений.

liquibase unexpected-changesets --verbose

В противовес status команда unexpected-changesets показывает наборы изменений, которые когда-то были применены к БД, но сейчас отсутствуют в нашем коде. Флаг --verbose работает аналогично.

Идентификация изменений в наборе изменений

Все применённые изменения liquibase хранит в таблице DATABASECHANGELOG. Таблица содержит множество колонок, но в контексте рефакторинга нас будут интересовать только колонки ключа изменения и контрольная сумма.

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

--liquibase formatted sql

--changeset Developer:1.0-create-table

create table table1 (
    id serial primary key,
    data text
);

create index t1_data_idx on table1 (data);

--changeset Developer:1.0-insert-table1

insert into table1 ("data") values ('Text');

Ключ позволяет идентифицировать набор изменений при последующих запусках liquibase (например чтобы повторно не применять изменения) и состоит из трёх полей: название файла, идентификатор и автор.

1.0-create-table.sql::1.0-create-table::Developer

Изменив любой из этих трёх атрибутов, вы получите новый набор изменений, который обязательно применится к каждой БД.

Среди возможных действий отсутствует вариант изменения идентификатора и/или автора набора изменений. Формально такое изменение допустимо, но в контексте рефакторинга оно не имеет смысла. В этом случае команда status покажет появление нового изменения, которого нет ни в одной существующей БД, а команда unexpected-changesets — что исчез исходный набор с предыдущим ключом.

Злоупотребление таким действием просто будет засорять вывод команды unexpected-changesets, что может только усложнить рефакторинг долго существующих БД.

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

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

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

Перемещение и переименование файлов

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

Поскольку код не меняется, контрольные суммы остаются прежними. Значит, изменения, уже применённые к БД, должны быть проигнорированы. Вот только из-за перемещения или переименования файла у нас поменяется ключ. С точки зрения liquibase мы создали новый набор изменений, и совершенно неважно, какая у него контрольная сумма, если такого ключа нет в DATABASECHANGELOG. Команда validate в этом случае не обнаружит никаких проблем, и новые наборы изменений попробуют примениться к БД. Последующее поведение зависит от кода: если в наборе изменений создаётся таблица, то обновление прервётся из-за ошибки, что таблица уже существует, и изменения не будут применены. Однако если добавляются строки в таблицу, они могут вставиться повторно, и система не выдаст ошибку.

Обнаруживать такого рода проблемы помогут команды status и unexpected-changesets. Первая добавит в список новых наборов изменений новый ключ с изменённым названием файла. Вторая команда покажет, что отсутствует набор изменений со старым названием файла.

Чтобы подобрать противодействие, нужно разобраться, как liquibase работает со структурой журнала изменений. Для поиска наборов изменений liquibase использует 2 параметра:

  • search-path — путь до директории, которая определяет стартовую точку для поиска наборов изменений. Можно считать это рабочей директорией.

  • changelog-file — стартовый файл журнала изменений, который может ссылаться на другие скрипты. При этом все пути к скриптам и путь до самого этого файла должны строиться относительно search-path.

Собственно путь до файла с наборами изменений относительно search-path и является одним из элементов ключа набора изменений.

В качестве противодействия при перемещении файла можно использовать атрибут журнала изменений logicalFilePath, указав в нём старый относительный путь.

Аналогично для случая переименования файла:

Перемещение набора изменений в другой файл

Есть ещё одно похожее действие — перемещение набора изменений в другой файл. С точки зрения разработчика, действие отличается от описанных выше, но для liquibase мы точно так же удаляем набор изменения со старым файлом в ключе, и создаём новый набор изменений.

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

Порядок следования наборов изменений

Описанные выше 3 вида действий обладают ещё одним эффектом. В результате их применения можно изменить порядок следования наборов изменений. Изменение порядка можно было бы вынести, как отдельный вариант действия, но это не сделано намеренно. Дело в том, что liquibase никак не поможет отследить даже сам факт изменения порядка, не говоря уже о возможной при таком изменении ошибки.

Кроме того, ошибка порядка следования наборов изменений может даже не проявиться в уже существующих БД, но выстрелит при раскате на новой чистой БД. Этому способствует факт того, что журнал изменений не тождественно равен истории изменений в существующей БД. Если наборы изменений были применены к БД, то их последующий порядок не играет роли — даже если он нарушен относительно исходного. В истории ключ есть, контрольная сумма совпадает, значит, всё хорошо.

Пример нагляднее покажет проблему. Допустим, вы написали набор изменений, который создаёт новую таблицу, и применили этот набор к БД:

--# 2.0-create-table2.sql

--liquibase formatted sql

--changeset Developer:create-table2

create table table2 (
    id serial primary key,
    data text
);

А при следующей доработке вы добавили набор изменений, который вставляет строку в эту таблицу, но этот набор добавили до создания таблицы:

--# 2.0-create-table2.sql

--liquibase formatted sql

--changeset Developer:insert-table2

insert into table2 ("data") values ('Text');

--changeset Developer:create-table2

create table table2 (
    id serial primary key,
    data text
);

Если вы примените такой скрипт к старой БД, то произойдёт следующее:

  1. Liquibase обнаружит новый набор изменений 2.0-create-table2.sql::insert-table2::Developer.

  2. Данный ключ отсутствует в DATABASECHANGELOG, поэтому этот набор изменений будет применён к БД.

  3. Строка успешно вставится, поскольку необходимая таблица существует в БД.

  4. Liquibase обнаружит набор изменений 2.0-create-table2.sql::create-table2::Developer.

  5. Данный ключ есть в DATABASECHANGELOG, и контрольная сумма совпадает, поэтому набор изменений пропустится.

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

К сожалению, с проблемой правильно порядка в журнале изменений разработчик остаётся один на один с кодом. Liquibase не анализирует смысл кода и не предоставляет явных инструментов для работы с этой проблемой. Однако есть общие рекомендации, которые помогут в некоторых случаях избежать ошибок. Они будут приведены в отдельном разделе в конце статьи.

Редактирование наборов изменений

Редактирование наборов изменений — наиболее сложное из действий. Рассмотренные ранее действия могли только спровоцировать нежелательный повторный запуск наборов изменений, поскольку менялся только ключ набора. При редактировании изменяется контрольная сумма, и нужно уже следить, чтобы новые версии наборов изменений не противоречили всем уже существующим экземплярам БД.

Ранее мы уже упоминали, что изменение любого непробельного символа приведёт к изменению контрольной суммы, а следовательно, и к ошибке валидации. Причём неважно, добавили мы новую инструкцию или комментарий. Для такого случая авторы liquibase предлагают вручную удалить контрольную сумму у нужной строки в таблице DATABASECHANGELOG во всех используемых БД. Это позволит избежать ошибки валидации, и при следующем запуске liquibase update в таблице пропишется новая контрольная сумма без повторного исполнения набора изменений.

Вариант, конечно, самый надёжный и всегда сработает, но очень сложный в реализации. По соображениям безопасности у разработчика может отсутствовать доступ к промышленной БД. Ешё схема БД может являться частью коробочного приложения, и помимо отсутствия доступа, вы даже не будете иметь представление, сколько экземпляров БД нужно вручную обновить. Решение больше подходит не для разработчиков, а для администраторов БД. Идея вставлять запрос обновления строки DATABASECHANGELOG в отдельный набор изменений перед тем, который собираетесь исправлять, тоже обречена на провал. Изменённый запрос не пройдёт валидацию, ведь контрольная сумма ещё не зачищена.

Другой вариант решения проблемы предлагает использовать конструкцию validCheckSum, в которой жёстко прописывается контрольная сумма. Причём можно указать как новую, так и старую контрольную сумму. При таком подходе команда liquibase update просто проигнорирует эту строку, и обновлять контрольную сумму в истории не будет.

Для формата sql декларацию validCheckSum обязательно нужно делать в отдельной строке.

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

Противодействие с использованием validCheckSum очень простое в реализации. Кроме того, оно будет работать для всех форматов файлов. Однако это решение просто отключает проверки и расслабляет разработчика. При отключении валидации очень легко забыться и сделать изменённую схему отличающейся или даже несовместимой с существующими БД. Если всё же решили использовать этот способ, то рекомендую прописывать новые контрольные суммы, чтобы хотя бы при повторном рефакторинге отработала валидация и обнаружились новые изменения.

runOnChange

Существует более элегантное противодействие для действия редактирования, и здесь формат sql покажет всю свою мощь по сравнению с остальными. Идея заключается в том, чтобы добавлять к набору изменений атрибут runOnChange со значением true, который будет применять наш набор изменений к БД при каждом отличии контрольной суммы.

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

К сожалению, команды самого liquibase очень ограничены в возможностях проверок реализующих идемпотентность. И те проверки, что имеются, поддерживаются не всеми БД, и не всеми версиями liquibase. Поэтому использование этого противодействия для форматов xml, json и yaml возможно далеко не для всех случаев. В таблице ниже описаны все атрибуты из бесплатной версии liquibase, которые позволят безопасно повторно исполнять наборы изменений:

Команда

Атрибут

Версия liquibase

createTable

ifNotExists

4.26.0+

createView

replaceIfExists

1.5+

dropView

ifExists

4.19.0+

createProcedure

replaceIfExists

3.3+

У формата sql ограничения зависят только от диалекта, который использует ваша БД. Для PostgreSQL пример выше можно переписать следующим образом. При этом обратите внимание, что команда status будет показывать один новый набор изменений, а unexpected-changesets не найдёт ни одного пропавшего.

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

Разумеется, противодействие с использованием runOnChange наиболее сложное, и требует больших навыков от программиста. Также подходит практически только для формата sql, и то могут быть ограничения, связанные с конкретной БД. Однако, если такая возможность есть, рекомендую использовать именно это противодействие при редактировании наборов изменений. Главная идея такого подхода заключается в том, что разработчику придётся написать такой код, который можно будет применять к БД безопасно сколько угодно раз.

runOnChange + preConditions

Если вы всё же отдаёте предпочтение форматам xml, json или yaml, но не хотите при рефакторинге прописывать контрольные суммы, то вам может помочь комбинация runOnChange и предварительных условий. Вот здесь уже плохо себя покажет формат sql, поскольку обладает достаточно низкой поддержкой предварительных условий. Для остальных форматов реализован большой выбор проверок, которые способны комбинироваться в любые необходимые конструкции. Со списком существующих проверок можно ознакомиться по ссылке.

Идея аналогична предыдущей: добавить проверки, чтобы предотвратить повторное выполнение кода. Однако в этом случае проверки выносятся в отдельный блок preConditions, который выполняется независимо в начале набора изменений. Сам код при этом уже не обязан быть идемпотентным. Важно добавить опцию onFail со значением MARK_RAN, чтобы в случае неудачной проверки набор изменений пометился как исполненный, но без реального выполнения. При этом в истории появится вторая запись с тем же ключом, но другой контрольной суммой и статусом MARK_RAN. Если проверка будет пройдена успешно, код выполнится, и в истории останется только одна запись со статусом EXECUTED.

Механизм предварительных условий гораздо мощнее, и используется для многих других задач, помимо рефакторинга. Однако работать с таким кодом несколько сложнее, чем с нативным sql, так как для отладки обязательно придётся запускать liquibase update.

Удаление сущностей БД при редактировании

В примере, который мы рассматривали выше, удаляется операция создания индекса в наборе изменений. Разумеется, в таком виде рефакторинг не может быть допустим. В течение жизни проекта могут возникнуть ситуации, когда нужно удалить какую-то сущность в БД. И неиспользуемый индекс — хороший тому пример. Для таких случаев необходимо заводить отдельный набор изменений с операцией удаления:

--changeset Developer:drop-t2_data_idx

drop index t2_data_idx;

Но раз нам больше не нужен индекс, зачем тогда создавать его в новых чистых БД, если тут же его будем удалять? Кажется естественным удалить и декларацию создания индекса. Однако если так поступить, то изменение с удалением индекса прекрасно применится на существующих БД, в которых есть индекс, но выскочит ошибка на новых БД, в который такого индекса и не будет. Опять же, для liquibase такой код будет полностью валидным, и он не сможет обнаружить эту ошибку.

Правильное решение для такой ситуации аналогично методу, рассмотренному в предыдущем разделе. Необходимо проверять существование объекта перед запуском удаления. И с учётом такого подхода можно удалить и операцию, порождающую этот объект. В таком случае код корректно отработает и на существующих, и на новых БД. На PostgreSQL это можно сделать так:

Удаление наборов изменений

А что, если удалить нужно не одну инструкцию, а целый набор изменений? На самом деле здесь совсем всё просто, набор изменений нужно просто удалить. Какие-то определённые противодействия не требуются. Разумеется, не стоит забывать о согласовании такого удаления с сущностями БД, как в предыдущем разделе, если имеется такая необходимость.

Только стоит отметить, что после удаления набора изменений, команда unexpected-changesets всегда будет показывать ключ удалённого набора изменений в своём списке. При регулярном удалении вывод команды unexpected-changesets может стать очень большим, и мешать отслеживанию рефакторинга. Чтобы исправить это, нужно удалить из таблицы DATABASECHANGELOG строки с ключами удалённых наборов изменений.

Объединение и разделение наборов изменений

Осталось ещё 2 вида нерассмотренных действий: объединение и разделение. Но для liquibase эти действия являются комбинациями рассмотренных выше действий. А следовательно, и противодействия будут являться комбинацией соответствующих противодействий.

Объединение наборов изменений можно рассматривать, как удаление одного набора изменений и редактирования другого. Единственное отличие в противодействии: не нужно согласовывать сущности БД после удаления, так как сами команды не пропадают. Остаётся только проследить, чтобы не было проблем с порядком операций.

Разделение рассматривается, как редактирование одного и создание другого набора изменений.

Общие рекомендации

Старайтесь не делать весь рефакторинг за 1 раз. Разбейте его на небольшие участки и работайте с ними по отдельности, желательно даже каждый такой участок отдельно раскатывать в БД.

Как можно чаще запускайте команды status и unexpected-changesets. Желательно это делать после каждого действия, чтобы точно убедиться, что вы учли все нюансы. Причём не просто запускайте, а проверяйте все списки, точно ли они показали вам те ключи, которые вы ожидали увидеть.

Чтобы отловить возможные ошибки, связанные с порядком наборов изменений, после рефакторинга раскатайте вашу схему на чистой БД, например в docker-контейнере. Хотя бы убедитесь, что нет ошибок. Но можно и сравнить получившуюся схему со схемой старой БД при помощи команды liquibase diff.

Можете создать тег для отката перед проливкой отредактированного кода. Но для этого формат sql будет скорее недостатком. Если для остальных форматов liquibase умеет автоматически генерировать код отката для некоторых команд, то для sql всегда придётся писать откаты вручную. Если вы не практикуете написание кода отката, то этот совет, скорее всего, вам не поможет. Про организацию отката можно прочитать в официальной документации.

Если же всё пошло плохо, или очень много наборов изменений пришлось отредактировать, то может помочь команда liquibase clear-checksums, которая удалит все контрольные суммы из истории. При последующем запуске обновления пропишутся новые контрольные суммы наборам изменений без повторной попытки запуска кода.

Шпаргалка по рефакторингу

Напоследок хочу предоставить сводную таблицу по действиям и противодействиям рефакторинга liquibase.

Действие

Противодействие

Форматирование набора изменений

Не требуется

Изменение автора или id

Не несёт смысла, равносильно удалению + созданию

Перемещения или переименование файла с наборами изменений

logicalFilePath на файл

Перемещение набора изменений в другой файл

logicalFilePath на набор изменений

Редактирование набора изменений

Удаление контрольной суммы; validCheckSum; runOnChange:true + идемпотентность; runOnChange:true + preConditions

Удаление набора изменений

Не требуется. Опционально удалить запись с соответствующим ключом из истории

Объединение наборов изменений

Редактирование + удаление

Разделение набора изменений

Редактирование + создание

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