Всем привет.

Первые пять лет своей карьеры программиста, я работал над in-house проектом, следующие семь лет я работал в разных стартапах, с командой максимум из пяти разработчиков.

Сейчас я пару месяцев работаю над проектом где 20+ разработчиков, работа одновременно ведётся в примерно 30-ти ветках, имеется пять сред для отработки кода (драфт, дев, тестинг, хотфикс, прод), у каждой среды своя БД (перед выкаткой камита на стенд/среду, происходит проверочная выкатка с использованием отдельной БД, то есть на пять сред мы имеем 10 отдельных баз данных).

Вести разработку в нескольких ветках мне не в новинку, всегда так делал. Открытием для меня стало, то что версия кода и версия схемы базы данных ни как не синхронизируются. В маленьком проекте, не проблема дропнуть схему целиком, и целиком её накатить, это занимает считанные минуты, в этом проекте накатить схему с нуля с посевом занимает от часа.

Есть большая проблема с тем как синхронизировать версию кода и версию схемы базы данных.

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

Disclaimer

Код представленный ниже это обусифицированный боевой код, я его не отлаживал, может потребоваться доработка напильником. Я делюсь с вами только идеями.

Описание проблемы

Есть у нас ветка драфт, что бы пушить в драфт не надо делать МР, эта ветка нужна для того что бы программист мог по быстрому показать свои наработки бизнес аналитику, что бы фронт-энд мог интегрироваться с изменениями на бэк-энде. Драфт регулярно ресетиться.

То есть ты что то сделал с функционалом, решил показать другим разработчикам - выкатываешь всё на драфт, на БД драфта раскатывается твоя миграция, ты показал, понял что был не прав и переделываешь миграцию, в это время кто то ресетит драфт вместе с твоей миграцией, и что мы имеем ? мы имеем миграцию которая были применена и которую ни кто не откатил, как думаете получиться ещё раз её применить ?

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

То есть порядок применения миграций иногда бывает хаотичным.

Правило первое: "накатывать можно бесконечно"

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

Миграцию можно как бесконечное количество раз накатить на саму себя, так и откатить с самой себя.

Для работы миграциями по штучно я использую такое команды:

# накатить конкретную миграцию
php artisan migrate --path="services/best-team-servise/database/migrations/2021_02_04_240000_alter_data_model_table_add_unique_index.php" --pretend
# ключ --pretend позволит нам посмотреть SQL до того как будет применена миграция, иногда полезно

# откатить ровно одну миграцию
php artisan migrate:rollback --step=1
# можно откатить и десять, при случае раберётесь

После того как миграция применена, можно сгенерировать описание модели

php artisan ide-helper:models "Project\Models\DataModel"

и сделать посев данных:

php artisan db:seed --class=DataModelSeeder

Как сделать так что бы миграцию можно было бесконечно применять ? в методах up() и down() миграции, мы должны делать проверку того, что действие со схемой БД можно выполнить.

Если мы добавляем колонку, то проверяем что колонка не существует, если мы дропаем индекс, то проверяем что индекс существует.

Получаем Builder :

        $conn = (new DataModel())->connection;
        $builder = Schema::connection($conn);

проверяем что миграция не была применена (проверяем что миграция может быть выполнена):

        $isExists = $builder->hasColumn(
            'data_model',
            'deleted_at'
        );

Если миграция может быть выполнена, то выполняем:

        if (!$isExists) {
            $builder->table(
                'data_model',
                function (Blueprint $table) {
                    $table->softDeletesTz();
                }
            );
        }

Аналогично с таблицами - проверяем что таблица не существует, с индексами - проверяем что индекс не существует, с индексами посложней, но можно, поможет такой код:

        $alias = (new DataModel())->connection;
        $builder = Schema
            ::connection($alias)
            ->getConnection()
            ->getDoctrineSchemaManager();

$existingIndexes = $builder->listTableIndexes('data_model');

С индексами в Laravel есть заморочка, если мы создали индекс как:

Blueprint::unique('index_name');

То и удалять надо как:

Blueprint::dropUnique('index_name');

С таблицами и колонками у Laravel ок, с индексами похуже, с триггерами совсем плохо, приходиться писать на чистом SQL, наверное я чего то не знаю о Laravel ? Адепты, подскажите !

Накатываем чистым SQL, пишем так:

DROP TRIGGER IF EXISTS trigger_name
    ON public.data_model;
CREATE TRIGGER trigger_name
    BEFORE INSERT
    ON public.data_model
    FOR EACH ROW
    EXECUTE PROCEDURE public.function_name();

Когда откатываем, пишем так же:

DROP TRIGGER IF EXISTS trigger_name
    ON public.data_model;

Правило второе: "шаблонные имена"

Миграций в проекте сотни, за полгода было написано 1000+ миграций. Конечно вся 1000 не лежит в одной директории, они раскиданы по директориям модулей.

Но тем нем нее, когда открываешь директорию в которой 50+ миграций, тебе сложно ориентироваться в них без "хороших" имён.

Соглашения об именах

Если создаём таблицу, то имя миграции начинается с create, если меняем таблицу то alter, если удаляем таблицу, то drop.

Следующая часть имени миграции это имя колонки или индекса над которым будет произведены изменения.

alter_data_model_add_property_column
alter_data_model_alter_property_column_to_text
alter_data_model_alter_property_column_set_default_value
alter_data_model_create_index_on_code_type_columns
alter_data_model_create_unique_index_on_code_column

Дропать таблицы, конечно, можно только на этапе MVP.

Команды для создания миграций:

# делаем миграцию для создания таблицы
php artisan make:migration create_profile_table --create=profile

# делаем миграцию для изменения таблицы
php artisan make:migration add_confirmed_to_profile --table=profile

Файл миграции будет помещён в директорию database/migrations собственно приложения, у нас каждый сервис это отдельный пакет и после создания файла миграции его надо переложить в директорию своего пакета.

Правило третье: таблицы и колонки дропать нельзя, все колонки nullable()

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

Откатывать миграции вместе с репозиторием, эта так себе занятие.

Поэтому все колонки должны быть только nullable(), проверку того что обязательные колонки имеют значение, надо делать на уровне кода бизнес логики или с помощью обсерверов.

Соответственно при посеве данных, что бы не подменять файлы данных для посева, не прописывать какие конкретно колонки мы пишем - мы должны делать запись только в те колонки которые существуют:

            $columns = Schema
            ::connection((new DataModel())->connection)
            ->getConnection()
            ->getDoctrineSchemaManager()
            ->listTableColumns($(new DataModel())->getTable());

            $data = [];
            foreach ($columns as $column) {
                $name = $column->getName();
/* @var array[] $record строка данных для посева*/
                $exists = key_exists($name, $record);
                if ($exists) {
                    $data[$name] = $record[$name];
                }
            }

            $isSuccess = DataModel
                ::withTrashed()
                ->updateOrCreate(
                    ['uniqe_index_column' => $data['uniqe_index_column'],],
                    $data
                )->exists;

Правило четвёртое: значения по умолчанию, там где null недопустим

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

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

Заключение

Этот набор правил конечно не абсолютен, в первую очередь включаем голову и пользуемся здравым смыслом.

Давайте обсудим в комментариях. Пожалуйста, поделитесь своим опытом.