Ostatnio zajmowałem się tematem zapisywania aktywności użytkowników. w uproszczeniu chodzi o to, iż potrzebowałem zapisać, a następnie wyświetlić to, co użytkownik robił na stronie. Miały to być zmiany statusu zamówienia, jego ceny, czy modyfikacja liczby produktów. W tym wpisie pokażę, jak podszedłem do tematu w aplikacji opartej na Symfony i Doctrine.
Jak śledzić zmiany w encjach
W Symfony mamy wiele możliwości na śledzenie zmian w encjach. Jednym z rozwiązań jest, np. sonata-project/entity-audit-bundle, który tworzy bliźniaczą tabelę do tabeli, w której przechowywane są dane encji. Jest to z pewnością wygodne rozwiązanie do prostego śledzenia zmian. Nie da się go jednak zbytnio dostosować do własnych wymagań.
W moim przypadku miałem w przystępny sposób wyświetlać użytkownikom, co zmieniło się w zamówieniu. Przy czym mieli oni być informowani tylko o niektórych zmianach. Postanowiłem podejść do tego, tworząc własne rozwiązanie. Doctrine oferuje listenery/subscribery, które odpalane są w momencie zapisu danych do bazy. Wykorzystanie tego mechanizmu pozwoliło mi na implementację rozwiązania.
Jak to działa w Doctrine
Encje mogą zawierać różne pola. Od typów prostych, dat, do różnego rodzaju relacji. W momencie, gdy wykonujemy operacje na encji, i wywołujemy $entityManager->flush(), Doctrine przechodzi po encjach i wyszukuje w nich zmian. Podczas tego procesu wywoływane są zdarzenia, na które możemy nasłuchiwać. Do tego celu służą:
-
Lifecycle callbacks - są to publiczne metody w klasie encji, które są wywoływane w momencie występowania poszczególnych zdarzeń. Należy pamiętać, aby dodać adnotację HasLifecycleCallbacks na początku encji. Co istotne, możemy zadeklarować kilka metod do obsługi tych samych zdarzeń.
#[ORM\Entity] #[ORM\HasLifecycleCallbacks] class Order { #[ORM\PrePersist] public function doSmth(): void { //... } #[ORM\PrePersist] public function doSmth2(): void { //... } } -
Lifecycle listeners i lifecycle subscribers - to osobne klasy z metodami obsługującymi poszczególne zdarzenia. Są wywoływane podczas zapisu wszystkich encji. Dzięki temu możemy w nich tworzyć interakcje pomiędzy zapisywanymi obiektami. Co więcej, niewątpliwą przewagą nad lifecycle callbacks jest możliwość użycia w nich innych serwisów. Z tego względu, iż wywoływane są na wszystkich encjach, ich wydajność jest mniejsza w porównaniu do poprzedniego rozwiązania. Przykładowy event subscriber wygląda tak:
use Doctrine\Common\EventSubscriber; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Events; class EntitySubscriber implements EventSubscriber { public function getSubscribedEvents(): array { return [ Events::preUpdate, Events::onFlush, ]; } public function preUpdate(PreUpdateEventArgs $args): void { $args->getEntity(); $args->getEntityChangeSet(); } public function onFlush(OnFlushEventArgs $args): void { $entityManager = $args->getEntityManager(); $unitOfWork = $entityManager->getUnitOfWork(); $scheduledInsertions = $unitOfWork->getScheduledEntityInsertions(); $scheduledUpdated = $unitOfWork->getScheduledEntityUpdates(); $scheduledDeletions = $unitOfWork->getScheduledEntityDeletions(); $scheduledCollectionUpdates = $unitOfWork->getScheduledCollectionUpdates(); $scheduledCollectionDeletions = $unitOfWork->getScheduledCollectionDeletions(); } } -
Entity listeners - są podobne do lifecycle listeners, z tą różnicą, iż są wywoływane dla encji konkretnej klasy. To z kolei sprawia, iż ich wydajność jest większa w porównaniu do powyższego rozwiązania. Przykładowy listener może wyglądać w ten sposób:
#[ORM\Entity] #[ORM\EntityListeners([OrderListener::class])] class Order { //... } use App\Entity\Order; use Doctrine\Persistence\Event\PreUpdateEventArgs; class OrderListener { public function preUpdate(Order $order, PreUpdateEventArgs $event): void { //do smth } }Zdarzenia
Powyżej opisałem, w jaki sposób można nasłuchiwać na poszczególne zdarzenia. Dobrze byłoby też wiedzieć, na jakie zdarzenia możemy nasłuchiwać. Jest ich dosyć sporo. Niektóre są jednak dostępne tylko w Lifecycle listeners i lifecycle subscribers. Należą do nich:
- prePersist - wywoływany jest przed zapisem zmian do bazy, w momencie wywołania metody persist na entity managerze.
- preRemove - wywoływany jest przed usunięciem encji, w momencie wywołania metody remove na entity managerze.
- preUpdate - wywoływany przed aktualizacją encji.
- preFlush - wywoływany jest na początku operacji flush.
- onFlush - w tym momencie zmiany we wszystkich zarządzanych encjach są przeliczone. Mamy tu dostęp do wszystkich encji, które będą dodane, zmodyfikowane lub usunięte, a także do zmodyfikowanych kolekcji. Ten event nie należy do lifecycle callback.
- postFlush - występuje po zakończeniu zapisu zmian. Ten event nie należy do lifecycle callback.
- postUpdate, postRemove, postPersist - wywoływane są analogicznie po zapisie zmian w bazie danych.
Kodujemy
W tej sekcji pokażę, co znajduje się w poszczególnych metodach nasłuchujących na zdarzenia. Użyję do tego Xdebuga.
Przygotowanie encji
Na potrzeby tego wpisu przygotowałem trzy encje: Order, Status i Product. Chcę śledzić zmiany w encji Order. Dodałem do niej pola, które są odpowiednikami typu prostego, daty, relacji do innej encji i kolekcji encji. Wygląda to następująco:
#[ORM\Entity] class Order { #[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\Column(type: 'integer')] private ?int $id; #[ORM\Column(type: 'integer', nullable: false)] private int $totalPrice; #[ORM\Column(type: 'date', nullable: true)] private ?\DateTime $deliveryDate; #[ORM\ManyToOne(targetEntity: Status::class)] #[ORM\JoinColumn(name: 'status_id', referencedColumnName: 'id')] private Status $status; #[ORM\ManyToMany(targetEntity: Product::class)] #[ORM\JoinTable(name: 'order_products')] #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id')] #[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id')] private Collection $products; } #[ORM\Entity] class Product { #[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\Column(type: 'integer')] private ?int $id; #[ORM\Column(type: 'string', nullable: false)] private string $name; #[ORM\Column(type: 'integer', nullable: false)] private int $price; } #[ORM\Entity] class Status { #[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\Column(type: 'integer')] private ?int $id; #[ORM\Column(type: 'string', nullable: false)] private string $name;Śledzenie zmian
Podczas zmiany wybranych wartości, dodawania czy usuwania zamówienia, wywoływane są odpowiednie eventy. Do ich nasłuchiwania użyję entity listenera. Aby to zrobić, muszę w encji Order dodać adnotację, która przypisze odpowiedniego listenera do tej encji.
#[ORM\Entity] #[ORM\EntityListeners([OrderListener::class])] class Order { //... }Klasa listenera wygląda następująco:
use App\Entity\Order; use Doctrine\ORM\Event\LifecycleEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs; class OrderListener { public function postPersist(Order $order, LifecycleEventArgs $args): void { //... } public function preUpdate(Order $order, PreUpdateEventArgs $args): void { $changeSet = $args->getEntityChangeSet(); } public function postUpdate(Order $order, LifecycleEventArgs $args): void { $em = $args->getEntityManager(); $uow = $em->getUnitOfWork(); $changeSet = $uow->getEntityChangeSet($order); } public function preRemove(Order $order, LifecycleEventArgs $args): void { //... } public function postRemove(Order $order, LifecycleEventArgs $args): void { //... } }Z zamówieniem można zrobić trzy rzeczy: dodać, zmodyfikować lub usunąć.
- w przypadku dodawania nowego zamówienia interesujące mnie dane mogę znaleźć, nasłuchując na event postPersist. Dodawane zamówienie nie będzie posiadało zmian, ale swój pierwotny stan. Mogę wówczas, np. zapisać, kiedy zamówienie zostało stworzone i z jakimi produktami.
- modyfikując zamówienie, dostęp do zmienionych pól będzie dostępny w metodzie nasłuchującej na event preUpdate (lub onFlush, ale w przypadku entity listenerów nie jest on obsługiwany). Co ważne, $args->getEntityChangeSet() nie zwróci nam informacji o zmodyfikowanych kolekcjach. Musimy sami sprawdzić, co się w nich zmieniło, ale o tym później. W przypadku, gdybyśmy chcieli wyciągnąć zmiany w metodzie postUpdate, należy do tego celu użyć UnitOfWork, jak pokazano w przykładzie.
- Jeśli zajdzie potrzeba usunięcia zamówienia, a będziemy potrzebować jego identyfikatora, wówczas lepiej skorelować w jakikolwiek sposób identyfikator tej encji z nią samą w metodzie preRemove. Dlaczego? W metodzie postRemove encja nie będzie już posiadała identyfikatora.
Nasłuchując na zmiany w encjach, trzeba zwrócić uwagę na dwa typy zmian. Zmiany w datach i relacjach.
-
W przypadku zmiany dat, gdy pole jest typu date, przechowujemy tylko datę, bez czasu. Modyfikując takie pole, często wrzucamy tam obiekt DateTime z czasem. Wówczas, choćby jeżeli data będzie ta sama, Doctrine i tak wykryje zmianę na tym polu, ponieważ różni się czas. Warto wtedy użyć, np. metody format('Y-m-d'), aby sprawdzić, czy data na pewno się zmieniła.
-
Kolekcje w encji wyrażane są poprzez interfejs Collection. Podczas tworzenia encji, kolekcja taka inicjowana jest najczęściej obiektem klasy ArrayCollection. Jest to klasa, która przechowuje wszystkie elementy kolekcji, oraz umożliwia wykonywanie na nich różnych operacji, takich jak np. dodawanie/usuwanie elementów, filtrowanie itp. W momencie, gdy encję zapiszemy lub pobierzemy ją z bazy, kolekcja nie jest już instancją klasy ArrayCollection, ale PersistentCollection. PersistentCollection umożliwia śledzenie zmian w kolekcji. Ma ona zapisany początkowy stan we adekwatności snapshot. Aktualne elementy kolekcji znajdują się pod adekwatnością collection. Klasa ta ma też metodę isDirty, która mówi o tym, czy coś w kolekcji się zmieniło. Dobrze jest jej użyć, aby niepotrzebnie nie inicjalizować kolekcji, jeżeli wcześniej nie była modyfikowana.
Składamy zamówienie
Załóżmy, iż mamy katalog produktów, w skład których wchodzi smartphone oraz telewizor. Będziemy też próbować zmienić status zamówienia z New na Collecting.
$productSmartphone = $productRepository->findOneBy(['name' => 'Smartphone']); //1000PLN $productTv = $productRepository->findOneBy(['name' => 'Tv']); //1500PLN $statusNew = $statusRepository->findOneBy(['name' => 'New']); $statusCollecting = $statusRepository->findOneBy(['name' => 'Collecting']);Dodajemy zamówienie
Dodajemy zamówienie w statusie New ze smartphonem:
$order = new Order(); $order ->setStatus($statusNew) ->setDeliveryDate(new \DateTime()) ->addProduct($productSmartphone) ->calculatePrice(); $this->entityManager->persist($order); $this->entityManager->flush();Po wywołaniu na entityManagerze metody persist, wywołana została metoda prePersist z event listenera. Jak widać na poniższym screenshocie, encja nie posiada jeszcze identyfikatora.
Po wywołaniu metody flush wywołana zostanie metoda postPersist w listenerze. Obiekt zamówienia będzie posiadał identyfikator, ponieważ został już zapisany w bazie danych. Klasa reprezentująca kolekcję zmieniła się z ArrayCollection na PersistentCollection.
Modyfikujemy zamówienie
Dodajmy do zamówienia kolejny produkt, zmieńmy jego status, przeliczmy cenę, a także ustawmy inną datę dostawy. jeżeli taki obiekt pobralibyśmy z bazy, miałby on datę z czasem 00:00:00. Tu podajemy datę z aktualną godziną i to spowoduje, iż Doctrine również wykryje zmianę na tym polu.
$order ->addProduct($productTv) ->setStatus($statusCollecting) ->setDeliveryDate(new \DateTime()) ->calculatePrice(); $this->entityManager->flush();W metodzie preUpdate, parametr $args będzie miał następującą wartość:
Nie widać tu jednak zmian w kolekcji. Można się do nich dostać przeglądając samą kolekcję - $order->getProducts() - zarówno w metodzie preUpdate, jak i postUpdate
Usuwamy zamówienie
Na koniec usuniemy złożone zamówienie. W tym celu trzeba wywołać poniższy kod:
$this->entityManager->remove($order); $this->entityManager->flush($order);W metodzie preRemove encja wygląda tak:
A w metodzie postRemove już tak:
Inny sposób
Dodatkowo pokażę jak moglibyśmy odczytać te zmiany nie w Entity Listenerze, ale w Event Subscriberze. Jak wspomniałem wyżej, Event Subscriber obsługuje dodatkowe zdarzenia, w tym onFlush. Mamy w nim dostęp do wszystkich encji, które będą dotknięte zmianą. Możemy dostać się do nich poprzez wywołanie poniższych metod:
public function onFlush(OnFlushEventArgs $args): void { $entityManager = $args->getEntityManager(); $unitOfWork = $entityManager->getUnitOfWork(); $scheduledInsertions = $unitOfWork->getScheduledEntityInsertions(); $scheduledUpdated = $unitOfWork->getScheduledEntityUpdates(); $scheduledDeletions = $unitOfWork->getScheduledEntityDeletions(); $scheduledCollectionUpdates = $unitOfWork->getScheduledCollectionUpdates(); $scheduledCollectionDeletions = $unitOfWork->getScheduledCollectionDeletions(); }Stwórzymy sobie trzy zamówienia:
$order1 = new Order(); $order1 ->setStatus($statusNew) ->setDeliveryDate(new \DateTime()) ->addProduct($productSmartphone) ->calculatePrice(); $this->entityManager->persist($order1); $this->entityManager->flush(); $order2 = new Order(); $order2 ->setStatus($statusNew) ->setDeliveryDate(new \DateTime()) ->addProduct($productSmartphone) ->calculatePrice(); $this->entityManager->persist($order2); $this->entityManager->flush(); $order3 = new Order(); $order3 ->setStatus($statusNew) ->setDeliveryDate(new \DateTime()) ->addProduct($productSmartphone) ->calculatePrice(); $this->entityManager->persist($order3); $this->entityManager->remove($order1); $order2 ->addProduct($productTv) ->calculatePrice(); $this->entityManager->flush();Powyższy kod wygląda dość skomplikowanie. Skupmy się na tym co się stanie po ostatnim wywołaniu metody flush - usuniemy $order1, do $order2 dodamy nowy produkt i przeliczymy cenę, a $order3 będzie dopiero pierwszy raz zapisywany do bazy. Z UnitOfWork możemy wyciągnąć interesujące dane:
Dodatkowo, jeżeli chcemy podejrzeć zmiany, jakie wystąpiły w zamówieniach (w tym przypadku na $order2), możemy przeiterować tablicę $scheduledUpdated
foreach ($scheduledUpdated as $entity) { $unitOfWork->getEntityChangeSet($entity); }Wynik będzie następujący:
Podsumowanie
Doctrine w dość prosty sposób umożliwia dostęp do informacji o tym, co zmieniło się w encji. Naszym zadaniem jest stworzyć odpowiednie listenery i podpiąć się pod wybrane zdarzenia. To, co zrobimy z tymi danymi, zależy już tylko od nas. Możemy wrzucić je na kolejkę i przetworzyć w innym procesie, zapisując je do stworzonego do tego celu dziennika zdarzeń. Unikałbym natomiast bezpośredniego flushowania w tych listenerach, ponieważ może to spowodować chociażby wpadnięcie w nieskończoną pętlę.