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:
def __init__(self):
self.email = EmailService()
self.sms = SmsService()
self.warehouse = Warehouse()
self.invoices = InvoiceService()
def mark_paid(self, order):
self.email.send_receipt(order)
self.sms.notify_paid(order)
self.warehouse.reserve(order)
self.invoices.issue(order)

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

payments = Payments()
payments.mark_paid(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 mark_paid. 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:
def notify_paid(self, order):
raise ConnectionError("La pasarela de SMS no responde")
payments = Payments()
try:
payments.mark_paid(Order(1042, 80))
except ConnectionError as e:
print(f"El pago falló: {e}")
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 función 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

Los pagos guardan una lista de funciones:
from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
id: int
total: float
class Payments:
def __init__(self):
self._listeners = []
def subscribe(self, listener):
self._listeners.append(listener)
def unsubscribe(self, listener):
self._listeners.remove(listener)
def mark_paid(self, order):
for listener in self._listeners:
listener(order)
Cada suscriptor es una función corriente que recibe el pedido:
def email(order):
print(f"Correo: recibo de #{order.id}")
def sms(order):
print(f"SMS: pedido #{order.id} está pagado")
def warehouse(order):
print(f"Almacén: reserva #{order.id}")
def loyalty(order):
print(f"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()
payments.subscribe(email)
payments.subscribe(sms)
payments.subscribe(warehouse)
payments.mark_paid(Order(1042, 80))
payments.subscribe(loyalty)
payments.unsubscribe(sms)
payments.mark_paid(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()
payments.subscribe(email)
payments.subscribe(sms)
payments.subscribe(warehouse)
payments.mark_paid(Order(1042, 80))
payments.subscribe(loyalty)
payments.unsubscribe(sms)
payments.mark_paid(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 puede ser cualquier cosa que se pueda llamar: una función, una lambda, un método de un objeto. payments.subscribe(mailer.send_receipt) suscribe el método de un objeto concreto. Escrito dos veces, mailer.send_receipt da valores iguales, así que unsubscribe(mailer.send_receipt) lo encuentra y lo quita. El pago siguiente no imprime nada:
payments = Payments()
mailer = EmailService()
payments.subscribe(mailer.send_receipt)
payments.unsubscribe(mailer.send_receipt)
payments.mark_paid(Order(1042, 80))
  • Python no tiene un Observer integrado. Una lista de funciones es lo habitual. El nombre _listeners con guion bajo dice «no lo toques desde fuera, usa subscribe».
  • Los suscriptores se llaman uno tras otro, en la misma llamada a mark_paid. 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:

def sms(order):
raise ConnectionError("La pasarela de SMS no responde")
payments = Payments()
payments.subscribe(email)
payments.subscribe(sms)
payments.subscribe(warehouse)
try:
payments.mark_paid(Order(1042, 80))
except ConnectionError as e:
print(f"El pago falló: {e}")
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:

def mark_paid(self, order):
for listener in self._listeners:
try:
listener(order)
except Exception as e:
print(f"Un suscriptor falló: {e}")

Con el mismo programa:

payments = Payments()
payments.subscribe(email)
payments.subscribe(sms)
payments.subscribe(warehouse)
try:
payments.mark_paid(Order(1042, 80))
except ConnectionError as e:
print(f"El pago falló: {e}")
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()
payments.subscribe(email)
payments.subscribe(warehouse)
payments.subscribe(email)
payments.mark_paid(Order(7, 10))
payments.unsubscribe(warehouse)
payments.mark_paid(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:
def welcome_coupon(order):
print(f"Cupón de bienvenida: #{order.id}")
payments.unsubscribe(welcome_coupon)
payments = Payments()
payments.subscribe(welcome_coupon)
payments.subscribe(email)
payments.subscribe(warehouse)
payments.mark_paid(Order(1042, 80))
payments.mark_paid(Order(1043, 25))
Salida
Cupón de bienvenida: #1042
Almacén: reserva #1042
Correo: recibo de #1043
Almacén: reserva #1043

El pedido #1042 no recibió el recibo por correo. ¿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 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. Recorre una copia de la lista.
  • Dos eventos. Añade también los reembolsos. Un diccionario nombre de evento → lista de suscriptores. 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 ·