Wzorzec projektowy Most

Wzorzec projektowy Most

Opublikowano Kategorie Czysty kodCzas czytania 7min

Wzorzec projektowy Most (Bridge) to jeden ze strukturalnych wzorców projektowych opisanych przez Gang of Four. W tym artykule poznasz zasadę działania tego wzorca oraz jego przykładowe zastosowania. Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce projektowe, to koniecznie sprawdź mój wpis o wzorcach projektowych. Przykłady kodu w artykule, przygotowane zostały w TypeScripcie.

Charakterystyka wzorca

Gang of Four tym razem moim zdaniem nie postarał się z prostą definicją wzorca Most:

Decouple an abstraction from its implementation so that the two can vary independently.

Co prawda jest ona krótka i zwięzła jednak moim zdaniem bez kontekstu lub przykładu jest trudna w zrozumieniu. By lepiej zrozumieć przedstawioną definicję, warto najpierw dowiedzieć się jaki problem rozwiązuje wzorzec. Stosuje się go, gdy mamy abstrakcję będącą wysokopoziomową warstwą sterującą określonym procesem w systemie oraz implementacje, które ta abstrakcja wykorzystuje. Naszym celem jest tak zaprojektować te dwa elementy aplikacji, by wyeliminować coupling między nimi i pozwalać na ich niezależny rozwój. Myślę, że sporo rozjaśni przedstawienie kilku przykładów:

  • Abstrakcją jest pilot (RemoteController) a wykorzystywane implementacje to: pilot do TV, pilot do dekodera, pilot do bramy garażowej. Chcemy zaprojektować jedną abstrakcję pilota z metodami np. on(), off(), toggle() i niezależne klasy implementujące komunikację z poszczególnymi urządzeniami.
  • Abstrakcją jest klasa obsługująca płatności z metodą pay() a wykorzystywane implementacje to metody płatności: Karta, BLIK, PayPal.
  • Abstrakcja to mechanizm generowania raportów a implementacje to format danych np. PDF, XLSX, CSV. Samych typów raportów może być więcej. Na przykład raport sprzedaży czy raport zapasów. Wraz z dodawaniem kolejnych typów raportów czy formatów danych liczba możliwych kombinacji będzie rosła w bardzo szybkim tempie. Most pozwala zapanować nad tą złożonością.

W każdym z przedstawionych przypadków chcemy móc niezależnie rozwijać abstrakcję, opisującą CO się ma zadziać w systemie od tego, W JAKI SPOSÓB ma to zostać zrobione. Dzięki temu łatwo można dodać lub usunąć poszczególne implementacje. Również samą abstrakcję można rozwijać niezależnie od wykorzystywanych implementacji. Dodatkowo nie wymagamy od użytkownika abstrakcji (np. kodu realizującego logikę biznesową) dostosowania się do naszych zmian.

Praktyczny przykład

Załóżmy, że w rozwijanym systemie potrzebujemy zaimplementować system notyfikacji. Jest to system logistyczny, do którego klienci wprowadzają zamówienia, a sprzedawcy je przetwarzają i aktualizują ich stan. Logika biznesowa odpowiedzialna za poszczególne scenariusze znajduje się w klasach *Handler np. SendOrderHandler. Na sam początek chcemy wspierać notyfikacje za pomocą e-mail oraz SMS z możlwością dodania kolejnych np. powiadomienia push. W zależności od akcji w systemie chcemy wysyłać różne rodzaje powiadomień. Pewne akcje mają skutkować wysłaniem SMS a inne wysłaniem e-maila. Przeanalizujmy, jakie mamy możliwości.

Pierwsza z nich to skorzystanie z możliwości programowania obiektowego i stworzenie abstrakcyjnej klasy bazowej Notifier i dwóch klas dziedziczących po niej — EmailNotifierSmsNotifier. Następnie można ich użyć, wstrzykując odpowiednią klasę w odpowiednie miejsce.


class SendOrderHandler {
    constructor(
        private readonly _notifier: Notifier
    ) {}

    public handle( order: IOrder, user: IUser ): void {
        // ....

        this._notifier.notify( user, message );
    }
}

Z jednej strony SendOrderHandler nie wie jakiego typu notyfikację wysyła, co jest dobre. Problem, jaki widzę w tym podejściu to wykorzystanie dziedziczenia. Osobiście nie jestem fanem dziedziczenia i staram się go unikać na rzecz kompozycji. Problem stanowi też dla mnie fakt, że Notifier jest jedynie klasą odpowiedzialną za wysłanie powiadomienia. Odpowiedzialność konstrukcji powiadomienia spoczywa na Handlerze.

Drugim podejściem jest stworzenie jednej klasy Notifier zawierającą logikę związaną z wysyłką wszystkich rodzajów powiadomień.


class SendOrderHandler {
    constructor(
        private readonly _notifier: Notifier
    ) {}

    public handle( order: IOrder, user: IUser ): void {
        // ....

        this._notifier.sendEmail( user, message );
        this._notifier.sendSms( user, message );
    }
}

Plusem jest brak dziedziczenia. Jednak minusów jest tu więcej niż zalet. Do kodu SendOrderHandler wypływa odpowiedzialność definiowania jaki rodzaj notyfikacji ma zostać wysłany. Mamy tu więc złamanie SRP. Dodatkowo między HandleremNotfierem powstał coupling. Handler zależy teraz od interfejsu, jaki definiuje Notifier. Przez to np. usunięcie lub dodanie konkretnego sposobu powiadamiania powoduje konieczność dostosowania kodu Handlerów. Ponownie też dochodzi konieczność definiowania treści wiadomości.

Trzecim i moim zdaniem odpowiednim podejściem jest skorzystanie ze wzorca Most. Bazując na charakterystyce wzorca, przygotowałem bazowy interfejs do opisania metod powiadamiania użytkownika oraz interfejs bazowy dla Notifiera. Interfejs INotifier definiuje CO system ma zrobić – wysłać powiadomienie.  Z kolei INotificationMethod definiuje W JAKI SPOSÓB to zrobić. Na ich podstawie stworzyłem klasy do poszczególnych metod powiadamiania oraz dla konkretnych zdarzeń w systemie. Każdemu zdarzeniu przypisany został konkretny rodzaj powiadomienia.


interface IUser {
    phone: string;
    email: string;
}

const testUser = { phone: '123456789', email: 'user(at)example.com' };

interface INotificationMethod {
    notify( user: IUser, message: string ): void;
}

class EmailNotificationMethod implements INotificationMethod {
    notify( user: IUser, message: string ): void {
        console.log( `Email sent to ${ user.email }: ${ message }` );
    }
}

class SMSNotificationMethod implements INotificationMethod {
    notify( user: IUser, message: string ): void {
        console.log( `SMS sent to ${ user.phone }: ${ message }` );
    }
}

interface INotifier {
    notify( user: IUser ): void;
}

class OrderSentNotifier implements INotifier {
    constructor( protected readonly _notificationMethod: INotificationMethod ) {}

    notify( user: IUser ): void {
        console.log( 'Your order has been sent!' );
        this._notificationMethod.notify( user, 'Your order has been sent!' );
    }
}

class OrderCreatedNotifier implements INotifier {
    constructor(protected readonly _notificationMethod: INotificationMethod) {}

    notify( user: IUser ): void {
        console.log( 'Your order has been created!' );
        this._notificationMethod.notify( user, 'Your order has been created!' );
    }
}

const emailMethod = new EmailNotificationMethod();
const smsMethod = new SMSNotificationMethod();

const emailOrderNotifier = new OrderCreatedNotifier( emailMethod );
emailOrderNotifier.notify( testUser );

const smsShipmentNotifier = new OrderSentNotifier( smsMethod );
smsShipmentNotifier.notify( testUser );

EmailNotificationMethod oraz SMSNotificationMethod to klasy, które dostarczają szczegóły, w jaki sposób powiadomienie zostanie wysłane. Można w łatwy sposób dodawać nowe metody powiadomień np. PushNotificationMethod bez dotykania pozostałych klas.

OrderSentNotifier oraz OrderCreatedNotifier to klasy, które definiują, jakie wysłać powiadomienie. Nie martwią się o sposób, w jaki zostanie to zrobione. Struktura ta umożliwia dodawanie kolejnych Notifierów dla innych przypadków. Między klasami powstał „most” łączący abstrakcję (mechanizm powiadamiania) z implementacją (sposób wysłania).

Dzięki temu podejściu z kodu Handlera znika konieczność definiowania treści wiadomości oraz sposobu powiadamiania. Handler robi tylko to co musi, czyli inicjuje wysłanie powiadomienia. Nie musi przy tym wiedzieć, jakie to jest powiadomienie i jaka jest jego treść.


class SendOrderHandler {
    constructor( private readonly _notifier: INotifier ) {}
    
    public handle( order: IOrder, user: IUser ): void {
        // ....
        this._notifier.notify( user );
    }
}

By być uczciwym, warto wspomnieć również o wadach. Wykorzystanie Mostu w przypadku prostej aplikacji będzie nadmiarowe i warto rozważyć jego wdrożenie dopiero wtedy, gdy dostrzeżemy taką potrzebę. Jest to jednak moim zdaniem wada większości wzorców. Wzorce projektowe są fajne, ale KISS i YAGNI są jeszcze fajniejsze.

Podsumowanie

Most to jeden z tych wzorców, dla których nie jest trudno znaleźć przykłady miejsc, gdzie warto rozważyć jego wdrożenie. Zalety wynikające z jego wykorzystania pomagają tworzyć kod spełniający zasady SRP i OCP. Dodatkowo połączenie go z Fabryką może dać naprawdę fajne rezultaty. W podanym przykładzie, chcąc wspierać wiele sposobów wysyłki powiadomień, można by spróbować wykorzystać Strategię.

Zachęcam do podzielenia się swoimi przemyśleniami co do wzorca w komentarzu i sprawdzenia pozostałych artykułów z serii o wzorcach projektowych. Zachęcam również do sprawdzenia źródeł i materiałów dodatkowych, które pozwolą na poznanie wzorca z innych perspektyw.

Książkę Design Patterns: Elements of Reusable Object-Oriented Software możesz kupić, klikając TEN link. Kupując z tego linku, wspierasz rozwój bloga.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Notify of
guest

2 komentarzy
Most Voted
Newest Oldest
Inline Feedbacks
View all comments
Marcin
Marcin
26 days ago

w sumie nieco podobny do strategii, tak jak rozumiem strategia bardziej się sprawdzi kiedy potrzebujemy w ramach danej implementacji zrobić coś raz a dobrze a tutaj w ramach już mechanizmu który łapie generyczną klasę (tego handlera w przykładzie) to po wykonaniu już implementacji można elegancko jeszcze coś dorobić żeby się robiło zawsze w ramach logiki wspólnej (w ramach kompozycji – w świecie javy to ma znaczenie – bo możnaby się alternatywnie pokusić o użycie klasy abstrakcyjnej i zrobienie wspólnej logiki co jest mocno takie sobie ;-))

Fajny artykuł 🙂