Статьи


Работа с Liquibase в Spring Boot

Вернуться назад

11 октября 2020

Тэги: Spring Boot SQL PostgreSQL Kotlin gradle

Liquibase позволяет автоматизировать внесение обновлений в структуру БД. Каждое изменение описывается в декларативном стиле и версионируется. Обновления накатываются в заранее определённом порядке на данную БД, если они ещё не накатывались. Автоматизация процесса наката изменений на базу данных особенно важна, если у вас несколько различных экземпляров приложений и для каждого из них требуется поддерживать свою БД.

Подключаем liquibase к приложению

Рассмотрим работу с Liquibase на конкретном примере. С помощью сайта start.spring.io создадим заготовку нашего Spring Boot приложения (выбираем в качестве языка kotlin, а в качестве сборщика - gradle). В качестве dependencies выберем компоненты web, spring data jdbc и cам liquibase. Также вручную добавим драйвер СУБД (в нашем случае postgres). В итоге файл build.gradle.kts в секции dependencies должен содержать следующие зависимости:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
    implementation("org.liquibase:liquibase-core")
    implementation("org.postgresql:postgresql:42.2.5")

Для подключения к БД добавим следующие настройки data source в файл application.yml (не забудьте подставить свои параметры):

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/example_db
    username: example_user
    password: "paSSw0rd"
  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQL95Dialect
        show_sql: false
        temp:
          use_jdbc_metadata_defaults: false
  liquibase:
    change-log: classpath:liquibase/changelog.yml

Параметр spring.liquibase.change-log в явном виде указывает расположение файла изменений liquibase. В данном случае он должен лежать в папке resources/liquibase/. Можно и не указывать этот параметр в настройках. Тогда изменения надо будет хранить в файле resources/db/changelog/db.changelog-master.yaml.

Предположим, наше приложение является блогом, в котором основная сущность - это статья. У каждой статьи должен быть уникальный идентификатор, url, по которому можно найти её в блоге, заголовок, признак видимости для пользователей и дата создания. Статьи будут храниться в таблице article в БД. Для создания таблиц будем использовать liquibase. Можно было бы сделать и наоборот: сначала создать таблицу в БД вручную, а затем импортировать её структуру в liquibase. Но я рекомендую делать создание именно через liquibase - так у вас будет больше контроля над всеми нюансами.

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

Для внесения изменения в БД требуется добавить новый changeSet в файл liquibase, который в декларативном стиле описывает необходимые изменения.

Создание таблиц

Теперь давайте создадим таблицу article. Добавим в файл changelog.yml первый набор изменений (changeSet) в формате yaml. Помимо yaml liquibase также поддерживает формат xml и json. Но мы будем рассматривать именно yaml как наиболее современный и компактный. В нём важно соблюдать правильное количество пробелов в отступах и не забывать про пробелы перед значениями после двоеточий.

databaseChangeLog:

  - changeSet:
      id: DEV-1
      author: devmarkru
      changes:
        - createTable:
            tableName: article
            columns:
              - column:
                  name: id
                  type: integer
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    primaryKeyName: article_pk
                    nullable: false
              - column:
                  name: url
                  type: varchar(50)
                  constraints:
                    unique: true
                    uniqueConstraintName: article_url_uq
                    nullable: false
              - column:
                  name: title
                  type: varchar(50)
                  constraints:
                    nullable: false
              - column:
                  name: is_visible
                  type: boolean
                  defaultValue: false
                  constraints:
                    nullable: false
              - column:
                  name: created
                  type: timestamp without time zone
                  defaultValue: now()
                  constraints:
                    nullable: false

Файл changelog начинается с корневого элемента databaseChangeLog. Затем мы создаём changeSet, у которого есть текстовый идентификатор (id), автор (author) и список изменений (changes). В качестве идентификатора можно указывать любую произвольную строку, но если вы работаете, например, с Jira, то таким идентификатором может быть номер задачи. В качестве автора можете указать ваш логин или email.

Инструкция createTable содержит описание имени таблицы (tableName) и набор колонок (columns). Для каждой колонки нужно обязательно указывать имя (name) и тип (type). При необходимости ограничения можно указать в constraints. Название типа указывается аналогично таковому названию в sql. Параметр autoIncrement позволяет создавать идентификаторы, значение которых увеличивается на 1 при добавлении каждой новой записи в таблицу. Флаг primaryKey позволяет указать, что именно это поле вы хотите сделать первичным ключом в таблице. Параметр primaryKeyName позволяет в явном виде задать имя данного ограничения. Делать это необязательно, но я настоятельно рекомендую давать имена для любых ограничений по определённым правилам.

Параметр nullable отвечает за допустимость значений null в данной колонке. Если nullable=false, то колонка в БД получит модификатор not null. По умолчанию nullable=true, т.е. null в ячейке допускается.

У каждой статьи должен быть уникальный url, по которому её можно найти в нашем блоге. Поэтому для url добавим специальное ограничение unique=true, а также дадим этому ограничению имя с помощью параметра uniqueConstraintName.

Статья может быть ещё не до конца написана, тогда её не следует отображать посетителям нашего блога. Поэтому добавим в таблицу поле is_visible и по умолчанию оно будет равно false. Значение по умолчанию позволяет задавать параметр defaultValue.

Наконец, у статьи есть время создания. По умолчанию при вставке будем проставлять текущее время. Для этого в качестве значения defaultValue укажем функцию now().

Теперь при запуске приложения liquibase создаст в нашей БД таблицу article, DDL которой приведён ниже:

CREATE TABLE public.article
(
  id integer NOT NULL,
  url character varying(50) NOT NULL,
  title character varying(50) NOT NULL,
  is_visible boolean NOT NULL DEFAULT false,
  created timestamp without time zone NOT NULL DEFAULT now(),
  CONSTRAINT article_pk PRIMARY KEY (id),
  CONSTRAINT article_url_uq UNIQUE (url)
)

Допустим, через некоторое время мы захотели навести порядок в нашем блоге и каждую статью поместить в какой-нибудь раздел. Для этого потребуется:

  1. Создать таблицу-справочник разделов для статей (chapter)
  2. Заполнить эту таблицу нужными разделами
  3. Добавить связь таблицы article с таблицей chapter с помощью foreign key

Добавим в файл changelog.yml второй changeSet:

  - changeSet:
      id: DEV-2
      author: devmarkru
      changes:
        - createTable:
            tableName: chapter
            columns:
              - column:
                  name: id
                  type: integer
                  constraints:
                    primaryKey: true
                    primaryKeyName: chapter_pk
                    nullable: false
              - column:
                  name: title
                  type: varchar(50)
                  constraints:
                    nullable: false

Для создания таблицы chapter используем уже знакомый нам тип изменений createTable. В этой таблице разделов будет всего два поля: id и title. Также определяем тут primary key, как и в первой таблице.

Вставка данных в таблицу

После создания этой таблицы нам нужно наполнить её названиями разделов. Для этого будем использовать инструкцию insert, которая на уровне БД будет преобразована в обычный insert into.

        - insert:
            tableName: chapter
            columns:
              - column:
                  name: id
                  value: 1
              - column:
                  name: title
                  value: Политика
        - insert:
            tableName: chapter
            columns:
              - column:
                  name: id
                  value: 2
              - column:
                  name: title
                  value: Культура

Для вставки данных мы указываем имя таблицы и колонки, для каждой из которых указывается её имя и значение. В данном примере две инструкции insert дадут нам две строки в таблице chapter.

Добавление новой колонки и внешнего ключа

Наконец, свяжем таблицу article с таблицей chapter через внешний ключ (foreign key).

        - addColumn:
            tableName: article
            columns:
              - column:
                  name: chapter_id
                  type: integer
                  constraints:
                    nullable: false
        - addForeignKeyConstraint:
            baseTableName: article
            baseColumnNames: chapter_id
            referencedTableName: chapter
            referencedColumnNames: id
            constraintName: article_chapter_fk

Инструкция addColumn добавляет новое поле chapter_id в таблицу article. Инструкция addForeignKeyConstraint вешает на это поле ограничение типа «внешний ключ». baseTableName - таблица, в которой находится внешний ключ. baseColumnNames - имя колонки для внешнего ключа. referencedTableName - имя таблицы, на которую ссылается таблица с внешним ключом. referencedColumnNames - имя колонки, на которую ссылается внешний ключ (чаще всего это первичный ключ id). Наконец, constraintName позволяет указать имя для внешнего ключа.

Теперь, если мы снова запустим приложение, то будет создана вторая таблица chapter. В неё будут вставлены две записи, а затем в таблицу article будет добавлен первичный ключ chapter_id.

Откат изменений

В процессе описания изменений легко ошибиться или что-то забыть. В случае ошибки на каком-либо шаге все изменения в этом changeSet будут отменены, так что можно не опасаться за сломанную базу. Если же вы написали скрипт правильно, но про что-то забыли, то лучше отменить («откатить») изменения. Для этого просто аккуратно вручную отмените ВСЕ внесенные в данном changeSet изменения (например, удалите только что созданную таблицу или поле). Затем удалите соответствующую запись из таблицы databasechangelog, которая связана с этими изменениями. После этого вы можете повторно накатить изменения, как будто они накатываются в первый раз.

Выводы

Как вы сами убедились, liquibase позволяет в декларативном стиле описывать все изменения, которые вы хотите произвести со структурой вашей БД. Liquibase абстрагируется от синтаксиса конкретной СУБД и потому может работать с любой из них (главное предоставить нужный драйвер). При этом синтаксис выглядит несколько многословным, но вы можете выбрать тот формат, который вам больше всего подходит: xml, json или yaml.

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