優化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就行了
-
值對象的
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);
}
}
在值對象里驗證幣種的有效性很合理對不對?否則每次用到幣種的時候都得判斷一下是否有效。一個處理業務邏輯的方法里到處都是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