При разработке и дальнейшей поддержки приложения база данных изменяется: добавляются, удаляются таблицы, столбцы и т.д. Для упрощения отслеживания изменений существует 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)
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/
Arb9i Автор
14.12.2021 18:09Да, действительно лучше добавить
@EqualsAndHashCode.Exclude
и@ToString.Exclude
. Обновила.SimSonic
14.12.2021 18:18Лучше добавить
@EqualsAndHashCode.Include
на единственное корректное для этого поле — id, и над каждой сущностью дополнительно@EqualsAndHashCode(onlyExplicitlyIncluded=true)
.Arb9i Автор
14.12.2021 18:26+1Опять же для
id
это может быть не всегда верно, т.к. при создание entity поле не будет заполнено, а после сохранения заполнится -> т.е. сущность изменится(и если она была добавлена в map до сохранения, то по хеш коду ее уже не получится найти).
Но в статье приведен абстрактный пример entity, поэтому тут подходят любые варианты.poxvuibr
15.12.2021 00:16Опять же для id это может быть не всегда верно, т.к. при создание entity поле не будет заполнено
Да, поэтому если id равен null equals должен возвращать false. Независимо от того, чему равен id другой энтити. А чтобы можно было найти сущность после появления id, нужно из hashCode всегда возвращать 31
aleksandy
Лишний геморрой. Скрипты, генерируемые хибером, а "под капотом" именно они и используются, довольно посредственного качества, в том числе и из-за упомянутых нюансов с переименованием столбцов.
В фазу накатывания ликвибэйзовых скриптов нужно добавлять верификатор соответствия того, что в БД есть и того, что на сущностях наразмечали. И откатывать при несоответствии.