在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。
我更愿意称DDD是一种架构思想,而不是一种“框架”,框架给人一种死板的感觉。事实也是这样,自然世界是复杂多变的,业务随着需求的变化也在不断的改变,仅采用固定的一套框架,告诉你,我们用DDD框架,什么代码应该放在哪个包,这并不是DDD的目的。我们来谈谈目前大家应该都了解的MVC架构。MVC分层架构是非常优秀的一种架构体系,ORM框架的盛行更是助燃了MVC的发展。在Mybatis的帮助下,我们的代码结构不可避免会有如下几个包,controller,service,mapper,pojo。借用工具,我们的pojo层的实现变得死板简单,仅仅只是与数据库表对应,面向对象编程的思想逐渐转化为面向数据库表编程。在十年前,大部分的商业应用还处于单机时代,分布式微服务的概念还未盛行。大家的API调用大多都是基于REST接口的外部调用。
在之后,服务概念开始冒头,从一开始的SOA架构,到后面更加细致的微服务架构,甚至于如今盛行的所谓“云原生”,“serverless架构”。微服务崇尚的是业务的拆分,拆分为高内聚低耦合的服务,而DDD也是同样着重于业务的视角,两者在追求同样的目标达到了上下文统一。微服务场景下,服务调用方式不仅是REST,RPC以及消息队列的调用方式变得更加常见,服务之间的依赖关系变得错综复杂,如何去合理拆分服务也成为了一大挑战和难题。一个电商系统拆分,难道全部笼统的拆分于用户模块,商家模块,商品模块,店铺模块......DDD的思想是领域驱动设计,DomainDrivenDesign,这种思想让我们能真正冷静下来,去思考到底哪些东西可以被服务化拆分,哪些逻辑需要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。GordonMoore提出软件设计好坏的标准是,高内聚低耦合,无论是单机时代,再到分布式时代,乃至今天的微服务云原生时代,这一言论依然可行,那么什么是高内聚与低耦合,DDD思想如何帮助我们设计出一个优秀的软件架构呢?
DP是DDD架构中的核心角色,也可以理解为最小单位,他明显区别于MVC架构中的model,最简单理解就是,充血模型与贫血模型。DomainPrimitive是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的ValueObject。下面我用一个订单的简单例子介绍一下什么是贫血模型,什么又是充血模型。
贫血模型是一种将业务逻辑与数据分离的设计方式。在这种模型中,领域对象通常只包含数据属性,而业务逻辑则分布在服务类(Service)中。领域对象在这种设计中更像是数据的载体,而实际的操作和逻辑处理则由独立的服务类负责。以下代码中,Order类仅包含订单的属性,这些属性一般和我们的数据库表对应,而所有的逻辑均放在我们的Service服务层进行处理。这种模型较为简单,并且比较清晰,但是他不符合高内聚的特点,是一种低内聚的设计,可惜的是,目前市面上绝大多数的教学,以及初学者都是应用这种模型进行编码。
一个招商系统需要在全国范围内进行招商,商家需要注册进这个招商系统,根据商家经营的商品种类,对商家的type进行区分,并且根据商家负责人的手机号,划定这个商家的地域。
我们先不要管这个业务合理不合理,我们着手于实践,这个需求拿到我们手上,我们用MVC的方式去拆分那便是一个Merchant(商家类),包含了商家的基本属性,一个Respository用来于持久层交互,然后在服务层拼凑实现我们的业务。代码如下:
数据模型(pojo)
publicclassMerchant{privateLongid;privateStringname;privateStringphone;privateStringproductCategory;privateStringtype;privateStringregion;//gettersandsetters}数据访问层(dao)
但是,以上给出的例子,还不是很“纯血”的DP。
跟着我的思路,我们现在来看这个注册商家的需求,假设需要以下一个接口
注册(商家名称,手机号),那么自然的我们会想到这样设计接口
publicvoidregister(Stringname,StringphoneNumber);在Java代码中,对于一个方法来说,所有的参数名在编译后消失,那么我们的代码在编译后其实是这样的
publicvoidregister(String,String)那么假设我们在service层这样调用接口,是否会出错呢?
merchant.register("17720778576","AAA潮鞋直营店");答案是不会的,虽然接口定义是name和phoneNumber,但是这两个参数都是String,我们传递手机号和商铺名字进去,自然是正确的,为什么会报错呢?或许现在还有些读者会看不明白,这样写有什么问题。问题就是我们定义的接口是先传入name再传入phoneNumber。而样例中我先传入了phoneNumber。
问题是这样在我们的代码编译过程中是不会出问题的,这个bug就会留到测试中显示出为业务问题,一段良好的代码应该具有更少的测试风险,测试诚然是必不可少的工作,但是倘若代码本身就具有自我检查机制,那么测试的工作量是否会减少,并且对于排查错误以及代码健壮性来说,都有极佳的意义。
那么我们要怎样改进这个方法。
首先就是把隐形的概念显性化,我们把phoneNumber和name中隐藏的业务逻辑显性化。创建PhoneNumber类和Name类。
publicclassMerchant{privateNamename;privatePhoneNumberphoneNumber;privateStringtype;privateStringregion;publicMerchant(Namename,PhoneNumberphoneNumber){this.name=name;this.phoneNumber=phoneNumber;this.type=determineType();this.region=determineRegion();}privateStringdetermineType(){//假设我们可以通过名字或其他方式来确定类型if(name.getName().contains("潮鞋")){return"鞋类";}else{return"其他";}}privateStringdetermineRegion(){returnphoneNumber.getAreaCode();}publicNamegetName(){returnname;}publicPhoneNumbergetPhoneNumber(){returnphoneNumber;}publicStringgetType(){returntype;}publicStringgetRegion(){returnregion;}}那么我们在真正调用这个register函数的时候,就需要改成如下调用。
register(newName("AAA潮鞋专卖"),newPhoneNumber("17720778576"));这样即便我们是跨层调用,业务属性也非常明显的显示出来,也再也不会出现,参数传递顺序错误的问题。假设出现
register(newPhoneNumber("17720778576"),newName("AAA潮鞋专卖"));那么编译器就会报错,无该函数的定义。这就把测试的工作提前到开发阶段完成,提高了代码的健壮性。
那么到这里,到底什么是DP,直白的说,就是这边我们创建的PhoneNumber和Name类,而这个Merchant便是由DP组成的domainservice。也就是领域层模型
何为架构,这边引用一位阿里技术大佬的描述
架构这个词源于英文里的“Architecture“,源头是土木工程里的“建筑”和“结构”,而架构里的”架“同时又包含了”架子“(scaffolding)的含义,意指能快速搭建起来的固定结构。而今天的应用架构,意指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式。在应用开发中架构之所以是最重要的第一步,因为一个好的架构能让系统安全、稳定、快速迭代。在一个团队内通过规定一个固定的架构设计,可以让团队内能力参差不齐的同学们都能有一个统一的开发规范,降低沟通成本,提升效率和代码质量。
在做架构设计时,一个好的架构应该需要实现以下几个目标:
了解了DP和充血模型的概念,那么一个DDD的应用架构如何落地,又或者说。我们为什么需要DDD架构思想,一个MVC系统如何向DDD架构转变呢?
我们继续引出一个具体的业务例子,非常简单,那就是实现一个跨币种的转账功能。
这个转账功能涉及到的业务点就是实现转入转出业务,并且涉及到跨币种还需要实时的去查看汇率,并且给出相应计算。这么简单的业务经过初步的技术选型后,可能会拆解成以下需求步骤
1、从MySql数据库中找到转出和转入的账户,选择用MyBatis的mapper实现DAO;
2、从一个RPC服务提供的汇率服务获取转账的汇率信息,假设是google提供
3、计算需要转出的金额,确保账户有足够余额。
4、实现转入和转出操作,扣除手续费,保存数据库;
5、发送Kafka审计消息,以便审计和对账用;
代码实现如下
一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本。
可维护性=当依赖变化时,有多少代码需要随之改变。
首先我们依赖于Account类,它是纯映射于数据库表的,数据库字段的变更是及其常见的,因此这个类要跟着数据库字段的改变而改变,他是及其不稳定的。并且我们显示的依赖了kafka,外部rpc服务,mybatis等。倘若有一天jar包依赖升级,或者我们不使用kafka改为rocketmq,又或者mybatis出现重大漏洞,需要迁移ORM层?那便是灾难性的代码重构体验,但是这些组件本是为业务服务的,而如今业务没有变化,只是因为组件本身的问题,我们便要对业务层代码做这么深的重构,这就涉及到一个问题。
代码的腐败,特别是一个大型项目中,对于外部组件高度耦合的代码变得难以维护,假设我们发现Mybatis出了一个新的版本,性能提升百分之五十,但是调用方式API有了很大的变化,采用这种架构的代码由于代码耦合,想要更换新版本需要耗费的精力和危险性大大增加,于是即使我们深刻的明白,更换新版本是可以直接提升系统性能,但是碍于种种,只能推迟这个计划。
我们的业务逻辑这么长,但是却只能使用在register这个方法中,业务无法复用,且业务逻辑和数据库格式高度耦合,有新的需求进来,我们又只能新建一个方法,再次用脚本描述的方法来完成这新的一题“模拟题”,久而久之,代码变得不可维护,代码量和架构也变得混乱模糊不清。
参考以上的一段代码,这种代码有极低的可测试性:
我们回到软件工程最基本的设计三大理念
可以看到,一个转账的简单例子,看似合理的设计,却违背了软件工程设计的三大原则。
我们需要对代码重构才能解决这些问题。在这之前,我们先画出以上有问题的代码的依赖图
我们发现在业务层对下层基建层有很明显的强依赖,耦合度很高,所以需要对上图节点做抽象处理,来降低对外部依赖的耦合度。
这一层的操作其实是比较简单的,首先我们来看到第一个问题,我们Service中操作的对象与数据库强关联,且这个对象本身只是对数据库的简单映射,自身不带有业务属性。那么要解决这个问题,很自然就联想到我们上文提到的DP和DomainService的概念。首先我们应该保留和数据库直接映射的类,命名为AccoutDo,Do类与数据库直接映射,同时我们又需要有DomainService类,也就是我们在Service层中直接使用的Accout类,这个Accout和数据库有一定关系,但是他并不是直接依赖于数据库的,他不但有数据库字段的属性,他还能有自身的业务行为。简单代码示范如下。
@DatapublicclassAccount{//可以看到,这边的属性我们使用了DP做处理,这样也顺便解决了参数校验的问题privateAccountIdid;privateAccountNumberaccountNumber;privateUserIduserId;privateMoneyavailable;privateMoneydailyLimit;//这里带有了这个domain自身的一些高内聚的方法函数publicvoidwithdraw(Moneymoney){//转出}publicvoiddeposit(Moneymoney){//转入}}@DatapublicclassAccountDo{//直接与数据库一一映射privateLongid;privateStringaccountNumber;privateLonguserId;privateDoubleavailable;privateDoubledailyLimit;}在这边我们还需要一个DAO,这个DAO很好理解,就是对应数据库类型的一个真实操作,比如Mysql的insert,update,然后我们还需要一个Repository。
这时候这个Repository需要做两件事
代码样例
经过这样的架构转变,我们看到计算更新等业务操作也是直接依赖于Accout这个领域对象,然后领域对象依赖于Repository做映射,最下层才依赖具体的Mapper实现,就解决了业务层依赖于具体实现的问题。当我们需要更换数据库或者ORM的时候,Service层代码完全不需要改变,只需要去改变相应的Mapper层实现即可。这便是单一职责原则。
在这里我想先引出一个概念。Anti-CorruptionLayer(防腐层或ACL)。
很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。
目的就是让我们的Service依赖于抽象而不是依赖于具体实现,真正的转换是通过反腐层去做的,而我们调用的永远是一个固定的抽象的业务接口,具体的适配器转换模式,下沉到防腐层中去实现。
我们这里有一个第三方的服务,googleExchangeRateService,他是依赖于google的。我们假设google服务返回的是这个json
{"source":"USD","rates":{"EUR":0.8534,"JPY":110.32,"GBP":0.7542,"CNY":6.4567},"timestamp":1625123456}但是将来某一天google服务不可用,或者收费变贵了,我们想改用雅虎服务,返回的json格式是这样的
{"base":"USD","date":"2024-07-01","rates":{"EUR":0.8456,"JPY":111.20,"GBP":0.7498,"CNY":6.5102}}因为我们在Service层强依赖google服务,所以需要对业务代码再次进行修改。
引入ACL层后,我们可以抽象出一个统一的json格式
{"source":"USD","date":"2024-07-01","rates":{"EUR":{"rate":0.8534,},"JPY":{"rate":110.32,},"GBP":{"rate":0.7542,},"CNY":{"rate":6.4567,}}}做一个适配器模式的转换,无论我们从什么数据源获取到汇率数据,我们都使用这个ACL层的适配器转换为我们需要的数据格式,所以在我们的Service业务层就可以依赖于抽象,也就是ExchangeRateService,无论这个Service底层依赖于哪个第三方的服务,返回的数据格式经过防腐层都是永远固定不变的。
同样的对于MQ,我们也可以抽象中间件,无论是RocketMQ,kafka,我们都可以抽象为MessageQueue。并且使用ACL层做一次适配。适配多种MQ实现,这样我们的业务层只依赖于抽象的mq,而不是依赖于具体的mq实现。
这一步就是把部分业务逻辑再内聚到我们的domain里面,比如汇率计算。
最后我们业务层代码就可以只写成
DoubleamountInFromCurrency=getFromCurrency(amount);结果展示经过以上种种重构,最后我们的业务代码会变得无比干净
可以看出来,经过重构后的代码有以下几个特征:
那么真正落地一个DDD项目,我们该如何分模块分包呢。由于业务的复杂性,以及每个公司落地DDD风格的不同,我不能给出一个绝对的答案,以下仅仅是抛砖引玉,给出一个比较基础简单的分包。
类比于Integer,String,Double这些type,我们的DP也是一个个包含业务校验逻辑的Type,因此Types模块中我们存放DP
Domain模块是核心业务逻辑的集中地,包含有状态的Entity、领域服务DomainService、以及各种外部依赖的接口类(如Repository、ACL、中间件等。Domain模块仅依赖Types模块,也是纯POJO。
这个模块主要依赖于Domain模块,他就是我们MVC结构中的service模块,用于组装拓展Domain中的业务逻辑,形成真正具体的业务需求函数
基建模块,依赖于具体的基建实现,包括DB,MQ,ORM层的依赖。
DDD架构不是一个具体的特殊的架构模式,他的理念是所有传统代码经过合理的重构要达到的终点,在当下爆炸的微服务时代,业务呈井喷发展,我们推崇敏捷开发的同时,更要注重代码的健壮性,以及可维护性。DDD架构能够有效的解决传统架构中的问题。
在一个电商系统中订单管理是一个领域,包括订单创建、支付、配送等业务。商品管理可以是一个子领域,涵盖商品发布、库存管理、价格策略等。在订单管理领域中,可能有一个用于订单创建和支付的限界上下文,另一个用于订单配送和物流管理的限界上下文。
当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。
例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。
值对象
当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(ValueObject)。
例:比如颜色信息,我们只需要知道{“name”:“黑色”,”css”:“#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。它具有不变性、相等性和可替换性。
聚合是由紧密关联的实体和值对象组成,是修改和保存数据的基本单位。每个聚合都有一个仓库,用于保存聚合的数据。
聚合有一个聚合根和上下文边界,边界根据业务需求和内聚原则,定义了聚合应该包含哪些实体和值对象,而聚合之间是松耦合的,这样设计的微服务,会很自然地实现高内聚、低耦合。
聚合在DDD分层架构中是领域层的一部分,领域层可以包含多个聚合,共同实现核心业务逻辑。实体在聚合内以充血模型实现业务能力,保证业务逻辑的高内聚。
跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。
聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。
聚合由根实体,值对象和实体组成。聚合根可以理解为是一系列聚合的管理者,他具有全局唯一id。聚合之间协作的时候,聚合根是对外的接口人,通过自己的ID关联其他聚合。外部对象不能直接访问聚合内实体。
一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。可以理解为domainservice中的一些内聚操作
聚合根应该是一个聚合中最重要的实体,其他实体和值对象通过聚合根关联。我们要考虑在这个业务中,哪个实体在业务中扮演核心角色。可以有效的管理封装聚合的行为。
聚合应该尽可能的小,只包含必须由聚合根直接管理的实体和值对象。提高内聚性。
聚合根应该封装聚合内部的业务规则,任何对聚合内部数据的修改,都应该通过聚合根的方法进行访问,这样聚合根才能保证聚合的状态的一致性。
聚合的状态应该是最小的一致性边界,保持自身聚合的一致性,事务应该在单个聚合范围内实现,不能跨聚合操作。
我们以电商平台的订单系统为例,看看聚合和聚合根是如何设计和使用的。
在订单系统中,订单通常作为聚合根,因为订单是整个业务流程中最核心的业务对象。
订单聚合包括多个实体对象,如订单明细、支付信息、收货信息等。聚合中包含了实体也包含了值对象(DP)。
订单明细:聚合内的实体,每个订单明细代表订单中的一个购买项。它包括商品ID、购买数量、单价和小计。订单明细与订单紧密关联,其生命周期由订单聚合根管理。
收货信息:通常是一个值对象,包含省、城市、街道和邮编等信息。因为它没有独立的标识,仅仅描述了一个地理位置。
通过设计正确的聚合,订单系统的业务操作会非常清晰,并能够集中管理。
下面列举一些常见的业务操作,介绍聚合是如何被使用的。
当客户选完商品,并提交订单时,系统会触发订单创建的流程。
系统首先创建一个新的订单聚合实例,此实例以订单为聚合根。订单聚合根包含了必要的信息,如订单编号、订单初始状态等。
客户选定的每个商品都会作为订单明细,添加到订单中。每个订单明细实体包括商品ID、购买数量和单价等信息。这些订单明细在创建过程中由订单聚合根动态管理,确保数据的完整性。
客户提供的收货地址被创建为值对象,并与订单聚合关联。同时,初始化的支付信息也会被设置为一个实体,包括支付方式和支付状态等信息。
这边我想强调一下,值对象是不可变的。当订单创建完毕后,这个商品的详细信息或者价格发生了更改,不应该影响到这条订单里面的数据,也就是可以理解为,订单创建的瞬间,里面的值对象应该是快照。
支付成功后,订单聚合根会将订单状态更新为“已支付”,这是通过聚合内部的业务逻辑完成,确保所有数据一致。
在订单准备发货时,订单聚合根会验证存储的收货地址信息的完整性和准确性。如果地址不完整,可能会要求客户提供更多信息,或进行二次确认。
一旦发货地址验证无误,且商品准备就绪,订单聚合根将订单状态更新为“已发货”。随后,实际物流操作开始进行,并在系统中记录和跟踪发货的过程信息。
领域驱动设计(DDD)是软件开发的一场革命,它将我们从技术细节的泥潭中解放出来,让我们重新聚焦于业务本身。DDD不是一套僵化的规则,而是一种思想,一种将业务领域知识置于软件开发核心的哲学。
通过DDD,我们构建的不再是简单的代码,而是一个个鲜活的业务模型。这些模型,如实体、值对象、聚合,它们携带着业务的DNA,它们的行为和属性定义了业务的逻辑和流程。这种以业务为中心的建模方式,让软件系统更加贴近真实世界,更加易于理解和维护。
DDD提倡的分层架构,将业务逻辑与技术实现解耦,使得系统更加灵活,更易于适应变化。它鼓励我们在不同的层次上使用最适合的技术,而不必为了技术而牺牲业务的清晰性和可维护性。
限界上下文的概念,更是DDD中的点睛之笔。它教会我们如何在庞大的业务领域中划定边界,定义清晰的业务规则和模型,同时通过上下文映射来实现不同业务领域间的协调和交互。
而充血模型的应用,则是对传统贫血模型的一种颠覆。它将业务逻辑重新赋予领域对象,让对象本身具备行为和决策能力,从而提升了代码的内聚性和可读性。
在微服务架构大行其道的今天,DDD的原则和模式更显价值。它指导我们在服务拆分时保持业务的完整性,避免业务逻辑的碎片化,构建出既灵活又健壮的微服务系统。
DDD是一种精神,一种追求业务与技术和谐统一的精神。它不是终点,而是起点,引导我们在软件开发的道路上不断探索和前行。拥抱DDD,就是拥抱一种更加人性化、更加富有创造力的软件开发方式。