Blog
Doctrine UnitOfWork — kiedy encja zachowuje się inaczej, niż oczekujemy
Encja zmienia się w PHP, ale baza pozostaje bez zmian. To często nie problem settera, lecz rozjazd między obiektem a aktualnym kontekstem EntityManagera.
Błąd, który wygląda niemożliwie
Zamówienie pokazuje status opłaconego, flush() kończy się bez błędu, a rekord w bazie pozostaje bez zmian. Jedna z możliwych przyczyn jest prosta: obiekt PHP przeżył kontekst persystencji, który śledził jego zmiany.
Poniższy przykład zakłada istniejące, nieopłacone zamówienie, zwykłe mapowane pole statusu oraz repozytorium korzystające z tego samego otwartego EntityManagera co reszta kodu. markAsPaid() zmienia to pole; nie wykonuje własnego SQL ani nie rejestruje obiektu ponownie.
$order = $orderRepository->find($id);
if ($order === null) {
throw new OrderNotFound($id);
}
$entityManager->clear();
$order->markAsPaid();
$entityManager->flush();OrderNotFound to wyjątek projektu, nie klasa Doctrine. Pominięte encje i repozytorium są ilustracją, a nie opisem zweryfikowanego incydentu u klienta GiSoft.
clear() odłącza załadowane zamówienie. Nie usuwa obiektu PHP i nie przywraca wartości jego właściwości, dlatego późniejsze $order->getStatus() może zwrócić paid. Następny flush() nie zaplanuje jednak aktualizacji tej odłączonej instancji. Inne operacje zarejestrowane po clear() nadal mogłyby zostać zapisane.
W tym przykładzie stan obiektu i jego udział w zapisie rozchodzą się:
Operacja Status PHP Stan encji Zapis statusu
find() unpaid MANAGED —
clear() unpaid DETACHED —
markAsPaid() paid DETACHED —
flush() paid DETACHED brak UPDATENa początek warto sprawdzić:
dump($order->getStatus());
dump($entityManager->contains($order));Wyniki to tutaj paid i false. Druga wartość oznacza, że ten EntityManager nie zarządza obecnie instancją w rozumieniu contains(). Sama w sobie nie dowodzi stanu DETACHED: false może też zwrócić nowy, niezarejestrowany obiekt lub encja przeznaczona do usunięcia. Instancja zarządzana przez inny EntityManager również nie staje się automatycznie zarządzana tutaj. W przykładzie znamy przyczynę odłączenia, ponieważ wiemy, jak obiekt został załadowany i kiedy wywołano clear().
Co śledzi UnitOfWork
UnitOfWork przechowuje informacje EntityManagera o instancjach encji: ich tożsamości, śledzonych wartościach oraz oczekujących operacjach na encjach i kolekcjach. Przy domyślnej polityce DEFERRED_IMPLICIT porównuje mapowane wartości podczas flush() i na tej podstawie ustala potrzebne zapisy. Nie przeszukuje wszystkich zmiennych PHP ani właściwości serwisów.
Sposób wykrywania zmian można skonfigurować. Przy DEFERRED_EXPLICIT zarządzane encje trzeba wskazać do sprawdzenia przez persist() lub odpowiednią kaskadę. Znaczenie mają także mapowania tylko do odczytu, strona właścicielska relacji i rodzaj zmienianej wartości. Stan zarządzany jest potrzebny do zwykłej śledzonej aktualizacji, ale nie oznacza, że każde wywołanie metody spowoduje UPDATE.
Cztery stany opisują instancję względem kontekstu persystencji:
Stan Znaczenie w tym kontekście
NEW Nowa encja, jeszcze niezarządzana;
jej INSERT nie jest jeszcze zaplanowany.
MANAGED Encja związana z tym EntityManagerem, nie REMOVED.
Śledzenie zależy od mapowania i polityki zmian.
DETACHED Encja reprezentująca utrwaloną tożsamość,
poza zarządzaniem tego EntityManagera.
REMOVED Istniejąca encja przeznaczona do usunięcia
podczas flush().Nowo zarejestrowana przez persist() encja może być zarządzana jeszcze przed wykonaniem INSERT. Z kolei sam identyfikator nie dowodzi, że konkretny EntityManager zarządza obiektem. Stan cyklu życia, wartości PHP i zawartość bazy są powiązane, lecz nie są tym samym.
clear() i mapa tożsamości
clear() opróżnia UnitOfWork EntityManagera, w tym oczekujące zmiany. Niezapisana praca przestaje być zaplanowana do utrwalenia. Istniejące referencje PHP pozostają dostępne. Czyszczenie nie zatwierdza ani nie wycofuje transakcji bazy i nie zamyka EntityManagera: może on potem załadować kolejne zarządzane obiekty.
Mapa tożsamości, czyli Identity Map, wyjaśnia znaczenie zachowanej referencji. Załóżmy, że użytkownik 42 istnieje, a między tymi wywołaniami nie następuje odłączenie, czyszczenie ani wymiana EntityManagera:
$userA = $entityManager->find(User::class, 42);
$userB = $entityManager->find(User::class, 42);$userA === $userB ma wtedy wartość true. W tym kontekście Doctrine wiąże trwałą tożsamość z jedną zarządzaną instancją; drugie find() nie musi ponownie czytać bazy. Po clear() odczyt tego samego identyfikatora może zwrócić inną instancję. Poprzedni obiekt nie staje się zarządzany tylko dlatego, że ma zgodny identyfikator.
Podobna niejasność może pojawić się przy imporcie:
$userFromDatabase = $userRepository->find(42);
$userFromPayload = User::fromImportedPayload($payload);User::fromImportedPayload() jest hipotetyczną fabryką projektu. Na potrzeby przykładu przyjmijmy, że tworzy osobny obiekt ze zwalidowanych danych opisujących użytkownika 42. Sama nazwa metody nie dowodzi ani przypisania identyfikatora, ani stanu Doctrine; zależą one od implementacji i mapowania. Nawet zgodny identyfikator nie czyni obiektu automatycznie instancją zwróconą przez repozytorium. Dozwolone zmiany z importu należy zastosować do encji zarządzanej, zamiast traktować dowolny odtworzony obiekt jak polecenie aktualizacji bazy.
persist(), flush() i właściwa poprawka
Dla rzeczywiście nowej, mapowanej encji ta znana para ma dwie różne role:
$entityManager->persist($entity);
$entityManager->flush();persist() obejmuje nową instancję zarządzaniem i planuje dodanie rekordu; flush() wykonuje oczekujące zapisy. Nie oznacza to, że persist() nigdy nie komunikuje się z bazą: generowanie identyfikatora lub callbacki mogą wykonać pracę jeszcze przed INSERT.
Istniejąca encja zarządzana przy domyślnej polityce zwykle nie wymaga ponownego persist() po zmianie pola. Opisana wcześniej jawna polityka śledzenia jest osobnym przypadkiem. persist() nie jest natomiast ogólnym sposobem ponownego przyłączenia encji odłączonej. Doctrine może potraktować nieznany obiekt jako nowy i podjąć próbę dodania rekordu lub zgłosić błąd zamiast oczekiwanej aktualizacji.
Starsze przykłady ORM 2 korzystają czasem z merge(), które kopiowało stan do instancji zarządzanej, a nie po prostu przyłączało oryginalny obiekt. Obsługę scalania usunięto w ORM 3; w ORM 3.6 ta metoda nie jest dostępna. To nie jest aktualna recepta na naprawę.
W przypadku odłączonego zamówienia użyj repozytorium związanego z obecnym, otwartym EntityManagerem. Załaduj zamówienie i wykonaj zamierzoną operację na otrzymanej instancji:
$order = $orderRepository->find($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
$order->markAsPaid();
$entityManager->flush();Nie oznacza to kopiowania wszystkich właściwości ze starego obiektu. Trzeba ponownie ocenić reguły operacji, uprawnienia i oczekiwaną wersję, jeśli są istotne. Przy współbieżnych zmianach potrzebne są mechanizmy aplikacji, na przykład blokada optymistyczna. find() może wykorzystać już zarządzany obiekt albo skonfigurowany cache; nie gwarantuje w każdej sytuacji najnowszych zatwierdzonych wartości.
Fragment zakłada, że za ten flush() odpowiada kod wywołujący i że po drodze nie ma czyszczenia ani wymiany EntityManagera. Załadowanie przez jeden EntityManager i zapis przez poprzedni pozostawiłoby sedno błędu bez zmian.
Messenger, zachowane encje i serializacja
Typowe żądanie PHP-FPM zwalnia swoje lokalne obiekty po zakończeniu wykonania. Długodziałający proces Messengera może natomiast korzystać z tych samych serwisów przy wielu wiadomościach. Właściwość serwisu może więc zachować encję po wyczyszczeniu lub wymianie kontekstu, który ją załadował:
final class CustomerContext
{
private ?Customer $customer = null;
public function remember(Customer $customer): void
{
$this->customer = $customer;
}
public function customer(): ?Customer
{
return $this->customer;
}
}To celowo przykład ryzykownego przechowywania stanu; nie zawiera obsługi resetu. Jeżeli CustomerContext przetrwa zmianę kontekstu Doctrine, customer() może zwrócić starą instancję. Reset lub odtworzenie samego serwisu może usunąć tę konkretną drogę zachowania referencji.
Symfony udostępnia mechanizmy resetowania serwisów, ale ich działanie zależy od wersji Symfony i DoctrineBundle, konfiguracji resetu, opcji workera oraz własnych serwisów aplikacji. Nie każdy worker czyści lub wymienia każdy EntityManager identycznie po każdej wiadomości. Jeśli serwis musi przechowywać stan, jego czyszczenie powinno współpracować ze skonfigurowanym cyklem resetu. Warto sprawdzić to na kilku kolejnych wiadomościach.
Przez granicę wiadomości zwykle lepiej przenosić identyfikatory albo celowo zaprojektowane DTO niż żywe encje ORM. Poniższe wywołania pokazują dwa alternatywne projekty komunikatu, nie dwie sygnatury konstruktora jednej implementacji.
Wiadomość zawierająca encję:
new GenerateInvoiceMessage($invoice);Wiadomość zawierająca identyfikator:
new GenerateInvoiceMessage($invoiceId);W drugim wariancie handler może odczytać fakturę przez swoje aktualne repozytorium:
$invoice = $invoiceRepository->find($message->invoiceId);
if ($invoice === null) {
throw new InvoiceNotFound($message->invoiceId);
}GenerateInvoiceMessage, jego właściwość invoiceId i InvoiceNotFound są elementami przykładu należącymi do projektu. Handler nadal musi obsłużyć usunięcie faktury, autoryzację i zmianę stanu biznesowego od chwili wysłania. Samo przekazanie ID nie zapewnia bezpiecznego ponowienia ani ochrony przed powtórzonym skutkiem zewnętrznym.
Serializacja encji do kolejki, sesji lub cache nie przenosi jej przynależności do EntityManagera. Odtworzenie obiektu nie rejestruje go w odbierającym UnitOfWork. Nie znaczy to jednak, że samo synchroniczne utworzenie wiadomości odłącza jej argument albo że serializacja odłącza oryginalną referencję pozostającą u nadawcy.
Zachowanie leniwego ładowania zależy również od wersji ORM, mapowania, mechanizmu proxy i serializera. Niezainicjalizowana relacja może być niedostępna po przesłaniu albo nadal używać zachowanego mechanizmu ładowania po clear(). Wcześniej załadowane wartości mogą pozostać czytelne. Ani udany odczyt, ani wyjątek nie dowodzą, że encja jest zarządzana. Potrzebne dane należy ładować w kontekście bieżącej operacji, nie liczyć na to, że dostęp do relacji naprawi stan odłączonego obiektu. Ustawienie wszystkich relacji jako eager nie przyłączy encji ponownie, a może pobrać niepotrzebne dane.
Kto odpowiada za transakcję?
Wyobraźmy sobie kontroler wywołujący cztery serwisy: A wykonuje flush, B czyści EntityManager, C zmienia wcześniej załadowane zamówienie, a D ponownie wykonuje flush. Problemem nie jest liczba serwisów, lecz brak uzgodnionej granicy persystencji i transakcji dla ich wspólnej pracy.
Warto rozdzielić cztery rzeczy: czas życia EntityManagera, śledzenie w UnitOfWork, wykonanie SQL oraz końcowe zatwierdzenie lub wycofanie transakcji bazy. Bez jawnej transakcji zewnętrznej flush obejmujący zapisy zwykle korzysta z niejawnej obsługi transakcji Doctrine. Wewnątrz jawnej transakcji poprawny flush() nie oznacza jeszcze jej końcowego zatwierdzenia.
Za tę granicę może odpowiadać serwis aplikacyjny, handler komendy lub skonfigurowane middleware transakcyjne. doctrine_transaction w Symfony może wykonać flush i commit po zakończeniu handlerów. Bezpośrednie wywołanie handlera w teście omija to middleware. Przed dodaniem kolejnego flush() w zagnieżdżonej usłudze trzeba ustalić, kto rzeczywiście odpowiada za zapis.
Rollback nie przywraca wcześniejszych wartości właściwości PHP. Niektóre błędy podczas flush zamykają EntityManager, a clear() go nie otwiera. Wznowienie pracy wymaga mechanizmu resetu przewidzianego w aplikacji i porzucenia starych referencji, nie ślepego ponowienia na zamkniętym EntityManagerze. Kilka granic transakcyjnych ma sens w przetwarzaniu partiami, o ile częściowy postęp i sposób wznowienia są świadomym wyborem.
Przetwarzanie partiami bez starych obiektów
Import może okresowo wykonywać flush i clear, aby ograniczyć liczbę zarządzanych obiektów. To szkic sterowania przebiegiem: komentarz zastępuje pominiętą walidację wiersza oraz tworzenie lub aktualizowanie encji, w tym persist() dla nowych obiektów.
$processed = 0;
foreach ($rows as $row) {
// Walidacja wiersza; tworzenie lub aktualizacja encji.
if (++$processed % 100 === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();Licznik nie zależy od kluczy wejściowej tablicy. Końcowy flush zapisuje niepełną ostatnią partię, a clear zwalnia referencje UnitOfWork. Kod kolejnego wiersza i powiązane serwisy nie mogą ponownie używać encji zachowanych sprzed czyszczenia.
Dla istniejących zamówień można przekazywać identyfikatory i ładować obiekty w aktywnym kontekście:
if ($batchSize < 1) {
throw new \InvalidArgumentException('batchSize >= 1');
}
$processed = 0;
foreach ($orderIds as $orderId) {
$order = $orderRepository->find($orderId);
if ($order === null) {
continue;
}
$order->recalculateTotals();
if (++$processed % $batchSize === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();$batchSize jest liczbą całkowitą, a $orderIds zbiorem iterowalnym poprawnych identyfikatorów. Przykład celowo pomija brakujące zamówienia; inny proces może wymagać zapisania takiego przypadku lub przerwania pracy. Licznik obejmuje przetworzone zamówienia, nie pozycje wejścia, więc brak rekordu nie omija granicy partii. Końcowy flush nie szkodzi, jeśli ostatnią partię już zapisano i nie ma nowej pracy.
Oba przykłady zakładają wydzielony, otwarty EntityManager, zwykłe niejawne wykrywanie zmian oraz brak zewnętrznej transakcji lub middleware zmieniającego te granice. Każda pomyślnie zapisana partia może więc zostać zatwierdzona osobno; późniejszy błąd nie wycofa wcześniejszych commitów. Wyjątki zatrzymują te szkice. Punkty wznowienia, ograniczone ponowienia, idempotencję i odzyskiwanie po błędach pominięto — kod ich nie zapewnia.
Czyszczenie ogranicza stan zachowany przez Doctrine, niekoniecznie całą pamięć procesu. Tablica wejściowa, właściwość serwisu, logger lub ostatnia zmienna $order nadal mogą trzymać obiekty. Przy dużych wejściach warto rozważyć strumieniowe odczytywanie identyfikatorów i mierzyć pamięć, zamiast usuwać clear() tylko po to, by ukryć odłączenie.
Osobno sprawdź kontekst, śledzenie i transakcję
Gdy pierwszy wynik contains() nie wyjaśnia przyczyny, zbieraj dowody na różnych poziomach. To osobne kontrole, nie łańcuch, w którym powodzenie jednego kroku dowodzi następnego:
- Ustal, które repozytorium załadowało instancję i który EntityManager wykonuje flush. Prześledź clear, close, reset i referencje zachowane w serwisach.
isOpen()mówi, czy EntityManager jest otwarty, nie czy zarządza tym obiektem. - Sprawdź mapowane pole, politykę śledzenia, tryb tylko do odczytu i stronę właścicielską zmienianych relacji. Listenery cyklu życia mogą zmienić lub cofnąć wartość.
- Obserwuj właściwy SQL przez profiler lub middleware DBAL zgodne z używanymi wersjami. Odróżnij brak zaplanowanej aktualizacji od wykonanego zapisu, po którym nastąpił rollback lub wyjątek.
- Potwierdź wynik transakcji zewnętrznej oraz bazę, organizację i połączenie odczytu, które sprawdzasz. Cache, opóźniona replika lub inny zapisujący proces mogą tłumaczyć odmienną wartość.
Jeśli potrzebne jest dokładniejsze rozróżnienie stanu, ORM 3.6 udostępnia takie API diagnostyczne:
$state = $entityManager->getUnitOfWork()->getEntityState($order);Wynik odpowiada stałej Doctrine\ORM\UnitOfWork::STATE_*. Dla nieznanej instancji odróżnienie nowej encji od odłączonej może wymagać odczytu z bazy. To pomocnicza informacja do zestawienia z historią obiektu, nie reguła biznesowa.
Inspekcja changesetu, czyli zestawu wykrytych zmian, zależy od momentu wykonania. Przed normalnym obliczeniem może być pusty, a po poprawnym flush już wyczyszczony. Pusty changeset sam nie dowodzi więc odłączenia ani nieudanego zapisu. Nie należy zmieniać wewnętrznego stanu UnitOfWork tylko po to, by diagnostyka pokazała oczekiwany wynik.
Testuj efekt w bazie, nie tylko obiekt
Test jednostkowy może potwierdzić zmianę stanu PHP nieopłaconego zamówienia:
$order->markAsPaid();
expect($order->isPaid())->toBeTrue();Nie mówi jednak nic o zapisie. Test integracyjny potrzebuje utrwalonego, nieopłaconego zamówienia testowego oraz ponownego odczytu, który nie zwróci tego samego obiektu:
$orderService->markAsPaid($orderId);
$entityManager->flush();
$entityManager->clear();
$reloadedOrder = $orderRepository->find($orderId);
expect($reloadedOrder?->isPaid())->toBeTrue();Tutaj OrderService::markAsPaid($orderId) zmienia zarządzane zamówienie, lecz nie wykonuje flush ani clear i nie odpowiada za transakcję. Test świadomie wykonuje flush. Jeśli prawdziwy serwis sam zatwierdza zapis, należy testować ten kontrakt bez zbędnego kolejnego wywołania. Jeśli odpowiada za to middleware, test powinien użyć magistrali lub jawnie odtworzyć jej granicę transakcyjną.
Repozytorium, serwis i test muszą korzystać z właściwego EntityManagera i bazy. Clear usuwa możliwość ponownego użycia obiektu z mapy tożsamości, ale samo nie wyłącza cache wyników ani drugiego poziomu. Przy kontrolowanej konfiguracji tych cache ponowny odczyt może zweryfikować stan widoczny w transakcji testu. Test objęty zewnętrzną transakcją wycofywaną na końcu nie dowodzi trwałości produkcyjnego commitu. Do tego potrzebny jest odpowiednio zaprojektowany test zatwierdzonego zapisu i niezależny odczyt z bazy stanowiącej źródło prawdy.
Powtórzony przykład odłączonego zamówienia jest bardziej użyteczny jako test regresji niż kolejny listing bez nowych obserwacji. Ponownie zaczynamy od utrwalonego, nieopłaconego zamówienia:
$order = $orderRepository->find($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
$entityManager->clear();
$order->markAsPaid();
$entityManager->flush();
$reloadedOrder = $orderRepository->find($orderId);
expect($order->isPaid())->toBeTrue()
->and($entityManager->contains($order))->toBeFalse()
->and($reloadedOrder?->isPaid())->toBeFalse();Asercje rozdzielają status opłacenia starego obiektu od nieopłaconego stanu odczytanego do innej instancji. To fragmenty testów w stylu Pest: pominięto przygotowanie danych, izolację testów, mapowania i konfigurację serwisów. Nie są samodzielnymi testami gotowymi do uruchomienia.
Przydatne informacje z produkcji
Zamiast całych encji zapisuj wybrane metadane: identyfikator korelacji, odpowiednio chroniony identyfikator encji, typ komendy lub wiadomości, liczbę ponowień oraz zaobserwowane zdarzenia clear/reset i wynik transakcji. Czas SQL, pamięć workera i informacje o nieudanych wiadomościach pomagają tam, gdzie skonfigurowano ich pomiar. Sam powrót z handlera nie dowodzi udanego commitu.
Unikaj danych uwierzytelniających, danych osobowych i wrażliwej treści wiadomości. Nawet identyfikatory mogą wymagać maskowania lub ograniczenia dostępu. W jednym procesie tożsamość obiektu pomaga rozróżniać referencje, lecz identyfikator obiektu PHP nie jest trwałym identyfikatorem między procesami.
Punkt wyjścia jest konkretny: który EntityManager, jeśli jakikolwiek, zarządza teraz tą instancją? Dopiero potem sprawdź, czy zmiana jest śledzona i czy jej transakcja się zakończyła. Tak można odróżnić zmianę w pamięci od zapisanego wyniku biznesowego bez dodawania przypadkowych persist() i kolejnych wywołań flush.
Dokumentacja techniczna
- Doctrine ORM: cykl życia encji i mapa tożsamości — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/working-with-objects.html
- Doctrine ORM: polityki śledzenia zmian — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/change-tracking-policies.html
- Doctrine ORM: transakcje i obsługa błędów — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/transactions-and-concurrency.html
- Symfony 7.4: cykl życia workera Messenger — https://symfony.com/doc/7.4/messenger.html
