领域驱动设计实践
1、前言
由于公司从0到1构建一套供应链系统,技术架构采用了DDD架构,我由最初的不理解,到慢慢接受,再到后来觉得很多理念跟自己实践得来的想法不谋而合。项目刚开始,项目组里没人熟悉DDD,有个技术经理做了一些调研,给我们做了一次培训,他自己也不熟悉,初步整出了一个项目demo,demo只有包目录结构,没有示例代码。由于时间紧迫,大家就参照那个demo来开发。由于刚开始对DDD的认识还很肤浅,生硬地套着DDD那套规范来,整个开发过程很痛苦。后来自己看了很多博客,逐步有了更深的认识,不过自己还是所了解的知识比较零散,于是就看了《领域驱动设计》和《实现领域驱动设计》。这两本书内容很多,概念也很多,我比较有针对性地阅读,带着问题去找答案。经过一年多的实践,基于自己对DDD的理解,我整理了一个自己的DDD demo,并在组内推广,并且得到大家的认同。对于DDD,网上的博客也很多,不同的人会有不同的认识和感悟,我也是辩证地看待。由于在网上比较难找到比较完整的DDD demo,我在此抛砖引玉,希望能对想了解或者正在实践DDD的朋友有所参考。
1.1 DDD的优点
- 分离关注点,把技术的复杂性和业务的复杂性进行分离,service层更轻量,业务逻辑更清晰
- 通过聚合根更新数据,保证数据的强一致性
- 分而治之,在业务逻辑上根据职责再划分出不同的领域,控制复杂度
- 分层架构,逻辑清晰,保证代码可维护性和可扩展性
- 统一语言,统一业务术语,消除混淆
- 提取领域模型,通过模型可以不失真地还原业务逻辑
- 代码可复用性高
1.2 DDD的缺点
- 概念多,学习成本高,难上手,新手往往不知所措。如果不熟悉DDD的思想,生硬地照搬ddd的规范,写出来的代码可能比传统的三层架构还糟糕,得不偿失。
- 实现一个功能写的代码量比传统的三层架构多
- 模型之间转换操作比较多,编码繁琐
2、概念与术语
实体:具有唯一标识,有状态,具有生命周期、mutable的领域对象 值对象:没有唯一标识、无状态、无生命周期、可复用、immutable的领域对象,类似基本类型int、long、String 聚合:一组具有关联关系领域对象,可包含实体和值对象 聚合根:聚合的根,可以理解为可以代表整体概念的实体,操作子实体和值对象需要通过聚合根遍历,类似树形数据结构的根节点,这样可以保证数据的完整性 领域事件:某个操作触发的事件,领域事件可以跟踪领域对象生命周期的状态变化过程。例如一个实体经过多次修改,每次产生一个实体修改事件,把所有实体修改事件按发生的顺序可以重建某个时间点的快照对象。领域事件也是同一个用例操作多个聚合的实现方式 领域服务:同一个操作中需要操作到多个聚合根对象的逻辑需要抽到领域服务,或者同一个聚合中可以被多个用例复用的公共逻辑 限界上下文:可以理解为一个独立的服务 领域:某一类业务相关知识的集合,一个领域可以包含多个限界上下文,也即是一个领域可以有多个服务组成 子域:一个子域是领域的一部分 统一语言:从业务中提炼出来的概念术语,领域专家、产品、开发可以互相理解的沟通语言
3、DDD战略架构
应用架构采用微服务架构的聚合模式,包含应用接口层和领域服务层。应用接口层主要负责对领域服务层进行功能的组合编排,为前端提供应用级别的接口,并对前端提交的参数进行校验。领域服务层主要属于纯粹的核心业务或者基础服务,具有稳定性、可复用性等特点。我们约定,服务之间不能随便互调,只能从上而下调用,也就是高层次的服务可以直接依赖下层或者更底层的服务,同层级服务之间不能互相依赖。例如,应用接口层的服务之间不能互相调用,它只能调用领域服务。同理,同样层次的领域服务也不能互相调用,彼此保持各自的自治性和独立性。其实,应用接口层和领域服务层之间还可以存在多个层次的领域服务层,他们都是对下层的领域服务进行编排,形成新的领域服务。不过,就目前我开发过的项目而言,还没遇到这么复杂的领域层,更多的是只有一个领域服务层。如图:
4、DDD战术架构
公司主要使用springcloud-alibaba、mybatis-plus、mysql、mapstruct等基础框架和组件来构筑整个项目,模块划分如下:
doc
└──sql 数据库脚本
infrastructure-common 基础设施公共组件,定义公共响应模型、分页模型、公共异常、常用工具等
xxx-api 应用服务层,对领域层服务进行编排,实现应用层用例逻辑,作为独立服务部署,应用层服务不能互调,只能调用领域层或者更底层的服务
xxx-service 领域层服务
├── xxx-client 客户端模块,给上层服务引用,远程调用提供接口,springboot项目只定义接口模型和枚举,dubbo项目还会定义接口
└── xxx-core 核心模块,实现核心业务逻辑,作为独立服务部署
4.1 api模块包结构图
├── business 业务逻辑层
│ ├── client 客户端,也就是防腐层,封装调用其他系统的接口
│ │ ├── feign openfeign远程调用接口层
│ │ └── model 对其他系统参数模型的抽象,可以在本系统其他地方使用
│ ├── config 与业务相关的配置
│ ├── domain 领域层
│ │ ├── model 领域模型
│ │ │ ├── entity 领域实体
│ │ │ ├── enums 枚举
│ │ │ └── value 值对象
│ │ └── service 领域服务
│ ├── exception 业务异常枚举
│ ├── factory 工厂
│ └── service 应用服务层,定义应用层接口
│ ├── dto 应用服务层参数
│ └── impl 应用服务层实现
├── facade
│ └── api 接口层
│ ├── event
│ │ └── handler 消息队列消费者
│ └── web http controller层
└── infrastructure 基础设施层
├── converter 模型转换器
└── common 公共包
└── exception 全局异常处理器
└── config 技术框架相关配置
4.2 core模块包结构图
├── business 业务逻辑层
│ ├── config 与业务相关的配置
│ ├── domain 领域层
│ │ ├── model 领域模型
│ │ │ ├── entity 领域实体
│ │ │ │ └── id 领域实体唯一标识
│ │ │ ├── enums 领域枚举
│ │ │ └── value 值对象
│ │ └── service 领域服务
│ ├── exception 业务异常枚举
│ ├── factory 工厂
│ └── service 应用服务层,定义应用层接口
│ ├── dto 应用服务层参数
│ └── impl 应用服务层实现
├── facade
│ └── api 接口层
│ ├── event
│ │ └── handler 消息队列消费者
│ └── rest http controller层
└── infrastructure 基础设施层
├── converter 模型转换器
├── common 公共包
│ └── exception 全局异常处理器
├── config 技术框架相关配置
├── persistence 数据持久化层
│ └── db 数据库层
│ ├── dao 数据库操作层
│ │ └── impl 数据库操作层实现,继承mybatis-plus实现层
│ ├── entity 数据表结构实体
│ ├── mapper mybatis mapper
│ ├── query 查询参数
│ └── vo 关联查询结果视图
└── repository 仓储层
└── impl
4.3 项目分层结构图
项目主要分为三大层次,分别是api
层、business
层、infrastructure
层。
1、api
层为接口层,对不同通信方式的封装,如封装http
协议的controller
,封装mq
协议的消息处理器。
2、business
层为业务逻辑层,包含service
层(应用服务层,api
层的直接调用者),domain
层(领域逻辑层),factory
层,数据结构工厂,负责对应用层的参数进行校验,并构造出领域聚合根实体。
3、infrastructure
为基础设施层,主要包括repository
层(抽象数据存储接口,主要用来保存聚合根实体)、dao
层(抽象数据库操作相关的接口)、converter
(数据转换器)、util
(工具类)
4.4 商品项目demo
具体例子以一个商品系统demo项目讲解,首先讲述领域服务核心模块product-core的领域模型设计,
Product
实体作为聚合的根实体,下面包含ProductDetail
和ProductAttribute
两个子实体,ProductAttribute
还包含一个Attribute
值对象。每个实体都继承BaseDomainObj
,BaseDomainObj
定义了一些公共属性,最重要的是id
字段,代表唯一标识。每个实体都有自己的id
类,每个id
类都继承DomainObjId
,如ProductId、ProductDetailId、ProductAttributeId
。每个实体同时实现EntityObj
接口,标识自身作为领域实体。每个值对象实现ValueObj
,标识自身作为值对象。商品域领域模型如图:
4.4.1 领域层服务设计
关于领域实体的设计,最常用的方式就是按照实体的关联关系进行设计,所有实体和值对象都没有setter
,统一使用builder
模型构造对象实例,保证领域对象的数据一致性。领域对象相对三层架构的贫血模型,最大的区别是他有自己的有意义的业务方法,而不是把所有业务逻辑都放在service
层。
编辑类操作时序图
查询类操作时序图
4.4.1.1 BaseDomainObj
BaseDomainObj
作为以关系数据库为存储技术的领域实体的基类,它定义了一些数据表结构公共的字段
/**
* <p>
* 基于数据库存储技术的基础实体
* </p>
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Getter
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class BaseDomainObj<T extends DomainObjId> {
/**
* 唯一标识
*/
private T id;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 创建人
*/
private Long createUser;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 更新人
*/
private Long updateUser;
/**
* 乐观锁版本号
*/
private Integer version;
}
4.4.1.2 DomainObjId
DomainObjId
,领域对象委派id
,一般是实体基于某种技术实现上的唯一标识,往往是没有业务意义的,如数据库主键,es文档的id。而有业务意义的唯一标识一般是作为唯一索引的,如商品编码code
。每个实体自定义的实体id
都继承DomainObjId
。该类有三个字段,preId
为预生成id,他的用处是为批量插入数据后,为领域实体回填自增id。如果不是使用数据库自增id作为唯一标识的,这个字段是没有用处的。value
是唯一标识的实际值,status
代表实体当前的状态,分别用NEW
、CHANGED
、DELETED
、UNCHANGED
四个枚举值表示新增、修改、删除、无变化。
/**
* 领域对象委派id
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Data
@SuperBuilder
@NoArgsConstructor
public class DomainObjId {
/**
* 预生成唯一id
*/
private final String preId = UUID.randomUUID().toString();
/**
* id值
*/
private Long value;
/**
* 状态
*/
private EntityStatus status;
public DomainObjId(Long value) {
this.value = value;
this.status = EntityStatus.UNCHANGED;
}
public DomainObjId(Long value, EntityStatus status) {
this.value = value;
this.status = status;
if (status == null) {
throw new IllegalArgumentException("DomainObjId's status cannot be null");
}
}
public DomainObjId(EntityStatus status) {
if (status == null) {
throw new IllegalArgumentException("DomainObjId's status cannot be null");
}
this.status = status;
}
@AllArgsConstructor
@Getter
public enum EntityStatus {
NEW(1, "新增"), CHANGED(2, "修改"), DELETED(3, "删除"), UNCHANGED(4, "无变化");
private Integer code;
private String name;
}
}
4.4.1.3 EntityObj
EntityObj
,领域实体标识,所有实体实现这个接口,泛型Parent
标识该领域实体的父实体
/**
* 实体对象
*
* @param <Parent> 父实体
* @author Joven
*/
public interface EntityObj<Parent> {
}
4.4.1.4 ValueObj
ValueObj
,值对象标识,所有值对象实现这个接口
/**
* 值对象
*
* @author Joven
* @date 2022/6/17 14:01
*/
public interface ValueObj {
}
4.4.1.5 ProductId
商品唯一标识
/**
* 商品id
*/
@Getter
@SuperBuilder
@AllArgsConstructor
public class ProductId extends DomainObjId {
public ProductId(Long value, EntityStatus status) {
super(value, status);
}
public ProductId(EntityStatus status) {
super(status);
}
}
4.4.1.6 ProductDetailId
商品详情唯一标识
/**
* 商品详情id
*/
@Getter
@SuperBuilder
@AllArgsConstructor
public class ProductDetailId extends DomainObjId {
public ProductDetailId(Long value, EntityStatus status) {
super(value, status);
}
public ProductDetailId(EntityStatus status) {
super(status);
}
}
4.4.1.7 ProductAttributeId
商品属性唯一标识
/**
* 商品属性id
*/
@Getter
@SuperBuilder
@AllArgsConstructor
public class ProductAttributeId extends DomainObjId {
public ProductAttributeId(Long value, EntityStatus status) {
super(value, status);
}
public ProductAttributeId(EntityStatus status) {
super(status);
}
}
4.4.1.8 Product
商品实体,他的父实体是他自己,代表他本身是聚合根。相对贫血模型,定义了changePrice
,changeStatus
两个有意义的业务方法。code
字段是具有业务意义的唯一标识,用UniqueIdentifierField
注解标注,技术上把他设计成唯一索引。这里的equals
方法自己实现,而没有使用EqualsAndHashCode
,因为存在BigDecimal
类型的price
字段,前端传过来1,数据库查出来的是1.00,他们的精度不一样,BigDecimal
的equals
比较是不等的。
/**
* <p>
* 商品领域实体
* </p>
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Getter
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class Product extends BaseDomainObj<ProductId> implements EntityObj<Product> {
/**
* 商品名称
*/
private String name;
/**
* 商品编码
*/
@UniqueIdentifierField
private String code;
/**
* 商品价格
*/
private BigDecimal price;
/**
* 0:待售,1:在售,2:下架
*/
private Integer status;
/**
* 商品分类id
*/
private Long categoryId;
/**
* 供应商id
*/
private Long supplierId;
/**
* 商品详情
*/
private ProductDetail detail;
/**
* 商品属性
*/
private List<ProductAttribute> attributes;
public void changePrice(BigDecimal price) {
if (price == null)
return;
CheckUtils.assertTrue(price.compareTo(BigDecimal.ZERO) > 0, ExceptionEnum.PRODUCT_PRICE_INVALID.createException());
this.price = price;
}
public void changeStatus(Integer status) {
ProductStatusEnum statusEnum = ProductStatusEnum.getByCode(status);
ProductStatusEnum currentStatusEnum = ProductStatusEnum.getByCode(this.status);
CheckUtils.assertTrue(currentStatusEnum.nextStatus().equals(statusEnum), ExceptionEnum.PRODUCT_STATUS_ERROR.createException());
this.status = status;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Product product = (Product) o;
return Objects.equals(name, product.name) && ((price == product.price) || (price != null && price.compareTo(product.price) == 0)) && Objects.equals(status, product.status) && Objects.equals(categoryId, product.categoryId) && Objects.equals(supplierId, product.supplierId) && Objects.equals(detail, product.detail) && Objects.equals(attributes, product.attributes);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), name, price, status, categoryId, supplierId, detail, attributes);
}
}
4.4.1.9 ProductDetail
商品详情实体对象,父实体是Product
,productId
其实算是他的业务上的唯一标识,一个商品只会有一个商品详情,而他自身的id
其实数据库的主键,没有业务意义,称为委派唯一标识。如果他不是作为单表存储,或者使用文档数据库存储,ProductDetail
可以设计成值对象,不用继承BaseDomainObj
,也就没有唯一标识。其作为聚合根Product
的一部分,整体存储。
/**
* <p>
* 商品详情实体对象
* </p>
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Getter
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class ProductDetail extends BaseDomainObj<ProductDetailId> implements EntityObj<Product> {
/**
* 商品id
*/
private ProductId productId;
/**
* 商品标题
*/
private String title;
/**
* 卖点
*/
private String sellPoint;
/**
* 文案详情
*/
private String detail;
}
4.4.1.10 ProductAttribute
商品属性实体对象,父实体为Product
,包含商品id
和Attribute
属性值对象。如果他不是作为单表存储,或者使用文档数据库存储,ProductAttribute
可以设计成值对象,不用继承BaseDomainObj
,也就没有唯一标识。其作为聚合根Product
的一部分,整体存储。
/**
* <p>
* 商品属性实体对象
* </p>
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Getter
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class ProductAttribute extends BaseDomainObj<ProductAttributeId> implements EntityObj<Product> {
/**
* 商品id
*/
private ProductId productId;
/**
* 属性
*/
private Attribute attribute;
}
4.4.1.11 Attribute
属性值对象
/**
* @author Joven
* @date 2022/6/22 14:50
*/
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class Attribute implements ValueObj {
/**
* 属性名称
*/
private String name;
/**
* 属性值
*/
private String value;
/**
* 属性类型:1基本属性,2销售属性
*/
private Integer type;
}
4.4.1.12 ProductFactory
商品工厂,工厂也就是参数组装的地方,是以聚合根作为最小逻辑单元的实现的。它有两个职责,1)对更新类操作进行参数的校验并返回组装好的聚合根实体,2)对查询类操作调用dao
层查询DO
对象回来,并转换成DTO
返回给应用层。
/**
* 商品工厂
*
* @author Joven
* @date 2022/6/20 10:58
*/
@Component
public class ProductFactory {
@Autowired
private ProductDao productDao;
@Autowired
private ProductDetailDao productDetailDao;
@Autowired
private ProductAttributeDao productAttributeDao;
@Autowired
private ProductCategoryDao productCategoryDao;
public PageResult<ProductDTO> getByPage(ProductPageQueryDTO query) {
Page<ProductDO> page = productDao.getByPage(new Page<>(query.getCurrent(), query.getSize()), ProductConverter.INSTANCE.convert(query));
PageResult<ProductDTO> pageResult = ProductConverter.INSTANCE.convert(page);
if (CollectionUtils.isEmpty(page.getRecords())) {
return pageResult;
}
List<ProductDTO> datas = page.getRecords().stream().map(ProductConverter.INSTANCE::toProductDTO).collect(Collectors.toList());
pageResult.setRecords(datas);
return pageResult;
}
public ProductDetailDTO getDetailById(Long id) {
ProductDO product = productDao.getById(id);
if (product == null) {
return null;
}
ProductDetailDO detailDO = productDetailDao.getByProductId(id);
List<ProductAttributeDO> attrDOs = productAttributeDao.listByProductId(id);
ProductDetailDTO dto = ProductConverter.INSTANCE.convert(product);
ProductDetailDTO.ProductDetail detail = ProductConverter.ProductDetailAssembler.INSTANCE.convert(detailDO);
List<ProductDetailDTO.ProductAttribute> attrs = ProductConverter.ProductAttributeAssembler.INSTANCE.toProductAttributes(attrDOs);
dto.setDetail(detail);
dto.setAttributes(attrs);
return dto;
}
public Product build(ProductCreateDTO createDTO) {
ProductCategoryDO category = productCategoryDao.getById(createDTO.getCategoryId());
CheckUtils.assertTrue(category != null, ExceptionEnum.PRODUCT_CATEGORY_NOT_EXISTS.createException());
Product.ProductBuilder<?, ?> productBuilder = Product.builder();
ProductConverter.INSTANCE.update(createDTO, productBuilder);
ProductId productId = new ProductId(EntityStatus.NEW);
productBuilder.id(productId)
.code(UUID.randomUUID().toString())
.status(ProductStatusEnum.TO_SALE.getCode())
.updateUser(createDTO.getCreateUser());
long attrDistinctCount = createDTO.getAttributes().stream()
.map(ProductCreateDTO.ProductAttribute::getName).distinct().count();
CheckUtils.assertTrue(createDTO.getAttributes().size() == attrDistinctCount, ExceptionEnum.ATTR_NAME_DUPLICATE.createException());
List<ProductAttribute> attributes = createDTO.getAttributes().stream().map(it -> {
AttributeTypeEnum type = AttributeTypeEnum.getByCode(it.getType());
CheckUtils.assertTrue(type != null, ExceptionEnum.ATTR_TYPE_ERROR.createException());
Attribute attribute = ProductConverter.ProductAttributeAssembler.INSTANCE.convert(it);
return ProductAttribute.builder()
.id(new ProductAttributeId(EntityStatus.NEW))
.productId(productId)
.attribute(attribute)
.createUser(createDTO.getCreateUser())
.updateUser(createDTO.getCreateUser()).build();
}).collect(Collectors.toList());
productBuilder.attributes(attributes);
return productBuilder.build();
}
public Product build(ProductEditDTO editDTO) {
ProductDO productDO = productDao.getById(editDTO.getId());
CheckUtils.assertTrue(productDO != null, ExceptionEnum.PRODUCT_NOT_EXISTS.createException());
ProductStatusEnum status = ProductStatusEnum.getByCode(editDTO.getStatus());
CheckUtils.assertTrue(status != null, ExceptionEnum.PRODUCT_STATUS_ERROR.createException());
ProductCategoryDO category = productCategoryDao.getById(editDTO.getCategoryId());
CheckUtils.assertTrue(category != null, ExceptionEnum.PRODUCT_CATEGORY_NOT_EXISTS.createException());
Product originProduct = ProductConverter.INSTANCE.toDomainObj(productDO);
Product.ProductBuilder<?, ?> productBuilder = Product.builder();
ProductConverter.INSTANCE.update(originProduct, productBuilder);
ProductConverter.INSTANCE.update(editDTO, productBuilder);
Product toEditProduct = productBuilder.build();
if (!originProduct.equals(toEditProduct)) {
productBuilder.id(new ProductId(productDO.getId(), EntityStatus.CHANGED))
.updateUser(editDTO.getUpdateUser());
}
ProductDetail detail = toProductDetail(editDTO);
productBuilder.detail(detail);
List<ProductAttribute> attrs = toProductAttributes(editDTO);
productBuilder.attributes(attrs);
return productBuilder.build();
}
private List<ProductAttribute> toProductAttributes(ProductEditDTO editDTO) {
List<ProductAttributeDO> attributeDOList = productAttributeDao.listByProductId(editDTO.getId());
Map<String, ProductAttributeDO> attrMap = attributeDOList.stream()
.collect(Collectors.toMap(ProductAttributeDO::getName, Function.identity()));
List<ProductAttribute> attrs = editDTO.getAttributes().stream().map(it -> {
AttributeTypeEnum type = AttributeTypeEnum.getByCode(it.getType());
CheckUtils.assertTrue(type != null, ExceptionEnum.ATTR_TYPE_ERROR.createException());
ProductAttributeDO attr = attrMap.get(it.getName());
ProductAttribute.ProductAttributeBuilder<?, ?> attributeBuilder = ProductAttribute.builder()
.productId(new ProductId(editDTO.getId(), EntityStatus.UNCHANGED));
if (attr == null) {
Attribute attribute = ProductConverter.ProductAttributeAssembler.INSTANCE.convert(it);
attributeBuilder.attribute(attribute)
.id(new ProductAttributeId(EntityStatus.NEW))
.createUser(editDTO.getUpdateUser())
.updateUser(editDTO.getUpdateUser());
return attributeBuilder.build();
} else {
Attribute attrOrigin = ProductConverter.ProductAttributeAssembler.INSTANCE.convert(attr);
Attribute attrEdit = ProductConverter.ProductAttributeAssembler.INSTANCE.convert(it);
if (!attrEdit.equals(attrOrigin)) {
attributeBuilder.id(new ProductAttributeId(attr.getId(), EntityStatus.CHANGED));
attributeBuilder.updateUser(editDTO.getUpdateUser());
} else {
attributeBuilder.id(new ProductAttributeId(attr.getId(), EntityStatus.UNCHANGED));
}
attributeBuilder.attribute(attrEdit);
attributeBuilder.version(attr.getVersion());
}
return attributeBuilder.build();
}).collect(Collectors.toList());
List<String> existedAttrs = editDTO.getAttributes().stream()
.map(ProductEditDTO.ProductAttribute::getName).collect(Collectors.toList());
List<ProductAttribute> deletedAttrs = attributeDOList.stream()
.filter(it -> !existedAttrs.contains(it.getName()))
.map(it -> ProductAttribute.builder()
.id(new ProductAttributeId(it.getId(), EntityStatus.DELETED)).build())
.collect(Collectors.toList());
attrs.addAll(deletedAttrs);
return attrs;
}
private ProductDetail toProductDetail(ProductEditDTO editDTO) {
ProductDetailDO detailDO = productDetailDao.getByProductId(editDTO.getId());
//首次编辑可能为空
ProductDetail.ProductDetailBuilder<?, ?> detailBuilder = ProductDetail.builder();
detailBuilder.productId(new ProductId(editDTO.getId(), EntityStatus.CHANGED));
if (detailDO != null) {
ProductDetail productDetail = ProductConverter.ProductDetailAssembler.INSTANCE.toDomainObj(detailDO);
ProductConverter.ProductDetailAssembler.INSTANCE.update(productDetail, detailBuilder);
ProductConverter.ProductDetailAssembler.INSTANCE.update(editDTO.getDetail(), detailBuilder);
if (!productDetail.equals(detailBuilder.build())) {
detailBuilder.id(new ProductDetailId(detailDO.getId(), EntityStatus.CHANGED))
.updateUser(editDTO.getUpdateUser());
}
} else {
ProductConverter.ProductDetailAssembler.INSTANCE.update(editDTO.getDetail(), detailBuilder);
detailBuilder.id(new ProductDetailId(EntityStatus.NEW))
.createUser(editDTO.getUpdateUser())
.updateUser(editDTO.getUpdateUser());
}
return detailBuilder.build();
}
}
4.4.1.13 BaseRepository
基础仓储接口,代表数据存储的抽象层,对底层不同的数据存储技术进行抽象,提供统一的接口模型,他的出入参要么是基本类型,要么是聚合根实体。参考CQRS
(Command Query Responsibility Segregation)的设计思想,仓储接口只定义了save
和saveBatch
两个保存方法,他的的职责只做命令类操作,如新增、修改、删除,而查询类操作直接在factory
调用dao
来实现。当然也有人把查询类操作统一放到Repository
,查询方法里面调用dao
,再把DO
对象转换成领域聚合根对象,然后在factory
再把领域对象转换成DTO
返回到应用层。这样设计也符合DDD
的设计原则,不过做多了一层转换,我不太推荐这种实现方式。需要指出一下,有些人把dao
层设计成Repository
,这种设计是错误的,dao
层是技术层面的,是对数据库技术的抽象,而Repository
是面向领域聚合根的,是两个不同层面的抽象。
* 基础Repository接口
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
public interface BaseRepository<T extends DomainObjId, DomainObj extends BaseDomainObj<T>, DataObj extends BaseDataObj> {
/**
* 保存
*
* @param data
* @return
*/
boolean save(DomainObj data);
/**
* 批量保存
*
* @param datas
* @return
*/
boolean saveBatch(List<DomainObj> datas);
}
4.4.1.14 BaseRepositoryImpl
基础仓储实现,提供保存和批量保存的默认实现,该类有两个字段,分别是dao
和converter
,save
方法实现了新增、修改和删除的通用逻辑,saveBatch
则是save
方法的批量操作版本
public class BaseRepositoryImpl<T extends DomainObjId, DomainObj extends BaseDomainObj<T>, DataObj extends BaseDataObj> implements BaseRepository<T, DomainObj, DataObj> {
private final IService<DataObj> dao;
private final BaseConverter<T, DomainObj, DataObj> converter;
public BaseRepositoryImpl(IService<DataObj> dao, BaseConverter<T, DomainObj, DataObj> converter) {
this.dao = dao;
this.converter = converter;
}
public IService<DataObj> getDao() {
return dao;
}
@Override
public boolean save(DomainObj data) {
if (data == null) {
return false;
}
DataObj dataObj = converter.toDataObj(data);
if (data.getId().getStatus().equals(EntityStatus.NEW)) {
dao.save(dataObj);
data.getId().setValue(dataObj.getId());
} else if (data.getId().getStatus().equals(EntityStatus.CHANGED)) {
return dao.updateById(dataObj);
} else if (data.getId().getStatus().equals(EntityStatus.DELETED)) {
return dao.removeById(dataObj.getId());
}
return true;
}
@Override
public boolean saveBatch(List<DomainObj> datas) {
if (CollectionUtils.isEmpty(datas)) {
return false;
}
Map<EntityStatus, List<DomainObj>> group = datas.stream().collect(Collectors.groupingBy(it -> it.getId().getStatus()));
List<DomainObj> datas2Add = group.get(EntityStatus.NEW);
List<DomainObj> datas2Update = group.get(EntityStatus.CHANGED);
List<DomainObj> datas2Delete = group.get(EntityStatus.DELETED);
List<DataObj> entityList = converter.toDataObjs(datas2Add);
if (ObjectUtils.isNotEmpty(datas2Delete)) {
dao.removeByIds(datas2Delete.stream().map(it -> it.getId().getValue()).collect(Collectors.toList()));
}
if (CollectionUtils.isNotEmpty(entityList)) {
dao.saveBatch(entityList);
Map<String, Long> map = entityList.stream().collect(Collectors.toMap(DataObj::getPreId, DataObj::getId));
datas2Add.forEach(it -> it.getId().setValue(map.get(it.getId().getPreId())));
}
dao.updateBatchById(converter.toDataObjs(datas2Update));
return true;
}
}
4.4.1.15 ProductRepositoryImpl
商品仓储实现,所有实体的仓储都是聚合根维度的,非聚合根的实体没有独立的仓储实现,因为对子实体的更新操作必须通过聚合根进行。下面的商品仓储实现类有两个字段,分别是productDetailRepository
(商品详情仓储实现)和productAttributeRepository
(商品属性仓储实现),他们没有属于他们自己独有的接口和实现,而是通用的BaseRepositoryImpl
类型,因为他们不是聚合根的仓储,不能离开聚合根仓储单独使用。商品仓储的save
方法依次调用父类的save
通用方法、productDetailRepository
的save
方法、productAttributeRepository
的save
方法,因为商品详情和商品属性需要依赖商品实体的id
,由于数据库id
都是按照自增方式生成的,所以ProductRepositoryImpl
必须先调用父类的save
方法保存Product
本身。如果是使用雪花算法预生成id
的,则商品、商品属性、商品详情的保存顺序就无关紧要。
/**
* 商品Repository接口实现
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Component
public class ProductRepositoryImpl extends BaseRepositoryImpl<ProductId, Product, ProductDO> implements ProductRepository {
private BaseRepositoryImpl<ProductDetailId, ProductDetail, ProductDetailDO> productDetailRepository;
private BaseRepositoryImpl<ProductAttributeId, ProductAttribute, ProductAttributeDO> productAttributeRepository;
public ProductRepositoryImpl(IService<ProductDO> productDao, IService<ProductDetailDO> detailDao, IService<ProductAttributeDO> attributeDao) {
super(productDao, ProductConverter.INSTANCE);
productDetailRepository = new BaseRepositoryImpl<>(detailDao, ProductConverter.ProductDetailAssembler.INSTANCE);
productAttributeRepository = new BaseRepositoryImpl<>(attributeDao, ProductConverter.ProductAttributeAssembler.INSTANCE);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean save(Product data) {
super.save(data);
productDetailRepository.save(data.getDetail());
productAttributeRepository.saveBatch(data.getAttributes());
return true;
}
@Override
public boolean saveBatch(List<Product> datas) {
if (CollectionUtils.isEmpty(datas)) {
return false;
}
super.saveBatch(datas);
List<ProductDetail> details = datas.stream().map(Product::getDetail).collect(Collectors.toList());
productDetailRepository.saveBatch(details);
List<ProductAttribute> attrs = datas.stream().map(Product::getAttributes).filter(Objects::nonNull)
.flatMap(Collection::stream).collect(Collectors.toList());
productAttributeRepository.saveBatch(attrs);
return true;
}
}
4.4.1.16 ProductServiceImpl
商品应用层实现,这层是很薄的一层实现,每个方法都是是面向用例的,更新类方法加了@Transactional
事务注解,具有原子性
/**
* 商品Service实现
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Slf4j
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private ProductRepository repository;
@Autowired
private ProductFactory factory;
/**
* 新建商品
*
* @param createDTO
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean create(ProductCreateDTO createDTO) {
Product product = factory.build(createDTO);
return repository.save(product);
}
/**
* 编辑商品
*
* @param editDTO
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean edit(ProductEditDTO editDTO) {
Product product = factory.build(editDTO);
return repository.save(product);
}
/**
* 分页查询
*
* @param pageQuery
* @return
*/
@Override
public PageResult<ProductDTO> getByPage(ProductPageQueryDTO pageQuery) {
return factory.getByPage(pageQuery);
}
/**
* 根据id查询详情
*
* @param id
* @return
*/
@Override
public ProductDetailDTO getDetailById(Long id) {
return factory.getDetailById(id);
}
}
4.4.2 api层服务设计
api层主要负责与领域层服务打交道,重点讲一下防腐层和应用层
防腐层
防腐层作用主要封装与其他系统交互的接口,避免其他系统的数据结构侵入当前系统,造成系统后续扩展性变差。且这层非常适合对查询操作做缓存
更新类操作时序图
查询类操作时序图
4.4.2.1 ProductClient
商品客户端实现,主要对领域层的数据结构和调用方式进行封装,出入参使用当前系统定义的数据结构。对于命令类操作的输入参数,如增删改以Cmd作为后缀,查询类操作的入参则以Query作为后缀,而不是什么数据结构都无脑以DTO结尾,因为防腐层的数据结构不作为数据传输用。
/**
* 商品客户端实现
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Slf4j
@Component
public class ProductClient {
@Resource
private ProductService remoteProductService;
/**
* 新建商品
*
* @param cmd
* @return
*/
public boolean create(ProductCreateCmd cmd) {
ProductCreateDTO dto = ProductConverter.INSTANCE.convert(cmd);
Response<Boolean> result = remoteProductService.create(dto);
result.checkResult();
return result.getData();
}
/**
* 编辑商品
*
* @param cmd
* @return
*/
public boolean edit(ProductEditCmd cmd) {
ProductEditDTO dto = ProductConverter.INSTANCE.convert(cmd);
Response<Boolean> result = remoteProductService.edit(dto);
result.checkResult();
return result.getData();
}
/**
* 分页查询
*
* @param pageQuery
* @return
*/
public PageResult<Product> getByPage(ProductPageQuery pageQuery) {
ProductPageQueryDTO dto = ProductConverter.INSTANCE.convert(pageQuery);
Response<PageResult<ProductDTO>> result = remoteProductService.getByPage(dto);
result.checkResult();
PageResult<ProductDTO> page = result.getData();
PageResult<Product> pageResult = (PageResult) page;
if (CollectionUtils.isEmpty(page.getRecords())) {
return pageResult;
}
List<Product> list = page.getRecords().stream()
.map(ProductConverter.INSTANCE::convert).collect(Collectors.toList());
pageResult.setRecords(list);
return pageResult;
}
/**
* 根据id查询详情
*
* @param id
* @return
*/
public ProductDetail getDetailById(Long id) {
Response<ProductDetailDTO> result = remoteProductService.getDetailById(id);
result.checkResult();
return ProductConverter.INSTANCE.convert(result.getData());
}
}
4.4.2.2 ProductFactory
api
层的工厂主要是有两个职责,1)对命令型操作的输入参数进行校验,主要是校验其他领域的参数,还有就是查询需要依赖其他领域的数据,构造好完整的Cmd
参数返回给应用层;2)对查询类操作主要是对各个领域层的数据进行聚合,返回完整的业务数据给应用层。下面看ProductFactory
的第一个build
方法,其作用是构造商品创建参数。首先调用供应商领域服务的客户端根据供应商id
查询供应商信息,校验供应商是否存在。这里的供应商属于供应商领域的数据结构,对它的校验就需要在商品域的api
层来做。而商品域固有的属性的合法性则放在商品域的领域层去做校验,没必要在api
层调一次领域层去校验数据合法性,因为后续还是需要调用商品域领域服务的创建接口的。下面继续看getDetailById
方法,它首先调用了商品域领域层的productClient
的getDetailById
获取商品详情,由于商品域只保存了用户id,没有保存用户名称,后续调用了用户领域层的接口根据用户id获取了用户信息,最终构造出完整的ProductDetailDTO
返回给应用层。
/**
* 商品工厂
*
* @author Joven
* @date 2022/6/28 15:25
*/
@Component
public class ProductFactory {
@Autowired
private SupplierClient supplierClient;
@Autowired
private ProductClient productClient;
@Autowired
private UserClient userClient;
public ProductCreateCmd build(ProductCreateDTO dto) {
Long supplierId = dto.getSupplierId();
Supplier supplier = supplierClient.getById(supplierId);
CheckUtils.assertTrue(supplier != null, ExceptionEnum.SUPPLIER_NOT_EXISTS.createException());
return ProductConverter.INSTANCE.convert(dto);
}
public ProductEditCmd build(ProductEditDTO dto) {
Long supplierId = dto.getSupplierId();
Supplier supplier = supplierClient.getById(supplierId);
CheckUtils.assertTrue(supplier != null, ExceptionEnum.SUPPLIER_NOT_EXISTS.createException());
return ProductConverter.INSTANCE.convert(dto);
}
public PageResult<ProductDTO> getByPage(ProductPageQueryDTO dto) {
PageResult<Product> page = productClient.getByPage(ProductConverter.INSTANCE.convert(dto));
PageResult<ProductDTO> pageResult = (PageResult) page;
if (CollectionUtils.isEmpty(pageResult.getRecords())) {
return pageResult;
}
List<Long> ids = page.getRecords().stream()
.map(it -> Lists.newArrayList(it.getCreateUser(), it.getUpdateUser()))
.flatMap(Collection::stream).collect(Collectors.toList());
Map<Long, String> userMap = userClient.listByIds(ids)
.stream().collect(Collectors.toMap(User::getId, User::getName));
List<ProductDTO> list = page.getRecords().stream()
.map(it -> {
ProductDTO product = ProductConverter.INSTANCE.convert(it);
product.setCreateUserName(userMap.get(it.getCreateUser()));
product.setUpdateUserName(userMap.get(it.getUpdateUser()));
return product;
}).collect(Collectors.toList());
pageResult.setRecords(list);
return pageResult;
}
public ProductDetailDTO getDetailById(Long id) {
ProductDetail product = productClient.getDetailById(id);
if (product == null) {
return null;
}
Map<Long, String> userMap = userClient.listByIds(Lists.newArrayList(product.getCreateUser(), product.getUpdateUser()))
.stream().collect(Collectors.toMap(User::getId, User::getName));
ProductDetailDTO dto = ProductConverter.INSTANCE.convert(product);
dto.setCreateUserName(userMap.get(dto.getCreateUser()));
dto.setUpdateUserName(userMap.get(dto.getUpdateUser()));
return dto;
}
}
4.4.2.3 ProductServiceImpl
商品应用层服务实现,对于命令类操作,先调用工厂构造命令入参,然后调用客户端的命令方法。一个应用层的命令类方法可能需要调用多个不同系统的命令方法,这就要考虑分布式事务来保证事务的一致性。我们项目中使用seata来实现分布式事务。demo中的命令方法没有涉及到调用多个系统的命令方法,故没有使用分布式事务,但是这存在一定的风险,因为当调用领域服务响应超时,而领域服务实际执行成功时,当前服务返回给前端的结果是失败,而操作实际是成功,用户却误以为失败了。不过也可以通过三种方式解决这个问题,1)最简单的是使用seata分布式事务;2)在ProductClient实现重试,领域层的接口保证幂等性;3)领域服务在操作成功之后调用api服务提供的一个回调接口,api回调接口再决定要不要通过手机、邮件或者站内消息等通知用户。对于通过不断重试一定可以执行成功,且后续操作不需要依赖消息消费结果的操作,可以使用mq的方式来实现事务的最终一致性。实现分布式事务的最终一致性可以通过所有当前操作都执行成功后,最后再推送mq,或者通过事务消息的方式,来避免当前系统执行失败而mq被成功推送到其他系统,导致系统之间的数据不一致。
/**
* 商品Service实现
*
* @author Joven
* @date 2022-01-06 18:21:27
*/
@Slf4j
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private ProductFactory factory;
@Autowired
private ProductClient productClient;
/**
* 新建商品
*
* @param createDTO
* @return
*/
@Override
public boolean create(ProductCreateDTO createDTO) {
ProductCreateCmd cmd = factory.build(createDTO);
return productClient.create(cmd);
}
/**
* 编辑商品
*
* @param editDTO
* @return
*/
@Override
public boolean edit(ProductEditDTO editDTO) {
ProductEditCmd cmd = factory.build(editDTO);
return productClient.edit(cmd);
}
/**
* 分页查询
*
* @param pageQuery
* @return
*/
@Override
public PageResult<ProductDTO> getByPage(ProductPageQueryDTO pageQuery) {
return factory.getByPage(pageQuery);
}
/**
* 根据id查询详情
*
* @param id
* @return
*/
@Override
public ProductDetailDTO getDetailById(Long id) {
return factory.getDetailById(id);
}
}
4.4.3 问题讨论
4.4.3.1 哪种领域实体模型更好?
我看到某些博客,说有失血模型、贫血模型、充血模型、胀血模型4种模型。失血模型是指实体只有getter、setter的模型。贫血模型是指实体除了有getter、setter还有其他有意义的方法,不过其他方法不依赖其他技术框架的逻辑,只对模型本身的属性进行运算。充血模型则把部分应用层依赖技术框架的逻辑也放在领域实体中实现。而胀血模型则把应用层所有实现逻辑都放在领域实体中实现。我是比较推荐贫血模型,它有自己比较纯粹的业务逻辑,不依赖底层技术框架,所有逻辑属于纯内存操作,可测试性更强,实现起来也更优雅。
4.4.3.2 同一个本地事务可以修改多个聚合吗?
正常来说是不推荐的,出现这种情况一般是领域模型设计有问题,或者产品设计的问题导致某个用例就是要同时操作多个聚合,但是如果有充分的理由是可以这么做的。对于一个领域服务的一个应用层方法需要同时更新或者新增多个不同聚合的情况,比较官方的做法是,当前本地事务只更新或者新建同一个类型的聚合,其他类型的聚合则通过推送领域事件的方式进行异步处理,实现多个不同类型聚合之间的最终一致性。至于为什么这么做,我自己的理解是不同的聚合实际上是可以拆分成不同的领域的,不同的领域是独立部署的,将来如果需要拆分出多个领域,对代码的迁移是有很大帮助的。例如,一个创建商品的用例,如果供应商不存在则新建这个供应商。如果商品和供应商都设计成同一个领域的两个不同聚合,如果创建一个供应商不存在的商品,那么这个用例就涉及到同时操作不同聚合的问题了。比较好的设计是把商品和供应商拆分为商品领域和供应商领域,然后在商品领域的api服务通过分布式事务来操作两个不同的领域。
4.4.4 gitee地址
ddd-demo: 领域驱动设计商品项目demo (gitee.com)
5、推荐DDD学习相关博客和网站
转载自:https://juejin.cn/post/7131186996277411876