Начнем с того, что создадим простейшее приложение на 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)
plakhov
22.07.2019 16:22Liquibase очень мощный инструмент. По мимо описания миграций в yaml, он позволяет еще использовать XML, что в свою очередь позволяет не писать rollback, потому что liquibase сам может откатить такую операцию. А если очень хочется извращений, то можно писать миграции в виде java кода.
usharik Автор
22.07.2019 16:25Насколько мне известно, форматы XML и YAML абсолютно эквивалентны для Liquibase скриптов. Поэтому rollback SQL будет автоматически создаваться и для YAML во всех случаях, когда это возможно, а невозможно это прежде всего, когда речь идет о работе с данными.
frozen_coder
22.07.2019 16:30Было бы здорово, почитать про сравнение liquibase с flywaydb. Почему и когда стоит выбрать одно, а не другое? Может flyway лучше
Borz
22.07.2019 18:35а flywaydb уже научился делать preconditions? считаю что это по сути единственная киллер-фича в liquibase. Всё остальное, из используемого мною, есть в flywaydb.
Но во flywaydb мне нравится подход к блокировке схемы для её изменения, а не записи флага в отдельную табличку
Borz
22.07.2019 18:32+1- вместо
spring.jpa.hibernate.ddl-auto=none
рекомендую прописыватьspring.jpa.hibernate.ddl-auto=validate
- вместо yaml лучше xml использовать, а ещё лучше — sql файлы. При этом идеально, чтобы одна миграция на один файл с группировкой миграций по дате из создания в подкаталогах — так не попадёте на конфликты при очерёдности исполнения миграций и уйдёте от "портяночных" файлов миграций
usharik Автор
22.07.2019 18:58А почему вы считаете, что xml лучше?
Borz
23.07.2019 00:58покажите пример более-менее сложного SQL запроса внутри миграции и сравните его читабельность с таким же запросом внутри блока CDATA в XML формате
И да, содержимое YAML файла не сильно уступает содержимому SQL файла, а выбирая между "YAML vs SQL" предпочтение отдам последнему, так как валидация и подсветка SQL синтаксиса в той же IDEA однозначно лучше будет + запрос можно выполнить сразу в тестовой БД минуя запуск liquibase
poxvuibr
23.07.2019 10:58Вы имеете в виду не sql миграции, а ссылки на sql файлы внутри xml, да?
<sqlFile path="file.sql" />
Так?
usharik Автор
23.07.2019 11:10Если скрипт действительно большой, то его и в самом деле лучше вынести в файл, но если все вынесено в файлы — многовато суеты при анализе кода. Если только IDE не помогает с этим)
Borz
23.07.2019 18:58многовато суеты будет разгребать портянку в одном XML или разбирать последовательность запуска миграций в нескольких сгрупированных XML — легко можно отхватить ситуацию, когда миграция в одном файле ожидает, что уже выполнилась миграция из другого файла, а это не так.
Плюс, в том же Git, гораздо легче сливать небольшие файлики миграций, чем большие — меньше конфликтов получаетсяusharik Автор
23.07.2019 19:01Пожалуй, соглашусь. Взгляните на вторую часть статьи, если не трудно)
Borz
23.07.2019 19:19смотрел) но
1) я не сторонник описания схемы БД через аннотации Hibernate и автогенераторы — не всё можно прописать или описания через аннотации получается больше чем сама сущность или не всё, что есть в БД надо описывать в сущности. Потому описание миграции руками + validate у Hibernate, что сущность соответствует схеме в минимальном виде
2) как писал уже выше, лучше раскладывать миграции по подкаталогам, а не все в один каталог. Представьте, что у вас по 2-3 миграции в день и вашему проекту уже год и поразгребайтесь в каталоге "db/changelog" после этого. Мы у себя на проекте используем следующую маску: "YYYY/MM/DD_HHmmNNN.xml". Вроде бы как и у вас с timestamp, но уже попроще выглядит содержимое каталога миграций
Borz
23.07.2019 18:34+1нет, через includeAll
poxvuibr
23.07.2019 20:05А как тогда описать rollback для конкретного sql файла?
Borz
23.07.2019 20:23согласно документации
но я редко (читай — никогда) пишу rollback — чаще preconditions и то, только в том случае, когда они реально нужны.
usharik Автор
24.07.2019 09:49А можете чуть подробней про ваш опыт с preconditions? Речь идет о разных скриптах для разных БД?
Borz
24.07.2019 14:05например, когда добавили что-то "на горячую" в боевой БД, а потом уже написали миграцию, делающую то же самое. Тут пишется preconditions, чтобы миграция пометилась как выполненная, но фактически не выполнялась и то, только если миграция может "упасть" или поломать данные при "повторной" накатке на БД. При этом в остальные окружения эта миграция раскатится в обычном режиме.
Явный пример: когда-то давно в PostgreSQL не было "IF NOT EXISTS" при создание индекса. на проде срочно добавили индекс, а теперь его надо прописать в миграцию. Раньше это решалось только через preconditions. Сейчас обычная миграция c "IF NOT EXISTS"
а "разные скрипты для разных БД" пишутся через "дублирование" changeSet с указанием нужной БД в атрибуте dbms у ChangeSet
usharik Автор
24.07.2019 16:02Благодарю! Это интересно)
Неужели Flyway ничего такого не поддерживает?Borz
24.07.2019 18:35Flyway поддерживает миграции для разных БД через суффиксы в имени файла — считай так же, как и Liquibase, только чуть другой формат
Но у Flyway нет preconditions на миграцию — она либо выполняется либо грохается — пометить её как исполненную не исполняя фактически нельзя
При этом мне больше нравится как у Flyway сделана кластерная миграция — делается LOCK на схему, а не флажком в отдельной табличке в БД.
- вместо
poxvuibr
23.07.2019 11:04Я лично против того, чтобы включать прогон ликви скриптов при старте приложения. Мне кажется это шаг нужно включать в CI отдельно, до непосредственно запуска приложения. Скрипты бывает выполняются долго, удобно сразу понять, что проблема возникла при миграциях, а не из-за приложения и ещё можно сделать отдельный сервер для тестирования миграций на живых данных.
usharik Автор
23.07.2019 11:13Вполне разумная мысль. Думал об этом. На всякий случай напишу, что можно отключить liquibase опцией
Ну или просто не подключать зависимость liquibase-core.spring.liquibase.enabled=false
frozen_coder
Для тех, кого не возбуждает dsl в yaml, liquibase поддерживает обычный SQL. И да, уж лучше я руками его напишу.
usharik Автор
Есть такая возможность! А что с переносимостью на разные БД при этом?
frozen_coder
А она и не нужна чаще всего. Это очень редкая операция. Часто видел, слышал, принимал участие в переезде на NoSQL, а вот про переезд на другую реляционную базу — это событие века. И потом, а почему мы выбрали БД, но не используем её фишки? Может они больше профита нам дадут здесь и сейчас, чем мифический переезд на другую базу хз когда в будущем? Кроме того, читать патчи на знакомом и привычном всем SQL намного приятнее, чем на очередном dsl. Конечно же, это ИМХО, в неопределившихся стартапах переносимость мб и нужна.
usharik Автор
У меня несколько раз была ситуация, когда локально и для тестирования H2 или MySQL, а в продакшн Postgres, Oracle или что-то еще.
frozen_coder
Согласен, это можно записать в use-case кроссплатформенности. Однако, по факту, получается, что в тестах вы тестируете интеграцию с небоевой базой и это может как-нибудь аукнуться. Наверное, скорее всего, с этим большинство проектов не столкнуться, но вероятность есть.
Я с некоторых пор стал предпочитать Testcontainers
Borz
неверный подход. Локально, даже для тестирования, надо использовать ту же БД, что и на сервере. Есть же docker сборки или embedded реализации для того же PostgreSQL или OracleDB. В противном случае можно словить коллизии, проявляемые только в конкретной БД конкретной версии и ловить будете эту особенность очень долго
shishmakov
Не делайте так: в проде одна БД, а тесты на другой. Используйте docker-compose для тестирования на нормальной БД.
usharik Автор
С docker-compose это и в самом деле становится проще!
Borz
ох уже эти "швейцарские ножи"… крайне редко "переносимость" делается на раз-два — чаще всего всё равно придётся подкручивать, подтягивать или и вовсе переписывать чтобы заработало на другом окружении (БД, ОС, etc). А если пишете "швейцарский нож", то платите за это например просадкой в производительности
poxvuibr
Ну так-то, если гонять тесты на всех СУБД, с которыми должно работать приложение — и будет переносимость. Есть гонять тесты только на H2, то и работать будет только на H2 ))
usharik Автор
Речь идет прежде всего о функциональных тестах ;)
poxvuibr
Да неважно о каких. Если в тестах используется СУБД и это не ваша целевая СУБД, то сказать, что будет работать с целевой СУБД — нельзя.