При разработке и дальнейшей поддержки приложения база данных изменяется: добавляются, удаляются таблицы, столбцы и т.д. Для упрощения отслеживания изменений существует Liquibase. Эта библиотека в начале запуска приложения решает, надо ли на конкретной базе выполнить конкретные скрипты, или же они в ней уже выполнены.

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

Нам понадобится java, spring, gradle и liquibase plugin.

В примерах используется lombok, но можно и без него. СУБД - PostgreSQL.

Начальные данные

Для начала нужно создать проект и пару простых Entity.

Базовый класс:

import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.time.OffsetDateTime;

@Data
@NoArgsConstructor
@MappedSuperclass
public class BaseEntity {

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

    @CreationTimestamp
    @Column(updatable = false, nullable = false)
    private OffsetDateTime createDate;

    @UpdateTimestamp
    @Column(nullable = false)
    private OffsetDateTime updateDate;
}

Класс для хозяина:

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Data
@NoArgsConstructor
@Entity
@Table(name = "person")
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class PersonEntity extends BaseEntity {

    @Column
    private String name;

    @OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
  	@EqualsAndHashCode.Exclude
    @ToString.Exclude
    private List<AnimalEntity> animals = new ArrayList<>();
} 

Класс животное:

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Data
@NoArgsConstructor
@Entity
@Table(name = "animal")
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class AnimalEntity extends BaseEntity {

    @Column
    private String name;

    @Column
    private Long age;

    @Column(updatable = false, nullable = false)
    @Enumerated
    private AnimalType type;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(foreignKey = @ForeignKey(name = "animal_person_fk01"))
		@EqualsAndHashCode.Exclude
    @ToString.Exclude
    private PersonEntity owner;
} 

И тип для жвотного:

public enum AnimalType {
    CAT,
    DOG,
    BIRD,
    HORSE
} 

После этого выполянем команду gradle build. Появляется директория build, в которой находятся наши классы.

Настройки плагина

Теперь в файле build.gradle добавляем плагин

plugins {
	id 'org.liquibase.gradle' version '2.0.'
}

Указываем директорияю для файла миграций, доступы к бд и ссылку на наши entity:

liquibase {
    activities {
        main {
            changeLogFile "$buildDir/generated-migrations.yaml" //указываем куда и с каким именем генерится файл
            //данные для доступа к бд
  					url "jdbc:postgresql://localhost:5432/testDb"
            username "test"
            password "test"
						//указываем путь к entity, а так же настройки для hibernate(диалект, сратегии наименования)
            referenceUrl 'hibernate:spring:entity?dialect=org.hibernate.dialect.PostgreSQL10Dialect&hibernate.physical_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy&hibernate.implicit_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy'
  					logLevel 'debug' //если хотим видить логи при выполнение команд
        }
        runList = "main"
    }
} 

Про настройки hibernate можно почитать тут, а про стратегии наименования есть пост на хабре.

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

 ext {
    database = new Properties().with {
        load(file("db.properties").newReader()) //название файла с данными для бд
        it
    }
}
liquibase {
    activities {
        main {
            changeLogFile "$buildDir/generated-migrations.yaml"
                      //используем переменные, а не сами значения
            url database.getProperty("dbUrl")
            username database.getProperty("dbUsername")
            password database.getProperty("dbPassword")
            referenceUrl 'hibernate:spring:entity?dialect=org.hibernate.dialect.PostgreSQL10Dialect&hibernate.physical_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy&hibernate.implicit_naming_strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy'
        }
        runList = "main"
    }
}

Сам файл с переменными db.properties

dbUrl=jdbc:postgresql://localhost:5432/testDb
dbUsername=test
dbPassword=test 

Для работы плагину нужны драйвера для дб, парсеры для ченджлога и т.д.

liquibaseRuntime 'org.liquibase:liquibase-core:3.8.9'
liquibaseRuntime("ch.qos.logback:logback-core:1.2.3")
liquibaseRuntime("ch.qos.logback:logback-classic:1.2.3")
//драйвер БД
liquibaseRuntime 'org.postgresql:postgresql'
//hibernate & spring & jpa
liquibaseRuntime 'org.liquibase.ext:liquibase-hibernate5:3.6'
liquibaseRuntime 'org.springframework.data:spring-data-jpa'
liquibaseRuntime 'org.springframework.boot:spring-boot'
liquibaseRuntime sourceSets.main.output
//для записи в yaml
liquibaseRuntime 'org.yaml:snakeyaml:1.26'

Обязательно нужно указать liquibaseRuntime sourceSets.main.output для того, чтобы плагин смог найти entity.

Поднимаем БД

Для генерации скриптов потребуется также поднять БД. Я делаю это в докере, но можно и просто на свой пк.

version: "3.5"
services:

  db:
    ports:
      - 5432:5432
    image: postgres:12
    environment:
      POSTGRES_DB: testDb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test 

Генерим changeSet'ы

Для генерации выбираем команду diffChangeLog

В директории билд у нас окажется файл changelog.yaml с автоматически созданными changeSet'ами:

Пример сгенерированного файла
databaseChangeLog:
  - changeSet:
      id: 1638648715035-1
      author: AnnKont (generated)
      changes:
        - createTable:
            columns:
              - column:
                  autoIncrement: true
                  constraints:
                    nullable: false
                    primaryKey: true
                    primaryKeyName: animalPK
                  name: id
                  type: BIGINT
              - column:
                  constraints:
                    nullable: false
                  name: create_date
                  type: TIMESTAMP WITHOUT TIME ZONE
              - column:
                  constraints:
                    nullable: false
                  name: update_date
                  type: TIMESTAMP WITHOUT TIME ZONE
              - column:
                  name: age
                  type: BIGINT
              - column:
                  name: name
                  type: VARCHAR(255)
              - column:
                  constraints:
                    nullable: false
                  name: type
                  type: INTEGER
              - column:
                  name: owner_id
                  type: BIGINT
            tableName: animal
  - changeSet:
      id: 1638648715035-2
      author: AnnKont (generated)
      changes:
        - createTable:
            columns:
              - column:
                  autoIncrement: true
                  constraints:
                    nullable: false
                    primaryKey: true
                    primaryKeyName: personPK
                  name: id
                  type: BIGINT
              - column:
                  constraints:
                    nullable: false
                  name: create_date
                  type: TIMESTAMP WITHOUT TIME ZONE
              - column:
                  constraints:
                    nullable: false
                  name: update_date
                  type: TIMESTAMP WITHOUT TIME ZONE
              - column:
                  name: name
                  type: VARCHAR(255)
            tableName: person
  - changeSet:
      id: 1638648715035-3
      author: AnnKont (generated)
      changes:
        - addForeignKeyConstraint:
            baseColumnNames: owner_id
            baseTableName: animal
            constraintName: animal_person_fk01
            deferrable: false
            initiallyDeferred: false
            referencedColumnNames: id
            referencedTableName: person
            validate: true

Теперь копируем этот файл в ресурсы в директорию db.changelog и можем запускать наше приложение.

После запуска будут созданы таблицы. Если нам нужно что-то изменить, смело меняем все в entity. И обязательно делаем clean и build.

public class PersonEntity extends BaseEntity {
		//заменили name на firstName
    @Column
    private String firstName;
		//добавили новую колонку
    @Column
    private String secondName;
 		...
} 
public class AnimalEntity extends BaseEntity {
		//добавили ограничение
    @Column(nullable = false)
    private String name;
		...
}

Снова выполняем команду diffChangeLog и получаем новые changeSet'ы

Пример сгенерированного файла
databaseChangeLog:
  - changeSet:
      id: 1638650678646-1
      author: AnnKont (generated)
      changes:
        - addNotNullConstraint:
            columnDataType: varchar(255)
            columnName: name
            tableName: animal
            validate: true
  - changeSet:
      id: 1638650678646-2
      author: AnnKont (generated)
      changes:
        - addColumn:
            columns:
              - column:
                  name: first_name
                  type: varchar(255)
            tableName: person
  - changeSet:
      id: 1638650678646-3
      author: AnnKont (generated)
      changes:
        - addColumn:
            columns:
              - column:
                  name: second_name
                  type: varchar(255)
            tableName: person
  - changeSet:
      id: 1638650678646-4
      author: AnnKont (generated)
      changes:
        - dropColumn:
            columnName: name
            tableName: person

Но если вам понадобится, прежде чем удалить столбец, перенести из него данные, например, столбец переместили из одной таблицы в другую, то тут придется написать скрипт руками.
Также, как можно заметить, замена name на firstName != изменению имени столбца в сгенерированном файле. Плагин думает, что нужно полностью удалить столбец name и новый столбец firstName.

Посмотреть готовый проект можно на github.

Заключение

Liquibase plugin вполне может облегчить создание changLog'ов, но доверять ему абсолютно невозможно. При простом добавлении и удалении колонок он справляется на ура. Но если нужно что-то сложнее, то лучше пройтись глазами по полученному файлу, и если требуется, модифицировать его.

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


  1. aleksandy
    14.12.2021 14:07

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

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


  1. SimSonic
    14.12.2021 17:49

    Я тоже не стесняюсь использовать Lombok в проектах и JPA-сущностях по полной, но должен предупредить автора о наличии в коде проблем. У вас @Data на сущностях с двусторонними взаимоотношениями — там по умолчанию есть @ToString и будет Stack Overflow при попытке вывести сущность в лог. Да и hashCode и equals тоже будут на него напарываться.

    Всё это описывает вот эта вполне себе "классическая" статья: https://thorben-janssen.com/lombok-hibernate-how-to-avoid-common-pitfalls/


    1. Arb9i Автор
      14.12.2021 18:09

      Да, действительно лучше добавить @EqualsAndHashCode.Exclude и@ToString.Exclude. Обновила.


      1. SimSonic
        14.12.2021 18:18

        Лучше добавить @EqualsAndHashCode.Include на единственное корректное для этого поле — id, и над каждой сущностью дополнительно @EqualsAndHashCode(onlyExplicitlyIncluded=true).


        1. Arb9i Автор
          14.12.2021 18:26
          +1

          Опять же для id это может быть не всегда верно, т.к. при создание entity поле не будет заполнено, а после сохранения заполнится -> т.е. сущность изменится(и если она была добавлена в map до сохранения, то по хеш коду ее уже не получится найти).
          Но в статье приведен абстрактный пример entity, поэтому тут подходят любые варианты.


          1. poxvuibr
            15.12.2021 00:16

            Опять же для id это может быть не всегда верно, т.к. при создание entity поле не будет заполнено

            Да, поэтому если id равен null equals должен возвращать false. Независимо от того, чему равен id другой энтити. А чтобы можно было найти сущность после появления id, нужно из hashCode всегда возвращать 31