前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >DDD落地之仓储

DDD落地之仓储

作者头像
柏炎
发布2022-08-23 14:37:08
1.1K0
发布2022-08-23 14:37:08
举报
文章被收录于专栏:深入浅出java后端

一.前言

hello,everyone。又到了周末了,没有出去玩,继续肝。从评论与粉丝私下的联系来看,大家对于DDD架构的热情都比较高。但是因为抽象化的概念较多,因此理解上就很困难。

昨天媳妇儿生病了在医院,她挂点滴的时候,我也没闲下来,抓紧时间做出了DDD的第一版demo,就冲这点,

这个项目我会持续维护,针对读者提出的issue与相关功能点的增加,我都会持续的补充。

查看demo,点这里,如果你觉得对你有帮助,欢迎star

DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

本文将给大家介绍的同样是DDD中的一个比较好理解与落地的知识点-仓储

本系列为MVC框架迁移至DDD,考虑到国内各大公司内还是以mybatis作为主流进行业务开发。因此,demo中的迁移与本文的相关实例均以mybatis进行演示。至于应用仓储选型是mybatis还是jpa,文中会进行分析,请各位仔细阅读本文。 我的第一本掘金小册《深入浅出DDD》已经在掘金上线,欢迎大家试读~

DDD的微信群我也已经建好了,由于文章内不能放二维码,大家可以加我微信**baiyan_lou**,备注DDD交流,我拉你进群,欢迎交流共同进步。

二.仓储

2.1.仓储是什么

原著《领域驱动设计:软件核心复杂性应对之道》 中对仓储的有关解释:

为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的Aggregate提供Repository。让客户始终聚焦于型,而将所有对象存储和访问操作交给Repository来完成。

上文通俗的讲,当领域模型一旦建立之后,你不应该关心领域模型的存取方式。仓储就相当于一个功能强大的仓库,你告诉他唯一标识:例如订单id,它就能把所有你想要数据按照设置的领域模型一口气组装返回给你。存储时也一样,你把整块订单数据给他,至于它怎么拆分,放到什么存储介质【DB,Redis,ES等等】,这都不是你业务应该关心的事。你完全信任它能帮助你完成数据管理工作。

2.2.为什么要用仓储

先说贫血模型的缺点:

有小伙伴之前提出过不知道贫血模型的定义,这里做一下解释。贫血模型:PO,DTO,VO这种常见的业务POJO,都是数据java里面的数据载体,内部没有任何的业务逻辑。所有业务逻辑都被定义在各种service里面,service做了各种模型之间的各种逻辑处理,臃肿且逻辑不清晰。充血模型:建立领域模型形成聚合根,在聚合根即表示业务,在聚合内部定义当前领域内的业务处理方法与逻辑。将散落的逻辑进行收紧。

  1. 无法保护模型对象的完整性和一致性: 因为对象的所有属性都是公开的,只能由调用方来维护模型的一致性,而这个是没有保障的;之前曾经出现的案例就是调用方没有能维护模型数据的一致性,导致脏数据使用时出现bug,这一类的 bug还特别隐蔽,很难排查到。
  2. 对象操作的可发现性极差: 单纯从对象的属性上很难看出来都有哪些业务逻辑,什么时候可以被调用,以及可以赋值的边界是什么;比如说,Long类型的值是否可以是0或者负数?
  3. 代码逻辑重复: 比如校验逻辑、计算逻辑,都很容易出现在多个服务、多个代码块里,提升维护成本和bug出现的概率;一类常见的bug就是当贫血模型变更后,校验逻辑由于出现在多个地方,没有能跟着变,导致校验失败或失效。
  4. 代码的健壮性差: 比如一个数据模型的变化可能导致从上到下的所有代码的变更。
  5. 强依赖底层实现: 业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。

虽然贫血模型有很大的缺陷,但是在我们日常的代码中,我见过的99%的代码都是基于贫血模型,为什么呢?

  1. 数据库思维: 从有了数据库的那一天起,开发人员的思考方式就逐渐从写业务逻辑转变为了写数据库逻辑,也就是我们经常说的在写CRUD代码
  2. 贫血模型“简单”: 贫血模型的优势在于“简单”,仅仅是对数据库表的字段映射,所以可以从前到后用统一格式串通。这里简单打了引号,是因为它只是表面上的简单,实际上当未来有模型变更时,你会发现其实并不简单,每次变更都是非常复杂的事情
  3. 脚本思维: 很多常见的代码都属于脚本胶水代码,也就是流程式代码。脚本代码的好处就是比较容易理解,但长久来看缺乏健壮性,维护成本会越来越高。

但是可能最核心的原因在于,实际上我们在日常开发中,混淆了两个概念:

  • 数据模型(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层 【应用服务层】EntityDTO的转化器有一个标准的名称叫DTO Assembler 【汇编器】

DTO Assembler的核心作用就是将1个或多个相关联的Entity转化为1个或多个DTO。

Data Converter: 在Infrastructure层 【基础设施层】EntityDO的转化器没有一个标准名称,但是为了区分Data Mapper,我们叫这种转化器Data Converter。这里要注意Data Mapper通常情况下指的是DAO,比如Mybatis的Mapper。

3.2.Repository规范

首先聚合仓储之间是一一对应的关系。仓储只是一种持久化的手段,不应该包含任何业务操作。

  1. 接口名称不应该使用底层实现的语法 定义仓储接口,接口中有save类似的方法,与面向集合的仓储的不同点:面向集合的仓储只有在新增时调用add即可,面向持久化的无论是新增还是修改都要调用save
  2. 出参入参不应该使用底层数据格式: 需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate Root),而不应该直接操作底层的 DO 。更近一步,Repository 接口实际上应该存在于Domain层,根本看不到 DO 的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
  3. 应该避免所谓的“通用”Repository模式 很多 ORM 框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。当然,这里避免通用不代表不能有基础接口和通用的帮助类
  4. 不要在仓储里面编写业务逻辑 首先要清楚的是,仓储是存在基础设施层的,并不会去依赖上层的应用服务,领域服务等。

仓储内部仅能依赖mapper,es,redis这种存储介质包装框架的工具类。save动作,仅对传入的聚合根进行解析放入不同的存储介质,你想放入redis,数据库还是es,由converter来完成聚合根的转换解析。同样,从不同的存储介质中查询得到的数据,交给converter来组装。

  1. 不要在仓储内控制事务 你的仓储用于管理的是单个聚合,事务的控制应该取决于业务逻辑的完成情况,而不是数据存储与更新情况。

3.3.CQRS仓储

回顾一下这张图,可以发现增删改数据模型走了DDD模型。而查询则从应用服务层直接穿透到了基础设施层。

这就是CQRS模型,从数据角度来看,增删改数据非幂等操作,任何一个动作都能对数据进行改动,称为危险行为。而查询,不会因为你查询次数的改变,而去修改到数据,称为安全行为。而往往功能迭代过程中,数据修改的逻辑还是复杂的,因此建模也都是针对于增删改数据而言的。

那么查询数据有什么原则吗?

  1. 构建独立仓储 查询的仓储与DDD中的仓储应该是两个方法,互相独立。DDD中的仓储方法严格意义上只有三个:save,delete,byId,内部没有业务逻辑,仅对数据做拆分组合。查询仓储方法可以根据用户需求,研发需求来自定义仓储返回的数据结构,不限制返回的数据结构为聚合,可以是限界范围内的任意自定义结构。
  2. 不要越权 不要再查询仓储内做太多的sql逻辑,数据查询组装交给assember。
  3. 利用好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演示

需求描述,用户领域有四个业务场景

  1. 新增用户
  2. 修改用户
  3. 删除用户
  4. 用户数据在列表页分页展示

核心实现演示,不贴全部代码,完整demo可从文章开头的github仓库获取

4.1.领域模型

代码语言:javascript
复制
/**
 * 用户聚合根
 *
 * @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<Role> 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<RolePO> 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<RolePO> 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仓储实现

代码语言:javascript
复制
/**
 *
 * 用户领域仓储
 *
 * @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.<UserRolePO>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<UserRolePO> userRoles = userRoleMapper.selectList(Wrappers.<UserRolePO>lambdaQuery()
                .eq(UserRolePO::getUserId, id).select(UserRolePO::getRoleId));
        List<Long> roleIds = CollUtil.isEmpty(userRoles) ? new ArrayList<>() : userRoles.stream()
                .map(UserRolePO::getRoleId)
                .collect(Collectors.toList());
        List<RolePO> 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.<UserRolePO>lambdaQuery().eq(UserRolePO::getUserId,user.getId()));
        }
        List<UserRolePO> userRolePos = UserConverter.serializeRole(user);
        userRolePos.forEach(userRoleMapper::insert);
        return this.byId(user.getId());
    }
​
}

4.3.查询仓储

代码语言:javascript
复制
/**
 *
 * 用户信息查询仓储
 *
 * @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的业务场景为例

  1. 生成Order实体类,初期字段可以和OrderDO保持一致
  2. 生成OrderDataConverter,通过MapStruct基本上2行代码就能完成
  3. 写单元测试,确保Order和OrderDO之间的转化100%正确
  4. 生成OrderRepository接口和实现,通过单测确保OrderRepository的正确性
  5. 将原有代码里使用了OrderDO的地方改为Order
  6. 将原有代码里使用了OrderDAO的地方都改为用OrderRepository
  7. 通过单测确保业务逻辑的一致性。

六.总结

  1. 数据模型与领域模型需要正确区分,仓储是它们互相转换的抽象实现。
  2. 仓储对业务层屏蔽实现,即领域层不需要关注领域对象如何持久化。
  3. 仓储是一个契约,而不是数据访问层。它明确表明聚合所必需的数据操作。
  4. 仓储用于管理单个聚合,它不应该控制事务。
  5. ORM框架选型在迁移过程中不可决定性因此,可以嫁接转换器,但是还是优先推荐JPA。
  6. 查询仓储可以突破DDD边界,用户交互层可以直接进行查询。

七.特别鸣谢

lilpilot

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-09-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一.前言
  • 二.仓储
    • 2.1.仓储是什么
      • 2.2.为什么要用仓储
      • 三.落地
        • 3.1.落地概念图
          • 3.2.Repository规范
            • 3.3.CQRS仓储
              • 3.4.ORM框架选型
              • 四.demo演示
                • 4.1.领域模型
                  • 4.2.DDD仓储实现
                    • 4.3.查询仓储
                    • 五.mybatis迁移方案
                    • 六.总结
                    • 七.特别鸣谢
                    相关产品与服务
                    对象存储
                    对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
                    领券
                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档