DDD落地之仓储

DDD落地之仓储一.前言 hello,everyone。又到了周末了,没有出去玩,继续肝。从评论与粉丝私下的联系来看,大家对于DDD架构的热情都比较高。但是因为抽象化的概念较多,因此理解上就很困难。 昨天媳妇儿生病了

一.前言

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

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

大家点个关注,点个赞,不过分吧。

image.png

这个项目我会持续维护,针对读者提出的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. 强依赖底层实现: 业务代码里强依赖了底层数据库、网络/中间件协议、第三方服务等,造成核心逻辑代码的僵化且维护成本高。

image.png

虽然贫血模型有很大的缺陷,但是在我们日常的代码中,我见过的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的核心价值。

image.png

三.落地

3.1.落地概念图

1.png

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. 不要在仓储里面编写业务逻辑

    首先要清楚的是,仓储是存在基础设施层的,并不会去依赖上层的应用服务,领域服务等。

图片1.png

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

  1. 不要在仓储内控制事务

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

3.3.CQRS仓储

2222.png 回顾一下这张图,可以发现增删改数据模型走了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中我也是这么给大家演示的。

image.png


当然,如果是新系统或者迁移时间足够多,我还是推荐使用JPA的,红红火火恍恍惚惚~

image.png

四.demo演示

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

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

核心实现演示,不贴全部代码,完整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<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仓储实现

/** * * 用户领域仓储 * * @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.查询仓储

/** * * 用户信息查询仓储 * * @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

八.联系我

文中如有不正确之处,欢迎指正,写文不易,点个赞吧,么么哒~

钉钉:louyanfeng25

微信:baiyan_lou

公众号:柏炎大叔

image.png

今天的文章DDD落地之仓储分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/19440.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注