一、DDD概述
DDD,即领域驱动设计,核心是不断提炼通用语言并用于与领域专家等团队所有成员交流,并用代码来表达出一个与通用语言一致的领域模型。
通用语言:通过团队交流达成共识的能够简单清晰准确传递业务规则的语言(可以是文字、图片等)
领域:软件系统要解决的问题域,是有边界的。领域一般包含多个子域,子域根据其功能划分为核心域、通用域、支撑域。
-
限界上下文:描述领域边界,一个限界上下文可能包含多个子域,但一般实践上都以一对一为好。应用单元和部署单元一般也与限界上下文一致。
-
限界上下文映射:多个上下文之间如何进展系统交互集成。
-
领域模型:对我们软件系统中要解决问题的抽象表达(解决方案)。模型一般在一个限界上下文中有效。
- 模块
- 聚合根
- 实体
- 值对象
- 领域事件
- 仓储定义
- 领域服务
- 工厂
-
限界上下文映射的反腐层定义
-
领域实现:
- 领域模型
- 应用服务
- 基础设施
- 服务暴露
- 仓储实现
- 反腐层实现
-
实践步骤为:
- 找到子域
- 识别核心域、通用域、支撑域
- 确定限界上下文映射
- 在每个子域内设计领域模型
- 实现领域模型和应用
二、示例需求分析
要实现多规格商品的创建和查询。
Spu相关操作如下:
@RestController
@RequestMapping("/v1/spu")
public class SpuRestApi {
//spu创建
@PostMapping("/create")
public Result<Long> create(@RequestBody SpuCreateParam param){
}
//spu详情
@GetMapping("/detail")
public Result<SpuVO> findSpuById(Long shopId, Long spuId){
}
}
SpuCreateParam的定义如下:
其中spuNo,skuNo,barCodes等要求唯一
{
"shopId": 0, //店铺ID
"categoryId": 0,//分类ID
"unitId": 0,//单位ID
"name": "string",//SPU名称,长度20,不能为空
"spuNo": "string",//SPU编码,不可变更,用于各系统间传递
"barCodes": [//SPU条码列表,最多10个,用于搜索
"string"
],
"photoTuple": {//图片列表,最多10张
"photos": [
{
"url": "string" //必须为合法url,每个url长度最大为120
}
]
},
"specDefineTuple": {//规格定义,项与值都不能重复,相对顺序用于sku列表的排序
"defines": [//规格项列表,如【颜色+尺寸】
{
"key": "string",//规格项,如颜色
"values": [//规格值列表,如红色、白色等
"string"
]
}
]
},
"skus": [//SKU列表,要符合规格定义的笛卡尔积
{
"skuNo": "string",//SKU编码
"barCodes": [//SKU条码,用于SKU维度搜索,最多10个
"string"
],
"retailPrice": 0,//零售价,分,最大为 100w*100
"specTuple": {//与规格定义笛卡尔积中每一个组合对应,如【红色 + 20号】
"specs": [
{
"key": "string", //规格项,如颜色
"value": "string" //规格值,如红色
}
]
}
}
]
}
三、分层架构 + 面向过程设计
特征:
- 包划分上以功能为准,如所有model放一个包,所有service放另一个
- 服务依赖:SpuApi -> SpuService -> SpuMapper -> Mybatis
- 创建时数据流向:SpuSaveParam -> Spu -> table
- 查询时数据流向:SpuVO <- Spu <- table
- 整个SpuService只包含简单的CRUD操作,尤其是更新操作,一般倾向于只有一个万能的Update。从方法名称,你看不出任何的业务含义。
- SpuService:一个服务方法几乎包含了所有的逻辑,负责校验、获取外部信息、组装、转换SpuSaveParam为Spu、并调用SpuMapper保存到数据库。
- Spu为失血模型,只包含字段,没有get/set之外的方法,Spu与table的字段几乎一一对应。
优缺点:
- 在逻辑很简单场景下,crud迭代最快,面向过程与人类思考的方式相近。
- 在复杂场景下,如spu创建涉及大量的校验组装等,很快SpuService.save方法就会过于庞大。另外,有大量的校验逻辑,在更新场景下是可以复用的。
四、pipeline设计
spu的校验可以根据spu的内聚信息块划分成多个checker,然后将多个checker组合成一个pipeline流,从而可以更好的重用,并快速应对新增的校验(加个checker就行了)。
另外,获取外部信息,如category、unit等,也可以用rxjava等并发去做,以加快速度。
缺点:
- 但是pipeline要求设计出一个好的context,用于上下文传递,一般会出现context的腐化。
- 另外,service的主逻辑不清晰,读代码的成本变高。
五、六边形架构 + 面向对象设计
特征:
- 采用分治法,将数据、约束、行为等划分到最能表达它的领域模型中。
- 包划分上以业务模块为准,同业务的identity、valueObject、event、repository、service等放在一个包下。
- SpuAppService:为应用服务,只是调用领域服务和仓储等来串流程,不包含业务逻辑,如校验等。
- 领域服务:本实例中没有领域服务,如果有的话,会定义为SpuXxxService(Xxx指明业务操作)
- Spu: 包含字段和行为,如校验在构造和set时内置,方法体现业务操作如changeName,不是单一的update动作。
- SpuRepo:定义了仓储的操作,实现在infra中基于mybatis等
- MybatisSpuRepo: 实现
- SpuMapper:基于mybatis访问数据库
具体包划分如下:
- domain
- shop
- category
- unit
- spu
- sku
- spec
- code
- event
- Spu
- SpuRepo
- SpuService
- infra
- repo
- proxy
具体代码实现如下:
class SpuAppService{ //应用服务
@Transactional
public SpuId save(SpuCreateParam param){
ShopId shopId = new ShopId(param.getShopId());
//调用外部服务获取关联信息,并验证了关联信息的合法性
Category category = categoryService.findById(param.getShopId(), param.getCategoryId());
Unit unit = unitService.findById(param.getShopId(), param.getUnitId());
//调用Repo生成ID,后续流程中很有可能需要它
SpuId spuId = spuRepo.nextId();
//SpuNo构造时验证参数的合法性,不包含特殊字符,不会超长等
//lockSpuNo, 用于保证编码的唯一性,注意要实现为可重入锁
SpuNo spuNo = codeLockService.lockSpuNo(new SpuNo(shopId, spuId, param.getSpuNo()));
//与SpuNo相似
SpuBarCodeTuple spuBarCodeTuple = codeLockService.lockSpuBarCodes(new SpuBarCodeTuple(shopId,spuId,param.getBarCodes()));
//用于根据参数生成对应的sku列表
List<Sku> skus = skuService.buildSkus(shopId, spuId, param.getSkus());
Spu spu = Spu.builder()
.shopId(shopId)
.spuId(spuId)
.no(spuNo)
.barCodes(spuBarCodeTuple)
.name(param.getName())
.photoTuple(param.getPhotoTuple())
.category(category)
.unit(unit)
.specDefineTuple(param.getSpecDefineTuple())
.skus( skus) //构造时触发笛卡尔积相关校验
.build(); //当实例化Spu时会调用构造函数来校验以上各信息的约束条件
//在本步骤前,spu和sku都未生成
//spu是聚合根,其包含sku的实体的创建。
//因为sku的规格组与spu的规格定义是有对应约束的。
spuRepo.save(spu);
return spuId;
}
}
class MybatisSpuRepo implements SpuRepo{//仓储实现
@Override
public void save(Spu spu) {
spuMapper.create(spu);
skuMapper.batchCreate(spu.getSkuTuple().getSkus());
}
}
@Mapper
public interface SpuMapper { //Mybatis实现数据库访问
@Options(useGeneratedKeys = true, keyProperty = "id")
@InsertProvider(type = SpuMapper.class, method = "createSql")
void create(Spu spu);
@Results(
id = "spuDetail",
value = {
@Result(property = "shopId.id", column = "shop_id"),//复杂对象映射
@Result(property = "barCodes", column = "bar_codes", typeHandler = SpuBarCodeTupleHandler.class)) //复杂对象JSON化为字符串
}
)
@Select("select * from spu where shop_id = #{shopId} and spu_id = #{spuId}")
Spu findById(@Param("shopId") Long shopId, @Param("spuId") Long spuId);
}
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Getter
@Setter(AccessLevel.PROTECTED)
@Accessors(chain = true)
@Builder
@Slf4j
public class Spu extends IdentifiedEntity {//实体,同时也是聚合根
@NotNull(message = "商家不能为空")
private ShopId shopId;
@NotNull(message = "ID不能为空")
private SpuId spuId;
@NotNull(message = "名称不可为空")
@Size(max = 100, min = 1, message = "名称字符数为1到100个字")
private String name;
@NotNull(message = "编码不能为空")
private SpuNo no;
//SpuBarCodeTuple内部保证其合法性,spu不用管理其细节,只要不为空,这个条码组就是合法的。
@NotNull(message = "条码组不能为空")
private SpuBarCodeTuple barCodes;
@NotNull(message = "图片不能为空")
private PhotoTuple photoTuple;
@NotNull(message = "分类不能为空")
private CategoryId categoryId;
//导航属性,可空,在某些需要的场景下去加载它
//如Spu详情中应该包含,而spu列表中可以不存在
private Category category;
@NotNull(message = "单位不能为空")
private UnitId unitId;
private Unit unit;
@NotNull(message = "规格定义不能为空")
private SpecDefineTuple specDefineTuple;
@ListDistinct(message = "规格不能重复")
@Size(max = 600, message = "规格数最大不能超过600")
private List<Sku> skus = new ArrayList<>();
protected Spu(){ //用于使mybatis等框架能正常工作
}
public Spu(
ShopId shopId,
SpuId spuId,
SpuNo no,
SpuBarCodeTuple barCodes,
String name,
PhotoTuple photoTuple,
Category category,
Unit unit,
SpecDefineTuple specDefineTuple,
List<Sku> skuTuple) {
this.shopId = shopId;
this.spuId = spuId;
this.name = name;
this.no = no;
this.barCodes= barCodes;
this.photoTuple = photoTuple;
this.category = category;
this.categoryId = category.getCategoryId();
this.unit = unit;
this.unitId = unit.getUnitId();
this.specDefineTuple = specDefineTuple;
this.skus = skuTuple;
//整合valiation框架,能基于上面定义的注解去校验,从而让校验以声明式写法来表述
super.validate();
//发布领域事件
DomainEventPublisher.publish(new SpuCreatedEvent()
.setShopId(shopId)
.setSpuId(spuId)
);
}
public Spu loadCategory(){ //加载分类
if(this.category!=null){
return this;
}
if(categoryId!=null){
this.category = DomainRegistry.repo(CategoryRepo.class).findByShopIdAndId(shopId, categoryId);
}
return this;
}
}
@ToString
@EqualsAndHashCode
@Getter
@Setter(AccessLevel.PROTECTED)
@Accessors(chain = true)
public class SpuBarCodeTuple extends AssertionConcern {
@NotNull(message = "商家不能为空")
private ShopId shopId;
@NotNull(message = "spuID不能为空")
private SpuId spuId;
@NotNull(message = "条码列表不能为空")
@ListStringSize(max = 20, message = "条码最多20个字符")
@ListDistinct(message = "条码列表不能重复")
@Size(max = 10, min = 0, message = "最多支持10个条码")
List<String> codes = new ArrayList<>();
public SpuBarCodeTuple(ShopId shopId, SpuId spuId, List<String> codes) {
this.codes = codes;
this.shopId = shopId;
this.spuId = spuId;
this.validate(); //触发约束校验
}
protected SpuBarCodeTuple() {
}
}
@Target({ ElementType.FIELD, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = { ListStringSize.ListStringSizeChecker.class })
public @interface ListStringSize { //自定义约束
int min() default 0;
int max() default Integer.MAX_VALUE;
String message() default "列表元素大小不符合定义";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
public static class ListStringSizeChecker implements ConstraintValidator<ListStringSize, List<String>> {
ListStringSize annotation;
@Override
public void initialize(ListStringSize constraintAnnotation) {
annotation = constraintAnnotation;
}
@Override
public boolean isValid(List<String> objects, ConstraintValidatorContext constraintValidatorContext) {
if(objects==null){
return true;
}
return objects.stream().allMatch(s-> s.length()<=annotation.max() && s.length()>=annotation.min());
}
}
}
参考
- DDD理论学习系列——案例及目录
- 《实现领域驱动设计》
- 示例实现