Chyba każdy, kto zaczynał pracować na dowolnym frameworku MVC, popełniał ten sam błąd: znaczną część logiki zamieszczał wewnątrz akcji kontrolera. Efektem tego były pliki kontrolerów o dużej ilości linijek. W dzisiejszym wpisie rozważymy wzorzec, dzięki któremu zadbamy nieco o nasz kod tak, aby duże kontrolery do nas więcej nie wróciły. Mowa oczywiście o wzorcu CQRS.

Logika w kontrolerze – zmora wielu programistów

Tak jak to zostało zawarte we wstępie, wyjściowym przykładem tego wpisu jest kontroler, w którym zawarta jest cała logika biznesowa. W tym akurat przypadku jest to logika endpointu aktualizacjącego post na blogu. Jest tutaj pobieranie encji z repozytorium, wywołanie serwisu sanityzującego dane oraz aktualizacja i zapis encji. Na końcu endpointu zwracamy informacje na temat zaktualizowanego właśnie wpisu.

Nasz kod ma się następująco:

<?php

class PostsController
{
    private const THUMBNAIL_WIDTH = 210;
    
    private const THUMBNAIL_HEIGHT = 580;

	private PostsRepository $postsRepository;

	private CurrentUserProvider $currentUserProvider;

    private TranslatorInterface $translator;

	private InputSanitizer $inputSanitizer;

	private ImageUrlGenerator $imageUrlGenerator;

	private EntityManagerInterface $entityManager;

    // Constructor...

	public function updatePostAction(Request $request): Response
	{
		$postId = $request->get('id', 0);
		$post = $this->postsRepository->findOneById($postId);
        if ($post === null) {
            throw new PostNotFoundException();
        }

		$currentUser = $this->currentUserProvider->provide();
		if ($post->getAuthor() !== $currentUser) {
			throw new AccessDeniedException();
		}

        $authorName = sprintf("%s %s", $author->getName(), $author->getLastName());
        if ($currentUser === $author) {
            $authorName .= sprintf(" (%s)", $this->translator->trans('you'));
        }

        $author = $post->getAuthor();

		$userInput = $this->inputSanitizer->sanitize($request->request->all());
		$post->setTitle($userInput->title);
		$post->setDescription($userInput->description);
		$post->setPublishDate($userInput->publishDate);

        $timeToPublish = -1;
		if ($post->getPublishDate() <= new \DateTime()) {
			$post->setPublished(true);
		} else {
            $now = new \DateTime();
            $publishTime = $post->getPublishDate();
			$timeToPublish = $now->getTimestamp() - $publishTime->getTimestamp();
        }

		$image = $userInput->image;
		if ($image === null) {
			$image = $this->imageUrlGenerator->generateForDefaultImage();
		}
		$post->setImageUrl($image);
		$thumbnailUrl = sprintf(
            "%s/%d/%d",
            $image,
            self::THUMBNAIL_WIDTH,
            self::THUMBNAIL_HEIGHT
        );

		$this->entityManager->persist($image);
		$this->entityManager->flush();

		$output = [
			'author' => $authorName,
			'title' => ucfirst($post->getTitle()),
			'description' => ucifrst($post->getDescription()),
			'thumbnail' => $thumbnailUrl,
			'visible' => $post->isPublished(),
			'timeToPublish' => $timeToPublish,
		];

		return new Response(
			json_encode($output),
			Response::HTTP_OK
		);
	}
}

Pomimo niewielkiego rozmiaru klasy możemy znaleźć kilka punktów zapalnych:

  • Odpowiedzialnością kontrolera jest jedynie sterowanie procesem, bez znajomości detali implementacji. Tutaj kontroler ma wiedzę na temat całej implementacji
  • Łamiemy Zasadę Pojedynczej Odpowiedzialności, ponieważ mamy co najmniej dwa powody do zmiany metody. Po pierwsze, kiedy zmieni się logika procesu zapisu posta, po drugie: jeżeli zdecydujemy się na zmianę informacji zwracanych przez endpoint
  • Nie mamy w sposób widoczny odseparowanej części edycji wpisu od zwracania o nim informacji. W konsekwencji tego, podczas zmiany logiki zapisu możemy niechcący wpłynąć na logikę zwracania danych
  • Klasa kontrolera posiada sporo zależności do części systemu, które nie powinny być w zakresie jej zainteresowań

Jak widać, jest kilka przewinień, na które warto zwrócić uwagę. W dzisiejszym wpisie postaramy się wpłynąć na ten kod tak, aby każdy z wymienionych punktów został rozwiązany zgodnie ze sztuką. Zanim jednak do tego przejdziemy, zapoznajmy się z konceptem, jaki kryje za sobą CQRS.

Dobra architektura z CQRS

CQRS – (ang. Command Query Responsibility Segregation) jest wzorcem architektonicznym, który wpływa na system, dzieląc go na:

  • Komendy, które modyfikują stan systemu, bez zwracania jakiejkolwiek informacji na temat tego, co zostało zmienione
  • Zapytania zwracające informacje o bieżącym stanie systemu, bez wpływania na jego stan

CQRS na pierwszy rzut oka jest bardzo podobny do wzorca CQS. Jeżeli dobrze rozumiemy istotę CQS, to śmiało możemy stwierdzić, że CQS działa w kontekście metod, a CQRS – na poziomie architektury systemu.

Co to oznacza w praktyce? Znaczy to tyle, że w CQS jeżeli chcemy zmienić stan klasy, to robimy to za pomocą metody, która nic nie zwraca. W CQRS jeżeli chcemy zmienić stan systemu (np. przez bazę danych, system cache, indeks czy system plików), to delegujemy do tego zadania osobną klasę, która w żaden sposób nie udostępnia na zewnątrz informacji na temat tego, co zostało zmienione. Jeżeli natomiast w CQS chcemy zwrócić na zewnątrz stan klasy, to tworzymy metodę, która w żaden sposób go nie modyfikuje. W CQRS natomiast tworzymy klasę, której odpowiedzialnością jest wysłanie odpowiednich zapytań bądź żądań w celu zaciągnięcia danych, a następnie przetworzenie oraz zwrócenie tych danych.

Teorię mamy za sobą, czas na przykładową implementację.

Komendy i zapytania w praktyce

W przytoczonym przeze mnie przykładzie mamy do czynienia z endpointem, który zarówno modyfikuje stan systemu, jak i zwraca informacje z tegoż systemu na zewnątrz. Gdyby przyjąć, że za modyfikację systemu odpowiada klasa komendy, a za część zwracającą dane odpowiedzialna byłaby klasa zapytania, to kod naszej akcji mógłby wyglądać podobnie, co kod na listingu poniżej:

<?php

class PostsController
{
	private InputSanitizer $inputSanitizer;

	private UpdatePostCommandHandler $updatePostCommandHandler;

    private GetPostDetailsQuery $getPostDetailsQuery;

    // Constructor...

    public function updatePostAction(Request $request): Response
	{
		$postId = $request->get('id', 0);
		$userInput = $this->inputSanitizer->sanitize($request->request->all());
		
        $command = new UpdatePostCommand($postId, $userInput);
        $this->updatePostCommandHandler->handle($command);

		return new Response(
			json_encode($this->getPostDetailsQuery->fetchResult($postId)),
			Response::HTTP_OK
		);
	}
}

Logika wewnątrz metody updatePostAction znacznie się uprościła. Wiele skomplikowanych warunków zostało schowanych do klas UpdatePostCommandHandler oraz GetPostDetailsQuery. Dodatkowo, liczba zależności wewnątrz klasy kontrolera znacznie stopniała, a sam kontroler zyskał sporo na czytelności.

Oprócz dużej ilości korzyści zauważyć możemy różnicę w implementacji komendy oraz zapytania. O ile implementacja zapytania mieści się w jednej klasie, to klasa komendy została już podzielona na dwie: UpdatePostCommand oraz UpdatePostCommandHandler. Aby to zrozumieć, potrzebujemy sięgnąć do istoty komend oraz zapytań. Jeżeli wydajemy systemowi komendę, to liczymy się z tym, że może ona zostać wykonana w niekoniecznie znanej nam przyszłości. Mówiąc technicznie: podczas procesowania żądania do systemu wykonawczego (np. systemu kolejkowego) zostaje wysłana wiadomość, która następnie zostaje przetwarzana niezależnie od wątku, w którym obsługujemy to żądanie. I to z powodu asynchroniczności (a dokładniej mówiąc, gotowości do wykonania każdej komendy w sposób asynchroniczny) CQRS nie pozwala na zwracanie danych na zewnątrz klasy wykonującej komendę. Jeżeli natomiast mowa o zapytaniach, to te służą do zwrócenia informacji o systemie, stąd wniosek, że nie mogą one zostać wykonane w sposób inny niż synchroniczny. A skoro tak, to nie ma potrzeby rozdzielania klasy zapytania na dwie części. Implementując system zapytań możemy jednak skorzystać np. z biblioteki Symfony Messenger, która wymusza podejście separowania wiadomości od klasy jej wykonującej.

Omówiony wyżej kod nie zawiera mechanizmu, który wspierałby model pracy asynchronicznej. Aby takowy zaimplementować, najczęściej korzysta się z wzorca Message Bus, którego wykorzystanie w kodzie może wyglądać następująco:

<?php

class PostsController
{
	private InputSanitizer $inputSanitizer;

	private CommandBus $commandBus;

    private GetPostDetailsQuery $getPostDetailsQuery;

    // Constructor...

    public function updatePostAction(Request $request): Response
	{
		$postId = $request->get('id', 0);
		$userInput = $this->inputSanitizer->sanitize($request->request->all());
		
        $command = new UpdatePostCommand($postId, $userInput);
        $this->commandBus->dispatch($command);

		return new Response(
			json_encode($this->getPostDetailsQuery->fetchResult($postId)),
			Response::HTTP_OK
		);
	}
}

W powyższym kodzie nigdzie w sposób jawny nie korzystamy z klasy UpdatePostCommandHandler. To klasa CommandBus jest odpowiedzialna za rozpoznanie, czy wiadomość powinna zostać przetworzona natychmiastowo, bądź wykonana poza linią wykonania żądania.

Dla spójności wpisu kodu, poniżej przedstawiłem implementację komendy oraz wykonującego ją handlera:

<?php

class UpdatePostCommand
{
    private int $postId;

    private UpdatePostInput $input;

    public function __construct(int $postId, UpdatePostInput $input)
    {
        $this->postId = $postId;
        $this->input = $input;
    }

    public function getPostId(): int
    {
        return $this->postId;
    }

    public function getInput(): UpdatePostInput
    {
        return $this->input;
    }
}

class UpdatePostCommandHandler
{
    private PostsRepository $postsRepository;

	private CurrentUserProvider $currentUserProvider;

	private ImageUrlGenerator $imageUrlGenerator;

	private EntityManagerInterface $entityManager;

    // Constructor...

    public function handle(UpdatePostCommand $command): void
    {
        $post = $this->postsRepository->findOneById($postId);
        if ($post === null) {
            throw new PostNotFoundException();
        }

		$currentUser = $this->currentUserProvider->provide();
		if ($post->getAuthor() !== $currentUser) {
			throw new AccessDeniedException();
		}

		$userInput = $command->getInput();
		$post->setTitle($userInput->title);
		$post->setDescription($userInput->description);
		$post->setPublishDate($userInput->publishDate);

		if ($post->getPublishDate() <= new \DateTime()) {
			$post->setPublished(true);
		}

		$image = $userInput->image;
		if ($image === null) {
			$image = $this->imageUrlGenerator->generateForDefaultImage();
		}
		$post->setImageUrl($image);

		$this->entityManager->persist($image);
		$this->entityManager->flush();
    }
}

Implementacja klasy zapytania natomiast ma się następująco:

<?php

class GetPostDetailsQuery
{
    private const THUMBNAIL_WIDTH = 210;
    
    private const THUMBNAIL_HEIGHT = 580;

    private PostRepository $postRepository;

    private CurrentUserProvider $currentUserProvider;

    private TranslatorInterface $translator;

    // Constructor...

    public function fetchResult(int $postId): PostDetailsQueryResult
    {
        $post = $this->postsRepository->findOneById($postId);
        if ($post === null) {
            throw new PostNotFoundException();
        }

		$currentUser = $this->currentUserProvider->provide();
        $author = $post->getAuthor();
        $authorName = sprintf("%s %s", $author->getName(), $author->getLastName());
        if ($currentUser === $author) {
            $authorName .= sprintf(" (%s)", $this->translator->trans('you'));
        }

        $timeToPublish = -1;
        if ($post->getPublishDate() > new \DateTime()) {
		    $now = new \DateTime();
            $publishTime = $post->getPublishDate();
			$timeToPublish = $now->getTimestamp() - $publishTime->getTimestamp();
        }

        $image = $post->getImageUrl();
		if ($image === null) {
			$image = $this->imageUrlGenerator->generateForDefaultImage();
		}
		$thumbnailUrl = sprintf(
            "%s/%d/%d",
            $image,
            self::THUMBNAIL_WIDTH,
            self::THUMBNAIL_HEIGHT
        );

        return new PostDetailsQueryResult(
            $authorName,
            ucfirst($post->getTitle()),
            ucifrst($post->getDescription()),
            $thumbnailUrl,
            $post->isPublished(),
            $timeToPublish
        );
    }
}

Teraz obydwie logiki są od siebie odseparowane, dzięki czemu przez modyfikację jednej, nie wpływamy na działanie tej drugiej.

Charakterystyka modelów zapisu i odczytu

W nomenklaturze powiązanej z wzorcem CQRS pojawia się wielokrotnie pojęcie modelu zapisu i odczytu. Jak domyślać się można, model zapisu powiązany będzie z częścią dotyczącą komend, a model odczytu dotyczyć będzie zapytań. Przyjrzyjmy się obydwu modelom pokrótce.

Model zapisu

Podczas wykonywania komend zmieniamy stan wewnątrz aplikacji. W trakcie tej zmiany zachodzą procesy biznesowe według sprecyzowanych wcześniej reguł. W zależności od przekazanych do systemu danych możemy sterować tymi procesami. Zazwyczaj te procesy będą sprowadzały się do zapisu danych w jakiejkolwiek formie bazy danych i/lub komunikacji z innymi systemami. Podstawowymi narzędziami będą tutaj systemy ORM, walidatory, zagnieżdżone serwisy zawierające logikę biznesową czy różnego rodzaju klasy klientów API. Do pracy z logiką biznesową będziemy wykorzystywać klasy fabryk, obiekty wartości oraz jeżeli nasz system ORM je wspiera, to również encje. Wszystkie te narzędzia (oraz im podobne) będą składały się na model zapisu wewnątrz aplikacji.

Model odczytu

Podczas odczytu obecnego stanu systemu nie potrzebujemy znać żadnych reguł modelu biznesowego. To, co nas interesuje, to wyciągnięcie odpowiednich danych oraz prezentacja ich w wybranej przez nas formie (w CQRS zazwyczaj sprowadza się to do zwrotki obiektu typu ViewModel). Zazwyczaj nie musimy specjalnie walidować danych przekazanych do zapytania (choć pamiętać należy o zabezpieczeniu przed SQL Injection). Całość pracy będzie szła w kierunku komponowania zapytań do systemu bazodanowego. Dobrym wyborem na narzędzie będzie tutaj system DBAL bądź system indeksujący dane (np. ElasticSearch).

Separacja modeli oraz związane z nią zalety

Jak już przeczytaliśmy, model odczytu oraz model zapisu mają dwa różne od siebie cele, do których mogą (choć nie muszą) prowadzić nas różne narzędzia. Dobrą praktyką jest zatem niemieszanie ze sobą modelu odczytu i zapisu. W dłuższej perspektywie spłacić się to może następującym zestawem zalet:

  • Jeżeli zdecydujemy się np. na zmianę narzędzia stosowanego w zapytaniach (np. zamiana wysyłki zapytań do bazy danych na odpytywanie odpowiedniego indeksu systemu przetwarzającego dane), to nasza logika biznesowa modelu zapisu jest w 100% bezpieczna (nie zostaniemy zmuszeni do zmian wewnątrz niej)
  • Oddzielenie modelu zapisu i odczytu możemy kontynuować nie tylko na poziomie aplikacyjnym (w kodzie). Możemy pójść o krok dalej i oddzielić je na poziomie infrastrukturalnym. Przykładowo, możemy skalować bazę danych przez oddelegowanie zapytań typu SELECT do replik skonfigurowanych i skoncentrowanych na zwracaniu danych). Możemy również skalować naszą aplikację przez fizyczne odłączenie od siebie klastrów odczytujących dane od tych zapisujących. Jeżeli tylko klaster zapisu ulegnie awarii, to część aplikacji dalej będzie działała
  • Separacja obydwu modeli od siebie w naturalny sposób daje nam możliwość korzystania z różnych technologii do zapisu i odczytu, dzięki czemu będziemy mieli większy wachlarz wyboru narzędzi

CQRS nie nadaje się wszędzie

Wzorzec CQRS jest niewątpliwie bardzo potężnym narzędziem. Z jednej strony daje nam możliwość delegowania zadań do wykonania w sposób asynchroniczny, a z drugiej pozwala na lepszy wybór technologii w stosunku do jej przeznaczenia. Dodatkowo, CQRS potrafi bardzo korzystnie wpłynąć na performance aplikacji. Skoro mamy tyle zalet, to gdzie jest haczyk?

CQRS jest narzędziem, i powinniśmy go wykorzystywać jako narzędzia. Nie powinniśmy ulegać modzie, nie powinniśmy stosować tego wzorca, aby być na topie. Jeżeli nasza aplikacja składa się z bardzo generycznych procesów, bądź te procesy biznesowe nie są zbytnio skomplikowane (bo np. to w dużej mierze tylko proste zapisy i odczyty danych), to kategorycznie nie powinniśmy sięgać po CQRS. Stosowanie tego wzorca w tego typu sytuacjach da nam duży narzut architektoniczny, który nie spłaci się w żaden sposób. Natomiast dodawanie nowych funkcji do systemu będzie wiązało się z tworzeniem kilku klas tylko po to, aby dorzucić trzy bądź cztery linijki prawdziwej logiki biznesowej.

Reasumując, CQRS jest bardzo potężnym wzorcem, pod warunkiem, że stosuje się go w sytuacjach, do których został stworzony.

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.