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 na widok użytkownika, te same przyciski w wielu miejscach. W najgorszym scenariuszu kod jest powielony i obsłużone są one osobno – więc 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 tam 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 na przykład 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 (1 komenda), zmienić jego status na „do wysłania” (2 komenda) i pobrać na dysk (3 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ę je też wykonywać samodzielnie i traktować je na zasadzie niewielkich serwisów, chociaż 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 Single Responsibility i Open/Closed. Czyni więc 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ść. Na przykład post w mediach społecznościowych. Każda reakcja to osobna komenda uruchamiana przez Invokera, który w tym przykładzie jest właśnie odpowiedzialny tylko za to.

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;

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;

class Like implements CommandInterface
{
    private Author $author;

    private InteractiveInterface $interactive;

    public function __construct(Author $author, InteractiveInterface $interactive)
    {
        $this->author = $author;
        $this->interactive = $interactive;
    }

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

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

class Wow implements CommandInterface
{
    private Author $author;

    private InteractiveInterface $interactive;

    public function __construct(Author $author, InteractiveInterface $interactive)
    {
        $this->author = $author;
        $this->interactive = $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;

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

    private array $reactionStatistics;

    public function __construct()
    {
        $this->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])
            &amp;&amp; ($this->reactionStatistics[$authorId] === $reactionSlug);
    }
}

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Command;

class Author
{
    private string $id;

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

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

<?php

declare(strict_types=1);

namespace 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);
        $command
            ->expects($this->once())
            ->method('execute');

        $invoker = new CommandInvoker();
        $invoker->setCommand($command);
        $invoker->invoke();
    }
}

<?php

declare(strict_types=1);

namespace 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();

        $this->assertSame(1, $interactive->countLikes());

        $command->execute();

        $this->assertSame(0, $interactive->countLikes());
    }
}

<?php

declare(strict_types=1);

namespace 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();

        $this->assertSame(1, $interactive->countWows());

        $command->execute();

        $this->assertSame(0, $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. 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.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.