[译文] 为你的事件瘦身
原文链接: 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 账单服务
}
OrderId
和 ShoppingCart
与售出的商品相关联,所以它们属于 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 服务将会同时订阅 OrderPlaced
和 OrderBilled
事件,一旦接收到两个事件,它就会知道可以开始运输商品给用户了。
让我们对比前后两个版本的 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 分步骤教程
标签:OrderId,服务,Sales,diet,订单,OrderPlaced,事件,Putting,your From: https://www.cnblogs.com/asjun/p/translate-putting-your-events-on-a-diet.html