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ę, że 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. Przejdźmy zatem do kwestii destruktorów.

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

W PHPie za uruchomienie destruktorów jest odpowiedzialny Garbage Collector. W odpowiednich miejscach sprawdza on, czy istnieją obiekty, do których aplikacja nie ma już żadnych referencji. Jeżeli gdzieś takie obiekty są, to GC uruchamia ich destruktory, po czym usuwa je.

Od momentu, kiedy decydujemy się na persystencję encji, Doctrine „wie” o jej istnieniu. Jeżeli stracimy wszystkie utworzone przez nas referencje, to destruktory tych encji nie uruchomią się, dopóki Doctrine o nich wie. Mowa tu o miejscu, które nazywa się to IdentityMap – jest to specjalna właściwość 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;
}

Właściwość identityMap nie jest statyczna, zatem w momencie niszczenia obiektów klasy UnitOfWork fizycznie stracimy ostatnie dowiązania do naszych encji, a co za tym idzie – uruchomione zostaną ich destruktory. Gdyby jednak identityMap było statyczne, to destruktory encji również by się odpaliły, jednakże w jeszcze późniejszym etapie; stanie się to wtedy, kiedy PHP będzie kończył proces interpretacji kodu i wezwie Garbage Collectora do zniszczenia wszystkiego.

Odczyt encji z repozytorium

Zaczytywać encje z repozytorium możemy na kilka różnych sposobów. Niezależnie od tego, czy zrobimy to za pomocą 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ę, że 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, że 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ć, że ZACZYTYWANIE ENCJI Z REPOZYTORIUM NIE URUCHAMIA KONSTRUKTORÓW TYCH ENCJI. Dziękuję za uwagę. A tak serio, to rozwiązała mi się w głowie zagadka, dlaczego pomimo, że ja w konstruktorach resetuję relacje przez wykorzystanie ArrayCollection, to Doctrine gdzieś tam dalej zamienia to na PersistentCollection. Dzieje się to właśnie na tym etapie, kiedy Doctrine może samemu zadbać o proces konstrukcji encji.

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ą. 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 właściwoś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, że 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.

Destruktory i… śmieszna sytuacja 😉

Zabawnym jest to, że pomimo braku uruchomienia konstruktora na początku, destruktory zostaną uruchomione. Należy mieć ten fakt z tyłu głowy. Potencjalnie, moglibyśmy mieć następujący kodzik:

<?php

final class Product
{
    ProductCategoryInterface $category;

    public function __construct(ProductCategoryInterface $category)
    {
        $this->category = $category;
        $this->category->addProduct($this);
    }

    public function __destruct()
    {
        $this->category->removeProduct($this);
    }
}

W tym miejscu zakładamy, że konstruktor zawsze się uruchamia i że jeżeli produkt istnieje, to kategoria zawsze będzie miała do niego dowiązanie. Niestety, powyżej udowodniłem, że konstruktor się nie uruchomi. Moment destrukcji obiektu klasy Product może skończyć się wyrzuceniem wyjątku przez obiekt klasy ProductCategory, bo ta stwierdzi, że nie może usunąć czegoś, do czego dowiązania nie posiada.

Aktualizacja śledzonej encji

Jeżeli chodzi o aktualizację, to tutaj nic ciekawego się nie dzieje… Konstruktory 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. Destruktory encji zostaną uruchomione w momencie niszczenia UnitOfWork.

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, natychmiastowo 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 nawet po uruchomieniu flusha. Bo dalej mamy dostęp do encji z poziomu kodu, który wywołuje te funkcje. Ponieważ UnitOfWork nie przetrzymuje już referencji do encji, to. ich destruktory uruchomią się wtedy, kiedy wyjdziemy ze scope, w którym mamy dostęp do obiektu (1).

Podsumowanie: Konstruktory i destruktory kontra Doctrine

Z powyższego wynika, że:

  • Konstruktory uruchamiane są wyłącznie, kiedy sami tworzymy nowy egzemplarz encji.
  • Destruktory uruchomią się zawsze, kiedy tracimy ostatnią referencję do encji. Jeżeli encja jest śledzona przez Doctrine, to destruktory uruchamiają się w momencie niszczenia obiektu klasy UnitOfWork. Kiedy przestajemy śledzić encję (uruchamiamy metodę EntityManager::remove(...)), destruktory uruchomią się prawdopodobnie w momencie, kiedy wyjdziemy z ostatniego scope logiki biznesowej, który wie coś o usuwanej encji.
  • Doctrine nie uruchamia żadnych metod należących do encji podczas odtwarzania jej z poziomu bazy danych.

(1) – pomijam fakt, jeżeli gdzieś trzymamy referencje do encji np. w polach statycznych.

Profesjonalista, zajmujący się na co dzień aplikacjami biznesowymi w ekosystemie PHP. Jego pasją jest odkrywanie nowych konceptów programistycznych oraz wzorce architektoniczne. Uwielbia również pisać testy, gdyż jak sam uważa, dobry kod to przetestowany kod.

Comments are closed.