一.前言
ddd出现的意义在于从业务的角度而不是技术的角度去解决软件的复杂性,正如某位大师所言:“program is logic and control”,所有的程序本质上就是两件事:逻辑和控制,而逻辑则是决定了复杂性的下限。举个例子,商品交易的业务逻辑复杂性天然就胜过im聊天的业务逻辑,这种时候无论怎样拆解,它的业务复杂性就摆在这里,而控制是我们尽可能用最优的手段去实现我们的逻辑,这里有点类似于面向对象中,接口和实现的感觉,但又不局限于此。
对于开发人员来说,我们接收到现实世界中的需求之后,基本上是以下的套路来解决问题:
1.确认现实问题;
2.将问题映射到脑海中的概念模型;
3.用模型来解决问题;
4.编码
我自身之前所有学到的具体的技术或者思想其实核心都是在做第三步和第四步,即如何用最优的技术方案和最好的编码方案,包括我们各种高大上的架构,各种代码上的tricky写法,各种算法的应用等等,这些本质上都是在做实现层面的事情。
但有的时候总会在想,我们设计出来的模型真的能够表达清楚我们的真实业务吗?未来随着业务的变更,我们的底层模型和设计真的能够支撑这种变更吗?
如果不能,那即便我的代码设计多么优雅合理,可能也只是当时的一时感动罢了,每一次的需求变更都依然可能会引发复杂的代码变更,也给自己带来了许多的叫苦不迭。
所以,ddd的目的更偏向于解决第一步和第二步,即依据业务领域模型为核心来驱动我们的系统设计。即抽象到更高层来理解的话,如何定义清楚问题和模型,而后续的实现方案其实是由它来驱动的。
正如上面所说的,问题和模型决定了逻辑复杂性的下限,举个例子,之前在做商品的评价服务时,产品需求是这样的:用户只允许在订单的流转状态中的某几个状态时(拼单成功,待发货,待退款等等)发表评价,此时展示为待评价状态,而评价完成后则订单扭转为已评价状态。
这个需求乍一看,我们的第一反应是在订单的状态机中添加两个状态,待评价和已评价,但后来会发现,无论怎样推演,加入了评价的两个状态之后,订单的状态机流转都会变得非常复杂,因为无论是订单的正向还是逆向流程中,都可能会有评论的状态的加入和退出。那我们退一步来思考,虽然产品需求是将订单的原状态列表中添加是否允许评价和是否已经评价的状态,但我们仔细拆解就会发现,评价的状态和订单的状态本质上就是两个事情。从核心领域的划分上,也是两个领域,一个是订单领域,一个是评轮领域,而获取订单的评论状态本质上是属于评论领域而非订单领域的工作。这样事情一下子变得简单了,一个订单能否被评价以及是否已经被评价过了,完全交由评论领域来判断,订单无需也不该维护和添加这么一个状态。
所以使用ddd,是为了很好地解决领域模型到设计模型的同步、演化,最后再将反映了领域的设计模型转为实际的代码。
二.ddd的基本概念
官方的解释是这样的:领域驱动设计(Domain-driven design,缩写 DDD)是一种通过将实现连接到持续进化的模型来满足复杂需求的软件开发方法。这个说起来实在是有点太虚无缥缈了,还记得上文中提到的解决问题的四个步骤,那现在如果用ddd的思路来指导,应该是怎样的呢?
1.确认现实问题;---和领域专家(产品经理等)一起,通过多次的拆解,交流和沟通,构建一套整个项目统一的领域语言,并用这些领域语言定义清楚具体的原型,操作和逻辑。
2.将问题映射到脑海中的概念模型;----基于上述的统一语言,构建出ddd中的领域模型,包括领域实体,上下文的边界,领域事件,聚合等等;
3.用模型来解决问题;----在ddd的思路下划分微服务,设计包结构,分层设计,定义接口,定义聚合根等等
4.编码;----编码实现
三.用ddd的思路做方案设计
以我最近一段时间做的津贴系统为例,来看下如何做具体的方案设计与拆解;
1.首先,明确产品需求和用例:
在用户侧,分三个操作:
收入:要能够做到津贴能够以多种形式和渠道来发放和领取,同时要保证每一笔津贴收入都有有效期的概念(包括开始生效和结束生效),同时要求这个时间必须十分精准,比如在大促时,要求大促期间生效的津贴必须在定点失效和生效。
支出:要求支出的时候可以合并支出,并且能够按照有效期快失效的先支出;
退还:要求支持退还的操作,但已经失效的则不再退还;
在平台侧:
创建津贴:制定特定规则的津贴,比如有效期等;
发放津贴:通过各种各样的形式发放给用户;
2.确认各个业务核心领域的边界划分:
首先我们可以发现,津贴的领取其实是由上层系统所驱动的,最终经过一定的规则之后发放到用户的个人津贴账户上,通过大量的讨论和梳理之后,我们会在这里发生第一次的业务领域划分,我们将领取划分为了四个子业务领域:
1.上层的发放渠道,比如红包领域,任务领域等等,这里负责计算用户是否满足了某些条件,然后可以自由组合并决定给用户的发放金额。
2.津贴控制领域:这里负责津贴规则的制定,以及如何发放,比如津贴的有效期,津贴的领取个数限制,防重入的控制等等(由于时间关系,一部分限额限次数等逻辑目前移交给上层渠道做了,但应该有这么个领域存在);
3.个人津贴账户领域:这里是最终发放到的个人账户上,负责管理用户的津贴账户,包括收入,支出和退还等等;
4.支付与核销领域:负责用户真正使用津贴支付的逻辑,比如与财务的核销,平台侧资金的支出与商户的资金收入等等;
这里核心领域划分清楚之后,其实就有点映射到了我们微服务的划分,也对应到了人员的划分,这时候任务就开始拆解了,我们会发现其实上层的发放渠道与津贴其实是基本无关的,那就从我们的核心领域移除了出去,我们目前重点关心了一个领域,个人账户;
以个人账户领域为例,那我们的业务领域核心目前就比较聚合了,只关注津贴的入账和出帐,以及内部的生效和失效,其他的都无需关注。
3.确认核心领域中的模型与边界上下文:
1.为了满足产品设计中津贴支出时能够一次性的合并支付,因此我们定义了用户总账户的概念,即每个用户用拥有一个个人的总账户,支持在下单时扣减这个总额,所以是津贴实体汇总在一起之后有个总账户;
2.为了满足有效性的需求(尤其是准时准点的生效失效),我们将每个用户总帐户又分为三个部分,预生效(当晚23:59:59秒开始生效),预失效(当晚23:59:59秒开始失效),普通(生效期内可直接扣减),这样即使更新脚本不及时,也可以通过这三个子账户决定可以支出的总金额;
3.为了满足退还有效性的需求,我们定义了津贴实体和支付订单的概念,用来标示每一笔聚合的支出包含了哪些具体的津贴实体的支出,用来做将来的退还。
4.为了保障数据的可追溯和可恢复,我们定义了用户流水的概念,包括收入流水,支出流水,退款流水,更新流水等。
所以在我们的模型设计中,通过流水可以重放出每一笔津贴实体,每一笔津贴实体聚合起来可以导出用户总额。
而在这个领域下,所有的模型定义都是在我们这个边界下是明确且独立的,比如说对于上层的红包系统来说,它所谓的发"一笔津贴",实质上是对应我们这里的津贴实体。对于上层的交易系统来说,它所谓的支付"一笔津贴",对应的其实是我们的总额。所以即便是同一个名词,在不同的核心领域下,也是映射着不同的含义。
接下来,我们看下如何ddd如何指导我们做具体的代码编写
四.用ddd的思路编写代码
1.分层设计:
如下图所示:
如果我们只是简单的增删改查,其实是不需要这么麻烦的,甚至我们完全可以不需要这么多层,只需要把业务逻辑退化为一个sql脚本就可以支持了,但如果业务逻辑比较复杂的情况下,我们一定必不可少的要做的事情就是关注点的分离,但在分离的同时也要保证内部的交互关系。这其中,领域层是整个模型的精髓,
那在golang里面是怎样的呢?目前我们的分包设计是这样的:
---ao:负责编排do和对外界的协议防腐层
---infra:基础设施层,
--dao:数据库层,封装了基本的数据库实现,比如各个表的增删改查,数据库的事务实现等等;
--integration:第三方接口,包括rpc接口,mq的producer等;
---do:核心领域层
---impl:核心领域层实现
beanfatory:impl下的文件,负责所有的实例化,包括单个do的build,以及一些单例的dao和service的构建
---service:对外接口层,包括rpc接口和mq的consumer等等;
2.领域层设计:
正如我们前面所说,一切以领域驱动,那我们编写代码的时候也是,先想清楚自己的系统功能以及边界,那第一步是设计对外的协议,第二步就是定义我们的领域层模型:
目前我这边把领域层的do分为了三类:
a: 实体类do:
这类模型是拥有真实的实体,能够被定义成实体的模型有一个最大的特点:具有唯一标识性。即在我们当前的系统上下文内,它需要能够通过这个唯一标识被追溯到。通常这类实体还会有一些自身的方法,与贫血模型相对的,我们更希望实体自身能够表达自己的属性和动作,而不是把自己的领域逻辑散落在其他地方;
b:聚合类do:
有很多情况下,是需要将实体和值对象们组合到一起做服务的,设计聚合的最大原则在于,要满足聚合的一致性边界;
举个例子,我们系统对外要提供用户的收入服务,那这个核心的领域模型就需要针对一笔入账,要做三件事情,第一,插入一笔收入流水,第二:插入一条津贴实体;第三,更新用户的总额度。
这个聚合的一致性原则在于:
这三件事情,要么同时发生,要么直接失败;其实就是一个事务操作
那很明显,在这种情况下,单一的实体总额度或者津贴实体都不能直接在自己的实体方法中执行修改操作,因为这样会造成整体的不一致,那这种时候,我们是这样处理的:
首先是三个实体:
type AllowanceEntity{
EnttyId uint64
UID uint64
Amount uint64
xxxx...
}
type AllowanceIncomeRecord{
RecordId uint64
UID uint64
Amount uint64
xxxx...
}
tyoe TotalAmountEntity{
UID uint64
Amount uint64
}
然后是聚合:
type IncomeAggregate struct{
TotalAmountEntity TotalAmountEntity
allowanceIncomeRecord AllowanceIncomeRecord
allowanceEntity AllowanceEntity
}
注意,这里很明显,这个聚合,外界能够访问到它的聚合根也就是总额度,但是无法访问到其他的对象,因为其他对象可以看作是它的私有属性,不对外暴露,这一点我觉得golang通过大小写区分有点容易分不清。
然后对于聚合来说,聚合根是可以被外界访问到的,所以如果从封装的角度出发,按照golang的思路这里其实应该作为一个子包存在,但这样总觉得有点太细粒度,所以还在探索中。
最后,津贴总额提供一个cqs查询方法,将写操作转换为一个读操作,保证了实体没有被修改,我自己实践后认为,这样的好处在于,在项目里有一大堆代码的时候,我可以很清楚的知道,只要是有返回值的方法,就一定不会对原来的实例有任何的修改,这样可以很好的节省我们的思考成本。
func (s*TotalAmountEntity) afterIncome(ctx context.context,entity AllowanceEntity) TotalAmountEntity{
copy:=s.copy()
copy.amount+=entity.amount;
return copy
}
津贴实体提供一个init方法:
津贴记录提供一个init方法:
最终在聚合中是一个类似如下的函数:
func(s*Aggregate) Income(ctx context.context,params xxxx){
//创建流水
s.record:=initRecord()
//创建津贴实体
s.entity:=initEntity()
//更新总额度
s.total=s.total.afterIncome(ctx,s.entity)
//持久化聚合内的全部实体
persist(s.record,s.entity,s.total)
}
那这就是一个聚合,聚合的最大特点在于,在聚合的边界内保证数据的一致性,也就是说,在上面那个income方法里,这三者的变更是一致的,不存在产生了流水,却没有插入津贴实体这种事情存在。
c:service方法
其实就是有点像胶水代码,本身并没有太多的复杂业务逻辑,只是用来做一些do或者聚合的生成以及使用,这类方法都是无状态的。
3.基础设施层:
这一层其实是基础设施的封装,这一层是与我们的领域模型无关的,只是单纯的基础设施,比如一个xxxDAO,那它的实现可以是单表的mysql,也可以是分库分表后的mysql,也可以是redis缓存,又或者是local cache,所以这里是完全与业务无关的,所以我这边的实现方案是设计成接口,然后让领域层依赖这个接口,但不依赖具体的实现,实现可以做自由的更改和替换。
五.用ddd的思路反哺
这一点是我最近一段时间慢慢感受到的,在和产品讨论需求时,其实很多时候也是一种大家共同摸索共同讨论的状态,因此一个好的方法论其实不只是可以帮助到自身,也可以反哺到团队中的其他同学,同样的,在面临产品需求时,用ddd的思路做产品的需求定义和模块拆解,这种反而会比单纯的实现更考验功力。