领域驱动设计实战
领域驱动介绍:
什么是领域模型设计?基于对象vs基于数据库
设计上我们通常从两种维度入手:
a. Data Modeling:通过数据抽象系统关系,也就是数据库设计
b. Object Modeling:通过面向对象方式抽象系统关系,也就是面向对象设计
我们目前就是依据Data Modeling设计系统,对象与数据库一一对应,而如果通过Object Modeling设计出来的类和表有以下几个显著区别,这些区别对领域建模的表达丰富度有显著的差别,有了封装、继承、多态,我们对领域模型的表达要生动得多,对SOLID原则的遵守也会严谨很多。
- 【引用】关系数据库表表示多对多的关系是第三张表来实现,这个领域模型表示不具象化, 业务同学看不懂。
- 【封装】类可以设计方法,数据并不能完整地表达领域模型,数据表可以知道一个人三维,并不知道“一个人是可以跑的”。
- 【继承、多态】类可以多态,数据上无法识别人与猪除了三维数据还有行为的区别,数据表不知道“一个人跑起来和一头猪跑起来是不一样的”。
根据这个思路,慢慢地,我们在面向对象的世界里设计了栩栩如生的领域模型,service层就是基于这些模型做的业务操作(它变薄了,很多动作交给了domain objects去处理):领域模型并不完成业务,每个domain object都是完成属于自己应有的行为(single responsibility),就如同人跑这个动作,person.run是一个与业务无关的行为,但这个时候manger或者service在调用 some person.run的时候可能完成的100米比赛这个业务,也可能是完成跑去送外卖这个业务。
什么是实体对象和值对象?
什么是实体对象和值对象?在领域驱动里,这是一个很基础的概念,根据Eric Evans的《领域驱动设计》所述,一个对象所代表的事物是一个具有连续性和标识的概念(可以跟踪该事物经历的不同状态,甚至可以让该事物跨越不同的实现),还是只是一个用来描述事物的某种状态的属性?这就是实体和值对象的最基本区别。
这个描述可能过于抽象,会造成很多的不理解,那么书中又对这两者做出了更仔细的解释:
-
有些对象并不主要是由它们的属性来定义的。它们体现了标识在时间上的连续性,经常要经历多种不同的形态。有时,一个对象与另一个对象有着不同的属性,但它们是互相匹配的;有时,一个对象与其他对象有着相同的属性,但它必须能够跟那些对象区分开来。弄错对象标识会导致数据破坏。以标识作为其基本定义的对象称之为实体。
-
如果一个对象代表了领域的某种描述性特征,并且没有概念性的标识,我们就称之为值对象。值对象就是那些在设计中我们只关心它们是什么,而不关心它们谁是谁的对象。
如果对于我们实际的开发中,简而言之可以概括为,只要带Id这种唯一性标识的就是实体,而实体中那些描述实体的属性则为值对象。
什么是失血,贫血,充血和胀血?
- 模型:模型仅仅包含数据的定义和getter/setter方法,业务逻辑和应用逻辑都放到服务层中。这种类在Java中叫POJO,在.NET中叫POCO。
- 贫血模型:贫血模型中包含了一些业务逻辑,但不包含依赖持久层的业务逻辑。这部分依赖于持久层的业务逻辑将会放到服务层中。可以看出,贫血模型中的领域对象是不依赖于持久层的。
- 充血模型:充血模型中包含了所有的业务逻辑,包括依赖于持久层的业务逻辑。所以,使用充血模型的领域层是依赖于持久层,简单表示就是 UI层->服务层->领域层<->持久层。
- 胀血模型:胀血模型就是把和业务逻辑不想关的其他应用逻辑(如授权、事务等)都放到领域模型中。我感觉胀血模型反而是另外一种的失血模型,因为服务层消失了,领域层干了服务层的事,到头来还是什么都没变。
技术实现:
一、值对象的创建和获取
1.非数据库值对象
先看写在service的下面这段代码
homeworks = getHomeWork(submitHomeworkInputDTO.getSentence());
Sentence sentence = new Sentence()
.setSentence(homeworks)
.setSentenceScore(submitHomeworkInputDTO.getScore())
.setSentenceTotalScore(submitHomeworkInputDTO.getTotalScore())
.setSentenceAnswerTimes(submitHomeworkInputDTO.getAnswerTimes())
.setSentenceDuration(submitHomeworkInputDTO.getDuration())
.setHandedInTime(DateTimeUtil.nowDate());
homeworkDetail.setSentenceData(sentence)
.setStatus(ConstantUtil.Flag.FLAG_NORMAL);
这里有个值对象Sentence,是存在HomeworkDetail对象里的,我们现在要把Sentence的创建放到HomeworkDetail对象里,改造后的代码如下:
public void setSentence(SubmitHomeworkInputDTO submitHomeworkInputDTO){
Sentence sentence = new Sentence()
.setSentence(submitHomeworkInputDTO.getSentenceHomework())
.setSentenceScore(submitHomeworkInputDTO.getScore())
.setSentenceTotalScore(submitHomeworkInputDTO.getTotalScore())
.setSentenceAnswerTimes(submitHomeworkInputDTO.getAnswerTimes())
.setSentenceDuration(submitHomeworkInputDTO.getDuration())
.setHandedInTime(DateTimeUtil.nowDate());
setSentenceData(sentence);
}
这个方法是创建在HomeworkDetail对象里的,从此以后设置Sentence的操作由HomeworkDetail对象自己来做,而不是由service来完,service里只需要完成下面的代码:
homeworkDetail.setSentence(submitHomeworkInputDTO);
2.数据库值对象(这里引用阿里盒马团队的一段代码)
public class Shop {
@Id
private Long id;
// private List<Product>products;这个商品列表在构建时太大了
private ProductRepository productRepo;
private List<Product>getProducts(){
// return this.products;
return productRepo.getShopProducts(this.id);
}
}
讲到这里,充血模型就要登场了,充血模型的存在让domain object失去了血统的纯正性,他不再是一个纯的内存对象,这个对象里埋藏了一个对数据库的操作,这对测试是不友好的,我们不应该在做快速单元测试的时候连接数据库。为保证模型的完整性,充血模型在有些情况下是必然存在的,一个盒马门店里可以售卖好几千个商品,每个商品有好几百个属性。如果我在构建一个店的时候把所有商品都拿出来,这个效率就太差了.
这里我们需要在实体类Shop里注入一个productRepo,但是在实体类做依赖注入并不是容易的事情,因为我们通常不会把实体类交给spring管理,而是通过new的方式来创建对象。这时候我们就要用到工厂模式了。
第一步,现在实体类里加上ProductRepository的构造方法
@Transient
private ProductRepository productRepo;
public Shop(ProductRepository productRepo) {
this.productRepo = productRepo;
}
第二步,创建工厂类
@Component
public class ShopFactory {
private ProductRepository productRepo;
public ShopFactory(ProductRepository productRepo){
this.productRepo=productRepo;
}
public Shop createShop(){
return new Shop(productRepo);
}
}
第三步,调用工厂方法创建对象
shopFactory.createShop()
通过工厂模式实现了充血模型下的依赖注入,并且这里根据盒马团队的描述,在充血模型下,对象里带上了persisitence特性,这就对数据库有了依赖,mock/stub掉这些依赖是高效单元化测试的基本要求,把ProductRepository放到构造函数的意义就是为了测试的友好性,通过mock/stub这个Repository,单元测试就可以顺利完成。
以上也可参见《领域驱动设计》第六章
二、给必需的属性加上构造方法
1.在HomeworkDetail对象里,(modifierId,modifierName,handedInTime,modifyTime,status)这几个字段是上交作业必需的,因此创建对象时可以给这些属性创建构造方法。
public HomeworkDetail(int modifierId,String modifierName){
this.modifierId=modifierId;
this.modifierName=modifierName;
this.handedInTime=DateTimeUtil.nowDate();
this.modifyTime=DateTimeUtil.nowDate();
this.status= ConstantUtil.Flag.FLAG_NORMAL;
}
这样,我们原先在service里的创建对象就可以在构造方法里完成赋值
三、对象的行为放到对象中处理
1.还是先看service中的一段代码
//计算时间
int duration = homeworkDetail.getDuration() == null ? 0 : homeworkDetail.getDuration();
int sentenceDuration = homeworkDetail.getSentenceDuration() == null ? 0 : homeworkDetail.getSentenceDuration();
tchHWDetailOutputDTO.setDuration(duration + sentenceDuration);
上面这段代码是将HomeworkDetail对象中的两段时长相加,然后设置到返回的对象里。但是我们可以发现计算时间用到的数据都是从homeworkDetail中来的,这说明这完全是homeworkDetail自己的行为,我自己的行为为什么要让别人来完成呢?这不符合领域驱动的设计原则,所以我们可以改进成下面这样:
/**
* 在HomeworkDetail对象里加入下面的方法,用于计算总时长
*
* @return
*/
public int getTotalDuration() {
int duration = this.getDuration() == null ? 0 : this.getDuration();
int sentenceDuration = this.getSentenceDuration() == null ? 0 : this.getSentenceDuration();
return duration + sentenceDuration;
}
然后service里面可以简约成一句话
tchHWDetailOutputDTO.setDuration(homeworkDetail.getTotalDuration());
写在最后
我们目前大部分的开发都是以service为载体去实现行为,实际上这种编程方式是面向过程编程(面向过程编程的可复用性和可扩展性非常不好,就像上面那个例子,计算时长如果放到service中,这个方法的功能肯定不仅仅是计算时长的功能,如果包含了其他的业务代码,那它的可复用性肯定就会大打折扣),那如果真的要做到面向对象编程,我们需要的是胀血模型,这样才能发挥一个对象真正的职能,实现面向对象。
本文作者:leecoders
本文链接:https://www.cnblogs.com/mi520/p/18420790
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)