导航

Spring Boot+JPA实现DDD(四)

Posted on 2020-08-18 15:18  ahau10  阅读(1834)  评论(0编辑  收藏  举报

优化Entity,类型改为值对象

前面我们已经定义了2个聚合根,定义了2个聚合根之间的关系,并且自动生成了表结构。
在实现具体的业务前,优化一下我们的Entity。

@Column(name = "product_no", length = 32, nullable = false, unique = true)
private String productNo;
@Column(name = "name", length = 64, nullable = false)
private String name;
@Column(name = "price", precision = 10, scale = 2)
private BigDecimal price;
@Column(name = "category_id", nullable = false)
private Integer categoryId;
@Column(name = "product_status", nullable = false)
private Integer productStatus;

咦?是不是有点眼熟?跟之前三层架构写的entity类有啥区别?没有区别,因为都是一些简单的字段跟DB对应一下就完事了。
这正是我们需要优化的地方,在实现DDD的时候我们应该尽量多使用值对象

  • 比如productNo这个字段,生成商品码这个方法放在哪里比较合适?放在Product里?
  • 比如price这个字段,假如我们希望加一个币种字段怎么办? 直接再加一个@Column
  • 比如productStatus这个字段,它应该是一个枚举对不对?定义成Integer类型我们看代码根本就不知道这个数字代表什么对不对?

把它们定义成值对象问题就迎刃而解了。解决问题的同时还收获了额外的好处:
我们的代码更加OO(面向对象)了。Entity类不再是一个简单的ORM类了,它是一个真正的模型对象了。

生成商品编码的方法放在ProductNumber里再适合不过了。
①新建domain.model.product.ProductNumber

@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ProductNumber implements Serializable {
    private String value;

    public static ProductNumber of(Integer categoryId) {
        checkArgument(categoryId != null, "商品类目不能为空");
        checkArgument(categoryId > 0, "商品类目id不能小于0");
        return new ProductNumber(generateProductNo(categoryId));
    }

    public static ProductNumber of(String value) {
        checkArgument(!StringUtils.isEmpty(value), "商品编码不能为空");
        return new ProductNumber(value);
    }

    private static String generateProductNo(Integer categoryId) {
        String prefix = "PRODUCT";
        String typeStr = String.format("%04d", categoryId);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS");
        String currentTime = sdf.format(new Date());
        int randomNum = (int) (Math.random() * 9999 + 1);
        String randomNumStr = String.format("%04d", randomNum);
        return prefix + typeStr + currentTime + randomNumStr;
    }
}

四个注意点(非常重要):

  • 商品编码是业务主键,它应该是用户可读的,并且本身包含了一些有用信息。
    我们定义商品码的生成规则为:PRODUCT + 4位类目 + 当前时间 + 4位随机数 共32位。

  • 检查参数的时候,我们全部使用guava包的checkArgument方法,而不是checkNotNull方法。因为我们这是业务代码,不能把空指针异常返回给客户端。
    我们要提供用户可读的错误信息。

  • 值对象是不可修改的,是不可修改的,是不可修改的。只提供getter就行了

  • 值对象的equalshashCode方法,与实体有唯一标识不同,值对象没有唯一标识,两个值对象所有的属性值相等才能判定相等。

然后将private String productNo; 替换成 private ProductNumber productNo;

②新建domain.model.product.ProductStatusEnum:

@AllArgsConstructor
public enum ProductStatusEnum {
    // 新建
    DRAFTED(1000111, "草稿"),
    // 待审核
    AUDIT_PENDING(1000112, "待审核"),
    // 已上架
    LISTED(1000113, "已上架"),
    // 已下架
    UNLISTED(1000114, "已下架"),
    // 已失效
    EXPIRED(1000115, "已失效");

    @Getter
    // @JsonValue
    private Integer code;

    @Getter
    private String remark;

    public static ProductStatusEnum of(Integer code) {
        ProductStatusEnum[] values = ProductStatusEnum.values();
        for (ProductStatusEnum val : values) {
            if (val.getCode().equals(code)) {
                return val;
            }
        }
        // throw new InvalidParameterException(String.format("【%s】无效的产品状态", code));
        return null;
    }
}

为什么是枚举而不是字典?
个人觉得符合以下特征才应该使用字典,否则就应该用枚举:

  • 子项可动态修改,而且修改比较频繁
  • 修改子项不影响现有业务逻辑,也就是说代码不用动

像商品状态这种字段,每个状态都很业务密切相关。如果你把它放在字典里,只在字典里新加了一个状态没有用,因为代码里还得修改相关业务逻辑。

private Integer productStatus;替换成private ProductStatusEnum productStatus;

调整一下of工厂方法:

public static Product of(String productNo, String name, BigDecimal price, Integer categoryId, Integer productStatus, String remark, 
                                           Boolean allowAcrossCategory, Set<ProductCourseItem> productCourseItems) {
    ProductNumber newProductNo = ProductNumber.of(categoryId);
    ProductStatusEnum defaultProductStatus = ProductStatusEnum.DRAFTED;
    return new Product(null, newProductNo, name, price, categoryId, defaultProductStatus, remark, allowAcrossCategory, 
                                                                                          productCourseItems);
}

③新建Price值对象
商品和课程明细都有价格,我们可以把Price放在一个公共的地方。
在domain下新建common.model.Price, 内容如下:

@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Price implements Serializable {

    //@Convert(converter = CurrencyConverter.class)
    @Column(name = "currency_code", length = 3)
    private Currency currency;
    @Column(name = "price", nullable = false, precision = 10, scale = 2)
    private BigDecimal value;

    public static Price of(String currencyCode, BigDecimal value) {
        checkArgument(!StringUtils.isEmpty(currencyCode), "币种不能为空");
        Currency currency;
        try {
            currency = Currency.getInstance(currencyCode);
        } catch (IllegalArgumentException e) {
            throw new InvalidParameterException(String.format("【%s】不是有效的币种", currencyCode));
        }
        checkArgument(value != null, "价格不能为空");
        checkArgument(value.compareTo(BigDecimal.ZERO) > 0, "价格必须大于0");
        return new Price(currency, value);
    }
}

在值对象里验证币种的有效性很合理对不对?否则每次用到币种的时候都得判断一下是否有效。一个处理业务逻辑的方法里到处都是if判断,不雅观不说,
还影响看代码的思路。

注意这里我故意加了一行代码:
checkArgument(value.compareTo(BigDecimal.ZERO) > 0, "价格必须大于0");
大家想想加在这里是否合理? 我的理解,如果你的系统所有用到价格的地方都必须是正价格,可以加这句代码。虽然大多数场景价格都是正的,
哪儿有倒赔钱的道理? 但是保不准有些系统就是有“负价格”这个概念,那样的话就不能加这个判断了。

Product

@Column(name = "price", precision = 10, scale = 2)
private BigDecimal price;

替换成

@Embedded
private Price price;

④自定义异常
定义一个通用的运行时异常:

@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public class BusinessException extends RuntimeException {
    private String code;
    private String message;
}

具体的业务异常:

public class InvalidParameterException extends BusinessException {
    private static final String CODE = "invalid-parameter";

    public InvalidParameterException(String message) {
        super(CODE, message);
    }

}

异常code定义成String类型,这样看到异常编码就能知道是哪种异常,如果定义成int类型,还得查表之后才能知道是哪种异常。

CourseItem类同理,这里就不再重复了。

demo地址: productcenter4.zip