Niezależnie od tego, z którego frameworka korzystamy, zawsze powinniśmy promować własną refleksję ponad wszystko. Nawet pracując w Symfony, który uchodzi za najlepsze narzędzie w swojej kategorii, jesteśmy w stanie stworzyć niefajny kod. Dziś poruszymy temat reprezentatywnego przykładu, który potwierdza tą tezę.

Piekło dostępności wszystkiego wszędzie

Kiedy uczymy się pracy na frameworku, uczymy się wszystkich jego elementów. Poznajemy, czym są kontrolery, do czego służy model aplikacji. Renderujemy pierwsze widoki. Krok po kroku budujemy pierwsze aplikacje, które rosną w zastraszającym tempie. Do tego dochodzi oczywiście konstrukcja serwisów, oraz oczywiście wykorzystywanie nowych serwisów w tych starszych. Budujemy sobie graf zależności między serwisami, nie przejmując się zbytnio tym, co z czym się komunikuje.

Załóżmy, że w taki sposób stworzyliśmy funkcjonalność dodawania produktów do systemu. Renderujemy formularz, uzupełniamy wszystkie pola, załączamy zdjęcia produktu. Ponieważ nasz system jest złożony, to pod spodem realizujemy również takie procesy jak wrzutka produktu na magazyn (komunikacja przez API), definiowanie translacji oraz generowanie wariantów produktu. Mamy masę serwisów, które realizują pojedyncze zadania takie jak tworzenie wariantów, czy obsługa translacji. Do tego mamy jeden główny serwis, który przyjmuje klasę formularza produktu. Na pierwszy rzut oka – rozwiązanie idealne.

Po ukończeniu pierwszej produkcyjnej wersji systemu na pewno będziemy chcieli go wdrożyć. Super by było, abyśmy mogli przemigrować produkty z naszej poprzedniej, kulawo działającej aplikacji. Zapala nam się lampka w głowie: skoro mamy zrobione dodawanie produktu, to przecież możemy napisać skrypt konsolowy do importu. Przecież to tak na prawdę odczyt pliku w formacie CSV i wpuszczenie w pętli serwisu, który już istnieje. I w tym miejscu właśnie okazuje się, że tego nie da rady zrobić w tak prosty sposób, bo dostajemy na twarz masę dziwnych komunikatów o błędach.

Przykład numer jeden: FlashBag

Rozważmy jeden z serwisów, który znalazł się we wspomnianym wyżej kodzie:

<?php

declare(strict_types=1);

namespace App\Checker;

use App\Repository\SupportedLanguagesRepositoryInterface;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;

final class MissingTranslationsChecker implements MissingTranslationsCheckerInterface
{
    private SupportedLanguagesRepositoryInterface $supportedLanguagesRepository;

    private FlashBagInterface $flashBag;

    public function __construct(
        SupportedLanguagesRepositoryInterface $supportedLanguagesRepository,
        FlashBagInterface $flashBag
    ) {
        $this->supportedLanguagesRepository= $supportedLanguagesRepository;
        $this->flashBag = $flashBag;
    }

    public function check(array $productTranslations): bool
    {
        $allTranslations = $this->supportedLanguagesRepository->findAll();
        if (count($allLanguages) !== count($productTranslations)) {
            $this->flashBag->add('error', 'Proszę uzupełnić wszystkie brakujące tłumaczenia!');

            return false;
        }

        return true;
    }
}

Powyższego kodu tłumaczyć chyba nie trzeba. Wyjaśnić jednak należy to, że jest to jeden fragmentów, który wybuchł podczas importu produktów. Dlaczego to nie zadziałało?

Wytłumaczeniem powstałego problemu jest to, że FlashBag jest częścią mechanizmu sesji. Sesja z kolei jest czymś, co istnieje tylko i wyłącznie w kontekście żądania HTTP. Jeżeli nie korzystamy z tego kontekstu (a nie korzystamy, bo import produktu jest oparty o kontekst CLI), to wtedy powstaje problem.

Rozwiązaniem tego problemu jest oczywiście podmiana w miejscu wykorzystania FlashBaga na wyrzucenie wyjątku. Samego FlashBaga przenosimy do kontrolera, który podobnie jak sesje, działa tylko w kontekście żądania HTTP.

Odpowiadając na pytanie z tytułu: Tak, można korzystać z FlashBaga w serwisach, jednakże musimy być świadomi tego, że ten serwis nie będzie mógł być używany w każdym kontekście. Ponieważ zazwyczaj po napisaniu serwisu zapominamy o tego typu detalach, to sam nie rekomenduję tego typu podejścia.

Przykład numer dwa: RequestStack

Załóżmy, że rozwiązaliśmy problem z FlashBagiem. Uruchamiamy import i… bum. Dalej nie działa. Szukamy, szukamy i… znajdujemy poniższą klasę:

<?php

declare(strict_types=1);

namespace App\Factory;

use Symfony\Component\HttpFoundation\RequestStack;

final class ProductVariantFactory implements ProductVariantFactoryInterface
{
    private RequestStack $requestStack;

    private ProductVariantsFactoryGeneratorInterface $variantsGenerator;

    private EntityManagerInterface $entityManager;

    public function __construct(
        RequestStack $requestStack,
        ProductVariantsFactoryGeneratorInterface $variantsGenerator,
        EntityManagerInterface $entityManager
    ) {
        $this->requestStack = $requestStack;
        $this->variantsGenerator = $variantsGenerator;
        $this->entityManager = $entityManager;
    }

    public function create(ProductDTO $product): array
    {
        $currentRequest = $this->requestStack->getCurrentRequest();
        $isConfigurableProduct = $currentRequest->query->get('isConfigurable', false);

        if ($isConfigurableProduct) {
            $variants = $this->variantsGenerator->generateFromProperties($product->getProperties());
        } else {
            $variants = $this->variantsGenerator->generateSingleVariant($product);
        }

        $this->entityManager->flush();
        return $variants;
    }
}

W tym wypadku okazuje się, że pod zmienną $currentRequest mamy wartość null. Okazuje się, że znowu, gdzieś w zakopanym przez nas serwisie, korzystamy z czegoś, co działa tylko w kontekście żądania HTTP. A nasz import działa, przypomnijmy, w kontekście CLI.

Rozwiązaniem powyższej sytuacji będzie (ponwnie) przeniesienie kodu do kontrolera. Ewentualnie, żeby nie pchać wszystkiego do kontrolera, to możemy utworzyć specjalny serwis, którego odpowiedzialnością będzie zebranie wszystkich informacji z żądania HTTP i przekazanie ich do serwisu tworzącego produkt. Po stronie importu natomiast, wartość tą będziemy wyliczali na podstawie informacji zawartych w pliku CSV.

Przykład numer trzy: EntityManager::flush()

Okej. Problem RequestStacka rozwiązany. Uruchamiamy ponownie importer. Wydaje się, że działa. Jest jednak problem, bo działa… podejrzanie długo. A to przecież raptem 500 produktów. Kiedy dobrnęliśmy do końca importu, po stronie naszego systemu wszystko wygląda dobrze. Logujemy się do systemu zarządzania magazynem (osobna aplikacja), po czym łapiemy się za głowę. Wszystkie produkty mają zerowy stan magazynowy. Nieco zrezygnowani, ponownie przechodzimy do żmudnego procesu debugowania.

Tym razem z debuggingiem zeszło nam dwa dni (!). Problemem okazało się to, że w wielu miejscach w kodzie wywołaliśmy metodę flush() dostępną w klasie EntityManagera. W skrócie nasz proces tworzenia produktu wyglądał następująco:

  • Utworzyliśmy encję produktu
  • Utworzyliśmy translacje
  • Wygenerowaliśmy warianty
  • Zaktualizowaliśmy stan magazynowy
  • Uploadowaliśmy zdjęcia (i utworzyliśmy odpowiednie encje)

Każdy z tych kroków był realizowany przez osobny serwis, który na samym końcu wywoływał flusha. Jak możemy dowiedzieć się z dokumentacji Doctrine, każdorazowy flush to nowe zapytanie do bazy danych. Każde zapytanie to czas aplikacji, poświęcony na jego wysłanie, obsługę oraz odbiór informacji zwrotnej. Zatem tracimy znacznie na performance. W sytuacji, kiedy wypełniamy formularz z produktem, nie jest to widoczne. Kiedy jednak pracujemy przy imporcie produktów, to już możemy się mocno zdziwić.

Rozwiązaniem tego problemu będzie przeniesienie odpowiedzialności transakcyjności (czyli flusha) poza serwis tworzący produkt. Po stronie obsługi formularza możemy to zrobić w kontrolerze, albo jeżeli korzystamy z CQRS, to również w command handlerze. Co do kwestii wydajności skryptu importującego produkty, rozwiązaniem może być wywołanie flusha co pewną określoną ilość utworzeń produktu (tzw. batchowanie). W międzyczasie możemy zbierać informacje do logów o produktach, których z powodu np. walidacji nie mogliśmy utworzyć.

Hej, a co ze stanami magazynowymi?

Zostaje nam ostatni do rozwikłania problem: dlaczego stany magazynowe były wyzerowane? Przecież plik CSV zawierał wszystkie potrzebne informacje. Aby dojść do rozwiązania tego problemu, należało przestudiować dokumentację systemu magazynującego. Dokumentacja ta mówiła, że utworzyć stan magazynowy możemy tylko jeden raz. Następnie możemy go aktualizować dowolną liczbę razy.

Problem związany z wielokrotnymi flushami odbił nam się czkawką, ponieważ utworzenie stanu magazynowego oparliśmy o… listener na zdarzeniu postFlush. Zapomnieliśmy jednak, że tego flusha robiliśmy więcej, niż raz.

Z perspektywy aplikacji wyglądało to następująco:

  • Utworzyliśmy produkt z zerowym stanem magazynowym. Do systemu magazynowego poszło żądanie o utworzenie wpisu z zerowym stanem magazynowym.
  • Utworzyliśmy translacje. Do systemu magazynowego poszło żądanie o utworzenie wpisu z zerowym stanem magazynowym, które zostało zignorowane, ponieważ stan magazynowy możemy utworzyć tylko raz.
  • Wygenerowaliśmy warianty. Do systemu magazynowego poszło żądanie o utworzenie wpisu z zerowym stanem magazynowym, które zostało zignorowane.
  • Zaktualizowaliśmy stan magazynowy. Do systemu magazynowego poszło żądanie o utworzenie wpisu z zerowym stanem magazynowym, które zostało zignorowane.

Debugowanie tego typu błędów do najprostszych nie należy. Oprócz problemu związanego z błędnymi stanami magazynowymi, ponownie oberwało się wydajności importu. Bo oprócz kilku zbędnych zapytań do bazy danych, co wiersz pliku CSV, wysyłaliśmy również zbędne żądania HTTP do systemu magazynu.

Rozwiązań tego problemu jest wiele. Zamiast przedstawić Wam rozwiązanie, zachęcam Was do refleksji, co w tym przypadku można zrobić lepiej.

To nie są historie wyssane z palca

Powyższa historia raczej nie wydarzyła się na prawdę. A przynajmniej nie mi. Nie jest to stwierdzenie pełne pychy, ponieważ ja, podobnie jak każdy programista – popełniam błędy. Cała ta historyjka została oparta o pojedyncze zdarzenia, z którymi spotkałem się na przestrzeni swojego doświadczenia zawodowego. Czasem debugowałem ja, czasem niektóre rzeczy wyszły na etapie Code Review. Zatem pomimo, że cała historia jest przeze mnie sztucznie zaaranżowana, to przedstawia ona prawdziwe scenariusze, które powtarzają się w życiu bardzo często.

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.