[译文] 为你的事件瘦身 - Putting your events on a diet

[译文] 为你的事件瘦身

原文链接: Putting your events on a diet

David Boike 于 2017/06/07 编写

本文是 NServiceBus 学习路径 的一部分

人人都能写出可以持续运行几周或数月的代码,但是如果你停下来,过了一段时间再回头看时,你还能理清代码结构吗?如果是别人的代码呢?如果一个项目隔一段时间都需要都重新学习一遍,你又要怎么给它加新功能?在你添加新的功能点时,你又如何保证不会破坏到其他代码?

复杂和耦合的代码,会将你拖入缓慢的死亡螺旋中,最后不可避免的走向 大量重写 的结局。你会尝试使用事件驱动等架构模式,以避免这种痛苦的局面发生。当你建立一个通过事件来通信的离散服务系统时,你通过减少耦合来限制每个服务的复杂度。在业务需求改动时,只需修改对应的代码,不会因此而修改其他无关服务。

但是如果你不够小心,很容易养成其他坏习惯,在传递事件时,携带过多的数据,加深了另一种层面上的耦合。我们可以通过分析一个案例:Amazon.com 的结账流程,来看看会发生什么,并讨论你能用哪些不同的方法解决问题

我说的事件(event)指的是什么?

在我们讨论结账流程之前,先让我来指明,我所说的 事件(command) 指的是什么。一个完整的事件有两个特点:它是已经发生过的,它与业务相关联。有一个用户注册、一个订单被确认和一个商品被添加,它们都是带有业务含义的事件样例。

与之相对的是命令(command)。一个命令是指:执行尚未发生的事情的指令,是一个动作,像确认订单,或者是更换地址。一般而言,命令和事件是成对出现的。比如说,如果一个 确认订单(PlaceOrder) 命令执行成功,那么 订单被确认(OrderPlaced) 事件就会被发布,其他服务就可以对该事件做出反应。

命令只有一个接收者:完成 '命令想要完成的工作' 的那段代码。比如说,一个 确认订单(PlaceOrder) 命令只会有一个接收者,因为只会有一块代码捕获到这个命令,并确认订单。因为它只有一个接收者,所以很容易去更改、修改和改进命令和它的处理代码。

然而事件却可以有很多个订阅者。也许是两个,五个或者甚至五十个相关的处理代码,来相应 订单被确认(OrderPlaced) 事件,比如付款处理、货物运输、仓库补货等。因为事件能被多个地方订阅,所以修改事件时,会使得其他服务产生连锁反应。

让我们买一些东西

我们现在去 Amazon 购买由 Gregor Hophe 和 Bobby Woolf 编写的 Enterprise Integration Patterns 书,这本书对于想要或者正要构建分布式系统的人来说,都值得一读。你浏览 Amazon,把这个商品添加到你的购物车,然后确认订单。这样操作后,Amazon 的后端会发生什么?

Note: 真实的 Amazon 结账流程远比这里描述的要更复杂,并且它一直在变化。这里的案例虽然简单,但足以体现我们要讨论的内容。

当你被引导,以结束结账流程,Amazon 会收集一系列数据,来确认订单内容。我们可以简要列出完成订单所需的基本信息:

  • 购物车中的商品项
  • 送货地址
  • 支付信息,包括支付类型、账单地址等

当你完成结账流程时,所有的信息都会被展示出来,并给你一个 “确认订单” 的按钮。当你点击按钮时,一个 订单被确认(OrderPlaced) 事件会被触发,它带着所有你提供的数据,以及一个 OrderId,就像下面的代码所示:

class OrderPlaced {
    Guid OrderId;
    Cart ShoppingCart;
    Address ShippingAddress;
    PaymentDetails Payment;
}

我们可以设想一个这样的系统,当这个事件被发布后,它的所有订阅者都会消费这个事件,并产生一定的业务行为:为订单开具账单,调整库存数量,准备装运货物以及发送电子邮件收据。可能还有别的订阅者,类似“用户忠诚度管理计划”,“根据商品火爆度来调整价格”,“更新'经常购买'的关联”,以及无数的业务逻辑等。最重要的一点是,在几天之后,这本书将会送到你的家门口。

所以看起来这一切都还好,是吗?

事件膨胀

OrderPlaced 事件将 Web 层从后台处理中解耦出来,表面上看起来很美好,但是背后暗藏的隐秘耦合,可能给你的代码带来麻烦。这就像你在一个大型家庭聚会中大快朵颐,吃的时候很美好,事后你就会肚子肿胀,胃部隐隐作痛。

像这样的事件剥夺了每项服务的自主权,因为它们都依赖销售服务 Sales 提供的数据。这些不同的数据都被绑定在 OrderPlaced 事件中。所以,如果物流服务 Shipping 想要添加新的 Amazon Prime 配送选项,那么就需要在 OrderPlaced 事件中添加这一个信息。要是支付服务 Billing 想要添加比特币支付支持,那么 OrderPlaced 需要再次修改。因为销售服务 Sales 负责 OrderPlaced 事件的发布,所以其他服务都依赖于销售服务 Sales。

每当修改 OrderPlaced 事件时,你都需要分析其他订阅者,看它们是否也需要做出相应的改动。可能还需要重新部署整个系统,这意味着你还要测试所有受影响的部分。

所以你并没有真的将服务独立出来,你创建了一个相互依赖的服务网络,它们之间的关系错综复杂。事件驱动架构的目的是解耦系统,当出现业务需求的修改时,你只需要修改指定的服务即可。但是就像上文说的,一个胖事务(fat event) 会让这个目的变得不可能。

恭喜你,你创建了一个弗兰肯斯坦的怪物。本质上,你只是将原本的单体系统,改成了由事件驱动的分布式单体系统。它只是代码层面上的分布式,但是彼此紧密耦合,逻辑上互相依赖,就像原先的单体服务。如果你能理清其中的关系,才能真正的让这个这些服务拥有自主性。

是时候简化了

为了裁剪事件并规整边界,你需要简化它。让我们重新开始分析 OrderPlaced 事件中的每条信息,并将其分配给对应的服务。

class OrderPlaced  
{  
    Guid           OrderId;        // Sales    销售服务  
    Cart           ShoppingCart;   // Sales    销售服务  
    Address        ShippingAddress;// Shipping 物流服务  
    PaymentDetails Payment;        // Billing  账单服务  
}

OrderIdShoppingCart 与售出的商品相关联,所以它们属于 Sales 服务。而 ShippingAddress 和物流运输相关,所以它应该归属于 Shipping 服务。Payment 信息与付款有关,所以它被分配给 Billing 服务。

给这些信息划分边界,我们就可以重新审视整个结账流程,并看看是否有可以改进的地方。

瘦身

简化事件并减少服务间的耦合的一个诀窍是预先创建 OrderId。并没有规定说所有的 ID 都必须来自数据库,当一个用户开始结账过程时,我们就可以预先创建一个 OrderId

在发送 CreateOrder 命令给 Sales 服务时,你就可以启动结账流程,以此来定义 OrderId,以及购物车中的商品。

class CreateOrder
{
    Guid OrderId;
    Cart ShoppingCart;
}

结账流程的下一步就是选择配送地址 ShippingAddress。我们可以将这个数据从 OrderPlaced 事件中剥离出来,为其单独创建一个命令。

class StoreShippingAddressForOrder
{
    Guid    OrderId;
    Address ShippingAddress;
}

你可以直接从 Web 程序中将 StoreShippingAddressForOrder 命令发送给 Shipping 服务。在这个时间点,订单还未被确认,所以还不会有打包发货的动作。等到真正开始运送订单时,Shipping 服务已经知道了要将这单货物发送到哪里。

如果用户最后没有完成订单,那么即使完成这个步骤,也不会有任何坏处。事实上,这个未完成的订单也有一定的价值,我们可以分析所有放弃的订单,来从中获取用户行为和商业见解。建立一个“联系放弃订单的客户”的流程,是增加销售额的一个有力方法。

让我们再进入到结账流程的下一步,你需要从客户方收集到相关的支付信息。因为 Payment 归属于 Billing 服务,所以我们可以创建一条命令,发送给它。

class StoreBillingDetailsForOrder 
{ 
    Guid           OrderId;
    PaymentDetails Payment;
}

Billing 服务现在还不会对订单收费,现在只是记录相关信息,以及等待订单被确认。如果你的公司不希望存储相关支付信息,可以在这个时候启动付款授权,并在订单确认后再获取。

最后,只剩下确认订单。通过提前创建 OrderId,我们将 OrderPleaced 事件中的大部分数据都剥离,将它们发送给对应的服务。因此,Sales 服务可以发布一个 极其 简单的 OrderPlaced 事件。

class OrderPlaced
{
    Guid OrderId;
}

裁剪后的 OrderPlaced 事件显得更加简练。所有非必须的耦合都已经被移除。当 Sales 服务发布这个事件后,Billing 服务就会取出早已存储的支付信息,对订单收费。当收款成功后,它将发布一个 OrderBilled 事件。Shiping 服务将会同时订阅 OrderPlacedOrderBilled 事件,一旦接收到两个事件,它就会知道可以开始运输商品给用户了。

fake-amazon

让我们对比前后两个版本的 OrderPleaced 事件:

// Before
class OrderPlaced  
{  
    Guid           OrderId;        // Sales    销售服务  
    Cart           ShoppingCart;   // Sales    销售服务  
    Address        ShippingAddress;// Shipping 物流服务  
    PaymentDetails Payment;        // Billing  账单服务  
}  

// After
class OrderPlaced  
{  
    Guid           OrderId;  
}

哪个事件部署到生产中的风险最小?哪个更容易测试?答案不言而喻,简化后的 OrderPlaced 事件体积较小,剔除了所有不必要的耦合。

战斗姿态

裁剪事件的好处在于可以将它们进入战斗姿态,以应对业务需求中肯定会发生的变化。如果我们想要引入 Amazon Prime 运费,或是新增比特币支付支持。无需修改 Sales 服务,就可以做到这点。

想要支持 Prime 运费,在结账服务期间,我们可以发送一条 SetShippingTypeForOrder 命令给 Shipping 服务,它的结构见下面的代码:

class StoreShippingTypeForOrder  
{  
    Guid OrderId;  
    int  ShippingType;  
}

这将成为我们发送给 Shipping 服务的第二条命令,第一条就是 StoreShippingAddressForOrder。增加 Prime 配送支持,将会改变 Shipping 服务处理订单的方式,但不应也不会波及到 OrderPlaced 事件,或 Sales 服务中的其他代码。

与之类似的,我们可以用多种不同的方法,来对 Billing 服务添加比特币支付支持。我们可以在原本的 StoreBillingDetailsForOrder 命令中的 PaymentDetails 类中添加相应的属性。或者可以专门为比特币来设计一个新的命令,这种情况下,Billing 服务会等待其中一种付款方法成功后,再发布 OrderBilled 事件。这个过程中,Shipping 服务并不关心用户如何支付,它只关心订单被支付了。

这两种方式中,对业务的修改只会波及到 Billing 服务。而 Sales 服务和 Shipping 服务将保持不变,并无需重复测试或重新部署。因为每次变更影响的范围很小,我们就可以更快地适应不断变化的业务需求。

这就是最初使用事件驱动架构的意义所在。

总结

在事件驱动系统中,大事件并不是一个好的设计。尝试尽可能保持事件的简洁。服务之间应该只分享 ID,或者还有一个时间戳,来表明信息的有效时间。如果服务之间需要共享的数据过多,那么也许需要注意到,你的服务之间的界限是错误的。根据谁拥有每项数据来考虑你的架构,并为你的事件们瘦身。

关于如何创建松散耦合的事件驱动系统的更多信息,请查看我们的 NServiceBus 分步骤教程

posted @ 2022-12-03 00:29  Asjun  阅读(28)  评论(0编辑  收藏  举报