电商架构革新:服务层解耦业务逻辑

随着我们电商平台的不断发展,我们之前构建的控制器开始感受到业务复杂性增加的压力:现在的定价逻辑包括条件折扣、税费计算和促销捆绑。订单处理涉及库存检查、用户信用验证和忠诚度计划集成。每增加一个新功能,我们的控制器就变得更大、更复杂。

这时,第三代架构出现了:服务层。

服务层在我们的架构演进中代表了一个重要的进步。它承认业务逻辑应该有自己的独立空间,而不是与控制器中的协调和请求处理混在一起。

  • 控制器关注“做什么”,
  • 而服务层则封装“怎么做”。

以我们的产品目录为例:最初只是简单的产品检索,现在却涉及动态定价、个性化推荐和基于库存的可用状态。与其将这些复杂性都塞进 ProductController,我们可以将其提取到一个专门的 ProductService 中,让每个组件专注于自己最擅长的部分。

架构模式概述
服务模式引入了一个更分层的架构,每个组件的职责更加清晰。

  • 控制器 作为协调者,负责处理 HTTP 请求、输入验证和工作流协调。它们决定调用哪些服务以及调用的顺序,管理事务边界,并在 API 请求和内部服务调用之间进行转换。在我们的电商平台中,CheckoutController 并不直接实现定价或库存逻辑——它通过调用适当的服务来协调这些活动。
  • 服务 将业务逻辑和领域规则封装在专门的组件中。PricingService 知道所有关于计算产品价格的规则,包括折扣、税费和促销。它不关心 HTTP 请求或数据库查询,只专注于根据输入计算正确的价格。
  • 仓库(Repositories) 在这一代架构中可能开始出现,作为可选组件来抽象数据访问。虽然一些服务仍然直接访问数据,但其他服务开始将这一职责委托给专门的组件。仓库并不是新概念,它们也可能出现在第一代(纯处理器)或第二代(控制器)的解决方案中。
  • DTO(数据传输对象) 和领域模型正式定义了各层之间的接口,创建了清晰的组件间契约。

我们的电商平台现在将其业务逻辑组织到专门的服务中——ProductService 管理目录功能,CartService 实现购物车业务规则,OrderService 处理订单处理和验证,而 PricingService 专注于产品定价的各个方面。

职责与关系
在这种架构中,控制器和服务形成了清晰的边界和共生关系。控制器负责处理 HTTP 请求、协调整体工作流并管理事务边界。当用户下订单时,OrderController 验证输入、启动事务,然后通过依次调用各种服务来协调流程。
另一方面,服务实现了实际的业务逻辑、领域规则和计算。OrderService 不关心 HTTP 或事务边界,但它了解订单验证、确认流程和业务规则的所有内容。控制器调用服务来执行业务操作,而服务可能会调用其他服务(需谨慎)来组合更复杂的功能。

在这一阶段,数据访问模式开始演变。在某些情况下,数据访问逻辑仍然存在于服务中,而在其他情况下,它开始转移到专门的仓库中。这种转变通常是渐进的,因为团队会识别出可以从抽象中受益的数据访问模式。
变更频率特征

有了服务层,我们开始看到系统的不同部分以各自的自然速度演进。服务中的业务逻辑可以独立于控制器中的请求处理进行演进。当税费计算规则发生变化时,我们只需要更新 PricingService,而控制器则保持不变。
业务规则的更新只影响相关的服务,从而创建了集中的变更,减少了连锁反应。如果我们改变了运费的计算方式,这一变化将仅限于 ShippingService。

服务的可重用性意味着共享业务逻辑的变更只需在一个地方进行。当 CartService 计算总额时,它会调用 PricingService——产品目录也使用相同的服务。如果定价逻辑发生变化,两个功能都会自动受益于这一更新。
现在,每个组件都根据其自身的自然节奏和需求进行演进,而不是被迫与其他组件同步变化。

分层分析
有了服务层,我们的架构发展出更有意义的分层,更好地反映了系统中的不同关注点。

  • 表示层 由调用控制器的 UI 组件或 API 端点组成。这些组件纯粹关注用户交互和数据展示,而不嵌入业务规则或数据访问逻辑。
  • 控制器层 负责协调、工作流管理和事务控制。控制器决定操作顺序,管理事务边界,并协调整体流程,但将实际的业务操作委托给服务。
  • 服务层 包含业务逻辑、领域规则和操作。这是我们电商业务规则的核心所在——如何计算价格、如何处理订单以及如何管理库存。服务不关心 HTTP 请求或数据库连接,它们专注于表达业务能力。
  • 数据访问层 开始出现,要么在服务内部,要么作为专门的仓库。这一层处理数据的检索和存储机制,将系统的其余部分与数据库技术的细节隔离开来。

这种分层在功能之间创建了更清晰的边界,使每一层都能专注于其核心职责,而不被其他地方的关注点分散注意力。

依赖流
在我们的架构中,依赖流变得更加结构化和有目的性。控制器依赖于服务,而服务可能依赖于仓库或直接的数据访问机制。与之前几代架构相比,这创建了更线性的依赖流,之前的架构中职责更加混乱。
当客户查看他们的订单历史时,请求从 UI 流向 OrderController,后者调用 OrderService,而 OrderService 可能使用 OrderRepository 来访问数据库。每一层都有明确的职责,并且只依赖于其下面的层。
事务管理通常位于控制器中,跨越多个服务调用,而领域逻辑则位于服务中,创建了这些核心关注点的清晰分离。

变更隔离
与之前的架构相比,服务模式显著提高了变更的隔离性。业务逻辑的变更现在只影响特定的服务,而不需要更改控制器或 UI 组件。当我们改变折扣的计算方式时,PricingService 会发生变化,但 ProductController 保持不变。
用户界面的变更主要影响控制器和 UI 组件,而不需要修改业务逻辑。数据访问的变更可以开始隔离到仓库或数据访问方法中,保护服务免受数据库演进的影响。

这种改进的隔离意味着不同类型的变更会影响不同的组件,使团队能够更独立地工作,并减少变更带来的意外后果的风险。

实现模式
服务结构的经典示例:

class ProductController {
  private readonly productService;
  
  // 控制器操作委托给服务
  GetProductDetails(id) {
    // 处理请求、验证和协调
    return this.productService.getProduct(id);

   // 或者如果你更喜欢更复杂的版本
   var productResult = this.productService.getProduct(id);
   return Contracts.V1.Product.FromDto(productResult)
  }
}

class ProductService {
  // 业务逻辑方法
  getProduct(id) { ... }
  searchProducts(criteria) { ... }
  calculateProductRating(productId) { ... }
}

常见变体
服务模式中有几种常见的变体:

  • 领域服务:专注于业务领域操作。
  • 基础设施服务:管理技术问题,如电子邮件、日志记录等。
  • 无状态 vs. 有状态服务:根据业务需求选择。
  • 应用服务:开始将业务逻辑按用例分组,并将协调从控制器中移出(接下来会讨论)。

示例
让我们看看电商平台中 OrderService 的实现:
这个服务将:

  • 实现订单验证和业务规则
  • 计算订单总额、税费和运费
  • 应用折扣和促销
  • 处理库存验证
  • 创建和更新订单状态

复杂性管理
复杂性的分布
服务模式并没有消除复杂性,而是将其重新分配到更合适的位置。业务逻辑现在与协调逻辑分离,使每种复杂性都有了自己的归属。以我们的订单处理流程为例:协调整个工作流的复杂性(获取购物车详情、处理支付、创建订单)位于控制器中,而应用业务规则的复杂性(计算总额、验证库存、确定运输选项)则位于专门的服务中。

每个服务都专注于特定的业务能力,而不是试图满足所有用例的需求。PricingService 深入了解定价逻辑,而 InventoryService 则成为库存管理的权威。这种专业化使每个服务都能在其领域中发展专业知识,而不被无关的关注点拖累。

复杂的操作现在可以通过简单的服务调用来组合。创建一个订单涉及多个业务操作,每个操作都由专门的服务处理。OrderController 协调这些调用,但将实际的业务操作委托给适当的服务。
每个服务现在都有一个清晰、专注的目标——单一职责指导其设计和演进。这种专注有助于防止许多系统中常见的“大杂烩”服务。

模式如何分配复杂性
通过将业务逻辑从控制器移到专门的服务中,我们在协调和业务规则之间创建了更清晰的分离。CartController 不再需要理解折扣计算——它只需在需要价格时调用 PricingService。这种分离使每个组件都更简单,更专注于其核心职责。

不同功能之间的清晰边界开始显现。业务规则位于服务中,工作流管理位于控制器中,数据展示位于 UI 中。这些边界通过允许开发人员一次只关注一种复杂性来减少认知负担。

业务规则现在可以更纯粹地表达,而不与 HTTP 处理或数据库查询混在一起。计算运费的逻辑可以在 ShippingService 中以干净、专注的方式表达,使其更容易理解、测试和修改。

服务组合使复杂操作能够由更简单的组件构建。将产品添加到购物车可能涉及检查库存、计算价格、应用用户特定折扣和更新购物车——每个操作都由控制器依次调用的专门服务处理。

扩展特性
服务在多个维度上比控制器更具扩展性。它们自然地与业务领域概念对齐,使系统能够沿着业务的自然边界扩展。目录团队可以专注于与产品相关的服务,而订单团队则专注于订单处理服务。

团队结构受益于这种对齐,因为团队可以拥有与其业务领域专业知识相匹配的特定服务。运输团队拥有 ShippingService,将业务和技术知识结合在一起。

复杂的业务规则终于找到了合适的归属,与协调逻辑分离。随着税费计算业务规则的复杂性增加,它们仍然包含在 TaxService 中,而不是使控制器膨胀。

也许最重要的是,服务支持跨多个控制器的重用。相同的 PricingService 可以用于产品目录、购物车和订单处理,确保整个系统中定价规则的一致性。

尽管有这些改进,仍然存在一些扩展挑战。如果服务没有适当的边界,它们可能会变得过于庞大,随着时间的推移承担过多的职责。跨多个服务调用的事务管理需要仔细协调以保持数据一致性。数据访问逻辑可能仍然嵌入在服务中,而不是被适当地抽象。随着服务调用其他服务,依赖图可能变得复杂且难以理解。

协调
服务模式中的协调是:

  • 以控制器为中心:控制器协调整个工作流。
  • 事务管理:控制器通常管理事务边界。
  • 工作流导向:复杂操作分解为服务方法调用。
  • 组合性:服务可以调用其他服务来组合功能。

在我们的电商示例中,CheckoutController 将通过调用 CartService、PaymentService 和 OrderService 等服务来协调整个结账流程。

通信
服务中的通信模式是:

  • 方法调用:组件之间的直接方法调用。
  • 基于 DTO:用于跨层通信的数据传输对象。
  • 服务间通信:服务之间的通信。
  • 主要是同步的:操作通常是同步执行的。

ProductCatalogService 可能会与 PricingService 通信以获取当前价格,同时通过定义良好的 DTO 传递数据。

一致性
服务中的一致性通过以下方式管理:

  • 控制器管理的事务:控制器定义事务边界。
  • 业务规则执行:服务强制执行领域不变量。
  • 服务原子性:服务实现从业务角度来看是原子的操作。
  • 显式状态管理:状态转换是经过深思熟虑的。

我们的电商 InventoryService 将强制执行“不能销售库存为零的产品”等一致性规则,而控制器确保整个操作在事务内进行。

摩擦点
服务模式中的主要摩擦点包括:

  • 事务管理:在控制器级事务和服务自治之间取得平衡。
  • 服务通信:管理服务之间的依赖关系。
  • 一致性边界:确定事务边界应该在哪里。
  • 数据访问耦合:服务可能仍然与数据访问机制耦合。

测试策略
服务的测试方法
服务的测试方法回归到更传统的金字塔结构:

  • 单元测试:隔离测试服务方法(大量)。
  • 集成测试:验证服务与数据源的交互(一些)。
  • 控制器测试:测试协调层(一些)。
  • 端到端测试:通过 UI/API 验证系统行为(少量)。

按层测试重点
测试的重点是:

  • 服务逻辑:单元测试业务规则和计算。
  • 控制器协调:测试工作流和协调。
  • 组件集成:验证服务之间的协作是否正确。
  • 数据访问:测试服务与数据库的交互。

测试隔离策略
常见的隔离方法包括:

  • 服务模拟:使用模拟服务测试控制器。
  • 仓库模拟:使用模拟数据访问测试服务。
  • 内存数据库:用于测试数据访问组件。
  • 测试替身:用于隔离服务依赖。

常见的测试挑战
测试服务时的挑战包括:

  • 服务依赖:管理测试中服务之间的依赖关系。
  • 事务边界:测试正确的事务管理。
  • 集成复杂性:测试与多个组件集成的服务。
  • 状态管理:测试有状态的服务。

何时演进
服务不再足够的迹象
随着我们的电商平台在复杂性和规模上的增长,有几个迹象可能表明我们的基于服务的架构已经达到了极限。

查询和命令操作通常会产生不同的需求。我们注意到,产品目录浏览需要高性能的读取操作,优化搜索和过滤,而产品库存更新则需要强一致性和业务规则执行。试图用同一个服务满足这两种需求会带来不合适的妥协。

服务本身可能会变得过于庞大,承担过多的职责。最初专注于订单处理的 OrderService 逐渐扩展到处理订单创建、修改、取消、退货、换货、订阅等。这种范围的扩大使服务更难理解、修改和测试。

客户端的多样性带来了另一个挑战。我们的移动应用需要轻量级的产品摘要,而管理仪表板则需要带有库存和定价历史的详细产品信息。网店前端需要带有个性化推荐的产品数据。一个服务返回“一刀切”的响应变得低效。

性能优化需求变得更加专业化。读取操作受益于缓存、非规范化数据结构和专门的查询优化,而写入操作则需要验证、一致性检查和事务管理。这些不同的需求在单个服务中难以调和。

最后,领域复杂性达到了一个水平,业务规则需要自己的丰富领域模型。订单处理现在涉及复杂的工作流、状态转换和业务规则,超出了简单服务方法所能清晰表达的范围。

转向 CQRS 和应用服务的触发点
有几个具体的触发点表明是时候考虑使用 CQRS(命令查询职责分离)和应用服务了。

当我们注意到读取和写入操作的扩展方式不同时——也许我们的产品目录处理数百万次读取,但只有数千次写入——强制两者通过同一个服务会带来不必要的限制。同样,读取操作中的性能瓶颈可以通过专门的优化来解决,这表明需要分离。

提醒:CQRS 不需要不同的数据库或不同的表。
复杂的领域规则将受益于显式的命令处理,这是另一个信号。当创建订单涉及多个验证步骤、库存检查和业务规则应用时,显式的命令模型可以使这一过程更清晰、更易于维护。

当不同客户端需要相同底层数据的截然不同的视图时,对专门的读取模型的需求变得明显。当我们开始考虑某些功能的事件驱动架构时,CQRS 为这种方法提供了自然的基础。

过渡策略
从服务到 CQRS 的过渡不必是突然或破坏性的。我们可以逐步演进我们的架构,从最能受益于分离的领域开始。

我们可能首先识别出具有明显读写差异的服务,并在内部分离它们的操作。ProductService 可以在内部将读取操作路由到优化的方法,同时保持写入操作专注于一致性和业务规则。

接下来,我们可以将命令处理提取到专门的应用服务中,这些服务专注于处理业务操作。这些服务实现显式的命令,如“CreateOrder”或“AddProductToCart”,具有清晰的验证和业务规则执行。

对于读取操作,我们创建专门的查询处理程序,针对特定的数据访问模式进行优化。这些处理程序可以使用非规范化数据结构、缓存或其他优化,而不受写入导向的约束。

控制器随后演变为显式使用这种命令/查询模式,在 API 级别分离读取和写入操作。最后,我们引入显式的命令和查询对象,正式定义操作的输入和输出,在消费者和应用服务之间创建更清晰的契约。

非功能性需求分析
非功能性需求对齐

  • 简单性:⭐⭐⭐(增加了复杂性,但组织得更好)
  • 可维护性:⭐⭐⭐⭐⭐(通过关注点分离显著改进)
  • 可测试性:⭐⭐⭐⭐(业务逻辑隔离后更好)
  • 可扩展性:⭐⭐⭐⭐(组件和团队扩展性更好)
  • 性能:⭐⭐⭐(由于额外层带来一些开销)
  • 可扩展性:⭐⭐⭐⭐(具有清晰的扩展点)

优缺点
优点:

  • 业务逻辑与协调逻辑的清晰分离
  • 业务功能的更好重用性
  • 业务规则的更好可测试性
  • 更清晰的领域边界和职责

缺点:
  • 增加了架构复杂性
  • 可能导致简单操作的过度设计
  • 服务依赖可能产生耦合
  • 数据访问可能仍然与业务逻辑混合

优化机会
即使在服务模式中,也有几种优化可能:

  • 将数据访问移到专门的仓库
  • 实现服务接口以更好地抽象
  • 创建领域模型来表示业务概念
  • 考虑对频繁使用的数据实施简单的缓存策略

架构师警告
服务提供了巨大的好处,但它们也有自己的架构陷阱。最常见的是,服务逐渐偏离其专注的目标,逐渐积累不相关的职责,直到它们变成“大杂烩”服务,承担了太多任务。

密切关注那些将业务逻辑与数据访问混在一起的服务。当 OrderService 包含订单验证逻辑和 SQL 查询时,它承担了太多的职责。同样,要警惕那些处理过多领域概念的服务——一个同时管理产品、订单和运输的 CustomerService 已经失去了其专注性。

也许最隐蔽的是那些积累杂项功能的“全能”服务。臭名昭著的 UtilityService 通常成为没有明确归属的功能的垃圾场,这表明需要进行架构优化。但实际上,没有“工具服务”,工具通常属于库,通常放在静态类中,如果它们需要状态或实例化,它们就不是工具。

当服务封装了内聚的业务能力时,它们的效果最好。它们擅长实现跨多个用例需要一致的业务规则和计算。PricingService 确保产品在目录、购物车或订单历史中定价一致。

它们是集中验证和跨多个控制器或用例应用的业务逻辑的理想选择。它们还为表达业务核心能力的领域特定操作提供了自然的归属。

然而,当读取和写入操作具有非常不同的扩展需求时,服务显示出局限性。它们难以应对需要专门优化(如非规范化数据结构或广泛缓存)的性能关键读取操作。随着领域模型在复杂性和丰富性上的增长,简单的服务方法可能无法充分表达业务概念和规则。

事件驱动系统通常受益于比服务模式通常提供的更显式的命令处理。

结论与下一步
服务通过为业务逻辑提供专门的归属,彻底改变了我们的架构。业务规则不再与请求处理混在一起,也不再隐藏在控制器中,而是可以在专注的、专门的组件中发挥作用。这种清晰性改善了系统的每个方面——使变更影响更小、更集中的组件,提高了可维护性;使业务逻辑能够独立验证,提高了可测试性;使服务可以从多个控制器调用,提高了可重用性。

我们的电商平台现在有了更清晰的关注点分离。ProductController 处理 HTTP 交互,但将复杂的产品管理业务委托给 ProductService。这种分离使团队能够发展特定业务领域的专业知识,而不是将注意力分散在整个系统上。

然而,随着平台的持续增长,新的挑战出现了。我们注意到,产品浏览和产品管理有着根本不同的需求。浏览需要极快的速度,优化搜索和过滤,并能够处理数千个并发用户。相比之下,产品管理需要仔细的验证、业务规则执行和事务一致性。

试图用相同的服务满足这两种需求会带来不合适的妥协。这就是我们的架构必须再次演进的地方——转向 CQRS(命令查询职责分离)和应用服务。这一代架构明确分离了读取和写入操作,使每个操作都能根据其特定需求进行优化,而不受另一个操作的约束。

从服务到 CQRS 的旅程不仅仅是一次技术演进。它反映了我们对领域的更深理解——认识到读取数据和更改数据是根本不同的操作,具有不同的需求、约束和优化机会。

反思问题
花点时间从服务的角度思考你自己的系统:

  • 在你的当前架构中,协调逻辑和业务逻辑的分离有多清晰?许多系统的边界模糊,业务规则分散在控制器和服务之间,没有明确的组织原则。
  • 在你的领域中,哪些业务规则最需要一个专门的归属?复杂的计算、验证规则和领域特定操作通常最需要立即提取到专注的服务中。
  • 你如何管理跨多个服务调用的事务?这仍然是服务模式中较为棘手的方面之一,需要仔细考虑事务边界和一致性要求。
  • 你是否体验过服务带来的测试优势?许多团队发现,将业务逻辑提取到服务中显著提高了他们编写专注、可靠测试的能力,而无需过多的模拟或复杂的设置。