Disfrutando del Command Bus

por Fran Iglesias

En el artículo anterior, presentamos el concepto de Command Bus y construimos uno sencillo para introducirlo

Antes de empezar la segunda parte de esta serie voy a organizar un poco el código en un repositorio y darle un poco de estructura.

Aquí tenemos el repositorio

En este capítulo, veremos como podemos usar el Command Bus para cambiar el comportamiento de la aplicación sin tocar su código, simplemente añadiendo nuevos ejecutores. Y, por otro lado,

Cambiando la aplicación con el Command Bus

Cambiar el comportamiento de nuestro reloj sin tocar su código es posible gracias al uso del Command Bus.

Estrictamente hablando, vamos a tocar el código. Pero lo que es importante destacar es que con este tipo de arquitecturas podemos hacer que esos cambios sean menos problemáticos y no destructivos. Vamos a ver como:

Cambiar el comportamiento, cambiando la configuración de los componentes

Supongamos que queremos que el reloj emita sus mensajes en inglés. En este fragmento de bin/alarm_clock podemos ver que tendríamos que tocar GoodMorningHandler y GoodNightHandler.

spanish = Spanish.new
spanish_language = SpanishLanguage.new(spanish)

resolver = Resolver.new
resolver.register('GoodMorningCommand', GoodMorningHandler.new(display, spanish_language))
resolver.register('GoodNightCommand', GoodNightHandler.new(display, spanish_language))

Pues no los vamos a tocar, solo los vamos a configurar de manera diferente. Esta es la forma más sencilla:

spanish_language = SpanishLanguage.new(Spanish.new)
english_language = EnglishLanguage.new(English.new)

resolver = Resolver.new
resolver.register('GoodMorningCommand', GoodMorningHandler.new(display, english_language))
resolver.register('GoodNightCommand', GoodNightHandler.new(display, english_language))

Aquí tienes el nuevo idioma inglés:

class English
  def wakeup
    'Good morning'
  end

  def bed
    'Good night'
  end
end

Gracias al patrón adaptador no tenemos que preocuparnos de que la librería English tiene una interfaz diferente.

class EnglishLanguage < Language
  def initialize(english)
    @english = english
  end

  def good_morning
    @english.wakeup
  end

  def good_night
    @english.bed
  end
end

Este cambio no es específico del uso de Command Bus, pero ilustra la idea de que podemos cambiar el comportamiento de la aplicación añadiendo código y reduciendo los cambios a áreas que tienen que ver con la configuración del sistema. En este caso, de los ejecutores en que estamos interesadas.

Cambiar el comportamiento añadiendo ejecutores

Ahora queremos que, en lugar de mostrar la hora con números, nuestro reloj muestre los emojis de la hora. Esto nos remite aquí, donde asociamos el comando a un

resolver = Resolver.new

# Code removed for clarity

resolver.register('ShowTimeCommand', ShowTimeHandler.new(display))
ShowTimeCommand = Struct.new(:time)

class ShowTimeHandler
  def initialize(display)
    @display = display
  end

  def execute(command)
    raise ArgumentError, 'invalid command' unless command.is_a? ShowTimeCommand

    @display.show("#{command.time}:00")
  end
end

Pero lo mejor es introducir otro ejecutor para el comando ShowTimeCommand. Uno que sepa mostrar emojis:

class EmojiShowTimeHandler
  def initialize(display)
    @display = display
  end

  def execute(command)
    raise ArgumentError, 'invalid command' unless command.is_a? ShowTimeCommand

    emojis = [
      "🕛", "🕐", "🕑", '🕒', "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚"
    ]
    pick = command.time % 12
    emoji = emojis[pick]

    @display.show("#{emoji}")
  end
end

Y únicamente tenemos que usar el nuevo ejecutor:

resolver = Resolver.new

# Code removed for clarity

resolver.register('ShowTimeCommand', EmojiShowTimeHandler.new(display))

Y esto es lo que obtenemos. Hay un par de errores porque el comando que muestra los textos de alarma no usa emojis.

🕛
      Playing... Beep! Beep!
🕐
      Playing... Beep! Beep!
🕑
      Playing... Beep! Beep!
🕒
      Playing... Beep! Beep!
🕓
      Playing... Beep! Beep!
🕔
      Playing... Beep! Beep!
🕕
      Playing... Beep! Beep!
7:00 -> Good morning
      Playing... Sounding Alarm!!!
🕗
      Playing... Beep! Beep!
🕘
      Playing... Beep! Beep!
🕙
      Playing... Beep! Beep!
🕚
      Playing... Beep! Beep!
🕛
      Playing... Beep! Beep!
🕐
      Playing... Beep! Beep!
🕑
      Playing... Beep! Beep!
🕒
      Playing... Beep! Beep!
🕓
      Playing... Beep! Beep!
🕔
      Playing... Beep! Beep!
🕕
      Playing... Beep! Beep!
🕖
      Playing... Beep! Beep!
🕗
      Playing... Beep! Beep!
🕘
      Playing... Beep! Beep!
22:00 -> Good night
🕚
      Playing... Beep! Beep!

Bueno, siempre podríamos introducir otros ejecutores que usen emojis. O extraer esa funcionalidad de mostrar las horas como emojis y como números a otros objetos que podamos inyectar y usar como sea necesario.

Es como una forma de immutabilidad: para modificar el comportamiento de un ejecutor o arreglar un bug, no tenemos más que introducir uno nuevo con el nuevo comportamiento o con el fix. No hay necesidad de lidiar con modificaciones de código que nos puedan generar nuevos problemas.

Algunas consideraciones

Esto debería ser un acicate para hacer ejecutores muy simples, que deleguen lo más posible en otros objetos para que se encarguen solo de coordinar u orquestar. Si los ejecutores son complicados no solo nos costaría modificarlos, sino también nos podría desanimar a escribir otros nuevos para reemplazarlos.

Para el mantenimiento de los ejecutores que dejan de usarse. Podríamos usar feature toggles, configuración u opciones en la línea de comandos, dependiendo de nuestras intenciones, para activar o desactivar su uso con facilidad.

language = case language_option
when 'spanish'
             SpanishLanguage.new(Spanish.new)
           when 'english'
             EnglishLanguage.new(English.new)
           end

show_time_executor = case display_option
               when 'standard'
                 ShowTimeHandler.new(display)
               when 'emoji'
                 ShowTimeHandler.new(display)
               end

resolver = Resolver.new
resolver.register('GoodMorningCommand', GoodMorningHandler.new(display, language))
resolver.register('GoodNightCommand', GoodNightHandler.new(display, language))
resolver.register('PlayAlarmCommand', PlayAlarmHandler.new(sound))
resolver.register('PlayBeepCommand', PlayBeepHandler.new(sound))
resolver.register('ShowTimeCommand', show_time_executor)

Si no quieres mantener los servicios no usados, una vez comprobado que todo funciona puedes marcarlos como deprecated, comunicando así el mensaje de que debe dejar de usarse y eliminarlo en un plazo razonable.

Todo esto forma parte del setup de la aplicación, pero como puedes ver ninguno de estos cambios de comportamiento implica un cambio en la lógica de los ejecutores. Simplemente, introducimos cambios añadiendo código. Todo lo demás, son ajustes de configuración, mucho menos costosos y con muchísimo menos riesgo.

Disfrutando con middlewares

Supongamos que la aplicación crece. Llega un momento en que es difícil sabe qué está pasando, incluso si has configurado bien el Resolver, etc. Podrías querer registrar toda la actividad de la aplicación en un log, o quizá chequear que un usuario está autorizado a realizar una cierta acción y un largo etcétera de cosas que no forman parte del dominio de la aplicación, sino que son cuestiones técnicas y que debes realizar para todas las acciones.

¿No sería genial poder interceptar de algún modo los comandos y hacer algo antes o después de ejecutar cada uno? Pues eso es lo que hacen los middlewares.

Se trata de unos objetos que colaboran con el Command Bus y que nos permiten hacer cosas antes y después de ejecutar cada comando. Están estructurados en una cadena o más bien una pipeline.

Para empezar a entenderlo vamos a ir poco a poco. Antes de introducir los middlewares como tales, vamos a ver como reproducir su comportamiento con el propio Command Bus.

Nuestro primer ejemplo será hacer un log de todo lo que ocurre. Para ello, registraremos el comando recibido en el log. Y cuando haya terminado, añadiremos otra línea con el resultado.

Esto es lo que tenemos ahora:

class CommandBus
  def initialize(resolver)
    @resolver = resolver
  end

  def execute(command)
    executor = @resolver.executor_for(command)
    executor.execute(command)
  end
end

Esto es lo que queremos hacer:

class CommandBus
  def initialize(resolver)
    @resolver = resolver
  end

  def execute(command)

    log = File.open('clock.log', 'a')
    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: Executing #{command.class.name}\n")

    executor = @resolver.executor_for(command)
    executor.execute(command)
    
    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} finished\n")
    log.close
  end
end

Y aquí un ejemplo del resultado:

2023-11-14 21:15:58: Executing ShowTimeCommand
2023-11-14 21:15:58: ShowTimeCommand finished
2023-11-14 21:15:58: Executing PlayBeepCommand
2023-11-14 21:15:58: PlayBeepCommand finished
2023-11-14 21:15:58: Executing ShowTimeCommand
2023-11-14 21:15:58: ShowTimeCommand finished
...

No ha estado mal, no hemos tenido que tocar ningún ejecutor y podemos ver toda la actividad de la aplicación. Pero, como es obvio, no queremos tener que modificar el CommandBus para cada cosa que se nos ocurra. Sería una pesadilla de mantenimiento.

Lo que nos interesa analizar aquí es el modo en que capturamos el comando antes y después de ejecutarlo. Y nos vamos a fijar especialmente en que el bloque que ejecuta el comando se mantiene intacto.

    executor = @resolver.executor_for(command)
    executor.execute(command)

Lo voy a separar en un método:

class CommandBus
  def initialize(resolver)
    @resolver = resolver
  end

  def execute(command)
    log = File.open('clock.log', 'a')
    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: Executing #{command.class.name}\n")

    handle(command)

    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} finished\n")
    log.close
  end

  def handle(command)
    executor = @resolver.executor_for(command)
    executor.execute(command)
  end
end

Ahora fíjate en este cambio:

class CommandBus
  def initialize(resolver)
    @resolver = resolver
  end

  def execute(command)
    log(command)
  end
  
  def handle(command)
    executor = @resolver.executor_for(command)
    executor.execute(command)
  end

  def log(command)
    log = File.open('clock.log', 'a')
    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: Executing #{command.class.name}\n")

    handle(command)

    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} finished\n")
    log.close
  end
end

Aquí se puede ver más claro cómo log envuelve la ejecución de command. log recibe el command y en un momento dado se lo pasa de nuevo al bus a través del método handle.

Hemos aprendido cómo funciona un middleware. Ahora bien. Si quisiésemos añadir una nueva etapa de procesamiento seguir por este camino sería infructuoso. Tendríamos que modificar el CommandBus cada vez. Para estar abiertas a extensión tenemos que extraer la lógica del Logger a otro objeto.

Hagamos un experimento:

class CommandLoggerMiddleware
  def execute(command, bus)
    log = File.open('clock.log', 'a')
    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: Executing #{command.class.name}\n")

    bus.handle(command)

    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} finished\n")
    log.close
  end
end

Y así queda el CommandBus:

class CommandBus
  def initialize(resolver, middleware)
    @resolver = resolver
    @middleware = middleware
  end

  def execute(command)
    @middleware.execute(command, self)
  end
  def handle(command)
    executor = @resolver.executor_for(command)
    executor.execute(command)
  end
end

Por supuesto, hemos tenido que hacer cambios. El código es más o menos el mismo, con la salvedad de que ahora podemos inyectar distintos middlewares, aunque solo uno cada vez. Ya veremos como mejorar en ese aspecto. Lo que nos tenemos que quedar ahora es que estamos en condiciones de cambiar el comportamiento del CommandBus añadiendo código y cambiando solo la forma en que se configura en construcción.

Así, por ejemplo, vamos a añadir este Middleware que captura los errores y los reporta en un archivo errors.log:

class ErrorLoggerMiddleware
  def execute(command, bus)
    log = File.open('errors.log', 'a')
    begin
    bus.handle(command)
    rescue => e
      log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} error: #{e.class.name} with message #{e.message}\n")
    end
    log.close
  end
end

Vamos a provocar un error, configurando un comando con el ejecutor incorrecto:

resolver = Resolver.new

# Code removed for clarity

resolver.register('ShowTimeCommand', PlayBeepHandler.new(sound))

Y, obviamente, tenemos que indicar que usaremos otro middleware:

errors = ErrorLoggerMiddleware.new

command_bus = CommandBus.new(resolver, errors)

clock = AlarmClock.new(command_bus, 7, 22)
clock.run

Y aquí tenemos el log de una ejecución completa:

2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
2023-11-15 09:23:55: ShowTimeCommand error: ArgumentError with message invalid command
...

Nos vendría bien añadir más información en este log, pero creo que se ve bien el argumento: Puedo cambiar lo que ocurre en el CommandBus sin tocar su código.

Ahora bien, vamos a tener que tocar un poco el código hasta obtener un sistema de Command Bus que admita una sucesión de Middlewares, porque nada sería más interesante que poder combinar una variedad de ellos. En pocas palabras lo que necesitamos es:

  • Poder registrar n middlewares en el CommandBus.
  • Hacer que cada middleware pueda invocar al siguiente, si sabe cuál es.
  • Cada middleware tiene que ejecutar su propio código
  • Finalmente, se debe ejecutar el comando.

Una lista ligada podría darnos una solución a los dos primeros puntos. La lista ligada nos permite componer objetos de tal forma que podemos recorrer la totalidad de la lista desde el primero al último, siguiendo una referencia. El último elemento de la lista no tiene siguiente.

Los puntos tercero y cuarto nos los da una simple decisión: si el middleware actual tiene un siguiente definido, delega en él. Si el middleware es el último invocará el ejecutor del comando.

Esto queda reflejado en este middleware base:

class Middleware
  def initialize(next_middleware = nil)
    @next = next_middleware
  end

  def execute(command, bus)
    handle(command, bus)
  end

  def handle(command, bus)
    if @next.nil?
      bus.handle(command)
    else
      @next.execute(command, bus)
    end
  end
end

Y así quedaría un ejemplo concreto. El middleware hace lo suyo en el método execute e invoca handle para que gestione el comando, que en último término delega en la superclase.

class CommandLoggerMiddleware < Middleware
  def initialize(next_middleware = nil)
    super
  end
  
  def execute(command, bus)
    log = File.open('clock.log', 'a')
    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: Executing #{command.class.name}\n")

    handle(command, bus)

    log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} finished\n")
    log.close
  end
  
  def handle(command, bus)
    super
  end
end

Otro ejemplo:

class ErrorLoggerMiddleware < Middleware
  def initialize(next_middleware = nil)
    super
  end
  def execute(command, bus)
    log = File.open('errors.log', 'a')
    begin
      handle(command, bus)
    rescue => e
      log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} error: #{e.class.name} with message #{e.message}\n")
    else
      log.write("#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}: #{command.class.name} OK\n")
    end
    log.close
  end

  def handle(command, bus)
    super
  end
end

Aquí tienes un ejemplo de configuración:

errors = ErrorLoggerMiddleware.new
logger = CommandLoggerMiddleware.new(errors)

command_bus = CommandBus.new(resolver, logger)

El cual podríamos expresar así, lo que nos da pistas del orden en que se aplican:

middlewares = CommandLoggerMiddleware.new(
  ErrorLoggerMiddleware.new
)

command_bus = CommandBus.new(resolver, middlewares)

Como dato curioso, mencionar que no hemos tenido que tocar el código de CommandBus, ya que al pasar los middlewares en forma compuesta, desde el punto de vista del bus es un único objeto.

Saltarse la cadena

Hay ocasiones en las que querrás evitar la ejecución de un comando de forma condicional o no. Por ejemplo, dependiendo de si el usuario tiene permisos para ello o no. O tal vez, a través de algún modificador.

Imagina que quieres tener un dry run de tu aplicación. Esta es una modalidad que ejecuta la aplicación sin hacer nada, pero nos sirve para ver qué efectos podría tener. Yo lo voy a implementar con un middleware.

class DryRunMiddleware < Middleware
  def initialize(dry, next_middleware = nil)
    @dry = dry
    super(next_middleware)
  end

  def execute(command, bus)
    handle(command, bus)
  end

  def handle(command, bus)
    if @next.nil?
      bus.handle(command) unless @dry
      puts "Executing #{command.class.name}" if @dry
    else
      @next.execute(command, bus)
    end
  end
end

Que se configuraría así:

middlewares =
  CommandLoggerMiddleware.new(
    ErrorLoggerMiddleware.new(
      DryRunMiddleware.new(true)
    )
  )

command_bus = CommandBus.new(resolver, middlewares)

Tal cual está escrito tiene que ejecutarse el último, pero nos sirve para mostrar que podemos cambiar el método handle a fin de gestionar de forma particular la decisión acerca de pasar el comando al siguiente middleware o al bus.

El resultado es que por pantalla ya no nos sale el reloj, sino esto:

Executing ShowTimeCommand
Executing PlayBeepCommand
Executing ShowTimeCommand
Executing PlayBeepCommand
Executing ShowTimeCommand
...

Y cambiando el parámetro a false, se ejecuta la aplicación con toda normalidad.

Concluyendo

Usar CommandBus nos permite una flexibilidad tremenda para configurar y modificar el comportamiento de las aplicaciones, así como separación de los asuntos de negocio, de muchos de sus aspectos técnicos.

En la primera parte del artículo hemos visto como podemos aprovechar el CommandBus para gestionar fácilmente cambios de comportamiento no destructivos en nuestras aplicaciones. Basta configurar los ejecutores de forma diferente, o escribir otros nuevos con los cambios necesarios.

En la segunda parte, hemos hablado sobre los middlewares, una forma de modificar el comportamiento del propio CommandBus sin tener que tocar su código.

Llegadas a este punto puede que me preguntes por las Queries. ¿Existe un QueryBus? Pues sí, es exactamente lo mismo que el CommandBus, pero devolviendo una respuesta. De hecho, he visto librerías de buses que usan por debajo el mismo MessageBus genérico, ignorando la respuesta o no dependiendo de si es CommandBus o QueryBus.

A pesar de lo que hemos dicho de la flexibilidad del bus para modificar el comportamiento de la aplicación sin cambiar su código, es cierto es que todavía no la hemos conseguido del todo: la decisión sobre qué comandos lanzar sigue hardcoded.

Podríamos solucionar esto de varias maneras. Una de ellas es mediante eventos, para lo que necesitaríamos un bus de eventos. Pero de eso preferiría ocuparme en otro artículo.

November 15, 2023

Etiquetas: design-patterns   ruby  

Temas

good-practices

refactoring

php

testing

tdd

python

design-patterns

blogtober19

design-principles

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

tips

hexagonal

ddd

bbdd

soft-skills

books

oop

javascript

api

sql

ethics

typescript

swift

java

agile