10CQRS
本系列包含以下文章:
- DDD入门
- DDD概念大白话
- 战略设计
- 代码工程结构
- 请求处理流程
- 聚合根与资源库
- 实体与值对象
- 应用服务与领域服务
- 领域事件
- CQRS(本文)
案例项目介绍 #
既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。
码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。
在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语。
在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。
码如云的源代码是开源的,可以通过以下方式访问:
码如云源代码:GitHub - mryqr-com/mry-backend: 本代码库为码如云后端代码。码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,手机扫码即可查看物品信息并发起相关业务操作,操作内容可由你自己定义,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。在技术上,码如云是一个无代码平台,全程采用DDD、整洁架构和事件驱动架构思想完成开发。
CQRS #
CQRS(Command Query Responsibility Segregation)直译成中文叫命令查询职责分离,可不要被这个读起来有些拗口的名字吓到了,事实上就是读写分离的意思,不过这里的读写分离和我们通常所理解的数据库级别的读写分离是两个不同的概念,CQRS指的读写分离是指在应用程序内部的代码级别的读写分离,在本文中,我将对此做出详细解释。
简单来讲,CQRS的提出是基于这么一种现象:软件中写数据的操作和读数据的操作是两个很不一样的过程,它们各有各的特点,因此可以并且应该将它们作为两个单独的关注点分别进行处理。“写数据”的过程也被称为“命令(Command)”,即表示外界通过向软件发送一些列的命令达到更新软件内部数据的目的,比如更新用户偏好设置、向电商网站下单等;“读数据”的过程也被称为“查询(Query)”,即从软件中获取数据,比如查看订单信息等。读和写的不同主要体现在以下几个方面:
- 业务逻辑的运用主要是在写数据一侧,也就是说,我们在本系列的其他文章中讲到的聚合根,实体,值对象,领域服务等领域模型中的概念主要用于“写数据”的过程,相比之下“读数据”一侧的业务逻辑则相对较少,主要是数据展现逻辑;
- 读数据是幂等的,即无论通过什么方式,都不应该修改系统中的数据,也即读数据相对安全,而在写数据时则需要始终保证数据的正确性和一致性,否则将导致严重Bug;
- 导致读数据和写数据过程发生变更的归因不同,对写数据侧的变更主要基于业务逻辑的变化,而读数据侧的变更则更多基于UI需求的变化,比如根据不同的屏幕尺寸返回不同的数据等;
- 读数据和写数据的频率往往各不相同,对于多数业务来说写数据的频率往往低于读数据的频率。
事实上,读写分离这种思想早在上世纪80年代末便由Bertrand Meyer提出,在他的《Object-Oriented Software Construction》一书中指出:
Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一个方法要么作为一个“命令”执行一个操作,要么作为一次“查询”向调用方返回数据,但两者不能共存。)
可以看出,Bertrand Meyer所谓的读写分离主要用于对象中的方法(Method),而CQRS将这种思想扩大到了软件架构层面,接下来让我们分别看看CQRS中的各种读写分离模式。
流程分离 #
最简单的读写分离模式莫过于读写流程的分离了,事实上这也是我们一直在用的一种方式,是的没错,你已经在用CQRS了。为此,让我们来看看一个具体的例子,在码如云中,有权限的成员(Member)可以更新表单(Submission),也可以查看表单详情数据,前者是一个写数据的过程,后者则是一个读数据的过程。更新表单的应用服务代码如下:
//SubmissionCommandService@Transactional
public void updateSubmission(String submissionId, UpdateSubmissionCommand command, User user) {Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);AppedQr appedQr = qrRepository.appedQrById(submission.getQrId());App app = appedQr.getApp();QR qr = appedQr.getQr();Page page = app.pageById(submission.getPageId());SubmissionPermissions submissionPermissions = submissionPermissionChecker.permissionsFor(user,app,submission.getGroupId());submissionDomainService.updateSubmission(submission,app,page,qr,command.getAnswers(),submissionPermissions.getPermissions(),user);submissionRepository.houseKeepSave(submission, app);log.info("Updated submission[{}].", submissionId);
}
源码出处:com/mryqr/core/submission/command/SubmissionCommandService.java
应用服务方法SubmissionCommandService.updateSubmission()
通过调用领域服务SubmissionDomainService.updateSubmission()
完成对表单的更新,然后再通过SubmissionRepository.houseKeepSave()
完成对表单的持久化。
在查看表单详情时的应用服务代码如下:
//SubmissionQueryServicepublic QDetailedSubmission fetchDetailedSubmission(String submissionId, User user) {Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);//将领域对象Submission转为展现对象QDetailedSubmissionreturn toSubmissionDetail(submission, user);
}
源码出处:com/mryqr/core/submission/query/SubmissionQueryService.java
在SubmissionQueryService.fetchDetailedSubmission()
方法中,先获取到需要查询的表单聚合根对象Submission
,然后调用toSubmissionDetail()
将Submission
转换为展现对象QDetailedSubmission
。
在上述2个代码例子中,写数据和读数据使用了不同的应用服务方法,也即流程分离了。你可能会说“我平时就是这么做的呀!”,的确如此,这种方式正是大家平时的编码实现,但是这里我们更希望强调的原则在于:写数据的SubmissionCommandService.updateSubmission()
返回的是void
,也即不会返回任何数据,而读数据的SubmissionQueryService.fetchDetailedSubmission()
则只是获取数据而未修改任何数据。
此外,虽然SubmissionCommandService
和SubmissionQueryService
均表示应用服务,但是在编码实现中被分成了2个单独的类以示分离。事实上,在码如云我们在代码的分包层面也做了相应的对读写分离的支持,所有与写数据相关的代码被组织在了command
包下,而所有与读数据相关的代码则被放在了query
包下。
在查询数据时,先获取到聚合根对象Submission
,再将其转化为展现对象QDetailedSubmission
,也就是说读数据和写数据的过程共享了同一个聚合根对象Submission
。这种方式对于简单的查询场景没有多大问题,但是对于一些复杂的查询场景来说并不合适,一是使得读数据侧对写数据侧存在依赖,二是在跨表查询的时候,需要将多个聚合根对象分别从数据库中加载到内存,导致对数据库的多次访问,在高并发场景下,这可能影响系统性能。
模型分离 #
既然业务逻辑主要作用于写数据侧,而读数据侧主要处理的是展现逻辑,那是不是在读数据时可以绕过领域模型(上例中的Submission
)呢?当然可以,这就是模型分离。模型分离的主要特点是:在写数据时,依然严格按照领域模型对业务逻辑的请求处理流程,但是在读数据时,可以绕过领域模型,直接从数据库创建相应的读模型对象。落到编码层面,在写数据侧可能需要通过ORM等工具完成对聚合根的持久化,但是在读数据侧则不见得,我们全然可以通过直接的SQL语句从数据库中加载所需查询的数据。
在码如云,租户管理员可以查看租户下所有的成员,其查询实现如下:
//MemberQueryServicepublic PagedList<QListMember> listMyManagedMembers(ListMyManagedMembersQuery queryCommand, User user) {String tenantId = user.getTenantId();Pagination pagination = pagination(queryCommand.getPageIndex(), queryCommand.getPageSize());String departmentId = queryCommand.getDepartmentId();String search = queryCommand.getSearch();Query query = new Query(buildMemberQueryCriteria(tenantId, departmentId, search));long count = mongoTemplate.count(query, Member.class);if (count == 0) {return pagedList(pagination, 0, List.of());}query.skip(pagination.skip()).limit(pagination.limit()).with(sort(queryCommand));//绕过Member,直接将从数据库中查到的数据创建为QListMemberquery.fields().include("name").include("avatar").include("role").include("mobile").include("wxUnionId").include("wxNickName").include("email").include("active").include("createdAt").include("departmentIds");List<QListMember> members = mongoTemplate.find(query, QListMember.class, MEMBER_COLLECTION);return pagedList(pagination, (int) count, members);
}
源码出处:com/mryqr/core/member/query/MemberQueryService.java
可以看到,在查询成员列表时,直接通过mongotTemplate
(码如云使用的是MongoDB)将从数据库中所查询到的数据创建为了读模型QListMember
,省去了加载Member
并从Member
转化为QListMember
的过程。
数据源分离 #
模型分离可以解决很大一部分读写分离的问题,不过它依然是一种相对简单的CQRS实现方式,对于更加复杂的查询场景来说则显得有些力不从心,主要有以下原因:
- 模型分离事实上只是代码层面模型的分离,底层的数据库模型并未分离,依然是读写共享的,对于主要服务于写数据一侧的数据库来说,可能由于对读数据一侧的“照料不周”而无法满足某些查询需求;
- 模型分离只能用于在同一个进程空间之内的查询,也即所查询的数据均位于同一个数据库的场景,但是对于诸如微服务这种需要跨进程查询的情况则无法满足,比如对于一个采用微服务架构的电商系统,在用户首页需要同时查看用户基本信息和积分,但是前者位于“用户”服务中,而后者来自于“积分”服务,此时需要分别从2个服务中获取数据并返回给前端;
- 查询所需数据不一定能够直接映射到数据库中的字段,而是有可能需要做一些额外的加工,比如将
省份(province)
、城市(city)
和详细地址(detailAddress)
拼接为最终的地址值等。
数据源分离便是用来解决这个问题的,在这种方式下,我们为数据查询侧单独创建一个数据库,这个数据库存在的目的仅仅是为了方便查询用,可以说是为读数据侧量身定制的,该数据库中的数据依然来自于写数据一侧,只是经过了一些预先的加工,比如根据查询端(前端)所需摒弃了一些无用的字段,或者将多个字段合并成单个字段便于前端的直接显示等。那么,数据又如何从写数据一侧传递到读数据一侧呢?答案是领域事件。
在写数据时,对业务数据的变更将通过领域事件的形式发布到消息队列(Kafka)中, 读数据侧作为一个独立的模块通过消费这些领域事件完成对读模型数据库的相应更新,之后在查询数据时,则采用与“模型分离”相似的模式直接从数据库构建读模型,最后返回给查询方(前端)。
在技术栈的选择上,读数据侧的数据库不必与写数据库保持一致,比如写数据侧可以采用诸如MySQL这种强事务一致性的数据库(为了保证业务数据的正确性),但是读数据侧可以采用更有利于数据查询的数据库,比如ElasticSearch等。
事实上,以上3种CQRS的实现模式并不是彼此互斥的,而是可以同时存在,哪种方式相对简单则采用哪种方式。比如,在码如云我们便同时采用了3种方式。
总结 #
CQRS即是读写分离的意思,它将软件中的写数据过程和读数据过程分开处理,各司其职,是一种可以在很大程度上简化软件架构的编程模式。在这种模式下,写数据的过程严格遵循DDD的各种原则,而读数据的过程则可以绕开DDD中的领域模型(主要是聚合根),直接从数据库构建需要查询的数据模型。根据具体场景的不同,可以采用不同的CQRS实现模式。
相关文章:

10CQRS
本系列包含以下文章: DDD入门DDD概念大白话战略设计代码工程结构请求处理流程聚合根与资源库实体与值对象应用服务与领域服务领域事件CQRS(本文) 案例项目介绍 # 既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术&…...

DAZ To UMA⭐一.DAZ简单使用教程
文章目录 🟥 DAZ快捷键🟧 DAZ界面介绍 🟥 DAZ快捷键 移动物体:ctrlalt鼠标左键 旋转物体:ctrlalt鼠标右键 导入模型:双击左侧模型UI 🟧 DAZ界面介绍 Files:显示全部文件 Products:显示全部产品 Figures:安装的全部人物 Wardrobe…...
面试题 —— Java集合篇(23题)
文章目录 1.Java中常见集合有哪些 ?2. 说说你对Java集合是怎么理解的?3.请你说一下List,Set,Map三者的特点是 ?4.在实际开发过程中如何更好的选择集合 ?5. ArrayList和Vector区别 ?6. ArrayList…...

SpringBoot2.7.14整合Swagger3.0的详细步骤及容易踩坑的地方
🧑💻作者名称:DaenCode 🎤作者简介:啥技术都喜欢捣鼓捣鼓,喜欢分享技术、经验、生活。 😎人生感悟:尝尽人生百味,方知世间冷暖。 📖所属专栏:Sp…...
题解:ABC321D - Set Menu
题解:ABC321D - Set Menu 题目 链接:Atcoder。 链接:洛谷。 难度 算法难度:B。 思维难度:C。 调码难度:B。 综合评价:见洛谷链接。 算法 枚举二分查找。 思路 先对b升序排序&#x…...

什么是Progressive Web App(PWA)?它们有哪些特点?
聚沙成塔每天进步一点点 ⭐ 专栏简介⭐ 渐进式Web App简介⭐ PWAs的主要特点⭐ 总结⭐ 写在最后 ⭐ 专栏简介 前端入门之旅:探索Web开发的奇妙世界 欢迎来到前端入门之旅!感兴趣的可以订阅本专栏哦!这个专栏是为那些对Web开发感兴趣、刚刚踏入…...

MySQL的高级SQL语句
目录 一、高级SQL语句 1、select 查询表中一个或多个字段的数据 2、distinct 不显示重复的数据记录 3、where 有条件查询 4、and与or 且与或 5、in 显示在某个范围值内 的字段的信息 6、between 显示两个值范围内的数据记录 7、order by 对字…...

基于人脸5个关键点的人脸对齐(人脸纠正)
摘要:人脸检测模型输出人脸目标框坐标和5个人脸关键点,在进行人脸比对前,需要对检测得到的人脸框进行对齐(纠正),本文将通过5个人脸关键点信息对人脸就行对齐(纠正)。 一、输入图像…...

vue3中两个el-select下拉框选项相互影响
vue3中两个el-select下拉框选项相互影响 1、开发需求2、代码2.1 定义hooks文件2.2 在组件中使用 1、开发需求 如图所示,在项目开发过程中,遇到这样一个需求,常规时段中选中的月份在高峰时段中是禁止选择的状态,反之亦然。 2、代…...

博弈论——反应函数
反应函数 1 引言 谢老师的《经济博弈论》书中对反应函数并没有给出一般笼统的定义,而是将其应用与古诺模型并给出了相关解释:反应函数是指在无限策略的古诺博弈模型中,博弈方的策略有无限多种,因此各个博弈方的最佳对策也有无限…...

UE5读取json文件
一、下载插件 在工程中启用 二、定义读取外部json文件的函数,参考我之前的文章 ue5读取外部文件_艺菲的博客-CSDN博客 三、读取文件并解析为json对象 这里Load Text就是自己定义的函数,ResourceBundle为一个字符串常量,通常是读取的文件夹…...

Vue中的插槽--组件复用,内容自定义
插槽 文章目录 插槽插槽-默认插槽插槽-后备内容(设置默认值)插槽-具名插槽插槽–作用域插槽 插槽-默认插槽 作用:让组件内部的一些结构支持自定义 需求:要在页面中显示一个对话框,封装成一个组件(对话框有很多功能是类…...
完全指南:mv命令用法、示例和注意事项 | Linux文件移动与重命名
文章目录 mv命令使用指南1. 简介什么是mv命令?mv命令的作用和功能是什么? 2. 基本用法基本语法格式如何移动文件?如何重命名文件?如何移动和重命名目录? 3. 高级用法使用通配符进行批量移动和重命名使用选项进行文件移…...

gitee生成公钥和远程仓库与本地仓库使用验证
参考文档: https://help.gitee.com/base/account/SSH%E5%85%AC%E9%92%A5%E8%AE%BE%E7%BD%AE(1)通过命令ssh-keygen 生成SSH key -t key类型 -c注释 ssh-keygen -t ed25519 -C "Gitee SSH Key" (2)按三次回车 (3)查看生成的 SSH 公钥和私钥: …...
请求后端接口413
当在进行HTTP请求时出现"413 Request Entity Too Large"错误时,通常是因为请求体的大小超过了服务器的配置限制。这个错误提示表明服务器拒绝接受过大的请求。 此时一般还未到后端服务,是被后端的ngnix代理服务器拦截的,所以可以检…...

HarmonyOS之 开发环境搭建
一 鸿蒙简介: 1.1 HarmonyOS是华为自研的一款分布式操作系统,兼容Android,但又区别Android,不仅仅定位于手机系统。更侧重于万物物联和智能终端,目前已更新到4.0版本。 1.2 HarmonyOS软件编程语言是ArkTS,…...

QTC++ day12
注册登录界面 widget.h #ifndef WIDGET_H #define WIDGET_H#include <QWidget> #include <QIcon> #include <QPushButton> #include <QLineEdit> #include <QLabel> #include <QDebug> #include <QMessageBox>//消息对话框类 #inc…...
Vue3中使用Proxy API取代defineProperty API的原因
目录 一、前言 二、defineProperty API的限制和问题 三、Proxy API的优势和特性 四、Vue3.0中使用Proxy API的原因 五、Proxy API的局限性和注意事项 一、前言 Vue3.0是Vue.js框架的最新版本,它在底层进行了许多重要的改进。其中最引人注目的变化之一是它转而…...

构建工具Webpack简介
一、构建工具 当我们习惯了Node中使用ES模块化编写代码以后,用原生的HTML、CSS、JS这些东西会感觉到各种不便。比如:不能放心的使用模块化规范(浏览器兼容性问题)、即使可以使用模块化规范也会面临模块过多时的加载问题。 这时候…...

Docker部署单点Elasticsearch与Kibana
一 、 创建网络 因为需要部署kibana容器,因此需要让es和kibana容器互联。这里创建一个网络: docker network create es-net # 创建一个网络名称为:es-net 二 、拉取并加载镜像 方式一 docker pull elasticsearch:7.12.1 版本为elasticsearch的7…...

el-switch文字内置
el-switch文字内置 效果 vue <div style"color:#ffffff;font-size:14px;float:left;margin-bottom:5px;margin-right:5px;">自动加载</div> <el-switch v-model"value" active-color"#3E99FB" inactive-color"#DCDFE6"…...
【AI学习】三、AI算法中的向量
在人工智能(AI)算法中,向量(Vector)是一种将现实世界中的数据(如图像、文本、音频等)转化为计算机可处理的数值型特征表示的工具。它是连接人类认知(如语义、视觉特征)与…...

(转)什么是DockerCompose?它有什么作用?
一、什么是DockerCompose? DockerCompose可以基于Compose文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器。 Compose文件是一个文本文件,通过指令定义集群中的每个容器如何运行。 DockerCompose就是把DockerFile转换成指令去运行。 …...
【HTTP三个基础问题】
面试官您好!HTTP是超文本传输协议,是互联网上客户端和服务器之间传输超文本数据(比如文字、图片、音频、视频等)的核心协议,当前互联网应用最广泛的版本是HTTP1.1,它基于经典的C/S模型,也就是客…...
DeepSeek 技术赋能无人农场协同作业:用 AI 重构农田管理 “神经网”
目录 一、引言二、DeepSeek 技术大揭秘2.1 核心架构解析2.2 关键技术剖析 三、智能农业无人农场协同作业现状3.1 发展现状概述3.2 协同作业模式介绍 四、DeepSeek 的 “农场奇妙游”4.1 数据处理与分析4.2 作物生长监测与预测4.3 病虫害防治4.4 农机协同作业调度 五、实际案例大…...

学校时钟系统,标准考场时钟系统,AI亮相2025高考,赛思时钟系统为教育公平筑起“精准防线”
2025年#高考 将在近日拉开帷幕,#AI 监考一度冲上热搜。当AI深度融入高考,#时间同步 不再是辅助功能,而是决定AI监考系统成败的“生命线”。 AI亮相2025高考,40种异常行为0.5秒精准识别 2025年高考即将拉开帷幕,江西、…...
Android第十三次面试总结(四大 组件基础)
Activity生命周期和四大启动模式详解 一、Activity 生命周期 Activity 的生命周期由一系列回调方法组成,用于管理其创建、可见性、焦点和销毁过程。以下是核心方法及其调用时机: onCreate() 调用时机:Activity 首次创建时调用。…...
MySQL JOIN 表过多的优化思路
当 MySQL 查询涉及大量表 JOIN 时,性能会显著下降。以下是优化思路和简易实现方法: 一、核心优化思路 减少 JOIN 数量 数据冗余:添加必要的冗余字段(如订单表直接存储用户名)合并表:将频繁关联的小表合并成…...
人工智能--安全大模型训练计划:基于Fine-tuning + LLM Agent
安全大模型训练计划:基于Fine-tuning LLM Agent 1. 构建高质量安全数据集 目标:为安全大模型创建高质量、去偏、符合伦理的训练数据集,涵盖安全相关任务(如有害内容检测、隐私保护、道德推理等)。 1.1 数据收集 描…...

9-Oracle 23 ai Vector Search 特性 知识准备
很多小伙伴是不是参加了 免费认证课程(限时至2025/5/15) Oracle AI Vector Search 1Z0-184-25考试,都顺利拿到certified了没。 各行各业的AI 大模型的到来,传统的数据库中的SQL还能不能打,结构化和非结构的话数据如何和…...