一、前言
最近对团队的很多同学代码进行了 Code Review ,发现存在很多问题。
其中一个问题就是普遍代码内聚不够,将原本需要对象提供的方法外泄给使用者。
如一个对象里包括状态字段,使用方需要根据状态判断是否为成功:
public class SomeResult{
// 值 为 0 表示成功
private String status;
// 标签,其中 HOT 表示热门
private String tage;
private Map<String, Object> extInfo;
// 省略其他
}
使用方:
if("0".equals(result.getStatus()) && "HOT".equals(result.getTag())){
// 执行某段逻辑
}
Map<String, Object> extInfo = result.getExtraInfo();
extInfo.put("xxx", YYY);
本质上使用方只需要让 result 对象 “告诉” 自己是否成功、是否是热门素材即可。
但由于 Result 对象只有属性和 Getter 和 Setter 方法,没有其他属性,这部分逻辑就需要外部去感知。
本质上是因为我们写惯了 贫血模型 代码的缘故,即只为对象定义属性、赋值和取值方法,将业务逻辑统一放到 Service 层来处理。
更多地是面向步骤编程,而不是面向业务编程。
<br/>
二、存在的问题
<br/>
2.1 和软件设计原则违背
这违背 软件工程领域 “高内聚、弱耦合” 的设计原则。
同样也违反设计模式中的: 迪米特法则。
迪米特法则(Law of Demeter)又叫作最少知识原则(The Least Knowledge Principle),一个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话。英文简写为: LOD。
更违背 “封装复杂度” 的类设计原则。
<br/>
2.2 不必要的魔法值
类似的场景经常需要外部去感知具体的状态码、错误码,需要通过各种状态码去判断,然后再执行对应的逻辑。
即使接口提供方给出了枚举,上游也感知到不必要的逻辑。
接口提供方如果没有给出枚举,使用方还需要自己去定义常量或者枚举。
然而,大多数同学是喜欢偷懒的,通常直接用魔法值去判断,造成可读性极差。
阅读代码人压根不知道各种数字或者字符串代表什么含义。
<br/>
三、解决之道
3.1 充血模型
对象不仅包含数据,还包含属于它自己的操作。
想了解更全面的内容需要系统学习 DDD 相关知识。
推荐一些读物:
但是现在很多团队的很多项目并没有采用领域驱动设计的思想和架构进行开发。
但,这并不妨碍我们采用类似领域驱动设计中的一些理念去设计类。
我们可以了解下 Domain Primitive ,将完全贫血的类设计为 Domain Primitive 甚至设计为一个 DDD 中的实体和聚合根等。
3.2 Domain Primitive
3.2 部分转载自《阿里技术专家详解 DDD 系列- Domain Primitive》
3.2.1 Domain Primitive 定义
Domain Primitive (简称 DP)是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。
- DP 是一个传统意义上的 Value Object,拥有 Immutable 的特性
- DP 是一个完整的概念整体,拥有精准定义
- DP 使用业务域中的原生语言
- DP 可以是业务域的最小组成部分、也可以构建复杂组合
注:Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 《Secure by Design》。
3.2.2 使用 Domain Primitive 的三个原则
- 让隐性的概念显性化
- 让隐性的上下文显性化
- 封装多对象行为
3.2.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.2.4 Domain Primitive 和 Data Transfer Object (DTO) 的区别
3.2.5 Domain Primitive 的使用场景
常见的 DP 的使用场景包括:
- 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
- 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
- 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
- Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
- 复杂的数据结构:比如 Map<String, List> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为。
<br/>
3.2.6 示例
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);
}
}
<br/>
3.3 简化案例
开头的案例就可以进行优化,将复杂度封装在对象内部,方便上游使用。
public class SomeResult{
// 值 为 0 表示成功
private String status;
private TagEnum tag;
// 省略其他
public boolean isSuccess(){
return StatusEnum.SUCCESS.getCode().equals(status);
}
public boolean isHot(){
return TagEnum.HOT == tag && isSuccess();
}
public void setXXX(XXX xxx){
extInfo.put(XXKeyConstant.XXX, xxx);
}
public void getXXX(){
return extInfo.get(XXKeyConstant.XXX);
}
当然,这个示例非常简单,只是希望帮助大家理解这种理念,实际开发中遇到的场景可能会复杂的多,需要封装的方法也可能会很多。
实践中大家可以将:
- 参数的合法性校验
- 业务状态判断
- 该类属性相关的部分处理方法
- 需要获取该对象的内部属性再进行的操作
- ...
都封装到该对象中,降低耦合,封装复杂度。
<br/>
四、总结
大多数程序员,写惯了贫血模型代码,不愿意去学习领域驱动设计的理念和实践。
很多程序员,潜意识认为团队项目没有明确使用领域驱动设计的思想和框架,就不应该或者不需要编写充血模型代码。
这个世界并非非黑即白的,即使团队中并没有明确使用领域驱动设计,甚至贫血模型满天飞,我们一样可以将设计原则渗透到编码中。
<br/>