Ir al contenido
wannacode

Observer

En resumen: Observer permite que un objeto avise a sus suscriptores cuando pasa algo, sin saber quiénes son; en C# esto es un evento.

Problema

El pago que llama a todos

Un cliente paga el pedido #1042 de 80 €. Después hay que enviar un recibo por correo y por SMS, el almacén tiene que reservar la mercancía y contabilidad tiene que emitir una factura. La clase de los pagos lo hace todo ella misma:

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);
}
}

Cada servicio es una clase pequeña que imprime lo que hizo:

var payments = new Payments();
payments.MarkPaid(new Order(1042, 80m));
Salida
Correo: recibo de #1042
SMS: pedido #1042 está pagado
Almacén: reserva #1042
Factura: 80.00 € por #1042

Funciona. Pero marketing quiere puntos de fidelidad en cada pago. Eso es un campo más y una línea más en MarkPaid. Mañana llega una notificación push, pasado mañana, estadísticas. Se toca el código por el que pasa cada pago para cosas que no tienen nada que ver con el pago.

Peor aún, hoy la pasarela de SMS no responde:

class SmsService
{
public void NotifyPaid(Order order)
{
throw new InvalidOperationException("La pasarela de SMS no responde");
}
}
var payments = new Payments();
try
{
payments.MarkPaid(new Order(1042, 80m));
}
catch (InvalidOperationException e)
{
Console.WriteLine("El pago falló: " + e.Message);
}
Salida
Correo: recibo de #1042
El pago falló: La pasarela de SMS no responde

El dinero está cobrado, y la página le dice al cliente que el pago falló. El almacén no reservó nada, no hay factura. Un servicio que nadie considera importante detuvo todo el pago.

La idea

Observer: quien quiere enterarse se suscribe

Los pagos no deben saber a quién le importan. Solo deben saber que alguien quiere enterarse de cuándo se paga un pedido.

Así que los pagos tienen un evento (event): una lista de suscriptores integrada en el lenguaje. Cada suscriptor tiene la misma forma, Action<Order>: una función que recibe el pedido. Quien quiere enterarse se suscribe desde fuera con +=. En un pago, el objeto lanza el evento, y este llama a cada suscriptor con el pedido.

Este es el patrón Observer. Tiene tres roles:

  • el sujeto es donde pasa algo. Aquí son los pagos. En los libros también se llama publicador (publisher);
  • los suscriptores (observadores) quieren enterarse. Son el correo, el SMS, el almacén;
  • el aviso es el recorrido por la lista: a cada suscriptor se le da lo que pasó, aquí el pedido pagado.

Un suscriptor nuevo es código nuevo fuera. El sujeto no se toca. Un suscriptor también puede darse de baja, y entonces ya no oye nada.

Míralo

Los suscriptores se enteran solos

Los pagos tienen el evento Paid. Paid?.Invoke(order) llama a cada suscriptor. Sin suscriptores el evento es null, de ahí el ?.:
record Order(int Id, decimal Total);
class Payments
{
public event Action<Order> Paid;
public void MarkPaid(Order order)
{
Paid?.Invoke(order);
}
}
Cada suscriptor es una lambda de tipo Action<Order>:
Action<Order> email = order => Console.WriteLine($"Correo: recibo de #{order.Id}");
Action<Order> sms = order => Console.WriteLine($"SMS: pedido #{order.Id} está pagado");
Action<Order> warehouse = order => Console.WriteLine($"Almacén: reserva #{order.Id}");
Action<Order> loyalty = order => Console.WriteLine($"Puntos: +{(int)order.Total} por #{order.Id}");

Tres suscriptores, un pago. Luego llegan los puntos de fidelidad, y el cliente desactiva los 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));
Salida
Correo: recibo de #1042
SMS: pedido #1042 está pagado
Almacén: reserva #1042
Correo: recibo de #1043
Almacén: reserva #1043
Puntos: +25 por #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));
Paso a paso, en palabras
  1. El evento Paid todavía no tiene ningún suscriptor.
  2. email se suscribe con +=: entra en el evento Paid.
  3. sms y warehouse también. Payments no sabe quiénes son, solo que reciben un pedido.
  4. El pedido #1042 está pagado. El aviso sale hacia cada suscriptor, en el orden en que se suscribieron.
  5. Uno tras otro, en la misma llamada: recibo, SMS, reserva. Cada suscriptor hace lo suyo.
  6. Llegan los puntos de fidelidad: loyalty se suscribe desde fuera. En Payments no cambia ni una línea.
  7. El cliente desactiva los SMS: sms se da de baja y sale de la lista.
  8. Pedido #1043: el aviso llega a email, warehouse y loyalty. sms ya no está en la lista y no se entera de nada.

Payments es el mismo código de principio a fin. Los puntos aparecieron sin que cambiara una sola línea, y los SMS se detuvieron sin que los pagos lo notaran.

Más precisamente, para curiosos
  • Desde fuera, al evento solo se puede suscribir (+=) y darse de baja (-=). Solo la clase que lo posee puede lanzarlo o borrar todos los suscriptores. Con un campo simple de tipo Action<Order>, cualquiera podría escribir payments.Paid = null.
  • Das de baja la misma lambda a la que te suscribiste, así que está en una variable (sms). -= con una lambda nueva, aunque tenga el mismo código, no quita nada.
  • En las bibliotecas de .NET los eventos son de tipo EventHandler<T>: el suscriptor recibe también quién lanzó el evento (sender). Lo verás en WPF y en los frameworks web. La idea es la misma.
  • Los suscriptores se llaman uno tras otro, en la misma llamada a MarkPaid. Una pasarela de SMS lenta frena también el pago. Las tiendas grandes mandan el evento a una cola (un message broker), y los servicios lo recogen de allí cuando están listos.

Míralo

Un suscriptor falla, los demás no deberían

Observer quitó el vínculo entre el pago y el SMS, pero no el error. El suscriptor de SMS lanza una excepción:

Action<Order> sms = order => throw new InvalidOperationException("La pasarela de SMS no responde");
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("El pago falló: " + e.Message);
}
Salida
Correo: recibo de #1042
El pago falló: La pasarela de SMS no responde

Los suscriptores se llaman en un bucle, y la excepción del SMS sale del bucle. El almacén sigue sin reservar nada, y el error sigue llegando al pago. El sujeto tiene que proteger cada llamada por separado:

public void MarkPaid(Order order)
{
if (Paid == null)
{
return;
}
foreach (Action<Order> listener in Paid.GetInvocationList())
{
try
{
listener(order);
}
catch (Exception e)
{
Console.WriteLine("Un suscriptor falló: " + e.Message);
}
}
}

Con el mismo programa:

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("El pago falló: " + e.Message);
}
Salida
Correo: recibo de #1042
Un suscriptor falló: La pasarela de SMS no responde
Almacén: reserva #1042

El pago pasó, el almacén reservó, y el fallo queda registrado. En una tienda real esta línea va al log, y el SMS se reintenta más tarde.

Paid?.Invoke(order) llama a los suscriptores uno tras otro y se detiene en la primera excepción. GetInvocationList() te da los suscriptores uno a uno, para que puedas capturar el error de cada uno.

Pruébalo

¿Qué saldrá?

Los suscriptores son los de la tarjeta «Los suscriptores se enteran solos». ¿Qué imprime el programa?

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));
Respuesta

Pruébalo

Encuentra el error

Un proceso nocturno marca como pagados los pedidos del extracto bancario. No hacen falta correos ni SMS, así que nadie se suscribe:
var payments = new Payments();
payments.MarkPaid(new Order(1042, 80m));
Console.WriteLine("El extracto bancario está procesado");
Error
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.

¿En qué línea está el error?

Elige la línea con el error.

Más precisamente, para curiosos

Un suscriptor que se da de baja durante el aviso es seguro en C#. El delegado del evento no cambia: -= crea una lista nueva, y el aviso en curso sigue sobre la vieja:

var payments = new Payments();
Action<Order> coupon = null;
coupon = order =>
{
Console.WriteLine($"Cupón de bienvenida: #{order.Id}");
payments.Paid -= coupon;
};
payments.Paid += coupon;
payments.Paid += email;
payments.MarkPaid(new Order(1042, 80m));
payments.MarkPaid(new Order(1043, 25m));
Salida
Cupón de bienvenida: #1042
Correo: recibo de #1042
Correo: recibo de #1043

Con una List<Action<Order>> propia y foreach, lo mismo lanza InvalidOperationException (Collection was modified). En Java es ConcurrentModificationException, y en Python el suscriptor que sigue al quitado se salta sin ningún error.

La idea

Cuándo no lo necesitas

Observer tiene un precio. El código no dice quién reaccionará al pago: para saberlo, buscas cada lugar que se suscribe. Un suscriptor al que nadie da de baja vive en memoria mientras viva el sujeto. No compensa cuando:

  • hay una sola reacción y siempre es la misma. Una llamada es más clara que una lista con un suscriptor;
  • el paso es parte de la propia acción. Si el pago no es válido sin factura, la factura no es un suscriptor: se llama directamente, y si falla, detiene el pago;
  • el orden importa y los pasos dependen unos de otros (primero el almacén, luego el pago, luego la factura). Eso es una secuencia, no un evento. Cómo reunirla en un solo lugar está en la lección «Adapter y Facade».

Compensa cuando las reacciones son muchas, crecen y el sujeto no debe depender de ellas: notificaciones, puntos, estadísticas, logs.

Ejercicio

La factura como suscriptor

Resuelve la tarea en tu editor.

Factura. Haz de la factura un suscriptor: una lambda invoice de tipo Action<Order> que imprima el importe con dos cifras después de la coma. Suscribe el correo, el almacén y la factura, y paga el pedido #1042 de 80 €. Los pagos no se tocan.

Salida
Correo: recibo de #1042
Almacén: reserva #1042
Factura: 80.00 € por #1042

Más tareas

  • Un regalo a partir de 100 €. Un suscriptor que imprima «Regalo: envío gratis para #…» solo para pedidos de más de 100 €. Paga uno de 80 € y otro de 120 €.
  • El cupón que se da de baja solo. Escribe el suscriptor del cupón de «Encuentra el error» y unos pagos que lo manejen. Comprueba que funciona con event sin cambios, y luego prueba una List<Action<Order>> propia y mira el error.
  • Dos eventos. Añade también los reembolsos. Un segundo evento Refunded y un método MarkRefunded. El correo escucha los dos, el almacén solo el pago.

Test

Ponte a prueba

1. La tienda añade puntos de fidelidad en cada pago. ¿Qué escribes con Observer?
2. El suscriptor de SMS lanza una excepción y el aviso no está protegido. ¿Qué pasa?
3. El mismo suscriptor está suscrito dos veces. ¿Cuántas veces se entera de un pago?
4. La factura debe emitirse, o el pago no cuenta. ¿Encaja Observer aquí?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·