领域驱动设计简称DDD,为了应对复杂程序设计而提出的设计思想
平时经常写的代码都是贫血模型,就是new一个对象,然后一大堆set方法,这种模型是以数据库为核心进行程序设计,通常是先创建表,再生成相应的Java代码,前期实现起来很简单,但是后期程序代码就会很难维护,根本的原因是界限没有很好的划分,可以在任何地方new出实体类并set字段的值,当业务发生变化从而调整了表的字段,那时就很容易出现bug
DDD则要求在开发之前先钻研该领域以获取更详细的业务知识,划分界限上下文,提炼出聚合根,开发领域服务。通常做项目是不可能有这么多时间精力去研究项目所在领域的,实现起来也很难,不过后期的维护就比贫血模型容易了
本文介绍DDD中值对象(Value Object)的概念和应用,以及MapStruct如何映射嵌套的值对象
为什么要使用DDD值对象
- 将对值的校验全部放到值对象内部去处理,得到的值对象一定是合法的或者是null,而对于null的校验通过注解就可以,这样不需要在各个地方编写重复的校验
- 使方法的入参更明确,比如定义一个方法有三个String类型的入参,调用方无法判断这三个类型分别该传入什么数据,如果入参顺序写错了又可能引起bug,如方法定义为:
void test(String email, String mobileNumber);
,调用方将email和mobileNumber写反了,这就引起了bug,举的这个例子可以通过参数的名称来理解意思,但是如果参数名叫s1和s2呢 - 值对象发生修改时,不会对外部产生太大影响,比如在值对象内部调整了校验规则,外部是不受任何影响的
应用场景
- 有限制的数据,如格式限制(邮箱、身份证号)和范围限制(评价星星只能是1-5的整数)
- 枚举,如各种状态
- 需要多个组合才有意义的数据,如转账金额就必须要指明币种和金额,单独其中一个是没有意义的
- 复杂的数据结构,通过值对象封装起来,只对外提供必要的行为(方法)
如何定义值对象
注意:不允许提供setter方法,可以提供getter方法
下面以两个例子来演示,平时可以收集整理更多通用的值对象定义,比如Email、手机号码和省市区编码等等
金额
@Getter
@ToString
public class Money {
/**
* 币种,如CNY、USD等
*/
private final String currency;
/**
* 金额
*/
private final BigDecimal amount;
/**
* 在构造函数中进行数据校验,注意:自己写了一个构造函数,编译时就不会自动创建一个无参构造函数了
*
* @param currency 币种
* @param amount 金额
*/
public Money(String currency, BigDecimal amount) {
Assert.isEmpty(currency, "币种不能为空");
// 可继续验证币种是否支持...
// 可限定金额不能小于0,根据具体业务要求
Assert.isTrue(BigDecimal.ZERO.compareTo(amount) > 0, "金额不能小于0");
this.currency = currency;
this.amount = amount;
}
/**
* 提供一个静态方法来方便调用方创建值对象
*
* @param currency 币种
* @param amount 金额
* @return Money
*/
public static Money of(String currency, BigDecimal amount) {
return new Money(currency, amount);
}
}
复制代码
评价分数
常见的评价时选择1-5星星进行打分
@Getter
public class ReviewScore {
private final Integer score;
public static final byte SCORE_MIN = 1;
public static final byte SCORE_MAX = 5;
public static final String BAD = "差评";
public static final String NORMAL = "不算好也不坏";
public static final String GOOD = "好评";
/**
* 构造函数验证分数是否合法,同时添加@JsonCreator注解使jackson序列化时不要使用无参构造
*
* @param score
*/
@JsonCreator
public ReviewScore(Integer score) {
Assert.isNull(score, "评价分数错误");
Assert.isTrue(score < SCORE_MIN || score > SCORE_MAX, "评价分数错误");
this.score = score;
}
public static ReviewScore of(Integer score) {
return new ReviewScore(score);
}
/**
* 获取评分等级,如好评和差评
*
* @return
*/
public String getScoreLevel() {
if (score == SCORE_MIN) {
return BAD;
} else if (score < SCORE_MAX) {
return NORMAL;
} else {
return GOOD;
}
}
}
复制代码
值对象使用
以Controller层接收请求参数为例,先定义一个转账请求的QO(Query Object)
@Data
public class TransferMoneyQo {
private String receiveUserName;
private Money money;
}
复制代码
再定义一个controller
@RestController
@RequestMapping("/user/transfer")
public class UserTransferController {
@PostMapping("/money")
public void money(@RequestBody TransferMoneyQo qo) {
// 打印qo查看是否正确
System.out.println(qo);
}
}
复制代码
统一异常处理,我们的Assert断言抛出的是自定义Runtime
异常CommonException
,所以下面getRootMessage
方法会进行判断
@Slf4j
@RestControllerAdvice
public class BaseExceptionHandler {
@ExceptionHandler(Exception.class)
public Result handleException(Exception e) {
log.error("发生异常", e);
return Result.error(StringUtils.isEmpty(e.getMessage()) ? Lang.message("请求异常") : e.getMessage());
}
@ExceptionHandler(CommonException.class)
public Result handleCommonException(CommonException e) {
log.debug("发生异常", e);
return Result.error(StringUtils.isEmpty(e.getMessage()) ? Lang.message("请求异常") : e.getMessage());
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public Result handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
log.debug("发生异常", e);
return Result.error(getRootMessage(e));
}
private String getRootMessage(Throwable e) {
while (e.getCause() != null) {
e = e.getCause();
}
return e instanceof CommonException ? e.getMessage() : "请求异常";
}
}
复制代码
上面的程序运行后,如果没有传入currency
或amount
就会抛出异常,由统一异常处理后返回给前端
值对象的转换
使用MapStruct来实现,注意版本是1.4.2.Final,太旧的版本方法三会报错
@Mapper
public interface TransferMoneyConverter {
TransferMoneyConverter INSTANCE = Mappers.getMapper(TransferMoneyConverter.class);
/**
* 方法一:指定Mapping,如果不想自己指定,可看下面的方法二
* 从QO转成实体类
* @param qo
* @return
*/
@Mapping(target = "currency", source = "money.currency")
@Mapping(target = "amount", source = "money.amount")
TransferMoneyEntity fromQo(TransferMoneyQo qo);
/**
* 方法二:通过MappingTarget指定已存在的实体对象,如果不想这样可看下面的方法三
* 映射到已经存在的实体类
* @param money
* @param entity
* @return
*/
TransferMoneyEntity fromMoney(Money money, @MappingTarget TransferMoneyEntity entity);
/**
* 方法三:指定Mapping,将qo.getMoney()这个对象的属性全部映射到实体类中
* 从QO转成实体类
* @param qo
* @return
*/
@Mapping(target = ".", source = "money")
TransferMoneyEntity fromQo2(TransferMoneyQo qo);
/**
* 方法一:指定Mapping,通过default方法实现
* 从实体类转为DTO
* @param qo
* @return
*/
@Mapping(target = "money", expression = "java(toMoney(entity))")
TransferMoneyDto toDto(TransferMoneyEntity entity);
/**
* 方法二:指定Mapping,将entity中同名属性映射到DTO中money这个值对象
* 从实体类转为DTO
* @param qo
* @return
*/
@Mapping(target = "money", source = ".")
TransferMoneyDto toDto2(TransferMoneyEntity entity);
default Money toMoney(TransferMoneyEntity entity) {
return Money.of(entity.getCurrency(), entity.getAmount());
}
}
复制代码
在需要的地方调用converter就可以转为实体类
TransferMoneyEntity entity = TransferMoneyConverter.INSTANCE.fromQo(qo);
System.out.println(entity);
复制代码
这里只是演示一下转换的方式
值对象持久化
JPA
可以在值对象类上添加@Embeddable
和实体类属性上添加@Embedded
实现值对象属性与表字段的映射
Mybatis
通过MapStruct将值对象与实体类的字段一一对应起来,由Dao层进行持久化
建议
最后建议ID也定义为对应的值对象,如UserId
和OrderId
等,优点如下:
-
比用Long要更明确意思,可以对比
(Long userId, Long orderId)
和(UserId userId, OrderId orderId)
-
有时候ID不是一个字段,可能是多个字段组合的,就是说用一个字段无法唯一的确定一条记录
-
ID的规则验证或生成ID的规则不同,如有些ID是字符串,有些ID是雪花算法生成的