业务越来越复杂,领域驱动如何在业务开发中落地?
TGO 鲲鹏会共创伙伴大搜车技术总监闫成洋对此进行了线上直播活动,给大家分享大搜车团队如何让领域驱动落地。以下内容来自于直播分享,有部分删减:
领域驱动本身是针对复杂系统设计的软件工程方法,它在战略层面有三个重要的点,一是聚焦业务核心价值,二是统一语言,三是业务领域性划分。
聚焦业务核心价值,也是领域驱动战略层面最主要的目标;统一语言是基于领域划分的,也就是在一个领域限界上下文中统一语言。通常情况下在微服务会对应到一个限界上下文。在战术层目标主要是在设计系统时,需要具备高内聚、低耦合、易扩展、易维护四个要点。战术层面主要是为了保证空泛的战略的无法落地,为落地提供了一系列具体的开发模式。这也是代码即架构的一种体现。
在传统的开发模式里面,当业务比较简单时,用“Controller — Service — DAO”架构基本就能解决。但是如果是比较复杂的情况下,不推荐这么做。
举个例子,在电子商城购物车,比如说有一个业务逻辑,购物车有最大商品数目的限制,加购不能超过最大商品个数。在开发时面向数据开发使用三层架构的时候,按照老的套路会在 service 中先去 DB 查一遍购物车里的商品列表,然后查看目前商品列表和最大的限制是否相等,如果超过了最大的 size,就抛异常不允许再添加商品。基本上也可以实现功能,但是主要问题就是 service 承担了太多的职责,也就是通常所说的上帝类。如果按照 DDD(Domain-Driven Design)的实践,业务逻辑最合适的地方是 Domain Model,也就是我们会专门设计一个聚合根作为购物车,购物车这个聚合根包含了多个商品条目,调用他的加商品方法,购物车聚合根内部来实现业务逻辑,因为聚合根包含了所有要实现这个业务判断的数据,数据和行为放到一起才是面向对象的思维,这也是业务规则不变性最应该放到 domain model 的原因。
传统面向数据开发,数据存取和业务逻辑都混杂在一起,所以 service 层都责任不是很单一,可维护性会相对弱一些,特别是系统比较复杂,业务逻辑散落各个 service 方法中问题就更加突出了。
DDD 中加入了 Domain Model 层,虽然多了一层,但是定位和职责划分更加清晰。再从战术层面来看,各种概念虽多,但核心还是围绕着 Domain Model 做设计。这个看起来很难学,但是理清楚之后还是比较简单,比如 Entity 、Value object 和聚合根都是为了更好的建模业务对象,repository 是为了更好存取业务对象,factory 是为了更好的创建对象等等。
这是参考官方战术图用到的模式,希望大家不要被他的复杂性吓到,理清了内部的逻辑关系还是比较简单的。
前面大概是讲领域驱动的两方面——战略层面和战术层面,主要是对领域驱动有一个大概介绍,接下来分享一个大搜车的实际案例——车抵贷项目。上图是我们团队的现状和技术架构的现状。
当时的现状是技术研发熟悉三层架构的传统写法和面向数据编码,以及对领域划分有初步的概念,同时也比较熟悉金融业务。系统方面,我们有自己相对独立的系统,比如风控系统已经做了,自己有内部的开发框架,RPC 用 dubbo 加了自己的封装,ORM 用 optiums-orm 是基于 mybatis 的封装。
车抵贷项目核心业务场景和功能就是:客户把车辆抵押给我们,我们根据车辆的一些车况等给用户放贷款。实际上系统交互有点复杂,整体流程是:首先客户通过“借呗”提供押品,然后我们会给一个估值,如果客户觉得 OK 就走申请贷款;接着,我们基于内部的逻辑对人进行了风控,如果通过了就会通知客户,同时客户也会线下验车;最后双方签合同、装 GPS、做抵押。
实际上,整个流程的交互场景走下来要将近 20 步,相对来说比较复杂。我们先做了领域划分,核心还是订单如何交付这个是核心域,支撑域包括风控、客户、工单,合同,合同我们有专门的基础服务。另外,还有两个外部系统,一个是渠道子域也就是银行,另一个是线下服务子域,也就是车辆线下服务,负责验车装 GPS,这个是和外部供应商来合作。
领域划分是保证业务划分的大框架,大框架很重要,它是保证方向性的东西,如果方向错了,后面还是要返工那代价就非常大。DDD 里面并没有说域和我们落地的微服务的关系,但是业界一般是一个域定位到一个上下文,或者说目标是这样,是努力的方向。
如果用到 DDD,都会提到最近都很火的 CQRS+EVENT Souring 架构。我在这里列举了 Event Souring 典型的架构,和团队沟通的时候,大家接受起来都感觉比较痛苦,开发模式和传统模式的思路差距比较大,它会把所有的 Event 都存起来,同时分发到 view model 这边的 event handler,同时更新视图存储的数据。我们没有完全采用这个架构,而是做了一些取舍。
在项目过程中我们遵循传统的 DDD 实践还是四层结构;而在项目具体操作的过程中,我们还是做了 Command 和 Query 分离,主要是 Query 和 Command 的处理套路不一样。Command 最终会改变状态,在 Query 层也做的比较灵活简单,通过 Query 和 DTO 反馈给展现层。两边的存储 View Model 和 Domain Model 存储放到同一个数据库。这样综合考虑既保留和 Query 的灵活性,比如视图层可能会跨实体,只取了聚合根内有限的字段,通过直接使用 dao 查询非常访问,又保证了 Command 的处理业务逻辑单独放到 Domain Model,保持了 CQRS 的灵活性。
综合以上的因素我们最终 Command 的处理流程如下所示:
Command 处理流程图
综合以上的因素我们最终 Query 的处理流程如下所示:
Query 处理流程图
CQRS 做完之后,实际上还是存在一个问题,就是在我们具体的业务场景下,订单流程还是有点复杂,订单流程状态节点非常多,如果单纯写 if else 的判断来处理订单状态,可维护性也不是很强。最后,我们用了状态机的模式,在传统的 DDD 里面没有这个模式,但是在我们落地的时候也要考虑具体的业务场景,并结合 DDD 给出自己的解决方案,核心不变,就是高内聚低耦合,提高代码的可维护性。
状态机示例图
选型用的 Spring 的 State Machine,基于事件驱动的流转的框架。如上示意图里有三个状态 State1、State2、State3,不同的 Event 发生有不同的跃迁,从 1 到 2,从 2 到 3,每次跃迁叫 transition,状态机里提供了一个接口叫 action,处理一些状态跃迁时需要转变的业务逻辑。这些都是状态机内的概念模型。
那么战术层面如何跟 DDD 结合呢?action 属于哪一层呢?DDD 原本的分层结构里面有 Application Service 和 Domain Service,Application Service 实际上是应用层的,外面可以直接来调取,action 明显不是 Application Service,Domain Service 实际上是处理偏业务逻辑的,实际上 action 本身相当于 Domain Service 同一层的概念。但是落地的代码的写法和传统的 Domain Service 的初衷不太一样。(一般 Domain Service 是用来放不适宜再某个聚合根内的业务逻辑)
action 的处理套路大体如上图所示,我们在状态机外部装了一个 façade 层,Application Service 不用暴露状态机的实现细节。Application Service 只需要把 Event 丢过来既可以驱动订单流转并实现业务逻辑。总体和传统的 CQRS 还不一样,结合了状态及的流转。每个公司和业务都有自己的特殊性,这个要根据现状来落地。
刚才只讲到系统内部分层以及代码架构,其实整个业务交付下来,还是有跨系统的调用。我们有风控系统、工单系统和合同等等,这个是在 DDD 里推荐的跨系统调用的实践。
在跨系统调用图里,最左边是核心域的系统,右边是要去调用外部的系统,每个系统的概念模型也就是 Domain Model 是不一样的,大部分的时候是根据自己的业务设计自己的模型。所以,中间会有 Anticorruption Layer 防腐层,包括 Adapter、Facade 和 Translators 模式 Facade 主要是为了解决当你要调用的系统接口非常复杂,比如个数也很多而你不会用到那么多细节的时候可以做一个 Facade 来封装一下。Adapter 会去调用 Facade,如果把两个域的 Domain Model 转换会比较复杂,就要有专门类的 Translators,它会把你需要的模型转化成目标需要的模型,然后进行调用。
实际上在我们的系统里面,对于 Adapter 和 Translator,我们有专门的命名规范,主要是为了代码比较好维护。
把我们所有的概念都放在一起的时候,结合 DDD 的六边形架构就是大体如下这个图。
第一选择是放在聚合根里,如果业务不是明显属于属于某个聚合根,这时候可以放在 Domain Service 中。但是新手常犯的错误就是 Domain Service 放了太多的业务逻辑,聚合根反而没有业务逻辑。都是按照传统 service 写法写习惯了,需要纠正下。
1. 有非独立存在的实体,要和依附在一起的实体并入一个聚合根;
2. 多个实体维护一个业务规则,多个实体就要放在一个聚合根里;
3. 一个事务针对一个聚合,也可以作为划分的参考,这个和第二点是相互呼应的,一般业务业务逻辑也是在一个事务内的;
4. 衡量利弊,聚合尽量小。如果特别大的话,就变成了一个超级类。
业务属性不强的场景,比如外部接口调用记录,因为是外部系统,我们主要是出于技术排查记录的角度要记录,并没有业务的强需求。这个部分我们没有用 DDD 的思路,而是直接用一个切面,把这些调用记录在一个日志表里面就好。
1.《领域驱动设计》《实现领域驱动》;
2.《Domain-Drive Design Distilled》较新,目前只有英文版,作为快速入门不错;
3. http://github.com/citerus/dddsample-core 分享一下官方的实例代码,其中包含的基本技术栈还是比较新的,其中包含了标准的 DDD 实现套路,大家可以作为一个参考。