Недавно мы столкнулись с необходимостью найти библиотеку для удобной работы с базами данных. В нашем проекте команда решила не использовать ORM (Object-Relational Mapping), а вместо этого применить миграции. Так как я работал только с ORM, мне, как и автору статьи, было мало знакомо понятие миграций баз данных. В поисках информации о миграциях и популярных решениях, я наткнулся на эту статью. Перевод статьи я оставил ниже. Возможно, она будет вам полезна. Буду признателен, если вы сможете поделиться библиотеками, которые используете.
Использование миграций баз данных в Go
Недавно я приступил к новой работе, и я был поражен инфраструктурой тестирования, которую создала команда. Для меня такой «тестовый» подход был в новинку.
Одна из тем, которую мы затронули во время обсуждения тестирования слоя базы данных, была посвящена миграциям баз данных. Я использовал базы данных на протяжении всей моей карьеры разработчика, и все же мне пришлось задать вопрос: "Что такое миграции баз данных?".
В этой статье я расскажу о том, как можно использовать миграции базы данных в ваших сервисах, написанных на Golang.
Что такое Миграция БД?
Согласно определению:
Миграции баз данных, также известные как схемные миграции или миграции схемы базы данных, представляют собой контролируемые наборы изменений, которые позволяют модифицировать структуру объектов в реляционной базе данных. Они помогают перевести схемы баз данных из текущего состояния в новое желаемое состояние, включая добавление таблиц и столбцов, удаление элементов, изменение типов и ограничений, а также разделение полей.
В этой статье я иногда называю миграции баз данных SQL-миграциями, потому что я сосредоточусь на SQL-базах данных, таких как PostgreSQL или MySQL, но, как упомянуто в определении, это применимо к многим различным базам данных.
Использование миграций баз данных имеет преимущество в том, что оно упрощает развитие БД по мере изменения требований к приложению и сервису. Кроме того, за счет различных миграций на каждое изменение легче отслеживать, регистрировать и связывать их с требуемым изменением сервиса.
Однако, этот процесс не лишен недостатков. При добавлении новой миграции, необходимо быть внимательным, чтобы не создать несовместимости между новой версией БД и самим сервисом. Например, случайно удалить столбец, изменить его имя, удалить используемую таблицу и так далее. Кроме того, существует риск потери данных при добавлении миграций. Например, если удалить столбец из таблицы, содержащей важную информацию, то необходимо убедиться, что эта информация не понадобится в будущем.
Как пишутся SQL-миграции?
Нет ничего сложного в написании 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 никогда не разочаровывает. Существует библиотека под названием golang-migrate, которая может использоваться для выполнения SQL-миграций. Это очень удобная библиотека, которая поддерживает большинство баз данных.
Библиотека (ее также можно использовать с помощью инструмента командной строки) позволяет нам выполнять миграции из различных источников данных: Список файлов SQL, хранящиеся в Google Cloud Storage или AWS Cloud, файлы в GitHub или GitLab. В нашем случае мы будем загружать миграции из определенной папки в нашем проекте, которая будет содержать файлы SQL-миграций. Теперь важная часть. Я уже упоминал, что порядок важен для того, чтобы гарантировать, что миграции будут выполнены "правильно". Ну, это делается с помощью шаблона именования. Он достаточно подробно описан, поэтому я просто дам вам краткий обзор.
Файлы имеют следующий шаблон именования:
{version}_{title}.up.{extension}
“version” указывает порядок, в котором будет применяться миграция. Например, если у нас есть:
1_innit_database.up.sql
2_alter_database.up.sql
тогда сначала будет применен `1_innit_database.up.sql`. "title” предназначен только для удобства чтения и описания и не служит никакой дополнительной цели.
Теперь относительно up / down. Метод up используется для добавления новых таблиц, столбцов или индексов в БД, в то время как метод down должен отменять операции, выполняемые методом up.
Теперь, когда мы знаем, как записывать файлы миграции, давайте посмотрим, как мы можем их применить. Я написал небольшую структуру Migrator:
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"
)
// Migrator структура для применения миграций.
type Migrator struct {
srcDriver source.Driver // Драйвер источника миграций.
}
// MustGetNewMigrator создает новый экземпляр Migrator с встроенными SQL-файлами миграций.
// В случае ошибки вызывает panic.
func MustGetNewMigrator(sqlFiles embed.FS, dirName string) *Migrator {
// Создаем новый драйвер источника миграций с встроенными SQL-файлами.
d, err := iofs.New(sqlFiles, dirName)
if err != nil {
panic(err)
}
return &Migrator{
srcDriver: d,
}
}
// ApplyMigrations применяет миграции к базе данных.
func (m *Migrator) ApplyMigrations(db *sql.DB) error {
// Создаем экземпляр драйвера базы данных для PostgreSQL.
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return fmt.Errorf("unable to create db instance: %v", err)
}
// Создаем новый экземпляр мигратора с использованием драйвера источника и драйвера базы данных PostgreSQL.
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, и создаем экземпляр миграции, используя библиотеку и указывая экземпляр базы данных. После этого мы просто вызываем функцию Up (или Down), и миграции применяются.
Я также написал небольшой файл main.go
, в котором создаю экземпляр Migrator и применяю его к локальному экземпляру базы данных в Docker.
package main
import (
"database/sql"
"embed"
"fmt"
"psql_migrations/internal/migrator"
)
const migrationsDir = "migrations"
//go:embed migrations/*.sql
var MigrationsFS embed.FS
func main() {
// --- (1) ----
// Recover Migrator
migrator := migrator.MustGetNewMigrator(MigrationsFS, migrationsDir)
// --- (2) ----
// Get the DB instance
connectionStr := "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
conn, err := sql.Open("postgres", connectionStr)
if err != nil {
panic(err)
}
defer conn.Close()
// --- (2) ----
// Apply migrations
err = migrator.ApplyMigrations(conn)
if err != nil {
panic(err)
}
fmt.Printf("Migrations applied!!")
}
Это позволит прочитать все файлы миграции внутри папки migrations и создать migrator с ее содержимым. Затем мы создаем экземпляр БД в нашей локальной базе данных и применяем к нему миграции.
Выводы
Мне очень нравится управлять базами данных. Я не знал о миграции. Это была интересная тема для написания.
Я думаю, что миграция баз данных - очень полезный инструмент не только для тестирования, но и для лучшего контроля и управления версиями ваших баз данных. Конечно, это не идеальный вариант, поскольку небольшая ошибка в определении миграции может вызвать проблемы для вашего сервиса (и других сервисов, если ваша база данных и таблицы являются общими).
Кроме того, я был очень впечатлен библиотекой go-migrate. На ее странице на Github есть очень подробные объяснения по использованию, типичные ошибки, часто задаваемые вопросы и т.д.. Эта библиотека очень мощная и проста в использовании, что делает ее отличным выбором для работы с миграциями. Я настоятельно рекомендую ее попробовать
Как всегда, вы можете найти полный проект, описанный в этой статье, в моем аккаунте на GitHub здесь.
Комментарии (15)
posledam
21.04.2024 21:07+5Т.е. sql-файлы в нужном порядке можно выполнить и без всякого GO? Выглядит, как изобретение колеса, простите :)
Sap_ru
21.04.2024 21:07+6В теории миграции должны уметь переводить базу из одного состояния в другое самостоятельно. То есть если у вас есть база, структура которой менялась несколько раз по мере развития программы, то если вы запустите последнюю версию, то она должна преобразовать базу к последней версии вне зависимости от того, какая текущая версия. Кроме того структура базы должна была автоматически строится из описания сущностей и взаимосвязей в ORM. Ради того оно всё и изобреталось. Это в теории. На практике оно ни черта так не работает. Точнее работает, но только для самых простых баз, а как только начинаем использовать что-то сложное и зависящее от конкретной реализации SQL, то сразу "Ой". А ещё оказалось, что часто при изменении структуры базы нужно ещё и данные в базе изменять нетривиальным образом, и тогда к красивой миграции приписывается куча кода, который очень все мечты об автогенерации. Ну, и в какой-то момент программисты, как обычно, стали миграциями заменять чуть ли не рукописное описание структуры базы, создав совершенно лишнюю сущность поверх SQL. На хрена это делается, и что именно экономится, никто зачастую толком объяснить не может, рассказывая абстрактные умные слова про миграции и автогенерацию, несмотря на то, что в реальности там внутри всё уже на костылях и подпорках и прибито к конкретной версии конкретной базы с кучей кастомного кода для преобразования базы от одной версии структуры к другой. Короче, всё как всегда.
dph
21.04.2024 21:07+3Угу, нет никаких возможностей одну структуру БД перевести в другую корректно и без явно указанных миграций. ORM тоже никак не избавляет от миграций (да и создавался для других вещей).
Впрочем, в статье практически ничего полезного не сказано о миграциях.
Так-то и команды добавления колонки могут подвесить базу (если одновременно идет длинный отчет). А команда создания индекса опасна почти всегда. А кроме изменений структуры данных нужно еще обновить и сами данные и для этого нужно писать специальный движок (так как просто сделать update на большой базе не получится).
И миграции надо тестировать (чтобы они боевую базу не убили), иногда откатывать (что почти всегда нетривиально), в процессе миграций поддерживать совместимость - и так далее и так далее.
Только SQL для этого, увы мало, все равно нужна внешняя обвязка, как минимум для идемпотентности миграций.
TSR2013
21.04.2024 21:07Есть https://github.com/c9s/goose , который является форком https://github.com/pressly/goose , который является форком https://bitbucket.org/liamstask/goose/src/master/
Akina
21.04.2024 21:07Угу, нет никаких возможностей одну структуру БД перевести в другую корректно и без явно указанных миграций.
Иногда и с ними - невозможно. И требуется чистый SQL-код. Пример - перемещение поля из одной таблицы в другую, которое в принципе не выполняется в рамках миграции, так как требует удаления триггеров перед перемещением данных и их восстановления после. То есть процесс перехода от старой версии к новой требует модификации объектов БД, которые не изменяются при переходе от начального состояния к конечному без учёта промежуточных.
Хотя... я вообще как-то не понимаю разницу между чистым SQL-кодом и тем, что называется "миграция". По-моему, вся разница - исключительно в том, что именно запустит набор DDL-запросов на исполнение.
Vovandro
21.04.2024 21:07+2А как это связано? ORM это слой над sql, позволяющий более легко и универсально формировать запросы к бд, миграции - это накатывание изменений в бд, они могут например добавить колонку, изменить тип текущий, исправить данные в бд и т. д. Использовать или нет ORM в миграциях зависит только от его наличия уже в проекте.
И самое важное в миграциях должен быть откат их, если что-то пошло не так чтобы можно было очень быстро вернуть бд в прошлое состояние и разбираться с проблемой
Ps мигратор пишется с 0 за пол часа со всем базовым функционалом включая контроль прошедших миграций и прочего
Akina
21.04.2024 21:07+1И самое важное в миграциях должен быть откат их, если что-то пошло не так чтобы можно было очень быстро вернуть бд в прошлое состояние и разбираться с проблемой
А вот тут - проблемка. Формально изменение структуры и данных может быть необратимым. Например, переход от BIGINT AUTOINCREMENT к UUID, который сопровождается удалением старых синтетических ключей. Или изменение модели, когда штамп времени признан избыточным, а хранение только даты (или иное понижение точности поля таблицы) - целесообразным.
Vovandro
21.04.2024 21:07Да, такое тоже бывает, но для этого делается например бекап табличка для сопоставления данных. Потом если все хорошо через время сносится и миграция признается не возвратной
ddwu
21.04.2024 21:07Мне нравится подход в django - миграции строятся автоматически при изменении модели данных. руками это всё писать - такое себе развлечение.
northartbar Автор
21.04.2024 21:07Я пришел в го из джанги, по этому когда пришлось прописывать таблицу вручную впал в небольшой ступор) Иногда слышу что ОРМ не всегда оптимально создает эти миграции и хочется иметь возможность подкрутить узкие места. Не сталкивался в своей практике с таким. Если применял джангу то для создания MVP, а там ОРМ за глаза)
ddwu
21.04.2024 21:07Касательно Go, то на мой взляд, entgo - весьма неплохая штука :
Там и автоматические миграции и "versioned migrations" - для подкуртки узких мест..И много всего разного :)
thegriglat
21.04.2024 21:07На node-проектах используем Prisma, генерит из схемы весьма приличные миграции (ну и в использовании удобна)
fujinon
Начал изучать Laravel и там тоже есть миграции, на php.