Cykl życia encji w Doctrine 2

gildia-developerow.pl 9 miesięcy temu

Encje są bardzo kontrowersyjnym tematem. Z jednej strony, są to klasy, które żyją niejako w odseparowaniu od Doctrine. Z drugiej strony, to Doctrine zarządza tym, kiedy, gdzie i jak encja powstaje. Dzieje się tam pod spodem trochę magii, którą, w dzisiejszym wpisie, postaram się nieco prześledzić.

Cztery scenariusze, z których korzystamy na codzień

Doctrine jest bardzo rozbudowanym narzędziem, które pozwala nam na bardzo wiele. Scenariuszy jego wykorzystania jest mnóstwo, ale w znacznej większości wszystko składa się na:

  • Miejsca, w których tworzymy własne egzemplarze encji
  • Miejsca, w których zaczytujemy encję z bazy danych
  • Miejsca, gdzie modyfikujemy zaciągniętą encję, a następnie ją zapisujemy z powrotem do bazy danych
  • Miejsca, gdzie decydujemy się na usunięcie encji

Każdy z powyższych punktów wpływa jakoś na cykl życia encji. Wydawałoby się, iż w sposób oczywisty, ale… z resztą, przeczytajcie sami

Tworzenie oraz persystencja encji

Podczas tworzenia encji sprawa jest jasna – my tworzymy obiekt, my przyczyniamy się do wywołania konstruktorów. Tzw. utwardzenie encji, czyli proces zapisu jej w bazie danych odbywa się po wywołaniu metod persist(...) oraz flush() na obiekcie klasy EntityManager:

<?php $blog = new Blog("The blog"); $entityManager->persist($blog); $entityManager->flush();

Sprawa wydaje się tutaj bardzo jasna. Poza tym, że… nigdzie nie uruchamiają się destruktory.

Za dokumentacją PHPa:

PHP possesses a destructor concept similar to that of other object-oriented languages, such as C++. The destructor method will be called as soon as there are no other references to a particular object, or in any order during the shutdown sequence.

https://www.php.net/manual/en/language.oop5.decon.php

Jest w takim razie coś, co powoduje, iż aplikacja posiada referencję do utworzonej encji, choćby jeżeli my jej już nigdzie nie trzymamy. Nazywa się to IdentityMap – jest to specjalna adekwatność klasy UnitOfWork, która trzyma dowiązanie do każdej encji znanej Doctrine na przestrzeni procesu / żądania. Każdorazowe uruchomienie metody persist(...) wykona nam metodę, która sprawdzi i ewentualnie dowiąże persystowaną encję do mapy.

Utworzenie tego dowiązania wygląda następująco:

<?php // https://github.com/doctrine/orm/blob/2.15.3/lib/Doctrine/ORM/UnitOfWork.php#L1603C9 public function addToIdentityMap($entity) { $classMetadata = $this->em->getClassMetadata(get_class($entity)); $idHash = $this->getIdHashByEntity($entity); $className = $classMetadata->rootEntityName; if (isset($this->identityMap[$className][$idHash])) { return false; } $this->identityMap[$className][$idHash] = $entity; return true; }

Co prawda, property identityMap nie jest statyczne, aczkolwiek konfiguracja Dependency Injection w Symfony ma tak skonfigurowaną klasę UnitOfWork, iż jej destruktory nie są uruchamiane. A skoro tak, to nie tracimy nigdzie referencji zapisanych w identityMap, czyli w tym momencie destruktory encji nie uruchomią się Bawiłem się w komentowanie i odkomentowywanie przedostatniej linijki tej funkcji – to na prawdę tak działa.

Przyznam, iż jestem ciekaw, jak sytuacja miałaby się, gdybyśmy sami konfigurowali Doctrine poza Symfony.

Odczyt encji z repozytorium

Zaczytywać encje z repozytorium możemy na kilka różnych sposobów. Niezależnie od tego, czy zrobimy to dzięki findOneBy(...), czy find(...) – tak czy tak, trafimy do tego samego mechanizmu. Ja wybrałem metodę findAll():

<?php $blogs = $entityManager ->getRepository(Blog::class) ->findAll();

Wybrana przeze mnie metoda pochodzi z klasy ORMa – EntityRepository:

<?php // https://github.com/doctrine/orm/blob/2.15.3/lib/Doctrine/ORM/EntityRepository.php#L205 public function findAll() { return $this->findBy([]); } /** ... */ public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) { $persister = $this->_em->getUnitOfWork()->getEntityPersister($this->_entityName); return $persister->loadAll($criteria, $orderBy, $limit, $offset); }

Dalej idziemy do persistera i metody loadAll(...):

<?php // https://github.com/doctrine/orm/blob/2.15.3/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php#L910 public function loadAll(array $criteria = [], ?array $orderBy = null, $limit = null, $offset = null) { $this->switchPersisterContext($offset, $limit); $sql = $this->getSelectSQL($criteria, null, null, $limit, $offset, $orderBy); [$params, $types] = $this->expandParameters($criteria); $stmt = $this->conn->executeQuery($sql, $params, $types); $hydrator = $this->em->newHydrator($this->currentPersisterContext->selectJoinSql ? Query::HYDRATE_OBJECT : Query::HYDRATE_SIMPLEOBJECT); return $hydrator->hydrateAll($stmt, $this->currentPersisterContext->rsm, [UnitOfWork::HINT_DEFEREAGERLOAD => true]); }

W tym miejscu jest uruchamiane zapytanie bazodanowe – $this->conn jest obiektem klasy Doctrine\DBAL\Connection. Po wysłaniu zapytania (oraz odebraniu danych) dochodzimy do momentu hydracji, czyli procesu przekształcania wyniku zapytania DBAL na inne formaty, będące już składnikiem ORMa. W przypadku tutaj omawianym formatem wyjściowym będzie to tablica klas encji. Zatem pracować będzie hydrator SimpleObjectHydrator (link).

Samego procesu hydracji nie będę tutaj omawiał. Moim celem jest znalezienie miejsca, w którym zajmujemy się konstrukcją encji. Okazuje się, iż wspomniany hydrator o utworzenie encji prosi wszystkim dobrze znaną klasę UnitOfWork (o tutaj). Ten z kolei prosi o utworzenie egzemplarza encji klasę ClassMetadataInfo (o, tu), a ten dalej prosi o utworzenie egzemplarza klasę spoza paczki ORMa – klasę Instantiator (link).

Przyznacie, iż trochę to pomieszane, nie? Koniec końców, za tworzenie encji odpowiada bardzo mała i bardzo generyczna klasa, która nie wie praktycznie nic o tym całym kontekście ORM. Jedyne jej zadanie to utworzenie klasy z poziomu… deserializacji :

<?php // https://github.com/doctrine/instantiator/blob/2.0.0/src/Doctrine/Instantiator/Instantiator.php#L121 private function buildFactory(string $className): callable { $reflectionClass = $this->getReflectionClass($className); if ($this->isInstantiableViaReflection($reflectionClass)) { return [$reflectionClass, 'newInstanceWithoutConstructor']; } $serializedString = sprintf( '%s:%d:"%s":0:{}', is_subclass_of($className, Serializable::class) ? self::SERIALIZATION_FORMAT_USE_UNSERIALIZER : self::SERIALIZATION_FORMAT_AVOID_UNSERIALIZER, strlen($className), $className, ); $this->checkIfUnSerializationIsSupported($reflectionClass, $serializedString); return static fn () => unserialize($serializedString); }

W tym miejscu należy potwierdzić, iż ZACZYTYWANIE ENCJI Z REPOZYTORIUM NIE URUCHAMIA KONSTRUKTORÓW TYCH ENCJI. Dziękuję za uwagę. A tak serio, to jest to moim zdaniem mistrzostwo, które zasługuje na uznanie. Bo w tym momencie, moim zdaniem, nie powinny być uruchamiane żadne konstruktory. Przeczytacie o tym w podsumowaniu całej akcji.

Aby dokończyć nieco temat pobierania encji z repozytoriów – co z propertiesami? Czy nasze settery będą uruchomione? Odpowiedź brzmi: nie, nie będą one uruchomione. Aby tego dowieść, wróćmy na chwilę do klasy UnitOfWork oraz metody createEntity(...) (link). O ile tworzenie instancji encji jest oddelegowane (o czym było wyżej), o tyle – uzupełnianie adekwatności encji to już zajęcie dla UnitOfWork. Odbywa się to przez wykonanie prostej pętli:

<?php // https://github.com/doctrine/orm/blob/2.15.3/lib/Doctrine/ORM/UnitOfWork.php#L2839 public function createEntity($className, array $data, &$hints = []) { // ... foreach ($data as $field => $value) { if (isset($class->fieldMappings[$field])) { $class->reflFields[$field]->setValue($entity, $value); } } }

Gdzieś tutaj już widzimy nazwę reflFields, która może sugerować nam, iż ustawianie tych propertiesów odbywa się przez refleksję. Ciekawość podpowiedziała mi, aby zdumpować, jaki obiekt siedzi pod $class->reflFields[$field]. Efekt dumpa poniżej:

UnitOfWork.php on line 2775: Doctrine\Persistence\Reflection\TypedNoDefaultReflectionProperty {#454 ▼ +name: "name" +class: "App\Entity\Blog" -key: "\x00*\x00name" modifiers: "protected" }

Nie byłbym sobą, gdybym nie kopał dalej. Ustawianie propertiesów przez refleksję odbywa się w klasie z osobnej paczki – Doctrine Persistence:

<?php // https://github.com/doctrine/persistence/blob/3.2.x/src/Persistence/Reflection/TypedNoDefaultReflectionProperty.php declare(strict_types=1); namespace Doctrine\Persistence\Reflection; /** * PHP Typed No Default Reflection Property - special override for typed properties without a default value. */ class TypedNoDefaultReflectionProperty extends RuntimeReflectionProperty { use TypedNoDefaultReflectionPropertyBase; }

Tutaj znowu musimy pobawić się w łańcuszek. Zatem: powyższa klasa (z paczki Doctrine) TypedNoDefaultReflectionProperty dziedziczy po innej klasie z paczki Doctrine: RuntimeReflectionProperty. Ta dalej dziedziczy po PHPowej klasie ReflectionProperty. Wniosek nasuwa się tylko jeden: PROPERTISY KLASY SĄ USTAWIANE PRZEZ REFLEKSJĘ .

Dla tych, co chcą dalej kopać – link do podpinania relacji pod encję macie tutaj.

Aktualizacja śledzonej encji

Jeżeli chodzi o aktualizację, to tutaj nic interesującego się nie dzieje… Ani konstruktory, ani destruktory nie uruchomią się, bo encja jest śledzona przez UnitOfWork. Doctrine uruchomi odpowiednie narzędzia do prześledzenia zmian, które zaszły w encjach i zapisze nowe wartości do bazy danych.

Usunięcie encji

Usunięcie encji odbywa się poprzez uruchomienie metody remove(...) na klasie EntityManager:

<?php $blogs = $entityManager ->getRepository(Blog::class) ->findAll(); foreach ($blogs as $blog) { $entityManager->remove($blog); } $entityManager->flush();

Uruchomienie metody remove(...) poskutkuje odpięciem encji od obiektu klasy UnitOfWork. Odbywa się to w metodzie scheduleForDelete(...):

<?php // https://github.com/doctrine/orm/blob/2.15.3/lib/Doctrine/ORM/UnitOfWork.php#L1521C5 public function scheduleForDelete($entity) { $oid = spl_object_id($entity); if (isset($this->entityInsertions[$oid])) { if ($this->isInIdentityMap($entity)) { $this->removeFromIdentityMap($entity); } unset($this->entityInsertions[$oid], $this->entityStates[$oid]); return; // entity has not been persisted yet, so nothing more to do. } if (! $this->isInIdentityMap($entity)) { return; } $this->removeFromIdentityMap($entity); unset($this->entityUpdates[$oid]); if (! isset($this->entityDeletions[$oid])) { $this->entityDeletions[$oid] = $entity; $this->entityStates[$oid] = self::STATE_REMOVED; } }

Ktoś by pomyślał – OK, super. Odpinamy encję z Doctrine, odpalają się destruktory i jesteśmy szczęśliwi. Bo w sumie jesteśmy – destruktory się odpalą. No, ale nie odpalą się ani w momencie wywołania metody remove(...), ani choćby po uruchomieniu flusha. Bo dalej mamy dostęp do encji, choćby jak ta jest odpięta od Doctrine. Destruktory uruchomią się w najlepszym przypadku dopiero wtedy, kiedy wyjdziemy ze scope, w którym mamy dostęp do obiektu.

Jeżeli trzymamy referencję do encji gdzieś np. w statycznej propertce – destruktory nie odpalą się. To jest sprawdzone info

Podsumowanie: Konstruktory i destruktory kontra Doctrine

Z powyższego wynika, że:

  • Konstruktory uruchamiane są wyłącznie, kiedy sami tworzymy nowy egzemplarz encji
  • Destruktory uruchamiane są wtedy, kiedy usuwamy encję (dajemy znać Doctrine, iż ma przestać się nią interesować), a my stracimy do niej wszystkie referencje.
  • Doctrine nie uruchamia żadnych metod na encji podczas odtwarzania jej z poziomu bazy danych

Dla niektórych może to być nieco mylące. Zarówno konstruktory jak i destruktory są czymś, co kojarzy nam się często z przejściem przez proces żądania HTTP – konstruktory tworzą się, kiedy obiekt powstaje, destruktory uruchamiają się, kiedy go tracimy. Przynajmniej, tego byśmy oczekiwali; iż Doctrine uruchomi konstruktory po zaczytaniu encji z bazy (a destruktory po wykonaniu kontrolera bądź podobnego obiektu). Ten temat ma jednak drugie dno.

Jeżeli spojrzymy ponownie na istotę encji, to możemy zauważyć, iż jej żywot nie kończy się w błachy sposób po każdorazowym domknięciu żądania. Encja jest specjalnym egzemplarzem klasy, który tworzymy i nadajemy jej specjalny, osobny identyfikator. Z perspektywy Domain Driven Design (musiałem wspomnieć, bo jest to mocno powiązane z Doctrine), po wykonaniu żądania, ta encja dalej istnieje w aplikacji. Znajduje się ona w repozytorium. Wyciągnięcie jej z tego repozytorium oraz wsadzenie jej tam z powrotem de facto nie zmienia nic w jej cyklu życia. Ona dalej żyje. Inne procesy (np. CLI) mogą do niej się odwoływać, modyfikować ją oraz z powrotem zwracać do repozytorium.

Dopiero wyrzucenie jej z repozytorium (odpięcie od UnitOfWork) skutkuje tym, iż jej żywot dobiega końca. Następnej encji tej klasy o tym identyfikatorze już nie będzie.

Idź do oryginalnego materiału