领域驱动介绍:

什么是领域模型设计?基于对象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中,这个方法的功能肯定不仅仅是计算时长的功能,如果包含了其他的业务代码,那它的可复用性肯定就会大打折扣),那如果真的要做到面向对象编程,我们需要的是胀血模型,这样才能发挥一个对象真正的职能,实现面向对象。