Dlaczego nie mieszamy Doctrine Schema oraz Migration?

gildia-developerow.pl 11 miesięcy temu

Doctrine, jako bardzo zaawansowane narzędzie, posiada bardzo dużo mechanizmów, które należałoby poznać zarówno od strony teoretycznej jak i w boju. Okazuje się, iż niektóre z tych funkcjonalności – pomimo, iż służą do podobnych celów – mogą się wzajemnie wykluczać. Niestety, niezbyt wiele osób wie, iż właśnie tak jest z funkcjami Doctrine Schema oraz Migration. Dlatego właśnie powstał ten wpis.

Dwa narzędzia do tego samego…

Z jednej strony – mamy dwa narzędzia, które pozwalają nam na nanoszenie zmian w strukturze bazy danych. Jedni korzystają z poleceń o prefixie doctrine:schema, a drudzy z doctrine:migration. Z drugiej jednak strony, mamy dwa narzędzia, które robią to samo. Należałoby zadać pytanie: które jest lepsze? To w takim razie, po co powstało to drugie? o ile jest lepsze, to czy nie lepiej pozbyć się tego pierwszego? Aby móc sensownie odpowiedzieć na te pytania, należałoby porównać obydwa te rozwiązania.

Charakterystyka Doctrine Schema

Pracując z Doctrine, tworzymy encje oraz mapowania bazodanowe. Niezależnie od tego, czy są to annotacje, czy XML – tworzymy schemat bazy danych. Jest to schemat, za pomocą którego aplikacja wie, co i gdzie się znajduje. Następnie, my – programiści, po skonfigurowaniu połączenia bazodanowego, powinniśmy utworzyć bazę danych oraz wszystkie tabelki tak, jak określiliśmy w mapowaniach.

Tworzenie całego schematu bazy jest procesem żmudnym, nudnym i niejednokrotnie długotrwałym. Dodatkowo, bardzo łatwo o pomyłkę, którą wychwycić możemy tylko podczas przeklikiwania (lub oczywiście automatycznego testowania) aplikacji. Przydałby się do tego celu automat, który zresztą już istnieje i ma się dobrze. Mowa o SchemaTool, który opakowany w skrypty konsolowe z rodziny doctrine:schema, służy do szybkiego tworzenia bazy danych na podstawie zdefiniowanych mapowań. Nic mniej, nic więcej.

No dobra, oprócz tworzenia mamy również aktualizację, usuwanie i takie tam Ale w tym, co wymieniłem – nie mamy żadnej innej opcji konfiguracyjnej. Kiedy tworzymy lub aktualizujemy strukturę bazy, to możemy jedynie zgodzić się na to, co zostanie nam zaproponowane.

Koncept migracji

Migracje są, w porównaniu do poprzedniego rozwiązania, nieco bardziej zaawansowanym narzędziem. Praca na nich polega na tym, iż tworzymy w projekcie kolejne pliki źródłowe (o bardzo specyficznych nazwach) zawierające dwie metody: up oraz down, do wnętrza których dorzucamy zapytania SQL modyfikujące strukturę bazy danych. Następnie mamy możliwość uruchamiania migracji jedna po drugiej, co daje nam funkcję wersjonowania całej struktury. Uruchomienie każdej migracji jest zapamiętywane w osobnej tabelce w bazie, dzięki czemu możemy „dogrywać” kolejne, wcześniej niedograne migracje.

W przeciwieństwie do komend z rodziny doctrine:schema, mamy tutaj pełną kontrolę nad tym, w jaki sposób konstruowane są struktury bazy danych. Możemy dorzucić własne indeksy, funkcje bazodanowe i w zasadzie wszystko, co oferuje nam silnik bazy danych. Dodatkowo, mamy tutaj również wbudowaną opcję „automatu”, który wygeneruje zapytania na podstawie zdefiniowanych w projekcie mapowań.

Z którego rozwiązania najlepiej skorzystać?

Wygląda na to, iż wygrały migracje, co nie? No ale, według mnie to trochę tak i trochę nie. Porównajmy obydwa rozwiązania, trochę powinno nam się rozjaśnić:

  • Doctrine Schema działa w oparciu o wszystkie bundle załączone do projektu. o ile załączyliśmy bundle, który ma poprawnie skonfigurowane mapowania encji, to zostanie to uwzględnione. Podobnie jest z migracjami – te również uruchamiają się dla wszystkich bundli. No, ale, o ile każdy bundle korzystający z Doctrine musi mieć mapowania, o tyle, nie musi on korzystać z migracji.
  • Możliwość kontrolowania zapytań aż kusi o to, aby (oczywiście przypadkowo) tworzyć migracje, które będą zawierały błędy. O ile lokalnie bądź środowisku stagingowym nie zrobi to problemu (możemy pozwolić sobie na edycję kodu migracji, wyczyszczenie bazy i postawienie wszystkiego na nowo), o tyle, na produkcji tego nie możemy tak prosto zrobić. Pracując z Doctrine Schema tego problemu nie ma, bo generowanie zapytań odbywa się automatycznie.
  • Skoro o automatyzacji mowa, to Doctrine Schema nie pomoże nam w niczym, co choćby odrobinę odbiega od tego, co jest zapisanego w mapowaniach. Indeksy będziemy mieli nałożone tylko na pola, po których mamy zrobione łączenia relacji. Nie utworzymy widoku ani triggera. Będziemy bardzo mocno ograniczeni choćby w kwestii zmiany struktury tabeli – choćby taki prosty MySQL ma tak wiele możliwości na ich modyfikację.

To jest tylko kilka z wielu punktów, z których zauważyć można, iż zarówno jedna jak i druga opcja ma swoje zalety i wady. Pomimo, iż sam bardzo skłaniam się ku migracjom, to nie znaczy, iż jest to jedyne słuszne rozwiązanie. Czasami do wbijania gwoździ wystarczy zwykły młotek Niezależnie od tego, którą opcję wybierzemy – o ile wiemy, na co się piszemy, to raczej powinniśmy przewidzieć potencjalne problemy. Schody zaczną się, kiedy zdecydujemy się na połączenie tych dwóch opcji… o czym przekonacie się, czytając dalej.

Pokopmy trochę w kodzie

Podczas prac projektowych powinniśmy jasno określić, z którego rozwiązania korzystamy. Powinien wiedzieć o tym cały zespół i trzymać się tych zasad, choćby lokalnie. Istnieje kilka powodów ku temu. Aby poznać jeden z nich, spójrzmy na to, co siedzi w środku komendy doctrine:schema:update:

// https://github.com/doctrine/orm/blob/2.14.x/lib/Doctrine/ORM/Tools/SchemaTool.php#L956 <?php // ... public function updateSchema(array $classes, $saveMode = false) { if (func_num_args() > 1) { Deprecation::triggerIfCalledFromOutside( 'doctrine/orm', 'https://github.com/doctrine/orm/pull/10153', 'Passing $saveMode to %s() is deprecated and will not be possible in Doctrine ORM 3.0.', __METHOD__ ); } $updateSchemaSql = $this->getUpdateSchemaSql($classes, $saveMode); $conn = $this->em->getConnection(); foreach ($updateSchemaSql as $sql) { $conn->executeStatement($sql); } }

Klasa SchemaTool jest głównym narzędziem wykorzystywanym w wyżej wspomnianym poleceniu. Na starcie możemy zauważyć, iż cały mechanizm składa się na wygenerowanie oraz uruchomienie zapytań aktualizacyjnych strukturę bazy danych. Zwróćmy uwagę, iż w tym miejscu wykorzystujemy flagę $saveMode, o której będzie kwestia w dalszej części wpisu.

Kopiąc w kodzie dalej znajdujemy:

// https://github.com/doctrine/orm/blob/2.14.x/lib/Doctrine/ORM/Tools/SchemaTool.php#L956 <?php // ... public function getUpdateSchemaSql(array $classes, $saveMode = false) { if (func_num_args() > 1) { Deprecation::triggerIfCalledFromOutside( 'doctrine/orm', 'https://github.com/doctrine/orm/pull/10153', 'Passing $saveMode to %s() is deprecated and will not be possible in Doctrine ORM 3.0.', __METHOD__ ); } $toSchema = $this->getSchemaFromMetadata($classes); $fromSchema = $this->createSchemaForComparison($toSchema); if (method_exists($this->schemaManager, 'createComparator')) { $comparator = $this->schemaManager->createComparator(); } else { $comparator = new Comparator(); } $schemaDiff = $comparator->compareSchemas($fromSchema, $toSchema); if ($saveMode) { return $schemaDiff->toSaveSql($this->platform); } if (! method_exists(AbstractPlatform::class, 'getAlterSchemaSQL')) { return $schemaDiff->toSql($this->platform); } return $this->platform->getAlterSchemaSQL($schemaDiff); }

W tym miejscu interesują nas dwa fakty. Pierwszy jest taki, iż Doctrine porównuje obecny stan bazy danych z tym zdefiniowanym w mappingach. Mowa o zmiennych $toSchema oraz $fromSchema. Następnie tworzony jest obiekt $schemaDiff, który jak się domyślamy z kontekstu, jest zbiorem różnic pomiędzy nimi. No, ale to wiemy – nie jest to wiedza ukryta. O tym zachowaniu mówi nam dokumentacja.

Drugim faktem jest to, iż w zależności od wartości flagi $saveMode, uruchamiana jest nieco inna logika. Kopiąc wgłąb, mozemy dogrzebać się do poniższego kodu DBAL:

// https://github.com/doctrine/dbal/blob/3.6.x/src/Schema/SchemaDiff.php#L250 protected function _toSql(AbstractPlatform $platform, $saveMode = false) { $sql = []; if ($platform->supportsSchemas()) { foreach ($this->newNamespaces as $newNamespace) { $sql[] = $platform->getCreateSchemaSQL($newNamespace); } } if ($platform->supportsForeignKeyConstraints() && $saveMode === false) { foreach ($this->orphanedForeignKeys as $orphanedForeignKey) { $sql[] = $platform->getDropForeignKeySQL($orphanedForeignKey, $orphanedForeignKey->getLocalTable()); } } if ($platform->supportsSequences() === true) { foreach ($this->changedSequences as $sequence) { $sql[] = $platform->getAlterSequenceSQL($sequence); } if ($saveMode === false) { foreach ($this->removedSequences as $sequence) { $sql[] = $platform->getDropSequenceSQL($sequence); } } foreach ($this->newSequences as $sequence) { $sql[] = $platform->getCreateSequenceSQL($sequence); } } $foreignKeySql = []; foreach ($this->newTables as $table) { $sql = array_merge( $sql, $platform->getCreateTableSQL($table, AbstractPlatform::CREATE_INDEXES) ); if (! $platform->supportsForeignKeyConstraints()) { continue; } foreach ($table->getForeignKeys() as $foreignKey) { $foreignKeySql[] = $platform->getCreateForeignKeySQL($foreignKey, $table); } } $sql = array_merge($sql, $foreignKeySql); if ($saveMode === false) { foreach ($this->removedTables as $table) { $sql[] = $platform->getDropTableSQL($table); } } foreach ($this->changedTables as $tableDiff) { $sql = array_merge($sql, $platform->getAlterTableSQL($tableDiff)); } return $sql; }

… i tutaj się robi ciekawie. W zależności od tego, jaką wartość przybiera parametr $saveMode, do listy wygenerowanych zapytań SQL mogą zostać dołożone zapytania… usuwające tabele oraz sekwencje. W zasadzie, to nie powinno to być dla nas wielkim problemem. Kiedy utworzyliśmy encję, która nie jest już nam więcej potrzebna, to możemy chcieć posiadać możliwość wygenerowania (za pomocą SchemaTool) zapytania, które usunie niepotrzebną już tabelę z bazy. No i w tym przypadku – wszystko działa, jak należy. No ale, niestety, świat nie jest skonstruowany z tylko jednego przypadku.

Problematyczna flaga –complete

Zanim przejdziemy do naszych przypadków, opowiedzmy sobie nieco o fladze $saveMode. Okazuje się, iż jej wartością możemy sterować z poziomu polecenia doctrine:schema:update. Mowa o fladze --complete. Bez tej flagi wartość $saveMode jest równa true i żadne zapytanie usuwające nie zostanie wygenerowane. Kiedy podajemy wspomnianą flagę, to $saveMode przyjmuje wartość false i wtedy właśnie jest mniej bezpiecznie.

Sterowanie zachowaniem tego polecenia jest bardzo fajne. o ile z jakiegoś powodu nie chcemy usuwać tabel, które nie są zapisane w plikach mappingu, to tego nie robimy. Niefajne natomiast jest to, iż ktoś chce nam tą opcję zabrać. Już w tej chwili podczas wykonywania polecenia pojawia nam się poniższy komunikat:

Not passing the „–complete” option to „orm:schema-tool:update” is deprecated and will not be supported when using doctrine/dbal 4

Czyli w Doctrine DBAL w wersji 4.x nie będzie możliwości wyboru i $saveMode zawsze będzie miało wartość false. Przynajmniej ja to tak odbieram. W sumie to nie wiem, czy można ten komunikat odebrać w inny sposób. No ale – przyjmijmy do wiadomości, iż od wersji Doctrine DBAL 4.x nie będzie opcji zostawiania tabel, które nie należą do schemy.

Dobra zasada mówi: nie mieszajmy!

Jeżeli korzystamy z bazy danych w bardzo prosty sposób – opierając się wyłącznie o pliki mappingów – nie ma sprawy. No ale, aplikacje dzisiaj potrafią być nieco bardziej zaawansowane. Dlatego właśnie korzystamy z systemu migracji. Dzięki niemu otwiera się przed nami cały wachlarz możliwości, które oferuje nam baza danych. Sęk w tym, iż to, co zawrzemy w migracjach – niekoniecznie musi znajdować się w mapowaniach schemy.

Z powyższego, pośrednio wynika, iż dobrze by było nie mieszać migracji z doctrine:schema (szczególnie, jak wyjdzie DBAL 4.x). Aby przekonać się o tym, zrobiłem bardzo krótki eksperyment: utworzyłem tabele dzięki migracji, a następnie wywołałem komendę doctrine:schema:update z flagą --complete (bo tak będzie się zachowywał Doctrine DBAL 4.x).

Komendy, których użyłem:

# somewhere in the bash... bin/console doctrine:database:drop --force bin/console doctrine:database:create bin/console doctrine:migrations:migrate bin/console doctrine:schema:update --complete --dump-sql

Wynik był następujący:

# ... the result DROP TABLE doctrine_migrations; DROP TABLE dummy_table; DROP INDEX doctrine_dummy_index_6 ON dummy_table;

Z tego wszystkiego, co dotąd odkryliśmy, to usunięte zostaną tabele oraz sekwencje (indeksy), które nie są zawarte w mapowaniach. Z mojej analizy wychodzi, iż usunięte zostaną:

  • table – wiadomo,
  • indeksy, bo obejmuje je logika klasy SchemaTool
  • triggery, ponieważ będą przynależeć do usuniętych tabel
  • widoki, choć nie zostaną usunięte, to – ponieważ będą wskazywać na nieistniejące już tabele – przestaną działać

Należy podkreślić, iż USUNIĘTA ZOSTANIE RÓWNIEŻ TABELA doctrine_migrations. Czyli, o ile przez przypadek odpalimy schema:update na produkcji korzystającej z migracji, to może nam się trochę posypać.

Istnieje całkiem prawdopodobny scenariusz

Wydaje mi się, iż widzę scenariusz, który mógłby wywrócić niejedną produkcję. Wystarczy, iż mamy programistę, który w pojedynkę pracował nad projektem. Z jakiegoś powodu ten programista się zmienia (zwalnia się, projekt trafia do innej firmy – niepotrzebne skreślić). Następnie, prace nad projektem kontynuuje mało kompetentny programista, który nie zdaje sobie sprawy z tego, iż w projekcie korzystamy z migracji. Ba, być może choćby nie korzystał z migracji wcześniej i nie do końca zdaje sobie sprawę z tego, co to jest i czym się charakteryzuje. Dalej możecie już sobie dopisać własne zakończenie, bo chyba wiecie, w którą stronę to idzie. I niestety, takich przypadków może być na prawdę dużo.

Słaby, ale workaround

Doctrine daje nam możliwość konfiguracji regexa wskazującego na tabele, które powinny zostać usunięte z flagą --complete:

# https://symfony.com/bundles/DoctrineBundle/current/configuration.html doctrine: dbal: connections: default: # If set to "/^sf2_/" all tables, and any named objects such as sequences # not prefixed with "sf2_" will be ignored by the schema tool. # This is for custom tables which should not be altered automatically. schema_filter: ~

Jest to opcja, kiedy wiemy, iż kilka różnych systemów korzysta z jednej bazy danych, a my na siłę korzystamy ze schema:update. Dzięki tej opcji możemy choćby zapobiec usunięciu tabeli doctrine_migrations.

No, ale, skoro bawimy się takimi opcjami, to nie lepiej byłoby już przejść na migracje w 100% i cieszyć się pełnią życia?

Idź do oryginalnego materiału