5 拆分单体应用为服务的难点
从表面上看,通过定义与业务能力或子域相对应的服务来创建微服务架构的策略看起来很简单。但是,你可能会遇到几个障碍:
- 网络延迟。
- 同步进程间通信导致可用性降低。
- 在服务之间维持数据一致性。
- 获取一致的数据视图。
- 上帝类阻碍了拆分。
让我们来看看每个问题,先从网络延迟开始。
网络延迟
网络延迟是分布式系统中一直存在的问题。你可能会发现,对服务的特定分解会导致两个服务之间的大量往返调用。有时,你可以通过实施批处理API在一次往返中获取多个对象,从而将延迟减少到可接受的数量。但在其他情况下,解决方案是把多个相关的服务组合在一起,用编程语言的函数调用替换昂贵的进程间通信。
同步进程间通信导致可用性降低
另一个需要考虑的问题是如何处理进程间通信而不降低系统的可用性。例如,实现createOrder()操作最常见的方式是让Order Service使用REST同步调用其他服务。这样做的弊端是REST这样的协议会降低Order Service的可用性。如果任何一个被调用的服务处在不可用的状态,那么订单就无法创建了。有时候这可能是一个不得已的折中,但是在第3章中学习异步消息之后,你就会发现其实有更好的办法来消除这类同步调用产生的紧耦合并提升可用性。
在服务之间维持数据一致性
另一个挑战是如何在某些系统操作需要更新多个服务中的数据时,仍旧维护服务之间的数据一致性。例如,当餐馆接受订单时,必须在Kitchen Service和Delivery Service中同时进行更新。Kitchen Service会更改Ticket的状态。Delivery Service安排订单的交付。这些更新都必须以原子化的方式完成。
传统的解决方案是使用基于两阶段提交(two phase commit)的分布式事务管理机制。但正如你将在第4章中看到的那样,对于现今的应用程序而言,这不是一个好的选择,你必须使用一种非常不同的方法来处理事务管理,这就是Saga。Saga是一系列使用消息协作的本地事务。Saga比传统的ACID事务更复杂,但它们在许多情况下都能工作得很好。Saga的一个限制是它们最终是一致的。如果你需要以原子方式更新某些数据,那么它必须位于单个服务中,这可能是分解的障碍。
获取一致的数据视图
分解的另一个障碍是无法跨多个数据库获得真正一致的数据视图。在单体应用程序中,ACID事务的属性保证查询将返回数据库的一致视图。相反,在微服务架构中,即使每个服务的数据库是一致的,你也无法获得全局一致的数据视图。如果你需要一些数据的一致视图,那么它必须驻留在单个服务中,这也是服务分解所面临的问题。幸运的是,在实践中这很少带来真正的问题。
上帝类阻碍了拆分
分解的另一个障碍是存在所谓的上帝类。上帝类是在整个应用程序中使用的全局类。上帝类通常为应用程序的许多不同方面实现业务逻辑。它有大量字段映射到具有许多列的数据库表。大多数应用程序至少有一个这样的上帝类,每个类代表一个对领域至关重要的概念:银行账户、电子商务订单、保险政策,等等。因为上帝类将应用程序的许多不同方面的状态和行为捆绑在一起,所以将使用它的任何业务逻辑拆分为服务往往都是一个不可逾越的障碍。
Order类是FTGO应用程序中上帝类的一个很好的例子。这并不奇怪:毕竟FTGO的目的是向客户提供食品订单。系统的大多数部分都涉及订单。如果FTGO应用程序具有单个领域模型,则Order类将是一个非常大的类。它将具有与应用程序的许多不同部分相对应的状态和行为。图6显示了使用传统建模技术创建的Order类的结构。
如你所见,Order类具有与订单处理、餐馆订单管理、送餐和付款相对应的字段及方法。由于一个模型必须描述来自应用程序的不同部分的状态转换,因此该类还具有复杂的状态模型。在目前情况下,这个类的存在使得将代码分割成服务变得极其困难。
一种解决方案是将Order类打包到库中并创建一个中央Order数据库。处理订单的所有服务都使用此库并访问访问数据库。这种方法的问题在于它违反了微服务架构的一个关键原则,并导致我们特别不愿意看到的紧耦合。例如,对Order模式的任何更改都要求其他开发团队同步更新和重新编译他们的代码。
另一种解决方案是将Order数据库封装在Order Service中,该服务由其他服务调用以检索和更新订单。该设计的问题在于这样的一个Order Service将成为一个纯数据服务,成为包含很少或没有业务逻辑的贫血领域模型(anemic domain model)。这两种解决方案都没有吸引力,但幸运的是,DDD提供了一个好的解决方案。
更好的方法是应用DDD并将每个服务视为具有自己的领域模型的单独子域。这意味着FTGO应用程序中与订单有关的每个服务都有自己的领域模型及其对应的Order类的版本。Delivery Service是多领域模型的一个很好的例子。如图7所示为Order,它非常简单:取餐地址、取餐时间、送餐地址和送餐时间。此外,DeliveryService使用更合适的Delivery名称,而不是称之为Order。
Order Service具有最复杂的订单视图,如图9所示。即使它有相当多的字段和方法,它仍然比原始版本的那个Order上帝类简单得多。
每个领域模型中的Order类表示同一Order业务实体的不同方面。FTGO应用程序必须维持不同服务中这些不同对象之间的一致性。例如,一旦OrderService授权消费者的信用卡,它必须触发在Kitchen Service中创建Ticket。同样,如果Kitchen Service拒绝订单,则必须在Order Service中取消订单,并且为客户退款。在第4章中,我们将学习如何使用前面提到的事件驱动机制Saga来维护服务之间的一致性。
除了造成一些技术挑战以外,拥有多个领域模型还会影响用户体验。应用程序必须在用户体验(即其自己的领域模型)与每个服务的领域模型之间进行转换。例如,在FTGO应用程序中,向消费者显示的Order状态来自存储在多个服务中的Order信息。这种转换通常由API Gateway处理,将在第8章中讨论。尽管存在这些挑战,但在定义微服务架构时,必须识别并消除上帝类。
我们现在来看看如何定义服务API。
6 定义服务API
到目前为止,我们有一个系统操作列表和一个潜在服务列表。下一步是定义每个服务的API:也就是服务的操作和事件。存在服务API操作有以下两个原因:首先,某些操作对应于系统操作。它们由外部客户端调用,也可能由其他服务调用。另次,存在一些其他操作用以支持服务之间的协作。这些操作仅由其他服务调用。
服务通过对外发布事件,使其能够与其他服务协作。第4章将描述如何使用事件来实现Saga,这些Saga可以维护服务之间的数据一致性。第7章将讨论如何使用事件来更新CQRS视图,这些视图支持有效的查询。应用程序还可以使用事件来通知外部客户端。例如,可以使用WebSockets将事件传递给浏览器。
定义服务API的起点是将每个系统操作映射到服务。之后确定服务是否需要与其他服务协作以实现系统操作。如果需要协作,我们将确定其他服务必须提供哪些API才能支持协作。首先来看一下如何将系统操作分配给服务。
把系统操作分配给服务
第一步是确定哪个服务是请求的初始入口点。许多系统操作可以清晰地映射到服务,但有时映射会不太明显。例如,考虑使用noteUpdatedLocation()操作来更新送餐员的位置。一方面,因为它与送餐员有关,所以应该将此操作分配给Courier Service。另一方面,它是需要送餐地点的DeliveryService。在这种情况下,将操作分配给需要操作所提供信息的服务是更好的选择。在其他情况下,将操作分配给具有处理它所需信息的服务可能是有意义的。表4显示了FTGO应用程序中的哪些服务负责哪些操作。
表4 FTGO应用程序的系统操作映射到具体的服务
把操作分配给服务后,下一步是确定在处理每一个系统操作时,服务之间如何交互。
确定支持服务协作所需要的API
某些系统操作完全由单个服务处理。例如,在FTGO应用程序中,Consumer Service完全独立地处理createConsumer()操作。但是其他系统操作跨越多个服务。处理这些请求之一所需的数据可能分散在多个服务周围。例如,为了实现createOrder()操作,Order Service必须调用以下服务以验证其前置条件并使后置条件成立:
- Consumer Service:验证消费者是否可以下订单并获取其付款信息。
- Restaurant Service:验证订单行项目,验证送货地址和时间是否在餐厅的服务区域内,验证订单最低要求,并获得订单行项目的价格。
- Kitchen Service:创建Ticket(后厨工单)。
- Accounting Service:授权消费者的信用卡。
同样,为了实现acceptOrder()系统操作,Kitchen Service必须调用Delivery Service来安排送餐员交付订单。表2-3显示了服务、修订后的API及协作者。为了完整定义服务API,你需要分析每个系统操作并确定所需的协作。
表5 服务、修订后的API及协作者
总结
- 微服务中的服务是根据业务需求进行组织的,按照业务能力或者子域,而不是技术上的考量。
- 有两种分解模式:
按业务能力分解,其起源于业务架构。
基于领域驱动设计的概念,通过子域进行分解。
- 可以通过应用DDD并为每个服务定义单独的领域模型来消除上帝类,正是上帝类引起了阻碍分解的交织依赖项。