DDD 核心概念与 Domain primitive

一、什么是DDD?

领域驱动设计(Domain-Driven Design 简称DDD),是一套成熟的理论方法来指导中台领域建模以及微服务拆分和设计,聚焦于“如何在复杂业务场景下设计软件”。

2003年埃里克·埃文斯(ErjcEvans)出版了《领域驱动设计》这本书后, DDD诞生。DDD的核心思想是从业务视角出发,根据限界上下文边界划分业务的领域边界,定义领域模型,确定业务边界。在微服务落地时,建立业务领域模型与微服务代码模型的映射关系,从而保证业务架构与微服务系统架构的—致性。但DDD提出后在软件开发领域一直都是“雷声大雨点小” ,直到MartjnFowler提出微服务架构后,DDD才真正 迎来了自己的时代。

DDD首先从业务领域入手,划分业务领域边界,采用事件风暴工作坊方法,分析并提取业务场景中的实体、值对象、聚合根、聚合、领域事件等领域对象,根据限界上下文边界构建领域模型,将领域模型作为微服务设计的输入,进而完成微服务洋细设计。用DDD方法设计出来的微服务,业务和应用边界非常清晰,符合“高内聚,低耦合”的设计原则,可以轻松适应业务模型变化和微服务架构演进。
——《基于DDD和微服务的中台架构与实现》

微服务与DDD相辅相成,属于共生关系。这体现在两个方面:

  • 微服务提倡将应用进行服务化拆分,通过业务领域边界实现应用服务边界的划分。
  • DDD恰好提供了一种基于业务限界上下文边界来实现微服务“高内聚,低藕合”的服务构建方法。

正因如此,将两者合理搭配使用,研发组织可以轻松实现面向服务的设计,享受持续交付与架构演进。

DDD与微服务,乃至中台设计的结合,目前仍是—个非常新的领域。

二、DDD核心知识体系概念

DDD的核心知识有:领域、子域、核心子域、通用子域、 支撑子域、限界上下文、实体、值对象、聚合和聚合根、领域事件、领域服务、应用服务和分层架构等。

2.1 领域

DDD的领域就是这个边界内要解决的业务问题域。

2.2 子域

领域会被细分为不同的子域,可以根据子域自身的重要性和功能属性将它们划分为三类子域,分别是:核心子域、通用子域和支撑子域。

2.3 核心子域

在企业内决定产品或企业核心竞争力的功能子域是核心子域。

2.4 通用子域

那些没有太多 个性化的诉求,同时又会被多个子域重复使用的通用功能子域是通用子域。

2.5 支撑子域

是企业必需的,但它既不是决定产品或企业核心竞争力的功能,也不是被其他子域复用的通用功能,这类子域是支撑子域。

2.6 限界上下文

限界上下文就是在限定的上下文环境内,用来封装通用语言领域对象。保证领域内的—些术语、领域对象等有一个确切的含义的没有语义二义性的一个业务边界。

通用语言中的名词一般可以给领域对象命名,如商品、订单等,它们对应领域模型中的 实体对象。而动词则表示—个动作或领域事件,如商品已下单、订单支付等,它们对应领域模型中的 领域事件 或者 命令。

比如:商品在不同的阶段有不同的表达形式。商品在销售阶段是商品,这是它的原始含义。在销售阶段结束后,商品就进人了运输阶段,这时商品就变成了货物。可见,同样的一件商品,由于业务领域边界的不同,这些通用语言的术语就有了不同的含义。限界上下文就是用来定义这些通用语言的上下文边界的。这个边界既是业务领域的边界,也是微服务拆分的边界。

2.7 实体

实体一般对应业务对象,它具有相对丰富的业务属性和业务行为。
在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法。通过这些方法实现实体自身的业务行为和业务逻辑。DDD更强调面向对象的设计方法。这些实体类通常采用充血模型,与实体相关的所有业务逻辑都在实体类方法中实现,跨多个实体的领域逻辑则在领域服务中实现。实体以领域对象(DO)的形式存在,而与数据库字段映射的应该以持久化对象(PO)的形式存在,PO是贫血的。

DO不一定要与表结构对应,比如用户user与角色role两个持久化对象可生成权限实体,一个DO实体会对应两个持久化对象,这是一对多的场景。
再比如,有些场景为了避免数据库的联表查询,提升系统性能,会将客户信息customer和账户信息account两类数据保存到同—张数据库表中。客户和账户两个实体可根据需要从—个持久化对象中生成,这就是多对—的场景。

充血模型与贫血模型的关键差异:在充血模型中,业务逻辑都在领域实体对象中实现,实体本身不仅包含了属性,还包含了它的业务行为。DDD领域模型中实体是一个具有业务行为和逻辑的对象。而在贫血模型中领域对象大多只有setter和getter方法,业务逻辑统一放在业务逻辑层实现,而不是在领域对象中实现。

2.8 值对象

值对象本质是—个属性集合,主要完成对实体的状态和特征描述。是若干个基于描述目的“具有整体概念和不可修改的属性”。在应用运行时,我们主要关注这些属性集的“值”。如下图,传统数据模型中一般如右边在类中罗列地址相关属性,而在领域模型中地址相关属性被封装为地址“值对象”,人员实体持有这个地址的引用。
屏幕截图 2021-07-10 171331.png
这样做在逻辑上显然更清晰。这样实现在表结构上有两种设计方法:

屏幕截图 2021-07-10 171852.png

2.9 聚合

聚合在领域模型里是一个逻辑边界,它本身没有业务逻辑实现相关的代码。

实体和值对象都只是个体化的业务对象,它们所表现出来的是个体的行为和能力。在领域模型中我们需要一个这样的组织,将这些紧密关联的个体对象聚集在一起,按照组织内统—的业务规则共同完成特定的业务功能,因此就有了聚合的概念。
领域模型内的 实体 和 值对象 就类似这些组织中的个体,而能让实体和值对象协同工作的组织就是聚合。

比如,订单聚合就有自己内部的业务规则,在订单聚合内每次修改商品数据时,它们都必须符合订单聚合的业务规则:“订单总金额等于所有商品明细金额之和。”违反了这个业务规则,就会出现聚合数据不一致等诸多问题。

聚合是数据修改和持久化的基本单元。在传统数据模型中每一个实体都是对等的,在业务逻辑实现时,可以随意找到实体或数据库表完成数据修改, 但这类操作在DDD的聚合内是不被允许的!

2.10 聚合根

如果把聚合比作组织,那聚合根就是这个组织的负责人。
聚合根也称为根实体,但它 不仅是实体,还是聚合的管理者。

  • 聚合根是实体,作为实体,它拥有实体的业务属性和业务行为,可以在聚合根实现自身的业务逻辑。
  • 它作为聚合的管理者,在聚合内负责协调实体和值对象,按照固定的业务规 则,协同完成聚合共同的业务逻辑°
  • 它还是聚合对外的联络人和接口人,聚合之间以聚合根ID关联的方式接受聚合的外部任务和请求,在限界上下文内实现聚合之间的业务协同,聚合外部对象不能直接通过对象引用的方式访问聚合内的对象。比如,当你需要访问其他聚合的实体时,可以在应用服务中调用其他聚合的领域服务,将关联的聚合根ID作为服务参数,先访问聚合根,再通过聚合根导航到聚合内部实体。

2.11 领域事件

在领域建模时,除了命令和操作等业务行为以外,还有—类非常重要的事件,这类事件发生后通常会触发进一步的业务操作,比如:“如果发生……,则……”,当做完……时,通知……”,发生……时,则……”等。在DDD中这类事件被称为领域事件(DomainEvent)。

举例来说,领域事件可以是业务流程的一个步骤,比如投保业务缴费完成后,触发投保单转保单的动作;
也可以是定时批处理过程中发生的事件,比如批处理生成季缴保费通知单,触发缴费邮件通知操作;
还可以是一个事件发生后触发的后续动作,比如密码连续输错三次,触发锁定账户的动作。

2.12 分层架构

  • **用户接口层:**面向前端用户提供服务和数据适配。这一层聚集了接口和数据适配相关的功能。
  • **应用层:**实现服务组合和编排,主要适应业务流程快速变化的需求。这一层聚集了应用服务和事件订阅相关的功能。
  • **领域层:**实现领域模型的核心业务逻辑。这—层聚集了领域模型的聚合、聚合根、 实体、值对象、领域服务和事件等领域对象,通过各领域对象的协同利组合形成领域模型的核心业务能力。
  • **基础层:**它贯穿所有层,为各层提供基础资源服务。这—层聚集了各种底层资源相关的服务和能力。常见的功能是完成实体的数据库持久化。

领域模型的业务逻辑从领域层、应用层到用户接口层逐层组合和封装,对外提供灵活的服务。既实现了各层的分工和解耦,又实现了各层的协作。
屏幕截图 2021-07-10 193120.png

三、Domain Primitive

实体与值对象,是领域模型的基础单元。
Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

下面从代码示例上来分析DP的好处。

3.1 案例

我们先看一个简单的例子,这个 case 的业务逻辑如下:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。

先不要去纠结这个根据用户电话去发奖金的业务逻辑是否合理,也先不要去管用户是否应该在注册时和业务员做绑定,这里我们看的主要还是如何更加合理的去实现这个逻辑。一个简单的用户和用户注册的代码实现如下:

public class User {
    
    
    Long userId;
    String name;
    String phone;
    String address;
    Long repId;
}

// 注册服务
public class RegistrationServiceImpl implements RegistrationService {
    
    

    // 销售员仓储
    private SalesRepRepository salesRepRepo;
    // 用户仓储
    private UserRepository userRepo;

    public User register(String name, String phone, String address) 
      throws ValidationException {
    
    
        // 校验逻辑
        if (name == null || name.length() == 0) {
    
    
            throw new ValidationException("name");
        }
        if (phone == null || !isValidPhoneNumber(phone)) {
    
    
            throw new ValidationException("phone");
        }
        // 此处省略address的校验逻辑

        // 取电话号里的区号,然后通过区号找到区域内的SalesRep
        String areaCode = null;
        String[] areas = new String[]{
    
    "0571", "021", "010"};
        for (int i = 0; i < phone.length(); i++) {
    
    
            String prefix = phone.substring(0, i);
            if (Arrays.asList(areas).contains(prefix)) {
    
    
                areaCode = prefix;
                break;
            }
        }
        SalesRep rep = salesRepRepo.findRep(areaCode);

        // 最后创建用户,落盘,然后返回
        User user = new User();
        user.name = name;
        user.phone = phone;
        user.address = address;
        if (rep != null) {
    
    
            user.repId = rep.repId;
        }

        return userRepo.save(user);
    }

    private boolean isValidPhoneNumber(String phone) {
    
    
        String pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return phone.matches(pattern);
    }
}

我们日常绝大部分代码和模型其实都跟这个是类似的,这属于脚本式编程,乍一看貌似没问题,但我们再深入一步,从以下四个维度去分析一下:接口的清晰度(可阅读性)、数据验证和错误处理、业务逻辑代码的清晰度、和可测试性。

3.1.1 问题1:接口清晰度

在Java代码中,对于一个方法来说所有的参数名在编译时丢失,留下的仅仅是一个参数类型的列表,所以我们重新看一下以上的接口定义,其实在运行时仅仅是:

User register(String, String, String);

所以以下的代码是一段编译器完全不会报错的,很难通过看代码就能发现的 bug :

service.register("张三", "河北省沧州市吴桥县黄河路二号院", "0571-12345678");

这三个入参不管顺序如何,在编译时都不会报错,但这种 bug 会在运行时被发现。普通的 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。

另外一种常见的,特别是在查询服务中容易出现的例子如下:

User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

由于入参都是 String 类型,不得不在方法名上面加上 ByXXX来区分,而 findByNameAndPhone同样也会陷入前面的入参顺序错误的问题,而且和前面的入参不同,这里参数顺序如果输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。

3.1.2 问题2:数据验证和错误处理

在前面这段数据校验代码:

if (phone == null || !isValidPhoneNumber(phone)) {
    
    
    throw new ValidationException("phone");
}

在日常编码中经常会出现,一般来说这种代码需要出现在方法的最前端,确保能够 fail-fast 。但是假设你有多个类似的接口和类似的入参,在每个方法里这段逻辑会被重复。而更严重的是如果未来我们要拓展电话号去包含手机时,很可能需要加入以下代码:

if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
    
    
    throw new ValidationException("phone");
}

如果你有很多个地方用到了 phone 这个入参,但是有个地方忘记修改了,会造成 bug 。这是一个 DRY 原则(Don’t Repeat Yourself)被违背时经常会发生的问题。

如果有个新的需求,需要把入参错误的原因返回,那么这段代码就变得更加复杂:

if (phone == null) {
    
    
    throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
    
    
    throw new ValidationException("phone格式错误");
}

若代码里充斥着大量的类似代码块时,会极大提高维护成本。

最后,在这个业务方法里,会(隐性或显性的)抛 ValidationException,所以需要外部调用方去try/catch,而业务逻辑异常和数据校验异常被混在了一起,是否是合理的?

在传统Java架构里有几个办法能够去解决一部分问题,常见的如BeanValidation注解或ValidationUtils类,比如:

// Use Bean Validation
User registerWithBeanValidation(
  @NotNull @NotBlank String name,
  @NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\\d{8}$") String phone,
  @NotNull String address
);

// Use ValidationUtils:
public User registerWithUtils(String name, String phone, String address) {
    
    
    ValidationUtils.validateName(name); // throws ValidationException
    ValidationUtils.validatePhone(phone);
    ValidationUtils.validateAddress(address);
    ...
}

但这几个传统的方法同样有问题:
BeanValidation:

  • 通常只能解决简单的校验逻辑,复杂的校验逻辑一样要写代码实现定制校验器
  • 在添加了新校验逻辑时,同样会出现在某些地方忘记添加一个注解的情况,DRY原则还是会被违背

ValidationUtils类:

  • 当大量的校验逻辑集中在一个类里之后,违背了Single Responsibility单一性原则,导致代码混乱和不可维护
  • 业务异常和校验异常还是会混杂

3.1.3 问题3 - 业务代码的清晰度

在这段代码里:

String areaCode = null;
String[] areas = new String[]{
    
    "0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
    
    
    String prefix = phone.substring(0, i);
    if (Arrays.asList(areas).contains(prefix)) {
    
    
        areaCode = prefix;
        break;
    }
}
SalesRep rep = salesRepRepo.findRep(areaCode);

出现了另外一种常见的情况,那就是从一些入参里(phone)抽取一部分数据(prefix),然后调用一个外部依赖获取更多的数据(findRep),然后通常从新的数据中再抽取部分数据用作其他的作用。

这种代码通常被称作**“胶水代码”**,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。比如,如果SalesRepRepository包含一个findRepByPhone的方法,则上面大部分的代码都不必要了。
所以,一个常见的办法是将这段代码抽离出来,变成独立的一个或多个方法:

private static String findAreaCode(String phone) {
    
    
    for (int i = 0; i < phone.length(); i++) {
    
    
        String prefix = phone.substring(0, i);
        if (isAreaCode(prefix)) {
    
    
            return prefix;
        }
    }
    return null;
}

private static boolean isAreaCode(String prefix) {
    
    
    String[] areas = new String[]{
    
    "0571", "021"};
    return Arrays.asList(areas).contains(prefix);
}

然后原始代码变为:

String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

而为了复用以上的方法,可能会抽离出一个静态工具类 PhoneUtils。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

3.1.4 问题4 - 可测试性

假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC (Test Case)。

如果这时候在该方法中加入一个新的入参字段 fax ,即使 faxphone 的校验逻辑完全一致,为了保证 TC 覆盖率,也一样需要 M 个新的 TC 。

而假设有 P 个方法中都用到了 phone这个字段,这 P 个方法都需要对该字段进行测试,也就是说整体需要:
**P * N * M **个测试用例才能完全覆盖所有数据验证的问题,在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。

3.1.5 解决方案

再回顾一下原来的业务描述:
一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,在用户注册后能够通过用户电话号的区号对业务员发奖金。

分析后发现,地推业务员、用户本身自带 ID 属性,属于 Entity(实体),而注册系统属于 Application Service(应用服务),这几个概念已经有存在。但是发现电话号这个概念却完全被隐藏到了代码之中。考虑一下,取电话号的区号的逻辑是否属于用户(用户的区号?)?是否属于注册服务(注册的区号?)?如果都不是很贴切,那就说明这个逻辑应该属于一个独立的概念。

所以这里引入第一个原则:
Make Implicit Concepts Explicit 将隐性的概念显性化

原来电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个Value Object 值对象:

public class PhoneNumber {
    
    
  
    private final String number;
    public String getNumber() {
    
    
        return number;
    }

    public PhoneNumber(String number) {
    
    
        if (number == null) {
    
    
            throw new ValidationException("number不能为空");
        } else if (isValid(number)) {
    
    
            throw new ValidationException("number格式错误");
        }
        this.number = number;
    }

    public String getAreaCode() {
    
    
        for (int i = 0; i < number.length(); i++) {
    
    
            String prefix = number.substring(0, i);
            if (isAreaCode(prefix)) {
    
    
                return prefix;
            }
        }
        return null;
    }

    private static boolean isAreaCode(String prefix) {
    
    
        String[] areas = new String[]{
    
    "0571", "021", "010"};
        return Arrays.asList(areas).contains(prefix);
    }

    public static boolean isValid(String number) {
    
    
        String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
        return number.matches(pattern);
    }

}

这里面有几个很重要的元素:

  1. 通过 private final String number 确保 PhoneNumber 是一个(Immutable)Value Object。(一般来说 VO 都是 Immutable 的,这里只是重点强调一下)
  2. 校验逻辑都放在了 constructor 里面,确保只要 PhoneNumber类被创建出来后,一定是校验通过的。
  3. 之前的 findAreaCode 方法变成了 PhoneNumber类里的 getAreaCode,突出了 areaCodePhoneNumber 的一个计算属性。

这样做完之后,我们发现把 PhoneNumber 显性化之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

  • Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里

这两个概念加起来,构造成了 Domain Primitive

3.1.6 使用DP重构后效果

public class User {
    
    
    UserId userId;
    Name name;
    PhoneNumber phone;
    Address address;
    RepId repId;
}

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) {
    
    
    // 找到区域内的SalesRep地推员
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

    // 最后创建用户,落盘,然后返回,这部分代码实际上也能用Builder解决
    User user = new User();
    user.name = name;
    user.phone = phone;
    user.address = address;
    if (rep != null) {
    
    
        user.repId = rep.repId;
    }

    return userRepo.saveUser(user);
}

可以看到在使用了 DP 之后,所有的数据验证逻辑和非业务流程的逻辑都消失了,剩下都是核心业务逻辑,可以一目了然。我们重新用上面的四个维度评估一下:

评估1-接口清晰度:
重构后的方法签名变成了很清晰的:

public User register(Name, PhoneNumber, Address)

而之前容易出现的bug,如果按照现在的写法

service.register(new Name("张三"), new Address("河北省沧州市吴桥县黄河路二号院"), new PhoneNumber("0571-12345678"));

让接口 API 变得很干净,易拓展。

评估2 - 数据验证和错误处理:

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) // no throws

如前文代码展示的,重构后的方法里,完全没有了任何数据验证的逻辑,也不会抛 ValidationException 。原因是因为 DP 的特性,只要是能够带到入参里的一定是正确的或 null(Bean Validation 或 lombok 的注解能解决 null 的问题)。所以我们把数据验证的工作量前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。
再展开来看,使用DP的另一个好处就是代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber的校验逻辑,只需要在一个文件里修改即可,所有使用到了 PhoneNumber 的地方都会生效。

评估3 - 业务代码的清晰度:

SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

除了在业务方法里不需要校验数据之外,原来的一段胶水代码 findAreaCode被改为了 PhoneNumber类的一个计算属性 getAreaCode,让代码清晰度大大提升。而且胶水代码通常都不可复用,但是使用了 DP 后,变成了可复用、可测试的代码。我们能看到,在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。

评估4 - 可测试性:

  • 首先 PhoneNumber 本身还是需要 M 个测试用例,但是由于我们只需要测试单一对象,每个用例的代码量会大大降低,维护成本降低。
  • 每个方法里的每个参数,现在只需要覆盖为 null 的情况就可以了,其他的 case 不可能发生(因为只要不是 null 就一定是合法的)

所以,单个方法的 TC 从原来的 N * M 变成了 N + M 。同样的,多个方法的 TC 数量变成了
N + M + P
这个数量一般来说要远低于原来的数量 N* M * P ,让测试成本极大的降低。
v2-78c099245efa683fb35a7d7f0ab27833_r.jpg

3.2 进阶使用

在上文介绍了 DP 的第一个原则:将隐性的概念显性化。在这里介绍 DP 的另外两个原则,用一个新的案例。

3.2.1 案例1 - 转账

假设现在要实现一个功能,让A用户可以支付 x 元给用户 B ,可能的实现如下:

public void pay(BigDecimal money, Long recipientId) {
    BankService.transfer(money, "CNY", recipientId);
}

如果这个是境内转账,并且境内的货币永远不变,该方法貌似没啥问题,但如果有一天货币变更了(比如欧元区曾经出现的问题),或者需要做跨境转账,该方法是明显的 bug ,因为 money对应的货币不一定是 CNY 。
在这个 case 里,当说“支付 x 元”时,除了 x 本身的数字之外,实际上是有一个隐含的概念那就是货币“元”。但是在原始的入参里,之所以只用了 BigDecimal 的原因是认为 CNY 货币是默认的,是一个隐含的条件,但是在写代码时,需要把所有隐性的条件显性化,而这些条件整体组成当前的上下文。所以 DP 的第二个原则是:
Make Implicit Context Explicit 将隐性的 上下文 显性化

所以当做这个支付功能时,实际上需要的一个入参是支付金额 + 支付货币。我们可以把这两个概念组合成为一个独立的完整概念:Money

@Value
public class Money {
    
    
    private BigDecimal amount;
    private Currency currency;
    public Money(BigDecimal amount, Currency currency) {
    
    
        this.amount = amount;
        this.currency = currency;
    }
}

而原有的代码则变为:

public void pay(Money money, Long recipientId) {
    
    
    BankService.transfer(money, recipientId);
}

通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为 Money ,可以避免很多当前看不出来,但未来可能会暴雷的bug。

3.2.2 案例2 - 跨境转账

前面的需求改变一下,假设用户可能要做跨境转账从 CNY 到 USD ,并且货币汇率随时在波动:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    
    
    if (money.getCurrency().equals(targetCurrency)) {
    
    
        BankService.transfer(money, recipientId);
    } else {
    
    
        BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
        BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
        Money targetMoney = new Money(targetAmount, targetCurrency);
        BankService.transfer(targetMoney, recipientId);
    }
}

在这个需求里,由于 targetCurrency不一定和 moneyCurreny一致,需要调用一个服务去取汇率,然后做计算。最后用计算后的结果做转账。
这个需求最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有2个 Currency ,2 个 Money ,1 个 BigDecimal,总共 5 个对象。这种涉及到多个对象的业务逻辑,需要用 DP 包装掉,所以这里引出 DP 的第三个原则:

Encapsulate Multi-Object Behavior 封装 多对象 行为

在这个 case 里,可以将转换汇率的功能,封装到一个叫做 ExchangeRate的 DP 里:

@Value
public class ExchangeRate {
    
    
    private BigDecimal rate;
    private Currency from;
    private Currency to;
    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
    
    
        this.rate = rate;
        this.from = from;
        this.to = to;
    }
    public Money exchange(Money fromMoney) {
    
    
        notNull(fromMoney);
        isTrue(this.from.equals(fromMoney.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}

ExchangeRate汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    
    
    ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
    Money targetMoney = rate.exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

3.3 讨论和总结

3.3.1 Domain Primitive 的定义

Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

  • DP是一个传统意义上的Value Object,拥有Immutable的特性
  • DP是一个完整的概念整体,拥有精准定义
  • DP使用业务域中的原生语言
  • DP可以是业务域的最小组成部分、也可以构建复杂组合

注:Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 Secure by Design。

3.3.2 使用 Domain primitive 的三个原则

  • 让隐性的概念显性化
  • 让隐性的上下文显性化
  • 封装多对象行为

3.3.3 Domain primitive 和 DDD 里的 Value Object 的区别

在 DDD 中, Value Object 这个概念已经存在:

  • 在 Evans 的 DDD 蓝皮书中,Value Object 更多的是一个非 Entity 的值对象
  • 在Vernon的IDDD红皮书中,作者更多的关注了Value Object的Immutability不变性、Equals方法、Factory方法等

Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。当然同样的要求无副作用(side-effect free)。

3.3.4 Domain primitive 和 Data Transfer Object(DTO) 的区别

v2-c534beeee92e2d54c7b7d5652d04f463_1440w.jpg

3.3.5 Domain primitive 在什么情况下使用?

常见的 DP 的使用场景包括:

  • 有格式限制的 String:比如NamePhoneNumberOrderNumberZipCodeAddress
  • 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
  • 可枚举的 int:比如 Status(一般不用Enum因为反序列化问题)
  • DoubleBigDecimal:一般用到的 DoubleBigDecimal都是有业务含义的,比如 TemperatureMoneyAmountExchangeRateRating
  • 复杂的数据结构:比如 Map<String, List<Integer>>等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

四、老应用重构的流程

在新应用中使用 DP 是比较简单的,但在老应用中使用 DP 是可以遵循以下流程按部就班的升级。
下面用本文的第一个 case 为例。

4.1 创建 Domain Primitive,收集所有 DP 行为

在前文中,发现取电话号的区号这个是一个可以独立出来的、可以放入 PhoneNumber 这个 Class 的逻辑。

类似的,在真实的项目中,以前散落在各个服务或工具类里面的代码,可以都抽出来放在 DP 里,成为 DP 自己的行为或属性。
这里面的原则是:所有抽离出来的方法要做到无状态,比如原来是 static 的方法。如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP 。因为 DP 本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。
(代码参考 PhoneNumber 的代码)

4.2 替换数据校验和无状态逻辑

为了保障现有方法的兼容性,在第二步不会去修改接口的签名,而是通过代码替换原有的校验逻辑和根 DP 相关的业务逻辑。比如:

public User register(String name, String phone, String address)
        throws ValidationException {
    
    
    if (name == null || name.length() == 0) {
    
    
        throw new ValidationException("name");
    }
    if (phone == null || !isValidPhoneNumber(phone)) {
    
    
        throw new ValidationException("phone");
    }
    
    String areaCode = null;
    String[] areas = new String[]{
    
    "0571", "021", "010"};
    for (int i = 0; i < phone.length(); i++) {
    
    
        String prefix = phone.substring(0, i);
        if (Arrays.asList(areas).contains(prefix)) {
    
    
            areaCode = prefix;
            break;
        }
    }
    SalesRep rep = salesRepRepo.findRep(areaCode);
    // 其他代码...
}

通过 DP 替换代码后:

public User register(String name, String phone, String address)
        throws ValidationException {
    
    
    
    Name _name = new Name(name);
    PhoneNumber _phone = new PhoneNumber(phone);
    Address _address = new Address(address);
    
    SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
    // 其他代码...
}

通过 new PhoneNumber(phone) 这种代码,替代了原有的校验代码。
通过 _phone.getAreaCode() 替换了原有的无状态的业务逻辑。

4.3 创建新接口

public User register(Name name, PhoneNumber phone, Address address) {
    
    
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
    // ...
}

4.4 修改外部调用

外部调用方需要修改调用链路,比如:“张三”, “河北省沧州市吴桥县黄河路二号院”, “0571-12345678”

service.register("张三", "0571-12345678", "河北省沧州市吴桥县黄河路二号院");

改为:

service.register(new Name("张三"), new PhoneNumber("0571-12345678"),new Address("河北省沧州市吴桥县黄河路二号院");

五、参考

  • 《基于DDD和微服务的中台架构与实现》
  • 阿里技术专家详解 DDD 系列 第一讲- Domain Primitive

https://zhuanlan.zhihu.com/p/340911587

猜你喜欢

转载自blog.csdn.net/qq_44503377/article/details/122232992
ddd