#UserBundle
Explore tagged Tumblr posts
Text
CommandBus na sterydach w praktyce
Najpierw zacznijmy od teorii. Command Bus o którym będzie tu mowa to połączenie wzorca polecenie (Command pattern) z warstwą usługi (service layer).
Wzorzec warstw usług to wzorzec polegający na grupowaniu usług na warstwy funkcjonalne. Powoduje to, że zmiany dokonywane są na mniejszych fragmentach kodu które dotyczą konkretnego zagadnienia. Zmniejsza to ilość zależności w ramach konkretnego działania.Usługi powinny być zaprojektowane w taki sposób, aby umożliwiać ich ponowne wykorzystanie. A ograniczenie ich odpowiedzialności do konkretnego zadania upraszcze ich budowę i logikę.
Wzorzec Polecenie (Command pattern) polega na zdefiniowaniu klasy która przechowuje niezbędne informacje do wykonania polecenia. Oraz oczywiście samego kodu wykonującego to polecenie. Implementacji tego wzorca widziałem wiele. Są źródła które zamykają wszystko w jednej klasie, ale są też takie które rozbiają te zależności na osobne byty. Nie chciałbym się skupiać tu na szczegółach a oprzeć się na implementacji którą oferuje league/tactician i osadzić ją w Symfony dzięki gotowemu już thephpleague/tactician-bundle.
Zacznijmy od zainstalowania biblioteki.
$ composer require league/tactician-bundle
Teraz dodajemy nowego Bundle do naszego AppKernel:
<?php // ... class AppKernel extends Kernel { public function registerBundles() { $bundles = array( // ... new League\Tactician\Bundle\TacticianBundle(), ); // ... } // ... }
Dodajmy teraz konfigurację:
tactician: commandbus: default: middleware: - tactician.middleware.locking - tactician.middleware.command_handler
Aby nie definiować wszystkich handlerów ręcznie, dodajmy taki wpis do naszego pliku services.yml
CommandBus\Command\: resource: "%kernel.project_dir%/src/CommandBus/Command/**/*Handler.php" tags: [{name: tactician.handler, typehints: true}]
W końcu możemy przejść do rzeczy. No więc jak działa Command Bus? Zasada jest dość prosta. Potrzebujemy prostą klasę polecenia (Command) która przyjmie konkretne dane wymagane do wykonania. Powinny być to dane proste. Nie całe encje a np. identyfikator który pozwoli tą encję wybrać z bazy. Szczegółowa implementacja może wyglądać tak jak to ustali zespół który zamierza z tego korzystać. Może być to prosty klasa z publicznymi właściwościami. Może być to klasa z samymi "geterami". Dobrze aby wymuszała ona istnieje wszystkich niezbędnych parametrów. Przyk��ad takiej klasy:
<?php declare(strict_types=1); namespace CommandBus\Command\User\AddFriend; class AddFriendCommand { /** * @var int */ private $userId; /** * @var int */ private $firndId; public function __construct(int $userId, int $firndId) { $this->userId = $userId; $this->firndId = $firndId; } public function getUserId(): int { return $this->userId; } public function getFirndId(): int { return $this->firndId; } }
Teraz potrzebowalibyśmy czegoś co nam wykona takiego commanda. Klasy tekie niech będą miały surfix Handler zamiast Command pozwoli to na łatwe odnalezienie takiej klasy która zdefiniuje nam właśnie serwis który wykona oczekiwaną operację. Przykład takiej klasy:
<?php declare(strict_types=1); namespace CommandBus\Command\User\AddFriend; use CommandBus\Command\User\AddFriend\AddFriendCommand; use UserBundle\Entity\UserRepository; class AddFriendHandler { /** * @var UserRepository */ private $userRepository; public function __construct(UserRepository $userRepository) { $this->userRepository = $userRepository; } public function execute(AddFriendCommand $command) { $user = $this->userRepository->find($command->getUserId()); $friend = $this->userRepository->find($command->getFirndId()); $user->addFriend($friend); } }
I teraz jak wygląda wywołanie takiego polecenia. Nalezy przekazać Command do naszego CommandBus reszte załatwi zainstalowana biblioteka. Czyli:
<?php declare(strict_types=1); use League\Tactician\CommandBus; use Symfony\Component\HttpFoundation\Request; use CommandBus\Command\User\AddFriend\AddFriendCommand; class UserController { private $commandBus; public function __construct(CommandBus $commandBus) { $this->commandBus = $commandBus; } public function addFriend(Request $request) { $command = new AddFriendCommand( $request->request->get('userId'), $request->request->get('friendId') ); $this->commandBus->handle($command); return $this->json([]); } }
Domyślnie command zostanie wykonany natychmiastowo. Czyli poniżej moglibyśmy pobrać listę znajomych i powinna ona być wzbogacona o dodanego w nim nowego znajomego. Więc jeżeli ktoś potrzebuje zwrócić od razu rezultat to może. Ale nic nie stoi na przeszkodzie by zdefiniować sobie drugi własny command bus który będzie odkładał polecenie na kolejkę. (Przykładu takiego serwisu) Do tego dodać konsumera który takie zadania będzie z kolejki ściągać i wykonywać. A przerzucenie zadania które wykonuje się za długo w kontrollerze na kolejkę sprowadzać się będzie tylko do użycia innego command busa.
I to jest podstawowe użycie i działanie. Nie jest źle ale szału nie ma. Command Bus od tactician pozwala nam na dodawanie pośredników (middleware). Co pozwala sprawić, że narzędzie to staje się jeszcze bardziej użyteczne. Obecnie proces wykonania polecenia wygląda następująco:
Dobrze jest rozdzielić warstwę kodu który wykonuje daną odperację od walidacji jego danych. Walidacja czasem jest prosta a czasem bardziej skomplikowana ale raczej zawsze jakaś jest. Dlatego spróbujmy stworzyć middleware który uruchomi walidator commanda by zweryfkować czy Command w ogóle powinien się wykonać.
Aby odróżnić błędne wywołania które zakończą się jakiś wyjątkiem. Lepiej tak zaprojektować middleware by błędy były po prostu zwracane. Może być to true/false może być to po prostu string, ale jeżeli zamierzamy mieć różne wersje językowe serwisu to lepiej niech to będzie obiekt do którego będzie można przekazać klucz tłumaczenia i predykaty. Ja na razię przekazuje po prostu treść błędu ale tu każdy powinien przemyśleć to co będzie odpowiednie do jego rojektu.
<?php declare(strict_types=1); namespace CommandBus\Middleware; use League\Tactician\Middleware; abstract class AbstractMiddleware implements Middleware { const COMMAND_SUFFIX = 'Command'; public function createServiceName(string $commandClassName, string $suffix): string { $commandSuffixLength = \strlen(self::COMMAND_SUFFIX); if (substr($commandClassName, -$commandSuffixLength) !== self::COMMAND_SUFFIX) { throw new \Exception(sprintf('Class %s is no command class', $commandClassName)); } return substr($commandClassName, 0, -$commandSuffixLength) . $suffix; } }
<?php declare(strict_types=1); namespace CommandBus\Middleware; use Symfony\Component\DependencyInjection\ContainerInterface; class ValidateMiddleware extends AbstractMiddleware { const VALIDATOR_SUFFIX = 'Validator'; /** * @var ContainerInterface */ private $container; public function __construct(ContainerInterface $container) { $this->container = $container; } /** * @return mixed * @throws \Exception */ public function execute($command, callable $next) { $validatorName = $this->createServiceName(get_class($command), self::VALIDATOR_SUFFIX); if (!$this->container->has($validatorName)) { throw new \Exception('Validator service can not be found'); } $validator = $this->container->get($validatorName); $error = $validator->validate($command); if ($error) { return $error; } return $next($command); } }
Dodajmy middleware do konfiguracji:
tactician: commandbus: default: middleware: - tactician.middleware.locking - CommandBus\Middleware\ValidateMiddleware - tactician.middleware.command_handler
Dopiszmy teraz klasę walidacji do naszego Command-a.
<?php declare(strict_types=1); namespace CommandBus\Command\User\AddFriend; use UsersBundle\Repository\UserRepository; class AddFriendValidator { public function __construct(UserRepository $userRepository) { $this->userRepository = $userRepository; } public function validate(AddFriendCommand $command): ?ValidationFailed { $user = $this->userRepository->find($command->getUserId()); if (!$user) { return new ValidationFailed('User does not exists'); } if ($user->isBanned()) { return new ValidationFailed('User is banned'); } $friend = $this->userRepository->find($command->getFirndId()); if (!$friend) { return new ValidationFailed('Friend does not exists'); } if ($user->hasFriend($friend)) { return new ValidationFailed('Users are already friends'); } } }
Proces działania naszych poleceń wygląda teraz tak:
Fajnie by było aby wszystkie Polecenia były zamknięte w transakcje. Jeżeli programista nie będzie musiał o tym pamiętać to jest szansa, że ochroni to nas przed kilkoma, czasem przykrymi w skutkach problemami. Zobaczmy jak taki middleware dodający transakcje mógłby wyglądać:
<?php declare(strict_types=1); namespace CommandBus\Middleware; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManager; class TransactionMiddleware extends AbstractMiddleware { /** * @var EntityManager */ private $entityManager; public function __construct(EntityManager $entityManager) { $this->entityManager = $entityManager; } /** * @throws \Doctrine\Common\Persistence\Mapping\MappingException * @throws \Exception */ public function execute($command, callable $next) { $this->entityManager->getConnection()->setTransactionIsolation(Connection::TRANSACTION_READ_COMMITTED); $this->entityManager->beginTransaction(); try{ $error = $next($command); if ($error) { $this->entityManager->clear(); $this->entityManager->rollback(); return $error; } $this->entityManager->flush(); $this->entityManager->commit(); } catch (\Exception $exception) { $this->entityManager->clear(); $this->entityManager->rollback(); throw $exception; } } }
Dodajemy go do konfiguracji:
tactician: commandbus: default: middleware: - tactician.middleware.locking - CommandBus\Middleware\TransactionMiddleware - CommandBus\Middleware\ValidateMiddleware - tactician.middleware.command_handler
Teraz proces działania naszych poleceń wygląda tak:
Gdy mamy operacje zamknięte w transakcje może wystąpić problem, że w trakcie trwania naszej transakcji encja zmieni stan na taki który już by nie przeszedł walidacji. Lub inne zadanie zablokuje rekordy w innej kolejności co spowoduje zablokowanie się zadań nawzajem. Zminimalizować wystąpienie tego problemu można blokując samemu rekordy wykonująć zapytanie SELECT FOR UPDATE. Jako, że mieszanie technikali z logiką biznesową nie jest najlepszym pomysłem zróbmy middleware który wywoła serwis w razie potrzeby i zablokuje sobie na czas transakcji odpowiednie rekordy. Przykład takiego middleware mógłby wyglądać tak:
<?php declare(strict_types=1); namespace CommandBus\Middleware; use Symfony\Component\DependencyInjection\ContainerInterface; class LockingStrategyMiddleware extends AbstractMiddleware { const LOCKER_SUFFIX = 'Locker'; /** * @var ContainerInterface */ private $container; public function __construct(ContainerInterface $container) { $this->container = $container; } /** * @return mixed * @throws \Exception */ public function execute($command, callable $next) { $lockerName = $this->createServiceName(get_class($command), self::LOCKER_SUFFIX); if ($this->container->has($lockerName)) { $lockingStrategyService = $this->container->get($lockerName); $lockingStrategyService->lock($command); } return $next($command); } }
Dodajemy middleware do konfiguracji:
tactician: commandbus: default: middleware: - tactician.middleware.locking - CommandBus\Middleware\TransactionMiddleware - CommandBus\Middleware\LockingStrategyMiddleware - CommandBus\Middleware\ValidateMiddleware - tactician.middleware.command_handler
No i dodajmy klasę blokującą nasze rekordy.
<?php declare(strict_types=1); namespace CommandBus\Command\User\AddFriend; use Doctrine\ORM\EntityManager; class AddFriendLocker { /** * @var EntityManager */ private $entityManager; public function __construct(EntityManager $entityManager) { $this->entityManager = $entityManager; } public function validate(AddFriendCommand $command): void { $this->entityManager->find('UserBundle\Entity\User', $command->getUserId(), LockMode::PESSIMISTIC_WRITE); $this->entityManager->find('UserBundle\Entity\User', $command->getFirndId(), LockMode::PESSIMISTIC_WRITE); } }
Proces działania naszych poleceń wygląda teraz tak:
Możemy stworzyć dodatkowo middleware który będzie logował wywołanie każdego Command-a oraz ewentualne błedy. Wtedy bez problemu będziemy w stanie ponowić takie commandy gdy poprawimy działanie naszego kodu.
<?php declare(strict_types=1); namespace CommandBus\Middleware; use CommandBus\Command\LoggableInterface; use Psr\Log\LoggerInterface; class LoggingMiddleware extends AbstractMiddleware { /** * @var LoggerInterface */ private $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; } /** * @param object $command * @param callable $next * * @return mixed */ public function execute($command, callable $next) { $commandInfo = [ 'class' => get_class($command), ]; try { $this->logger->info('Execute command', $commandInfo); $error = $next($command); if ($error) { $this->logger->warning( 'Command not pass validate', [ 'command' => $commandInfo, 'error' => $error, ] ); } else { $this->logger->info('Command executed', $commandInfo); } return $error; } catch (\Exception $exception) { $this->logger->error( 'Command failed', [ 'command' => $commandInfo, 'error' => $exception->getMessage(), 'stacktrace' => $exception->getTraceAsString(), ] ); throw $exception; } } }
Dodajemy middleware do konfiguracji:
tactician: commandbus: default: middleware: - tactician.middleware.locking - CommandBus\Middleware\LoggingMiddleware - CommandBus\Middleware\TransactionMiddleware - CommandBus\Middleware\LockingStrategyMiddleware - CommandBus\Middleware\ValidateMiddleware - tactician.middleware.command_handler
Teraz uzyskaliśmy taką architektórę naszego CommandBus:
Zastanówmy się teraz jaki uzyskaliśmy rezultat.
Mamy proste wejście do każdej operacji zapisu w naszej aplikacji. Bez problemu więc możemy napisać command Symfonowy który wywoła dowolny command w naszej aplikacji.
Mamy ładny obraz tego co nasz system robi. Nawet nowa osoba w zespole widząc taką listę commandów szybciej zrozumie co się dzieje w systemie niż grzebiąc i szukając po kontrolerach co gdzie jak. A tworząc kod analogicznie jest większa szansa, że będzie on spójny z resztą. W natłoku serwisów często zadarza się tak, że każdy pisze kod trochę inaczej.
Dodaliśmy transakcje do każdej opercji zapisu - nie trzeba już o tym pamiętać
Rozdzieliliśmy logikę blokowania rekordów do zapisu, walidacji oraz wykonania.
Logujemy wykonanie i niepowodzenie każdej operacji zapisu w naszym systemie.
Łatwo możemy wydelegować dowolne zadanie na kolejkę. Jaśli raz stworzymy taką wersję command bus-a. Wystarczy zmienić commandBus do którego przekażemy Command.
Dzięki ładnemu podziałowi odpowiedzialności na rózne klasy nasz kod stał się bardziej SOLID.
Będąc uczciwym trzeba powiedzieć kilka słów o wadach tego rozwiązania. Jeżeli oddzielnie pobieramy encje Lock oddzielnie w Validate i na koniec w Handler to zwiększya się ilość zapytań bazodanowych. W większości przypadków to nie będzie problem. W większości pozostałych przypadków przeniesienie zadań na kolejkę rozwiąże problem. A dla pozostałych przypadków można postarać się o modyfikacje middleware-ów i przekazywanie pobranych encji między tymi warstwami. Osobiście pracując nawet przy projektach gdzie wykonywanych było naprawdę dużo zapisów w jednym czasie. To o ile zapytania wykorzystywały indeksy to nie powodowało to większych problemów.
Drugim minusem to na pewno większa liczba klass do napisania. Jeżeli kogoś bardzo drażni tworzenie tylu klas to ten problem można rozwiązać tworząc command w cli. Przekażemy do niego nazwę commanda a on nam wygeneruje wszystkie pliki w odpowiednim katalogu.
Kiedy więc takie rozwiązanie nam się nie sprawdzi. W małych aplikacjach, czyli takich które da się ogarnąć jedną myślą w kilka minut. W aplikacjach które są czystym CRUDem. Wtedy zapewne lepszym pomysłem będzie użycie jakiejś biblioteki lub frameworka który zapewne proste zapisy i odczyty załatwi za nas. W takich przypadkach byłby to przerost formy nad treścią. W każdym innym przypadku zapewne szybko docenimy wyżej wymienione zalety. Oczywiście należy pamiętać, że to nie jedyny sposób rozwiązania omawianych tu problemów. Zawsze wprowadzając jakieś nowe rozwiązanie/wzorzec do projektu trzeba rozważyć wady i zalety.
W rozbudowywaniu procesu naszego Command Bus-a o nowe middleware ogranicza nas tylko wyobraźnia. Ale co najważniejsze robimy to raz dla wszystkich commandów i zapominamy. W naszej gestii zostanie tylko pisanie nowych Command i ich wywoływanie. Czyli dokładnie to czego dotyczą zadania. Kwestie transakcji, kolejkowania, czy logowania tego co się dzieje w systemie mamy już rozwiązane. Ciekaw jestem czy ktoś ma jakieś pomysły na inne middleware które mogłyby w czymś pomóc?
1 note
·
View note