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 jakiegoś 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ę 3 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 trzy, 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 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 jakąś 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 podzielić zależne od siebie komponenty.

Trzeba przyznać, że kod oparty o ten mechanizm jest niesamowicie przyjemny w rozszerzaniu (Open-Closed Principle). 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 jeśli ktoś by miał ochotę sam przygotować taką abstrakcję. Konferencja jest obiektem publikującym i implementuje interfejs SplSubject. Gwarantuje on 3 metody do obsługi subskrybentów pozwalające dodać, usunąć i powiadomić. Reszta metod w tej klasie to już logika biznesowa plus jedna funkcja do zliczenia subskrybentów 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 4 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;

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

    private string $id;

    private \DateTimeImmutable $date;

    private int $type;

    private \SplObjectStorage $observers;

    public function __construct(\DateTimeImmutable $date)
    {
        $this->id = uniqid();
        $this->date = $date;
        $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 offline(): void
    {
        if ($this->type !== self::TYPE_UNDECIDED) {
            throw new InvalidConferenceTypeException();
        }

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

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

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

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

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

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

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;

            return;
        }
    }

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

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

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

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)
    {
        $this->calendar[$conferenceId] = $conferenceDate->format('d.m.Y');
    }

    private function cancel(string $conferenceId)
    {
        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;

class Sponsor implements \SplObserver
{
    public function update(\SplSubject $subject): void
    {
        if ($subject->isOnline()) {
            $subject->detach($this);
        }
    }
}

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer;

class InvalidConferenceTypeException extends \Exception
{

}

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Observer\Test;

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

final class ConferenceTest extends TestCase
{
    public function testCanNotifyAttachedObserver(): void
    {
        $conference = new Conference(new \DateTimeImmutable());
        $splObserverMock = $this->createMock(\SplObserver::class);
        $splObserverMock
            ->expects($this->once())
            ->method('update');

        $conference->attach($splObserverMock);
        $conference->notify();
    }

    public function testCannotNotifyDetachedObserver(): void
    {
        $conference = new Conference(new \DateTimeImmutable());
        $splObserverMock = $this->createMock(\SplObserver::class);
        $splObserverMock
            ->expects($this->never())
            ->method('update');

        $conference->attach($splObserverMock);
        $conference->detach($splObserverMock);
        $conference->notify();
    }

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

        $conferenceUndecided->online();

        $this->assertTrue($conferenceUndecided->isOnline());

        $this->expectException(InvalidConferenceTypeException::class);

        $conferenceOffline->online();
    }

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

        $conferenceUndecided->offline();

        $this->assertTrue($conferenceUndecided->isOffline());

        $this->expectException(InvalidConferenceTypeException::class);

        $conferenceOnline->offline();
    }
}

<?php

declare(strict_types=1);

namespace Behavioral\Observer\Test;

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);
        $conference1->online();

        $this->assertSame($conferenceStatistics->countOnline(), 1);

        $conference2 = new Conference(new \DateTimeImmutable());
        $conference2->attach($conferenceStatistics);
        $conference2->online();

        $this->assertSame($conferenceStatistics->countOnline(), 2);
    }
}

<?php

declare(strict_types=1);

namespace Behavioral\Observer\Test;

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

        $this->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();

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

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

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

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

        $this->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);
        $conference1->online();

        $conference2 = new Conference((new \DateTimeImmutable())->modify('+1 days'));
        $conference2->attach($participant);
        $conference2->online();

        $this->assertCount(2, $participant->getCalendar());

        $conference2->changeDate($bookedDate);

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

<?php

declare(strict_types=1);

namespace Behavioral\Observer\Test;

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

        $this->assertSame(1, $conference->countObservers());

        $conference->online();

        $this->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 więc w swojej skrzynce narzędziowej miejsce na Obserwatora.

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.