1. 程式人生 > 實用技巧 >Spring Boot+JPA實現DDD(四)

Spring Boot+JPA實現DDD(四)

優化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);
    }
}

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類同理,這裡就不再重複了。