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四層架構的玩法:

  1. 用戶接口層使用payload接收參數,payload把自己轉成command傳給應用層(application service)
  2. 應用層開啟事務,查詢聚合根,調用領域層方法,調用資源庫(repository)持久化實體
  3. 領域層實現業務邏輯
  4. 基礎服務層負責持久化

接收參數是用戶接口層的工作。用戶接口層的payload會提供set&get方法的。我們現在實現的是領域層的東西,還沒到應用層和用戶接口層呢。
為什么不直接將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嗎?
可以。但是一般不推薦。那為什么我還要在域服務里注入repository呢? 因為我想讓application service盡可能地薄一點。

新建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外部是不知道的,也不需要知道。

還有一條業務規則沒實現?
很好,被細心的你發現了。 “商品的價格是明細價格的總和”這條業務規則還沒實現。 這個我就不寫了,留給讀者自己實現。TODO

商品上下架功能:

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兼容性最好,因為其它語言的框架可能不支持 PUTDELETE這樣的方法。
  • 每個url都是由動詞結尾,意思很明確。
  • 資源(名詞)單復數分的很清楚。操作單個資源就用單數,操作多個資源就用復數。 而Restful單復數就很難分清楚

小結

  • 本系列文章旨在說明如何使用Spring Boot+JPA實現DDD,關於DDD戰術工具(聚合、實體、值對象、域服務、倉儲、領域事件)細節沒有詳細說明。
    代碼只是演示了如何使用這些戰術工具。如果你懶得看這些戰術工具的定義,不妨直接從代碼里感受一下,然后回過頭來再看定義可能印象更深刻。
  • 戰略上如何划分子領域,如何構建上下文映射圖也沒有說。這個其實是非常非常重要的,如果一開始領域都划分錯了,后面寫出來的代碼也是有問題的。作者水平有限,實在不知道怎么說這個東西。作為一個IT民工,能用好DDD戰術工具就很不錯了。戰略上的東西更多是領導層面決定的。
  • 如果你仔細看完本系列文章會發現這個demo項目不完整。商品查詢怎么辦?尤其是關聯查詢怎么辦?這個就要提一下DDD的架構風格了。其中一種架構風格是CQRS(讀寫分離),商品中心就很適合用這個。這就是說,應該再起一個項目,可以使用mybatis或者jdbc,這個項目專門用來查詢。 另一種架構風格是事件驅動。比如訂單系統比較復雜,關聯的領域比較多,事件也多,非常適合用事件驅動。這些東西就有待大家自己探索了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM