Command (Polecenie)

command uml

Opis

Wzorzec projektowy Command (Polecenie) należy do grupy wzorców behawioralnych. Pozwala zamknąć całe żądanie wykonania konkretnej czynności w osobnym obiekcie. Przez to łatwo go dostosować za pomocą przekazywanych parametrów, a większość poleceń jest też łatwa do kolejkowania, czy nawet wycofania.

Komenda w rozumieniu klasycznych wzorców projektowych jest czymś innym, niż komenda w popularnym podejściu CQRS. Chociaż koncepty te mają wiele wspólnego to główną ich różnicą jest fakt, że polecenie w CQRS to zwykłe DTO. Najpierw trafia do szyny, która deleguje go do osobnej klasy (zwanej handlerem) odpowiedzialnej za jego obsługę. Za to bohater dzisiejszego wpisu posiada własną metodę execute(), którą można wywołać bezpośrednio. Chociaż zazwyczaj implementuje się tak zwanego Invokera.

Problem i rozwiązanie

Akcja realizująca to samo albo prawie identyczne zadanie w wielu różnych miejscach. W aplikacjach to norma. Przekładając to na widok użytkownika – te same przyciski w wielu miejscach. W najgorszym scenariuszu kod jest powielony i obsłużone są one osobno. W przypadku zmiany trzeba przeszukać całą aplikację.

Inny problem to pomieszanie między warstwami i wadliwa komunikacja między nimi. Obiekty wiedzą za dużo o innych i mieszają między sobą odpowiedzialności, a w dodatku są od siebie zależne, w miejscach gdzie nie powinny.

Wzorzec projektowy Polecenie pozwala na separację odpowiedzialności i enkapsulację. Klasa Invokera nie ma pojęcia za co odpowiedzialna jest dana komenda. Za zadanie ma tylko ją obsłużyć, co najczęściej wiąże się z jej odpaleniem. Czasem jednak może ją zaprowadzić do kolejki albo śledzić jej historię, tym samym pozwalając chociażby na jej wycofanie. Odpowiedzialnością tej klasy jest więc delegowanie – nie powinna wiedzieć, co realizuje dana komenda.

Za to polecenie nie musi wiedzieć w jakim kontekście zostanie uruchomione. Realizuje konkretne zadanie. Na żądanie z dowolnego miejsca w programie. Jest też obiektem, który można łatwo parametryzować, więc niewielka różnica między wywołaniem jest w większości przypadków trywialna w obsłużeniu.

Można też tworzyć obszerniejsze komendy zestawiając kilka mniejszych. Przykładowo, da się osobno w aplikacji wygenerować dokument (pierwsza komenda), zmienić jego status na „do wysłania” (druga komenda) i pobrać na dysk (trzecia komenda). To wszystko można zamknąć w jednej dużej komendzie umożliwiając użytkownikowi jednym kliknięciem odpalenie zgrupowanych akcji.

Plusy i minusy

Polecenie wprowadza warstwę pośrednią generującą dodatkową złożoność. Tę warstwę stanowi Invoker, który jest kolejną zależnością w klasach, gdzie trzeba obsłużyć komendę. Mimo wszystko, da się też wykonywać je samodzielnie i traktować je na zasadzie niewielkich serwisów. Tyle, że z Invokera można zrobić większy pożytek. Szczególnie, że jako dodatkowa warstwa rozluźnia powiązanie nadawca-odbiorca.

Jeśli chodzi o plusy, ten wzorzec projektowy żyje w zgodzie z pierwszymi zasadami SOLID, czyli pojedyncza odpowiedzialność (single responsibility) i otwarte-zamknięte (open-closed). Czyni kod bardziej czytelnym oraz łatwym w utrzymaniu i rozwoju. Odpowiednia enkapsulacja zachowania oraz możliwość dostosowania żądania za pomocą parametrów, powodują że komendy są reużywalne. Kolejnymi zaletami Command Pattern jest możliwość ich kolejkowania, a także zachowania historii wykonanych poleceń, co pozwoli na przykład na cofnięcie zmian.

Przykładowa implementacja w PHP

Przykład prezentuje uproszczoną implementację reakcji na daną zawartość. Wystarczy sobie wyobraźić post w mediach społecznościowych. Każda reakcja to osobna komenda uruchamiana przez Invokera.

Invokera można jednak rozbudować o historię wykonanych komend, czy kolejkowanie – wtedy jest z niego większy pożytek. Zauważcie, że odbiorcą każdej komendy może być zupełnie inny obiekt. Akurat w tym przypadku jest ten sam, ale konstruktor komendy i jej zależności nie mają nic wspólnego z interfejsem.

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

interface CommandInterface
{
    public function execute(): void;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

final class CommandInvoker
{
    private CommandInterface $command;

    public function setCommand(CommandInterface $command): void
    {
        $this->command = $command;
    }

    public function invoke(): void
    {
        $this->command->execute();
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

final class Like implements CommandInterface
{
    public function __construct(
        private Author $author,
        private InteractiveInterface $interactive
    ) {}

    public function execute(): void
    {
        $this->interactive->like($this->author->getId());
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

final class Wow implements CommandInterface
{
    public function __construct(
        private Author $author,
        private InteractiveInterface $interactive
    ) {}

    public function execute(): void
    {
        $this->interactive->wow($this->author->getId());
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

interface InteractiveInterface
{
    public function like(string $authorId): void;

    public function wow(string $authorId): void;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

final class Post implements InteractiveInterface
{
    public const string LIKE_SLUG = 'like';
    public const string WOW_SLUG = 'wow';

    public function __construct(
        private array $reactionStatistics = []
    ) {}

    public function like(string $authorId): void
    {
        if ($this->shouldUndo($authorId, self::LIKE_SLUG)) {
            unset($this->reactionStatistics[$authorId]);

            return;
        }

        $this->reactionStatistics[$authorId] = self::LIKE_SLUG;
    }

    public function wow(string $authorId): void
    {
        if ($this->shouldUndo($authorId, self::WOW_SLUG)) {
            unset($this->reactionStatistics[$authorId]);

            return;
        }

        $this->reactionStatistics[$authorId] = self::WOW_SLUG;
    }

    public function countLikes(): int
    {
        $sums = array_count_values($this->reactionStatistics);

        return $sums[self::LIKE_SLUG] ?? 0;
    }

    public function countWows(): int
    {
        $sums = array_count_values($this->reactionStatistics);

        return $sums[self::WOW_SLUG] ?? 0;
    }

    private function shouldUndo(string $authorId, string $reactionSlug): bool
    {
        return !empty($this->reactionStatistics[$authorId])
            && ($this->reactionStatistics[$authorId] === $reactionSlug);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

final class Author
{
    private string $id;

    public function __construct()
    {
        $this->id = uniqid();
    }

    public function getId(): string
    {
        return $this->id;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command\Test;

use DesignPatterns\Behavioral\Command\CommandInterface;
use DesignPatterns\Behavioral\Command\CommandInvoker;
use PHPUnit\Framework\TestCase;

final class CommandInvokerTest extends TestCase
{
    public function testCanInvokeCommand(): void
    {
        $command = $this->createMock(CommandInterface::class);
        $invoker = new CommandInvoker();
        $invoker->setCommand($command);

        $command
            ->expects($this->once())
            ->method('execute');

        $invoker->invoke();
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command\Test;

use DesignPatterns\Behavioral\Command\Author;
use DesignPatterns\Behavioral\Command\Like;
use DesignPatterns\Behavioral\Command\Post;
use PHPUnit\Framework\TestCase;

final class LikeTest extends TestCase
{
    public function testCanReactLike(): void
    {
        $interactive = new Post();
        $command = new Like(new Author(), $interactive);

        $command->execute();

        self::assertSame(1, $interactive->countLikes());
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command\Test;

use DesignPatterns\Behavioral\Command\Author;
use DesignPatterns\Behavioral\Command\Post;
use DesignPatterns\Behavioral\Command\Wow;
use PHPUnit\Framework\TestCase;

final class WowTest extends TestCase
{
    public function testCanReactWow(): void
    {
        $interactive = new Post();
        $command = new Wow(new Author(), $interactive);

        $command->execute();

        self::assertSame(1, $interactive->countWows());
    }
}

Command – podsumowanie

Przyznam, że nie często spotykam ten wzorzec projektowy w kodzie w takiej klasycznej postaci. CQRS skradł całe show. Mimo wszystko jest to ciekawy sposób na jednostronną komunikację nadawca-odbiorca żądania. Jako, że ten wzorzec projektowy doskonale wspiera regułę DRY to sam koncept można też wykorzystywać w innych miejscach. Małe obiekty z parametryzowanymi metodami używane w wielu miejscach są bardzo czytelnym i praktycznym zabiegiem.

Wydaje mi się, że w innych technologiach jest bardziej popularny, ale mogę się mylić. Po prostu specyfika aplikacji webowych sprawia, że niekoniecznie czerpie się z niego pełnymi garściami.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.