Observer
Накратко: Observer позволява на един обект да известява списък от абонати, когато нещо стане, без да знае кои са те и какво правят.
Проблем
Плащането, което вика всички
Клиентът плаща поръчка #1042 за 80 €. След това трябва да тръгне разписка по имейл и SMS, складът да резервира стоката, а счетоводството да издаде фактура. Класът за плащанията прави всичко сам:
class Payments { private final EmailService email = new EmailService(); private final SmsService sms = new SmsService(); private final Warehouse warehouse = new Warehouse(); private final InvoiceService invoices = new InvoiceService();
public void markPaid(Order order) { email.sendReceipt(order); sms.notifyPaid(order); warehouse.reserve(order); invoices.issue(order); }}Всяка услуга е малък клас, който печата какво е направил:
Payments payments = new Payments();payments.markPaid(new Order(1042, 80));Имейл: разписка за #1042SMS: поръчка #1042 е платенаСклад: резервирай #1042Фактура: 80.00 € за #1042Работи. Но маркетингът иска точки за лоялност при всяко плащане. Това е още едно поле и още един ред в markPaid. Утре идва push известие, вдругиден статистика. Кодът, през който минава всяко плащане, се пипа заради неща, които нямат нищо общо с плащането.
По-лошото е друго. Днес SMS шлюзът не отговаря:
class SmsService { public void notifyPaid(Order order) { throw new IllegalStateException("SMS шлюзът не отговаря"); }}Payments payments = new Payments();try { payments.markPaid(new Order(1042, 80));} catch (IllegalStateException e) { System.out.println("Плащането се провали: " + e.getMessage());}Имейл: разписка за #1042Плащането се провали: SMS шлюзът не отговаряПарите са взети, а страницата казва на клиента, че плащането се е провалило. Складът не е резервирал нищо, фактура няма. Една услуга, която никой не смята за важна, спря цялото плащане.
Идеята
Observer: който иска да знае, се абонира
Плащанията не трябва да знаят кой се интересува от тях. Трябва да знаят само, че някой иска да научи, когато поръчка е платена.
Затова плащанията пазят списък с абонати. Всеки абонат има една и съща форма: интерфейс с един метод, който приема поръчката. Който иска да знае, се абонира отвън. При плащане обектът обикаля списъка и вика всеки абонат с поръчката.Това е шаблонът Observer (наблюдател). Има три роли:
- субектът е този, при когото става нещо. Тук това са плащанията. В книгите се казва още издател (publisher);
- абонатите (наблюдателите) искат да научат. Те са имейлът, SMS-ът, складът;
- известяването е обиколката на списъка: на всеки абонат се дава какво е станало, тук платената поръчка.
Нов абонат е нов код отвън. Субектът не се пипа. Абонатът може и да се отпише, и тогава повече не научава.
Виж го
Абонатите научават сами
PaidListener. Плащанията пазят списък от тях:record Order(int id, double total) {}
interface PaidListener { void paid(Order order);}
class Payments { private final List<PaidListener> listeners = new ArrayList<>();
public void subscribe(PaidListener listener) { listeners.add(listener); }
public void unsubscribe(PaidListener listener) { listeners.remove(listener); }
public void markPaid(Order order) { for (PaidListener listener : listeners) { listener.paid(order); } }}PaidListener има един метод, затова всеки абонат може да е ламбда:PaidListener email = order -> System.out.println("Имейл: разписка за #" + order.id());PaidListener sms = order -> System.out.println("SMS: поръчка #" + order.id() + " е платена");PaidListener warehouse = order -> System.out.println("Склад: резервирай #" + order.id());PaidListener loyalty = order -> System.out.println("Точки: +" + (int) order.total() + " за #" + order.id());Три абоната, плащане. После идват точките за лоялност, а клиентът изключва SMS-ите:
Payments payments = new Payments();payments.subscribe(email);payments.subscribe(sms);payments.subscribe(warehouse);payments.markPaid(new Order(1042, 80));
payments.subscribe(loyalty);payments.unsubscribe(sms);payments.markPaid(new Order(1043, 25));Имейл: разписка за #1042SMS: поръчка #1042 е платенаСклад: резервирай #1042Имейл: разписка за #1043Склад: резервирай #1043Точки: +25 за #1043Payments payments = new Payments();payments.subscribe(email);payments.subscribe(sms);payments.subscribe(warehouse);payments.markPaid(new Order(1042, 80));
payments.subscribe(loyalty);payments.unsubscribe(sms);payments.markPaid(new Order(1043, 25));Стъпка по стъпка, с думи
- Payments започва с празен списък listeners.
- email се абонира: влиза в списъка listeners.
- sms и warehouse също. Payments не знае кои са, само че приемат поръчка.
- Поръчка #1042 е платена. Известието тръгва към всеки абонат, по реда на абонирането.
- Един след друг, в същото извикване: разписка, SMS, резервация. Всеки абонат прави своето.
- Идват точките за лоялност: loyalty се абонира отвън. В Payments не се пипа нито ред.
- Клиентът изключва SMS-ите: sms се отписва и излиза от списъка.
- Поръчка #1043: известието стига до email, warehouse и loyalty. sms вече не е в списъка и не научава нищо.
Payments е един и същ код от началото до края. Точките се появиха, без да се пипне ред в него, а SMS-ът спря, без плащанията да разберат.
По-точно, за любопитните
- Абонатът може да е и препратка към метод:
payments.subscribe(mailer::sendReceipt). Но всякоmailer::sendReceiptправи нов обект.unsubscribeс ново::не намира стария в списъка и не маха нищо:
Payments payments = new Payments();EmailService mailer = new EmailService();payments.subscribe(mailer::sendReceipt);payments.unsubscribe(mailer::sendReceipt);payments.markPaid(new Order(1042, 80));Имейл: разписка за #1042Затова абонатът, когото ще отписваш, се пази в променлива, както sms по-горе.
- Абонатите се викат един след друг, в същото извикване на
markPaid. Бавен SMS шлюз бави и плащането. Големите магазини пращат събитието в опашка (message broker), а услугите го взимат оттам, когато са готови.
Виж го
Един абонат пада, другите не трябва
Observer махна връзката между плащането и SMS-а, но не и грешката. SMS абонатът хвърля:
PaidListener sms = order -> { throw new IllegalStateException("SMS шлюзът не отговаря");};Payments payments = new Payments();payments.subscribe(email);payments.subscribe(sms);payments.subscribe(warehouse);try { payments.markPaid(new Order(1042, 80));} catch (IllegalStateException e) { System.out.println("Плащането се провали: " + e.getMessage());}Имейл: разписка за #1042Плащането се провали: SMS шлюзът не отговаряАбонатите се викат в цикъл, и изключението от SMS-а излиза от цикъла. Складът пак не резервира, а грешката пак стига до плащането. Субектът трябва да пази всяко извикване поотделно:
public void markPaid(Order order) { for (PaidListener listener : listeners) { try { listener.paid(order); } catch (RuntimeException e) { System.out.println("Абонат се провали: " + e.getMessage()); } }}Със същата програма:
Payments payments = new Payments();payments.subscribe(email);payments.subscribe(sms);payments.subscribe(warehouse);try { payments.markPaid(new Order(1042, 80));} catch (IllegalStateException e) { System.out.println("Плащането се провали: " + e.getMessage());}Имейл: разписка за #1042Абонат се провали: SMS шлюзът не отговаряСклад: резервирай #1042Плащането мина, складът резервира, а провалът е записан. В истински магазин този ред отива в лога, а SMS-ът се пробва пак по-късно.
Пробвай
Предвиди изхода
Абонатите са от картата „Абонатите научават сами“. Какво печата програмата?
Payments payments = new Payments();payments.subscribe(email);payments.subscribe(warehouse);payments.subscribe(email);payments.markPaid(new Order(7, 10));
payments.unsubscribe(warehouse);payments.markPaid(new Order(8, 10));Пробвай
Намери грешката
class WelcomeCoupon implements PaidListener { private final Payments payments;
public WelcomeCoupon(Payments payments) { this.payments = payments; }
@Override public void paid(Order order) { System.out.println("Купон за добре дошъл: #" + order.id()); payments.unsubscribe(this); }}Payments payments = new Payments();payments.subscribe(new WelcomeCoupon(payments));payments.subscribe(email);payments.subscribe(warehouse);payments.markPaid(new Order(1042, 80));payments.markPaid(new Order(1043, 25));Купон за добре дошъл: #1042Exception in thread "main" java.util.ConcurrentModificationExceptionПървата поръчка получи купона, а после програмата спря. На кой ред в Payments е грешката?
Избери реда с грешката.
ArrayList забелязва, че списъкът е сменен по време на обиколката, и хвърля ConcurrentModificationException. Абонат, който се отписва, щом получи известие, е нещо нормално, затова субектът трябва да го понесе: обикаля копие, for (PaidListener listener : List.copyOf(listeners)). Тогава купонът се маха от истинския списък, а обиколката продължава по копието. Имейлът и складът научават и за двете поръчки, а купонът само за първата.По-точно, за любопитните
В C# и JavaScript тази грешка я няма, ако ползваш вградените средства. Делегатът на събитието в C# не се променя: -= прави нов списък, а текущото известяване продължава по стария. emit на EventEmitter копира списъка, преди да вика слушателите. Собствен списък трябва да се пази ръчно.
Идеята
Кога не ти трябва
Observer има цена. Кодът не казва кой ще реагира на плащането: за да разбереш, търсиш всички места, които абонират. Абонат, когото никой не отписва, живее в паметта, докато живее субектът. Не си струва, когато:
- реакцията е една и винаги същата. Едно извикване е по-ясно от списък с един абонат;
- стъпката е част от самото действие. Ако без фактура плащането е невалидно, фактурата не е абонат: вика се направо, а провалът ѝ спира плащането;
- редът има значение и стъпките зависят една от друга (първо склад, после плащане, после фактура). Това е последователност, не събитие. Как се събира на едно място, е в урока „Adapter и Facade“.
Струва си, когато реакциите са много, растат и субектът не трябва да зависи от тях: известия, точки, статистика, логове.
Упражнение
Фактурата като абонат
Реши задачата в редактора си.
Фактура. Направи фактурата абонат: ламбда invoice от тип PaidListener, която печата сумата с две цифри след точката. Абонирай имейла, склада и фактурата и плати поръчка #1042 за 80 €. Плащанията не се пипат.
Имейл: разписка за #1042Склад: резервирай #1042Фактура: 80.00 € за #1042Още задачи
- Подарък над 100 €. Абонат, който печата „Подарък: безплатна доставка за #…“ само за поръчки над 100 €. Плати една за 80 € и една за 120 €.
- Купонът, който се отписва сам. Напиши абоната за купона от „Намери грешката“ и плащания, които го понасят. Обикаляй копие на списъка.
- Две събития. Добави и връщане на пари. Втори списък с абонати и метод
markRefunded. Имейлът слуша и двете, складът само плащането.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди · record, switch и шаблони · Шаблони за дизайн · Strategy
- Продължава в: Магазинът: кой шаблон къде · Събития и опашки · Transactional outbox
- Задачи по темата: Шаблони за дизайн