В этой статье я разберу использование утилиты Liquibase в Spring Boot приложениях для версионирования структуры реляционной БД и миграции этой структуры с одной версии на другую. В первой части разберем базовый пример, а во второй поговорим об использовании liquibase-mave-plugin для отката изменений и автоматической генерации скриптов через сравнение структур БД.

Начнем с того, что создадим простейшее приложение на Spring Boot + JPA (Hibernate). В этом нам поможет Spring Initializr. Из зависимостей выбираем JPA, MySQL и Web. Liquibase тоже можно подключить на этом шаге, но для лучшего понимания мы это сделаем далее вручную.

Создаем основу приложения


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

@Entity
@Table(name = "users")
public class User implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "username", unique = true, nullable = false)
    private String userName;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "first_name")
    private String firstName;

    @Column(name = "last_name")
    private String lastName;

    @Column(name = "email")
    private String email;

    // Далее конструктор по умолчанию, геттеры и сеттеры
    // Или можете использовать Lombok
}

Spring Data позволяет сделать код репозитория чрезвычайно кратким

public interface UserRepository extends JpaRepository<User, Long> {
}

REST-контроллер, который будет выдавать все содержимое таблицы пользователей

@RestController
public class UserController {
    
    private UserRepository userRepository;

    @Autowired
    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @GetMapping("/user/all")
    public List<User> allUsers() {
        return userRepository.findAll();
    }
}

Настройки в файл application.properties

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/geek_db?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=dbuser
spring.datasource.password=dbpassword

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect

Предполагается, что на вашем компьютере запущен сервер MySQL на стандартном порту. При необходимости, подкорректируйте URL сервера в строке соединения, а также логин и пароль. Также стоит обратить внимание на параметр createDatabaseIfNotExist. Благодаря нему мы будем при подключении создавать базу данных с именем geek_db, если её нет на сервере.

Добавляем Liquibase


Наверняка вы обратили внимание, что не хватает одной настройки для Hibernate, а именно spring.jpa.hibernate.ddl-auto. В большинстве руководств для начинающих для нее указывают значение update, благодаря которому Hibernate будте сама создавать и корректировать структуру таблиц на сервере, основываясь на присутствующих в проекте классах сущностей. Такой подход вполне может быть использован, если схема данных очень простая или проект учебный, но при сколь-нибудь сложной схеме скорее всего начнутся проблемы хотя бы из-за того, что мы никак не можем контролировать процесс генерации Hibernate-ом DDL скриптов. Ещё одна проблемная состоит в том, что при таком подходе нет простого способа откатить сделанные Hibernate изменения в структуре БД.

Именно для решения выше описанных проблем мы и будем использовать утилиту Liquibase. На наше счастье, она отлично умеет интегрироваться со Spring Boot приложениями! Чтобы начать её использовать, необходимо выполнить следующие действия

Добавляем в файл application.properties настройку

spring.jpa.hibernate.ddl-auto=none

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

Добавляем в pom.xml зависимость

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>

После её добавления Spring boot автоматически будет создавать специальный бин с именем liquibase, который при каждом запуске приложения будет выполнять все действия по настройке схемы БД на основе скриптов Liquibase.

Теперь необходимо добавить сам Liquibase скрипт, который будет создавать нужную нам таблицу. Создаем в папке /src/main/resources/db/changelog файл с именем db.changelog-master.yaml и добавляем в него следующее содержимое

databaseChangeLog:
  - logicalFilePath: db/changelog/db.changelog-lesson1.yaml
  - changeSet:
      id: 1
      author: your_liquibase_username
      changes:
        - createTable:
            tableName: users
            columns:
              - column:
                  name: id
                  type: BIGINT
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: username
                  type: varchar(50)
                  constraints:
                    unique: true
                    nullable: false
              - column:
                  name: password
                  type: varchar(512)
                  constraints:
                    nullable: false
              - column:
                  name: first_name
                  type: varchar(50)
              - column:
                  name: last_name
                  type: varchar(50)
              - column:
                  name: email
                  type: varchar(50)

Разберем содержимое этого скрипта. Прежде всего, в нем содержится один changeSet. ChangeSet представляет из себя аналог коммита в системах контроля версий, таких как Git или SVN. По аналогии с коммитом, изменения, которые были внесены в рамках одного changeSet-а можно накатить или откатить (rollback) на сервер БД. У каждого changeSet-а должен быть уникальный идентификатор при помощи которого Liquibase определяет был ли данный changeSet накачен на данную БД или нет.

ChangeSet содержит команду создания таблицы, причем структура таблицы описана средствами Liquibase, а не SQL-скриптом. Благодаря этому данный файл становится кросплатформенным. Liquibase будет формировать SQL-скрипт в зависимости от используемого сервера БД. Кроме того, если нам будет нужно откатить данный changeSet, Liquibase сумеет автоматически создать скрипт для удаления данной таблицы. Если бы мы использовали SQL скрипты, то нам пришлось бы вручную писать скрипт для отката изменений. В разделе сhanges у нас всего одна команда и это считается хорошей практикой, не смотря на то, что команд в одном changeSet может быть сколько угодно.

Написанного кода вполне достаточно, чтобы запустить программу, но чтобы более наглядно видеть результаты её работы давайте добавим еще один changeSet, который заполнит таблицу данными.

  - changeSet:
      id: 2
      author: your_liquibase_username
      comment: "Create admin user"
      changes:
        - insert:
            tableName: users
            columns:
              - column:
                  name: username
                  value: "admin"
              - column:
                  name: password
                  value: "admin"
              - column:
                  name: email
                  value: "admin@server.com"
        - insert:
            tableName: users
            columns:
              - column:
                  name: username
                  value: "guest"
              - column:
                  name: password
                  value: "guest"
              - column:
                  name: email
                  value: "guest@server.com"
      rollback:
        - delete:
            tableName: users
            where: username in ('admin', 'guest')

В данном случае нам уже пришлось написать вручную блок для rollback операций, т.к. Liquibase не умеет автоматически создавать rollback SQL при работе с данными. Вообще работа с данными в БД не входит в число ключевых фич Liquibase и ограничивается лишь простейшими операциями вставки и удаления или изменения. К слову, если нужно больше, тот тут можно воспользоваться инструментами от фирмы Red Gate.

И так, давайте запустим наше приложение и попробуем перейти по ссылке http://localhost:8080/user/all. Если приложение запустилась, то вы увидите JSON-ответ с информацией о двух пользователях, которые были добавлены в таблицу. Также стоит взглянуть на логи запуска приложения, в которых можно видеть скрипты, которые Liquibase выполняет для инициализации БД. Особое внимание стоит обратить на таблицу DATABASECHANGELOG. Именно в ней Liquibase хранит лог изменений, внесенных в базу.

На этом пока все. Через некоторое время планирую опубликовать продолжение об использовании liquibase-maven-plugin для автоматической генерации скриптов через сравнение структур БД и отката внесенных изменений.

Буду благодарен за любые дополнения и замечания!

P.S. Полный код, написанный на основе этой статьи github.com/usharik/spring-liquibase-demo/tree/part-1

Продолжение по ссылке habr.com/ru/post/460907

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


  1. frozen_coder
    22.07.2019 15:52

    Для тех, кого не возбуждает dsl в yaml, liquibase поддерживает обычный SQL. И да, уж лучше я руками его напишу.


    1. usharik Автор
      22.07.2019 15:57

      Есть такая возможность! А что с переносимостью на разные БД при этом?


      1. frozen_coder
        22.07.2019 16:09

        А она и не нужна чаще всего. Это очень редкая операция. Часто видел, слышал, принимал участие в переезде на NoSQL, а вот про переезд на другую реляционную базу — это событие века. И потом, а почему мы выбрали БД, но не используем её фишки? Может они больше профита нам дадут здесь и сейчас, чем мифический переезд на другую базу хз когда в будущем? Кроме того, читать патчи на знакомом и привычном всем SQL намного приятнее, чем на очередном dsl. Конечно же, это ИМХО, в неопределившихся стартапах переносимость мб и нужна.


        1. usharik Автор
          22.07.2019 16:13

          У меня несколько раз была ситуация, когда локально и для тестирования H2 или MySQL, а в продакшн Postgres, Oracle или что-то еще.


          1. frozen_coder
            22.07.2019 16:24

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

            Я с некоторых пор стал предпочитать Testcontainers


          1. Borz
            22.07.2019 18:40

            неверный подход. Локально, даже для тестирования, надо использовать ту же БД, что и на сервере. Есть же docker сборки или embedded реализации для того же PostgreSQL или OracleDB. В противном случае можно словить коллизии, проявляемые только в конкретной БД конкретной версии и ловить будете эту особенность очень долго


          1. shishmakov
            23.07.2019 15:33

            Не делайте так: в проде одна БД, а тесты на другой. Используйте docker-compose для тестирования на нормальной БД.


            1. usharik Автор
              23.07.2019 15:46

              С docker-compose это и в самом деле становится проще!


      1. Borz
        22.07.2019 18:38
        -1

        ох уже эти "швейцарские ножи"… крайне редко "переносимость" делается на раз-два — чаще всего всё равно придётся подкручивать, подтягивать или и вовсе переписывать чтобы заработало на другом окружении (БД, ОС, etc). А если пишете "швейцарский нож", то платите за это например просадкой в производительности


        1. poxvuibr
          23.07.2019 11:26

          Ну так-то, если гонять тесты на всех СУБД, с которыми должно работать приложение — и будет переносимость. Есть гонять тесты только на H2, то и работать будет только на H2 ))


          1. usharik Автор
            24.07.2019 09:04

            Речь идет прежде всего о функциональных тестах ;)


            1. poxvuibr
              24.07.2019 09:16
              +1

              Да неважно о каких. Если в тестах используется СУБД и это не ваша целевая СУБД, то сказать, что будет работать с целевой СУБД — нельзя.


  1. plakhov
    22.07.2019 16:22

    Liquibase очень мощный инструмент. По мимо описания миграций в yaml, он позволяет еще использовать XML, что в свою очередь позволяет не писать rollback, потому что liquibase сам может откатить такую операцию. А если очень хочется извращений, то можно писать миграции в виде java кода.


    1. usharik Автор
      22.07.2019 16:25

      Насколько мне известно, форматы XML и YAML абсолютно эквивалентны для Liquibase скриптов. Поэтому rollback SQL будет автоматически создаваться и для YAML во всех случаях, когда это возможно, а невозможно это прежде всего, когда речь идет о работе с данными.


  1. frozen_coder
    22.07.2019 16:30

    Было бы здорово, почитать про сравнение liquibase с flywaydb. Почему и когда стоит выбрать одно, а не другое? Может flyway лучше


    1. usharik Автор
      22.07.2019 16:36

      Flyway лучше (удобнее и современнее), но Liquibase бесплатный, проверенный и надежный ;)
      Шутка, разумеется, но лишь от части. Вообще есть куда более мощные инструменты от компании RedGate, но это уже совсем-совсем коммерческое ПО.


    1. Borz
      22.07.2019 18:35

      а flywaydb уже научился делать preconditions? считаю что это по сути единственная киллер-фича в liquibase. Всё остальное, из используемого мною, есть в flywaydb.
      Но во flywaydb мне нравится подход к блокировке схемы для её изменения, а не записи флага в отдельную табличку


  1. Borz
    22.07.2019 18:32
    +1

    • вместо spring.jpa.hibernate.ddl-auto=none рекомендую прописывать spring.jpa.hibernate.ddl-auto=validate
    • вместо yaml лучше xml использовать, а ещё лучше — sql файлы. При этом идеально, чтобы одна миграция на один файл с группировкой миграций по дате из создания в подкаталогах — так не попадёте на конфликты при очерёдности исполнения миграций и уйдёте от "портяночных" файлов миграций


    1. usharik Автор
      22.07.2019 18:46

      Спасибо! Про best practices постараюсь во второй части написать!


    1. usharik Автор
      22.07.2019 18:58

      А почему вы считаете, что xml лучше?


      1. Borz
        23.07.2019 00:58

        покажите пример более-менее сложного SQL запроса внутри миграции и сравните его читабельность с таким же запросом внутри блока CDATA в XML формате


        И да, содержимое YAML файла не сильно уступает содержимому SQL файла, а выбирая между "YAML vs SQL" предпочтение отдам последнему, так как валидация и подсветка SQL синтаксиса в той же IDEA однозначно лучше будет + запрос можно выполнить сразу в тестовой БД минуя запуск liquibase


        1. poxvuibr
          23.07.2019 10:58

          Вы имеете в виду не sql миграции, а ссылки на sql файлы внутри xml, да?


          <sqlFile path="file.sql" />

          Так?


          1. usharik Автор
            23.07.2019 11:10

            Если скрипт действительно большой, то его и в самом деле лучше вынести в файл, но если все вынесено в файлы — многовато суеты при анализе кода. Если только IDE не помогает с этим)


            1. Borz
              23.07.2019 18:58

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


              1. usharik Автор
                23.07.2019 19:01

                Пожалуй, соглашусь. Взгляните на вторую часть статьи, если не трудно)


                1. Borz
                  23.07.2019 19:19

                  смотрел) но
                  1) я не сторонник описания схемы БД через аннотации Hibernate и автогенераторы — не всё можно прописать или описания через аннотации получается больше чем сама сущность или не всё, что есть в БД надо описывать в сущности. Потому описание миграции руками + validate у Hibernate, что сущность соответствует схеме в минимальном виде
                  2) как писал уже выше, лучше раскладывать миграции по подкаталогам, а не все в один каталог. Представьте, что у вас по 2-3 миграции в день и вашему проекту уже год и поразгребайтесь в каталоге "db/changelog" после этого. Мы у себя на проекте используем следующую маску: "YYYY/MM/DD_HHmmNNN.xml". Вроде бы как и у вас с timestamp, но уже попроще выглядит содержимое каталога миграций


                  1. usharik Автор
                    23.07.2019 19:28

                    Благодарю! Кстати, очень интересная идея с использованием даты в имени папок.


                    1. Borz
                      23.07.2019 19:57

                      парсер скушал часть имени…
                      вот полное имя:


                      YYYY/MM/DD_HHmm__<TASK>[__NNN].[xml,sql]


          1. Borz
            23.07.2019 18:34
            +1

            нет, через includeAll


            1. poxvuibr
              23.07.2019 20:05

              А как тогда описать rollback для конкретного sql файла?


              1. Borz
                23.07.2019 20:23

                согласно документации


                но я редко (читай — никогда) пишу rollback — чаще preconditions и то, только в том случае, когда они реально нужны.


                1. usharik Автор
                  24.07.2019 09:49

                  А можете чуть подробней про ваш опыт с preconditions? Речь идет о разных скриптах для разных БД?


                  1. Borz
                    24.07.2019 14:05

                    например, когда добавили что-то "на горячую" в боевой БД, а потом уже написали миграцию, делающую то же самое. Тут пишется preconditions, чтобы миграция пометилась как выполненная, но фактически не выполнялась и то, только если миграция может "упасть" или поломать данные при "повторной" накатке на БД. При этом в остальные окружения эта миграция раскатится в обычном режиме.


                    Явный пример: когда-то давно в PostgreSQL не было "IF NOT EXISTS" при создание индекса. на проде срочно добавили индекс, а теперь его надо прописать в миграцию. Раньше это решалось только через preconditions. Сейчас обычная миграция c "IF NOT EXISTS"


                    а "разные скрипты для разных БД" пишутся через "дублирование" changeSet с указанием нужной БД в атрибуте dbms у ChangeSet


                    1. usharik Автор
                      24.07.2019 16:02

                      Благодарю! Это интересно)
                      Неужели Flyway ничего такого не поддерживает?


                      1. Borz
                        24.07.2019 18:35

                        Flyway поддерживает миграции для разных БД через суффиксы в имени файла — считай так же, как и Liquibase, только чуть другой формат
                        Но у Flyway нет preconditions на миграцию — она либо выполняется либо грохается — пометить её как исполненную не исполняя фактически нельзя
                        При этом мне больше нравится как у Flyway сделана кластерная миграция — делается LOCK на схему, а не флажком в отдельной табличке в БД.


  1. poxvuibr
    23.07.2019 11:04

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


    1. usharik Автор
      23.07.2019 11:13

      Вполне разумная мысль. Думал об этом. На всякий случай напишу, что можно отключить liquibase опцией

      spring.liquibase.enabled=false
      Ну или просто не подключать зависимость liquibase-core.


  1. usharik Автор
    23.07.2019 15:15

    Дописал продолжение habr.com/ru/post/460907