El patrón de publicación-suscripción en Rails: un tutorial de implementación

Publicado: 2022-03-11

El patrón de publicación-suscripción (o pub/sub, para abreviar) es un patrón de mensajería de Ruby on Rails en el que los remitentes de mensajes (editores) no programan los mensajes para que se envíen directamente a receptores específicos (suscriptores). En cambio, el programador “publica” mensajes (eventos), sin ningún conocimiento de los suscriptores que pueda haber.

Del mismo modo, los suscriptores expresan interés en uno o más eventos y solo reciben mensajes que son de su interés, sin ningún conocimiento de los editores.

Para lograr esto, un intermediario, llamado "agente de mensajes" o "bus de eventos", recibe los mensajes publicados y luego los reenvía a los suscriptores que están registrados para recibirlos.

En otras palabras, pub-sub es un patrón utilizado para comunicar mensajes entre diferentes componentes del sistema sin que estos componentes sepan nada sobre la identidad de los demás.

En este tutorial de Rails, el patrón de diseño de publicación-suscripción se presenta en este diagrama.

Este patrón de diseño no es nuevo, pero los desarrolladores de Rails no suelen usarlo. Hay muchas herramientas que ayudan a incorporar este patrón de diseño en su base de código, como:

  • Wisper (que personalmente prefiero y lo discutiré más adelante)
  • EventBus
  • EventBGBus (una bifurcación de EventBus)
  • ConejoMQ
  • redis

Todas estas herramientas tienen diferentes implementaciones pub-sub subyacentes, pero todas ofrecen las mismas ventajas importantes para una aplicación Rails.

Ventajas de la implementación de Pub-Sub

Reducción de la hinchazón del modelo/controlador

Es una práctica común, pero no una buena práctica, tener algunos modelos o controladores voluminosos en su aplicación Rails.

El patrón pub/sub puede ayudar fácilmente a descomponer modelos o controladores gordos.

Menos devoluciones de llamada

Tener una gran cantidad de devoluciones de llamadas entrelazadas entre los modelos es un olor de código conocido y, poco a poco, une estrechamente los modelos, lo que los hace más difíciles de mantener o ampliar.

Por ejemplo, un modelo de Post podría tener el siguiente aspecto:

 # app/models/post.rb class Post # ... field: content, type: String # ... after_create :create_feed, :notify_followers # ... def create_feed Feed.create!(self) end def notify_followers User::NotifyFollowers.call(self) end end

Y el controlador Post podría tener un aspecto similar al siguiente:

 # app/controllers/api/v1/posts_controller.rb class Api::V1::PostsController < Api::V1::ApiController # ... def create @post = current_user.posts.build(post_params) if @post.save render_created(@post) else render_unprocessable_entity(@post.errors) end end # ... end

Como puede ver, el modelo de Post tiene devoluciones de llamada que acoplan estrechamente el modelo tanto al modelo de fuente como al servicio o inquietud User::NotifyFollowers Feed Al usar cualquier patrón pub/sub, el código anterior podría refactorizarse para que sea algo como lo siguiente, que usa Wisper:

 # app/models/post.rb class Post # ... field: content, type: String # ... # no callbacks in the models! end

Los editores publican el evento con el objeto de carga útil del evento que podría ser necesario.

 # app/controllers/api/v1/posts_controller.rb # corresponds to the publisher in the previous figure class Api::V1::PostsController < Api::V1::ApiController include Wisper::Publisher # ... def create @post = current_user.posts.build(post_params) if @post.save # Publish event about post creation for any interested listeners publish(:post_create, @post) render_created(@post) else # Publish event about post error for any interested listeners publish(:post_errors, @post) render_unprocessable_entity(@post.errors) end end # ... end

Los suscriptores solo se suscriben a los eventos a los que desean responder.

 # app/listener/feed_listener.rb class FeedListener def post_create(post) Feed.create!(post) end end
 # app/listener/user_listener.rb class UserListener def post_create(post) User::NotifyFollowers.call(self) end end

Event Bus registra los diferentes suscriptores en el sistema.

 # config/initializers/wisper.rb Wisper.subscribe(FeedListener.new) Wisper.subscribe(UserListener.new)

En este ejemplo, el patrón pub-sub eliminó por completo las devoluciones de llamada en el modelo Post y ayudó a los modelos a trabajar de forma independiente con un conocimiento mínimo entre sí, lo que garantiza un acoplamiento flexible. Expandir el comportamiento a acciones adicionales es solo cuestión de conectarse al evento deseado.

El Principio de Responsabilidad Única (PRS)

El principio de responsabilidad única es realmente útil para mantener una base de código limpia. El problema de apegarse a ella es que a veces la responsabilidad de la clase no es tan clara como debería ser. Esto es especialmente común cuando se trata de MVC (como Rails).

Los modelos deben manejar la persistencia, las asociaciones y no mucho más.

Los controladores deben manejar las solicitudes de los usuarios y ser un contenedor de la lógica empresarial (objetos de servicio).

Los objetos de servicio deben encapsular una de las responsabilidades de la lógica comercial, proporcionar un punto de entrada para servicios externos o actuar como una alternativa a las preocupaciones del modelo.

Gracias a su poder para reducir el acoplamiento, el patrón de diseño pub-sub se puede combinar con objetos de servicio de responsabilidad única (SRSO) para ayudar a encapsular la lógica comercial y evitar que la lógica comercial se infiltre en los modelos o los controladores. Esto mantiene el código base limpio, legible, mantenible y escalable.

Este es un ejemplo de una lógica empresarial compleja implementada mediante el patrón pub/sub y los objetos de servicio:

Editor

 # app/service/financial/order_review.rb class Financial::OrderReview include Wisper::Publisher # ... def self.call(order) if order.approved? publish(:order_create, order) else publish(:order_decline, order) end end # ...

Suscriptores

 # app/listener/client_listener.rb class ClientListener def order_create(order) # can implement transaction using different service objects Client::Charge.call(order) Inventory::UpdateStock.call(order) end def order_decline(order) Client::NotifyDeclinedOrder(order) end end

Al usar el patrón de publicación-suscripción, el código base se organiza en SRSO casi automáticamente. Además, la implementación de código para flujos de trabajo complejos se organiza fácilmente en torno a eventos, sin sacrificar la legibilidad, la capacidad de mantenimiento o la escalabilidad.

Pruebas

Al descomponer los modelos pesados ​​y los controladores, y tener muchos SRSO, la prueba del código base se convierte en un proceso mucho, mucho más fácil. Este es particularmente el caso cuando se trata de pruebas de integración y comunicación entre módulos. Las pruebas simplemente deben garantizar que los eventos se publiquen y reciban correctamente.

Wisper tiene una gema de prueba que agrega comparadores RSpec para facilitar la prueba de diferentes componentes.

En los dos ejemplos anteriores (ejemplo de Post y ejemplo de Order ), las pruebas deben incluir lo siguiente:

Editores

 # spec/service/financial/order_review.rb describe Financial::OrderReview do it 'publishes :order_create' do @order = Fabricate(:order, approved: true) expect { Financial::OrderReview.call(@order) }.to broadcast(:order_create) end it 'publishes :order_decline' do @order = Fabricate(:order, approved: false) expect { Financial::OrderReview.call(@order) }.to broadcast(:order_decline) end end

Suscriptores

 # spec/listeners/feed_listener_spec.rb describe FeedListener do it 'receives :post_create event on PostController#create' do expect(FeedListner).to receive(:post_create).with(Post.last) post '/post', { content: 'Some post content' }, request_headers end end

Sin embargo, existen algunas limitaciones para probar los eventos publicados cuando el editor es el controlador.

Si desea hacer un esfuerzo adicional, tener la carga útil probada también ayudará a mantener una base de código aún mejor.

Como puede ver, la prueba de patrones de diseño pub-sub es simple. Solo es cuestión de asegurarse de que los diferentes eventos se publiquen y reciban correctamente.

Rendimiento

Esto es más una posible ventaja. El patrón de diseño de publicación-suscripción en sí mismo no tiene un impacto inherente importante en el rendimiento del código. Sin embargo, como con cualquier herramienta que utilice en su código, las herramientas para implementar pub/sub pueden tener un gran efecto en el rendimiento. A veces puede ser un mal efecto, pero a veces puede ser muy bueno.

Primero, un ejemplo de un efecto negativo: Redis es un "caché y almacenamiento avanzado de clave-valor". A menudo se lo denomina servidor de estructura de datos”. Esta popular herramienta es compatible con el patrón pub/sub y es muy estable. Sin embargo, si se utiliza en un servidor remoto (no en el mismo servidor en el que se implementa la aplicación Rails), se producirá una gran pérdida de rendimiento debido a la sobrecarga de la red.

Por otro lado, Wisper tiene varios adaptadores para el manejo de eventos asincrónicos, como wisper-celluloid, wisper-sidekiq y wisper-activejob. Estas herramientas admiten eventos asincrónicos y ejecuciones de subprocesos. que, si se aplica adecuadamente, puede aumentar enormemente el rendimiento de la aplicación.

La línea de fondo

Si está buscando la milla extra en el rendimiento, el patrón pub/sub podría ayudarlo a alcanzarlo. Pero incluso si no encuentra un aumento de rendimiento con este patrón de diseño de Rails, seguirá ayudando a mantener el código organizado y hacerlo más fácil de mantener. Después de todo, ¿quién puede preocuparse por el rendimiento del código que no se puede mantener o que, en primer lugar, no funciona?

Desventajas de la implementación Pub-Sub

Como con todas las cosas, también existen algunos posibles inconvenientes en el patrón pub-sub.

Acoplamiento suelto (acoplamiento semántico inflexible)

La mayor de las fortalezas del patrón pub/sub son también sus mayores debilidades. La estructura de los datos publicados (la carga útil del evento) debe estar bien definida y rápidamente se vuelve bastante inflexible. Para modificar la estructura de datos de la carga útil publicada, es necesario conocer todos los suscriptores y modificarlos también o asegurarse de que las modificaciones sean compatibles con versiones anteriores. Esto hace que la refactorización del código de Publisher sea mucho más difícil.

Si desea evitar esto, debe tener mucho cuidado al definir la carga útil de los editores. Por supuesto, si tiene un excelente conjunto de pruebas, que prueba la carga útil tan bien como se mencionó anteriormente, no tiene que preocuparse demasiado por la caída del sistema después de cambiar la carga útil o el nombre del evento del editor.

Estabilidad del bus de mensajería

Los editores no tienen conocimiento del estado del suscriptor y viceversa. Con herramientas simples de publicación/suscripción, es posible que no sea posible garantizar la estabilidad del propio bus de mensajería y garantizar que todos los mensajes publicados se pongan en cola y se entreguen correctamente.

El creciente número de mensajes que se intercambian genera inestabilidades en el sistema cuando se utilizan herramientas simples, y es posible que no sea posible garantizar la entrega a todos los suscriptores sin algunos protocolos más sofisticados. Según la cantidad de mensajes que se intercambien y los parámetros de rendimiento que desee lograr, puede considerar el uso de servicios como RabbitMQ, PubNub, Pusher, CloudAMQP, IronMQ o muchas otras alternativas. Estas alternativas brindan funcionalidad adicional y son más estables que Wisper para sistemas más complejos. Sin embargo, también requieren algo de trabajo adicional para implementarse. Puede leer más sobre cómo funcionan los intermediarios de mensajes aquí

Bucles de eventos infinitos

Cuando el sistema está completamente controlado por eventos, debe tener mucho cuidado de no tener bucles de eventos. Estos bucles son como los bucles infinitos que pueden ocurrir en el código. Sin embargo, son más difíciles de detectar con anticipación y pueden paralizar su sistema. Pueden existir sin su aviso cuando hay muchos eventos publicados y suscritos en todo el sistema.

Conclusión del tutorial sobre rieles

El patrón de publicación-suscripción no es una bala de plata para todos los problemas de Rails y olores de código, pero es un patrón de diseño realmente bueno que ayuda a desacoplar diferentes componentes del sistema y lo hace más mantenible, legible y escalable.

Cuando se combina con objetos de servicio de responsabilidad única (SRSO), pub-sub también puede ayudar mucho a encapsular la lógica comercial y evitar que diferentes preocupaciones comerciales se infiltren en los modelos o los controladores.

La ganancia de rendimiento después de usar este patrón depende principalmente de la herramienta subyacente que se utilice, pero la ganancia de rendimiento se puede mejorar significativamente en algunos casos y, en la mayoría de los casos, ciertamente no perjudicará el rendimiento.

Sin embargo, el uso del patrón pub-sub debe estudiarse y planificarse cuidadosamente, porque con el gran poder del acoplamiento flexible viene la gran responsabilidad de mantener y refactorizar componentes acoplados débilmente.

Debido a que los eventos podrían salirse fácilmente de control, es posible que una simple biblioteca pub/sub no garantice la estabilidad del intermediario de mensajes.

Y finalmente, existe el peligro de introducir bucles de eventos infinitos que pasan desapercibidos hasta que es demasiado tarde.


He estado usando este patrón durante casi un año y es difícil para mí imaginar escribir código sin él. Para mí, es el pegamento que hace que los trabajos en segundo plano, los objetos de servicio, las preocupaciones, los controladores y los modelos se comuniquen entre sí de forma limpia y trabajen juntos como un encanto.

Espero que haya aprendido tanto como yo al revisar este código, y que se sienta inspirado para darle al patrón Publicar-Suscribir la oportunidad de hacer que su aplicación Rails sea increíble.

Finalmente, muchas gracias a @krisleech por su increíble trabajo implementando Wisper.