Observer
Накратко: Observer позволява на един обект да известява абонатите си, когато нещо стане, без да знае кои са те; в C# това е събитие (event).
Проблем
Плащането, което вика всички
Клиентът плаща поръчка #1042 за 80 €. След това трябва да тръгне разписка по имейл и SMS, складът да резервира стоката, а счетоводството да издаде фактура. Класът за плащанията прави всичко сам:
class Payments{ private readonly EmailService email = new EmailService(); private readonly SmsService sms = new SmsService(); private readonly Warehouse warehouse = new Warehouse(); private readonly InvoiceService invoices = new InvoiceService();
public void MarkPaid(Order order) { email.SendReceipt(order); sms.NotifyPaid(order); warehouse.Reserve(order); invoices.Issue(order); }}Всяка услуга е малък клас, който печата какво е направил:
var payments = new Payments();payments.MarkPaid(new Order(1042, 80m));Имейл: разписка за #1042SMS: поръчка #1042 е платенаСклад: резервирай #1042Фактура: 80.00 € за #1042Работи. Но маркетингът иска точки за лоялност при всяко плащане. Това е още едно поле и още един ред в MarkPaid. Утре идва push известие, вдругиден статистика. Кодът, през който минава всяко плащане, се пипа заради неща, които нямат нищо общо с плащането.
По-лошото е друго. Днес SMS шлюзът не отговаря:
class SmsService{ public void NotifyPaid(Order order) { throw new InvalidOperationException("SMS шлюзът не отговаря"); }}var payments = new Payments();try{ payments.MarkPaid(new Order(1042, 80m));}catch (InvalidOperationException e){ Console.WriteLine("Плащането се провали: " + e.Message);}Имейл: разписка за #1042Плащането се провали: SMS шлюзът не отговаряПарите са взети, а страницата казва на клиента, че плащането се е провалило. Складът не е резервирал нищо, фактура няма. Една услуга, която никой не смята за важна, спря цялото плащане.
Идеята
Observer: който иска да знае, се абонира
Плащанията не трябва да знаят кой се интересува от тях. Трябва да знаят само, че някой иска да научи, когато поръчка е платена.
Затова плащанията имат събитие (event): списък с абонати, вграден в езика. Всеки абонат има една и съща форма, Action<Order>: функция, която приема поръчката. Който иска да знае, се абонира отвън с +=. При плащане обектът пуска събитието и то вика всеки абонат с поръчката.Това е шаблонът Observer (наблюдател). Има три роли:
- субектът е този, при когото става нещо. Тук това са плащанията. В книгите се казва още издател (publisher);
- абонатите (наблюдателите) искат да научат. Те са имейлът, SMS-ът, складът;
- известяването е обиколката на списъка: на всеки абонат се дава какво е станало, тук платената поръчка.
Нов абонат е нов код отвън. Субектът не се пипа. Абонатът може и да се отпише, и тогава повече не научава.
Виж го
Абонатите научават сами
Paid. Paid?.Invoke(order) вика всеки абонат. Без абонати събитието е null, затова е ?.:record Order(int Id, decimal Total);
class Payments{ public event Action<Order> Paid;
public void MarkPaid(Order order) { Paid?.Invoke(order); }}Action<Order>:Action<Order> email = order => Console.WriteLine($"Имейл: разписка за #{order.Id}");Action<Order> sms = order => Console.WriteLine($"SMS: поръчка #{order.Id} е платена");Action<Order> warehouse = order => Console.WriteLine($"Склад: резервирай #{order.Id}");Action<Order> loyalty = order => Console.WriteLine($"Точки: +{(int)order.Total} за #{order.Id}");Три абоната, плащане. После идват точките за лоялност, а клиентът изключва SMS-ите:
var payments = new Payments();payments.Paid += email;payments.Paid += sms;payments.Paid += warehouse;payments.MarkPaid(new Order(1042, 80m));
payments.Paid += loyalty;payments.Paid -= sms;payments.MarkPaid(new Order(1043, 25m));Имейл: разписка за #1042SMS: поръчка #1042 е платенаСклад: резервирай #1042Имейл: разписка за #1043Склад: резервирай #1043Точки: +25 за #1043var payments = new Payments();payments.Paid += email;payments.Paid += sms;payments.Paid += warehouse;payments.MarkPaid(new Order(1042, 80m));
payments.Paid += loyalty;payments.Paid -= sms;payments.MarkPaid(new Order(1043, 25m));Стъпка по стъпка, с думи
- Събитието Paid още няма нито един абонат.
- email се абонира с +=: влиза в събитието Paid.
- sms и warehouse също. Payments не знае кои са, само че приемат поръчка.
- Поръчка #1042 е платена. Известието тръгва към всеки абонат, по реда на абонирането.
- Един след друг, в същото извикване: разписка, SMS, резервация. Всеки абонат прави своето.
- Идват точките за лоялност: loyalty се абонира отвън. В Payments не се пипа нито ред.
- Клиентът изключва SMS-ите: sms се отписва и излиза от списъка.
- Поръчка #1043: известието стига до email, warehouse и loyalty. sms вече не е в списъка и не научава нищо.
Payments е един и същ код от началото до края. Точките се появиха, без да се пипне ред в него, а SMS-ът спря, без плащанията да разберат.
По-точно, за любопитните
- Отвън събитието може само да се абонира (
+=) и да се отпише (-=). Да го пусне или да изтрие всички абонати може само класът, който го има. С обикновено поле от типAction<Order>всеки би могъл да напишеpayments.Paid = null. - Отписваш същата ламбда, която си абонирал, затова тя е в променлива (
sms).-=с нова ламбда, дори със същия код, не маха нищо. - В библиотеките на .NET събитията са от тип
EventHandler<T>: абонатът получава и кой е пуснал събитието (sender). Ще го срещнеш в WPF и в уеб рамките. Идеята е същата. - Абонатите се викат един след друг, в същото извикване на
MarkPaid. Бавен SMS шлюз бави и плащането. Големите магазини пращат събитието в опашка (message broker), а услугите го взимат оттам, когато са готови.
Виж го
Един абонат пада, другите не трябва
Observer махна връзката между плащането и SMS-а, но не и грешката. SMS абонатът хвърля:
Action<Order> sms = order => throw new InvalidOperationException("SMS шлюзът не отговаря");var payments = new Payments();payments.Paid += email;payments.Paid += sms;payments.Paid += warehouse;try{ payments.MarkPaid(new Order(1042, 80m));}catch (InvalidOperationException e){ Console.WriteLine("Плащането се провали: " + e.Message);}Имейл: разписка за #1042Плащането се провали: SMS шлюзът не отговаряАбонатите се викат в цикъл, и изключението от SMS-а излиза от цикъла. Складът пак не резервира, а грешката пак стига до плащането. Субектът трябва да пази всяко извикване поотделно:
public void MarkPaid(Order order){ if (Paid == null) { return; } foreach (Action<Order> listener in Paid.GetInvocationList()) { try { listener(order); } catch (Exception e) { Console.WriteLine("Абонат се провали: " + e.Message); } }}Със същата програма:
var payments = new Payments();payments.Paid += email;payments.Paid += sms;payments.Paid += warehouse;try{ payments.MarkPaid(new Order(1042, 80m));}catch (InvalidOperationException e){ Console.WriteLine("Плащането се провали: " + e.Message);}Имейл: разписка за #1042Абонат се провали: SMS шлюзът не отговаряСклад: резервирай #1042Плащането мина, складът резервира, а провалът е записан. В истински магазин този ред отива в лога, а SMS-ът се пробва пак по-късно.
Paid?.Invoke(order) вика абонатите един след друг и спира при първото изключение. GetInvocationList() дава абонатите поотделно, за да хванеш грешката на всеки.Пробвай
Предвиди изхода
Абонатите са от картата „Абонатите научават сами“. Какво печата програмата?
var payments = new Payments();payments.Paid += email;payments.Paid += warehouse;payments.Paid += email;payments.MarkPaid(new Order(7, 10m));
payments.Paid -= warehouse;payments.MarkPaid(new Order(8, 10m));Пробвай
Намери грешката
var payments = new Payments();payments.MarkPaid(new Order(1042, 80m));Console.WriteLine("Извлечението е обработено");Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.На кой ред е грешката?
Избери реда с грешката.
null. Paid.Invoke(order) вика метод на null и хвърля NullReferenceException. Нощната задача пада на първата поръчка. Поправката е един знак: Paid?.Invoke(order) не вика нищо, когато абонати няма. Абонатите не са задължителни, затова субектът не трябва да зависи от това дали ги има.По-точно, за любопитните
Абонат, който се отписва по време на известяването, е безопасен в C#. Делегатът на събитието не се променя: -= прави нов списък, а текущото известяване продължава по стария:
var payments = new Payments();Action<Order> coupon = null;coupon = order =>{ Console.WriteLine($"Купон за добре дошъл: #{order.Id}"); payments.Paid -= coupon;};payments.Paid += coupon;payments.Paid += email;payments.MarkPaid(new Order(1042, 80m));payments.MarkPaid(new Order(1043, 25m));Купон за добре дошъл: #1042Имейл: разписка за #1042Имейл: разписка за #1043Със собствен List<Action<Order>> и foreach същото хвърля InvalidOperationException (Collection was modified). В Java е ConcurrentModificationException, а в Python абонатът след отписания се прескача без грешка.
Идеята
Кога не ти трябва
Observer има цена. Кодът не казва кой ще реагира на плащането: за да разбереш, търсиш всички места, които абонират. Абонат, когото никой не отписва, живее в паметта, докато живее субектът. Не си струва, когато:
- реакцията е една и винаги същата. Едно извикване е по-ясно от списък с един абонат;
- стъпката е част от самото действие. Ако без фактура плащането е невалидно, фактурата не е абонат: вика се направо, а провалът ѝ спира плащането;
- редът има значение и стъпките зависят една от друга (първо склад, после плащане, после фактура). Това е последователност, не събитие. Как се събира на едно място, е в урока „Adapter и Facade“.
Струва си, когато реакциите са много, растат и субектът не трябва да зависи от тях: известия, точки, статистика, логове.
Упражнение
Фактурата като абонат
Реши задачата в редактора си.
Фактура. Направи фактурата абонат: ламбда invoice от тип Action<Order>, която печата сумата с две цифри след точката. Абонирай имейла, склада и фактурата и плати поръчка #1042 за 80 €. Плащанията не се пипат.
Имейл: разписка за #1042Склад: резервирай #1042Фактура: 80.00 € за #1042Още задачи
- Подарък над 100 €. Абонат, който печата „Подарък: безплатна доставка за #…“ само за поръчки над 100 €. Плати една за 80 € и една за 120 €.
- Купонът, който се отписва сам. Напиши абоната за купона от „Намери грешката“ и плащания, които го понасят. Провери, че с
eventработи и без промяна, после пробвай със собственList<Action<Order>>и виж грешката. - Две събития. Добави и връщане на пари. Второ събитие
Refundedи методMarkRefunded. Имейлът слуша и двете, складът само плащането.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди и делегати · record, switch и шаблони · Шаблони за дизайн · Strategy
- Продължава в: Магазинът: кой шаблон къде · Събития и опашки · Transactional outbox
- Задачи по темата: Шаблони за дизайн