zoukankan      html  css  js  c++  java
  • 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,这个项目专门用来查询。 另一种架构风格是事件驱动。比如订单系统比较复杂,关联的领域比较多,事件也多,非常适合用事件驱动。这些东西就有待大家自己探索了。
  • 相关阅读:
    实战:垂直电商如何撬动“女人腰包”
    谈谈项目收尾
    项目管理心得:一个项目经理的个人体会、经验总结
    IT项目经理沟通技巧的重要性
    项目跟踪:项目跟踪要跟踪什么呢?
    会员营销,你真的做到了吗?
    Git入门——基础知识问答
    文摘:威胁建模(STRIDE方法)
    写在2015年工作的第一天
    简化工作——我的bat文件
  • 原文地址:https://www.cnblogs.com/ahau10/p/13522928.html
Copyright © 2011-2022 走看看