DDD落地之仓储
一.前言
hello,everyone。又到了周末了,没有出去玩,继续肝。从评论与粉丝私下的联系来看,大家对于DDD架构的热情都比较高。但是因为抽象化的概念较多,因此理解上就很困难。
昨天媳妇儿生病了在医院,她挂点滴的时候,我也没闲下来,抓紧时间做出了DDD的第一版demo,就冲这点,
大家点个关注,点个赞,不过分吧。
这个项目我会持续维护,针对读者提出的issue与相关功能点的增加,我都会持续的补充。
DDD系列博客
本文将给大家介绍的同样是DDD中的一个比较好理解与落地的知识点-仓储。
本系列为MVC框架迁移至DDD,考虑到国内各大公司内还是以mybatis作为主流进行业务开发。因此,demo中的迁移与本文的相关实例均以mybatis进行演示。至于应用仓储选型是mybatis还是jpa,文中会进行分析,请各位仔细阅读本文。
二.仓储
2.1.仓储是什么
原著《领域驱动设计:软件核心复杂性应对之道》 中对仓储的有关解释:
为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的Aggregate提供Repository。让客户始终聚焦于型,而将所有对象存储和访问操作交给Repository来完成。
上文通俗的讲,当领域模型一旦建立之后,你不应该关心领域模型的存取方式。仓储就相当于一个功能强大的仓库,你告诉他唯一标识:例如订单id
,它就能把所有你想要数据按照设置的领域模型一口气组装返回给你。存储时也一样,你把整块订单数据给他,至于它怎么拆分,放到什么存储介质【DB,Redis,ES等等】
,这都不是你业务应该关心的事。你完全信任它能帮助你完成数据管理工作。
2.2.为什么要用仓储
先说贫血模型
的缺点:
有小伙伴之前提出过不知道贫血模型的定义,这里做一下解释。贫血模型:PO,DTO,VO这种常见的业务POJO,都是数据java里面的数据载体,内部没有任何的业务逻辑。所有业务逻辑都被定义在各种service里面,service做了各种模型之间的各种逻辑处理,臃肿且逻辑不清晰。充血模型:建立领域模型形成聚合根,在聚合根即表示业务,在聚合内部定义当前领域内的业务处理方法与逻辑。将散落的逻辑进行收紧。
- 无法保护模型对象的完整性和一致性: 因为对象的所有属性都是公开的,只能由调用方来维护模型的一致性,而这个是没有保障的;之前曾经出现的案例就是调用方没有能维护模型数据的一致性,导致脏数据使用时出现bug,这一类的 bug还特别隐蔽,很难排查到。
- 对象操作的可发现性极差: 单纯从对象的属性上很难看出来都有哪些业务逻辑,什么时候可以被调用,以及可以赋值的边界是什么;比如说,Long类型的值是否可以是0或者负数?
- 代码逻辑重复: 比如校验逻辑、计算逻辑,都很容易出现在多个服务、多个代码块里,提升维护成本和bug出现的概率;一类常见的bug就是当贫血模型变更后,校验逻辑由于出现在多个地方,没有能跟着变,导致校验失败或失效。
- 代码的健壮性差: 比如一个数据模型的变化可能导致从上到下的所有代码的变更。
- 强依赖底层实现: 业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。
虽然贫血模型有很大的缺陷,但是在我们日常的代码中,我见过的99%的代码都是基于贫血模型,为什么呢?
- 数据库思维: 从有了数据库的那一天起,开发人员的思考方式就逐渐从
写业务逻辑
转变为了写数据库逻辑
,也就是我们经常说的在写CRUD代码
。 - 贫血模型“简单”: 贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情
- 脚本思维: 很多常见的代码都属于
脚本
或胶水代码
,也就是流程式代码
。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。
但是可能最核心的原因在于,实际上我们在日常开发中,混淆了两个概念:
- 数据模型(Data Model): 指业务数据该如何持久化,以及数据之间的关系,也就是传统的ER模型。
- 业务模型/领域模型(Domain Model): 指业务逻辑中,相关联的数据该如何联动。
所以,解决这个问题的根本方案,就是要在代码里区分Data Model和Domain Model,具体的规范会在后文详细描述。在真实代码结构中,Data Model和 Domain Model实际上会分别在不同的层里,Data Model只存在于数据层,而Domain Model在领域层,而链接了这两层的关键对象,就是Repository。
能够隔离我们的软件(业务逻辑)和固件/硬件(DAO、DB),让我们的软件变得更加健壮,而这个就是Repository的核心价值。
三.落地
3.1.落地概念图
DTO Assembler: 在Application层 【应用服务层】 ,Entity
到DTO
的转化器有一个标准的名称叫DTO Assembler
【汇编器】 。
DTO Assembler的核心作用就是将1个或多个相关联的Entity转化为1个或多个DTO。
Data Converter: 在Infrastructure层 【基础设施层】 ,Entity
到DO
的转化器没有一个标准名称,但是为了区分Data Mapper,我们叫这种转化器Data Converter
。这里要注意Data Mapper通常情况下指的是DAO,比如Mybatis的Mapper。
3.2.Repository规范
首先聚合
和仓储
之间是一一对应
的关系。仓储
只是一种持久化的手段,不应该包含任何业务操作。
接口名称不应该使用底层实现的语法
定义仓储接口,接口中有save类似的方法,与面向集合的仓储的不同点:面向集合的仓储只有在新增时调用add即可,面向持久化的无论是新增还是修改都要调用save
出参入参不应该使用底层数据格式:
需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate Root),而不应该直接操作底层的 DO 。更近一步,Repository 接口实际上应该存在于Domain层,根本看不到 DO 的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
应该避免所谓的“通用”Repository模式
很多 ORM 框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。当然,这里避免通用不代表不能有基础接口和通用的帮助类
不要在仓储里面编写业务逻辑
首先要清楚的是,仓储是存在基础设施层的,并不会去依赖上层的应用服务,领域服务等。
仓储内部仅能依赖mapper,es,redis这种存储介质包装框架的工具类。save动作,仅对传入的聚合根进行解析放入不同的存储介质,你想放入redis,数据库还是es,由converter来完成聚合根的转换解析。同样,从不同的存储介质中查询得到的数据,交给converter来组装。
不要在仓储内控制事务
你的仓储用于管理的是单个聚合,事务的控制应该取决于业务逻辑的完成情况,而不是数据存储与更新情况。
3.3.CQRS仓储
回顾一下这张图,可以发现增删改数据模型走了DDD模型。而查询则从应用服务层直接穿透到了基础设施层。
这就是CQRS模型,从数据角度来看,增删改数据非幂等操作,任何一个动作都能对数据进行改动,称为危险行为
。而查询,不会因为你查询次数的改变,而去修改到数据,称为安全行为
。而往往功能迭代过程中,数据修改的逻辑还是复杂的,因此建模也都是针对于增删改数据而言的。
那么查询数据有什么原则吗?
构建独立仓储
查询的仓储与DDD中的仓储应该是两个方法,互相独立。DDD中的仓储方法严格意义上只有三个:save,delete,byId,内部没有业务逻辑,仅对数据做拆分组合。查询仓储方法可以根据用户需求,研发需求来自定义仓储返回的数据结构,不限制返回的数据结构为聚合,可以是限界范围内的任意自定义结构。
不要越权
不要再查询仓储内做太多的sql逻辑,数据查询组装交给assember。
利用好assember
类似于首页,一个接口可能返回的数据来源于不同的领域,甚至有可能不是自己本身业务服务内部的。
这种复杂的结果集,交给assember来完成最终结果集的组装与返回。结构足够简单的情况下,用户交互层【controller,mq,rpc】甚至可以直接查询仓储的结果进行返回。
当然还有很多其他博文中会说,如果查询结果足够简单,甚至可以直接在controller层调用mapper查询结果返回。除非你是一个固定的字典服务或者规则表,否则哪怕业务再简单,你的业务也会迭代,后续查询模型变化了,dao层里面的查询逻辑就外溢到用户交互层,显然得不偿失。
3.4.ORM框架选型
目前主流使用的orm框架就是mybatis与jpa。国内使用mybatis多,国外使用jpa多。两者框架上的比较本文不做展开,不清楚两个框架实现差异的,可以自行百度。
那么我们如果做DDD建模的话到底选择哪一种orm框架更好呢?
mybatis是一个半自动框架(当然现在有mybatis-plus的存在,mybatis也可以说是跻身到全自动框架里面了)
,国内使用它作为orm框架是主流。为什么它是主流,因为它足够简单,设计完表结构之后,映射好字段就可以进行开发了,业务逻辑可以用胶水
一个个粘起来。而且在架构支持上,mybatis不支持实体嵌套实体,这个在领域模型建模结束后的应用上就优于mybatis。
当然我们今天讨论的是架构,任何时候,技术选型不是决定我们技术架构的关键性因素。
jpa天生就具备做DDD的优势。但是这并不意味着mybatis就做不了DDD了,我们完全可以将领域模型的定义与orm框架的应用分离,单独定义converter去实现领域模型与数据模型之间的转换,demo中我也是这么给大家演示的。
当然,如果是新系统或者迁移时间足够多,我还是推荐使用JPA的,红红火火恍恍惚惚~
四.demo演示
需求描述,用户领域有四个业务场景
- 新增用户
- 修改用户
- 删除用户
- 用户数据在列表页分页展示
核心实现演示,不贴全部代码,完整demo可从文章开头的github仓库获取
4.1.领域模型
/**
* 用户聚合根
*
* @author baiyan
*/
@Getter
@NoArgsConstructor
public class User extends BaseUuidEntity implements AggregateRoot {
/**
* 用户名
*/
private String userName;
/**
* 用户真实名称
*/
private String realName;
/**
* 用户手机号
*/
private String phone;
/**
* 用户密码
*/
private String password;
/**
* 用户地址
*/
private Address address;
/**
* 用户单位
*/
private Unit unit;
/**
* 角色
*/
private List roles;
/**
* 新建用户
*
* @param command 新建用户指令
*/
public User(CreateUserCommand command){
this.userName = command.getUserName();
this.realName = command.getRealName();
this.phone = command.getPhone();
this.password = command.getPassword();
this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
this.relativeRoleByRoleId(command.getRoles());
}
/**
* 修改用户
*
* @param command 修改用户指令
*/
public User(UpdateUserCommand command){
this.setId(command.getUserId());
this.userName = command.getUserName();
this.realName = command.getRealName();
this.phone = command.getPhone();
this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
this.relativeRoleByRoleId(command.getRoles());
}
/**
* 组装聚合
*
* @param userPO
* @param roles
*/
public User(UserPO userPO, List roles){
this.setId(userPO.getId());
this.setDeleted(userPO.getDeleted());
this.setGmtCreate(userPO.getGmtCreate());
this.setGmtModified(userPO.getGmtModified());
this.userName = userPO.getUserName();
this.realName = userPO.getRealName();
this.phone = userPO.getPhone();
this.password = userPO.getPassword();
this.setAddress(userPO.getProvince(),userPO.getCity(),userPO.getCounty());
this.relativeRoleByRolePO(roles);
this.setUnit(userPO.getUnitId(),userPO.getUnitName());
}
/**
* 根据角色id设置角色信息
*
* @param roleIds 角色id
*/
public void relativeRoleByRoleId(List<Long> roleIds){
this.roles = roleIds.stream()
.map(roleId->new Role(roleId,null,null))
.collect(Collectors.toList());
}
/**
* 设置角色信息
*
* @param roles
*/
public void relativeRoleByRolePO(List roles){
if(CollUtil.isEmpty(roles)){
return;
}
this.roles = roles.stream()
.map(e->new Role(e.getId(),e.getCode(),e.getName()))
.collect(Collectors.toList());
}
/**
* 设置用户地址信息
*
* @param province 省
* @param city 市
* @param county 区
*/
public void setAddress(String province,String city,String county){
this.address = new Address(province,city,county);
}
/**
* 设置用户单位信息
*
* @param unitId
* @param unitName
*/
public void setUnit(Long unitId,String unitName){
this.unit = new Unit(unitId,unitName);
}
}
4.2.DDD仓储实现
/**
*
* 用户领域仓储
*
* @author baiyan
*/
@Repository
public class UserRepositoryImpl implements UserRepository {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleMapper roleMapper;
@Autowired
private UserRoleMapper userRoleMapper;
@Override
public void delete(Long id){
userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRolePO::getUserId,id));
userMapper.deleteById(id);
}
@Override
public User byId(Long id){
UserPO user = userMapper.selectById(id);
if(Objects.isNull(user)){
return null;
}
List userRoles = userRoleMapper.selectList(Wrappers.lambdaQuery()
.eq(UserRolePO::getUserId, id).select(UserRolePO::getRoleId));
List roleIds = CollUtil.isEmpty(userRoles) ? new ArrayList<>() : userRoles.stream()
.map(UserRolePO::getRoleId)
.collect(Collectors.toList());
List roles = roleMapper.selectBatchIds(roleIds);
return UserConverter.deserialize(user,roles);
}
@Override
public User save(User user){
UserPO userPo = UserConverter.serializeUser(user);
if(Objects.isNull(user.getId())){
userMapper.insert(userPo);
user.setId(userPo.getId());
}else {
userMapper.updateById(userPo);
userRoleMapper.delete(Wrappers.lambdaQuery().eq(UserRolePO::getUserId,user.getId()));
}
List userRolePos = UserConverter.serializeRole(user);
userRolePos.forEach(userRoleMapper::insert);
return this.byId(user.getId());
}
}
4.3.查询仓储
/**
*
* 用户信息查询仓储
*
* @author baiyan
*/
@Repository
public class UserQueryRepositoryImpl implements UserQueryRepository {
@Autowired
private UserMapper userMapper;
@Override
public Page<UserPageDTO> userPage(KeywordQuery query){
Page<UserPO> userPos = userMapper.userPage(query);
return UserConverter.serializeUserPage(userPos);
}
}
五.mybatis迁移方案
以OrderDO与OrderDAO的业务场景为例
- 生成Order实体类,初期字段可以和OrderDO保持一致
- 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成
- 写单元测试,确保Order和OrderDO之间的转化100%正确
- 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性
- 将原有代码里使用了OrderDO的地方改为Order
- 将原有代码里使用了OrderDAO的地方都改为用OrderRepository
- 通过单测确保业务逻辑的一致性。
六.总结
- 数据模型与领域模型需要正确区分,仓储是它们互相转换的抽象实现。
- 仓储对业务层屏蔽实现,即领域层不需要关注领域对象如何持久化。
- 仓储是一个契约,而不是数据访问层。它明确表明聚合所必需的数据操作。
- 仓储用于管理单个聚合,它不应该控制事务。
- ORM框架选型在迁移过程中不可决定性因此,可以嫁接转换器,但是还是优先推荐JPA。
- 查询仓储可以突破DDD边界,用户交互层可以直接进行查询。
七.特别鸣谢
来源:juejin.cn/post/7006595886646034463