usharik.dev

← Все статьи

Liquibase в приложении на Spring Boot 4

Опубликовано · Алексей Ушаровский

Впервые опубликовано на Хабре 22 июля 2019; здесь переработанная и обновлённая версия.

В 2019 году я написал на Хабре две статьи о Liquibase в Spring Boot: основы, а затем сравнение схем и откат через Maven-плагин. Идеи с тех пор не устарели, а вот почти все номера версий и несколько ключей конфигурации — да. Здесь обе части в одной, пересобранные на Spring Boot 4.1, Liquibase 5.0 и Hibernate 7 и прогнанные от начала до конца перед написанием. В проекте H2 в файле, чтобы он запускался где угодно; со стороны Liquibase для PostgreSQL или MySQL всё то же самое.

Почему не ddl-auto=update

Большинство руководств ставят spring.jpa.hibernate.ddl-auto=update, и Hibernate сам подгоняет таблицы под сущности. В первый день удобно, потом опасно:

Liquibase превращает изменения схемы в файлы в репозитории. Каждый changeset выполняется в базе один раз; что выполнено, Liquibase записывает в таблицу DATABASECHANGELOG. Работа Hibernate сводится к проверке, что нужные сущностям таблицы и колонки существуют и типы совместимы:

spring.jpa.hibernate.ddl-auto=validate

С validate недостающая колонка роняет приложение при старте, а не первый запрос в работе. Это не полное сравнение: ограничения вроде NOT NULL и индексы не проверяются.

Проект

Spring Boot 4 разделил автоконфигурацию на модули, и у Liquibase теперь свой стартер:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-liquibase</artifactId>
  </dependency>
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
  </dependency>
</dependencies>

В статье 2019 года liquibase-core подключался вручную. В Boot 4 правильный путь — стартер: он приносит liquibase-core в версии, которой управляет Boot, и модуль, запускающий его при старте.

Одна сущность:

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

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

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

    @Column(name = "email", length = 255)
    private String email;

    // конструкторы и геттеры
}

Changelog

По умолчанию Spring Boot ищет на classpath db/changelog/db.changelog-master.yaml. Пусть главный файл будет списком подключений, по файлу на изменение:

databaseChangeLog:
  - include:
      file: changes/001-create-users.yaml
      relativeToChangelogFile: true
databaseChangeLog:
  - changeSet:
      id: 001-create-users
      author: usharik
      changes:
        - createTable:
            tableName: users
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: username
                  type: varchar(64)
                  constraints:
                    nullable: false
                    unique: true
              - column:
                  name: email
                  type: varchar(255)

При запуске приложения Liquibase отрабатывает до проверки Hibernate:

Running Changeset: db/changelog/changes/001-create-users.yaml::001-create-users::usharik
ChangeSet db/changelog/changes/001-create-users.yaml::001-create-users::usharik ran successfully in 5ms

Changeset определяется по id, author и пути к файлу. Выполненный changeset не редактируйте: Liquibase хранит контрольную сумму и откажется стартовать, если применённый changeset изменился. Ошибки исправляются новым changeset.

Генерируем changeset по сущностям

Теперь сущность меняется: email становится обязательным, появляется display_name.

@Column(name = "email", nullable = false, length = 255)
private String email;

@Column(name = "display_name", length = 128)
private String displayName;

Changeset можно написать руками. Для изменений побольше пусть Liquibase сравнит базу с сущностями. Это делает Maven-плагин с расширением для Hibernate. В 2019 году это был liquibase-hibernate5, для Hibernate 7 — liquibase-hibernate7:

<plugin>
  <groupId>org.liquibase</groupId>
  <artifactId>liquibase-maven-plugin</artifactId>
  <version>${liquibase.version}</version>
  <configuration>
    <propertyFile>liquibase.properties</propertyFile>
  </configuration>
  <dependencies>
    <dependency>
      <groupId>org.liquibase.ext</groupId>
      <artifactId>liquibase-hibernate7</artifactId>
      <version>${liquibase.version}</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
      <version>${project.parent.version}</version>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>${h2.version}</version>
    </dependency>
  </dependencies>
</plugin>

${liquibase.version} берётся из родительского Spring Boot, так что плагин, расширение и библиотека, которую Boot запускает при старте, остаются на одной версии. Настройки плагина — в liquibase.properties рядом с pom.xml:

changeLogFile=db/changelog/db.changelog-master.yaml
searchPath=src/main/resources
url=jdbc:h2:file:./target/demo-db;AUTO_SERVER=TRUE
username=sa
password=
referenceUrl=hibernate:spring:demo.model?dialect=org.hibernate.dialect.H2Dialect
diffChangeLogFile=target/diff-changelog.yaml
changeSetAuthor=usharik

url — база как она есть, referenceUrl — какой она должна быть, здесь по сущностям из пакета demo.model. changeSetAuthor заменяет трюк 2019 года с системным свойством user.name; без него автором станет имя пользователя ОС.

mvn compile liquibase:diff

compile важен: плагин читает скомпилированные классы сущностей. Результат:

databaseChangeLog:
- changeSet:
    id: 1791372710407-2
    author: usharik
    changes:
    - addColumn:
        columns:
        - column:
            name: display_name
            type: VARCHAR(128)
        tableName: users
- changeSet:
    id: 1791372710407-1
    author: usharik
    changes:
    - addNotNullConstraint:
        columnDataType: varchar(255)
        columnName: email
        tableName: users
        validate: true

Это черновик. Прочитайте его, дайте changeset осмысленные id (в листинге выше остались сгенерированные) и подумайте о данных: addNotNullConstraint на email упадёт, если у какой-то строки нет почты, поэтому в жизни сначала идёт changeset, заполняющий колонку. Затем сохраните его как changes/002-user-display-name.yaml и подключите в главный файл. При следующем старте Liquibase применит оба changeset, и validate пройдёт.

Откат и ловушка

Большинство изменений Liquibase умеет откатывать сам: обратное к addColumn — dropColumn. Для изменений, которые обратить нельзя, например сырого sql, пишется блок rollback в самом changeset. Откатим два последних changeset:

mvn liquibase:rollback -Dliquibase.rollbackCount=2

В первый раз Liquibase ответил мне:

INFO: 0 changesets rolled back.
Rollback command completed successfully.

«Завершено успешно» — и ничего не произошло. Причина в пути к файлу. Spring Boot читает changelog с classpath, поэтому в DATABASECHANGELOG лежат пути вида db/changelog/changes/002-user-display-name.yaml. Мой первый liquibase.properties указывал плагину на src/main/resources/db/changelog/..., а для Liquibase это другой файл, чьи changeset никогда не применялись. Откатывать нечего.

Исправление — пара настроек, показанная выше: changeLogFile относительно папки ресурсов и searchPath=src/main/resources. Тогда пути совпадают:

Rolling Back Changeset: db/changelog/changes/002-user-display-name.yaml::1791372710407-1::usharik
Rolling Back Changeset: db/changelog/changes/002-user-display-name.yaml::1791372710407-2::usharik

mvn liquibase:update применит их снова. Ещё две команды полезны перед работой с настоящей базой: liquibase:updateSQL и liquibase:rollbackSQL не выполняют SQL, а пишут его в target/liquibase/migrate.sql, а liquibase:tag ставит метку, к которой потом можно откатиться по имени через -Dliquibase.rollbackTag=....

Итоги

Исходные файлы