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;
enum Type: int
{
case UNDECIDED = 0;
case OFFLINE = 1;
case ONLINE = 2;
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\Observer;
use DateTimeImmutable;
use SplObjectStorage;
use SplObserver;
use SplSubject;
final class Conference implements SplSubject
{
private string $id;
private Type $type;
private SplObjectStorage $observers;
public function __construct(
private DateTimeImmutable $date
) {
$this->id = uniqid();
$this->type = Type::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 Type::TYPE_ONLINE === $this->type;
}
public function isOffline(): bool
{
return Type::TYPE_OFFLINE === $this->type;
}
/**
* @throws InvalidConferenceTypeException
*/
public function online(): void
{
if (Type::TYPE_UNDECIDED !== $this->type) {
throw new InvalidConferenceTypeException();
}
$this->type = Type::TYPE_ONLINE;
$this->notify();
}
/**
* @throws InvalidConferenceTypeException
*/
public function offline(): void
{
if (Type::TYPE_UNDECIDED !== $this->type) {
throw new InvalidConferenceTypeException();
}
$this->type = Type::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 instanceof Conference) {
return;
}
if ($subject->isOffline()) {
++$this->offlineAmount;
return;
}
if ($subject->isOnline()) {
++$this->onlineAmount;
}
}
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
{
if (! $subject instanceof Conference) {
return;
}
$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 instanceof Conference) {
return;
}
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 DesignPatterns\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 DesignPatterns\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 DesignPatterns\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.
Odpowiedz