Миграции баз данных с Golang

Недавно я присоединился к новой команде и был поражен созданной у них инфраструктурой тестирования для успешной работы приложений. Для меня это было большой переменой: я не привык к такой манере «тестирования».

С тестированием уровня данных связаны миграции БД. С базами данных я работаю на протяжении всей своей карьеры инженера-разработчика и все же задался вопросом: «Что это за миграции БД?»

Рассмотрим применение миграций БД в службах, написанных на Golang.

Что такое «миграции БД»?

Вот определение из prisma.io:

Миграции БД, известные как миграции схем, миграции схем баз данных или просто миграции,  —  это контролируемые наборы изменений, разработанные для модификации структуры объектов в реляционной базе данных. Миграции способствуют переводу схем БД из текущего их состояния в новое, желаемое  —  с добавлением таблиц и столбцов, удалением элементов, разделением полей или изменением типов и ограничений.

Будем называть миграции БД SQL-миграциями, делая акцент на базах данных вроде PostgreSQL или MySQL, хотя такие миграции применяются ко многим другим БД.

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

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

Как пишутся SQL-миграции?

Очень просто, это операторы SQL, написанные в порядке их применения. Вот пример SQL-миграции:

CREATE TABLE books (
id UUID,
name character varying (255),
description text
);

ALTER TABLE books ADD PRIMARY KEY (id);

Что, если применить эту миграцию, развернуть службу, но забыть добавить индекс? Тогда пишем другой оператор SQL в другой миграции:

CREATE INDEX idx_book_name 
ON books(name);

Важен порядок применения этих миграций. Нельзя запустить вторую первой, так как таблица, на которую ссылаются, еще не создана. Подробнее об этом  —  далее.

SQL-миграции в Go

На Go SQL-миграции выполняются с библиотекой golang-migrate для самых разных БД.

Этой библиотекой  —  с собственным инструментом CLI  —  миграции запускаются из различных источников данных:

  • Список .sql-файлов.
  • Файлы из Google Storage или AWS Cloud.
  • Файлы из Github или Gitlab.

Мы загрузим миграции из конкретной папки проекта с .sql-файлами миграции. А теперь важная часть: порядок для корректного выполнения миграций, обеспечиваемый в шаблоне именования файла. Подробное объяснение этого шаблона содержится у владельцев пакета здесь, поэтому приведем лишь краткую версию.

У файлов имеется такой шаблон именования:

{version}_{title}.up.{extension}

Версией version указывается порядок, в котором применится миграция. Например, если у нас:

1_innit_database.up.sql
2_alter_database.up.sql

то сначала применится миграция из первой строки. Заголовок title нужен для удобства и описания, без дополнительных целей.

Методом up в базу данных добавляются новые таблицы, столбцы или индексы, методом down операции, выполняемые методом up, отменяются.

Посмотрим теперь, как файлы миграции применяются. Вот небольшая структура Migrator на Go с таким определением:

package migrator

import (
"database/sql"
"embed"
"errors"
"fmt"

"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source"
"github.com/golang-migrate/migrate/v4/source/iofs"
)

type Migrator struct {
srcDriver source.Driver
}

func MustGetNewMigrator(sqlFiles embed.FS, dirName string) *Migrator {
d, err := iofs.New(sqlFiles, dirName)
if err != nil {
panic(err)
}
return &Migrator{
srcDriver: d,
}
}

func (m *Migrator) ApplyMigrations(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return fmt.Errorf("unable to create db instance: %v", err)
}

migrator, err := migrate.NewWithInstance("migration_embeded_sql_files", m.srcDriver, "psql_db", driver)
if err != nil {
return fmt.Errorf("unable to create migration: %v", err)
}

defer func() {
migrator.Close()
}()

if err = migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("unable to apply migrations %v", err)
}

return nil
}

При создании Migrator мы передаем путь, где находятся все файлы миграции. И указываем встроенную файловую систему, подробнее о встраивании Go  —  здесь. При этом создается драйвер источника с загруженными файлами миграции.

Методом ApplyMigrations миграции запускаются в указываемый экземпляр БД. Мы используем драйвер источника файла, определенный в Migrator, и создаем экземпляр migrate с помощью библиотеки экземпляра БД. Затем просто запускаем функцию Up или Down, и миграции применяются.

Написали также небольшой main.go, где создаем экземпляр Migrator и применяем его к локальному экземпляру БД в Docker:

package main

import (
"database/sql"
"embed"
"fmt"
"psql_migrations/internal/migrator"
)

const migrationsDir = "migrations"

//go:встроенные миграции/*.sql
var MigrationsFS embed.FS

func main() {
// --- (1) ----
// Восстанавливаем «Migrator»
migrator := migrator.MustGetNewMigrator(MigrationsFS, migrationsDir)

// --- (2) ----
// Получаем экземпляр БД
connectionStr := "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
conn, err := sql.Open("postgres", connectionStr)
if err != nil {
panic(err)
}

defer conn.Close()

// --- (2) ----
// Применяем миграции
err = migrator.ApplyMigrations(conn)
if err != nil {
panic(err)
}

fmt.Printf("Migrations applied!!")
}

Так считаются все файлы миграции в папке migrations и создастся migrator с ее содержимым. Затем для локальной базы данных создаем экземпляр БД и применяем к нему миграции.

Заключение

Это была действительно интересная тема для статьи. Мне нравится управление базами данных, но об их миграциях я и не подозревал.

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

Также впечатлила библиотека go-migrate. На ее странице Github приведены подробнейшие объяснения по применению, типичные ошибки, часто задаваемые вопросы и т. д. Поэтому ее очень просто использовать, рекомендуем заглянуть.

Весь проект выложен здесь.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Simeon Grancharov: Using Database Migrations with Golang

Предыдущая статьяOTP-аутентификация c Devise
Следующая статьяiOS/Swift: подробное руководство по модульным и UI-тестам. Часть 2