DDD值对象的应用和MapStruct映射嵌套值对象

领域驱动设计简称DDD,为了应对复杂程序设计而提出的设计思想

平时经常写的代码都是贫血模型,就是new一个对象,然后一大堆set方法,这种模型是以数据库为核心进行程序设计,通常是先创建表,再生成相应的Java代码,前期实现起来很简单,但是后期程序代码就会很难维护,根本的原因是界限没有很好的划分,可以在任何地方new出实体类并set字段的值,当业务发生变化从而调整了表的字段,那时就很容易出现bug

DDD则要求在开发之前先钻研该领域以获取更详细的业务知识,划分界限上下文,提炼出聚合根,开发领域服务。通常做项目是不可能有这么多时间精力去研究项目所在领域的,实现起来也很难,不过后期的维护就比贫血模型容易了

本文介绍DDD中值对象(Value Object)的概念和应用,以及MapStruct如何映射嵌套的值对象

为什么要使用DDD值对象

  1. 将对值的校验全部放到值对象内部去处理,得到的值对象一定是合法的或者是null,而对于null的校验通过注解就可以,这样不需要在各个地方编写重复的校验
  2. 使方法的入参更明确,比如定义一个方法有三个String类型的入参,调用方无法判断这三个类型分别该传入什么数据,如果入参顺序写错了又可能引起bug,如方法定义为:void test(String email, String mobileNumber);,调用方将email和mobileNumber写反了,这就引起了bug,举的这个例子可以通过参数的名称来理解意思,但是如果参数名叫s1和s2呢
  3. 值对象发生修改时,不会对外部产生太大影响,比如在值对象内部调整了校验规则,外部是不受任何影响的

应用场景

  1. 有限制的数据,如格式限制(邮箱、身份证号)和范围限制(评价星星只能是1-5的整数)
  2. 枚举,如各种状态
  3. 需要多个组合才有意义的数据,如转账金额就必须要指明币种和金额,单独其中一个是没有意义的
  4. 复杂的数据结构,通过值对象封装起来,只对外提供必要的行为(方法)

如何定义值对象

注意:不允许提供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() : "请求异常";
    }
}
复制代码

上面的程序运行后,如果没有传入currencyamount就会抛出异常,由统一异常处理后返回给前端

值对象的转换

使用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也定义为对应的值对象,如UserIdOrderId等,优点如下:

  1. 比用Long要更明确意思,可以对比(Long userId, Long orderId)(UserId userId, OrderId orderId)

  2. 有时候ID不是一个字段,可能是多个字段组合的,就是说用一个字段无法唯一的确定一条记录

  3. ID的规则验证或生成ID的规则不同,如有些ID是字符串,有些ID是雪花算法生成的