Недавно мы столкнулись с необходимостью найти библиотеку для удобной работы с базами данных. В нашем проекте команда решила не использовать 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)


  1. fujinon
    21.04.2024 21:07

    Начал изучать Laravel и там тоже есть миграции, на php.


  1. posledam
    21.04.2024 21:07
    +5

    Т.е. sql-файлы в нужном порядке можно выполнить и без всякого GO? Выглядит, как изобретение колеса, простите :)


    1. Sap_ru
      21.04.2024 21:07
      +6

      В теории миграции должны уметь переводить базу из одного состояния в другое самостоятельно. То есть если у вас есть база, структура которой менялась несколько раз по мере развития программы, то если вы запустите последнюю версию, то она должна преобразовать базу к последней версии вне зависимости от того, какая текущая версия. Кроме того структура базы должна была автоматически строится из описания сущностей и взаимосвязей в ORM. Ради того оно всё и изобреталось. Это в теории. На практике оно ни черта так не работает. Точнее работает, но только для самых простых баз, а как только начинаем использовать что-то сложное и зависящее от конкретной реализации SQL, то сразу "Ой". А ещё оказалось, что часто при изменении структуры базы нужно ещё и данные в базе изменять нетривиальным образом, и тогда к красивой миграции приписывается куча кода, который очень все мечты об автогенерации. Ну, и в какой-то момент программисты, как обычно, стали миграциями заменять чуть ли не рукописное описание структуры базы, создав совершенно лишнюю сущность поверх SQL. На хрена это делается, и что именно экономится, никто зачастую толком объяснить не может, рассказывая абстрактные умные слова про миграции и автогенерацию, несмотря на то, что в реальности там внутри всё уже на костылях и подпорках и прибито к конкретной версии конкретной базы с кучей кастомного кода для преобразования базы от одной версии структуры к другой. Короче, всё как всегда.


      1. dph
        21.04.2024 21:07
        +3

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

        Так-то и команды добавления колонки могут подвесить базу (если одновременно идет длинный отчет). А команда создания индекса опасна почти всегда. А кроме изменений структуры данных нужно еще обновить и сами данные и для этого нужно писать специальный движок (так как просто сделать update на большой базе не получится).
        И миграции надо тестировать (чтобы они боевую базу не убили), иногда откатывать (что почти всегда нетривиально), в процессе миграций поддерживать совместимость - и так далее и так далее.

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


  1. TSR2013
    21.04.2024 21:07

    Есть https://github.com/c9s/goose , который является форком https://github.com/pressly/goose , который является форком https://bitbucket.org/liamstask/goose/src/master/


    1. DieSlogan
      21.04.2024 21:07

      Форк c9s, последний коммит 4 года назад, тогда как над pressly fork работа кипит.


      1. TSR2013
        21.04.2024 21:07

        Поэтому я и привел все 3, чтобы можно было выбрать. Поддержка open source вещь нестабильная


  1. Akina
    21.04.2024 21:07

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

    Иногда и с ними - невозможно. И требуется чистый SQL-код. Пример - перемещение поля из одной таблицы в другую, которое в принципе не выполняется в рамках миграции, так как требует удаления триггеров перед перемещением данных и их восстановления после. То есть процесс перехода от старой версии к новой требует модификации объектов БД, которые не изменяются при переходе от начального состояния к конечному без учёта промежуточных.

    Хотя... я вообще как-то не понимаю разницу между чистым SQL-кодом и тем, что называется "миграция". По-моему, вся разница - исключительно в том, что именно запустит набор DDL-запросов на исполнение.


  1. Vovandro
    21.04.2024 21:07
    +2

    А как это связано? ORM это слой над sql, позволяющий более легко и универсально формировать запросы к бд, миграции - это накатывание изменений в бд, они могут например добавить колонку, изменить тип текущий, исправить данные в бд и т. д. Использовать или нет ORM в миграциях зависит только от его наличия уже в проекте.

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

    Ps мигратор пишется с 0 за пол часа со всем базовым функционалом включая контроль прошедших миграций и прочего


    1. Akina
      21.04.2024 21:07
      +1

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

      А вот тут - проблемка. Формально изменение структуры и данных может быть необратимым. Например, переход от BIGINT AUTOINCREMENT к UUID, который сопровождается удалением старых синтетических ключей. Или изменение модели, когда штамп времени признан избыточным, а хранение только даты (или иное понижение точности поля таблицы) - целесообразным.


      1. Vovandro
        21.04.2024 21:07

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


  1. ddwu
    21.04.2024 21:07

    Мне нравится подход в django - миграции строятся автоматически при изменении модели данных. руками это всё писать - такое себе развлечение.


    1. northartbar Автор
      21.04.2024 21:07

      Я пришел в го из джанги, по этому когда пришлось прописывать таблицу вручную впал в небольшой ступор) Иногда слышу что ОРМ не всегда оптимально создает эти миграции и хочется иметь возможность подкрутить узкие места. Не сталкивался в своей практике с таким. Если применял джангу то для создания MVP, а там ОРМ за глаза)


      1. ddwu
        21.04.2024 21:07

        Касательно Go, то на мой взляд, entgo - весьма неплохая штука :
        Там и автоматические миграции и "versioned migrations" - для подкуртки узких мест..

        И много всего разного :)


  1. thegriglat
    21.04.2024 21:07

    На node-проектах используем Prisma, генерит из схемы весьма приличные миграции (ну и в использовании удобна)