DDD和微服务

一. DDD

DDD 是 Martin Folwer 提出来的,对现有常见开发模式的一种反转。
举个栗子,我们通常拿到一个业务需求,开始辨认可能需要用到的属性,然后做成数据库表,接着开始编写 JavaBean 映射类。写完了,就开始把需求在业务中实现,开始 getset 去的生活。讲道理,我之前刚刚入门的时候就觉得这种模式太枯燥了,千篇一律,Java 刚开始学的面向对象思想也不知道跑到哪里去了。
后来接触了《领域驱动设计 Domain Design Driven》(简称 DDD)这本返璞归真的业务编写指导,才逐渐的觉悟,原来之前那种方式完全都是面向过程的形式,也就是说,我们在用面向对象语言编写面向过程的代码。
先简单说说 DDD 主要讲了什么: DDD 主要通过建立业务通用语言(该语言将会贯穿整个开发上下文,并且是开发人员与业务人员沟通的桥梁,如:一个产品在产品上下文叫产品定义,在仓储上下文则应该称为库存件),将语言通过一种易于维护的组织形式在编码中表达出来,这样开发人员可以通过快速阅读代码,达到如同在阅读业务文档一样的效果。

二. DDD的概念

DDD 涉及一些概念,如果还没理解透彻 Java 中这些概念的使用的话,那么理解起来是有点晦涩的。
我这里可以先简单总结一下:在 Java 中,模块的划分可以通过 这个概念来划分,包中 public 是可以对外公开的,也就是说调用者一般只能看到该包中被 public 的类,至于这个类是接口还是实现类就要看这个包作者怎么做了,简单地说如果实现一个功能,作者通过工厂类的形式返回了一个接口,而内部实现类都是包级别 default 或以下的话,那么我们通过接口调用也就自然而然的看不到实现类了,那么这个包也就可以理解是一个设计的不错的包模块。当然在项目编程中我们通常需要配合诸如 Spring 之类的框架,所以我们写的这些类很可能都是 public 级别的。
那么我先从简单到难这个过程说说 DDD 中各个概念的作用(当然如果说 DDD 就是不同分层的话也不合适,但是万事都有个开头,我们可以先通过分层来做,慢慢体会其中的乐趣)

2.1 模块module

OK,那么模块就是我上面举的例子中的包级别的意思了,通过包来分割不同的模块。
那么在 DDD 中一般是根据什么原则来划分的?
答:
1. 第一级:根据业务模块,我们每个业务,都会分出来一个模块,比如:产品模块,购物车模块,仓储模块等等;
2. 第二级:根据不同功能的模块,有视图层,应用服务层以及领域层;
3. 领域层整个模块,还可以根据爱好决定是否划分成 domain 层、repository 层以及 领域服务层(这些层的职责我会在下面的领域层做解释)

2.2 应用服务层

注意:这里是应用服务层不是叫做领域服务层,两者的职责是不同的。
那么应用服务层是做什么的,我得先总结一下,应用服务层不同于领域服务层,应用服务层只要是一项业务开始的入口,应用服务层是领域层的客户端,通过领域层暴露的公开接口,协调业务的完成(业务完成细节隐藏在领域层中),他主要完成三件事情:
1. 日志的记录;
2. 事务的管理;
3. 通过调用某个仓库获取需要操作的领域对象并且通过调用该领域对象的接口完成业务。

那么结合起来可以总结一句话,他是某个业务下可以完成的业务逻辑的集合。这个项目他可以完成哪些业务,通过应用服务层即可得知。

2.3 领域层

这个题目是 DDD 设计的重中之重,只有这部分做好了,DDD 才算是落实的好。

在领域层中,有三个很重要的角色:
1. 领域类Entity;
2. 协助领域类的值对象ValueObject;
3. 不属于领域类的业务动作集合体领域服务层 DominSevice

2.3.1 实体Entity

说实话,在实体和聚合(其实是实体一种管理方式)我总是有模糊的印象,在现实编码过程中,也经常将实体和聚合混在一起。

查询了一些资料,结合了其他 DDD 玩家的认识,也可以稍微体会两者的区别。

实体其实是和值对象(关联的属性在业务意义上是不可变的)并行的。实体指的是一个业务对象他的属性,是可以通过业务方法进行变更的,比如我要修改订单状态为 已支付,那么订单实体是有暴露 pay 方法,这个方法则会修改内部属性,完成支付所需要的业务逻辑。

订单有订单明细,这部分就是关联订单明细对象了,按道理来说,这个关联应该是必须放在聚合类上的,但是没有确定的方式,所以通过组合的方式,放置于聚合也可以,毕竟订单和订单明细同属于一个事务,那么此时订单实体也就是一个订单聚合了。

2.3.2 不可变值对象ValueObject

说实在先讲实体还是先讲 ValueObject 我没什么底,不过文章并没有并行的概念而且是先有实体后有值对象,所以就实体先说了。

值对象需要结合 Java 中的 String Integer 来说,很快就能获得灵感。

我们知道编程语言他只能提供给我们一些数据的值对象,比如字符串 String 以及数值 Integer 等等,那么我们业务的值对象就是需要辨认出来,哪个部分他一旦创建了就不可改变从而组合这些基础数值对象,不提供 setter 來达到业务值对象的目的。

还是以订单业务作为例子,我们知道订单业务中,下单者和下单时间,一旦建立了就不可改变。所以我们的订单中有个这样的对象:

public final class OrderCreateInfo {

    private final String userUID;
    private final Date createDateTime;

    public OrderCreateInfo(String userUID, Date createDateTime) {
        this.userUID = userUID;
        this.createDateTime = createDateTime;
    }

    public final String getUserUID() {
        return userUID;
    }

    public final Date getCreateDateTime() {
        return createDateTime;
    }

    /** 如果下单用户以及下单时间相同的话,那么视两个创建信息为同一个对象 */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderCreateInfo that = (OrderCreateInfo) o;
        return userUID.equals(that.userUID) &&
                        createDateTime.equals(that.createDateTime);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userUID, createDateTime);
    }
    /** HashCode & equals Ends */

    @Override
    public String toString() {
        return "OrderCreateInfo{" +
                        "userUID='" + userUID + '\'' +
                        ", createDateTime=" + createDateTime +
                        '}';
    }

}

然而因为我们需要使用 ORM 框架与数据库打交道,所以以上的代码兵不兼容现在市面上的 ORM 框架,我们需要稍加修改:

public final class OrderCreateInfo {

    private String userUID;
    private Date createDateTime;

    public OrderCreateInfo(String userUID, Date createDateTime) {
        this.userUID = userUID;
        this.createDateTime = createDateTime;
    }

    /** protected 权限,目的是不是任何人就可以创建空对象,并且最小权限。 */
    protected OrderCreateInfo() {
    }

    public final String getUserUID() {
        return userUID;
    }

    public final Date getCreateDateTime() {
        return createDateTime;
    }

    /** 如果下单用户以及下单时间相同的话,那么视两个创建信息为同一个对象 */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderCreateInfo that = (OrderCreateInfo) o;
        return userUID.equals(that.userUID) &&
                        createDateTime.equals(that.createDateTime);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userUID, createDateTime);
    }
    /** HashCode & equals Ends */

    @Override
    public String toString() {
        return "OrderCreateInfo{" +
                        "userUID='" + userUID + '\'' +
                        ", createDateTime=" + createDateTime +
                        '}';
    }

}

OK,完成了订单创建信息的 ValueObject,那么订单对象 Order 中就应该有这样一个属性:

public class Order {

    private OrderCreateInfo orderCreateInfo;

    // ...
}

当然订单对象中还有其他的业务 ValueObject 类,基本很少有 String Integer 等这样的 Java 提供的基础数据类型。
而至于需不需要提供 setter 方法,这就需要视业务规则而定,基本上很少有属性需要单独提供 setter 方法,因为基本对订单的每一个业务操作都需要修改多个属性,那么在 Order 中提供业务方法即可。

而对于集合类型而言,比如订单明细,则需要用另外方式保证订单明细不被修改:
1. 返回 Guava 中的 ImmutableList 副本;
2. 返回另外一个重新拷贝的集合对象;

我个人而言偏喜欢第一种,比较方便。这么做的目的是防止将订单明细的可变集合暴露到外部,导致外部修改了。

2.3.3 领域仓库

现在 ORM 框架这么成熟,一般来说,仓库都不需要我们自己编写,只需要提供 Spring Data JPA 中的仓库接口实现。
当然仓库我们可以通过 命令查询CQRS 的原则进行分割。

  1. 命令仓库:一般只有增删改以及根据 uid 查询获取对象方法;
  2. 查询仓库:根据业务需求,不同条件查询列表;

2.3.4 领域事件

这里涉及一个对象生命周期的概念:一个业务对象,从他诞生到死亡,一般经历了很多次修改,才会演变为死亡状态(当然也不是说就删掉,就是他已经进入一个稳定的状态,比如已经支付的订单)

举个例子,一个订单,从他创建的,到支付,到发货,到用户收货,评价等等经理了不同的历程。那么这些历程,均可以编写对应的领域事件来表达。比如:
1. 订单已创建,new 出来一个 Order 对象,存储数据库,并且发布 OrderCreated 事件将订单信息发布出去,其他业务需要的话就进行订阅,并做相对应的业务处理(比如积分奖励,发送短信);
2. 订单支付,系统受到用户支付的费用,并且开始调用订单对象的 finishPay() 方法,修改订单状态,这时候可以发布 OrderPaied 事件,同上进行不同的业务处理;
3. ……

事件的定义:基本是领域自己事情完成以后发布,事件的名称均是过去式的形式,表示 XXX已做好XXX。这时候其他业务很方便的进行订阅集成,主领域也不需要去关注其他业务的动作,只要发布事件就好了,后期也可以很简单的进行集成。

可以说,一个业务牵连的另外一个业务的修改,均可以通过事件来发布。事件在内存中或者消息中间件中排队,依次做修改,是不是很熟悉,这就是分布式事务了,一般来说,大部分业务的分布式事务并不需要追求强一致性的概念,而只要最终一致性即可,即用户在做完一个业务以后,可以在允许时间内,奖励用户或者其他动作,只要用户可以收到结果即可。

2.3.5 聚合

聚合可以说是领域设计中的重点所在了。

聚合有两个作用,其实就是个组合的类对象:
1. 组合实体以及 ValueObject
2. 将领域范围内,设计数据库事务的操作,合并起来,共处在同一个事务中。

在现实编码中,我也是经常将实体 Entity 和聚合放在一起使用。

举个例子:
在订单的聚合类中,包含创建者信息,订单明细,支付单(另外一个聚合,可以通过 ObjectID 关联)等等。

这里有一篇不错的设计聚合文章,推荐进入查看:关于领域驱动设计(DDD)中聚合设计的一些思考

2.3.6 领域工厂

简单说是工厂模式,业务对象或者聚合对象出生地。

如果说对象只有一种状态,那么简单工厂模式即可。

但是如果说业务对象有多重不同的条件而出生的存在,抽象工厂就必不可少了。比如普通订单、vip 订单、预售订单。

那么根据参数,创建一个领域对象,只需要封装创建过程,比如调用领域服务创建订单的 ValueObject 或计算订单总价,创建出来的订单聚合返回,存入数据库即可。

三. 微服务和DDD的关系

以上设计基本单体很容易实现,但是用在微服务上,就需要一些更改了。

3.1 SpringCloud组件使用

OK,我就使用最属性的 SpringCloud 来说吧。

Eureka:协调服务执行,心跳服务所需要的其他服务的调用信息更新;
Gateway:系统总入口,对外提供服务,一套微服务就像单体一样,只需要调用必需的服务即可;
Feign:聚合一般都需要其他服务的信息,比如产品信息,调用返回到仓储变成另外一个意思 仓储物品,那么可以使用 Feign 调用第三方,也可以配合 ObjectMapper 的注解配合使用,让客户端自动封装所需要的 ValueObject 或者 Entity,也可以修改其封装的数据形式使其更快传递数据:SpringCloud服务使用ProtolBuffer编码进行传值
StreamSpringCloud 使用的连接第三方消息队列的工具,提供分组功能,此工具一般用来发送领域事件;
SpringDataJPA:严格来说这部分不属于 SpringCloud 的内容,不过需要对象和数据库保持同步的时候,使用这个框架是比较舒服的,也可以在聚合类发送领域事件,但是缺乏灵活性SpringBootJpa 与 DDD 开发

3.2 SpringCloud服务分层

《DDD和微服务》

分层有以下元素:
1. 统一入口 Gateway:这是一个微服务系统的统一入口,只提供最简单的鉴权功能,以及分发请求到下一层的 ResourceGateway
2. 聚合路由服务ResourceGateway:一般来说我们一个查询可以涉及到不同的服务参与,这里聚合服务路由可以提供一个聚合信息的功能(这个聚合和上面的聚合功能有部分重叠功能),此处聚合可以设计用来查询,也可以在聚合里边进行命令操作,不过命令操作的实现比较难以实现。查询的时候涉及多业务查询时可以使用 jdk8CompleteFuture 进行查询 jdk8 多线程处理的使用
3. 业务服务层:对聚合服务的更进一步切割,在业务说法上分开来,当某些业务不能放在一起的时候需要分开服务,并通过领域事件进行沟通(比如发布一个产品已下架,仓储系统、活动系统进行订阅完成接下去的业务,分布式最大通知型事务)

我们的领域事件可以在聚合服务发布也可以在业务服务发布,具体看业务需求了,当然如果在路由层进行发布,我们还可以使用 SpringBoot 最新的 reactive 模型进行查询订阅,当某个特定的业务数据处理完成以后再返回给用户。

3.3 业务多元化查询

CRUD 做完了,老板肯定想要一些方便的功能,这些查询不属于领域驱动设计的内容,如果只是简单的单服务查询,那么直接使用 Endpoint 查询 领域仓库 一般即可完成业务了。

那么如果是根据产品查询最近下单的情况呢,根据产品名字,供应商名字,品牌呢?

我还没想出来什么好的办法,当然我们订单服务可以适当的保存一些冗余的、变化不大的信息,比如供应商的 uid 或者 产品的 uid,如果连产品名字、品牌信息也要保存,那么就需要订阅更多的信息来维护一致性。这时候我们可以使用视图层工具 ElasticSearch 或者其他搜索引擎来加快我们领域信息的查询。也可以使用 Hadoop 工具集等来查询,不过查询速度就需要机器跟上了。我现在还没使用到这部分,没有话语权。

资料参考

互联网架构:从设计到开发让你少踩坑
领域驱动设计,盒马技术团队这么做
《领域驱动设计》
《领域驱动开发》
《微服务设计》

点赞