Odzyskaj kontrolę nad swoimi migracjami! Sortowanie migracji w Doctrine Migrations Bundle.

phphub.pl 10 miesięcy temu

Jeżeli na co dzień pracujesz z frameworkiem Symfony, to myślę, iż ikonicznego Doctrine Migrations Bundle nie muszę Ci przedstawiać. o ile jednak pierwszy raz się spotykasz z tą nazwą, to śpieszę z wyjaśnieniem, iż jest to paczka kompatybilna z frameworkiem Symfony i Doctrine ORM, dzięki której możemy uruchamiać, tworzyć i zarządzać migracjami bazodanowymi.

W tym artykule przedstawię problem, który może zaistnieć w aplikacjach używających doctrine/migrations-bundle. Ten problem zwykle będzie dotyczył projektów bazujących na kodzie zewnętrznych platform, które ze względu na dynamicznie zmieniające się wymagania interesariuszy, muszą modyfikować kod dostawców 3rd party.

Spis treści
  1. Czym są migracje bazodanowe?
  2. Tło problemu
    1. Migracje aplikacji
    2. Migracje aplikacji vs. migracje platformy
    3. Migracje 3rd party
    4. Migracje on-top innych migracji
    5. Problem sortowania migracji
  3. Problem
  4. Rozwiązanie
  5. Inne problemy i rozwiązania
  6. Podsumowanie

Czym są migracje bazodanowe?

W cyklu wytwarzania systemu aplikacji, która jest połączoną z bazą danych, zupełnie naturalnym zjawiskiem jest fakt, iż schemat bazy danych będzie się zmieniał. Migracje bazodanowe są mechanizmem, który umożliwia kontrolowane i automatyczne wprowadzanie zmian w strukturze bazy danych. Dzięki migracjom możemy definiować i śledzić wersje schematu bazy danych w postaci plików migracyjnych. Umożliwia nam to tworzenie, aktualizację i cofanie zmian w bazie danych w sposób uporządkowany i bezpieczny. Aby móc te zmiany później zaaplikować na środowiskach o różnym przeznaczeniu, czyli np. stagingowym, produkcyjnym, jak i również na środowiskach twoich kolegów z biurka obok, musisz napisać migrację. Migracja ta zostanie później uruchomiona dzięki narzędzia takiego jak doctrine-migrations-bundle.

Nie wiesz czym są migracje bazodanowe w Doctrine ORM?


W tym artykule skupię się na pewnym bardzo konkretnym problemie w ramach migracji bazodanowych przy użyciu doctrine/migrations-bundle i zakładam, iż wiesz w jaki sposób to działa.

Jeżeli jednak chcesz dowiedzieć się więcej na ten temat, to chciałbym Ci serdecznie polecić artykuł Marcina Kuklińskiego, który w bardzo przystępny sposób może wprowadzić Cię w temat w ramach poniższego artykułu:

Koncept migracji bazodanowych i Doctrine 2
Podczas tworzenia aplikacji biznesowych, nie sposób nie poruszyć tematu aktualizacji bazy danych – elementu, z którym spotykamy się w codziennej pracy. Niezależnie od typu środowiska, zawsze trafimy na temat migracji bazy danych. Na szczęście Doctrine posiada specjalnie do tego wyspecjalizowany mech…

Każda migracja, którą stworzymy, jest potem wersjonowana. Zazwyczaj, wersjonowanie to odbywa się poprzez dodanie do nazwy klasy daty, w której została ona stworzona. Na przykład migracja może wyglądać w sposób następujący:

namespace MyApp\Migrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20201015121224 extends AbstractMigration { public function up(Schema $schema): void { $this->addSql('CREATE TABLE cats (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL);'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE cats;'); } }

Nazwa klasy migracji, będzie wskazywać na to, iż jest to migracja, która została utworzona 15 października 2020 roku o godzinie 12:12:24. Wraz z tym, jak aplikacja powstaje, dochodzą do niej kolejne migracje.

Tło problemu

Rozważmy zatem aplikację, która łączy się z bazą danych SQL. Aplikacja ta jest oparta o framework Symfony i Doctrine ORM. Aplikacja ta posiada już pewne migracje.

Migracje aplikacji

Ze względu na to, iż nie chcę nadwerężać Twoich oczu, to pozwolę sobie skrócić nieco daty w nazwach tych migracji ( ͡° ͜ʖ ͡°).

MyApp\Migrations\Version20201015 MyApp\Migrations\Version20201016 MyApp\Migrations\Version20201017

Standardowo doctrine-migrations-bundle uruchomi migracje posortowane w sposób alfabetyczny:

$ bin/console doctrine:migrations:migrate -v [info] ++ migrating MyApp\Migrations\Version20201015 [info] ++ migrating MyApp\Migrations\Version20201016 [info] ++ migrating MyApp\Migrations\Version20201017

Prosta sprawa! Takie podejście może jak najbardziej działać przez lata i nie będzie z tym absolutnie żadnego problemu.

Migracje aplikacji vs. migracje platformy

Załóżmy, iż aplikacja, którą rozważamy, bazuje na jakiejś platformie, takiej jak na przykład Sylius czy Akeneo. Platformy te dostarczają nam masę przydatnych funkcjonalności dostępnych out of the box. Oprócz tego posiadają również własne migracje bazujące na doctrine-migrations-bundle ( ͡° ͜ʖ ͡°)…

Okazuje się więc, iż nasza aplikacja musi uruchomić następujące migracje:

CoolPlatform\Migrations\Version20190120 CoolPlatform\Migrations\Version20190121 CoolPlatform\Migrations\Version20190122 MyApp\Migrations\Version20201015 MyApp\Migrations\Version20201016 MyApp\Migrations\Version20201017

Doctrine Migrations Bundle uruchomi to w następującej kolejności:

[info] ++ migrating CoolPlatform\Migrations\Version20190120 [info] ++ migrating CoolPlatform\Migrations\Version20190121 [info] ++ migrating CoolPlatform\Migrations\Version20190122 [info] ++ migrating MyApp\Migrations\Version20201015 [info] ++ migrating MyApp\Migrations\Version20201016 [info] ++ migrating MyApp\Migrations\Version20201017

Migracje 3rd party

Żeby było ciekawiej, załóżmy, iż do naszej platformy doinstalowaliśmy sobie jakiś plugin 3rd party. Ten plugin również posiada swoje własne migracje.

namespace FabulousPlugin\Migrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20200314 extends AbstractMigration { public function up(Schema $schema): void { $this->addSql('CREATE TABLE great_feature_table (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL);'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE great_feature_table;'); } }

Migracje zostaną uruchomione w następującej kolejności:

[info] ++ migrating CoolPlatform\Migrations\Version20190120 [info] ++ migrating CoolPlatform\Migrations\Version20190121 [info] ++ migrating CoolPlatform\Migrations\Version20190122 [info] ++ migrating FabulousPlugin\Migrations\Version20200314 [info] ++ migrating MyApp\Migrations\Version20201015 [info] ++ migrating MyApp\Migrations\Version20201016 [info] ++ migrating MyApp\Migrations\Version20201017

Zastosowane podejście wciąż się sprawdza. Migracje, które zostały dodane z perspektywy aplikacji, wciąż są uporządkowane w sposób prawidłowy.

Aplikacja od dawna została już zdeployowana na produkcję. Wszyscy zarabiają pieniądze i są szczęśliwi. Aż tu nagle…

Migracje on-top innych migracji

Developer pracujący nad naszą hipotetyczną aplikacją otrzymuje nowe zadanie. Zadanie to polega na zmodyfikowaniu sposobu działania pluginu, który został wcześniej zainstalowany. Aby zrealizować zadanie, musi on zaingerować w schemę bazy danych, utworzoną wcześniej przez plugin.

namespace MyApp\Migrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20210501 extends AbstractMigration { public function up(Schema $schema): void { $this->addSql('ALTER TABLE great_feature_table ADD status VARCHAR(255) DEFAULT NULL;'); } public function down(Schema $schema): void { $this->addSql('ALTER TABLE great_feature_table DROP status;'); } }

Kolejność wykonania migracji wygląda w sposób następujący:

[info] ++ migrating CoolPlatform\Migrations\Version20190120 [info] ++ migrating CoolPlatform\Migrations\Version20190121 [info] ++ migrating CoolPlatform\Migrations\Version20190122 [info] ++ migrating FabulousPlugin\Migrations\Version20200314 [info] ++ migrating MyApp\Migrations\Version20201015 [info] ++ migrating MyApp\Migrations\Version20201016 [info] ++ migrating MyApp\Migrations\Version20201017 [info] ++ migrating MyApp\Migrations\Version20210501

Kolejność wykonywania migracji wciąż jest prawidłowa!

Problem sortowania migracji

Czas mija nieubłaganie, a w aplikacji przybywa coraz to więcej migracji. Przezorni developerzy cyklicznie aktualizują platformę i pluginy do najnowszych wersji. Ze względu na to, iż migracje były uruchamiane wyłącznie na ich lokalnych środowiskach oraz dzięki CI/CD na systemie produkcyjnym i stagingowym, nikt nie zwrócił uwagi na to, iż kolejność wykonywania migracji wygląda teraz w sposób następujący:

[info] ++ migrating CoolPlatform\Migrations\Version20190120 [info] ++ migrating CoolPlatform\Migrations\Version20190121 [info] ++ migrating CoolPlatform\Migrations\Version20190122 [info] ++ migrating CoolPlatform\Migrations\Version20201129 [info] ++ migrating CoolPlatform\Migrations\Version20211202 [info] ++ migrating FabulousPlugin\Migrations\Version20200314 [info] ++ migrating FabulousPlugin\Migrations\Version20220703 [info] ++ migrating MyApp\Migrations\Version20201015 [info] ++ migrating MyApp\Migrations\Version20201016 [info] ++ migrating MyApp\Migrations\Version20201017 [info] ++ migrating MyApp\Migrations\Version20210501 [info] ++ migrating MyApp\Migrations\Version20220514

Wprawione oko obserwatora zauważy, iż migracje, które zostały uruchomione, nie są posortowane po dacie, ale alfabetycznynie po nazwach namespace’ów klas migracji.

Załóżmy teraz, iż nowa migracja plugina, który zainstalowaliśmy, robi następującą rzecz:

namespace FabulousPlugin\Migrations; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; final class Version20220703 extends AbstractMigration { public function up(Schema $schema): void { // verify $this->addSql('RENAME TABLE great_feature_table TO fabulous_feature_table;'); } public function down(Schema $schema): void { $this->addSql('RENAME TABLE fabulous_feature_table TO great_feature_table;'); } }

Developerzy naszej aplikacji wciąż dodawali kolejne migracje:

[info] ++ migrating CoolPlatform\Migrations\Version20190120 [info] ++ migrating CoolPlatform\Migrations\Version20190121 [info] ++ migrating CoolPlatform\Migrations\Version20190122 [info] ++ migrating CoolPlatform\Migrations\Version20201129 [info] ++ migrating CoolPlatform\Migrations\Version20211202 [info] ++ migrating FabulousPlugin\Migrations\Version20200314 [info] ++ migrating FabulousPlugin\Migrations\Version20220703 [info] ++ migrating MyApp\Migrations\Version20201015 [info] ++ migrating MyApp\Migrations\Version20201016 [info] ++ migrating MyApp\Migrations\Version20201017 [info] ++ migrating MyApp\Migrations\Version20210501 [info] ++ migrating MyApp\Migrations\Version20220514 [info] ++ migrating MyApp\Migrations\Version20230515

I wtedy developerzy dostają nowe zadanie. Muszą uruchomić aplikację na nowym środowisku dla innego klienta. Z pomocą DevOpsów uruchamiają aplikację na nowym środowisku przy pomocy niezawodnych dotychczas skryptów zawartych w ramach procesu CI/CD.

W kontekście bazy danych plan jest taki, aby utworzyć zupełnie „czystą” schemę bazy danych, czyli po prostu uruchomić migracje od stanu „zerowego”.

Jak się okazuje, nie jest to możliwe:

[info] ++ migrating CoolPlatform\Migrations\Version20190120 [info] ++ migrating CoolPlatform\Migrations\Version20190121 [info] ++ migrating CoolPlatform\Migrations\Version20190122 [info] ++ migrating CoolPlatform\Migrations\Version20201129 [info] ++ migrating CoolPlatform\Migrations\Version20211202 [info] ++ migrating FabulousPlugin\Migrations\Version20200314 [info] ++ migrating FabulousPlugin\Migrations\Version20220703 [info] ++ migrating MyApp\Migrations\Version20201015 [info] ++ migrating MyApp\Migrations\Version20201016 [info] ++ migrating MyApp\Migrations\Version20201017 [info] ++ migrating MyApp\Migrations\Version20210501 [error] Migration MyApp\Version20210501 failed during Execution. Error: "An exception occurred while executing 'ALTER TABLE great_feature_table ADD status VARCHAR(255) DEFAULT NULL;': SQLSTATE[42S02]: Base table or view not found: 1146 Table 'great_feature_table' doesn't exist" [info] ++ migrating MyApp\Migrations\Version20220514 [info] ++ migrating MyApp\Migrations\Version20230515

Jedna z migracji rzuciła nam błąd. Oczywiście jest to feralna migracja, w której jeden z developerów zaingerował w schemę bazy danych jednego z pluginów. Niefortunnie twórcy plugina zmienili później nazwę tej tabeli. Doctrine, który w trybie domyślnym uruchamia migracje poprzez sortowanie klas migracji alfabetycznie, nie był w stanie samodzielnie zadecydować o tym, w jaki sposób uruchomić migracje w sposób prawidłowy.

Problem

Problemem, który rozważamy, jest fakt, iż doctrine-migrations-bundle uruchamia migracje poprzez alfabetyczne posortowanie nazwy klasy i jej namespace’a.

Na podstawie tła, które przedstawiłem, możemy uprościć nasz problem do następującej postaci:

ModuleA\Migrations\V01 ModuleA\Migrations\V02 ModuleB\Migrations\V03 ModuleB\Migrations\V06 ModuleC\Migrations\V04 ModuleC\Migrations\V05

Z perspektywy aplikacji jest dla nas logiczne, iż chcielibyśmy, aby migracje zostały uruchomione, poprzez sprawdzenie jedynie ostatniego członu namespace’a. A więc w następującej kolejności:

ModuleA\Migrations\V01 ModuleA\Migrations\V02 ModuleB\Migrations\V03 ModuleC\Migrations\V04 ModuleB\Migrations\V06 ModuleC\Migrations\V05

Z perspektywy doctrine/migrations-bundle jest to nielogiczne, ponieważ nazwy te nie są posortowane alfabetycznie.

Rozwiązanie

W obliczu tego problemu najprostszym rozwiązaniem jest użycie następującej implementacji.

Doctrine używa własnej implementacji odpowiedzialnej za porównywanie wersji w algorytmie sortowania:

// vendor/doctrine/migrations/lib/Doctrine/Migrations/Version/AlphabeticalComparator.php <?php declare(strict_types=1); namespace Doctrine\Migrations\Version; use function strcmp; final class AlphabeticalComparator implements Comparator { public function compare(Version $a, Version $b): int { return strcmp((string) $a, (string) $b); } }

Możemy zatem stworzyć własną implementację Comparatora:

<?php namespace MyApp\Doctrine\DateVersionComparator; use Doctrine\Migrations\Version\Comparator; use Doctrine\Migrations\Version\Version; class DateVersionComparator implements Comparator { public function compare(Version $a, Version $b): int { return strcmp($this->versionWithoutNamespace($a), $this->versionWithoutNamespace($b)); } private function versionWithoutNamespace(Version $version): string { $path = explode('\\', (string) $version); return array_pop($path); } }

A następnie dokonać prostej konfiguracji:

//config/packages/doctrine_migrations.yaml doctrine_migrations: // ... services: 'Doctrine\Migrations\Version\Comparator': MyApp\Doctrine\DateVersionComparator

Po odświeżeniu cache’u migracje zostaną uruchomione w pożądany sposób:

ModuleA\Migrations\V01 ModuleA\Migrations\V02 ModuleB\Migrations\V03 ModuleC\Migrations\V04 ModuleB\Migrations\V06 ModuleC\Migrations\V05

Inne problemy i rozwiązania

W tym artykule przedstawiłem jedynie wierzchołek góry lodowej. W rzeczywistości problemy z kolejnością uruchamiania migracji bazodanowych mogą być zupełnie inne. Wszystko zależy od pożądanego rezultatu i tego, w jaki sposób w aplikacji były dodawane kolejne migracje.

Częstą sytuacją, z którą możemy się spotkać, jest to, iż migracje z jednego modułu będziemy chcieli uruchomić przed migracjami z innego. Czyli zamiast:

ModuleA\Migrations\V01 ModuleB\Migrations\V02

Chcemy uzyskać następujący rezultat:

ModuleB\Migrations\V02 ModuleA\Migrations\V01

Oczywiście podobnie jak w proponowanym przeze mnie rozwiązaniu możemy pisać własną implementację Comparatora. Jednak po co to robić, skoro ktoś już to zrobił za nas? Istnieje świetna implementacja SyliusLabs/DoctrineMigrationsExtraBundle, która pozwoli nam na utworzenie hierarchii między migracjami modułów.

Podsumowanie

Mam nadzieję, iż artykuł ci się podobał i zwróciłem Twoją uwagę na problem kolejności uruchamiania migracji. W różnych sytuacjach i kontekstach rozwiązanie tego problemu może się wydawać nieoczywiste. Warto zapoznać się z dostępnymi możliwościami customizacji zachowania doctrine/migrations-bundle.

Czy zdarzyły Ci się jakieś interesujące problemy z migracjami bazodanowymi? A o ile tak, to w jaki sposób udało Ci się je rozwiązać? Daj znać w komentarzu!

Idź do oryginalnego materiału