Queen: или как я писал библиотеку миграций для Go

Как всё начиналось
Мне всегда не нравилось, во что со временем превращаются папки с SQL-миграциями. В рабочих проектах, особенно легаси, их количество легко переваливает за сотни, а иногда и за тысячу. Разобраться в них сложно, а timestamp-имена делают навигацию ещё хуже.
Тестирование таких миграций — отдельная, хорошо знакомая боль.
Почему решил делать своё
Я попробовал goose — не зашло. Его init() — вещь, конечно, сомнительная, да и timestamp-миграции никуда не делись. Всё те же проблемы с читаемостью, всё те же коллизии.
Про golang-migrate и так понятно — SQL не нравится.
И тут я поймал себя на мысли:
Впервые за последние пару лет у меня появилась реальная потребность в продукте, который я могу сделать сам.
Не в пет-проекте ради пет-проекта, а в инструменте, который решает конкретную боль. Именно поэтому большинство наших пет-проектов так и не доходят даже до репозитория — нет настоящей необходимости.
А здесь она была:
- есть проблема
- есть потребность
- есть скиллы
Значит, нужно делать.
Я понял, что мне нужен инструмент, в котором миграции — это часть кода.
- Компилируется вместе
- Деплоится вместе
- Тестируется вместе
Мне нужен был инструмент под конкретные проблемы, с которыми я уже сталкивался.
Что искал
Я пересмотрел несколько докладов и провёл значительное время на Хабре, Reddit и в Github issue, пытаясь понять, как люди на самом деле пользуются миграциями: что их раздражает, где они чаще всего обжигаются и какие проблемы повторяются из проекта в проект.
В итоге у меня сложился список формальных требований — того, что я хотел бы видеть в инструменте для себя.
1. Никаких SQL-файлов
Я понимаю плюсы SQL-файлов: простота, наглядность, возможность править без перекомпиляции. Но нам как разработчикам на Go подход code-first даёт заметно больше:
- IDE-поддержка — автокомплит, навигация и рефакторинг работают из коробки
- Нативный Git — история изменений, diff и blame без костылей
- Тестирование — миграции можно импортировать как обычный Go-пакет
- Type-safety — там, где это возможно
- Единый бинарник — невозможно забыть файлы при деплое
- Программная логика — условия, циклы и API-вызовы внутри миграций
Подход не новый — так работают миграции во многих фреймворках.
2. Убрать timestamp из версий
Timestamp в названиях миграций — сомнительное удовольствие. Вот с чем я сталкивался на практике:
- CI/CD-коллизии — параллельные ветки генерируют одинаковые timestamp
- Timezone-проблемы — разработчики в разных часовых поясах
- Out of range — timestamp как
int64не помещается в колонку и ломает SELECT - Clock skew — разное системное время на машинах
Так что в Queen:
Простые версии — 001, 002 или users_001, posts_001.
Читаемые. Предсказуемые. Без сюрпризов.
Договаривайтесь с командой об именовании заранее. А всё, о чём не договоритесь, решит queen validate и queen create.
3. Прозрачная история
В коде:
Обычные
git logиgit blame— ничего нового изучать не нужно.
В базе:
Таблица
queen_migrationsс версиями, временем применения и checksum. Командаqueen statusпоказывает текущее состояние всех миграций.
4. Без ORM, но удобно
Писать SQL вручную иногда утомительно, но полноценный ORM вроде gorm не вписывается в философию инструмента.
В Queen я пришел к генерации шаблонов.
Для простого SQL:
./queen create add_users_table --type sql
↓ генерирует ↓
q.MustAdd(queen.M{
Version: "002",
Name: "add_users_table",
UpSQL: `-- TODO: add your SQL here`,
DownSQL: `-- TODO: add rollback SQL here`,
})
Для сложной логики — Go-функции:
./queen create normalize_emails --type go
↓ генерирует ↓
q.MustAdd(queen.M{
Version: "003",
Name: "normalize_emails",
UpFunc: func(ctx context.Context, tx *sql.Tx) error {
// Читаем, обрабатываем, пишем
return nil
},
DownFunc: func(ctx context.Context, tx *sql.Tx) error {
return nil
},
ManualChecksum: "v1",
})
Это не ORM, но рутина снимается. SQL остаётся явным, а структура — готовой.
Философия: простота и прозрачность.
Queen не скрывает SQL — Queen делает работу с ним удобнее.
Первые шаги
С этим списком я начал работу.
Кстати, в начале проект назывался honey-migrate, но, посмотрев на свой бэклог пет-проектов (где половина начинается с honey), решил взять название попроще — Queen.
Первым делом я сделал драйвер для PostgreSQL — основной базы, с которой мы работаем почти каждый день. Затем добавил SQLite3 для тестов и после этого — MySQL.
Принципы Queen
Никакой магии — Явная регистрация вместо
init(), явный SQL вместо ORM
Один артефакт — Миграции компилируются вместе с CLI
Контроль в руках разработчика —
Downпишется явно, автоматических rollback нет
Простота важнее фич.
Разработка PostgreSQL драйвера: первые грабли
Казалось бы, PostgreSQL-драйвер для миграций — что может быть проще? Табличка, INSERT/DELETE, транзакции.
Но, как обычно, дьявол оказался в деталях.
Проблема 1: Версии “1”, “2”, “10”
Первая версия сортировала миграции лексикографически:
1, 10, 100, 2, 20, 3
Красота. Добавляешь миграцию "10" после "9", а она накатывается раньше "2".
Именно в этот момент понимаешь, что лексикографическая сортировка — плохая идея для версий.
Решение: Natural sorting. Парсим строки, выделяем числовые и текстовые части, сравниваем числа как числа:
// Простые версии
// "1" < "2" < "10" < "100"
// С префиксами
// "v1" < "v2" < "v10"
// "user_001" < "user_002" < "user_010"
// Смешанные
// "user_001a" < "user_001b" < "user_002a"
Теперь работает как ожидает человек.
Проблема 2: Checksum и переформатирование
Зачем вообще checksum?
Чтобы знать, изменилась ли уже накаченная миграция. Если кто-то поменял SQL в миграции, которая уже в проде — это проблема, и Queen скажет об этом.
Сделал checksum миграций (SHA-256) для детекта изменений. Переформатировал SQL:
-- Было
CREATE TABLE users(id INT);
-- Стало
CREATE TABLE users (
id INT
);
Checksum изменился → Queen скажет “миграция изменена!”
Но логически-то ничего не поменялось.
Решение: Нормализация whitespace перед хешированием
func normalizeWhitespace(s string) string {
// Убираем leading/trailing пробелы
// Схлопываем пустые строки
}
Теперь можно спокойно форматировать SQL, не боясь сломать историю миграций.
Проблема 3: Параллельные миграции
Два разработчика одновременно запускают миграции. Или CI/CD деплоит на несколько инстансов параллельно:
Developer 1: INSERT migration 001
Developer 2: INSERT migration 001 ← Дубликат!
Решение: PostgreSQL advisory locks
func (d *Driver) Lock(ctx context.Context, timeout time.Duration) error {
var acquired bool
err = d.db.QueryRowContext(ctx,
"SELECT pg_try_advisory_lock($1)", d.lockID).Scan(&acquired)
if !acquired {
return ErrLockTimeout
}
return nil
}
Lock ID генерируется из имени таблицы миграций — разные таблицы = разные локи. Можно иметь несколько независимых наборов миграций в одной БД.
Проблема 4: SQL Injection
Наивная реализация:
query := fmt.Sprintf("CREATE TABLE %s (...)", tableName)
Пользователь передает:
tableName = "users; DROP TABLE important_data; --"
Привет hh...
Решение: Функция quoteIdentifier — экранируем кавычки, оборачиваем в двойные кавычки. Теперь безопасно.
Проблема 5: Rollback при ошибках
Миграция упала посередине. Половина изменений применилась. База в inconsistent состоянии.
Решение: Всё в транзакциях
func (d *Driver) Exec(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
Миграция либо применяется полностью, либо откатывается полностью.
Testing helpers
Это убрало 90% бойлерплейта и сделало тесты миграций такими же обычными, как тесты бизнес-логики.
Сделал NewTest helper:
q := queen.NewTest(t, driver) // Auto-cleanup!
q.MustAdd(migration)
q.TestUpDown() // Автоматически проверяет Up и Down
testing.go делает тестирование миграций тривиальным.
Что получилось
PostgreSQL драйвер:
- Правильно сортирует версии — natural sort
- Детектит изменения, но игнорирует форматирование — checksum с нормализацией
- Защищен от параллельных запусков — advisory locks
- Безопасен от SQL injection — экранирование идентификаторов
- Атомарен — транзакции
- Легко тестируется —
NewTesthelper
Весь код в одном файле — postgres.go, ~170 строк.
Без магии.
В следующих постах разберём драйверы для Clickhouse и CockroachDB, а так же разберем CLI
Если попробуете — буду рад фидбеку.