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 poleauthor
będzie typuAuthor
tylko wtedy, kiedy wyciągamy encjęBlog
z repozytorium, np. przezfindOneBy(...)
. - Zrobienie joina (wraz z selectem) w QueryBuilderze o pole
author
spowoduje, że pole to będzie miało typAuthor
. - Skorzystanie z metody
setFetchMode(...)
w QueryBuilderze z opcjąEAGER
nie ma wpływu na typ polaauthor
. Zawsze będzie toProxies\__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 skonfigurowanegofetch="EAGER"
na relacji z encjąAuthor
dają efekt, że otrzymujemy encjęBlog
z polemauthor
typuProxies\__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
– metodadoRemove(...)
. 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 dlagetProxyDefinition(...)
, - 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żeliUnitOfWork
ś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 trybEAGER
, lub jest skonfigurowana jakoEAGER
, 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 informujemyUnitOfWork
o encji, która została z niego odpięta. Mergowanie, a dokładnej metodaUnitOfWork::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:
- Mamy możliwość konfiguracji ścieżki w systemie plików, gdzie Doctrine będzie zapisywał pliki z klasami,
- Jest również opcja konfiguracji namespace, w którym znajdować się będą klasy Proxy,
- Doctrine pozwala nam również zdecydować, czy chcemy zezwolić na generowanie klas Proxy w runtime.
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.
Comments are closed.