Rails 上的发布-订阅模式:实现教程

已发表: 2022-03-11

发布-订阅模式(或简称 pub/sub)是一种 Ruby on Rails 消息传递模式,其中消息的发送者(发布者)不会将消息编程为直接发送给特定的接收者(订阅者)。 相反,程序员“发布”消息(事件),而不知道可能有任何订阅者。

类似地,订阅者表达对一个或多个事件的兴趣,并且只接收感兴趣的消息,而不知道任何发布者。

为此,称为“消息代理”或“事件总线”的中介接收已发布的消息,然后将它们转发给注册接收它们的订阅者。

换句话说,pub-sub 是一种用于在不同系统组件之间传递消息的模式,而这些组件不知道彼此的身份。

在本 Rails 教程中,发布-订阅设计模式在此图中进行了布局。

这种设计模式并不新鲜,但 Rails 开发人员并不常用。 有许多工具可以帮助将此设计模式整合到您的代码库中,例如:

  • Wisper(我个人更喜欢并将进一步讨论)
  • 事件总线
  • EventBGBus(EventBus 的一个分支)
  • 兔MQ
  • 雷迪斯

所有这些工具都有不同的底层 pub-sub 实现,但它们都为 Rails 应用程序提供了相同的主要优势。

Pub-Sub 实施的优势

减少模型/控制器膨胀

在 Rails 应用程序中包含一些胖模型或控制器是一种常见做法,但不是最佳做法。

pub/sub 模式可以很容易地帮助分解胖模型或控制器。

更少的回调

模型之间有很多相互交织的回调是众所周知的代码异味,它一点一点地将模型紧密耦合在一起,使它们更难维护或扩展。

例如,一个Post模型可能如下所示:

 # 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

Post控制器可能如下所示:

 # 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

如您所见, Post模型具有将模型与Feed模型和User::NotifyFollowers服务或关注点紧密耦合的回调。 通过使用任何 pub/sub 模式,之前的代码可以重构为如下所示,它使用 Wisper:

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

发布者使用可能需要的事件有效负载对象发布事件。

 # 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

订阅者只订阅他们希望响应的事件。

 # 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

事件总线在系统中注册不同的订阅者。

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

在这个例子中,pub-sub 模式完全消除了Post模型中的回调,并帮助模型彼此独立工作,并且对彼此的了解最少,从而确保松散耦合。 将行为扩展到其他操作只是挂钩到所需事件的问题。

单一职责原则 (SRP)

单一职责原则对于维护干净的代码库非常有帮助。 坚持下去的问题是,有时类的责任并不像应有的那样明确。 这在涉及 MVC(如 Rails)时尤其常见。

模型应该处理持久性、关联性,而不是其他。

控制器应该处理用户请求并成为业务逻辑(服务对象)的包装器。

服务对象应封装业务逻辑的职责之一,为外部服务提供入口点,或充当模型关注点的替代方案。

由于其减少耦合的能力,pub-sub 设计模式可以与单一职责服务对象 (SRSO) 相结合,以帮助封装业务逻辑,并禁止业务逻辑潜入模型或控制器。 这使代码库保持干净、可读、可维护和可扩展。

以下是使用发布/订阅模式和服务对象实现的一些复杂业务逻辑的示例:

出版商

# 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 # ...

订户

# 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

通过使用发布-订阅模式,代码库几乎自动组织成 SRSO。 此外,为复杂的工作流实现代码很容易围绕事件进行组织,而不会牺牲可读性、可维护性或可扩展性。

测试

通过分解胖模型和控制器,并拥有大量的 SRSO,代码库的测试变得非常容易。 在集成测试和模块间通信方面尤其如此。 测试应该简单地确保事件被正确发布和接收。

Wisper 有一个测试 gem,它添加了 RSpec 匹配器以简化不同组件的测试。

在前面的两个示例( Post示例和Order示例)中,测试应包括以下内容:

出版商

# 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

订户

# 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

但是,当发布者是控制器时,测试发布的事件有一些限制。

如果您想加倍努力,那么对有效负载进行测试也将有助于维护更好的代码库。

如您所见,发布-订阅设计模式测试很简单。 这只是确保正确发布和接收不同事件的问题。

表现

这更多是一个可能的优势。 发布-订阅设计模式本身对代码性能没有重大的内在影响。 但是,与您在代码中使用的任何工具一样,用于实现 pub/sub 的工具会对性能产生很大影响。 有时它可能会产生不好的影响,但有时它可能会非常好。

首先,一个坏影响的例子:Redis 是一个“高级键值缓存和存储”。 它通常被称为数据结构服务器。” 这个流行的工具支持 pub/sub 模式并且非常稳定。 但是,如果在远程服务器上使用(不是部署 Rails 应用程序的同一台服务器),则会由于网络开销而导致巨大的性能损失。

另一方面,Wisper 有各种用于异步事件处理的适配器,例如 wisper-celluloid、wisper-sidekiq 和 wisper-activejob。 这些工具支持异步事件和线程执行。 如果应用得当,可以极大地提高应用程序的性能。

底线

如果您的目标是在性能上加倍努力,那么 pub/sub 模式可以帮助您实现这一目标。 但是,即使您没有发现这种 Rails 设计模式的性能提升,它仍然有助于保持代码的组织性并使其更易于维护。 毕竟,谁能担心无法维护或一开始就不起作用的代码的性能呢?

Pub-Sub 实施的缺点

与所有事情一样,pub-sub 模式也有一些可能的缺点。

松耦合(不灵活的语义耦合)

pub/sub 模式最大的优点也是它最大的缺点。 发布的数据(事件有效负载)的结构必须明确定义,并且很快就会变得相当不灵活。 为了修改已发布有效负载的数据结构,有必要了解所有订阅者,并且要么也修改它们,要么确保修改与旧版本兼容。 这使得 Publisher 代码的重构变得更加困难。

如果您想避免这种情况,则在定义发布者的有效负载时必须格外小心。 当然,如果您有一个很棒的测试套件,可以像前面提到的那样测试有效负载,那么您不必担心在更改发布者的有效负载或事件名称后系统会崩溃。

消息总线稳定性

发布者不知道订阅者的状态,反之亦然。 使用简单的发布/订阅工具,可能无法确保消息总线本身的稳定性,并确保所有发布的消息都正确排队和传递。

当使用简单的工具时,正在交换的消息数量增加会导致系统不稳定,如果没有一些更复杂的协议,可能无法确保向所有订阅者传递。 根据正在交换的消息数量以及您想要实现的性能参数,您可能会考虑使用 RabbitMQ、PubNub、Pusher、CloudAMQP、IronMQ 等服务或许多其他替代方案。 这些替代方案提供了额外的功能,并且对于更复杂的系统比 Wisper 更稳定。 但是,它们还需要一些额外的工作来实现。 您可以在此处阅读有关消息代理如何工作的更多信息

无限事件循环

当系统完全由事件驱动时,您应该格外小心,不要有事件循环。 这些循环就像代码中可能发生的无限循环一样。 但是,它们更难提前检测到,它们可能会使您的系统陷入停顿。 当系统中发布和订阅了许多事件时,它们可能会在您不通知的情况下存在。

Rails 教程结论

发布-订阅模式并不是解决所有 Rails 问题和代码异味的灵丹妙药,但它是一种非常好的设计模式,有助于解耦不同的系统组件,使其更易于维护、可读和可扩展。

当与单一职责服务对象 (SRSO) 结合使用时,pub-sub 还可以真正帮助封装业务逻辑并防止不同的业务关注点蔓延到模型或控制器中。

使用此模式后的性能增益主要取决于所使用的底层工具,但在某些情况下性能增益可以显着提高,并且在大多数情况下肯定不会损害性能。

但是,应该仔细研究和规划 pub-sub 模式的使用,因为松散耦合的强大功能带来了维护和重构松散耦合组件的重大责任。

因为事件很容易失控,一个简单的发布/订阅库可能无法确保消息代理的稳定性。

最后,存在引入无限事件循环的危险,直到为时已晚才被注意到。


我已经使用这种模式将近一年了,我很难想象没有它来编写代码。 对我来说,它是使后台作业、服务对象、关注点、控制器和模型彼此干净地通信并像魅力一样协同工作的粘合剂。

我希望你和我从查看这段代码中学到的一样多,并且你会受到启发,给 Publish-Subscribe 模式一个机会,让你的 Rails 应用程序变得很棒。

最后,非常感谢 @krisleech 为实现 Wisper 所做的出色工作。