Observer (Obserwator)

observer obserwator uml

Opis

Wzorzec projektowy Observer (Obserwator) należy do grupy wzorców behawioralnych. Opiera się o mechanizm subskrypcji umożliwiający powiadomienie obiektów obserwujących przez obiekt obserwowany o zajściu zdarzenia (najczęściej zmianie własnego stanu).

Problem i rozwiązanie

Wydaje mi się, że mechanizm subskrypcji i potrzeba na jaką odpowiada są dość dobrze rozumiane. Obiekt chce znać stan innego. Widzę trzy podstawowe sposoby na jego poznanie. Pierwszy zakłada, że to po stronie podmiotu spoczywa odpowiedzialność poinformowania o zmianie stanu. Ale uwaga – powiadamia wszystkich. Pewne obiekty skorzystają, reszta niekoniecznie. Ponadto, przy dużej liczbie odbiorców, słaba optymalizacja. W drugim, zainteresowany obiekt, co pewien okres pyta i dowiaduje się czy zaszła jakaś istotna zmiana. Dochodzi do sytuacji, że większa część zapytań jest zbędna.

No i w końcu – rozwiązanie numer 3, czyli wzorzec Obserwator. Obiekt sam wie, kiedy powinien powiadomić subskrybentów. No ale właśnie – subskrybentów, a nie wszystkich jak leci (patrz rozwiązanie numer 1). Niezależnie od podejścia, cel jest ten sam. Pewna instancja chce posiadać informację o innej, ale jak się okazuje poza rozsądnym sposobem komunikacji akurat to rozwiązanie ma dużo więcej zalet.

Plusy i minusy

Klient jest odpowiedzialny za zarejestrowanie subskrybentów – w dużej mierze to zaleta, bo to właśnie ten element gwarantuje luźne powiązanie i elastyczność. Tyle, że w miejscu, gdzie obserwujących będzie dużo, trzeba ich wszystkich skądś wziąć. Kolejny minus to przypadkowość w kolejności powiadamiania. Choć akurat ten aspekt można poprawić dorzucając sobie obsługę priorytetyzacji. Idealnie by było, gdyby kolejność nie miała żadnego znaczenia – w końcu to niezależne komponenty. W mojej implementacji kolejność notyfikacji jest dokładnie taka sama jak kolejność rejestracji.

Tego wzorca projektowego jest łatwo nadużywać. Skoro mechanizm jest już gotowy i można dowolny obiekt zarejestrować to czemu by nie korzystać z tej możliwości przy każdej okazji. Powstaje przez to sporo małych klas, co zazwyczaj nie jest wadą. Problem w tym, że łatwo źle umieścić logikę i rozsiać ją po całej aplikacji. A co najgorsze, źle rozdzielić zależne od siebie komponenty.

Trzeba przyznać, że kod oparty o ten mechanizm jest niesamowicie przyjemny w rozszerzaniu (open-closed). Co więcej, gwarantuje wymienialność rozwiązań i dużą elastyczność. Klasy są ze sobą luźno powiązane, a w innych miejscach aplikacji mogą działać niezależnie od siebie. Wzorzec Obserwatora umożliwia wielokrotne użycie raz zaimplementowanego mechanizmu subskrypcji.

Przykładowa implementacja w PHP

Przykładowa implementacja zawiera sporo kodu, ale mam nadzieję że nadal nie zaciemnia istoty wzorca Obserwator. Żeby przykład miał sens (nawet konceptualnie) pokusiłem się o kilka metod pomocniczych, które niekoniecznie mają coś wspólnego z wzorcem, a bardziej pokazują sens użycia patternu.

Przykład pokazuje wzorzec projektowy Observer zaimplementowany w oparciu o klasy SPL (Standard PHP Library). Interfejsy dostarczane przez standardową bibliotekę są dość łatwe w odtworzeniu. Konferencja jest obiektem publikującym i implementuje interfejs SplSubject. Gwarantuje on trzy metody do obsługi subskrybentów pozwalające dodać, usunąć i powiadomić. Reszta metod w tej klasie to już logika biznesowa i jedna funkcja do zliczania subskrybentów. W sumie bardziej na potrzeby testów.

Obiekty obserwujące muszą natomiast implementować SplObserver wymuszający tylko jedną metodę update() przez którą odbywa się komunikacja z obserwowanym. Każdy z trzech subskrybentów z tego przykładu inaczej reaguje na powiadomienie. Dodatkowo do wszystkich czterech klas prezentuje testy, które częściowo pokazują jak w klasach klienckich można używać tego mechanizmu. Tyle wprowadzenia – kod powie Wam więcej.

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

use DateTimeImmutable;
use SplObjectStorage;
use SplObserver;
use SplSubject;

final class Conference implements SplSubject
{
    public const TYPE_UNDECIDED = 0;
    public const TYPE_OFFLINE = 1;
    public const TYPE_ONLINE = 2;

    private string $id;

    private int $type;

    private SplObjectStorage $observers;

    public function __construct(
        private DateTimeImmutable $date
    ) {
        $this->id = uniqid();
        $this->type = self::TYPE_UNDECIDED;
        $this->observers = new SplObjectStorage();
    }

    public function attach(SplObserver $observer): void
    {
        $this->observers->attach($observer);
    }

    public function detach(SplObserver $observer): void
    {
        $this->observers->detach($observer);
    }

    public function notify(): void
    {
        /** @var SplObserver $observer */
        foreach ($this->observers as $observer) {
            $observer->update($this);
        }
    }

    public function countObservers(): int
    {
        return $this->observers->count();
    }

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

    public function getDate(): DateTimeImmutable
    {
        return $this->date;
    }

    public function changeDate(DateTimeImmutable $date): void
    {
        $this->date = $date;
        $this->notify();
    }

    public function isOnline(): bool
    {
        return self::TYPE_ONLINE === $this->type;
    }

    public function isOffline(): bool
    {
        return self::TYPE_OFFLINE === $this->type;
    }

    /**
     * @throws InvalidConferenceTypeException
     */
    public function online(): void
    {
        if (self::TYPE_UNDECIDED !== $this->type) {
            throw new InvalidConferenceTypeException();
        }

        $this->type = self::TYPE_ONLINE;
        $this->notify();
    }

    /**
     * @throws InvalidConferenceTypeException
     */
    public function offline(): void
    {
        if (self::TYPE_UNDECIDED !== $this->type) {
            throw new InvalidConferenceTypeException();
        }

        $this->type = self::TYPE_OFFLINE;
        $this->notify();
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

use SplObserver;
use SplSubject;

final class ConferenceStatistic implements SplObserver
{
    private int $onlineAmount = 0;

    private int $offlineAmount = 0;

    public function update(SplSubject $subject): void
    {
        if ($subject->isOffline()) {
            ++$this->offlineAmount;

            return;
        }

        if ($subject->isOnline()) {
            ++$this->onlineAmount;
        }
    }

    public function countOffline(): int
    {
        return $this->offlineAmount;
    }

    public function countOnline(): int
    {
        return $this->onlineAmount;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

use DateTimeImmutable;
use SplObserver;
use SplSubject;

final class Participant implements SplObserver
{
    private array $calendar;

    public function __construct()
    {
        $this->calendar = [];
    }

    public function update(SplSubject $subject): void
    {
        $conferenceId = $subject->getId();
        $conferenceDate = $subject->getDate();

        if ($this->isDateFree($conferenceId, $conferenceDate)) {
            $this->book($conferenceId, $conferenceDate);
        } else {
            $this->cancel($conferenceId);
        }
    }

    public function getCalendar(): array
    {
        return $this->calendar;
    }

    private function book(string $conferenceId, DateTimeImmutable $conferenceDate): void
    {
        $this->calendar[$conferenceId] = $conferenceDate->format('d.m.Y');
    }

    private function cancel(string $conferenceId): void
    {
        unset($this->calendar[$conferenceId]);
    }

    private function isDateFree(string $conferenceId, DateTimeImmutable $date): bool
    {
        return !in_array($date->format('d.m.Y'), $this->calendar)
            || (isset($this->calendar[$conferenceId]) && $this->calendar[$conferenceId] === $date->format('d.m.Y'));
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

use SplObserver;
use SplSubject;

final class Sponsor implements SplObserver
{
    public function update(SplSubject $subject): void
    {
        if (!$subject->isOnline()) {
            return;
        }

        $subject->detach($this);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

use Exception;

final class InvalidConferenceTypeException extends Exception
{
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer\Test;

use DateTimeImmutable;
use DesignPatterns\Behavioral\Observer\Conference;
use DesignPatterns\Behavioral\Observer\InvalidConferenceTypeException;
use PHPUnit\Framework\TestCase;
use SplObserver;

final class ConferenceTest extends TestCase
{
    public function testCanNotifyAttachedObserver(): void
    {
        $conference = new Conference(new DateTimeImmutable());
        $splObserverMock = $this->createMock(SplObserver::class);
        $conference->attach($splObserverMock);

        $splObserverMock
            ->expects($this->once())
            ->method('update');

        $conference->notify();
    }

    public function testCannotNotifyDetachedObserver(): void
    {
        $conference = new Conference(new DateTimeImmutable());
        $splObserverMock = $this->createMock(SplObserver::class);
        $conference->attach($splObserverMock);
        $conference->detach($splObserverMock);

        $splObserverMock
            ->expects($this->never())
            ->method('update');

        $conference->notify();
    }

    public function testCanChangeTypeToOnlineIfIsUndecided(): void
    {
        $conferenceUndecided = new Conference(new DateTimeImmutable());

        $conferenceUndecided->online();

        self::assertTrue($conferenceUndecided->isOnline());
    }

    public function testCannotChangeTypeToOnlineIfIsNotUndecided(): void
    {
        $conferenceOffline = (new Conference(new DateTimeImmutable()));
        $conferenceOffline->offline();

        self::expectException(InvalidConferenceTypeException::class);

        $conferenceOffline->online();
    }

    public function testCannotChangeTypeToOfflineIfIsNotUndecided(): void
    {
        $conferenceOnline = (new Conference(new DateTimeImmutable()));
        $conferenceOnline->online();

        self::expectException(InvalidConferenceTypeException::class);

        $conferenceOnline->offline();
    }

    public function testCanChangeTypeToOfflineIfIsUndecided(): void
    {
        $conferenceUndecided = new Conference(new DateTimeImmutable());

        $conferenceUndecided->offline();

        self::assertTrue($conferenceUndecided->isOffline());
    }
}
<?php

declare(strict_types=1);

namespace Behavioral\Observer\Test;

use DateTimeImmutable;
use DesignPatterns\Behavioral\Observer\Conference;
use DesignPatterns\Behavioral\Observer\ConferenceStatistic;
use PHPUnit\Framework\TestCase;

final class ConferenceStatisticTest extends TestCase
{
    public function testCanCollectStatisticsForFewConferences(): void
    {
        $conferenceStatistics = new ConferenceStatistic();
        $conference1 = new Conference(new DateTimeImmutable());
        $conference1->attach($conferenceStatistics);
        $conference2 = new Conference(new DateTimeImmutable());
        $conference2->attach($conferenceStatistics);

        $conference1->online();
        $conference2->online();

        self::assertSame($conferenceStatistics->countOnline(), 2);
    }
}
<?php

declare(strict_types=1);

namespace Behavioral\Observer\Test;

use DateTimeImmutable;
use DesignPatterns\Behavioral\Observer\Conference;
use DesignPatterns\Behavioral\Observer\Participant;
use PHPUnit\Framework\TestCase;

final class ParticipantTest extends TestCase
{
    public function testCanBookNewConference(): void
    {
        $participant = new Participant();
        $conference = new Conference(new DateTimeImmutable());
        $conference->attach($participant);

        $conference->online();

        self::assertSame($participant->getCalendar()[$conference->getId()], $conference->getDate()->format('d.m.Y'));
    }

    public function testCannotBookSameConferenceTwice(): void
    {
        $participant = new Participant();
        $conference = new Conference(new DateTimeImmutable());
        $conference->attach($participant);

        $conference->changeDate(new DateTimeImmutable());
        $conference->online();

        self::assertCount(1, $participant->getCalendar());
    }

    public function testCannotBookSameDateTwice(): void
    {
        $sameDate = new DateTimeImmutable();
        $participant = new Participant();
        $conference1 = new Conference($sameDate);
        $conference1->attach($participant);
        $conference2 = new Conference($sameDate);
        $conference2->attach($participant);

        $conference1->online();
        $conference2->online();

        self::assertCount(1, $participant->getCalendar());
    }

    public function testIsBookDateChangedWhenNewDateIsFree(): void
    {
        $changedDate = new DateTimeImmutable();
        $participant = new Participant();
        $conference = new Conference(new DateTimeImmutable());
        $conference->attach($participant);

        $conference->changeDate($changedDate);
        $conference->online();

        self::assertSame($participant->getCalendar()[$conference->getId()], $changedDate->format('d.m.Y'));
    }

    public function testIsBookDateCanceledWhenNewDateIsNotFree(): void
    {
        $bookedDate = new DateTimeImmutable();
        $participant = new Participant();
        $conference1 = new Conference($bookedDate);
        $conference1->attach($participant);
        $conference2 = new Conference((new DateTimeImmutable())->modify('+1 days'));
        $conference2->attach($participant);

        $conference1->online();
        $conference2->online();
        $conference2->changeDate($bookedDate);

        self::assertCount(1, $participant->getCalendar());
    }
}
<?php

declare(strict_types=1);

namespace Behavioral\Observer\Test;

use DateTimeImmutable;
use DesignPatterns\Behavioral\Observer\Conference;
use DesignPatterns\Behavioral\Observer\Sponsor;
use PHPUnit\Framework\TestCase;

final class SponsorTest extends TestCase
{
    public function testShouldStopSubscribingOnlineConference(): void
    {
        $sponsor = new Sponsor();
        $conference = new Conference(new DateTimeImmutable());
        $conference->attach($sponsor);

        $conference->online();

        self::assertSame(0, $conference->countObservers());
    }
}

Observer – podsumowanie

Observer jest często wykorzystywany ze względu na popularyzację zdarzeń w programowaniu. Tak naprawdę wszelkie opieranie komunikacji o eventy jest adaptacją tego mechanizmu. Przynajmniej w swoich bazowych założeniach, bo implementacje mogą być różne.

Bardzo fajnie sprawdza się w miejscach, gdzie obiekt powinien być obserwowany tylko w niektórych przypadkach albo w określonym czasie. A to dzięki łatwości subskrypcji i rezygnacji w dowolnym momencie. Zróbcie w swojej skrzynce narzędziowej miejsce na Obserwatora.

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