🧩 Розділ 2 · Питання #11

Як реалізований Observer у Java

Підписка на YouTube-канал або новинну розсилку. Автор відео (Subject) не знає кожного глядача особисто. Глядачі підписуються (subscribe), і коли виходить новий ролик, сповіщення...


🟢 Junior Level

Observer (Спостерігач / Видавець-Підписник) — це поведінковий патерн проєктування, який створює механізм підписки, що дозволяє одним об’єктам (спостерігачам / підписникам) стежити за подіями, які відбуваються в іншому об’єкті (суб’єкті / видавці), та автоматично реагувати на них.


Побутова аналогія

Підписка на YouTube-канал або новинну розсилку. Автор відео (Subject) не знає кожного глядача особисто. Глядачі підписуються (subscribe), і коли виходить новий ролик, сповіщення автоматично розсилається всім активним підписникам (Observers).


Класична реалізація на чистій Java

import java.util.ArrayList;
import java.util.List;

// 1. Інтерфейс спостерігача
public interface EventListener {
    void onEvent(String message);
}

// 2. Видавець (Subject)
public class EventPublisher {
    private final List<EventListener> listeners = new ArrayList<>();

    public void subscribe(EventListener listener) {
        listeners.add(listener);
    }

    public void unsubscribe(EventListener listener) {
        listeners.remove(listener);
    }

    public void notifyListeners(String data) {
        for (EventListener listener : listeners) {
            listener.onEvent(data);
        }
    }
}

// 3. Використання (включаючи лямбда-вирази Java 8+)
public class Main {
    public static void main(String[] args) {
        EventPublisher publisher = new EventPublisher();

        publisher.subscribe(msg -> System.out.println("Email-сервіс отримав: " + msg));
        publisher.subscribe(msg -> System.out.println("SMS-шлюз отримав: " + msg));

        publisher.notifyListeners("Замовлення #1042 успішно оплачено");
    }
}

🟡 Middle Level

Чому стандартні java.util.Observer та Observable оголошені Deprecated у Java 9?

Історично в пакеті java.util були присутні клас Observable та інтерфейс Observer. Починаючи з Java 9, вони офіційно позначені як @Deprecated із таких причин:

  1. Observable — це клас, а не інтерфейс: Оскільки в Java множинне наслідування класів заборонене, клас, який уже має батьківський клас, фізично не може стати Observable.
  2. Відсутність типобезпеки (Generics): Метод update(Observable o, Object arg) приймає сирий Object, що вимагає постійного небезпечного низхідного приведення типів (downcasting).
  3. Незручна модель прапорців: Перед викликом notifyObservers() програміст зобов’язаний вручну викликати метод setChanged(). Забутий виклик setChanged() призводив до мовчазної відсутності сповіщень.
  4. Не є потокобезпечним і серіалізованим: Внутрішня синхронізація застарілого Vector неефективна і не гарантує узгодженості в сучасних багатопотокових сценаріях.

Альтернатива зі стандартної Java SE: PropertyChangeSupport

У стандартну бібліотеку Java (пакет java.beans) входить вбудований та підтримуваний механізм відстеження змін властивостей:

import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;

public class OrderAccount {
    private final PropertyChangeSupport support = new PropertyChangeSupport(this);
    private String status = "NEW";

    public void addListener(PropertyChangeListener listener) {
        support.addPropertyChangeListener(listener);
    }

    public void removeListener(PropertyChangeListener listener) {
        support.removePropertyChangeListener(listener);
    }

    public void setStatus(String newStatus) {
        String oldStatus = this.status;
        this.status = newStatus;
        // Автоматично передає ім'я властивості, старе значення та нове значення
        support.firePropertyChange("status", oldStatus, newStatus);
    }
}

Типові виробничі пастки патерна Observer

1. Помилка ConcurrentModificationException під час відписки

Якщо слухач усередині методу onEvent() вирішує відписатися (publisher.unsubscribe(this)), класичний цикл for (EventListener l : listeners) негайно викине ConcurrentModificationException.

  • Рішення: Використання потокобезпечної колекції CopyOnWriteArrayList для збереження списку підписників або створення захисної локальної копії списку перед ітерацією.

2. Падіння одного слухача блокує сповіщення решти

Якщо один із підписників викидає необроблений виняток (RuntimeException), цикл переривається, і всі наступні підписники сповіщення не отримають.

  • Рішення: Ізоляція викликів у блоці try-catch всередині видавця.

3. Витік пам’яті (Проблема «Lapsed Listener»)

Видавець утримує сильне посилання (Strong Reference) на зареєстрованого слухача. Якщо життєвий цикл слухача коротший за життєвий цикл видавця, збирач сміття GC не зможе видалити слухача, доки той явно не відпишеться.

  • Рішення: Використання слабких посилань WeakReference<EventListener> або обов’язкова явна відписка під час завершення життєвого циклу об’єкта.

🔴 Senior Level

Enterprise Observer у Spring: @EventListener та @TransactionalEventListener

У корпоративних додатках пряме написання патерна Observer замінюється вбудованим брокером подій Spring Application Context:

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.math.BigDecimal;

// Подія (незмінний record)
public record OrderPaidEvent(Long orderId, BigDecimal amount) {}

// Видавець
@Service
public class PaymentProcessingService {
    private final ApplicationEventPublisher eventPublisher;

    public PaymentProcessingService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public void processPayment(Long orderId, BigDecimal amount) {
        // Бізнес-логіка транзакції списання коштів
        eventPublisher.publishEvent(new OrderPaidEvent(orderId, amount));
    }
}

// Слухачі подій
@Component
public class OrderNotificationListener {

    // Асинхронна обробка (не блокує основний HTTP-потік запиту)
    @Async
    @EventListener
    public void sendReceipt(OrderPaidEvent event) {
        // Відправка квитанції електронною поштою
    }

    // Виклик СУВОРО ПІСЛЯ успішного коміту транзакції бази даних
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void updateWarehouse(OrderPaidEvent event) {
        // Відправка запиту на склад лише у випадку, коли кошти реально списані й закомічені в БД!
    }
}

Фази @TransactionalEventListener:

  • BEFORE_COMMIT — безпосередньо перед фіксацією транзакції.
  • AFTER_COMMIT (за замовчуванням) — виключно в разі успішного коміту. Запобігає помилковій відправці сповіщень при відкаті (rollback) транзакції!
  • AFTER_ROLLBACK — у разі відкату (для запуску компенсаційних дій).
  • AFTER_COMPLETION — за будь-якого підсумку завершення транзакції.

Reactive Streams та Java 9 Flow API (Реактивний Observer)

Класичний Observer має фундаментальний недолік: Push-модель без контролю швидкості. Якщо видавець генерує 1 000 000 повідомлень на секунду, а підписник встигає обробити лише 100, у підписника переповнюється буфер і настає аварійне падіння (OOM: Java heap space).

Специфікація Reactive Streams (реалізована в java.util.concurrent.Flow у Java 9+, а також у Project Reactor / RxJava) доповнює Observer механізмом Backpressure (зворотного тиску):

Publisher (Видавець)  <─── Subscriber.onSubscribe(Subscription)
      │
      │ ◄─── Subscription.request(10) [Запит: надішли мені рівно 10 елементів!]
      ├───── Subscriber.onNext(data_1)
      ├───── Subscriber.onNext(data_2)
      │      ... (рівно 10 елементів)
      │
      │ ◄─── Subscription.request(5)  [Підписник звільнився і запитує ще 5]

Небезпека Deadlock: Виклик «чужих методів» (Alien Methods) під блокуванням

Джошуа Блох у праці Effective Java (Item 79) суворо застерігає від виклику методів слухачів усередині синхронізованих блоків:

// ❌ СМЕРТЕЛЬНО НЕБЕЗПЕЧНО: Виклик Alien Method всередині synchronized!
public synchronized void notifyObservers(Event event) {
    for (Listener l : listeners) {
        l.onEvent(event); // Якщо реалізація слухача спробує захопити інший лок,
                          // виникне взаємне блокування (Deadlock)!
    }
}

// ✅ ПРАВИЛЬНО: Відкриті виклики (Open Calls)
public void notifyObservers(Event event) {
    List<Listener> snapshot;
    synchronized (this) {
        snapshot = new ArrayList<>(listeners); // Знімок під коротким локом
    }
    for (Listener l : snapshot) {
        l.onEvent(event); // Виклик слухача без утримання монітора!
    }
}

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Патерн Observer (Спостерігач) реалізує слабку зв’язаність (loose coupling) між видавцем подій та множиною підписників. У Java стандартний клас java.util.Observable визнаний застарілим (@Deprecated у Java 9), тому в чистій Java використовують власні інтерфейси зі списками слухачів (на базі CopyOnWriteArrayList), а в Enterprise-середовищі — Spring Events (@EventListener, @Async, @TransactionalEventListener). Для високонавантажених асинхронних систем розвитком патерна Observer стала специфікація Reactive Streams / Java Flow API, яка додає механізм Backpressure для захисту повільних підписників від переповнення пам’яті».


3–4 каверзних запитання з відповідями

  1. Чому клас java.util.Observable був позначений як Deprecated у Java 9?
    • Відповідь: Observable реалізований як клас, а не інтерфейс, що робить неможливим його наслідування класами, які вже мають батьківський клас (через заборону множинного наслідування в Java). Крім того, він не підтримує Generics, вимагає обов’язкового виклику setChanged() і не гарантує повної потокобезпечності в сучасних середовищах.
  2. Що таке проблема «Lapsed Listener» і як вона призводить до витоків пам’яті?
    • Відповідь: Видавець зберігає сильні посилання (Strong References) на зареєстрованих слухачів. Якщо клієнтський код завершив роботу зі слухачем, але забув явно викликати unsubscribe(), збирач сміття GC не зможе видалити об’єкт слухача, оскільки він залишається досяжним із GC Root через колекцію видавця.
  3. У чому користь анотації @TransactionalEventListener(phase = AFTER_COMMIT) у Spring?
    • Відповідь: Звичайний @EventListener спрацьовує синхронно безпосередньо під час виконання транзакції. Якщо після публікації події транзакція бази даних відкотиться (Rollback), зовнішній світ уже отримає сповіщення (наприклад, користувачу піде лист про успішну оплату, яка фактично зірвалася). Фаза AFTER_COMMIT гарантує виконання слухача виключно після успішного підтвердження транзакції в БД.
  4. Що таке Backpressure у реактивній реалізації Observer (Flow API)?
    • Відповідь: Це механізм зворотного контролю, за якого підписник сам регулює обсяг вхідного потоку даних за допомогою виклику subscription.request(n). Видавець передає порцію даних лише тоді, коли підписник явно повідомив про готовність обробити чергову партію елементів, що виключає виникнення OutOfMemoryError.

Типові помилки та Red Flags

  • 🚩 Використання застарілих java.util.Observable та Observer у новому коді.
  • 🚩 Ітерація по звичайному ArrayList без захисту від конкурентної модифікації (відписка всередині виклику onEvent призведе до ConcurrentModificationException).
  • 🚩 Виклик методів підписників усередині блоку synchronized — гарантований ризик отримання Deadlock при складній ієрархії блокувань.
  • 🚩 Надсилання зовнішніх повідомлень (SMS/Email) із синхронного слухача без анотації @Async або черги задач, що блокує транзакції та HTTP-потоки додатка.

Пов’язані теми