php
artykuł··zaktualizowano ·6 min czytania

Wzorzec Bridge: oddziel to, co wysyłasz, od tego, jak to wysyłasz

#php #design-patterns #bridge #architecture #notifications #abstraction

Bridge rozcina hierarchię klas wzdłuż dwóch osi, które zmieniają się niezależnie, i łączy je kompozycją. Podręczniki pokazują go zwykle na kształtach i rendererach, a GoF na oknach i systemach okienkowych. Bliżej codziennej roboty jest system powiadomień, który ma dokładnie dwie osie: treść wiadomości i sposób jej dostarczenia.

Problem: M × N klas

Weźmy system, który wysyła cztery typy powiadomień (potwierdzenie płatności, niski stan magazynu, zawieszenie konta, raport tygodniowy) mailem, SMS-em i na Slacka. Jeśli każda kombinacja dostaje osobną klasę, wychodzi tak:

PaymentConfirmationEmail     PaymentConfirmationSms     PaymentConfirmationSlack
LowInventoryAlertEmail       LowInventoryAlertSms       LowInventoryAlertSlack
AccountSuspendedEmail        AccountSuspendedSms        AccountSuspendedSlack
WeeklyReportEmail            WeeklyReportSms            WeeklyReportSlack

Dwanaście klas. Czwarty kanał to cztery kolejne, piąty typ powiadomienia to trzy. Liczba rośnie jak M × N, a każda klasa powiela logikę treści swojego typu i logikę dostarczania swojego kanału, więc zmianę w skracaniu SMS-ów trzeba wprowadzić w czterech miejscach.

Każda z tych klas łączy dwie decyzje: co napisać i jak to dostarczyć. Bridge rozdziela je na dwie hierarchie.

Kanały z jednej strony, wiadomości z drugiej

Strona implementacji (implementor) odpowiada za dostarczenie wiadomości:

interface NotificationChannel
{
    public function send(string $recipient, string $subject, string $body): void;
}

final class EmailChannel implements NotificationChannel
{
    public function __construct(private readonly Mailer $mailer) {}

    public function send(string $recipient, string $subject, string $body): void
    {
        $this->mailer->send($recipient, $subject, $body);
    }
}

final class SlackChannel implements NotificationChannel
{
    public function __construct(private readonly SlackClient $slack) {}

    public function send(string $recipient, string $subject, string $body): void
    {
        // Slack nie ma tematu wiadomości, więc temat trafia do treści jako pogrubienie
        $this->slack->postMessage($recipient, "*{$subject}*\n{$body}");
    }
}

final class SmsChannel implements NotificationChannel
{
    public function __construct(
        private readonly SmsProvider $sms,
        private readonly int $maxLength = 70,
    ) {}

    public function send(string $recipient, string $subject, string $body): void
    {
        $text = "{$subject}: " . strip_tags($body);
        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));
    }
}

Mailer, SlackClient i SmsProvider zastępują tu klientów, których aplikacja i tak już używa. Limit SMS-a nie jest parametrem przypadkiem. Jeden segment mieści 160 znaków w alfabecie GSM-7, ale wystarczy jeden znak spoza niego (choćby polskie ogonki), żeby wiadomość poszła w UCS-2, a wtedy segment mieści 70. Stąd też mb_substr, bo zwykłe substr liczy bajty i potrafi przeciąć wielobajtowy znak w połowie. mb_substr liczy znaki, a UCS-2 jednostki 16-bitowe, więc emoji zajmie dwie z 70. Znaki z rozszerzenia GSM-7, jak € czy [, też liczą się podwójnie.

Strona abstrakcji decyduje, co wysłać, a dostarczenie zostawia kanałowi:

final readonly class Payment
{
    public function __construct(
        public string $reference,
        public int $amountMinor,
        public string $currency,
        public DateTimeImmutable $paidAt,
    ) {}
}

abstract class Notification
{
    public function __construct(protected readonly NotificationChannel $channel) {}

    abstract public function send(string $recipient): void;
}

final class PaymentConfirmationNotification extends Notification
{
    public function __construct(
        NotificationChannel $channel,
        private readonly Payment $payment,
    ) {
        parent::__construct($channel);
    }

    public function send(string $recipient): void
    {
        $this->channel->send(
            $recipient,
            "Payment confirmed: {$this->payment->reference}",
            sprintf(
                "Your payment of %s %s has been confirmed.\nReference: %s\nDate: %s",
                number_format($this->payment->amountMinor / 100, 2),
                $this->payment->currency,
                $this->payment->reference,
                $this->payment->paidAt->format('Y-m-d H:i'),
            ),
        );
    }
}

Klas jest teraz M + N, cztery od powiadomień i trzy od kanałów. Nowy kanał to jedna klasa. Nowy typ powiadomienia też, i żadna z tych zmian nie rusza drugiej hierarchii.

O kombinacji kanału i powiadomienia decyduje się przy tworzeniu obiektu:

(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))
    ->send($user->email);

(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))
    ->send($user->phone);

W aplikacji to wszystko powinno siedzieć w jednym miejscu, zwykle w dispatcherze, który zna dostępne kanały i wie, jak zbudować każdy typ powiadomienia:

final class NotificationDispatcher
{
    /**
     * @param array<string, NotificationChannel> $channels
     * @param array<string, Closure(NotificationChannel, array): Notification> $factories
     */
    public function __construct(
        private readonly array $channels,
        private readonly array $factories,
    ) {}

    public function dispatch(string $type, array $payload, User $user): void
    {
        $factory = $this->factories[$type]
            ?? throw new InvalidArgumentException("Unknown notification type: {$type}");

        foreach ($user->enabledChannels() as $name) {
            $channel = $this->channels[$name]
                ?? throw new InvalidArgumentException("Unknown channel: {$name}");

            $factory($channel, $payload)->send($user->contactFor($name));
        }
    }
}

enabledChannels() i contactFor() to metody twojego modelu User. Dispatcher jest jedyną klasą, która zna obie hierarchie.

Kiedy wymiary nie są niezależne

Bridge zakłada, że każde powiadomienie ma sens w każdym kanale. Założenie się sypie, gdy któryś typ ma treść pasującą tylko do jednego kanału, jak raport tygodniowy w postaci tabeli HTML. Poznasz to po sprawdzaniu typu w abstrakcji:

final class WeeklyReportNotification extends Notification
{
    public function send(string $recipient): void
    {
        if ($this->channel instanceof SmsChannel) {
            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');
            return;
        }

        // renderHtmlReport() pominięta w przykładzie
        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());
    }
}

Przez to instanceof powiadomienie zależy od konkretnego kanału, czyli dokładnie od tego, przed czym wzorzec miał chronić. Od tej chwili przy każdym nowym kanale trzeba przejrzeć wszystkie powiadomienia i szukać takich gałęzi.

Jeśli dotyczy to jednego typu, najprościej przyznać, że ten przypadek ma kształt M × N, i napisać osobne klasy WeeklyReportEmailNotification i WeeklyReportSmsSummaryNotification.

Gdy osobnych wariantów dla kanałów potrzebuje wiele typów, trzeba zmienić kontrakt. Powiadomienie buduje obiekt wiadomości z kilkoma reprezentacjami (pełna treść, krótki tekst), a kanał bierze tę, którą umie dostarczyć.

Powiadomienia w Laravelu leżą gdzieś pośrodku. Klasa powiadomienia deklaruje kanały w via() i ma osobną metodę dla każdego z nich (toMail(), toArray() itd.), co daje do M × N metod w M klasach. Za to treść dla każdego kanału piszesz osobno i nie potrzebujesz instanceof.

Testy

Każdą hierarchię da się testować bez drugiej. Kanał sprawdzasz z zamockowanym klientem, powiadomienie z zamockowanym kanałem:

use PHPUnit\Framework\TestCase;

final class EmailChannelTest extends TestCase
{
    public function testDelegatesToMailer(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->once())
            ->method('send')
            ->with('[email protected]', 'Test', '<p>Body</p>');

        (new EmailChannel($mailer))->send('[email protected]', 'Test', '<p>Body</p>');
    }
}

final class PaymentConfirmationNotificationTest extends TestCase
{
    public function testBuildsSubjectAndBody(): void
    {
        $channel = $this->createMock(NotificationChannel::class);
        $channel->expects($this->once())
            ->method('send')
            ->with(
                '[email protected]',
                $this->stringContains('PAY-2024-001'),
                $this->stringContains('100.00'),
            );

        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));

        (new PaymentConfirmationNotification($channel, $payment))->send('[email protected]');
    }
}

Testów jest tyle, ile klas, i żaden nie potrzebuje prawdziwego serwera pocztowego ani API Slacka. W projekcie to dwa pliki, bo PHPUnit szuka w pliku klasy o tej samej nazwie. createMock(Mailer::class) zadziała, jeśli Mailer jest interfejsem albo klasą bez final.

Czy to się tu opłaci

Pierwszą wskazówką bywają nazwy klas. PaymentEmailNotification, PdfInvoiceExporter, CsvAuditLogFormatter sklejają po dwa pojęcia. Jeśli oba wymiary zmieniają się w czasie (nowe kanały i nowe typy wiadomości, nowe formaty eksportu i nowe raporty), a prawie każda kombinacja jest poprawna, Bridge się zwraca. Kiedy zmienia się tylko jeden wymiar, już nie. Przy jednym kanale wystarczy interfejs po stronie powiadomień. Gdy wiele kombinacji wymaga specjalnej obsługi, problem ma kształt M × N i jawne klasy albo osobne metody dla kanałów opisują go uczciwiej. A przy dwóch, trzech kombinacjach w sumie dodatkowa warstwa kosztuje więcej niż duplikacja, którą usuwa.

koniec artykułu