记录些 DDD 实践规范(4)

阿里单据系统的 DDD 最佳实践

本篇以电商购物场景为背景,探讨了领域驱动设计(DDD)在实际应用中的实践过程。你会发现,DDD 的核心理念在于,通过一系列实用技巧,挖掘出能揭示问题本质的领域模型,并通过模型间的协作解决领域问题,从而驾驭问题领域的复杂性。对于 DDD 爱好者来说,它犹如一个充满挑战和智慧的玩具,在深入思考问题本质和构建抽象知识模型的过程中,让人沉浸于心流状态。

一、前言

领域驱动设计(Domain-Driven Design),简称 DDD,并非一种框架或具体的架构设计,而是一种架构设计思想。其代表性著作便是“领域驱动设计之父”Eric Evans 的经典书籍《领域驱动设计》。DDD的核心目标是通过各种实用方法和技巧提炼出具有体现问题实质的领域模型,并通过保护和组织模型协作来解决领域问题,从而掌控问题领域本身的复杂性,也就是为什么DDD会被认为是软件核心复杂性的应对之道。

DDD的理想应用场景是具有固定领域体系且复杂性较高的应用软件系统设计的各个环节和过程,但这无疑是一项艰巨的任务。DDD要求技术人员高度协同,提升建模技巧,精通领域设计,并通过不断的时间推移和领域知识的吸收消化,以达成应对复杂性的目标。只有这样,DDD的价值才能在项目的中后期得到充分体现。本文旨在带领大家从第一视角体验这种实践过程,感受DDD的独特魅力,掌握其精髓,为在DDD中探索的朋友们指明方向。

我个人对面向对象编程有着浓厚的兴趣,编写代码如同孩子玩玩具般充满乐趣。DDD让我有机会玩得更高级、更复杂、更具挑战性的玩具。对于一个始终保持少年心态的程序员来说,构建领域模型极易让人进入心流状态。这种深入思考问题本质,构建抽象知识模型的过程,让我对DDD情有独钟。

我想用两个词来表达我体会到的魅力:知识、思考。

知识:Eric Evans发行的《领域驱动设计》一书中第一章介绍的就是知识,特别指领域知识,但是这里的知识并不是简单的问题的表象,而是深入到问题的本质,只有获取到真正的知识,运用好各种DDD模式和优秀的战术,打造具有丰富知识设计的模型,才能充分发挥领域驱动设计的好处。

思考:获取知识并不容易。例如,给你一批地球日出月落的数据,你可以用地心说、日心说和地平说等不同模型来拟合地球的各种现象,究竟哪个模型的知识最适合呢?产品和业务提出需求时,很多时候难以触及问题的本质。因此,设计模型、选择模型都需要设计者做到深入思考,挖掘概念,并和领域专家(如果存在)达成一致。

本文是一篇关于DDD实践的典型案例文章,读者也可认为它类似于一种多字段单据的设计模式。全文将以一个简化的电商购物背景作为领域上下文,重点介绍领域组件的形成过程,并突出DDD的核心要点。但同时需要注意到,本文专注于单个领域上下文的战术实践,不涉及多个领域上下文的协作。文章核心内容将按照4个小节展开:

  1. 从实体生命周期出发,围绕一个聚合根的设计作介绍,包括原因、好处;

  2. 从单据字段的性质,特点等,挖掘出一类命令对象集合;

  3. 是体现如何从深层领域本质修正一个状态机模型,从而改变了我的组件设计为状态同步模型

  4. 根据防腐层的一些好处,以及如何在防腐层中通过重构去捡回来重要的领域实体

通过本文,希望大家能更好地理解和应用领域驱动设计,为复杂业务场景找到解决问题的方法。

二、单据

1、生命周期

1)领域概念:贫血实体

为了简化问题,本文将以简化的电商交易平台领域为例,探讨其中的核心概念。简而言之,即消费者在某个购物平台下单购买商品,支付完成后,商品按照计划送达消费者手中。

其中系统比较重要的就是订单,订单作为单据,是一种交易凭证,表达了交易关系的事实依据。它主要涵盖了客户、商品、时间、支付等要素,可作为会计核算的原始资料和重要依据。电商交易单据以电子化形式存在于信息系统中,我们统一称之为交易主订单

通常情况下,一个交易主订单代表一次交易行为。其中的交易内容,会用交易子订单表示,例如:用户一次性购买5个苹果,3个梨子,那就对应为一个交易主订单,它刻画了用户的购买行为,其中有两个交易子订单,一个描述5个苹果,一个描述3个梨子。

如果我们系统的子单可以单独发货,甚至多仓发货的,那么我们再加一个发货单的概念,用作和包裹一一对应,一个包裹可以放任意交易子单的物品,例如上面的两个子单可以放到两个包裹,用两个发货单表示,一个发货单4个苹果,另一个发货单1个苹果加3个梨子,当然我们的电商系统还有商品、客户、收件人、供应商等实体,现在我们在系统中有了这些实体,如下图所示。

图片

在系统中,实体具有各自的生命周期。一个交易主订单可能包含多个交易子订单,一个包裹可以随意组合子订单进行发货。但这些模型相对较弱,因为难以充实。如何将这些需求封装为知识,以设计出更完善的模型,只有在实际操作中才能找到答案。这也是系统初期面临的实际情况,不应过度设计,往往一开始就是一个简单的CRUD系统。

2)领域知识:生命周期

以上介绍的实体都有自己的生命周期,生命周期体现在系统行为中。以简单电商系统为例,从下单到服务结束,基本经历以下过程行为:

下单

  1. 用户提交订单

  2. 商品的库存占用

  3. 用户在规定时间内进行支付

  4. 订单阶段性状态推进:待支付、支付完成、待发货、运输中、配送中、妥投等等

查询

  1. 生命周期中发生查询请求

取消

  1. 订单有效期到期取消订单

  2. 用户取消订单

以上流程,都和上面提到的实体相关,但具有相同生命周期的实体组合较少。例如,订单实体的生命周期与客户完全不同。客户从注册到注销,一直存在,而订单仅在一次完整交易行为中存在。商品和订单也不同,订单被取消生命周期结束,而商品可以重新售卖。因此,在商品、供应商、客户、交易主订单、交易子订单、发货单等实体中,只有交易主订单和交易子订单具有相同生命周期,过程还包括发货单。

另一方面,我们看一下会改变交易主订单和交易子订单状态的一些代码行为(通常我们会封装到服务类中),代码在系统刚开始基本会写成如下这样:

图片

但以上的依赖链路,会导致各种单据实体异常稳定,无人敢于轻易更改。随着需求的复杂化,管理这种依赖关系显得尤为重要。如果不加改进,这种方式可能会引发诸多问题,以下列举了一些问题及其特点:

事务一致性:在某种程度上,这些服务都需要改变订单单据的状态。以提交订单服务、订单支付服务、取消逻辑处理服务、时效管理服务、物流服务等服务为例,它们都需要独立保证事务的逻辑一致性。这涉及到并发和乱序问题,保持一致性的逻辑代码复杂且易错,开发人员维护起来也会感到疲惫。跨进程操作订单更是灾难性的后果。

共同闭包性:我们发现,大部分交易主订单的状态是由子订单或发货单推进的。例如(妥投一致性规则例子):如果包裹从不同仓库发出,可以走不同的路线。只有当发货单Asub1、Asub2、Asub3都妥投后,才能将交易主订单AOrder修订为完成。每个包裹更新事件基本上都是独立的一次事务。假设Asub1的妥投事件同步过来,我们必须将Asub2、Asub3、AOrder从数据库中取出进行检查和处理。实际上,子单的状态也可能被发货单推动。如果发现实体组合中有许多这样的同时修改需求,说明它们基本上是一个共同闭包的整体,我们可以考虑将这样的组合进行抽象封装。

共性逻辑散落:例如,以上提到的维护妥投一致性规则的代码,如果还有一些乱序状态回传处理的代码,记录状态变化流水的代码,这些代码各自的本质其实是基本相同的。这些重复的逻辑散落在各类服务中,每次修改一个需求的时候可能需要修改各个服务。例如,我需要在记录流水类换了接口签名实现,那么我就需要在各个服务类中都去更换这个接口签名,这样的共性逻辑散落对修改就是关闭的。

3)领域模式:聚合与聚合根

实际上,对于熟悉DDD的人来说,他们会很容易接触到聚集的概念。我们需要考虑是否可以将这些单据构建为聚集,以及构建后是否存在其他潜在问题。在经过对系统各个方面的权衡之后,我们基本上可以确定,得大于失;

知识拓展(聚合与聚合根):在具有复杂关联的模型中,确保对象更改的一致性是具有挑战性的。不仅相互无关的对象需要遵循一定的规则,紧密关联的对象组也需要遵循一定的规则。然而,过于保守的锁定机制会导致多个用户之间无谓地相互干扰,从而使系统不可用。针对这些模型,我们采用一个抽象来封装它们之间的引用。聚集(Aggregate)是一种相关对象的抽象封装,被视为数据修改的基本单位。每个聚集都有一个根(Root)和一个边界(boundary),边界用于定义聚集内包含什么,而聚合根则是唯一对外的引用。——摘自《领域驱动设计》。

如下图所示,我们先来观察将交易主订单、交易子订单和发货单(包裹)构建为聚集,并以交易主订单作为聚集根后的效果。然后,我将列举几个方面,说明通过这些改变,使得交易实体从贫血模型转变为充血模型,还有这样做的理由:

图片

聚合根一致性:聚合模式的核心特征是,所有涉及交易子订单的操作都会通过其聚合根交易主订单来执行,并由聚合根负责保持它们之间的规则一致性。聚合之间的实体能够相互引用,下面我们将详细介绍一些关于聚合根一致性的规则:

  • 主子单一致性:不仅上面提到的妥投一致性规则,还可以有出仓库一致性规则,也就是说,交易子单的出仓操作是独立的,当所有发货单都已完成出仓,交易主单才将状态更新为出仓。这一规则的逻辑维护责任将转移到聚合根交易主订单实体中,每次聚合状态发生变化时,都会触发一次检查;

  • 发货单与子单一致性:如前所述,包裹是存放子单的部分数量的地方。每个包裹中包含哪些子单以及数量多少,所有包裹的子单总数需要与源交易子订单逻辑保持一致。现在,这一一致性得以由交易主订单保证,不再分散。在发现不一致的情况下,只需在一个地方进行报警监控。

聚合根封装细节:所以很自然的,我们也可以把一些散落在各个操作交易主订单和交易子订单的逻辑都封装到聚合根中:

  • 节点流水记录:由于单据作业流水记录的节点与单据状态相对应,因此流水记录逻辑可以被封装到聚合根中。每当状态发生变化(例如从提交订单(Accepted)到支付完成(Paid)),都会记录一条流水。在2.3节中,我们将专门介绍封装在聚合根内的状态同步模型。需要注意的是,这里并不是让主订单直接操作数据库,它只需负责生成流水,而将流水记录到数据库则应由领域服务负责;

  • 订单状态推进:各种事件(支付、发货、妥投)同步及异步回传的处理代码,都将会封装到交易主订单中,让主订单变更子订单和发货单状态,逻辑只有一份,可维护性强;另外一个状态的变更用状态机是前期可以考虑的方案。

事务修改的基本单元:有了聚合,仓储必须得到整合。而仓储整合的关键是确保聚合的修改成为事务的基本单元,这具有诸多好处:

  • 没有数据库概念:取消逻辑服务、查询服务、支付逻辑处理服务等服务,不在需要写一遍SELECT交易主订单,交易子订单,UPDATA交易主订单,交易子订单等逻辑,甚至没有INSERT这种逻辑,而它们都只需两个动作:拿出交易主订单聚合,放交易主订单聚合回仓库;

  • 副作用的保护:以前的模型中,各个服务都会对单据产生副作用。而现在只有交易主订单会对包裹和子单产生副作用。这种副作用还可以被监控,下一节我们将详细介绍如何通过深入演进命令实体模型来保护这些副作用。

图片

有了以上的设计,可以想得到,如果需要添加新的状态或一致性逻辑,只需在交易主订单聚合操作中进行即可。此外,新增拒收回传服务也无需重新编写保障业务事务的逻辑,无需编写一行记录流水的代码,封装性和可维护性的价值得到了很好的保障。同时,支付处理服务、取消逻辑处理服务和妥投逻辑处理服务等服务的职责变得单一,代码逻辑变得更加轻松,可读性也得到了提升。

聚合的坏处:正如没有完美的架构一样,聚合模式也有其利弊。以下是实施聚合根后需要面临的问题及解决方法:

  • 查询性能:显然,如果你只想修改交易主订单的一个字段,仓储将加载所有相关的交易子订单,这无疑会对性能产生负面影响。另一方面,如果你一次性加载所有聚合实体,那么不需要修改的实体也必须写回数据库,但这可以通过一些微小的设计优化来解决,例如,根据聚合根修改了哪个实体,为该实体添加不同的版本,这样仓储就只会根据版本按需更新对象。另外,有些状态变化可能对一致性没有影响,但仍然会触发一致性检查,这类性能影响不大。

  • 无谓的更新:例如你只想更新单据的一个字段,而你的SQL是这样写的,UPDATE TABLE A SET A.name = "Marry" WHERE XX,而使用了聚合根之后,就需要更新整个DO的多个字段。如果你不小心设置了其他字段,它们也会被更新,从而减少了犯错的成本。但这并不会成为大问题,可以加入断言或显式打印每次修改字段的日志,以便开发者迅速发现错误。

  • 属性访问:访问单据的问题显而易见,例如,某个服务需要访问交易子订单的数据,只能通过交易主订单进行交互,这是否让人难以接受?其实这个问题很容易解决,只要将查询分离出来,创建一套聚合的访问视图(访问模型),让交易主订单的充血方法返回这个访问视图,让服务操作这个视图即可。而且这个视图可以在各种地方使用,不必担心会产生副作用,性价比非常高。

其实聚合根的设计不应该过大,里面的实体种类最好不要太多,上面例子提到的聚合只有3个Entity刚刚好,但实际问题中最多三到四个实体就到了一个比较合适的度了,而且这个时候聚合根的好处会体现的更明显。

2、隐式概念

1)领域知识:单据字段

这节,我们将会直面单据类CURD最讨厌的问题,它就是单据的字段。单据字段在MVC三层架构中,程序员很可能会去偷懒直接用一个DO对象捅到业务层去,最多加一个DTO对象。而在聚合根中,字段更加会难以管理,但如果你愿意用心去细细思考字段的一些特性,说不定也能发现很多不一样的世界。

单据字段多样性:单据最重要的作用是承载属性,而且属性非常多,如下面的交易子订单实体的属性,而且还有各种用作关联的属性,再加上拓展字段,如果这些字段全部由聚合根去维护,那么聚合根的方法会臃肿成怎么样子?

@Getter
@Setter
public class TradeSubOrder {
 
    private Long id;
    private Date gmtCreate;
    private Date gmtModified;
    private boolean test;
    private StatusEnum status;
    // more field
    private String size;
    private boolean repositoryTrace = false;
    private String extendAttribute;
    //还有更多
    // getter setter toString
 
}

如果聚合根有20个属性,发货单有15个属性,交易子订单有20个属性,那么聚合根就要有(20+20+15) * 2 = 110个属性访问器对外,这个充血对象和DTO感觉是没有差别的,而且新加一个字段需要加两遍,这样看的话,子单据、发货单等实体必须单独自己去管理自己的字段比较好,而聚合根只需维护一致性的时候去访问该字段即可。

动态拓展字段:如果要你做一个属性经常动态变化的实体,你应该很容易想起把属性打平(建立一个表存key、value、关联id),或者直接加一个extAttribute的Map实现,把属性打平后,我们也不用担心实体的搜索问题,因为现在的查询分离的宽表、NoSQL索引都比较强大了,如果一些字段属性只是在单据上作展示和透传用的,并无多少行为关联,那么很建议这样做。

字段的内聚性:分析一下订单不难发现,一个订单的字段可以归类,从每一类的修改入参可以看出,各自都具有相同的修改原因,如果字段是具有内聚性的,那么多样性的字段就应该是可以分类治理的:

  • 交易主订单:“收货地址”,“收货人姓名”,“联系电话”,“邮箱”;共同变化的原因:<联系人信息类>

  • 交易主订单:“客户id”,“客户姓名”,“会员等级”,“账号”;;共同变化的原因:<购买者信息类>

  • 交易主订单:“支付方式”,“支付单号”,“支付状态”,“支付时间”,“实付金额”,共同变化原因<支付行为>

  • 发货单:“送达时间”、“服务时效”、“配送员”、“物流订阅商”;共同变化的原因:<物流节点>

  • 交易子订单:“规格”、“数量”、“价格”、“图片”、“货主”、“优惠价”;共同变化的原因:<商品编码>

  • 其他归类......

有意识的程序员,已经开始把以上各类获取、设置字段属性的代码分别归类到各个不同的函数中,或者不同的类(可能叫商品表达类、物流信息Handler类等)中等等,这种方式在一定程度上是提高了复用性,提高了可维性,但这还远远不够;

字段变化难跟踪:单据承载了很多的信息,各种字段信息是什么时候变化的,单据字段的变化也可能是多次变化的,这些变化的时间和轨迹对于业务的意义有时候也很重要,通常有些特殊的字段产品和业务同学会明确给你提用例需求去做,我们来展示一个真实例子“修改地址”:

  • 用户修改地址:对于电子商务类服务,无论是各种快递、淘宝等都是支持在未妥投之前让用户去修改地址的,做的好的产品,甚至可以支持多次地址的修改,那么用户在什么时间修改了地址呢?修改前是什么?修改后是什么?这些信息都必须在某个地方很明显的表达出来。

  • 加一个服务类:当业务需要的时候,我们自然可以专门开发一个服务类插入系统去支持,但这种需求又有多少呢?未来有没有?能不能有一套设计方案可以保护核心流程,保留可选项,又不失优雅的去支持这类业务呢?

2)概念突破:命令实体

知识拓展:本小节将会介绍一个叫命令实体的领域概念,在许多DDD框架和介绍文中,Command通常被描述为一个简单的贫血DTO加上参数验证逻辑,而不承担业务逻辑的角色。如果经常使用DDD框架,可能会对这个概念产生混淆。这种用法可以类比应用Service和领域Service。为了避免误解,我们将在下文中明确指出Command与CQRS架构中的Command的区别,并建议将下文的Command替换为Operation,以符合领域逻辑。

沟通获取知识:在DDD中,想要和领域专家通过沟通获取知识,统一语言是很重要的,本文的电商领域入门其实比较低,所以基本上沟通会很顺畅,但这不代表知识挖掘是一件容易的事情,下面来自我和产品经历的一段对话:

:业务最近上了新的话费充值特惠版产品,那个客户的手机号他不让用户输入,要我从账号中心获取,为什么?

产品:是的,他们这款是优惠充值产品,只能给客户自己充值,所以省略用户自己填写的步骤,提高体验。

:明白了,其实本质都是填写发货单的手机号,只不过是实现方式不同,在我们工程领域是一个标准,实现方式不同。

产品:我大概明白你的意思,这样做没问题,当然肯定还会有第三种方式的,但他们都是做一件事。

:我记得在电脑端下单和手机端下单,发货单的地址填充方式也不同。

产品:嗯,是的,手机端可以提供精准下单地址,电脑不行,这也是不同的方式,而且这些和产品无关了,所以你也要支持组合使用哦。

:我知道怎么实现了,我做一个发货单手机号填充命令,但是实现类不同,下次你们变化,我就让你们自己配置。

产品:可以,命令我能听懂,上次小冰跟我说什么interface就不知道是啥了。

:哦,interface你就不用管了,其实我也是用的interface哈哈哈哈。

其实通过以上沟通,我明白的是,产品需要的是这个补充字段是可以配置的,但大多数人拿到需求立马代码就出来了,也不考虑一下这样写的原因,其实很多时候,只有在写代码的时候,你才会知道除了业务和产品表面上的需求,内部可能蕴含着更深知识可以挖掘;

专业知识:查询与命令的划分是常见的做法。我们经常将代码分为两类:一类用于改变状态,这类代码称为命令;另一类用于获取状态但不改变,这类代码称为查询。单据的字段通常都会改变单据实体的状态,因此,如果我们将这类逻辑视为命令,那么很明显,如果我们看到一个类的名称带有Command后缀,我们可以很容易地想到,这个类必然会改变状态,而单据的状态就是字段。

知识拓展(柔性设计):在Eric Evans的《领域驱动设计》一书中,他建议我们将逻辑代码组织成无副作用的函数,让函数返回Value Object。然后,让简单的副作用命令根据返回的Value Object来更改对象状态。如果可能的话,尽可能将这些逻辑代码封装到Value Object中,形成一个无副作用、可组合复用的Value Object。由于无副作用,我们可以自由地组合和复用这些函数。(见《领域驱动设计》——柔性设计 p174)

说到组合复用,再结合产品要的配置,以及我需要的柔性设计,那么把以前所有的改变状态的代码,都组织为命令对象,让命令返回修改后的单据的编辑稿版本(Value Object),最后让聚合根自己把编辑稿(Value Object)更新到自己的字段上,这样就基本符合Eric Evans的这种模式。整个过程类似于编辑表单的过程,用户点击编辑命令后,会获得可编辑的界面(表单草稿),编辑完成后提交按钮触发后台操作,将表单草稿应用于实际生效的表单中。

图片

命令模式:这个过程和命令模式是差不多的。我们会把命令交给聚合根去执行,对比上面命令模式的图我们可以看出,其实运维人员就是Client,他把封装好的命令间接设置给交易主订单聚合根,而Invoker,则是聚合根,他负责执行具体的命令,同时也会记录命令的执行,改变自身状态。例如下面的代码所示,为聚合根执行命令的过程。

public class SubmitOrderUsercase{
    
    public void sumit(Request request) {
        TradeMainOrder mainOrder = getMainOrder();
        //获取命令的具体实现
        IPhoneNumberCompleteCommand command = getCommand(request,IPhoneNumberCompleteCommand.class);
        //聚合根执行手机号完善命令
        mainOrder.execute(command);
        // ......
        //获取命令的具体实现
        IDiscountCalculateCommand command = getCommand(request,IDiscountCalculateCommand.class);
        //聚合根执行折扣计算命令
        mainOrder.execute(command);
        // ......
    }
  
    public IPhoneNumberCompleteCommand getCommand(Request request,Class clazz){
      // 业务配置好的,什么场景用什么命令.......
    }
  
  }

上面还提到字段内聚性,那么我可以把所有相同原因变化的逻辑设计为一个个命令对象来管理我的单据字段。这个对象会封装逻辑所需的入参,甚至查询外部服务(实际上只是查询,没有状态变更,查询结果也是入参的一种,它只依赖于入参)。因此,我们可以创建发货单完善命令、支付信息完善命令、购买者信息完善命令、商品信息完善命令等等对象,我还可以给他们做一个最大的分类,按照不同实体有不同的命令修改接口得到交易主订单变更命令、交易子订单变更命令,这些命令只能由聚合根(交易主订单)执行。最后,通过修改依赖关系,我们可以得到如下图所示的组件结构:

图片

如此的灵机一闪,引入命令实体后,以上所有的字段问题,都刚好被这个模型拟合了,我列举几个好处:

设计良好:很明显的倒置依赖,保护聚合根的独立性;函数式编程,可以组合而不担心逻辑错误,有人可能会质疑,命令内部是不是会直接访问对象呢?如下图的命令接口所示,如果这样设计该接口明显是有副作用的,但如果我们传入的是编辑稿(类似视图),然后我们编辑视图,最后更新回到实体就可以了。

public interface TradeSubOrderChangeCommand {
 
    String getSubOrderId();
 
    void execute(TradeSubOrderDraft subOrder);
 
}

字段分治管理:有了命令后,加上适当的命令命名,字段的管理再也不混乱。每个字段都应该有其对应的设置命令进行管理,而不是让各种服务类去进行赋值管理。同时,对字段的处理也可以封装到命令中。你可以随时定位一个字段的变更命令,只需要思考一下字段的归类。最重要的是,这种字段的分类的独立性可以让你操作字段的代码独立分离,使其具有更好的开闭性。这一点正好可以解决字段的多样性问题。

命令封装逻辑:命令可以封装action调用。赋值只是命令的目的。既然封装了action的调用,那么对action的入参和结果的处理也可以封装到命令中。更重要的是,只要是符合触发源的目的、职责单一,部分业务逻辑也可以封装到命令中。在以往很多贫血系统中,这些都是由service负责的,似乎没有service不知道该如何安置代码一样。

随时随地跟踪:下面是一个简单版本的聚合根执行命令的代码示例。其中record方法根据命令本身的属性提供有选择性地记录执行结果的能力。如果有重要的字段,你可以找到该单据对应命令的执行流水,并进行可视化管理。这种粒度的管理在业务运维和开发疑难问题排查上都非常有用。

public class TradeMainOrder{
  
    public void onCommand(TradeSubOrderChangeCommand command) {
        if (!tradeSubOrderDict.isEmpty()) {
            TradeSubOrder subOrder = findSubOrder(command.getSubOrderId());
             // 变更前的快照代码
            command.execute(subOrder);
            // 变更后的对比逻辑代码,记录字段变化个数、时间
            record();
            // ......
            makeStateConsistent();
        } else {
            log.error("子单变更命令执行失败,子单列表为空,{}", EagleEye.getTraceId());
        }
    }
    public void onCommand(TradeOrderChangeCommand command) {
        // 变更前的快照代码
        command.execute(this);
        // 变更后的对比逻辑代码,记录字段变化个数、时间
        record();
        // ......
        makeStateConsistent();
    }
  }

组合命令:如前所述,无副作用的函数可以方便地进行组合复用。举个例子,一个提交订单的场景中调用了一个名为Combine的发货单完善命令。这个Combine命令充当容器的角色,包含了手机完善、邮箱完善等几个命令。它的执行逻辑就是依次执行这些命令,因为它们具有相同的接口,所以实现这个组合非常简单。此外,它还具备以下特点:

  • 只要给每个命令一个id,那么命令组合就可以在外界进行配置化;

  • 由于命令组合可以进行配置化,因此哪些命令被执行是在运行时决定的,从而体现了灵活性;

  • 命令组合的实现都是基于函数式的,因此组合后的命令不会出现“组合爆炸”的问题,整个过程也是透明和安全的;

有了组合命令,我们可以轻松地根据业务需求将命令定制为组合并上线。能够被管理和配置的独立代码是程序员追求的最高艺术境界。

3、深层模型

1)领域知识:状态推进本质

发货单也具有状态:已接单、待发货、运输中、揽收、妥投、拒收、取消。这些状态的变化驱动是接收外部事件进行推动的,但因为要考虑事件丢失、乱序问题,当一个事件到来后,但前置事件已经丢失、延迟未到,那单据应该决策成为什么状态呢?自然而言,我们很容易联想到状态机,开始我们也是这样做的,状态图如下:

图片

问题空间(物流实操):业务流程的真正设置如下,且中间流程不允许跳过,例如如果没有在运输中,那么揽收就不可能发生,这说明实际业务状态转换与状态机的解空间不匹配,后者包含了很多不必要的部分。另一方面,如果我们设计一个游戏机的投币程序,用一个状态机实例来表示游戏机的状态:投币状态、空闲状态、游戏状态,那么状态机就完全没问题,这里的根本原因在于,问题空间本身就是解空间的模型驱动的。

图片

不纯粹的解空间:为什么解空间中有多余的连线?例如:运输中会跳到妥投,这是因为解空间考虑了计算机和架构的细节问题,如事件传播中的异常和速度问题。如果事件保证顺序消费,那这条连线就不需要;如果按照这种思路组织代码和编写代码,必然会在领域实体中加入不属于领域的逻辑,这是领域驱动设计(DDD)所禁忌的。另一方面,如果使用状态机,需求变更添加一种新状态,那么新的连线也会让很多人感到困扰。是时候调整模型,把该逻辑给去掉了,那么怎么去掉呢?

代码职责问题:假设业务要求在每个状态节点经历时,记录节点流水。状态机的实现方式会如何呢?

如果正确的事件顺序是:

1、运输事件,2、揽收事件,3、妥投事件,

但实际顺序是:

2、揽收事件,1、运输事件,3、妥投事件;

当状态顺序混乱时,状态机在揽收状态,运输事件到达,记录运输节点流水应该让messageHandler处理还是揽收节点处理呢?后者明显不合理,前者也显得勉强。如果除了记录流水,还需要发送外部消息呢?那么messageHandler的职责就会越来越重。

2)深层模型:修正状态机模型

不存在状态推进:我们讨论的是发货单的状态,它代表者物流的操作过程,所以其操作进度要反馈到订单的进度,这个过程其实更多的是一种状态同步过程,而不是状态流转的过程,所以我们的解决办法是:换个角度思考订单状态变更这件事,是状态同步,而不是状态推进**。我们用一个流程实例 (也可以设计为无状态的流程)来解决整个问题。

图片

我们现在把更新状态的算法换了,从状态推进变为状态同步,如上图所示,首先刻画整个问题空间的状态流程作为解空间模型,我们发现这个流程是绝对的无环的,一种拓扑排序。它和状态机有几点不同:

  • 有序性:状态机的节点是无序的,或者说只能相对有序,而我们的同步模型则是有序的;这与问题空间的工序顺序一致,问题空间的每一步都是有序的。

  • 拓扑结构:状态机的节点可能存在环状结构,但我们的同步模型是拓扑排序的,符合业务节点的特性。每个节点都没有环,拓扑结构是该领域的特有属性,这一点很重要,因为我们采用领域驱动设计。

  • 运作机制:状态机的运作核心是围绕事件和当前状态寻找下一个流转状态,而同步模型则以流程实例为核心。每当有事件到来时,我们将该流程的节点标记为已同步。如上图所示,1、2、4、5对应的事件均已到达,因此它们呈绿色。每次事件处理完成后,我们比较最大序号的节点和单据当前状态的序号,将序号较大的节点更新为单据状态。

  • 计算机无关性:模型不再关注事件是否乱序、延迟。只要事件到达,我们就将其对应的节点标记为已同步,并触发相应节点的业务逻辑,如计费消息的发送和流水的记录。

逻辑封装到节点上:显然,上述流水记录代码无需放入messageHandler,发送节点的外部消息发送代码也不需要绑定messageHandler。它们可以封装在运输节点内,或采用观察者模式,监听运输节点以完成相应行为。这样的代码更具扩展性,灵活性较高。

与状态机相比,新的状态同步模型在开发效率和代码维护方面都有所提升。状态机具有线性复杂度,而状态同步模型则是常数级别的复杂度。这个例子充分证明了领域驱动设计的核心本质*:领域的重要性、知识的重要性*

4、边界模型

1)领域知识:边界隐式概念

上面我们讨论完核心模型,我们这节主要讨论的是边界的模型,软件设计的一个关键在于恰当地区分边缘。的确,单一订单处理系统与许多外部系统(如账户中心、商品中心、库存中心、决策中心、支付中心和履约中心等)具有丰富的交互。这些交互主要通过调用各系统的接口来获取或写入数据,其依赖关系如下所示:

图片

零散的隐式概念:很明显在整个接单的系统中,这些边界很多概念是应该属于我们的领域上下文中的,例如赠品、计划、库存、会员等级等概念,但这些概念往往只是存在于字段属性中,例如会员等级就只存在账号实体的属性中,并没有专门为他们创建实体,但需不需要为他们创建实体,也是一个问题,这种发现实体的契机,其实也是需要另一个因素决定的,那就是有没有行为和这些属性绑定,所以一开始,我们先不为这些散落的领域逻辑设计实体,但我们应该为以后需要这些实体而做好准备,下面的防腐层正是最重要的一步。

2)领域模式:防腐层

知识拓展(防腐层):设计一个隔离层,以便根据客户自己的领域模型为其提供相关功能。这个层通过与另一个系统的现有接口进行对话,而对系统的修改最小。在内部,这个层负责在两个模型之间进行必要的双向转换。——摘自《领域驱动设计》

如上图所示的依赖关系,我们显然与其他领域进行了绑定。为确保内部逻辑的独立性,实现对修改的封闭和对扩展的开放,我们必须改变依赖关系。这是明确划分外部边界的关键一步,如下图所示:

图片

设计防腐层会带来一定的编程困扰。你需要在内部设计一个进出参模型或内部接口,并添加一层适配器层。适配器层负责实现内部与外部实体的对接。尽管这种方法较为复杂,但我们仍需了解使用防腐层的理由:

  • 保护核心层概念

  • 例子:

    例如,你在公司中的角色是老板,但在家里的角色是父亲。如果你将老板实体放在家庭中为孩子做饭,这个家庭就会依赖不必要的逻辑,这违反了整洁架构的原则,可能导致变更和稳定性问题;

  • 例子:

    在交易系统这种复杂的系统中,例如一个在供应商系统中代表它自己编码的merchantCode可能来到交易系统这边会变成supplyMerchantCode,同一个值,用角色字段区分他们这自然是很重要的;

  • 关注点分离

  • 说明:外部接口的非逻辑依赖变更不会影响核心逻辑。你只需确保返回字段的含义一致即可;

  • 适配逻辑的代码

  • 说明:有很多代码你只是用来做外部实体的处理的,变成内部可识别的实体,例如决策中心传给你的是2021-07-12 ~ 2021-07-13,但你内部用的是一个stat的Date变量和一个end的Date变量;那就需要适配了,这些代码如果编写在核心逻辑中,那你在维护核心逻辑的时候也不得不多思考一件事,不仅代码臃肿,还消耗你的精力。

  • 说明:有一个点很重要,为什么要做这种设计,因为设计就是需要把代码放在它该呆的地方,这种转换的代码,总要有一个适配器处理;

  • 可随时挖掘隐式概念

  • 说明:例如用户的会员等级,这个会员等级字段属性,就是一个隐藏的概念,它存在于用户账号中,所以你难以发觉。但日后不断的需求变更中,你或许会发现它可能是一个封装性很好的实体。下一节我们将具体介绍如何挖掘除会员等级这个实体。

严格遵守防腐层并不容易,首先要求编写代码的人具备这方面的意识,以免违反规则。其次,编写代码的人应对整个系统架构有一定了解。虽然如此,如果有人把控这部分代码,新手也可以参与系统建设。这一思想来源于《人月神话》中的外科手术医生只有一个的观点。

实际上,划分边缘有两种方式:防腐层和基础设施、应用类业务划分。当将与领域逻辑无关的逻辑划分边界后,六边形架构就出来了。

3)隐式概念:重构中发现模型

以上提到,在划分和外部边界的时候,先不考虑散落的逻辑概念抽象为实体,有了防腐层之后,当有新实体的产生需求即可把这些概念实体化了;这篇我们用一个例子来说明如何通过重构,从防腐层代码中,抽象一个实体出来;首先下面是一个简化版本账号中心域的防腐设计,我们专门为计划的返回做了一个内部的Entity — 用户账号;

@Data
public class UserAccount {

    // 其他字段 ...... 

    /**
     * 会员等级
     */
    private int userLevel;

    // 其他字段 ...... 

}

现在有另一段获取账号信息,根据会员等级获取对应折扣比例的信息,这个代码是这样写的:

public class XxxxxxxService {

    public Double getDiscount(UserAccount account) {
        switch(account.getUserLeval){
            case 1:
                return 0.99;
            case 2:
                return 0.98;
            case 3:
                return 0.97;
            case 5:
                return 0.95
                    default:
                return 1.00;
        }
    }
}

特别的,我们在其他service中也发现了一样的代码,当你注意到这点的时候,就是一个领域实体出现的时候了,那么我们可以复用这段逻辑,并把逻辑和账号关联起来,把该行为封装到账号中,如下所示:

@Data
public class UserAccount {
    /**
     * 客户等级
     **/
    int userLevel;

    public Double getDiscount() {
        switch(userLevel){
            case 1:
                return 0.99;
            case 2:
                return 0.98;
            case 3:
                return 0.97;
            case 5:
                return 0.95
                    default:
                return 1.00;
        }
    }
}

但这还不够,我们忘记了一个领域概念遗留了,那就是会员等级 ,现在是时候把它显性化为一个领域实体了,所以最终的重构结果是:

@Data
public class UserAccount {
    /**
     * 客户等级
     */
    private UserLevel userLevel;
}

public class UserLevel {

    int userLevel;   

    public Double getDiscount() {
        switch(userLevel){
            case 1:
                return 0.99;
            case 2:
                return 0.98;
            case 3:
                return 0.97;
            case 5:
                return 0.95
                    default:
                return 1.00;
        }
    }
}

领域上下文:在代码重构的过程中,可能会遇到这样一个情况:UserLevel类在账户中心也有,名称一样,但数据绑定行为却大相径庭。这是因为我们所关注的字段所处的领域上下文发生了改变,从账户中心领域转向了交易领域。在不同的领域中,针对相同字段的行为封装是各具特色的,这也是领域驱动设计(DDD)的一个显著特点。

重构是领域驱动设计的引擎:在重构过程中,借助领域知识来引导设计方向,确保领域逻辑的独立性,发掘领域实体和聚合根,具有至关重要的意义。这个例子虽然简单,但在很多情况下,我们要突破深层模型,发掘更优质的设计,都离不开重构。掌握重构技巧是程序员的必备素质。若你认为代码难以重构,可以尝试引入单测和小步快跑的方法。

5、领域服务

知识拓展:有时候,对象不是一个事物,在某些情况下的操作你可能找不到合适的Entity或者Value Object去封装,强制把他们归于一类,不如顺其自然引入一种新元素:SERVICE(服务)。其中,这个SERVICE元素在DDD的各个层中也会有体现,所以会存在应用层的SERVICE,领域层的SERVICE和基础设施层的SERVICE。

领域服务:如何设计领域服务是一个值得探讨的问题。本文借鉴了《架构整洁之道》中的用例划分领域服务。在需求分析阶段,用例对于问题分析非常有帮助。将一个用例设计为一个服务,有助于区分应用层服务和领域服务。

  • 应用层服务用作和输入输出相关的逻辑,并且负责调用领域层服务

  • 领域层服务用作和领域模型交互,负责组织和协调的领域模型工作的逻辑

因此,针对本文“生命周期”小节介绍的单据的流程,自然就有以下的领域服务:

  • 提交订单领域服务:执行读取命令配置、执行命令、库存占用、价格计算、定时失效等逻辑代码;

  • 支付领域服务:读取命令配置、执行命令,负责支付校验、调用支付服务、订单各种命令执行等逻辑代码;

  • 取消领域服务:读取命令配置、执行命令,负责释放库存、取消订单、取消定时任务等逻辑代码;

  • .......

此外,还有应用层服务,如提交订单应用服务、支付应用服务和取消应用服务。区分它们的关键在于逻辑是否具有领域概念。例如,导出操作并无领域含义,但获取运维针对不同产品身份的命令配置、命令组合及执行命令结果等业务逻辑,则应放到领域服务层。

从上面可以看出,许多领域或应用层服务是基于实体(Entity)和价值对象(Value Object)构建的。例如,提交订单服务涉及操作单据(Entity)和命令(Value Object)。从这个角度看,他们的行为类似于将领域中的潜在功能组织起来以执行特定任务的脚本。由于文章重点关注单据模型设计和介绍,此处不再赘述。

三、后记

领域驱动设计的核心目标:我们在文章开头阐述了领域驱动设计(DDD)的关键目的,它旨在利用多种实用策略和技巧来提炼出一个能够真正反映实际问题本质领域的模型,并且保护和组织好这个模型之间的相互作用以便解决领域内的问题。我们已经在文章中运用了聚合根模式、统一语言交流、防腐层模式和重构技术等方式来进行探讨,然而,在实际应用中,可用于解决该问题的方法和知识远远不止于此。有时候,我们还需要对现有的模式进行调整和创新来克服建模过程中遇到的问题。这就需要我们的团队技术人员全面掌握DDD的相关知识,同时具备精湛的建模技术和丰富的实践经验,还包括灵活的思维能力和敏锐的洞察力。这些品质对于我们技术人员的日常工作的开展和自身的专业发展都有着重要的意义。

领域模型协作与组织:由于文章的篇幅和主题的限制,我们并没有在文章中详细地探讨关于领域模型之间合作与组织的问题,然而这实际上是非常重要的一部分内容。通常情况下,我们需要考虑的不仅仅是领域模型的纯度,还有其性能和交易属性。例如,领域服务如何管理领域实体,怎样将领域服务和应用程序服务区分开,以及如何将构建程序和执行程序独立开来等等。如果没有具体的案例作为参考,那么上述的问题可能会让人感到有些抽象,但是我们可以通过参考一些优秀的设计规范,比如提高模块间的凝聚力和降低它们之间的耦合度,SOLID原则,以及软件架构的基本原则等,以此来指导我们的实践工作。读者可以在自己的实践中慢慢领悟这些原则的重要性。

演进与重构:本文以业务单据系统为例,系统初始阶段可能非常简单,直接采用三层架构即可。但随着需求的增长和变化,简单的系统将面临复杂性问题。我们必须掌握每次需求变化,实践Martin Fowler的两顶帽子原则——重构+编写新功能。不断重复这个过程,系统得以逐步演进。若重构+编写新功能始终围绕领域知识统一模型进行设计,那么这个过程就是所谓的领域驱动设计。这也是为什么DDD会如此重视那些随着时间的推移而逐渐演化的强大领域模型。

总结:领域驱动设计是一个不断发展和重构的过程。在实际情况当中,我们可以采用多种方式和技术,比如说聚合根模式、统一的语言交流、防腐层模式等等,来解决领域内的问题。团队的技术人员应该具备丰富的DDD的知识,同时也需要有出色的建模技巧和丰富的实践经验。在设计的过程中,我们还需要考虑到性能、交易属性等方面的因素,以确保领域模型的纯度。通过不断地进行重构和添加新的功能的工作,我们就能很好地应对系统的发展和复杂性的问题。最终,领域驱动设计的目标是将投入的成本与业务需求的行为价值保持平衡。

DDD落地:有赞的生产项目

一、有赞教育线索资源管理项目背景

在教育行业中,业务流程涵盖了招生开发、潜在学员信息管理、教务调度、学员沟通、互动辅导以及口碑传播。首先,在招生开发阶段,通过网络营销或线下推广活动收集潜在学员信息,并将其纳入信息管理系统。在潜在学员信息管理环节,利用信息资源管理系统对收集的数据进行统一管理,并将潜在学员转化为实际学员,为后续教务调度提供数据支持。显然,潜在学员信息管理在教育行业中起着承前启后的作用,其重要性不言而喻。

整个项目的业务场景如图 1-1 所示,项目分为两大业务领域,即线索领域和配置中心领域。线索领域主要负责线索收集、线索信息管理等职责;配置中心领域则负责整合公共配置资源,如线索相关的标签、来源等。

图片

图 1-1 线索管理业务总览图

在实际项目中,我们可以根据需求复杂程度和业务特点,灵活运用四色原型图、用例图、时序图等工具,结合 DDD 思想,全面、准确地描述业务需求,构建符合业务需求的优秀领域模型。只有经过严谨的分析和设计,我们才能开发出高质量的软件系统,为教育行业的发展贡献力量。此外,在项目实施过程中,还需关注潜在学员信息管理的实时动态,以便为招生开发、教务调度等环节提供准确的数据支持,确保整个业务流程的顺畅进行。

二、领域驱动基础概念介绍

在介绍 DDD 相关概念前,我们先探讨一下为何要采用领域驱动设计。在非领域驱动设计的项目中,我们通常会先进行数据库表的设计,然后根据表结构推导出相应的实体对象。这些实体对象仅仅是数据的载体,缺乏实际的行为。在这种设计模式下,业务流程实现仍属于面向过程,以数据为中心的过程式思想,开发过程可以理解为对数据进行移动、处理和实现的过程。而如果采用 DDD 的思想去设计,我们将构建一个基于面向对象原则的系统。接下来,我先介绍 DDD 的标准分层架构,然后介绍下需求分析阶段非常有用的四色原型分析模式,最后简要介绍下方案设计阶段常用到的几个 DDD 领域概念。

2.1 领域驱动设计标准分层架构

当前,业界较为通用的 DDD 架构采用的是四层模型,从下到上依次为基础设施层、领域层、应用层和用户界面层。具体的分层架构见图 2-1。

图片

图 2-1 领域驱动架构模型

2.1.1 基础设施层

基础设施层主要负责为其他层提供通用技术能力,如消息发送、领域持久化等。在实际项目应用中,这一层主要处理数据持久化操作,将领域对象序列化到各种存储介质中,如数据库、Hbase、MongoDB、ES 等,并从这些存储介质中读取数据,组装成领域对象。这一层通常采用仓储机制实现领域持久化能力。

2.1.2 领域层

领域层,也称为模型层,是业务系统中最核心的一层,几乎所有的业务逻辑都在这一层实现。领域层主要包括领域模型和领域服务。

(1)领域模型

领域模型用于抽象复杂的业务逻辑,将其转换为便于理解的概念图模型,一般由实体和值对象构成。它与数据模型的区别在于:数据模型描述的是对象的持久化方式,而领域模型表述的是领域中各个类以及各类之间的关系。

(2)领域服务

领域服务可以认为是领域模型的一种补充,因为在实际建模过程中,一些概念本质上是一些操作,涉及多个领域对象,并需要协调这些对象完成操作。若将这些操作硬性归类到某个对象,可能导致对象职责不明确。此时,就需要领域服务来承载这些操作,串联多个领域对象。例如,在线索管理项目中,线索详情页信息包括“线索基础信息”、“标签信息”、“来源信息”和“线索处理日志信息”。在建模时,我们考虑到合理性将这四者定义为四个单独的实体。然而,在获取线索详情时,如何整合完整的线索信息成为一个问题。为了解决这个问题,我们引入了领域服务,负责承载线索信息聚合操作。

2.1.3 应用层

应用层负责提供应用服务,主要负责业务用例的编排和组装。与应用层的主要区别在于是否处理业务逻辑。应用层主要协调领域层与用户界面层之间的关系,对外提供各种应用功能,对内调用领域层的领域对象或领域服务完成业务编排和组装。

2.1.4 用户界面层

用户界面层主要负责展示用户信息。具体来说,就是请求应用层获取所需展示的数据,并发送命令给应用层,要求其执行特定用户命令。在实际应用中,这一层可以不存在。例如,在教育团队早期的项目中,前端通过http方式调用后端服务。在这一层,我们通过提供REST服务与前端进行交互。之后,统一采用RPC调用方式,减弱了这一层的存在感。在这一层声明二方服务接口与前端node层交互,然后在应用层实现具体接口。

2.1.5 线索管理应用工程结构简单介绍

本小节将简要介绍线索管理项目涉及的应用工程目录结构,并对比四层架构。首先,来看工程目录结构,如图2-2所示。

出于商业保密性,实际工程结构中部分模块做了隐藏

图片

图 2-2 线索管理应用工程目录

目录中各模块的定义如下:

  • demo-api:接口层,负责系统间或对外的接口声明。通过RPC调用方式对外提供二方服务。

  • demo-biz:应用服务、领域服务处理层,接口层所声明接口的具体实现。

  • demo-dependency:外部系统的调用封装,比如,系统需要调用商品中心的服务,则需要在本 module 中封装 client。

  • demo-domain:领域层,系统领域的一些 model、上下文对象、仓储接口定义等。

  • demo-web:对外的 REST 接口。

  • demo-dal:基础设施层,数据持久化。

与 DDD 四层构架的对应关系见下表。

用户界面层demo-web
demo-api
应用层demo-biz(api)
领域层demo-biz(domain)
demo-domain
demo-dependency
基础设施层demo-dal

通过以上内容,我们可以了解到领域驱动设计的基本概念和分层架构。在实际项目中,我们可以根据需求复杂程度和业务特点,灵活运用四色原型图、用例图、时序图等工具,结合 DDD 思想,全面、准确地描述业务需求,构建符合业务需求的优秀领域模型。只有经过严谨的分析和设计,我们才能开发出高质量的软件系统,为教育领域的发展贡献力量。同时,在项目实施过程中,我们需要关注潜在学员信息管理的实时动态,以便为招生开发、教务调度等环节提供准确的数据支持,确保整个业务流程的顺畅进行。

2.2 需求分析利器 — 四色原型图

对于简单的需求,用例图往往足以阐述清楚。当需求变得复杂时,我们可以添加时序图、状态图等来进一步说明。然而,当业务流程异常复杂时,如何找出关键点以及各点之间的关联呢?是否存在一种科学的理论来指导我们进行分析呢?这时,我们可以考虑使用四色原型分析模式。它主要应用于业务分析阶段,有助于我们分析业务行为、参与对象以及业务对象之间的关系。

那什么是四色原型图呢?我们先来看下它的四个构成元素,具体如下:

(1)时刻-时间段原型(Moment-Interval Archetype)

原型简称 MI,表述的是某刻或某段时间内发生的一件事,比如:租房合同签署,是在某个时刻签署的,它有发生日期、行为人;租房行为是在一段时间内发生的,它有开始、结束时间和退租行为。这些我们都是可以通过此原型来表达的。在画原型图时,采用粉红色表示。

(2)参与方-地点-物品原型(Part-Place-Thing Archetype)

原型简称 PPT,用来表示参与某个活动的人或物,地点则是活动的发生地。比如签署租房合同这个行为,合同、承租人分别对应这里的物、人,中介办公室对应这里的地点。在画原型图时,使用绿色表示。

(3)描述原型(Description Archetype)

原型简称 DESC,是对 PPT 公共属性的描述,拿“签署租房合同”这个场景为例,在合同中会有一些租期、租金、押金、违约条件等约定,这些约定信息便可采用 DESC 原型来描述。绘制原型图时,采用蓝色表示。

(4)角色原型(Role Archetype)

原型简称 Role,这里的角色,指的是我们通常理解的“身份”。在签署租房合同场景中,行为人包括承租人和中介工作人员,这里的角色便是指“承租人”和“中介工作人员”。绘制原型图时,用黄色表示。

总结:如果必须要用一句话来概括四色原型的话,那就是:一个什么样的人或物以某种角色在某个时刻或某段时间内在某个地点参与某个活动。其中“什么样的”就是 DESC,“人或物”、“地点”就是 PPT,“角色”就是 Role,而”某个时刻或某段时间内的某个活动"就是 MI。

2.3 DDD 几个核心领域概念

2.3.1 实体

实体是一个具有身份和连贯性的概念,它具有以下几个特征:

  • 实体是数据(属性)和行为(业务逻辑关系)的结合体;

  • 每个实体都有自己的唯一标识,判断两个实体对象是否相等,是通过唯一标识来判断的。比如,两个实体对象,如果唯一标识相等,即使其他属性不相等,这两个实体也会认为是同一个。实体的其他属性不相等,表征的是同一个实体在其生命周期的不同阶段。

  • 实体的唯一标识属性值是不可变的,其他属性值是可变的。

举个例子简单说明下,比如在有赞精选内容平台(类似于小红书的电商导购平台)这个业务域中,每一篇“博文”就是一个业务实体,可以采用“博文 id”作为实体的唯一标识,然后这个博文实体拥有着属性(标题、作者、发表时间、内容等)和行为(更新博文、删除博文、关联导购商品等),同时,属性是会随着行为而不断变化的。

2.3.2 值对象

值对象一般会作为一个属性存放于一个实体内部,它具有以下几个特征:

  • 值对象不需要唯一标识,判断两个值对象是否相等,是通过值对象内部所有属性值是否相等来判断的。

  • 值对象的属性值不允许更改,即在创建后,其实体将保持不变。若要更改属性值,需先删除对象,然后重新创建一个新对象。

以“有赞精选内容平台”为例,用户可以在博文下留言,我们会挑选部分留言置顶。对于“置顶留言”,可以将其定义为值对象,并作为博文实体的属性。当置顶留言发生变化时,只需创建新的值对象,并将其赋值给博文实体的相应属性。

2.3.3 聚合

聚合是一组具有内在联系的领域对象(包括实体和值对象)的集合,其中一个或多个实体组成。每个聚合都有一个根实体(又称聚合根),主要负责与外部交互。外部对象若要访问聚合内的实体,必须先访问聚合根,再由聚合根与内部实体交互。

还是拿“有赞精选内容平台”举例说明,一篇博文中,它包含博文基础信息(内容、标题等)、关联的商品信息、关联的标签信息等,这一组合构成一个聚合,其中“博文基础信息”可作为聚合根。

2.3.4 仓储

首先说明,仓储被设计出来的初衷,在领域模型中,对象被创建出来后一般会在内存中活动,待其不活动了后,需要将其进行持久化存储。然后,当我们需要重建对象时,需要根据对象当前状态进行重建。可见这整个过程中,会频繁的与数据库(广义的数据库,包括关系型数据库、NoSql 数据库等)打交道,进行对象的创建、组装等。因而,能否提供一种机制,帮助我们管理领域对象以及做对象持久化,仓储并应运而生了。

仓储,又称资源库,它具有以下几个特征:

  • 仓储作为领域层与基础设施层的桥梁,将仓储接口定义放在领域层,具体实现放在基础设施层。这种解耦有助于减轻领域层与 ORM 之间的关联,任何 ORM 变更只需修改仓储实现,领域层接口定义无需修改。

  • 仓储存储的对象一定是聚合,因为领域模型以聚合划分业务边界。因此,我们只对聚合设计仓储。同样,在进行数据更新、删除等操作时,应以聚合为单位进行操作,而非仅操作聚合内的某一个实体。

三、线索资源管理 DDD 实战

结合四色原型图,设计领域模型的步骤可概括为以下几步:

  • 根据需求,采用四色原型分析法建立一个初步的领域模型;

  • 进一步分析领域模型,识别出哪些是实体,哪些是值对象,哪些是领域服务;

  • 对实体、值对象进行关联和聚合,提炼出聚合边界和聚合根;

  • 为聚合根设计仓储(通常情况下,一个聚合对应一个仓储),同时考虑实体、值对象的创建方式,是通过工厂创建还是直接使用构造函数;

  • 走查需求场景,验证设计的领域模型的合理性。

在图 1-1(线索管理业务总览图)中,我们可以看到“线索域”是核心部分。接下来,我将重点针对“线索域”,按照上述步骤一步步推导出其领域模型。

3.1 场景分析提炼四色原型图

无论是线上营销工具渠道还是线下地推渠道,线索收集环节最终触发的业务场景都是线索新增。而在线索管理环节,主要业务场景包括:新增/更新线索、查询线索、分配线索、跟踪线索和放弃线索等。这些业务场景在图 3-1 中可见。

图片

图 3-1 线索域业务场景用例图

(1)新增/更新线索四色原型图

根据业务规定,只有高级管理员、课程顾问、普通管理员等有权操作线索。依据四色原型的“一个什么样的人或物以某种角色在某个时刻或某段时间内在某个地点参与某个活动”的原则,我们可以得出操作过程中的参与方原型为商家,参与方角色包括商家高级管理员、课程顾问、普通管理员等;物品原型为线索,包括线索基础信息、线索标签、线索来源;参与的活动为“新增/更新线索”。提炼出的四色原型图见图 3-2。

图片

图 3-2 新增/更新线索四色原型图

(2)查询线索

参与方原型为商家,参与方角色包括商家高级管理员、课程顾问、普通管理员等;物品原型为线索基础信息、线索标签、线索来源;参与的活动为“查询线索”。提炼出的四色原型图见图 3-3。

图片

图 3-3 查询线索四色原型图

(3)分配线索

每个线索若需分配跟进人,必须指定一个跟进者。如果当前跟进人无法完成线索跟踪,可以将线索转让给其他人(线索分配者、线索承接人、线索原跟进人的身份均为“高级管理员、课程顾问、普通管理员”之一)。根据上述场景,参与方原型为商家,参与方角色包括商家高级管理员、课程顾问、普通管理员等;物品原型为线索基础信息、线索标签、线索来源;参与的活动为“分配线索”。提炼出的四色原型图见图 3-4。

图片

图 3-4 分配线索四色原型图

(4)跟踪线索

课程顾问在获得分配的线索后,需要进行线索跟踪。在跟踪过程中,课程顾问可以记录相关的跟踪信息。此时,参与方原型为商家,参与方角色是课程顾问;物品原型为跟踪记录;参与的活动为“添加跟踪记录”。归纳出的四色原型见图 3-5。

图片

图 3-5 线索添加跟进记录四色原型图

(5)放弃线索

如果课程顾问认为当前线索难以跟进,可以选择放弃该线索。从这一场景中,我们可以看出:参与方原型是商家,参与方角色是课程顾问;物品原型是线索(包括基础信息、标签、来源);参与的活动是“放弃线索”。提炼出的四色原型图见图 3-6。

图片

图 3-6 放弃线索四色原型图

综合以上所有场景,可得出图 3-7 所示的“线索域”四色原型图。

图片

图 3-7 线索域四色原型图

3.2 领域模型中实体/值对象/领域服务/聚合识别

通常,我们可以将四色原型图中的原型与 DDD 进行简单映射。例如:PPT 原型表示活动中的唯一个体,可对应 DDD 中的实体;Role 原型描述实体在不同状态下的表现,通常将其放入实体中,共同构成带状态的完整实体;DESC 原型表示 PPT 的公共属性,一般作为值对象存储;MI 原型描述特定活动,可间接对应领域服务。

我们回过来看下图 3-7,在图中,有“商家、线索基础信息、跟踪记录、来源信息、标签信息”5 个 PPT,我们可以据此定义 5 个实体,“高级管理员”、“课程顾问”、“普通管理员”可以认为是商家在不同身份下的表现,可在商家对象中使用一个标识符来描述。于是,我们可以总结出以下实体,见图 3-8。

实际的线索信息比图 3-8 中定义的要复杂,出于商业保密性,这里仅列出部分字段,且部分字段采用 xxx 来表示。

图片

图 3-8 线索域实体

接着,我们进一步分析实体间的关系,提炼出聚合边界和聚合根,并定义出仓储。

在线索域中,线索是核心,很明显 ClueEntity 与 SourceEntity、RecordEntity、TagEntity、UserEntity 是相关联的,而后四者间是没有联系的。首先,来看下 ClueEntity 和 UserEntity,线索在创建之初是可以没有跟进人(用户)的,但在之后被跟进的过程中,需要强制绑定一个跟进人(用户),而用户脱离线索是不具有存在价值的。同时,本项目中,用户信息仅作为线索的归属属性存在,最终我们将 UserEntity(改名为 UserVO)作为值对象放置于 ClueEntity 内,且令线索信息实体为聚合根;然后,分析下 ClueEntity 和 SourceEntity、TagEntity、RecordEntity,主要从两个方面考虑是否需要组成聚合:

(1)聚合应具备内部一致性,即聚合内对象要么一起获取,要么一起更新,要么一起删除。若聚合内任意对象在保存时被修改,都应视为聚合被修改,此时保存失败。因此,在定义聚合时,保证合理性的前提下,尽量设计较小聚合。在线索管理中,线索管理人员频繁为线索关联标签、来源、跟进记录等信息,从内部一致性角度考虑,三者分开更好。

(2)聚合内聚合根和对象间要保持不变性。何为不变性?简单来说,对象之间存在某种不变的规则。举个例子说明下,x=y+5,如果规定 y 大于 1,那么 x 一定大于 6。回到线索管理,ClueEntity 和 SourceEntity、TagEntity、RecordEntity 间并不存在这种不变性,因为任意一个来源、标签、跟踪记录一定有一条对应的线索,但一条线索可以没有来源、标签、动态记录,同时,来源、标签、跟踪记录均可以在各自领域被单独访问到。

综合以上两点,我们采取如下策略:SourceEntity、TagEntity、RecordEntity 各自定义为一个聚合,本身作为聚合根。然而,在查询线索详情时,线索包含来源、标签、跟踪记录信息,而 ClueEntity 聚合内不包含这些信息,如何实现信息聚合?我们采用领域服务来实现领域对象间的聚合。

最后,定义仓储。我们遵循一个聚合对应一个仓储的原则来定义。最终,我们得到了如下表所示的领域模型。

仓储聚合聚合根
ClueRepositoryClueAggregateClueEntity, UserVO
SourceRepositorySourceAggregateSourceEntity
TagRepositoryTagAggregateTagEntity
RecordRepositoryRecordAggregateRecordEntity

对应的类图可见图 3-9。

图片

图 3-9 线索域类图结构

通过以上步骤,我们可以构建并完善线索管理的领域模型。在实际项目中,根据需求复杂程度,我们可以灵活运用四色原型图,并与用例图、时序图、状态图等相结合,全面准确地描述业务需求。同时,不断审视和调整领域模型,以确保其合理性和有效性。只有经过严谨的分析和设计,我们才能构建出一个符合业务需求的优秀领域模型。

四、总结与思考

本文以“线索资源管理”实际项目为背景,详细阐述了从需求分析到方案设计阶段,如何运用 DDD 理念逐步构建领域模型。首先,在第一章中,我们重点介绍了项目背景及其在教育领域的重要性,并给出了主要业务场景,以便读者对项目有一个全面了解。接下来,在第二章中,我们详细介绍了 DDD 的分层结构、需求阶段可用的四色原型分析法以及方案设计阶段所需的几个 DDD 领域概念。最后,在第三章中,结合前两章的项目背景和领域概念,我们一步一步地构建了本次项目的领域模型。

在实际项目中,我们可以根据需求复杂程度和项目特点,灵活运用四色原型图、用例图、时序图等工具,结合 DDD 思想,全面、准确地描述业务需求,构建出符合业务需求的优秀领域模型。只有经过严谨的分析和设计,我们才能打造出高质量的软件系统,为教育领域的发展贡献力量。

携程订单系统重构的DDD实践

随着历史业务的不断演进和业务场景的日益复杂,携程用车和租车(简称两车)在面对历史技术债务和系统复杂度不断上升带来的理解、维护、迭代难题时,开始寻求更有效的方法来降低复杂度和提升效率。

本文描述了两车如何利用DDD(Domain-driven Design,领域驱动设计)方法论来减轻系统复杂度,以及在重构历史系统过程中所作出的取舍和思考。此案例对于复杂业务场景下的领域驱动设计具有借鉴意义。

一、案例介绍

携程用车订单业务包括接送机、包车、打车等产线,涉及订单状态管理、支付状态管理、供应商订单状态管理、履约状态管理等功能。履约状态关乎司机相关状态,完成订单需结清额外费用。

携程租车订单业务涵盖订单状态管理、支付状态管理、押金扣除记录、供应商订单状态管理、履约状态管理等功能。履约状态主要涉及取还车相关状态。

订单和相关实体如下图所示:

图片

二、问题分析

由于两车业务存在一些差异,为了读者更容易理解,因此将抽取共性问题来说明。

2.1 沟通困难

关于沟通困难,在整个开发过程中,沟通是一个耗时且复杂的过程。需求方需与产品沟通,产品再与研发沟通。研发在开发过程中发现问题需与产品确认,产品则需寻找需求方核实。跨团队沟通更加复杂。以下是一些常见场景:

  • 产品不关心研发的实现,但是觉得需求很简单或者很复杂。

  • 研发开发过程中发现一些忽略的细节需要产品确认,产品要找需求方确认。

  • 历史逻辑没人知道,需求评审的时候无法发现问题,做到最后发现有问题。

  • 跨团队之间不了解对方的业务,需要反复沟通确认。

  • 遇到同一个名词不同的理解导致无效沟通。

  • 一个需求到底该哪个域来实现是我们在实践中经常反复探讨的问题。

  • ...

例如订单和供应商订单在不同的团队内都叫订单,在沟通中针对“订单”的讨论就会产生歧义。

2.2 业务边界不清晰

设计之初,订单被各调用方当作了对外输出的数据源头,数据需求方只要调订单详情即可获取全量数据,这为以后订单的迭代带来了相当大的隐患。订单在自己的业务模型中加入大量不涉及自身业务的冗余字段,在系统的演进过程中,由于无脑插入他方业务字段使得订单自己也要维护相关的逻辑(解释和修改),导致各方对订单的耦合日益加深,导致订单服务的发布变成高风险行为,甚至一个无关订单业务的相关字段修改也可能导致系统故障。

例如订单上关于供应商的相关数据,用户订单有一份,采购订单也有一份,当采购要修改供应商的相关逻辑时要用户订单也一起修改,而用户订单必须排查和推动相关使用到这个字段的业务方切换替代方案。

图片

2.3 面对业务变化修改困难

随着历史业务迭代,订单中集成了许多非订单关注的核心业务逻辑。例如,历史上发送用户通知是根据订单状态变化触发的。订单需提供所有相关参数,导致核心业务依赖非核心业务。在这种情况下,若需求变更,如重发发送失败的通知,逻辑似乎只能放在订单上,不够优雅。

图片

三、解决方案

3.1 回归业务本质——挖掘愿景

为了解决业务归属问题和明确系统发展方向,避免将资源浪费在非核心功能上,我们需要了解当前项目的本质和目标。因此,我们需要为系统设定一个愿景,这将引导我们在未来的迭代中保持正确的方向。愿景就像是我们的产品定位,它区别于其他系统,也确定了当前系统的边界。

图片

愿景就像是手电筒发出的光,黑暗中的边界就是我们的系统,而光的延伸方向则代表着系统的未来。

有许多方法可以阐述一个愿景,为了降低实施的难度,我们选择采用麦肯锡的“电梯演讲”形式,围绕机遇、挑战、优势和劣势来展开。这个过程中,领域专家和开发团队需要共同进行头脑风暴,这实际上也是领域驱动设计(DDD)统一语言的起点,我们必须在愿景的层面达成一致。

产品名称用户订单管理系统
产品品类一个查看和管理订单平台
描述目标客户或利益相关人的需求或机会1、定后履约的查看和管理
2、流畅: 每个用户有操作需求的节点高度自动化,无需人工处理
3、贴心:想用户所想,在合适的时间告诉用户合适的事情
4、无忧: 减少用户的焦虑,让用户放心的使用我们的服务
阐释产品能够带来的关键价值(或者说购买的理由)为用户提供订单流程管理。
与竞争产品的不同之处

友情提醒

Eric Evans 在他的书中曾提到过一种模式:领域愿景描述(Domain Vision Statement)

“由于一开始项目的模型通常不存在,但是需求是早已定下的重点,为了我们在后续阶段清楚了解系统的价值,以价值作为我们的导向。”

在研究领域愿景声明时,我们发现编写一份合格的文档并非易事。因为它没有明确的规范和套路,Eric 也只是提供了几个案例供我们参考。虽然撰写愿景声明并不复杂,但要达到合格标准仍有一定的门槛。因此,我们选择回归 Eric 的观点:“很多项目团队都会编写‘愿景说明’以便管理。最好的愿景说明会展示出应用程序为组织带来的具体价值。”

3.2 高效沟通——利用事件风暴统一语言

图片

关于统一语言,一个经典的例子便是传话游戏。一句话经过多人的转述,最后可能完全变了一个意思。

为了迅速实现统一语言,我们在订单重构过程中投入了大量时间进行事件风暴。事件风暴有以下几个优点:

  • 事件风暴以业务流程为核心进行讨论,使在场的每一个人都能通过多条流程深入了解业务实体的变化。

  • 事件风暴汇集了“领域专家”,包括产品、开发、测试等团队成员,本质上是一场集合集体智慧的头脑风暴。在事件风暴中,所有人都能达成业务共识。

  • 事件风暴整合了各人的领域知识,是一场领域知识的分享会。

图片

过去,事件风暴主要以线下工作坊的形式开展,以增强参与感。然而,由于成本和线上办公的兴起,我们现在更多地采用在线工作坊。在此推荐两个工具:行知蜂(BeeArt)和可画(Canva),它们都支持多人在线协作。

事件风暴实际上非常简单,就是业务流程+业务用例。将业务流程横向展开,通过用例将业务中的名词状态变化一一列举。色块的大小和颜色可以参考www.eventstorming.com,但只要能统一大家的认知,颜色并非关键。

在实践过程中,我们发现先列举业务中单据的状态变化,再补全触发状态变化的动作和角色,效率会更高。关键是要识别出大家认知中的不同事物相同名词、不同名词相同事物,以便后续建模。

通过事件风暴,我们主要关注以下几种情况:

  • 沟通中那些脱离当前领域就难以理解的词汇;

  • 相同名词,含义不同的;

  • 名词不同,含义相同的。

将以上三种情况涉及的名词动词总结成统一语言表,特别是第三种情况,是我们划分限界上下文的关键依据。例如,在讨论支付单时,我们发现存在两种支付单:一个是包含我们业务的支付单,它需要记录当前支付场景并包含一定的业务规则;另一个是支付平台的支付单,每次支付都会生成一个支付单,它可以认为是与更抽象的订单相关(如会员订单、优惠券订单)。

于是我们提取了费项记录这个概念,表示一笔订单可以有多个支付事件,用于区分我们的支付单与支付中台的支付单之间的差异。通过这种方式,我们逐步实现了统一语言,为后续的建模和协作奠定了基础。

3.3 自上而下细化边界——子域划分

传统的面向过程开发方法在面对复杂系统时,通常采用 DFD(数据流图)进行拆分。在 DDD(领域驱动设计)中,子域概念应运而生。我们经常听到领域和子域这两个词,无论是 Eric 的 DDD 还是 IDDD,都大量使用了这两个概念。但有时候,我们并未真正理解子域是如何划分而来的。

对于一个已有的系统而言,我们可以根据康威定律得出:团队边界=系统边界,因此,我们可以认为每个团队负责的部分就是天然的子域。以订单团队为例,我们可以初步划分为用户订单组和采购派发组。基于此,我们可以得出一个领域划分:

图片

此时我们根据愿景,可以明确两个子域各自的职责:

  • 用户订单子域:负责用户订单流程的查看和管理,并在需要时主动通知用户。

  • 采购订单子域:负责采购订单流程的流转,包括供应商和行前行中行后的状态更新。

最后支付使用的是携程金融的能力,由于支付平台的能力在携程内部是统一的,因此我们认为支付平台属于通用域。

3.4 自下而上抽象概念——限界上下文

领域的概念相对而言还是模糊的,因此Eric提出了DDD中最重要的概念:限界上下文。而限界上下文并非凭空而来,而是需要对我们在事件风暴中得到的名词进行归纳而来。

图片

首先,我们列举了用户订单域的各种用例,包括下订单、支付订单、修改订单、取消订单等。

通过建模法归纳模型,我们发现订单流程中存在多种支付场景,同时依赖支付平台的支付单。因此,我们得到了维护支付单状态的支付费项记录,它既维护了支付单相关的信息,也维护了订单系统内关于支付的业务逻辑。

最后我们根据业务相关性对得出的实体进行归纳,结合我们的愿景得出三个上下文,分别是:

  • 用户订单状态上下文:负责管理用户订单状态管理;

  • 支付费项上下文:负责订单支付相关状态管理;

  • 用户通知上下文:负责对用户进行多种方式的通知。

图片

3.5 挖掘业务变化的瓶颈——上下文依赖关系

实际上,限界上下文可以拆分为更细的粒度,但应遵循奥康姆剃刀原则,尽量设置合理的数量,有理有据地进行拆分。我们先来看看原系统的上下文依赖关系:

图片

  • 消息中心:作为携程的消息中台,不会为某个业务线做特殊逻辑,因此是遵奉者(Conformist)。此时,订单内部耦合了消息处理,如果发送消息没有业务逻辑,采用防腐层(ACL)方式较为常见。

  • 用户通知:由于用户通知存在业务逻辑,订单直接与消息中心交互显得不合理。订单作为核心域,应尽量不依赖其他域。为此,我们进行了如下设计:

  • 用户订单状态上下文:更加内聚,关注订单状态管理。

  • 用户通知上下文:独立于订单,便于迭代。

根据Eric对上下文关系的总结,我们可以得出消息中心作为携程的消息中台,不会为了某个业务线做特殊逻辑,因此是很明显的遵奉者(Conformist)。此时消息相关的处理耦合在订单内部,如果发送消息没有业务逻辑那么采取防腐层(ACL)的方式是比较常见的。

由于我们已经识别到用户通知存在业务逻辑,因此订单直接和消息中心交互显得奇怪,而且订单作为核心域,本来就应该尽量不依赖其它域,对此我们进行了如下设计:

图片

这样用户订单上下文更加内聚,而用户通知也更加易于迭代。

四、收益总结

4.1 业务逻辑耦合降低

通过划分上下文和明确职责,各个领域负责自己的数据和领域知识。这样一来,订单不再涉及这些字段,而是由数据写入的业务方负责维护。因此,在后续与订单无关的业务逻辑发生变化时,订单无需进行修改。这有助于降低业务逻辑耦合,提高系统的灵活性和可维护性。

4.2 团队效率提高

在上下文划分和康威定律应用的过程中,各个团队的职责与各自领域形成映射。这使得过去在功能分工上存在的争议得以解决,团队之间能够更加高效地协作。此外,通过优化资源分配和任务协调,进一步提高了团队协作的效率。

4.3 性能和稳定性提高

经过上下文划分,订单实体从原来的780多个字段简化为200多个字段,大大降低了订单的维护成本。同时,存储数据量减少,各个业务逻辑也由各自的写入方负责维护。接口性能得到优化,p95写入速度从68ms提升至12ms,读取速度从63ms降低至5ms。这些改进提高了系统性能和稳定性,为业务发展奠定了基础。

4.4 数据一致性

过去,业务方在将数据写入订单时,可能因网络抖动等原因导致写入失败或数据错误。如今,业务方将数据存储在自己的领域内,不再写入订单。这样一来,数据一致性得到增强,避免了因耦合导致的数据不一致和字段写错等问题。

4.5 人力成本大幅下降

产研沟通涉及的相关方数量减少,链路缩短。去除因业务逻辑耦合导致订单修改的人力成本,整体人力成本在小项目中下降70%,大项目甚至下降80%。通过降低人力成本,提高了项目效益和投资回报率。

五、遇到的问题和方案探索

实际应用领域驱动设计(DDD)的过程中,我们遇到诸如领域专家难寻、业务需求多变且紧急、团队对DDD理解不足等问题。针对这些问题,我们总结出以下几点经验:

5.1 领域专家难寻

在实际工作中,找到一位严格的领域专家往往是困难且成本高昂的。为了解决这个问题,我们可以借助资深研发人员、资深QA人员等,同时通过互相借鉴的方式,尽管业务不完全相同,但领域上仍有相通之处。此外,培养内部领域专家和建立知识体系也是非常重要的。

5.2 业务需求多且急

实际工作中,我们常常陷入各种业务项目的忙碌之中,很多项目都十分紧急。这种情况很容易导致我们无法专注于DDD改造。为应对这一问题,我们采取在有时间时确定方向、提前进行设计,然后在业务项目中逐步实现的策略。同时,通过敏捷开发方法和良好的需求管理,确保在紧急业务需求中仍能保持DDD改造的进度。

5.3 团队对DDD理解不深

为提高团队对DDD的认识,我们成立了专门的DDD培训小组,将实际落地经验整理成规范和最佳实践。在落地过程中,培训小组负责把关,确保大家不走弯路。此外,通过内部交流、分享和实践,提高团队成员对DDD的认知和实践能力。

DDD落地:京东的微服务生产项目

一、前言

现在对于一个后端开发工程师来说,微服务,DDD都是挂在嘴边的东西,感觉大家接触到多,也了解的多。然而,个人对微服务架构的理解却如同我小时候读《三国演义》,随着年龄和经验的增长,感受也各不相同。微服务对于开发人员而言,如同千面镜,每个人看到的风景都独具特色。基于此,我想结合自己的业务实践,分享一下在微服务架构实践过程中的心路历程。

二、首先,我们需要思考一下:什么是微服务架构?

图片

在笔者看来,微服务架构并没有一个准确的定义,但他会有很多特征,通过描述他的特征用来把控它是什么。

a. 白马是马

这是一个哲学命题,表明个别和一般的关系。我认为,任何的技术和架构都不是凭空出现的,一定是发展而来,而微服务架构的前身就是SOA,面向服务的编程。SOA是一种架构设计模式和思想,微服务架构继承了这一思想。在我看来,微服务就像是那匹白马,它将复杂系统从业务视角划分为独立的模块,每个模块都具有提供服务的能力。基于这些能力,我们构建了一个复杂的系统架构,并在此基础上演化了去中心化和分布式思想。

b. 服务治理

以上说的是思想层级,在战术层面,微服务架构的核心诉求和能力在于服务治理,包括服务寻址、流量管理、可观测性等。

c. 十二要素

Heroku创始人Adam Wiggins 提出的微服务十二要素,下面,我贴出我对微服务十二要素的解读:

图片

这十二要素可以说是微服务架构的方法论,有了思想,方法论和战术维度,我觉得就可以完整的描绘出一个微服务架构的全景图。然后,我将我理解的微服务架构总结成一句话:

微服务架构是一种去中心化的分布式服务架构,具备服务寻址、故障容错、流量调度、控制访问和可观测性的服务治理能力,从而实现服务的隔离性、可移植性、可扩展性和稳定性。

三、其次,我们的问题焦点:微服务架构的难点是什么?

微服务的两个核心点正是他的难点:

第一个难点, 微服务的服务治理和流量治理

对于这个难点,现阶段已经有了很好的解决,因为服务治理和流量治理具有去业务场景化的特点,许多先行者通过努力和开源框架的贡献,使我们能够直接应用并实现良好兼容。以下是一个典型的微服务治理架构图:

图片

第二个难点, 微服务拆分的架构思想

如何基于领域驱动设计(DDD)方法论将服务分解落地。这里涉及许多核心能力,如子域划分、限界上下文定义、实体、聚合根抽象、基于领域事件的事件驱动设计,以及工厂模式、策略等设计模式的应用。

这第二个难点是很难做到直接的迁移,因为面对的业务场景各异。前人经验只能以思想为指导,但具体的实施将考验每位架构师的经验和理解。如同画家对同一景色的诠释各异,有的成为梵高,有的成为毕加索。对我而言,现阶段可能只是一个初学素描的爱好者。然而,我仍愿意分享我在采灵通项目中对微服务落地的经验,供大家参考。

四、采灵通系统–微服务架构落地实践

采灵通业务是一个相对复杂的系统,带领20人的开发团队历时5个月,从零开始设计、开发并上线。该系统涵盖了ToB全供应链数字化相关的全场景业务。下面将从业务场景分析、技术架构解析和服务落地几个方面解读该系统的实践过程。

1. 业务场景解析

理解DDD的前提是先要理解业务场景,笔者的业务场景是采灵通SAAS系统,在此简单做一个业务介绍:

采灵通是一个标准的提供B商城触达的,同时解决企业数字化采购供应链的SAAS平台,核心能力包括多供应商协同,订单管理,商品管理,客户管理,及B商城的搭建和管理,具体如下图所示:

图片

2. 技术架构解析

基于以上业务场景,我们构建出我们的技术架构方案,如下图所示:

图片

在技术架构上,整体分为四层,自上而下为:业务前端层, 网关层, 业务服务层,技术底座层。

1)业务前端层

前端根于业务场景,有多个触达端,最主要的端有供应商管理端,伙伴管理端,PC商城端,H5商城端, 页面装修系统。前端封装有统一的组件库,保证了整个系统的前端交互风格一致性。

2)网关层

入口网关为nginx反向代理,主要功能是对不同域名的SSL解析和路径映射,部分前端静态资源的直接映射。入口网关下为业务网关,包括COP网关和通用业务域网关。

通用业务域网关:主要功能是将前端有状态的HTTP请求(header:jwt信息加密和域名信息过滤),进行鉴权,过滤,路由。同时解析为明文上下文Map,存于header中,可供业务层服务使用。其中针对不同域名的路由信息是采用了nacos的配置中心进行统一管理,并下发至gateway,实现一个动态的域名路由管理。

COP网关:负责系统对外开放平台的API接口鉴权和路由代理。

3)业务服务层

该层又有细分,分为COP服务,业务平台服务,数据平台服务。

a. COP:主要封装对外开发接口统一认证服务。通过COP,SAAS系统可以实现与第三方供应链接口平台和伙伴客户ERP系统、政采平台、OMS系统等对接。

b. 业务平台:分为核心的领域服务层和聚合/适配器服务层。

领域服务层:基于DDD领域驱动设计思想,对现有业务进行抽象,按照不同子域划分不同的服务。每个领域服务内有针对该子域的聚合根抽象封装。聚合服务:针对不同业务场景,对领域服务调用进行一层聚合,使其更好地为前端提供接口服务。

应用聚合层:针对业务场景进行封装和聚合。

适配器服务层:针对不具有领域概念的但又有一定意义的服务进行封装,如基于CQRS设计理念的查询服务;以及后台异步任务服务,如数据导入导出、数据同步等。

以上这几层可以看成是对六边形架构的一个很好的分解和落地。

c. 数据平台层:针对SAAS系统的产生核心数据,例如订单,商品,基于CQRS理念,将数据进行整合和同步,实现多场景下单数据复杂查询和BI数据分析。

4)技术底座层

该层细分的话,可以分为TPAAS服务和BPAAS服务两部分。

TPAAS服务:包括k8s、容器化编排、Istio流量管理、日志采集和查看、链路追踪监控、中间件(数据存储、MQ)、CI/CD等。

BPAAS服务:包含基础jar包(如低代码框架、安全组件)和基础服务(如工作流服务、任务调度服务、分布式ID生成器服务等)。

3. 最终服务落地

图片

采灵通系统总计服务65个,按照服务分类分层的概念,结合DDD的六边形架构思想,可以分为:

前端服务,BFF服务,组合服务,适配器服务,领域服务,基础服务,网关服务。

其中这些服务有一些设计规约:

1. 领域服务不可依赖组合/聚合服务、适配器服务和BFF服务。

2. 组合/聚合服务和适配器服务不可依赖BFF服务

3. 领域服务进行天然分库处理。

五、总结

通过采灵通业务场景的落地实践,最大的感触是在前期子域拆分时候的纠结,在此,给大家讲个例子:

比如商品模块,前期会考虑到底是拆成一个商品子域呢,还是拆成两个子域:伙伴商品子域和供应商商品子域。如果是一个商品子域,业务实现上会简单一些,但未来业务场景拓展会受限。如果拆成两个子域,前期系统设计会增加复杂度,包括商品池的同步问题,数据一致性问题,等等。但后期来说,会更适配业务场景,所以最终笔者的选择是跟产品经理充分沟通过业务需求后,选择了拆成两个子域。

所以,作为一个业务架构师,第一要务是要有充分理解业务的能力,所以如何跟产品经理紧密配合,其实是一个业务架构师的核心能力。其次才是技术维度的能力,包括对六边形架构的把控,对多种设计模式的应用,对系统高并发和高可用性的应对经验等。后面所说的这些能力,对于一个技术宅来说是很好提升的,但对于前面的这个能力,可能就因人而异了。

  • 18
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值