0%

领域驱动设计DDD简介

什么是领域驱动设计

接触领域驱动设计已经有一年多的时间了,其更关注的是解决复杂的软件设计。这期间也拿一些小项目尝试实践过,也看过一些领域驱动的框架如Halo,Cola,在实践过程中发现如果小项目如果采用简化版的领域驱动设计的理念去实践,代码结构也会有明显的改善,在小项目实践过程中更关注的是边界的划分,和不同功能模块代码的解耦,把核心的领域代码和其它代码区分出来,更好的将代码和现实业务结合起来,更好的执行面向对象,在使用领域驱动设计时能让我们从数据库驱动设计的理念中转换出来,在设计时假设计算机内存是无限大,只考虑领域模型,而不考虑数据库模型,从而使代码更贴近业务,我觉得这是领域驱动设计给我带来的感触。就比如架构,架构的本质无非就是把代码的边界区分好,把代码放在该放的地方Eric Evans提出的领域驱动设计是一个很好的解决这部分问题的方法。
领域驱动设计,领域代表着行业,你的软件是应用在哪个行业实现什么样的功能,比如你编写了一个财务软件,那么财务行业就是你编写软件的领域,Eric Evans强调软件一定是要由领域专家和领域知识去驱动开发的,因为软件最终是要应用到领域当中,如果绕过这部分内容直接开发软件,那么开发的软件往往无法交付或者无法满足功能,在我认为,软件是现实生活中的映射,在开发第一个版本的时候,可以和领域专家或者领域中的从业人员进行交流业务流程(现实往往只能找到产品经理),交流一下在没有软件的情况下,线下业务是如何进行的,然后再根据这些流程去建立领域对象,去驱动整体的软件设计。

领域驱动设计的核心概念

通用语言

在开发准备之前必须要和领域人员(如果没有,那只能是需求人员)建立好一套通用的开发语言,我曾经在没接触过过领域驱动设计时实现一个功能时并没有和当时对应需求人员建立一套通用的语言,就是产品说的一个名词和你说的名词并不是同一个名词,但是你以为你理解了产品经理的意思,然后就去开发,结果到交付时发现整个理念完全错误,导致最后返工。建立通用语言的过程往往要经过反复沟通后才能得出的,在反复沟通的过程中,抓住一个反复提及的名词,然后确定这个名词代表的含义,反复提及的名词往往就是找到通用语言的关键,在软件建模中也要紧紧围绕这个名词去建模,当你建立完模型后(通常是UML图),把这部分模型拿给领域专家看,因为你这时候的模型就代表着现实的业务,和领域人员确认模型这样设计是否合理。

领域模型
贫血型模型

在领域驱动设计中Eric Evans一直不赞成使用贫血型模型,什么是贫血型模型,简单来说就是我们平常建立的java bean对象,只包行一些字段和get,set方法,因为贫血型对象不能很好的展示出领域模型,有一些本该属于领域模型的方法外泄到其它类比如Service类中去了,所有和领域相关的代码都要包含在领域模型中或者领域服务中,举个例子,有一个用户信息模型,一般我们是这样设计和调用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class User {

private String userId;

private String userName;

public String getUserId() {
return userId;
}

public void setUserId(String userId) {
this.userId = userId;
}

public String getUserName() {
return userName;
}

public void setUserName(String userName) {
this.userName = userName;
}
}

现在我要修改用户的名字那边就要用Service去实现

1
2
3
4
5
6
7
8
9
10
11
public class UserService {

public void changeUserName(String userName,User user){

if(userName!=null){
user.setUserName(userName);
}

}

}

认真考虑一下现实项目中采用上文编写代码所产生的问题

  • 代码不能很好的体现业务
    changeUserName这个方法应该是属于用户类中的方法,但是现实中该类却放在了调用方的Service中,上文提到了通用语言,在和领域人员沟通时,他们会说到用户修改了用户名,这里作用的主体是用户,只有用户有权利修改自己的用户名,但是体现在代码中却变成了用户把修改用户名的权利交给了调用方,这样的代码并不能很好的反应出业务,也并不是很好的面向对象的模型。

  • Service上帝类
    采用上述方法去建模,将会导致service类的代码快速膨胀,久而久之service类负责所有的功能,包括校验,对象持久化,远程调用等待代码,如果在项目开始时还可以接受,但是随着时间的发展,service变成了乱麻,再也没有人能够轻松分清里面的逻辑了。

    充血模型
  • 现在我们采用充血模型实现上述代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class User {

    private String userId;

    private String userName;

    public void updateUserName(String userName) {
    if(userName!=null){
    this.userName=userName;
    }

    }
    }

    现在我们将原本存在Service中的updateUserName方法放到领域模型中,就修改用户名时传入用户名即可完成用户名修改,这完全符合通用语言的用户修改了用户名,并且在修改用户名时候进行了校验,校验本身也是领域对象提供的功能之一,修改用户名的规则应该由领域对象去觉得,而不是由调用方去决定

  • 现在第三方人员修改用户名变的简单了,再也不需要处理本该属于领域对象校验等功能,这样将属于领域的代码和属于调用方的代码很好的分开,代码的层次也更加分明。

    1
    2
    3
    4
    5
    6
    7
    8
    public class UserService {

    public void changeUserName( User user,String userName) {

    user.changeUserName(userName);
    }

    }
    实体(Entity)

    采用领域驱动设计建模的一个很重要的概念就是实体,书中给的定义是主要由标识定义的对象称为实体(Entity),现在我们扩展上诉的User对象,在对象里加入一个Address家庭住址对象

    1
    2
    3
    4
    5
    6
    7
    8
      public class User {

    private String userId;

    private String userName;

    private Address address;
    }

    家庭住址对象包含街道名和邮编

    1
    2
    3
    4
    5
    6
    public class Address {

    private String street;

    private String postcode;
    }

    在通用语言中我们会这样描述,我想根据用户id找到对应用户所居住的地址,这里的用户id就是User的标识定义,那么我们认为User它就是一个实体,但是这里的Address并不是实体,它只是一个VO(键值对对象)在用户模型中给地址一个唯一标识定义是没有意义的,因为在用户跟换地址时我直接将整个Address对象替换掉即可,我并不关心地址的状态。

    在现实模型中同一个事物可能需要标识为实体,也有可能不需要表示实体,举个例子上述的User为实体,但是换一个场景,订单对象下关联了一个用户对象来表明这个订单是谁买的,在这里我们关注的是订单,把用户作为实体是没有意义的,所以这里订单是实体,User只是一个VO(键值对对象)

    VO

    很多对象没有概念上的标识,它们描述了一个事务的某种特性,我们称这些对象为VO

    如果将所有对象设计成实体会有什么问题?在书中有一个很好的例子:小孩子总是可以很好的分清楚那副画是自己画的,因为每幅画都有一些标识来区分哪些是自己画的哪些不是,但是如果必须记住哪些线条使用哪只笔画的那情况该有多复杂?上文的User对象的Address就是一个VO,因为我并不关心Address 的状态,它在User对象中只是单纯是键值属性,但是什么情况下地址为实体什么情况下为VO呢?书中举了另外一个例子:在购物系统中需要用地址来标识发货地址,如果室友也从这家店购买了商品,那么意识到他们是否住在同一个地方并不重要(不需要维护地址标识),这里是地址是VO,如果你和舍友同时去申请宽带,那么这里的地址就是实体,因为电信公司需要知道你和你的舍友居住在同一个地方,这样他们只要上门一次即可。

    另一个VO的重要特性是VO是可以整个被替换的,其没有包含副作用的方法,举个例子上文User关联的Address对象,将Address对象传给其它人调用里面的方法并不会对User类产生影响,因此可以放心的将VO传给任何人调用,在设计中尽量将对象设计成VO来减少系统的复杂性,大举个例子,上文中User如果修改Address如果Address的话我们直接在User对象中整个替换Address对象即可完成地址的修改,但是如果地址为实体的话,那么我们必须用地址标识还原出Address对象,然后将地址对象的属性做修改

    领域服务

    在书中是给领域服务这样定义的:在领域中的某个操作过程或转换过程不是实体或者值对象(VO)的职责时,我们应该将操作放在一个单独的接口中,即领域服务,请确保领域服务和通用语言是一致的,并且保证它是无状态的。

    在现实代码中我更喜欢把领域当成客户端调用的代码,领域模型由建模人员编写完代码,然后将这部门代码交给客户端人员去调用,领域建模人员必须保证客户端代码的易用性,如果有些方法并不适合在领域模型中,那么可以将其放在领域服务中,这样可以对客户端人员屏蔽领域细节,例如用户登录时可能涉及到鉴权,鉴权部分可能是另一个模型对象,如果我们不使用领域服务将这些工作全部交给客户端人员去做的话,那么客户端的程序员就必须知道业务的逻辑,要知道原来登录要先鉴权,他先去执行鉴权代码再去执行验证用户密码的代码,这样就导致领域信息暴露,更好的做法应该是用领域服务将鉴权和验证用户信息的方法放在领域服务中,然后再对外暴露一个登陆验证的服务,这样就对客户端程序员屏蔽了领域信息,他只需要调用一个登陆的领域服务即可,不用去关心领域里的逻辑,关于领域服务的具体实践和分包我讲在后续小项目领域驱动实践中介绍。

    聚合

    在日常开发中将实体和VO进行聚合组成聚合对象是非常普遍的,我们将这些对象称之为聚合,聚合对象都有一个主体我们称之为聚合跟,在使用聚合时对客户端程序员暴露的都应该是聚合根,而不是聚合根下的对象,举例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
      public class User {

    private String userId;

    private String userName;

    private Address address;

    public void saveAddress(Address address){
    this.address=address;
    }
    }

    上文中的User对象包就是一个聚合它包含了用户的用户名,用户id属性,也包含了一个地址VO,我们在对外提供领域模型给客户端程序员时我们只能建User对象中的方法,比如上文的saveAddress保存地址方法,而不是将一个个调用Address对象中的get set方法。在实际设计过程中有时候由于聚合包含太多的VO和实体导致聚合过于庞大,我们可以将聚合进行拆分,具体的实践方法我讲在后续小项目领域驱动实践的文章中表述。

    资源库(Repository)

    Repository对领域模型屏蔽了持久化的代码,因为现实软件设计中领域对象不可能全部都存留在内存中,当一个领域对象从创建开始我们就认为其已开始其的生命周期,即使后续将这个领域对象存储在数据库等持久化的对象中我们仍然认为其仍处于生命周期内,直到我们在数据库中将其删除我们才认为一个领域对象生命的结束,资源库就是为了在领域对象存储在数据库或者其它一些持久化组件中时将其还原成领域对象而设计的,比如如下代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class UserRepository {

    public void addUser(User user){

    //持久化代码
    }

    public User findUserById(String userId){
    //模拟从数据库中生成对象
    User user=new User();
    return user;
    }

    }

    在客户端服务调用完领域服务生成领域对象后,客户端将领域对象传入到资源库中将其持久化(addUser),同样,资源库也可以根据各种条件去还原一个领域对象(findUserById)

架构设计(六边形架构)

在采用领域驱动设计时我采用的是六边形架构如图

六边形架构

下面是我在网上摘抄的对六边形架构的简介

六边形架构还是一种分层架构,如上图所示,它被分为了三层:端口适配器、应用层与领域层。而端口又可以分为输入端口和输出端口。

  • 输入端口
    用于系统提供服务时暴露API接口,接受外部客户系统的输入,并客户系统的输入转化为程序内部所能理解的输入。系统作为服务提供者是对外的接入层可以看成是输入端口。

  • 输出端口
    为系统获取外部服务提供支持,如获取持久化状态、对结果进行持久化,或者发布领域状态的变更通知(如领域事件)。系统作为服务的消费者获取服务是对外的接口(数据库、缓存、消息队列、RPC调用)等都可以看成是输入端口。

  • 应用层
    定义系统可以完成的工作,很薄的一层。它并不处理业务逻辑通过协调领域对象或领域服务完成业务逻辑,并通过输入端口输出结果。也可以在这一层进行事物管理。

  • 领域层
    负责表示业务概念、规则与状态,属于业务的核心。

    应用层与领域层的不变性可以保证核心领域不受外部的干扰,而端口的可替换性可以很方便的对接不用的外部系统

    我在这里对以上的解释说明下

  • 输入端口在上图中对应适配器A,B,C,D比如在http服务中其对应的就是Controller层,用Spring的话就是SpringMVC框架,适配器完成了http对应用用程序的转化,因为应用层提供了固定的API来满足各种渠道的调用,比如现在我要增加一个人RPC调用,我只要根据应用层所提供的API写一个转换器将应用层的API转化成RPC调用的方法即可,图中领域模型作为为核心最稳定的内容放在了六边形的中央,其它所有的组件都必须依赖领域模型,这符合了领域驱动的模型。最后是输出端口,我们将持久化的代码放入这里(资源库Repository)下面给出我们架构设计的依赖结构*

    输出层 -> 应用程序(APP) -> 资源库(Repository)-> 领域层

  • 实际中领域层中的领域服务有可能会调用到资源库(Repository),但是我们又不能让领域层去依赖资源库层,因为领域层是项目的核心,应该是最稳定的部分,会被大量的组件依赖,而资源库层相对领域层更加不稳定(比如数据库中新增一个字段等),那么我们这里就要用依赖倒置技术使资源库依赖领域层,具体实践放在小项目领域驱动实践中去讲解*