Когда разрабатываешь веб-приложение не один, а в команде и/или на нескольких машинах, рано или поздно сталкиваешься с проблемой синхронизации кода проекта и базы данных. Для управления кодом есть системы контроля версий, в частности, git, а для СУБД придуманы миграции.
Есть много готовых разнообразных инструментов, которые занимаются миграциями, но!
Очень часто все, что мы хотим — это просто залить в базу изменения, которые сделаны другим разработчиком или же самим собой на другой машине. И желательно при этом затратив минимальные усилия, в том числе и на изучение и настройку незнакомой системы. К тому же далеко не всегда мы располагаем полным доступом к серверу для установки оных инструментов.
Поэтому всех, кому интересно узнать, как самим сделать простую утилиту миграций, написав полсотни строк php-кода, прошу в статью.
Идея миграций
Идея довольно проста: в проекте создаем отдельную папку sql, куда складываем sql-файлы с миграциями, то есть, со скриптами, которые меняют содержимое базы, а также один php-файл, который эти миграции и накатывает.
Нужно учесть 2 вещи: во-первых, каждая миграция должна выполняться строго один раз, а во-вторых, в строго определенном порядке. Это разумно и обязательно, потому как если нам прилетают от коллеги 2 миграции, одна из которых создает таблицу users, а другая добавляет в нее тестовых пользователей, то мы хотим выполнить эти скрипты именно в таком порядке и не добавить при этом данные в базу больше одного раза.
Проблему повторных выполнений миграций мы решим, записывая в отдельную таблицу уже отработавшие скрипты, а порядок выполнения установим четкими правилами именования sql-файлов. Как это решается в коде, увидим чуть позже, а пока займемся подготовкой самих тестовых миграций.
Создаем тестовые sql-скрипты
Пусть у нас есть тестовая база данных под названием test. Мы работаем с ней какое-то время и решили внедрить миграции. Есть разумное правило: самая первая миграция должна содержать в себе полный дамп уже существующих сущностей в базе. Уточню: миграции помогают не только последовательно расширять уже существующую базу, но еще и накатить эту самую базу с нуля, например, для новых людей в команде.
Перед началом выполнения миграций я предполагаю, что указанная база данных уже существует, поэтому в миграции нигде не указывается название базы. На мой взгляд, это хорошо в том плане, что на одной машине программист может работать с разными базами и с разными их версиями. Поэтому у нас скрипта создания базы не будет.
В тестовом примере рассмотрим такой ход событий.
Первая миграция накатывает уже существующие данные и создает таблицу versions — она содержит уже выполненные скрипты.
Вторая создает таблицу users с тремя полями: id, email, password.
Третья добавляет в users три тестовых записи.
А четвертая добавляет в users новую колонку active.
Напишем первую миграцию: дамп базы и таблица versions. Пусть на момент внедрения миграций у нас есть таблица goods с парой товаров. Их нужно скинуть в скрипт и в тот же скрипт добавить таблицу versions. В итоге файл будет выглядеть так.
0000_base.sql
-- Дамп существующей базы --
-- Таблица товаров --
create table `goods` (
`id` int(10) unsigned not null auto_increment,
`name` varchar(255) not null,
`price` int(11) not null,
primary key (id)
)
engine = innodb
auto_increment = 1
character set utf8
collate utf8_general_ci;
-- Данные из таблицы товаров --
insert into `goods` (`name`, `price`) values
('Ноутбук', 30000),
('Телефон', 20000);
-- /Дамп существующей базы --
-- Таблица versions --
create table if not exists `versions` (
`id` int(10) unsigned not null auto_increment,
`name` varchar(255) not null,
`created` timestamp default current_timestamp,
primary key (id)
)
engine = innodb
auto_increment = 1
character set utf8
collate utf8_general_ci;
В файле мы видим структуру goods и 2 строчки с данными, а также versions. Эта таблица содержит 3 столбца: первичный ключ id, name — имя файла с миграцией, created — дата ее накатывания. Как Вы догадываетесь, в versions мы будем добавлять строки после накатывания новых миграций. А пока создадим оставшиеся 3 миграции.
0002_add_users.sql
-- Таблица пользователей --
create table if not exists `users` (
`id` int(10) unsigned not null auto_increment,
`email` varchar(255) not null,
`password` varchar(255) not null,
primary key (id)
)
engine = innodb
auto_increment = 1
character set utf8
collate utf8_general_ci;
0003_insert_data_into_users.sql
-- Добавляем тестовых пользователей --
insert into `users` (`email`, `password`) values
('test1@gmail.com', '111111'),
('test2@gmail.com', '222222'),
('test3@gmail.com', '333333')
0004_add_column_active_to_users.sql
-- Добавляем колонку active в users --
alter table `users`
add column `active` tinyint(1) not null default 1 after `password`;
Возможно, Вам не удобно писать sql-скрипты руками, Вы привыкли создавать и заполнять таблицы через phpMyAdmin или другой инструмент. Спешу успокоить, все известные мне утилиты позволяют генерировать такой sql-код автоматически. То есть Вы можете работать с базой, как удобно, а при подготовке файла-миграции вытащить нужный скрипт из условного phpMyAdmin-a в режиме copy-paste.
Обратим внимание на правила наименования файлов — это важная часть.
- 0001_base.sql
- 0002_add_users.sql
- 0003_insert_data_into_users.sql
- 0004_add_column_active_to_users.sql
Порядковые номера в начале каждого файла нужны, чтобы правильно отсортировать файлы. Только в этом случае миграции будут выполняться в правильном порядке. После _ идет краткое описание миграции, которое нужно исключительно для нашего удобства, чтобы понимать, что делает тот или иной скрипт, не заглядывая в него.
В нашем варианте мы предполагаем, что миграций не будет больше 9999 штук, и номер всегда должен состоять из 4-х цифр. Например, 10-я миграция будет называться 0010_description, а 101-я — 0101_description.
Теперь у нас все готово к написанию php-скрипта для выполнения миграций — migration.php.
Пишем php-код для запуска миграций
Подумаем, что нам нужно уметь делать.
- Во-первых, получить список всех sql-файлов из папки sql.
- Во-вторых, понять, какие из них уже накатились ранее (сравнить этот список с тем, что лежит в таблице versions).
- В-третьих, залить содержимое отобранных файлов в базу, заодно записав в versions их названия.
Пожалуй все, остальные операции довольно просты, разберем их по ходу пьесы.
Можно писать код.
Каркас migration.php, базовые константы и функции
Нам нужно написать фунцию подключения к базе данных и общую логику работы скрипта. Создадим файл migration.php, кинем его в папку sql рядом с миграциями и напишем в нем следующее:
// Объявляем нужные константы define('DB_HOST', 'localhost'); define('DB_USER', 'root'); define('DB_PASSWORD', 'root'); define('DB_NAME', 'test'); define('DB_TABLE_VERSIONS', 'versions'); // Подключаемся к базе данных function connectDB() { $errorMessage = 'Невозможно подключиться к серверу базы данных'; $conn = new mysqli(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME); if (!$conn) throw new Exception($errorMessage); else { $query = $conn->query('set names utf8'); if (!$query) throw new Exception($errorMessage); else return $conn; } } // Получаем список файлов для миграций function getMigrationFiles($conn) { // ... } // Накатываем миграцию файла function migrate($conn, $file) { // ... } // Стартуем // Подключаемся к базе $conn = connectDB(); // Получаем список файлов для миграций за исключением тех, которые уже есть в таблице versions $files = getMigrationFiles($conn); // Проверяем, есть ли новые миграции if (empty($files)) { echo 'Ваша база данных в актуальном состоянии.'; } else { echo 'Начинаем миграцию...<br><br>'; // Накатываем миграцию для каждого файла foreach ($files as $file) { migrate($conn, $file); // Выводим название выполненного файла echo basename($file) . '<br>'; } echo '<br>Миграция завершена.'; }
Пробежимся по коду.
Сначала объявляем константы для подключения к базе и с названием таблицы versions (она будет упоминаться в коде несколько раз).
Функция connectDB возвращает mysqli-объект для работы с базой.
getMigrationFiles($conn) возвращают список актуальных, еще не выполненых миграций.
migrate($conn, $file) накатывает одну миграцию — по имени файла.
Дальше сама логика работы.
Подключаемся к базе и получаем список актуальных миграций.
Если он пустой, то просто выводим сообщение, что база в актуальном состоянии.
Если файлы миграции имеются, то запускаем для каждого функцию migrate, выводя название файла для информации. А в конце пишем позитивное сообщение, что миграция завершена.
Теперь нам нужен код для getMigrationFiles()
// Получаем список файлов для миграций function getMigrationFiles($conn) { // Находим папку с миграциями $sqlFolder = str_replace('\', '/', realpath(dirname(__FILE__)) . '/'); // Получаем список всех sql-файлов $allFiles = glob($sqlFolder . '*.sql'); // Проверяем, есть ли таблица versions // Так как versions создается первой, то это равносильно тому, что база не пустая $query = sprintf('show tables from `%s` like "%s"', DB_NAME, DB_TABLE_VERSIONS); $data = $conn->query($query); $firstMigration = !$data->num_rows; // Первая миграция, возвращаем все файлы из папки sql if ($firstMigration) { return $allFiles; } // Ищем уже существующие миграции $versionsFiles = []; // Выбираем из таблицы versions все названия файлов $query = sprintf('select `name` from `%s`', DB_TABLE_VERSIONS); $data = $conn->query($query)->fetch_all(MYSQLI_ASSOC); // Загоняем названия в массив $versionsFiles // Не забываем добавлять полный путь к файлу foreach ($data as $row) { array_push($versionsFiles, $sqlFolder . $row['name']); } // Возвращаем файлы, которых еще нет в таблице versions return array_diff($allFiles, $versionsFiles); }
Почти каждая строка прокомментирована, но поясню еще подробнее.
В переменную $sqlFolder попадает полный абсолютный путь к текущему файлу migration.php. А соответственно, и к папке sql с миграциями. Замена \\ на / нужна для тех случаев, когда realpath возвращает путь с обратными слешами вместо прямых (например, в windows). Первый \ нужен для экранирования второго.
Дальше функция glob вытащит все файлы из указанной папки по нужной маске *.sql.
Затем нужно понять, не в первый ли раз мы собираемся накатывать миграции. Определить это можно только по наличию таблицы versions в базе. Мы же помним, что она создается в самой первой миграции. Если запрос show tables from test like «versions» вернет пустой список, значит таблицы еще нет, в переменную $firstMigration запишем true и вернем из функции весь список файлов.
Если же таблица versions существует, то вытаскиваем из нее список файлов — уже выполненные миграции. И возвращаем из функции файлы, которых нет в таблице versions. В этом поможет array_diff.
Все, список нужных файлов мы получили, осталось понять, как закинуть их содержимое в базу — функция migrate.
// Накатываем миграцию файла function migrate($conn, $file) { // Формируем команду выполнения mysql-запроса из внешнего файла $command = sprintf('mysql -u%s -p%s -h %s -D %s < %s', DB_USER, DB_PASSWORD, DB_HOST, DB_NAME, $file); // Выполняем shell-скрипт shell_exec($command); // Вытаскиваем имя файла, отбросив путь $baseName = basename($file); // Формируем запрос для добавления миграции в таблицу versions $query = sprintf('insert into `%s` (`name`) values("%s")', DB_TABLE_VERSIONS, $baseName); // Выполняем запрос $conn->query($query); }
Из интересного в этой функции только shell_exec($command) — выполнение строки $command в терминале. Залить содержимое sql-файла в базу можно такой командой
mysql -uuser -ppassword -h host -D database < file.sql
Оную команду формируем с помощью sprintf и передаем ее в shell_exec. Собственно миграция накатилась, осталось добавить соответствующее название файла в таблицу versions — это заурядный запрос вида
insert into `versions` (name) values(‘0001_base.sql’)
И это весь код, который нужен нам для создания простой системы миграций. Теперь создадим пустую базу данных test, закинем, если еще не успели, папку sql куда-нибудь в проект и откроем в браузере sql/migration.php.
Если Вы не хотите на боевом сайте светить содержимым своих миграций возможным злоумышленникам, то поместите сами sql-миграции в место, недоступное для просмотра извне. migration.php можно закинуть куда угодно, хоть в корень проекта, только придется немного поправить пути к папке с миграциями. Думаю, с этим делом Вы без труда справитесь сами 🙂
Итак, migration.php в браузере запущен, если все сделано правильно, то увидим такую картину
Начинаем миграцию...
0001_base.sql
0002_add_users.sql
0003_insert_data_into_users.sql
0004_add_column_active_to_users.sql
Миграция завершена.
Посмотрите в базу и убедитесь, что нужные таблицы и данные созданы, в versions лежат 4 строки с названиями файлов. Теперь обновите страницу в браузере и увидите сообщение «Ваша база данных в актуальном состоянии.» Все правильно, все миграции сделаны, а новых пока нет.
Чтобы окончательно убедиться в работоспособности системы, удалите все три таблицы из базы и оставьте в папке sql только первую миграцию. Обновите страницу — накатится одна миграция, таблица goods и versions с единственной строкой. Добавьте в папку миграции 0002_add_users.sql и 0003_insert_data_into_users.sql, обновите, выполнятся еще 2 миграции и в базе появится users с данными. И наконец последний файл и снова запуск — в users добавится новая колонка active.
Итоги и исходники
Что же у нас получилось?
Все работает, как мы и задумали.
Теперь можно править базу, добавляя все изменения с ней в миграции, не боясь, что кто-то и когда-то будет работать с неактуальной базой.
Главное, не забывать в нужные моменты при изменении базы создавать соответствующие миграции, соблюдая правила именования файлов, и запускать скрипт migration.php.
Как видим, код получился действительно короткий и простой (половину занимают комментарии). И при этом прямые свои обязанности он выполняет — накатывает миграции, не выполняя ничего лишнего.
Я намеренно не стал усложнять код, добавив обработку ошибок при накатывании скриптов. Также не рассматривал тему даунгрейда — так называемые обратные миграции, которые по идее должны автоматически откатывать неудачные изменения к предыдущим версиям, но на практике это опасная процедура и ее все равно приходится делать руками.
Если Вы работаете над большим и долгим проектом с постоянным редактированием базы сотней программистов, то такая простейшая система скорее всего не подойдет. Нужна одна из упомянутых выше серьезных утилит для работы с миграциями, где доступно больше возможностей и на проработана отказоусточивость.
Но не секрет, что в таких проектах уже давно подобная система используется, Вы можете даже не знать, в какой момент сборщик накатывает новые миграции. Кстати, это можно сделать и при помощи моего любимого gulp-a, но это уже другая история, и возможно, тема отдельной статьи.
Задавайте вопросы и оставляйте отзывы в комментариях, и конечно, скачивайте исходники