一个 DDD 小白的取经之路 前言 为什么要使用 DDD D

前言

工作中我们经常听周围的同事谈起 DDD 这个东西,但是大部分时候,我们对于 DDD 是什么,为什么要使用 DDD ,以及如何使用 DDD 还是存在不少的疑惑。本文从一个 DDD 小白的角度,介绍一下我理解的 DDD 是什么,并且通过一个典型的案例简单实践一下 DDD。

为什么要使用 DDD

如果我们的业务非常简单,普通的 CRUD 就能满足大部分的业务需求,那我们完全不需要 DDD。然而,随着需求的不断迭代,系统的不断演化,我们面临的问题越来越复杂,业务逻辑也越来越复杂,由此产生的一个问题是,模块之间过度耦合,当修改一个功能时,往往只是回溯该功能的修改点就需要很长时间,更别提修改带来的不可预知的影响面。

下图是一个模块过度耦合的示意图:

订单服务中包含了订单接口、评价接口、支付接口和保险接口,多个不同的业务模块耦合在一起,牵一发而动全身。此外,订单表也是一个大表,包含了非常多的字段。当我们维护代码时,有可能只是想修改一下评价相关的功能,却影响到了订单相关的功能。虽然我们可以通过测试保证功能的正确性,但是,当我们在订单领域下有大量的需求并行开发时,我们很有可能就会顾此失彼,从而降低开发的效率。

为了解决模块过度耦合的问题,我们可以采用重构的方式,在保证代码行为不变的前提下,改善局部不协调的设计,提高代码的可读性、可维护性和可测试性。业务初期,功能比较简单,我们往往采用过程式开发的方式快速实现业务需求。当业务发展到一定阶段时,系统逐渐变得复杂,传统的过程式开发已经不能满足业务快速迭代的需求了,此时,我们会对系统做一定的重构,通过一些重构手法或设计模式将原有的代码组织成可读性、可维护、可测试性更强的代码,方便我们继续在原有的系统上进行快速迭代。

然而,仅仅通过重构的方式只能赋予系统一个技术上的含义,很难给它一个业务上的含义。这会带来什么问题呢?在回答这个问题之前,想一想我们平时是怎么通过代码解决业务问题的。在拿到一个业务问题之后,我们首先会将问题映射为脑海中的一个概念模型,然后在模型中解决问题,最后再将解决方案转化为代码。假设我们现在是一个团队的新同学,并没有参与到团队之前对系统的建设或者重构中来,这就导致在开发新需求时,我们并不能很自然地将一个业务问题映射为一个概念模型,相反,我们需要深入到系统的设计模型之中,梳理出与我们本次需求相关的修改点,然后再着手进行开发,这样一来就提高了系统的理解成本。此外,当我们在和业务同学聊需求的时候,也会由于双方在概念模型理解上的差异造成需求沟通上的困难。

使用 DDD 可以很好地解决概念模型到设计模型的同步和演化,同时将反映了概念模型的设计模型转换为实际的代码。

DDD 是什么

DDD(Domain-Driven Design),中文叫领域驱动设计,是一套应对复杂软件系统分析和设计的面向对象的建模方法论。

2003 年,Eric Evans 发表了一篇著作《Domain-driven Design: Tackling Complexity in the Heart of Software》,正式定义了领域的概念,开始了 DDD 的时代。2013 年,Vaughn Vernon 出版了《Implementing Domain-Driven Design》 进一步定义了 DDD 的领域方向,并且给出了很多落地指导,让 DDD 离人们又近了一步。

DDD 中包含很多概念,比如领域、限界上下文、聚合、实体、值对象等。我们之所以觉得 DDD 很难学,就是因为这些概念太抽象了,导致 DDD 在实践中很难落地。说实话,我也觉得 DDD 中的一些概念很抽象,但是,就像我们想学好编程就必须学好数据结构和算法一样,数据结构和算法也很抽象,但是,它作为计算机学科的基础知识,对于我们理解程序是如何组织和运行的很有帮助,而且其中的很多的思想也会被我们潜移默化地应用到日常的开发当中。类似地,当我们在实践 DDD 的时候,虽然不会刻意地去套领域、限界上下文这些概念,但是学习这些概念有助于我们更好地理解 DDD 的思想,从而在实践中更好地发挥 DDD 的优势,享受 DDD 给我们日常开发带来的便利。

我这里简单罗列一下我认为 DDD 中的一些比较重要的概念,关于 DDD 的更多知识大家可以阅读 Eric Evans 的 《Domain-driven Design: Tackling Complexity in the Heart of Software》或其他资料。

  • 领域(Domain) :领域就是范围,可以类比为微服务中的一个服务。领域的核心思想是将问题逐级细分来降低业务和系统的复杂度,是 DDD 的核心概念。领域又可以细分为子域、核心域、通用域、支撑域等。
  • 限界上下文(Bounded Context) :即定义上下文的边界,可以类比为微服务中服务职责划分的边界。领域存在于边界之内。对于同一个概念,不同上下文会有不同的理解,比如商品,在销售阶段叫商品,在运输阶段就叫货品。
  • 聚合(Aggregate) :聚合类似于包的概念,每个包里包含一类实体或者行为,它有助于分散系统的复杂性。
  • 实体(Entity) :《Patterns, Principles, and Practices of Domain-Driven Design》(领域驱动设计模式、原理与实践)一书中对实体的定义为:实体是具有唯一标识的身份和连续性的领域概念。从上面的定义可以看出,实体也是一种特殊的领域。这里我们需要注意两点:唯一标识的身份连续性,两者缺一不可。你可以想象,文章可以是实体,作者也可以是实体,因为它们有 id 作为唯一标识。
  • 值对象(Value Object) :为了更好地展示领域之间的关系制定的一个对象,本质上也是一种实体,但相对实体而言,它没有身份和状态,它存在的目的就是为了表示一个值。比如 money,让它具有 id 显然是不合理的,你也不可能通过 id 查询一个 money。

下图是一种常见的 DDD 设计图:

上面我们介绍了 DDD 中的一些概念 ,下面我们通过一个典型的案例,来简单介绍一下如何实践 DDD,这里主要用到了值对象的思想 。

案例分析

假设现在我们接到了产品经理的一个需求如下:

我们的应用准备在全国范围内通过业务员做推广,现在需要在应用内实现一个用户注册系统,同时希望在用户注册后能够通过用户电话号码(假设目前只有座机)的区号对业务员发放奖金。

根据上述需求,一个简单的用户注册系统的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
java复制代码public class User {
  Long userId;
  String name;
  String phone;
  String address;
  Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {
 
@Autowired
  private SalesRepRepository salesRepRepo;
 
@Autowired
  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);
 }
}

相信我们日常开发中绝大部分的代码应该都和这段代码类似,乍一看好像没什么问题,但是,当我们再深入一步,从接口的清晰度、数据校验和错误处理的方式、以及业务逻辑的清晰度这三个维度去分析一下,这段代码可能并不像我们想象地那么好用。

1. 接口的清晰度

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

1
java复制代码User register(String, String, String);

因此下面这段代码是一段编译器完全不会报错的代码,很难通过肉眼发现其中隐藏的 bug(正确的调用方法是第二个入参是电话号码,第三个入参是地址):

1
java复制代码service.register("zhaoqiang05", "浙江省杭州市", "0571-12345678");

当然,上面的代码在运行时还是会报错,但是,这种 bug 是在运行时被发现的,而不是在编译时,即使通过 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。那么,有没有办法让方法入参一目了然,避免入参错误导致出现 bug?

2. 数据校验和错误处理的的方式

这是代码中的数据校验部分:

1
2
3
java复制代码if (phone == null || !isValidPhoneNumber(phone)) {
  throw new ValidationException("phone");
}

类似的代码在我们的日常编码中也会经常出现,一般来说,这种代码需要出现在方法的最前端,确保能够 fail-fast(快速失败)。但是假设你有多个类似的接口和类似的入参,那么在每个方法里都要加入类似的校验逻辑,造成代码的重复。更严重的是,如果未来我们要扩展用户电话号码包含手机时,很可能需要加入以下代码:

1
2
3
java复制代码if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
  throw new ValidationException("phone");
}

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

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

1
2
3
4
5
java复制代码if (phone == null) {
  throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
  throw new ValidationException("phone格式错误");
}

能够想像得到,代码里充斥着大量类似的代码时,维护成本要有多高。

此外,这个业务方法会抛 ValidationException,所以需要外部去捕获这个异常,造成业务逻辑异常和数据校验异常被混在了一起,这样是否是合理的呢?

对于数据校验问题,传统的 Java 架构有一些方法可以解决一部分问题,比如 Validator 或编写一个静态工具类 ValidationUtils,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// 使用 Validator
public User registerWithBeanValidation(
 @NotNull @NotBlank String name,
 @NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\d{8}$") String phone,
 @NotNull String address) {
...
}

// 使用 ValidationUtils
public User registerWithUtils(String name, String phone, String address) {
  ValidationUtils.validateName(name);
  ValidationUtils.validatePhone(phone);
  ValidationUtils.validateAddress(address);
 ...
}

但这几个方法同样有问题:

  • Validator:
    • 通常只能解决简单的逻辑校验,复杂的校验逻辑一样要写代码实现定制的校验器
    • 在添加了新校验逻辑时,同样会出现在某些地方忘记添加注解,导致 DRY 原则被违背
  • ValidationUtils 类:
    • 当大量的校验逻辑集中在一个类里之后,违背了单一性原则,导致代码混乱和难以维护
    • 业务逻辑异常和数据校验异常还是会混杂在一起

那么,有没有一种方法能够一劳永逸的解决所有的校验问题,降低后续的维护成本呢?

3. 业务逻辑的清晰度

再看获取 SalesRep 的这段代码:

1
2
3
4
5
6
7
8
9
10
java复制代码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);

这段代码做的一件事情就是:首先从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,最后从新的数据中再抽取部分数据用作他用。我们一般称这种代码为胶水代码,其本质是由于外部服务的入参并不符合我们原始的入参导致的。比如,如果 SalesRepRepository 包含一个 findRepByPhone 方法,那么上面大部分的代码都不需要了。

解决这种胶水代码的一个常见的做法是,利用重构中抽取函数的方式将这段代码抽取出来,变成独立的一个或多个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码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);
}

然后原始代码变为:

1
2
java复制代码String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

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

解决方案

现在,让我们重新看一下这个需求,并且标注其中可能重要的概念:

我们的应用准备在全国范围内通过业务员做推广,现在需要在应用内实现一个用户注册系统,同时希望在用户注册后能够通过用户电话号码(假设目前只有座机)的区号对业务员发放奖金。

分析上述需求后我们发现,业务员、用户带有唯一身份标识 ID,属于实体(Entity),而注册系统属于应用服务(Application Service),但是电话号码这个概念却完全被隐藏到了代码之中。我们可以问一下自己,取电话号码的区号的逻辑是否属于用户?是否属于注册服务?如果都不是很贴切,那就说明这个逻辑是一个独立的概念。实际上,电话号码的区号是包含业务逻辑的,我们可以通过写一个值对象(Value Object) 将电话号码的概念显性化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
java复制代码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);
 }

}

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

  • 通过 private final String number 确保 PhoneNumber 是一个不可变的值对象。
  • 校验逻辑都放在了构造函数里面,确保只要 PhoneNumber 的对象被创建出来后,一定是校验通过的。
  • 之前的 findAreaCode 方法变成了 PhoneNumber 类里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一个计算属性。

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

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

我们看一下全面使用了值对象之后的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public class User {
  UserId userId;
  Name name;
  PhoneNumber phone;
  Address address;
  RepId repId;
}

// 注意这里 register 方法的三个入参均改为了值对象
public User register(
 @NotNull Name name,
 @NotNull PhoneNumber phone,
 @NotNull Address address
) {
  // 找到区域内的 SalesRep
  SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

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

  return userRepo.saveUser(user);
}

我们可以看到,在使用了值对象之后,所有的数据校验逻辑和非核心的业务逻辑都消失了,剩下都是核心的业务逻辑。我们重新用上面的三个维度来评估一下现在的代码:

1. 接口的清晰度

重构后的方法签名变得很清晰:

1
java复制代码public User register(Name, PhoneNumber, Address)

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

1
java复制代码service.register(new Name("zhaoqiang05"), new Address("浙江省杭州市"), new PhoneNumber("0571-12345678"));

可以看到无论是入参的类型还是值都一目了然。

2. 数据校验和错误处理的方式

1
2
3
4
5
java复制代码public User register(
 @NotNull Name name,
 @NotNull PhoneNumber phone,
 @NotNull Address address
) // No throws

如代码所示,重构后的方法中完全没有了任何数据校验的逻辑,也不会抛 ValidationException 。原因是,因为值对象的特性,只要是能够带到入参里的一定是正确的。我们把数据校验的工作前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。

此外,使用值对象的另外一个好处就是,代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber 的校验逻辑,只需要在一个文件里修改即可,所有使用到 PhoneNumber 的地方都会生效。

3. 业务代码的清晰度

1
2
3
java复制代码SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

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

一些讨论

1. 值对象和 DTO(Data Transfer Object)的区别

在日常开发中经常会碰到的另外一个数据结构是 DTO,比如方法的入参和出参。值对象和 DTO 的主要区别如下:

值对象DTO功能表示特定业务领域的概念数据传输和数据的关联数据之间具有高相关性数据之间不一定有关联,只是一堆数据放在一起行为丰富的行为和业务逻辑无行为

2. 什么情况下应该使用值对象

常见的使用值对象的场景包括:

  • 有格式限制的 String:比如 Name,PhoneNumber,OrderNumber,ZipCode,Address 等
  • 有限制的 Integer:比如OrderId(> 0),Percentage(0-100%),Quantity(>= 0)等
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map<String, List<Integer>> 等,尽量把 Map 的所有操作包装掉,仅暴露必要行为

总结

本文先介绍了一些 DDD 相关的背景和概念,然后通过一个案例分析了如何在代码中实践 DDD 中值对象的思想以及带来的好处,最后讨论了值对象和 DTO 的一些区别和值对象的适用场景。

值对象只是 DDD 思想的冰山一角,本文作为 DDD 小白也还只是在 DDD 的大门前徘徊,不过确实能感受到 DDD 带给业务开发的便利。希望未来可以继续深入学习下 DDD,并有机会参与 DDD 相关的实践和落地,早日踏进 DDD 的大门。

参考

  1. Domain-driven Design: Tackling Complexity in the Heart of Software
  2. Implementing Domain-Driven Design
  3. 阿里技术专家详解 DDD 系列- Domain Primitive
  4. 领域驱动设计在互联网业务开发中的实践
  5. 领域驱动设计—概念篇
  6. DDD 模式从天书到实践

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%