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 Node.js esto es un EventEmitter.

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 {
constructor() {
this.email = new EmailService();
this.sms = new SmsService();
this.warehouse = new Warehouse();
this.invoices = new InvoiceService();
}
markPaid(order) {
this.email.sendReceipt(order);
this.sms.notifyPaid(order);
this.warehouse.reserve(order);
this.invoices.issue(order);
}
}

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

const payments = new Payments();
payments.markPaid({ id: 1042, total: 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 {
notifyPaid(order) {
throw new Error("La pasarela de SMS no responde");
}
}
const payments = new Payments();
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`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 son un EventEmitter del módulo integrado node:events: un objeto que guarda oyentes por nombre de evento. Cada oyente es una función que recibe el pedido. Quien quiere enterarse escucha desde fuera el evento "paid" con on. En un pago, el objeto emite el evento con emit, y este llama a cada oyente 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 extienden EventEmitter. emit("paid", order) llama a cada oyente de "paid":
import { EventEmitter } from "node:events";
class Payments extends EventEmitter {
markPaid(order) {
this.emit("paid", order);
}
}
Cada oyente es una función flecha que recibe el pedido:
const email = (order) => console.log(`Correo: recibo de #${order.id}`);
const sms = (order) => console.log(`SMS: pedido #${order.id} está pagado`);
const warehouse = (order) => console.log(`Almacén: reserva #${order.id}`);
const loyalty = (order) => console.log(`Puntos: +${Math.floor(order.total)} por #${order.id}`);

Tres suscriptores, un pago. Luego llegan los puntos de fidelidad, y el cliente desactiva los SMS:

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
payments.markPaid({ id: 1042, total: 80 });
payments.on("paid", loyalty);
payments.off("paid", sms);
payments.markPaid({ id: 1043, total: 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

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
payments.markPaid({ id: 1042, total: 80 });
payments.on("paid", loyalty);
payments.off("paid", sms);
payments.markPaid({ id: 1043, total: 25 });
Paso a paso, en palabras
  1. Todavía nadie escucha el evento "paid".
  2. email escucha con on: entra en los oyentes de "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
  • EventEmitter también tiene once: un oyente que se quita solo tras el primer evento. En el navegador la misma idea es EventTarget: un botón tiene addEventListener("click", ...), y un clic llama a cada oyente.
  • Quitas la misma función que pusiste, así que está en una variable (sms). off con una función flecha nueva, aunque tenga el mismo código, no quita nada.
  • Los oyentes 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:

const sms = (order) => {
throw new Error("La pasarela de SMS no responde");
};
const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`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:

markPaid(order) {
for (const listener of this.listeners("paid")) {
try {
listener(order);
} catch (e) {
console.log(`Un suscriptor falló: ${e.message}`);
}
}
}

Con el mismo programa:

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`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.

emit llama a los oyentes uno tras otro y se detiene en la primera excepción. this.listeners("paid") te da una copia de la lista de oyentes, 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?

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", warehouse);
payments.on("paid", email);
payments.markPaid({ id: 7, total: 10 });
payments.off("paid", warehouse);
payments.markPaid({ id: 8, total: 10 });
Respuesta

Pruébalo

Encuentra el error

El almacén está suscrito, pero no reserva nada. El programa imprime solo:
Salida
Correo: recibo de #1042

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

Elige la línea con el error.

Más precisamente, para curiosos

Un oyente que se quita a sí mismo durante el aviso es seguro con EventEmitter: emit copia la lista antes de llamar a los oyentes. once hace justo eso:

const payments = new Payments();
payments.once("paid", (order) => console.log(`Cupón de bienvenida: #${order.id}`));
payments.on("paid", email);
payments.markPaid({ id: 1042, total: 80 });
payments.markPaid({ id: 1043, total: 25 });
Salida
Cupón de bienvenida: #1042
Correo: recibo de #1042
Correo: recibo de #1043

Con un array propio de funciones y for...of, lo mismo se salta el oyente que sigue al quitado, y en Java lanza ConcurrentModificationException.

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 función invoice 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. Hazlo con once, luego con un array propio, y mira qué oyente se salta.
  • Dos eventos. Añade también los reembolsos. El 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 ·