SpringBoot+JPA實現DDD(五)
實現功能
篇幅所限,我們以建立商品、上下架商品 這兩個功能為例:
domain
我們已經有了一個建立商品的工廠方法of
,但是裡面沒有業務邏輯,現在來補充業務邏輯。
of
方法了引數太多了,我們把它放在Command類裡。Command不屬於領域物件,應該放在哪個包下面呢?
放在application
包下。在appliction
這個包下新建一個command
包,再新建一個CreateProductCommand
類:
@Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class CreateProductCommand { private String name; private Integer categoryId; private String currencyCode; private BigDecimal price; private String remark; private Boolean allowAcrossCategory; private Set<ProductCourseItem> productCourseItems; public static CreateProductCommand of(String name, Integer categoryId, String currencyCode, BigDecimal price, String remark, Boolean allowAcrossCategory, Set<ProductCourseItem> productCourseItems) { // 檢查是否有重複的明細編碼,考慮再三,還是要校驗一下,不然突然少了一個明細,會嚇到使用者。 Set<String> dup = getDuplicatedItemNos(productCourseItems); if (!CollectionUtils.isEmpty(dup)) { throw new IllegalArgumentException(String.format("明細編號不能重複【%s】", String.join(",", dup))); } return new CreateProductCommand(name, categoryId, currencyCode, price, remark, allowAcrossCategory, productCourseItems); } private static Set<String> getDuplicatedItemNos(Set<ProductCourseItem> productCourseItems) { if (CollectionUtils.isEmpty(productCourseItems)) { return null; } Map<String, Long> duplicatedNoMap = productCourseItems.stream().collect(collectingAndThen(groupingBy(ProductCourseItem::getCourseItemNo, counting()), m -> { m.values().removeIf(v -> v <= 1); return m; })); return duplicatedNoMap.keySet(); } }
注意:
- command雖然不是領域物件,但是它可以引用領域物件,比如這裡我們引用了ProductCourseItem這個值物件。
- command也是不可修改的。這裡只提供了getter
command連set方法都沒有,外部怎麼將引數傳進來?
這裡要說一下DDD四層架構的玩法:
- 使用者介面層使用
payload
接收引數,payload
把自己轉成command
傳給應用層(application service) - 應用層開啟事務,查詢聚合根,呼叫領域層方法,呼叫資源庫(repository)持久化實體
- 領域層實現業務邏輯
- 基礎服務層負責持久化
接收引數是使用者介面層的工作。使用者介面層的payload
為什麼不直接將payload傳給application?
command是相對穩定的東西。不管外部埠如何變化,只要能把接收到的引數轉成相應的command。我們的領域模型就能提供相應的服務。
我們早就說過領域模型是穩定的,也就是說它能適應變化。
payload
的欄位名稱和型別可能不符合模型的要求,所以需要轉成command。
扯遠了,回到of
方法上,這個方法的引數太多了,用起來非常不方便不說,看起來也不向面向物件的寫法。改成如下:
public static Product of(CreateProductCommand command) { Integer categoryId = command.getCategoryId(); checkArgument(!StringUtils.isEmpty(command.getName()), "商品名稱不能為空"); checkArgument(categoryId != null, "商品類目不能為空"); checkArgument(categoryId > 0, "商品類目id不能小於0"); // 生成產品碼時有限制,該欄位不能超過4位 checkArgument(categoryId < 10000, "商品類目id不能超過10000"); checkArgument(command.getAllowAcrossCategory() != null, "是否跨類目不能為空"); Price price = Price.of(command.getCurrencyCode(), command.getPrice()); if("CAD".equalsIgnoreCase(price.getCurrency().getCurrencyCode())){ throw new NotSupportedCurrencyException(String.format("【%s】對不起,暫不支援該幣種", command.getCurrencyCode())); } ProductNumber newProductNo = ProductNumber.of(categoryId); ProductStatusEnum defaultProductStatus = ProductStatusEnum.DRAFTED; Product product = new Product(null, newProductNo, command.getName(), price, categoryId, defaultProductStatus, command.getRemark(), command.getAllowAcrossCategory(), command.getProductCourseItems()); return product; }
等等,我們建立商品的時候似乎缺了點什麼。需求裡有一句“明細的類目可以跟商品保持一致,也可以不保持一致”,這條業務規則我們好像還沒有實現。
當允許跨類目的時候,商品和明細的類目不用保持一致,但是當不允許跨類目的時候,商品和明細的類目必須保持一致。
很明顯我們需要一個判斷商品及明細類目是否一致的方法。問題來了,這個方法放在哪裡合適? 放在商品裡,然後把明細集合傳到of
方法裡?
不行,前面說過了,聚合根和聚合根之間不要直接引用。 那怎麼辦?
當某些功能放在任何一個實體裡都不合適的時候,我們需要把它放在域服務(domain service)裡。
在域服務裡將明細實體查出來,然後挨個比對類目是否一致。
域服務裡能使用repository嗎?
可以。但是一般不推薦。
新建domain.model.product.ProductManagement
@Component
public class ProductManagement {
private CourseItemRepository courseItemRepository;
// 使用構造器的方式注入,因為@Autowired等註解注入方式容易上癮:)
public ProductManagement(CourseItemRepository courseItemRepository) {
this.courseItemRepository = courseItemRepository;
}
/**
* 檢查明細的專案跟商品的專案是否保持一致
* 因為涉及了另一個聚合根CourseItem,把CourseItem實體轉成值物件好麻煩
* 所以把這段邏輯放在domain service裡
*
* @param allowCrossCategory 是否允許跨類目
* @param categoryId 商品類目id
* @param productCourseItems 明細資訊
*/
public void checkCourseItemCategoryConsistence(Boolean allowCrossCategory, Integer categoryId, Set<ProductCourseItem> productCourseItems) {
checkArgument(allowCrossCategory != null, "是否允許跨類目不能為空");
checkArgument(categoryId != null, "商品類目不能為空");
// 檢查編碼對應的明細是否存在,這個不算business logic
List<CourseItemNumber> itemNos = productCourseItems.stream().map(item -> CourseItemNumber.of(item.getCourseItemNo())).collect(Collectors.toList());
List<CourseItem> courseItems = courseItemRepository.findByItemNos(itemNos);
Map<CourseItemNumber, List<CourseItem>> courseItemMap = courseItems.stream().collect(groupingBy(CourseItem::getItemNo));
List<String> notFoundItemNos = itemNos.stream().filter(itemNo -> !courseItemMap.containsKey(itemNo))
.map(item -> item.getValue())
.collect(Collectors.toList());
if (!CollectionUtils.isEmpty(notFoundItemNos)) {
throw new NotFoundException(String.format("明細【%s】未找到", String.join(",", notFoundItemNos)));
}
// 不允許跨類目時才需要檢查類目是否一致,這個是business logic,前面的查詢就是為這裡服務的
if (!allowCrossCategory) {
List<CourseItem> unmatchedCourseItems = getUnmatchedCourseItems(categoryId, courseItems);
if (!CollectionUtils.isEmpty(unmatchedCourseItems)) {
List<String> unmatchedItemNos = unmatchedCourseItems.stream().map(item ->
item.getItemNo().getValue()).collect(Collectors.toList());
throw new CategoryNotMatchException(String.format("明細【%s】類目不匹配", String.join(",", unmatchedItemNos)));
}
}
}
private List<CourseItem> getUnmatchedCourseItems(Integer productCategoryId, List<CourseItem> courseItems) {
return courseItems.stream().filter(item -> !item.getCategoryId().equals(productCategoryId))
.collect(Collectors.toList());
}
}
注意,Product.of
方法和ProductManagement.checkCourseItemCategoryConsistence
方法加起來才是完整的建立商品的邏輯。看起來有點散,
但是別忘了,建立商品時會先經過application service。 application service提供了建立商品的統一入口。從外部看來,它只需要呼叫applicaton service
的createProduct
方法即可。 至於真正建立商品時用了幾個domain service外部是不知道的,也不需要知道。
商品上下架功能:
public void listing() {
if(this.productStatus.getCode() < ProductStatusEnum.APPROVED.getCode()){
throw new NotAllowedException("已稽核通過的商品才允許上架");
}
this.productStatus = ProductStatusEnum.LISTED;
}
public void unlisting() {
if(!this.productStatus.equals(ProductStatusEnum.LISTED)){
throw new NotAllowedException("已上架的商品才允許下架");
}
this.productStatus = ProductStatusEnum.UNLISTED;
}
application
domain層的程式碼寫完了,在應用層呼叫它。
application service是很薄的一層,做的工作比較少。通常有以下工作:
- 開啟事務
- 查詢實體(呼叫其它方法需要用到這些實體)
- 呼叫實體的方法,或者域方法
- 呼叫repository方法,持久化
- 許可權控制
新建application.ProductService
:
public interface ProductService {
Product createProduct(CreateProductCommand command);
}
新建application.impl.ProductServiceImpl
:
@Service
public class ProductSerivceImpl implements ProductService {
private ProductRepository productRepository;
private ProductManagement productManagement;
public ProductSerivceImpl(ProductRepository productRepository, ProductManagement productManagement) {
this.productRepository = productRepository;
this.productManagement = productManagement;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Product createProduct(CreateProductCommand command) {
Set<ProductCourseItem> productCourseItems = command.getProductCourseItems();
if (CollectionUtils.isEmpty(productCourseItems)) {
throw new IllegalArgumentException("明細不能為空");
}
// 不允許跨類目的商品,明細類目要跟商品類目保持一致。思來想去,這個邏輯還是放在domain service裡好
productManagement.checkCourseItemCategoryConsistence(command.getAllowAcrossCategory(), command.getCategoryId(),
productCourseItems);
Product product = Product.of(command);
productRepository.save(product);
return product;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Integer unlistingProduct(String productNo) {
checkArgument(!StringUtils.isEmpty(productNo), "商品編號不能為空");
Product product = productRepository.findByProductNo(ProductNumber.of(productNo));
if (product == null) {
throw new NotFoundException(String.format("商品【%s】未找到", productNo));
}
ProductStatusEnum oldStatus = product.getProductStatus();
product.unlisting();
productRepository.update(product);
return oldStatus.getCode();
}
}
repository
在model.product
包下新建介面:
public interface ProductRepository {
void save(Product product);
void update(Product product);
Product findByProductNo(ProductNumber productNo);
}
在infrastructure
包新新建實現類
@Repository
public class HibernateProductRepository extends HibernateSupport<Product> implements ProductRepository {
HibernateProductRepository(EntityManager entityManager) {
super(entityManager);
}
@Override
public Product findByProductNo(ProductNumber productNo) {
if (StringUtils.isEmpty(productNo)) {
return null;
}
Query<Product> query = getSession().createQuery("from Product where productNo=:productNo and isDelete=0", Product.class).setParameter("productNo", productNo);
return query.uniqueResult();
}
}
abstract class HibernateSupport<T> {
private EntityManager entityManager;
HibernateSupport(EntityManager entityManager) {
this.entityManager = entityManager;
}
Session getSession() {
return entityManager.unwrap(Session.class);
}
public void save(T object) {
entityManager.persist(object);
entityManager.flush();
}
public void update(T object) {
entityManager.merge(object);
entityManager.flush();
}
}
@Entity
裡的值物件如何持久化?
需要用到轉換器。
以ProductNumber為例,在model裡定義如下轉換器:
@Converter
public class ProductNumberConverter implements AttributeConverter<ProductNumber, String> {
@Override
public String convertToDatabaseColumn(ProductNumber productNumber) {
return productNumber.getValue();
}
@Override
public ProductNumber convertToEntityAttribute(String value) {
return ProductNumber.of(value);
}
}
ui
這個就很簡單了,跟以前一樣使用Controller。
注意,這裡接收引數的叫payload, payload要把自己轉化成command之後再呼叫application service。
@PostMapping("/api/v1/product/create")
public ApiResult<Product> createProduct(@RequestBody CreateProductPayload createProductPayload) {
CreateProductCommand command = createProductPayload.toCommand();
Product product = productService.createProduct(command);
return ApiResult.ok(product);
}
payload:
看到沒有,payload跟command不一樣,payload有get,set方法,為了省事,我直接用@Data
這個註解了。
@Data
public class CreateProductPayload {
private String name;
private Integer categoryId;
private String currencyCode;
private BigDecimal price;
private String remark;
private Boolean allowAcrossCategory;
private Set<ProductCourseItemPayload> productCourseItems;
public CreateProductCommand toCommand() {
Set<ProductCourseItem> itemRelations = productCourseItems.stream()
.map(item -> ProductCourseItem.of(item.getCourseItemNo(),
item.getRetakeTimes(), item.getRetakePrice())).collect(Collectors.toSet());
return CreateProductCommand.of(name, categoryId, currencyCode, price, remark, allowAcrossCategory, itemRelations);
}
@Data
public static class ProductCourseItemPayload {
private String courseItemNo;
private Integer retakeTimes;
private BigDecimal retakePrice;
}
}
Restful or Not?
我不推薦使用restful。
可以看看淘寶商品中心開發api。它採用的Richardson Maturity Model(成熟度模型)是level 1。所有的請求都是post請求。
原因有三:
- 這種api相容性最好,因為其它語言的框架可能不支援
PUT
,DELETE
這樣的方法。 - 每個url都是由動詞結尾,意思很明確。
- 資源(名詞)單複數分的很清楚。操作單個資源就用單數,操作多個資源就用複數。 而Restful單複數就很難分清楚