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(面向物件)
生成商品編碼的方法放在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就行了
-
值物件的
equals
和hashCode
方法,與實體有唯一標識不同,值物件沒有唯一標識,兩個值物件所有的屬性值相等才能判定相等。
然後將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類同理,這裡就不再重複了。