Observer

Wzorce behawioralne

ukończona

1. Definicja

Obserwator to wzorzec behawioralny, który definiuje zależność jeden-do-wielu między obiektami w taki sposób, że gdy jeden obiekt (podmiot, ang. subject) zmienia swój stan, wszyscy jego obserwatorzy zostają o tym automatycznie powiadomieni i zaktualizowani.

Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. — Gang of Four, Design Patterns

Najprościej rzecz ujmując: jeden nadawca, wielu odbiorców, zero wzajemnej znajomości szczegółów implementacji.

Podmiot nie wie, ilu ma obserwatorów, ani co dokładnie robią z otrzymaną informacją. Wie tylko jedno - jeśli coś się zmieni, musi ich powiadomić.


2. Przykład z życia: zobaczmy jak to zrozumieć?

Wyobraźmy sobie prenumeratę gazety.

Redakcja wydaje kolejny numer i wysyła go do wszystkich prenumeratorów. Redakcja nie musi znać każdego czytelnika osobiście - nie wie, czy pan Kowalski czyta gazetę przy śniadaniu, a pani Nowak dopiero wieczorem. Redakcja zna tylko jedną rzecz: listę adresów prenumeratorów. Gdy pojawia się nowy numer, wysyła go pod każdy z tych adresów.

Co więcej, lista prenumeratorów jest dynamiczna:

  • ktoś może zapisać się na prenumeratę w dowolnym momencie,
  • ktoś może zrezygnować z prenumeraty,
  • redakcja może dodać zupełnie nowy kanał dystrybucji (np. wersję elektroniczną wysyłaną e-mailem), nie zmieniając niczego w procesie tworzenia gazety.

Zauważmy, co by się stało, gdyby redakcja działała inaczej - gdyby proces drukowania gazety wymagał, żeby redaktor naczelny osobiście znał każdego czytelnika i ręcznie decydował, w jaki sposób dostarczyć mu egzemplarz (SMS-em? e-mailem? gołębiem pocztowym?). Każdy nowy czytelnik lub nowy sposób dostawy wymagałby zmiany w sercu redakcji - w miejscu, gdzie tworzona jest gazeta.

Ćwiczenie myślowe: wyobraźmy sobie teraz sklep internetowy. Mamy produkt, który jest chwilowo niedostępny. Kilku klientów chce wiedzieć, kiedy produkt wróci na stan - jeden chce dostać e-mail, drugi SMS-a, trzeci powiadomienie push w aplikacji. Czy magazyn (czyli miejsce, które wie, że produkt wrócił na stan) powinien znać szczegóły każdego z tych kanałów komunikacji? Czy raczej powinien tylko krzyknąć "produkt wrócił!" i pozwolić, by każdy zainteresowany sam zadbał o odebranie tej wiadomości we właściwy dla siebie sposób?

Dokładnie to jest istotą wzorca Observer. Magazyn to nasz podmiot (subject), a kanały powiadomień to obserwatorzy (observers).


3. Przykład - naruszenie wzorca Observer

Załóżmy, że budujemy właśnie ten system powiadomień o dostępności produktu w naszym sklepie internetowym.

Pierwsze podejście, które przychodzi do głowy, wygląda tak:

class ProductStock {

    private int quantity;
    private EmailService emailService;
    private SmsService smsService;

    public ProductStock(EmailService emailService, SmsService smsService) {
        this.emailService = emailService;
        this.smsService = smsService;
    }

    public void restock(int newQuantity) {
        this.quantity = newQuantity;

        // magazyn samodzielnie decyduje, KOGO i JAK powiadomić
        if (quantity > 0) {
            emailService.sendEmail("Produkt wrócił na stan!");
            smsService.sendSms("Produkt wrócił na stan!");
        }
    }
}

Na pierwszy rzut oka działa - klient dostaje e-mail i SMS-a. Problem pojawia się w momencie, gdy dział marketingu prosi o dodanie trzeciego kanału - powiadomień push w aplikacji mobilnej.

public void restock(int newQuantity) {
        this.quantity = newQuantity;

        if (quantity > 0) {
            emailService.sendEmail("Produkt wrócił na stan!");
            smsService.sendSms("Produkt wrócił na stan!");
            pushService.sendPush("Produkt wrócił na stan!"); // musimy dodać nowe pole i zmienić konstruktor
        }
    }

Musimy zmodyfikować klasę ProductStock - dodać nowe pole, rozbudować konstruktor i dopisać nową linijkę w metodzie restock(). A co, jeśli za tydzień klient poprosi o możliwość wyłączenia powiadomień SMS, a za dwa tygodnie okaże się, że jeden konkretny klient VIP chce dostawać powiadomienie na dedykowany komunikator wewnętrzny?

Jakie problemy to powoduje?

  • Naruszenie Open/Closed Principle - każdy nowy kanał powiadomień wymaga edycji klasy ProductStock, zamiast jedynie jej rozszerzenia.
  • Sztywne powiązanie (tight coupling) - ProductStock musi znać konkretne klasy EmailService, SmsService, PushService. Magazynowi w ogóle nie powinno zależeć na tym, jak wygląda dostarczanie wiadomości.
  • Trudne testowanie - żeby przetestować logikę zmiany stanu magazynu, musimy podstawić mocki wszystkich usług powiadamiających, nawet jeśli test dotyczy wyłącznie liczby sztuk na stanie.
  • Brak dynamiki - nie możemy w czasie działania aplikacji dodać lub usunąć odbiorcy powiadomień (np. klient rezygnuje z subskrypcji "powiadom mnie") bez ingerencji w kod źródłowy.

4. Przykład - zgodny z Observer

Podejdźmy do tego tak, jak zrobiłaby to redakcja gazety - zamiast znać konkretne usługi, ProductStock będzie znał tylko listę obserwatorów, którzy mają wspólny, ujednolicony sposób odbierania wiadomości.

Zaczynamy od wspólnego interfejsu, który musi zaimplementować każdy zainteresowany "prenumerator":

interface StockObserver {
    void onRestock(String productName, int quantity);
}

Następnie definiujemy podmiot (subject), który przechowuje listę obserwatorów i potrafi ich powiadomić, ale nie wie nic więcej na ich temat:

class ProductStock {

    private int quantity;
    private final String productName;
    private final List<StockObserver> observers = new ArrayList<>();

    public ProductStock(String productName) {
        this.productName = productName;
    }

    // dowolny obserwator może dołączyć w dowolnym momencie
    public void subscribe(StockObserver observer) {
        observers.add(observer);
    }

    // ...i równie łatwo się wypisać
    public void unsubscribe(StockObserver observer) {
        observers.remove(observer);
    }

    public void restock(int newQuantity) {
        this.quantity = newQuantity;

        if (quantity > 0) {
            notifyObservers();
        }
    }

    // podmiot krzyczy "produkt wrócił!" - nie wie, kto słucha ani jak zareaguje
    private void notifyObservers() {
        for (StockObserver observer : observers) {
            observer.onRestock(productName, quantity);
        }
    }
}

Teraz każdy kanał powiadomień staje się osobną klasą implementującą StockObserver:

class EmailNotifier implements StockObserver {

    private final EmailService emailService;

    public EmailNotifier(EmailService emailService) {
        this.emailService = emailService;
    }

    @Override
    public void onRestock(String productName, int quantity) {
        emailService.sendEmail("Produkt \"" + productName + "\" wrócił na stan!");
    }
}

class SmsNotifier implements StockObserver {

    private final SmsService smsService;

    public SmsNotifier(SmsService smsService) {
        this.smsService = smsService;
    }

    @Override
    public void onRestock(String productName, int quantity) {
        smsService.sendSms("Produkt \"" + productName + "\" wrócił na stan!");
    }
}

A dodanie powiadomień push nie wymaga już żadnej zmiany w ProductStock - po prostu tworzymy nową klasę i subskrybujemy ją:

class PushNotifier implements StockObserver {

    private final PushService pushService;

    public PushNotifier(PushService pushService) {
        this.pushService = pushService;
    }

    @Override
    public void onRestock(String productName, int quantity) {
        pushService.sendPush("Produkt \"" + productName + "\" wrócił na stan!");
    }
}

Użycie wygląda następująco:

ProductStock stock = new ProductStock("Klawiatura mechaniczna");

stock.subscribe(new EmailNotifier(new EmailService()));
stock.subscribe(new SmsNotifier(new SmsService()));

// w dowolnym momencie możemy dodać kolejnego obserwatora...
StockObserver push = new PushNotifier(new PushService());
stock.subscribe(push);

// ...albo się z niego wypisać
stock.unsubscribe(push);

stock.restock(15); // powiadomi wszystkich aktualnie zasubskrybowanych

Co się zmieniło krok po kroku?

  1. ProductStock przestał znać konkretne klasy usług - zna tylko interfejs StockObserver.
  2. Dodanie nowego kanału powiadomień to dodanie nowej klasy, a nie edycja istniejącej (zgodność z OCP).
  3. Lista obserwatorów jest dynamiczna - subskrypcję można dodawać i usuwać w czasie działania aplikacji.
  4. Testowanie ProductStock można teraz zrobić przy pomocy prostego mocka implementującego StockObserver, bez angażowania prawdziwych usług e-mail czy SMS.

5. Diagram struktury

┌────────────────────────┐
│   <<interface>>        │
│   StockObserver        │
│----------------------- │
│ + onRestock(name, qty) │
└─────────────────▲──────┘
                  │ implements
     ┌────────────┼───────────────┐
     │            │               │
┌────┴─────┐ ┌────┴──────┐ ┌──────┴─────┐
│EmailNotif│ │SmsNotifier│ │PushNotifier│
└──────────┘ └───────────┘ └────────────┘
     ▲            ▲              ▲
     └────────────┼──────────────┘
                  │ subscribe/unsubscribe
                  │ notify (pętla po liście)
          ┌───────┴──────────┐
          │  ProductStock    │
          │ (Subject)        │
          │----------------- │
          │ - observers: List│
          │ + subscribe()    │
          │ + unsubscribe()  │
          │ + restock()      │
          └──────────────────┘

Podmiot (ProductStock) trzyma listę obiektów typu StockObserver i wywołuje na nich tę samą metodę - nie musi wiedzieć, która konkretna implementacja kryje się pod interfejsem.


6. Dlaczego Observer jest ważny?

Luźne powiązanie (loose coupling) - podmiot i obserwatorzy komunikują się wyłącznie przez wspólny interfejs. Podmiot nie musi znać klas konkretnych - wystarczy, że obserwator "umie" odebrać powiadomienie.

Otwartość na rozszerzenia - nowy kanał powiadomień (np. Slack, webhook, komunikator wewnętrzny) to nowa klasa, a nie ingerencja w istniejący, przetestowany kod magazynu.

Dynamika w czasie działania aplikacji - subskrypcje można dodawać i usuwać "w locie", co jest niemożliwe, gdy logika powiadamiania jest zaszyta na sztywno w jednej metodzie.

Rozdzielenie odpowiedzialności w zespole - zespół odpowiedzialny za logikę magazynową nie musi w ogóle wiedzieć, jak wygląda integracja z dostawcą SMS-ów. Zespół od powiadomień pracuje niezależnie, implementując tylko jeden interfejs.

Realna konsekwencja zaniedbania - w dużych systemach e-commerce brak tego wzorca (lub jego odpowiednika, np. systemu eventów) prowadzi do klas "Boga" (God classes), które przy każdej nowej integracji rozrastają się i stają się coraz trudniejsze do bezpiecznego zmieniania - każda zmiana niesie ryzyko zepsucia niepowiązanej funkcjonalności.


7. Kiedy stosować?

  • Systemy powiadomień - e-mail, SMS, push, webhooki reagujące na zdarzenia biznesowe (zmiana stanu magazynu, status zamówienia, aktualizacja ceny).
  • Interfejsy graficzne (GUI) - obsługa zdarzeń typu onClick, onChange to w praktyce wzorzec Observer - komponent UI (podmiot) powiadamia zarejestrowane listenery o interakcji użytkownika.
  • Systemy zdarzeniowe we frameworkach - Spring udostępnia mechanizm ApplicationEventPublisher i adnotację @EventListener, które są gotową implementacją tego wzorca - publikujemy zdarzenie, a Spring powiadamia wszystkich zarejestrowanych słuchaczy.
  • Java Standard Library - starszy interfejs java.util.Observer/Observable (obecnie oznaczony jako deprecated) był historyczną, wbudowaną implementacją tego wzorca. Współcześnie częściej korzysta się z PropertyChangeListener (pakiet java.beans) albo bibliotek reaktywnych.
  • Systemy kolejkowe i komunikacja asynchroniczna - architektura pub/sub (np. Redis Pub/Sub, Kafka) to ten sam wzorzec rozciągnięty na poziom całej infrastruktury, a nie tylko pojedynczego procesu.

Kiedy NIE stosować (lub stosować ostrożnie)?

  • Gdy liczba obserwatorów jest bardzo duża, a powiadomienia są synchroniczne - może to prowadzić do zauważalnych opóźnień (jeden wolny obserwator blokuje powiadomienie kolejnych). W takich przypadkach warto rozważyć powiadamianie asynchroniczne lub kolejkę zdarzeń.
  • Gdy zależności między obserwatorami stają się skomplikowane (obserwator A wywołuje zmianę, która powiadamia obserwatora B, który z kolei modyfikuje podmiot) - grozi to trudnymi do namierzenia efektami kaskadowymi i pętlami powiadomień.
  • Gdy potrzebujemy gwarancji kolejności lub transakcyjności powiadomień - podstawowy wzorzec Observer tego nie zapewnia; potrzebna byłaby dodatkowa logika lub inny mechanizm (np. kolejka komunikatów z gwarancjami dostarczenia).

"Redakcja krzyczy w eter, nie zna czytelników z imienia" - podmiot ogłasza fakt zmiany, a każdy obserwator sam decyduje, co z tą informacją zrobić.