Aby nasz kod mógł być czysty, twórcy frameworków muszą się nieraz porządnie nagimnastykować. Dobrym tego przykładem jest biblioteka Doctrine, która skrywa wiele bardzo ciekawych technik. Jedną z nich jest wykorzystywanie tzw. klas Proxy, którym poświęcam dzisiejszy wpis.

Jaką rolę spełniają klasy Proxy?

Dokumentację Doctrine dotyczącą klas Proxy znajdziecie pod tym linkiem. Jest tam wspomniane, że klasy te implementują wzorzec Proxy, dając naszym encjom kilka dodatkowych funkcjonalności, choć – jak sami potwierdzają – z grubsza sprowadza się to do Lazy Loadingu, czyli procesu ładowania zawartości encji dopiero w momencie, kiedy tego potrzebujemy.

Klasy Proxy są generowane przez Doctrine w sposób automagiczny. Doctrine, na podstawie zdefiniowanych przez nas mapowań, poznaje wszystkie nasze encje i generuje dziedziczące po nich klasy, które domyślnie znajdują się w katalogu var/cache/{$env}/doctrine/orm/Proxies. To z tego powodu klasy encji nie mogą być finalne – w aplikacji jest coś, co po nich dziedziczy. Pracując na encjach, zupełnie nie potrzebujemy w żaden sposób integrować się z klasami Proxy. Na pewnym poziomie nie potrzebujemy mieć nawet wiedzy o ich istnieniu.

Kontynuując wątek dokumentacji, dowiemy się, że Doctrine zwraca encje w type Proxy w dwóch sytuacjach. Pierwszą z nich jest pobranie referencji do encji, czyli takiej „wydmuszki”, która będzie posiadała wyłącznie identyfikator. Referencja w Doctrine ma to do siebie, że jeżeli już gdzieś wcześniej Doctrine zaczytał tą encję, to dostaniemy obiekt już w pełni zainicjalizowany wszystkimi danymi. Wspomnianą wyżej „wydmuszkę” dostaniemy w momencie, kiedy Doctrine nie posiada w pamięci (IdentityMap) dowiązania do egzemplarza o zadanym ID.

Drugą opcją, kiedy Doctrine podkłada nam klasę Proxy są asocjacje, czyli po naszemu – relacje. Mowa tu o relacjach, które są ładowane w trybie LAZY (tryb domyślny Doctrine). Jeżeli skonfigurujemy asocjację (w mapowaniu lub query builderze) jako EAGER lub wyślemy zapytanie z joinem po tej relacji (query builder), to wtedy Doctrine zarzuci nam obiekt typu zdefiniowanej przez nas encji.

Teoria teorią, praktyka praktyką…

Aby dowiedzieć sie, jak to tak na prawdę działa, zrobiłem eksperyment z kilkoma scenariuszami. Utworzyłem dwie klasy – App\Entity\Blog oraz App\Entity\Author (dla uproszczenia dalej będę pisał Blog oraz Author), a następnie połączyłem je relacją one-to-one po stronie encji Blog:

<?xml version="1.0" encoding="UTF-8"?>

<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                                      http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
    <entity name="App\Entity\Blog" table="blog">
        <id name="id" type="integer">
            <generator strategy="AUTO" />
        </id>

        <field name="name" />
        <field name="anyValue" />
        <one-to-one field="author" target-entity="App\Entity\Author" />
    </entity>
</doctrine-mapping>

<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                                      http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
    <entity name="App\Entity\Author" table="author">
        <id name="id" type="integer">
            <generator strategy="AUTO" />
        </id>

        <field name="name" />
    </entity>
</doctrine-mapping>

Następnym krokiem było pobawienie się odrobinę repozytorium klasy Blog oraz QueryBuilderem w celu wyciągnięcia egzemplarza encji bloga. Pozwoliło mi to dojść do kilku nieoczywistych wniosków:

  • Ustawienie fetch="EAGER" na relacji powoduje, że pole author będzie typu Author tylko wtedy, kiedy wyciągamy encję Blog z repozytorium, np. przez findOneBy(...).
  • Zrobienie joina (wraz z selectem) w QueryBuilderze o pole author spowoduje, że pole to będzie miało typ Author.
  • Skorzystanie z metody setFetchMode(...) w QueryBuilderze z opcją EAGER nie ma wpływu na typ pola author. Zawsze będzie to Proxies\__CG__\App\Entity\Author. Ma za to wpływ na to stan inicjalizacji tego pola – będzie ono w pełni zainicjalizowane.
  • Wszystkie inne próby wyciągnięcia egzemplarza encji Blog, która nie ma skonfigurowanego fetch="EAGER" na relacji z encją Author dają efekt, że otrzymujemy encję Blog z polem author typu Proxies\__CG__\App\Entity\Author (niezainicjalizowanym).

Prawdę mówiąc, to trochę się zawiodłem. Przed testami miałem nadzieję, że będzie to o wiele bardziej klarowne. Myślałem, że klasę Proxy będziemy dostawali tylko wtedy, kiedy nie będzie potrzeby inicjalizacji relacji pełnym obiektem.

Jak działa Lazy Loading?

Programując w zgodzie z Domain Driven Design, wszyscy wrzucamy encje do warstwy Domeny. Bo przecież encje są prostymi obiektami modelu. Kopiąc w logice Doctrine możemy dojść do wniosku, że… o ile zdefiniowane przez nas klasy encji są faktycznie w pełni odseparowane od warstwy Infrastruktury, to… same egzemplarze tych encji już niekoniecznie. Okazuje się, że klasy Proxy wygenerowane przez Doctrine mają gdzieś tam połączenie z mechanizmem bazodanowym.

Kiedy tak poczyścimy wygenerowaną klasę Proxy z komentarzy, to początek klasy będzie wyglądał mniej więcej tak, jak poniżej:

<?php

namespace Proxies\__CG__\App\Entity;

class Author extends \App\Entity\Author implements \Doctrine\ORM\Proxy\Proxy
{
    public $__initializer__;

    public $__cloner__;

    public $__isInitialized__ = false;

    public static $lazyPropertiesNames = array ();

    public static $lazyPropertiesDefaults = array ();

    public function __construct(?\Closure $initializer = null, ?\Closure $cloner = null)
    {
        $this->__initializer__ = $initializer;
        $this->__cloner__      = $cloner;
    }

    // ...
}

Do konstruktora dostajemy dwie funkcje Closure; pierwszą związaną z inicjalizacją obiektu, drugą – z klonowaniem. Dodatkowo, podczas generowania klasy Proxy, Doctrine skanuje naszą encję w poszukiwaniu metod, które mogłyby korzystać z danych jeszcze niezainicjalizowanych i… zresztą, spójrzcie sami:

<?php

namespace Proxies\__CG__\App\Entity;

class Author extends \App\Entity\Author implements \Doctrine\ORM\Proxy\Proxy
{
    // ...

    public function getName(): string
    {
        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getName', []);

        return parent::getName();
    }

    public function setName(string $name): void
    {
        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setName', [$name]);

        parent::setName($name);
    }
}

Kwestię tego, co dzieje się w tym miejscu poruszam we wpisie Doctrine i problem Lazy Loadingu, do którego lektury serdecznie zapraszam. My na tą chwilę musimy wiedzieć, że tutaj nie są „przykrywane” settery oraz gettery pól encji. Doctrine robi to dla wszystkich publicznych metod w encji.

Gdzie encja musi być załadowana?

Oprócz niejawnego Lazy Loadingu, Doctrine zostawia sobie furtkę na załadowanie encji jawnie w kilku strategicznych miejscach. Mowa o wygenerowanej metodzie __load():

<?php

namespace Proxies\__CG__\App\Entity;

class Author extends \App\Entity\Author implements \Doctrine\ORM\Proxy\Proxy
{
    // ...

    public function __load(): void
    {
        $this->__initializer__ && $this->__initializer__->__invoke($this, '__load', []);
    }

    // ...
}

Metoda ta robi praktycznie to samo, co dzieje się w momencie, kiedy my uruchamiamy zdefiniowane przez nas publiczne metody. Doctrine na pewnym poziomie nie zna naszych metod, stąd potrzeba utworzenia tej jawnej metody, z której może skorzystać wszędzie tam, gdzie potrzeba. A potrzeba to zrobić w kilku kontekstach:

  • Nakładanie locka typu OptimisticLock (w dokumentacji Doctrine możemy znaleźć informację na jego temat). W naszej perpektywy, w tym miejscu potrzebujemy dostać się do pola wersji, które jest zapisane jako zwykła wartość bazodanowa. I dlatego właśnie musimy zaczytać całą encję. Łapcie miejsce w kodzie dla tych, co lubą analizować 🙂 ,
  • Jawna inicjalizacja obiektu encji z poziomu klasy UnitOfWork : klik źródełko,
  • Usunięcie obiektu z poziomu klasy UnitOfWork – metoda doRemove(...). W tym miejscu iterujemy po wszystkich asocjacjach (relacjach) usuwanego obiektu, aby również usunąć je kaskadowo. Kodzik tutaj i tutaj.

Wszystkie powyższe miejsca bezpośrednio dotyczą klasy UnitOfWork, ale możemy do nich dostać się również z poziomu klasy EntityManager.

Serializacja i deserializacja encji

Oprócz Lazy Loadingu, Doctrine dba również o to, aby encje mogły być poprawnie serializowane oraz deserializowane. Do tego celu każda klasa Proxy posiada zestaw dogenerowywanych metod:

<?php

namespace Proxies\__CG__\App\Entity;

class Author extends \App\Entity\Author implements \Doctrine\ORM\Proxy\Proxy
{
    // ...

    public function __sleep()
    {
        if ($this->__isInitialized__) {
            return ['__isInitialized__', 'id', 'name'];
        }

        return ['__isInitialized__', 'id', 'name'];
    }

    public function __wakeup()
    {
        if ( ! $this->__isInitialized__) {
            $this->__initializer__ = function (Author $proxy) {
                $proxy->__setInitializer(null);
                $proxy->__setCloner(null);

                $existingProperties = get_object_vars($proxy);

                foreach ($proxy::$lazyPropertiesDefaults as $property => $defaultValue) {
                    if ( ! array_key_exists($property, $existingProperties)) {
                        $proxy->$property = $defaultValue;
                    }
                }
            };

        }
    }

    // ...
}

Prawdę mówiąc, nie jestem fanem serializacji encji, jeżeli nie muszę tego robić. No ale, jeżeli już nam się to zdarzy, to róbmy to nie w celu użycia jej w innym miejscu jako regularną encję. Encja po deserializacji nie będzie miała już tego samego inicjalizera oraz clonera co poprzednio, a co za tym idzie – może inaczej zachowywać się względem samego Doctrine.

Gdzie i kiedy klasy Proxy są generowane?

Domyślnie, klasy Proxy są generowane przez Doctrine w momencie pierwszego użycia konkretnego typu. Klasa odpowiedzialna za proces ich generowania (ProxyGenerator) znajduje się w paczce doctrine/common, o tutaj. Punktem wejściowym tego (wbrew pozorom) prostego generatora jest publiczna metoda generateProxyClass(...). Nie będziemy tutaj analizowali tego, co dzieje się wewnątrz. Zamiast tego, zbadamy, które mechanizmy Doctrine korzystają z tego generatora.

Generowanie zbiorowe i jednostkowe

Na przestrzeni całego Doctrine, z klasy ProxyGenerator korzysta tak na prawdę tylko jedna klasa – AbstractProxyFactory. Jak pogrzebiemy, to wyłuskamy dwa konteksty generowania – pierwszy, związany z generowaniem wielu klas jednocześnie, oraz drugi – dotyczącej generowania wybranej klasy Proxy.

W kontekście generowania zbiorowego należy spojrzeć na metodę generateProxyClasses(...) (klik źródełko), która jest wykorzystywana w dwóch miejscach:

  • Komenda do generowania wszystkich Proxy – orm:generate-proxies (klik),
  • Metoda DoctrineBundle::boot(...)miejsce, w którym DoctrineBundle bootuje się. Dokładniej, jest to część autoloadera – callback wywoływany wtedy, kiedy autoloader nie znajdzie żądanej klasy. Jest to trochę oszukiwane, bo korzystając z klasy bulkowej, fizycznie generujemy tylko jedną klasę Proxy.

Przechodzimy teraz do kontekstu generowania jednostkowego. W tym miejscu również jest tylko jedna metoda, która korzysta z generatora: getProxyDefinition(...) (klik). Tutaj mamy mechanizm Lazy Loadingu klas Proxy (nie mylić z Lazy Loadingiem encji wprowadzanym przez nie). Jeżeli Proxy nie istnieje, to jest generowane, a dopiero następnie zwracane. Wspomniana metoda ma specyfikator private, dlatego wszystkie jej wykorzystania będą się znajdowały w tej samej klasie – AbstractProxyFactory.

Pobieranie definicji Proxy jest wykorzystywane w dwóch miejscach:

  • Publiczna metoda getProxy(...) – służąca coś na wzór fasady dla getProxyDefinition(...),
  • Publiczna metoda resetUninitializedProxy(...) – metoda resetująca inicjalizer i cloner wybranego Proxy.

Kontynuując wątek gdzie i kiedy, musimy wyjaśnić, gdzie wykorzystywana jest metoda getProxy(...). Pomimo tego, że jest to metoda publiczna, to korzystają z niej tylko dwie inne klasy:

  • EntityManager::getReference(...)metoda służąca pobieraniu referencji encji. Mechanizm referencji polega na tym, że my podajemy tylko nazwę klasy oraz identyfikator. Jeżeli UnitOfWork śledzi tą encję, to zwraca ją. Jeżeli nie posiada o niej informacji, to wtedy zwracana jest niezainicjalizowana klasa Proxy tej encji. Łapcie link do dokumentacji,
  • UnitOfWork::createEntity(...)w miejscu tworzenia encji mamy aż dwa wykorzystania. W momencie, kiedy tworzymy asocjację, która nie jest ustawiona na tryb EAGER, lub jest skonfigurowana jako EAGER, ale z opcją deferable. To właśnie to miejsce jest głównym źródłem problemu N+1,
  • UnitOfWork::mergeEntityStateIntoManagedCopy(...)prywatna metoda wykorzystywana kilkukrotnie przez mechanizm mergowania (zarówno kaskadowego, jak i pojedynczego). Mergowaniem nazywamy proces, kiedy informujemy UnitOfWork o encji, która została z niego odpięta. Mergowanie, a dokładnej metoda UnitOfWork::merge(...) została oznaczona jako deprecated w wersji 2.7 i, zgodnie z mechanizmem Semantic Versioning, zostanie usunięta w wersji 3.0.

Konfigurowanie klas Proxy

Ostatnim punktem naszej podróży jest poruszenie kwestii konfiguracji mechanizmu klas Proxy. Okazuje się, że Doctrine daje nam kilka opcji:

Czy powinniśmy generować Proxy automatycznie?

Odpowiedź brzmi: to zależy 🙂 Dla środowisk developerskich/testowych jest to fajna opcja, bo nie musimy martwić się o to, czy dla nowo utworzonej encji będziemy musieli coś robić dodatkowo, aby Proxy się pojawiło. Na środowisku produkcyjnym mamy nieco inną sytuację – tutaj runtime powinien być odciążony od niepotrzebnej pracy. Dlatego tam ustawiłbym opcję automatycznego generowania na false. Generowanie Proxy przeniósłbym do poziomu skryptu realizującego deploy – Doctrine ma komendę, która za to odpowiada.

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.