导读:对于一个架构师来讲,在软件开发中如何下降系统复杂度是一个永恒的挑战,不管是 94 年 GoF 的 Design Patterns , 99 年的 Martin Fowler 的 Refactoring , 02 年的 P of EAA ,仍是 03 年的 Enterprise Integration Patterns ,都是经过一系列的设计模式或范例来下降一些常见的复杂度。可是问题在于,这些书的理念是经过技术手段解决技术问题,但并无从根本上解决业务的问题。因此 03 年 Eric Evans 的 Domain Driven Design 一书,以及后续 Vaughn Vernon 的 Implementing DDD , Uncle Bob 的 Clean Architecture 等书,真正的从业务的角度出发,为全世界绝大部分作纯业务的开发提供了一整套的架构思路。
因为 DDD 不是一套框架,而是一种架构思想,因此在代码层面缺少了足够的约束,致使 DDD 在实际应用中上手门槛很高,甚至能够说绝大部分人都对 DDD 的理解有所误差。举个例子, Martin Fowler 在他我的博客里描述的一个 Anti-pattern,Anemic Domain Model (贫血域模型)在实际应用当中层出不穷,而一些仍然火热的 ORM 工具好比 Hibernate,Entity Framework 实际上滋长了贫血模型的扩散。一样的,传统的基于数据库技术以及 MVC 的四层应用架构(UI、Business、Data Access、Database),在必定程度上和 DDD 的一些概念混淆,致使绝大部分人在实际应用当中仅仅用到了 DDD 的建模的思想,而其对于整个架构体系的思想没法落地。html
我第一次接触 DDD 应该是 2012 年,当时除了大型互联网公司,基本上商业应用都还处于单机的时代,服务化的架构还局限于单机 +LB 用 MVC 提供 Rest 接口供外部调用,或者用 SOAP 或 WebServices 作 RPC 调用,但其实更多局限于对外部依赖的协议。让我关注到 DDD 思想的是一个叫 Anti-Corruption Layer(防腐层)的概念,特别是其在解决外部依赖频繁变动的状况下,如何将核心业务逻辑和外部依赖隔离的机制。到了 2014 年, SOA 开始大行其道,微服务的概念开始冒头,而如何将一个 Monolith 应用合理的拆分为多个微服务成为了各大论坛的热门话题,而 DDD 里面的 Bounded Context(限界上下文)的思想为微服务拆分提供了一套合理的框架。而在今天,在一个全部的东西都能被称之为“服务”的时代(XAAS), DDD 的思想让咱们能冷静下来,去思考到底哪些东西能够被服务化拆分,哪些逻辑须要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。前端
因此今天,我开始这个关于 DDD 的一系列文章,但愿能继续在总结前人的基础上发扬光大 DDD 的思想,可是经过一套我认为合理的代码结构、框架和约束,来下降 DDD 的实践门槛,提高代码质量、可测试性、安全性、健壮性。数据库
将来会覆盖的内容包括:编程
今天先给你们带来一篇最基础,但极其有价值的Domain Primitive的概念。设计模式
就好像在学任何语言时首先须要了解的是基础数据类型同样,在全面了解 DDD 以前,首先给你们介绍一个最基础的概念: Domain Primitive(DP)。安全
Primitive 的定义是:数据结构
不从任何其余事物发展而来
初级的造成或生长的早期阶段
就好像 Integer、String 是全部编程语言的Primitive同样,在 DDD 里, DP 能够说是一切模型、方法、架构的基础,而就像 Integer、String 同样, DP 又是无所不在的。因此,第一讲会对 DP 作一个全面的介绍和分析,但咱们先不去讲概念,而是从案例入手,看看为何 DP 是一个强大的概念。架构
咱们先看一个简单的例子,这个 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); } }
咱们平常绝大部分代码和模型其实都跟这个是相似的,乍一看貌似没啥问题,但咱们再深刻一步,从如下四个维度去分析一下:接口的清晰度(可阅读性)、数据验证和错误处理、业务逻辑代码的清晰度、和可测试性。
▍问题1 - 接口的清晰度
在Java代码中,对于一个方法来讲全部的参数名在编译时丢失,留下的仅仅是一个参数类型的列表,因此咱们从新看一下以上的接口定义,其实在运行时仅仅是:
User register(String, String, String);
因此如下的代码是一段编译器彻底不会报错的,很难经过看代码就能发现的 bug :
service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");
固然,在真实代码中运行时会报错,但这种 bug 是在运行时被发现的,而不是在编译时。普通的 Code Review 也很难发现这种问题,颇有多是代码上线后才会被暴露出来。这里的思考是,有没有办法在编码时就避免这种可能会出现的问题?
另一种常见的,特别是在查询服务中容易出现的例子以下:
User findByName(String name); User findByPhone(String phone); User findByNameAndPhone(String name, String phone);
在这个场景下,因为入参都是 String 类型,不得不在方法名上面加上 ByXXX 来区分,而 findByNameAndPhone 一样也会陷入前面的入参顺序错误的问题,并且和前面的入参不一样,这里参数顺序若是输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。这里的思考是,有没有办法让方法入参一目了然,避免入参错误致使的 bug ?
▍问题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 原则被违背时常常会发生的问题。
若是有个新的需求,须要把入参错误的缘由返回,那么这段代码就变得更加复杂:
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:
ValidationUtils类:
因此,有没有一种方法,可以一劳永逸的解决全部校验的问题以及下降后续的维护成本和异常处理成本呢?
▍问题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);
实际上出现了另一种常见的状况,那就是从一些入参里抽取一部分数据,而后调用一个外部依赖获取更多的数据,而后一般重新的数据中再抽取部分数据用做其余的做用。这种代码一般被称做“胶水代码”,其本质是因为外部依赖的服务的入参并不符合咱们原始的入参致使的。好比,若是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 。可是这里要思考的是,静态工具类是不是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?
▍问题4 - 可测试性
为了保证代码质量,每一个方法里的每一个入参的每一个可能出现的条件都要有 TC 覆盖(假设咱们先不去测试内部业务逻辑),因此在咱们这个方法里须要如下的 TC :
假如一个方法有 N 个参数,每一个参数有 M 个校验逻辑,至少要有 N * M 个 TC 。
若是这时候在该方法中加入一个新的入参字段 fax ,即便 fax 和 phone 的校验逻辑彻底一致,为了保证 TC 覆盖率,也同样须要 M 个新的 TC 。
而假设有 P 个方法中都用到了 phone 这个字段,这 P 个方法都须要对该字段进行测试,也就是说总体须要:
P N M
个测试用例才能彻底覆盖全部数据验证的问题,在平常项目中,这个测试的成本很是之高,致使大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。
在这个状况下,下降测试成本 == 提高代码质量,如何可以下降测试的成本呢?
咱们回头先从新看一下原始的 use case,而且标注其中可能重要的概念:
一个新应用在全国经过 地推业务员 作推广,须要作一个用户的注册系统,在用户注册后可以经过用户电话号的区号对业务员发奖金。
在分析了 use case 后,发现其中地推业务员、用户自己自带 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); } }
这里面有几个很重要的元素:
经过 private final String number 确保 PhoneNumber 是一个(Immutable)Value Object。(通常来讲 VO 都是 Immutable 的,这里只是重点强调一下)
校验逻辑都放在了 constructor 里面,确保只要 PhoneNumber 类被建立出来后,必定是校验经过的。
以前的 findAreaCode 方法变成了 PhoneNumber 类里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一个计算属性。
这样作完以后,咱们发现把 PhoneNumber 显性化以后,实际上是生成了一个 Type(数据类型)和一个 Class(类):
这两个概念加起来,构形成了本文标题的 Domain Primitive(DP)。
咱们看一下全面使用了 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("浙江省杭州市余杭区文三西路969号"), 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 后,变成了可复用、可测试的代码。咱们能看到,在刨除了数据验证代码、胶水代码以后,剩下的都是核心业务逻辑。( Entity 相关的重构在后面文章会谈到,此次先忽略)
▍评估4 - 可测试性
当咱们将 PhoneNumber 抽取出来以后,在来看测试的 TC :
因此,单个方法的 TC 从原来的 N * M 变成了今天的 N + M 。一样的,多个方法的 TC 数量变成了
N + M + P
这个数量通常来讲要远低于原来的数量 N M P ,让测试成本极大的下降。
▍评估总结
在上文我介绍了 DP 的第一个原则:将隐性的概念显性化。在这里我将介绍 DP 的另外两个原则,用一个新的案例。
▍案例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。
▍案例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); } }
在这个case里,因为 targetCurrency 不必定和 money 的 Curreny 一致,须要调用一个服务去取汇率,而后作计算。最后用计算后的结果作转帐。
这个case最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有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); }
▍Domain Primitive 的定义
让咱们从新来定义一下 Domain Primitive :Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。
注:Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 Secure by Design。
▍使用 Domain Primitive 的三原则
▍Domain Primitive 和 DDD 里 Value Object 的区别
在 DDD 中, Value Object 这个概念其实已经存在:
Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每一个 DP 拥有概念的总体,而不只仅是值对象。在 VO 的 Immutable 基础上增长了 Validity 和行为。固然一样的要求无反作用(side-effect free)。
▍Domain Primitive 和 Data Transfer Object (DTO) 的区别
在平常开发中常常会碰到的另外一个数据结构是 DTO ,好比方法的入参和出参。DP 和 DTO 的区别以下:
▍什么状况下应该用 Domain Primitive
常见的 DP 的使用场景包括:
在新应用中使用 DP 是比较简单的,但在老应用中使用 DP 是能够遵循如下流程循序渐进的升级。在此用本文的第一个 case 为例。
▍第一步 - 建立 Domain Primitive,收集全部 DP 行为
在前文中,咱们发现取电话号的区号这个是一个能够独立出来的、能够放入 PhoneNumber 这个 Class 的逻辑。相似的,在真实的项目中,之前散落在各个服务或工具类里面的代码,能够都抽出来放在 DP 里,成为 DP 本身的行为或属性。这里面的原则是:全部抽离出来的方法要作到无状态,好比原来是 static 的方法。若是原来的方法有状态变动,须要将改变状态的部分和不改状态的部分分离,而后将无状态的部分融入 DP 。由于 DP 自己不能带状态,因此一切须要改变状态的代码都不属于 DP 的范畴。
(代码参考 PhoneNumber 的代码,这里再也不重复)
▍第二步 - 替换数据校验和无状态逻辑
为了保障现有方法的兼容性,在第二步不会去修改接口的签名,而是经过代码替换原有的校验逻辑和根 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() 替换了原有的无状态的业务逻辑。
▍第三步 - 建立新接口
建立新接口,将DP的代码提高到接口参数层:
public User register(Name name, PhoneNumber phone, Address address) { SalesRep rep = salesRepRepo.findRep(phone.getAreaCode()); }
▍第四步 - 修改外部调用
外部调用方须要修改调用链路,好比:
service.register("殷浩", "0571-12345678", "浙江省杭州市余杭区文三西路969号");
改成:
service.register(new Name("殷浩"), new PhoneNumber("0571-12345678"), new Address("浙江省杭州市余杭区文三西路969号"));
经过以上 4 步,就能让你的代码变得更加简洁、优雅、健壮、安全。你还在等什么?今天就去尝试吧!
本文为云栖社区原创内容,未经容许不得转载。