Към съдържанието
wannacode

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));
Изход
Имейл: разписка за #1042
SMS: поръчка #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));
Изход
Имейл: разписка за #1042
SMS: поръчка #1042 е платена
Склад: резервирай #1042
Имейл: разписка за #1043
Склад: резервирай #1043
Точки: +25 за #1043

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));
Стъпка по стъпка, с думи
  1. Събитието Paid още няма нито един абонат.
  2. email се абонира с +=: влиза в събитието Paid.
  3. sms и warehouse също. Payments не знае кои са, само че приемат поръчка.
  4. Поръчка #1042 е платена. Известието тръгва към всеки абонат, по реда на абонирането.
  5. Един след друг, в същото извикване: разписка, SMS, резервация. Всеки абонат прави своето.
  6. Идват точките за лоялност: loyalty се абонира отвън. В Payments не се пипа нито ред.
  7. Клиентът изключва SMS-ите: sms се отписва и излиза от списъка.
  8. Поръчка #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));
Отговор

Пробвай

Намери грешката

Нощна задача маркира като платени поръчките от банковото извлечение. Имейли и SMS-и не трябват, затова никой не се абонира:
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.

На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните

Абонат, който се отписва по време на известяването, е безопасен в 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. Имейлът слуша и двете, складът само плащането.

Тест

Провери се

1. Магазинът добавя точки за лоялност при всяко плащане. Какво пишеш при Observer?
2. Абонатът за SMS хвърля изключение, а известяването не е защитено. Какво става?
3. Един и същ абонат е абониран два пъти. Колко пъти научава за едно плащане?
4. Фактурата трябва да е издадена, иначе плащането не се брои. Подходящ ли е тук Observer?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·