Doctrine i problem Lazy Loadingu

gildia-developerow.pl 1 rok temu

Większość świeżych programistów, którzy pytają tych starszych o porady, słyszą: „Ucz się Symfony, Doctrine i pisz testy”. A młodzi przyjmują to za świętość i uczą się. Znają podstawowe pojęcia, po czym wchodzą na projekt, napiszą endpoint dla dużego zestawu danych i… całość wykonuje się w 13 sekund. Ta historia, choć nieco przeze mnie ufarbowana, wydarzyła się całkiem niedawno. I z chęcią podzielę się z Wami kilkoma szczegółami oraz wnioskami z tej sprawy

Kiedy dostałem w pracy proste zadanie…

Sytuacja wygląda tak, iż dostaliśmy w pracy proste zadanie: wyświetl listing produktów na stronie. Taką tabelkę. Podstawowe informacje, które ten listing powinien zawierać to:

  • Zdjęcie produktu
  • Nazwa produktu
  • Opcje wariantów produktu
  • Cena najtańszego wariantu w obrębie produktu
  • Cena najdroższego wariantu w obrębie produktu

Ponieważ listing ma być wyświetlany w panelu administracyjnym, to fajnie by było zrobić do niego paginację.

Powiązania między encjami wyglądają następująco: Podstawowe informacje o produkcie mamy w jednej tabelce, zdjęcia mamy w osobnej encji, relacja jeden do wielu. Opcje wariantów oraz same warianty, to osobne encje (również relacja jeden produkt do wielu wariantów i wielu opcji). Ceny oczywiście zapisywane są w wariantach, ponieważ czerwone skarpetki męskie sprzedajemy w innej cenie, niż różowe skarpetki męskie. Translacje – te również są osobno, bo możemy mieć różne tłumaczenia dla różnych locale.

Najprostszym pomysłem będzie pobranie wszystkich produktów (uff, repozytorium ma metodę findBy()), a następnie w szablonach powyciągamy odpowiednie dane w pętelkach. Bo przecież Doctrine nam to ładnie relacjami pozałatwia ;).

Drugiego dnia dostajemy jeszcze prostsze zadanie

Po dobrze spędzonym wieczorze przychodzi następny dzień w pracy i nowe wyzwania.

Przychodzi do nas PM i mówi: to, co zrobiłeś wczoraj, przyda nam się w innym miejscu. Trzeba przygotować generowanie pliku XML, do którego link wrzucimy do Google Merchant Center, jako feed. Wszystko mamy ogarnięte, trzeba tylko przerobić twigi na coś, co wygeneruje nam XML. Ważne: mamy w systemie 10.000 dostępnych produktów i chcielibyśmy, aby wszystkie znajdowały się na jednej stronie.

Estymujemy zadanie na dwie godzinki, bo to w sumie tylko generowanie XMLa i zabieramy się do pracy. Lokalnie na 20 produktach wszystko działa jak należy. Możemy iść na produkcję. Aby dodać dramaturgii całej sytuacji, załóżmy, iż robimy deploy w piątek o godzinie 15. A o 16, szczęśliwi, zmykamy do domu. Deploy zrobiony, ale funkcja z Google Feed nie działa. Produkty się nie zaczytują. O 15:45 przychodzi nasz PM i informuje nas, iż trzeba rozbić biwak w biurze i debugować (1).

To, co w takiej sytuacji najlepiej zrobić, to wygenerować sobie 10.000 produktów lokalnie i ustawić flagę APP_DEBUG na wartość 1. Kiedy już to zrobiliśmy i próbujemy ogarnąć w profilerze, co się dzieje – chwytamy się za głowę. Ilość zapytań to 40.001. Co tyle zapytań generuje, skoro my zrobiliśmy jedno zapytanie w metodzie findAll()?

W procesie debuggingu wychodzi na to, iż Doctrine wysyła każdorazowo, osobno, zapytania o translacje produktu, opcje, warianty i zdjęcia. Dla każdego produktu z osobna.

Czym jest Doctrine Lazy Loading?

Czy zastanawialiście się kiedyś, dlaczego encje Doctrine nie mogą być klasami finalnymi? Jest tak dlatego, ponieważ do każdej encji Doctrine tworzy, w sposób zautomatyzowany, dodatkową klasę twz. Proxy Class, która dziedziczy po encji.

Za dokumentacją Doctrine:

A proxy object is an object that is put in place or used instead of the real object. A proxy object can add behavior to the object being proxied without that object being aware of it. In ORM, proxy objects are used to realize several features but mainly for transparent lazy-loading.

Aby zrozumieć, czym jest Lazy Loading, należy, abyśmy przestudiowali sobie wygenerowany przed Doctrine fragment kodu:

// Generated proxy class public function isInStock(): bool { $this->__initializer__ && $this->__initializer__->__invoke($this, 'isInStock', []); return parent::isInStock(); }

oraz kod wykorzystywanego wyżej initializera:

// https://github.com/doctrine/orm/blob/2.14.x/lib/Doctrine/ORM/Proxy/ProxyFactory.php private function createInitializer(ClassMetadata $classMetadata, EntityPersister $entityPersister): Closure { $wakeupProxy = $classMetadata->getReflectionClass()->hasMethod('__wakeup'); return function (CommonProxy $proxy) use ($entityPersister, $classMetadata, $wakeupProxy): void { $initializer = $proxy->__getInitializer(); $cloner = $proxy->__getCloner(); $proxy->__setInitializer(null); $proxy->__setCloner(null); if ($proxy->__isInitialized()) { return; } $properties = $proxy->__getLazyProperties(); foreach ($properties as $propertyName => $property) { if (! isset($proxy->$propertyName)) { $proxy->$propertyName = $properties[$propertyName]; } } $proxy->__setInitialized(true); if ($wakeupProxy) { $proxy->__wakeup(); } $identifier = $classMetadata->getIdentifierValues($proxy); if ($entityPersister->loadById($identifier, $proxy) === null) { $proxy->__setInitializer($initializer); $proxy->__setCloner($cloner); $proxy->__setInitialized(false); throw EntityNotFoundException::fromClassNameAndIdentifier( $classMetadata->getName(), $this->identifierFlattener->flattenIdentifier($classMetadata, $identifier) ); } }; }

Z powyższych faktycznie wychodzi, iż kiedy ładujemy encję, to reszta, która jest przechowywana poza nią jest… dociągana z osobna. W końcu wyraźnie widzimy linijkę:

if ($entityPersister->loadById($identifier, $proxy) === null) { // ... }

W tej sytuacji, kiedy wołamy metodę findBy() repozytorium produktów, to Doctrine w rzeczywistości uzupełni danymi tylko encje produktów. Pozostałe wartości zostaną niestety… dociągnięte.

Po co nam Lazy Loading?

Niestety, w programowaniu, podobnie jak w dzisiejszym świecie, jako społeczność dążymy do uproszczeń. Nie dość, iż chcemy mieć wszystko na już, to jeszcze w prosty sposób, abyśmy się zbytnio nie namęczyli. Moim zdaniem Lazy Loading jest niejako odpowiedzią na ten trend. Nie musimy pamiętać o aktualizowaniu zapytań. W ogóle nie musimy myśleć o zapytaniach. NIestety, przez podobne rozwiązania, coraz częściej mentalnie bliżej mi do Doctrine DBAL niż Doctrine ORM.

PS. Czy też widzicie, iż taki Lazy Loading nie jest DDD-friendly?

Pobierajmy WSZYSTKO, czego potrzebujemy NA RAZ (2)

Najprostszym rozwiązaniem tego problemu jest oczywiście utworzenie nowej metody w repozytorium i zaciągnięcie wszystkiego w jednym zapytaniu poprzez Query Builder, joiny oraz odpowiednie selecty. To my, jako programiści, powinniśmy dbać o to, aby Doctrine nie robiło głupot po drodze.

Jeżeli następnym razem nasz PM przyjdzie do nas z zadaniem na dorzucenie pola do naszej XMLki, to niech zaświeci nam się lampka w głowie – czy repozytorium zwraca nam to pole? o ile nie zwraca – to zróbmy wszystko, aby to w tym miejscu zostało pobrane.

(1) – Jest to praktyka, z którą w żaden sposób się nie zgadzam. Wykorzystanie jej we wpisie jest jedynie formą koloryzowania historyjki.

(2) – Oczywiście wszędzie tam, gdzie to może dać wartość

Idź do oryginalnego materiału