Ir al contenido
wannacode

Observer

En resumen: Observer permite que un objeto avise a una lista de suscriptores cuando pasa algo, sin saber quiénes son ni qué hacen.

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

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

Payments payments = new Payments();
payments.markPaid(new Order(1042, 80));
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 IllegalStateException("La pasarela de SMS no responde");
}
}
Payments payments = new Payments();
try {
payments.markPaid(new Order(1042, 80));
} catch (IllegalStateException e) {
System.out.println("El pago falló: " + e.getMessage());
}
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 llevan una lista de suscriptores. Cada suscriptor tiene la misma forma: una interfaz con un método que recibe el pedido. Quien quiere enterarse se suscribe desde fuera. En un pago, el objeto recorre la lista y 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

Un suscriptor es un PaidListener. Los pagos guardan una lista de ellos:
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 tiene un solo método, así que cada suscriptor puede ser una lambda:
PaidListener email = order -> System.out.println("Correo: recibo de #" + order.id());
PaidListener sms = order -> System.out.println("SMS: pedido #" + order.id() + " está pagado");
PaidListener warehouse = order -> System.out.println("Almacén: reserva #" + order.id());
PaidListener loyalty = order -> System.out.println("Puntos: +" + (int) order.total() + " por #" + order.id());

Tres suscriptores, un pago. Luego llegan los puntos de fidelidad, y el cliente desactiva los 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));
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

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));
Paso a paso, en palabras
  1. Payments empieza con una lista listeners vacía.
  2. email se suscribe: entra en la lista listeners.
  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
  • Un suscriptor también puede ser una referencia a un método: payments.subscribe(mailer::sendReceipt). Pero cada mailer::sendReceipt crea un objeto nuevo. unsubscribe con un :: nuevo no encuentra el viejo en la lista y no quita nada:
Payments payments = new Payments();
EmailService mailer = new EmailService();
payments.subscribe(mailer::sendReceipt);
payments.unsubscribe(mailer::sendReceipt);
payments.markPaid(new Order(1042, 80));
Salida
Correo: recibo de #1042

Por eso un suscriptor al que vas a dar de baja se guarda en una variable, como sms arriba.

  • 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:

PaidListener sms = order -> {
throw new IllegalStateException("La pasarela de SMS no responde");
};
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("El pago falló: " + e.getMessage());
}
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) {
for (PaidListener listener : listeners) {
try {
listener.paid(order);
} catch (RuntimeException e) {
System.out.println("Un suscriptor falló: " + e.getMessage());
}
}
}

Con el mismo programa:

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("El pago falló: " + e.getMessage());
}
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.

Pruébalo

¿Qué saldrá?

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

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

Pruébalo

Encuentra el error

El cupón de bienvenida se da solo en el primer pedido pagado. Así que el suscriptor del cupón se da de baja a sí mismo en cuanto lo envía:
class WelcomeCoupon implements PaidListener {
private final Payments payments;
public WelcomeCoupon(Payments payments) {
this.payments = payments;
}
@Override
public void paid(Order order) {
System.out.println("Cupón de bienvenida: #" + 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));
Error
Cupón de bienvenida: #1042
Exception in thread "main" java.util.ConcurrentModificationException

El primer pedido recibió el cupón, y luego el programa se detuvo. ¿Qué línea de Payments tiene el error?

Elige la línea con el error.

Más precisamente, para curiosos

En C# y JavaScript este error no ocurre si usas las herramientas integradas. El delegado de un evento de C# no cambia: -= crea una lista nueva, y el aviso en curso sigue sobre la vieja. emit del EventEmitter copia la lista antes de llamar a los oyentes. Una lista propia tienes que protegerla a mano.

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 PaidListener 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. Recorre una copia de la lista.
  • Dos eventos. Añade también los reembolsos. Una segunda lista de suscriptores 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 ·