27 Grudnia, 2020

788 słów 4 min.

Command Bus w Laravel

Command Bus w Laravel

Command, to prosty wzorzec, dzięki któremu możemy w łatwy sposób wydzielić w naszej aplikacji mniejsze akcje i wyzwalać je przy pomocy jednej zależności, tzw. Command Bus’a. Samo używanie komend jest dość proste, a może przynieść spore korzyści choćby w czytelności kodu. Kiedy używamy command busa, możemy w przyszłości w prosty sposób skręcić w naszym projekcie w kierunku CQS, czyl podziału akcji na komendy i zapytania (nie mylić z CQRS, które mówi o rozdzieleniu modeli zapisu i odczytu, co jest już trochę bardziej skomplikowane, ale oczywiście to ten kierunek).

Wzorzec ten składa się w zasadzie z trzech elementów.

Komenda/Command - jest to prosta klasa, której nazwa opisuje akcję, którą wyzwala. Powinna też zawierać wszystkie dane, które są potrzebne do wyzwolenia danej akcji.

class ReserveResource
{
    private ResourceId $resourceId;
    private Period $period;

    public function __construct(ResourceId $resourceId, Period $period)
    {
        $this->resourceId = $resourceId;
        $this->period = $period;
    }
    
    public function getId(): ResourceId
    {
        return $this->resourceId;
    }

    public function getPeriod(): Period
    {
        return $this->period;
    }
}

Handler - jest to klasa zawierająca metodę handle lub magiczną metodę __invoke. Klasa ta jest instancjonowana po wywołaniu komendy i wyzwalana jest jej metoda przechwytująca, która dostaje w parametrze tę konkretną komendę.

class ReserveResourceHandler
{
    private ResourceRepository $resourceRepository;

    public function __construct(ResourceRepository $resourceRepository)
    {
        $this->resourceRepository = $resourceRepository;
    }

    public function handle(ReserveResource $reserveResource): void
    {
        $resource = $this->resourceRepository->find($reserveResource->getId());
        $resource->reserve($reserveResource->getPeriod());
        $this->resourceRepository->save($resource);
    }
}

Bus, lub szyna jest ostatnim elementem tej układanki. To przy jej pomocy właśnie wyzwalamy odpowiednie komendy. Sama szyna ma za zadanie dopasować odpowiedni handler zinstancjonować i wyzwolić go. Można napisać jego implementację samodzielnie, ale istnieją już gotowe komponenty, o których za chwilę.

Tak może wyglądać przykładowe wyzwolenie komendy za pomocą szyny:

class ReservationController
{
    // inject bus...

    public function reserve(int $id, ReserveRequest $request): void
    {
        $command = ReserveResource::fromRaw(
            $id,
            $request->get('from'),
            $request->get('to')
        );

        $this->bus->dispatch($command);
    }
}

Command Bus jest świetnym wzorcem, oczywiście, jeśli tylko zostanie odpowiednio użyty. Dzięki użyciu komend możemy osiągnąć niski coupling pomiędzy komponentami systemu. Każda klasa, która musi wyzwolić jakąś akcję, potrzebuje jedynie szyny, na którą wrzuca odpowiednią komendę i to szyna jest odpowiedzialna, za dopasowanie odpowiedniego handlera. Komendy mogą również w prosty sposób zostać ponownie użyte w dowolnym miejscu systemu, niezależnie od tego, czy wejściem jest request http po api, request z przeglądarki czy z linii poleceń. Jest to również bardzo przydatne w przypadku stosowania wzorca sagi, czy process managera, gdzie musimy wyzwolić wiele akcji, składających się na większy proces.

Command bus jednak dodaje pewien dodatkowy poziom skomplikowania naszego kodu. Używanie go w małych, prostych projektach może przynieść nam więcej problemów i szkód niż korzyści. Trzeba po prostu zawsze stosować zasadę dobierania rozwiązania do problemu, a nie na odwrót.

Implementacja szyny

W przypadku Symfony mamy do dyspozycji komponent Messenger, który z powodzeniem możemy wykorzystać jako command bus. Możemy użyć również Tactician od The PHP League, którego do niedawna używałem. W przypadku Laravela sprawa wygląda dość ciekawie. Do tej pory w mojej głowie pojawiała się myśl, że może dałoby się użyć istniejących komponentów frameworka jako szyny, ale nie przyglądałem się temu uważniej. Ostatnio jednak Matt Komarnicki obudził we mnie ciekawość, wspominając o tym, że w Laravelu w wersji 5, istniało coś takiego jak CommandBus, jednak komendy zostały przemianowane na Jobs ze względu na problemy w nazewnictwie. Postanowiłem więc zgłębić temat i spróbować wykorzystać istniejące komponenty Laravela, do implementacji command busa.

Jeśli przyjrzymy się dokumentacji Laravela 5, możemy trafić na użycie “fasady” Bus:

Bus::dispatch(
    new PurchasePodcast(Auth::user(), Podcast::findOrFail($podcastId))
);

Jest to dokładnie ta sama “fasada” którą można znaleźć w kolejkach. Ja jednak nie jestem fanem “fasad” laravelowych, dlatego pogrzebałem w kodzie frameworka i znalazłem interfejs Illuminate\Contracts\Bus\Dispatcher, który to natomiast jest implementowany przez Illuminate\Bus\Dispatcher. Implementacja ta zawiera m.in. takie metody jak map oraz dispatch. Początkowo próbowałem skorzystać z dokładnie tej implementacji bezpośrednio, ale nie mam pojęcia dlaczego, mimo tego, że Dispatcher jest zadeklarowany w service providerze jako singleton, to jednak wstrzykując go, nie wszędzie dostawałem tę samą instancję. Przygotowałem więc własny prosty adapter, który konfiguruję w kontenerze DI jako singleton.

Zacząłem więc od przygotowania odpowiedniego interfejsu:

interface CommandBus
{
    public function dispatch($command): void;
    public function map(array $map): void;
}

A następnie implementacji:

use Illuminate\Bus\Dispatcher;

class IlluminateCommandBus implements CommandBus
{
    private Dispatcher $bus;

    public function __construct(Dispatcher $bus)
    {
        $this->bus = $bus;
    }

    public function dispatch($command): void
    {
        $this->bus->dispatch($command);
    }

    public function map(array $map): void
    {
        $this->bus->map($map);
    }
}

Teraz wystarczy zadeklarować nasz bus w service providerze:

public function register()
{
    $this->app->singleton(CommandBus::class, IlluminateCommandBus::class);
}

Dzięki temu w każdym miejscu aplikacji otrzymamy dokładnie tę samą implementację command busa. Wystarczy teraz, że zadeklarujemy odpowiednie powiązania między komendami i handlerami, np. w dedykowanym service providerze:

public function register()
{
    /** @var CommandBus $bus */
    $bus = $this->app->make(CommandBus::class);
    $bus->map([
        ReserveResource::class => ReserveResourceHandler::class,
        TurnOnResource::class => TurnOnResourceHandler::class,
        WithdrawResource::class => WithdrawResourceHandler::class,
        CreateResource::class => CreateResourceHandler::class,
    ]);
}

I od tego momentu można zacząć korzystać z naszej szyny 🙂.