1:DDD入门

news2024/12/26 1:15:32

产品代码都给你看了,可别再说不会DDD(一):DDD入门 #

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云(https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:

  1. DDD入门(本文)
  2. DDD概念大白话
  3. 战略设计
  4. 代码工程结构
  5. 请求处理流程
  6. 聚合根与资源库
  7. 实体与值对象
  8. 应用服务与领域服务
  9. 领域事件
  10. CQRS

案例项目介绍 #

既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。

码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。

在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语。

在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:GitHub - mryqr-com/mry-backend: 本代码库为码如云后端代码。码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,手机扫码即可查看物品信息并发起相关业务操作,操作内容可由你自己定义,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。在技术上,码如云是一个无代码平台,全程采用DDD、整洁架构和事件驱动架构思想完成开发。

DDD入门 #

本文是本系列的第一篇文章,主要讲解DDD入门知识,如果你已经对DDD有所了解,可跳过本文。

在阅读本文之前,你可能会认为DDD是整天做PPT的架构师们才应该去关注的东西;或者会认为DDD是比较顶层的东西,跟我写代码的程序员关系不大;你可能还会认为DDD是一种被咨询师们吹得天花乱坠但是却无法落地的概念炒作而已。在日常实践中,我接触过不懂装懂的言必称DDD者,也见识过声称DDD与编码毫无关系的虚无主义者,当然也接触过真正能将DDD落地者。在本系列文章中,我将向你证明,DDD正是软件工程师的工具,可以用于编写更好的代码,设计更好的架构,进而做出更好的软件。当然,我也会针对DDD中被夸大其词的那部分进行澄清,甚至批评。

DDD是什么呢?是架构思想?是方法论?还是软件之道?从某种层度上说这些都对,但是对于程序员或者架构师来讲,最接地气的回答应该是:DDD是面向对象进阶。对于写了几年代码希望在职业生涯中更上一层楼的程序员来说,学习DDD是再适合不过的了。为了能让DDD新手们更快地上手,我们还是以代码为入口展开讲解,首先让我们来看看DDD项目代码和非DDD项目代码有何不同。

实现业务逻辑的三种方式 #

在案例项目码如云中有这样一个业务需求:所有可登录的用户被称为成员(Member),成员可以自行修改自己的手机号码,修改后该成员将被标记为“手机号已识别”的状态。为了实现这个需求,我们分别通过三种方式予以实现,读者可以对照看看这些实现方式是不是和自己曾经的编码方式有相似之处。

第一种: 事务脚本 #

对于上述需求,从纯技术上讲,我们希望达到的最终目的不过是在数据库中的member表中更新2个字段而已,一个是手机号(mobile_number)字段,另一个是手机号已识别(mobile_identified)字段。为了实现这个需求,最简单直接的方式难道不是直接写个SQL语句直接更新数据库表么?的确如此,这个简单的方式其实有个专门的名词 —— 事务脚本(Transactional Script),也即通过类似编写脚本的方式完成一个业务用例,一个业务用例对应一次事务。

    @Transactional//事务边界
    public void updateMyMobile(String mobileNumber, String memberId) {
        
        //采用事务脚本的方式,直接通过SQL语句实现业务逻辑
        String sql = "update member set mobile_number = ? , mobile_identified = 1 where id = ?;";
        jdbcTemplate.update(sql, mobileNumber,memberId);
    }

这种直接通过技术手段实现业务功能的方式没有任何软件建模可言,它将原本可以分开的业务性代码和技术性代码揉杂在一起,既不利于业务的重用,也不利于系统的长期演进,因此通常被认为只适合一些小型软件项目。

第二种:贫血对象 #

看到第一种实现方式你可能会想:这都什么年代了,还在像写C语言那样编写代码,不使用点儿面向对象技术连一个刚入职的毕业生估计都不好意思。那好吧,让我们创建一个Member对象。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //先后调用Member对象中的2个setter方法实现业务逻辑
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

在上例中,首先我们将数据库访问相关的逻辑全部封装在memberRepository中,从而解决了“技术性代码和业务性代码揉杂”的问题。其次,创建了Member对象,其中包含两个setter方法,setMobileNumber()用于设置手机号码,setMobileIdentified()用于标记标记手机号已识别,这应该面向对象了吧?!但是,问题恰恰出在了这两个setter方法上:此时的Member对象只是一个数据容器而已,而非真正的对象。这种只有数据没有行为的对象被称为贫血对象。

问题还不止于此,本例中先后调用的两个setter方法事实上违背了软件开发的一个根本性原则 —— 内聚性。简单来讲,“设置手机号”和“标记手机号已识别”这两个步骤在业务上是紧密联系在一起的,应该由Member中的单个方法完成,而不应该由2个独立的方法完成。为了解释这里体现的内聚性,让我们再来看个需求:除了成员自己可以修改手机号外,管理员也可以为任何成员设置手机号,为此我们再实现一个updateMemberMobile()方法。

    @Transactional
    public void updateMemberMobile(String mobileNumber,String memberId) {
        Member member = memberRepository.findMemberById(memberId);

        //与updateMyMobile()相同,需要先后调用Member对象中的2个setter方法实现业务逻辑
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

这里,updateMemberMobile()方法也需要显式地先后调用Member的setMobileNumber()setMobileIdentified()方法,也就是说编码者需要记住必须同时调用2个方法,否则程序就会出Bug。这种方式存在以下问题:

  1. 业务逻辑的泄漏:对于维持“设置手机号”和“标记手机号已识别”同时发生的职责来说,本应该由Member对象自身完成的,结果泄漏到了Member对象的外部;
  2. 增加调用者的负担:对于作为Member客户方的updateMyMobile()updateMemberMobile()方法来讲,他们本应该将Member当做一个黑盒,但在本例中却需要了解Member的内部细节(先后调用setMobileNumber()setMobileIdentified()方法),这无疑是调用者的负担。
  3. 难于维护:如果以后业务需求有变,那么需要同时修改updateMyMobile()updateMemberMobile()2个方法,这可能不是能够轻易做到的,特别是在人员流动频繁的软件项目中。

与事务脚本相似,贫血对象除了可用于一些小的软件项目外,通常被认为是一种反模式,应该避免使用。

第三种:领域对象 #

领域对象是一个与贫血对象相对立的概念,它表示直接体现业务逻辑的一类对象,这类对象不仅包含业务数据,还包含业务行为。领域对象希望达到的理想状态是:所有业务逻辑均由领域对象完成,外界将领域对象当做一个黑盒向其发送指令(调用方法)即可。在本例中,设置手机号的同时需要标记“手机号已识别”均属业务逻辑,应该全部放到领域对象中完成。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //只需调用Member种的updateMobile()方法即可
        member.updateMobile(mobileNumber);

        memberRepository.updateMember(member);
    }

这里,updateMyMobile()方法只需调用Member中的updateMobile()方法即可,然后由Member自行处理具体的业务逻辑:

    //由Member对象自身处理同时更新mobileNumber和mobileIdentified字段
    public void updateMobile(String mobileNumber) {
        this.mobileNumber = mobileNumber;
        this.mobileIdentified = true;
    }

在本例中,除了将数据和行为同时放到Member对象之外,我们还会考虑如何设计和安排这些行为才最得当,比如将高内聚的mobileNumbermobileIdentified放到同一个方法中,此时的Member便是一个行为饱满的领域对象,并开始变得有些“领域驱动”的意味了,所谓的"DDD是面向对象进阶"这个说法也正体现于此。事实上,在DDD中Member对象也被称为聚合根,而“更新mobileNumber的同时需要一并更新mobileIdentified”则被称为聚合根的不变条件,我们将在后续文章中对此做详细讲解。

看到这里,你可能会问:领域对象的实现方式不就是将贫血对象中的业务逻辑实现挪了个位置吗?的确,但是这一挪,便挪出了编程的讲究与思考,挪出了模型的设计与原则,挪出了软件的发展与进步。就像云计算早年被认为不过是将本地的计算资源搬移到网络上一样,我们将很多看似并不具有颠覆性的微小创新合在一起,便可将理想编织成一个个能够为行业为社会带来实际进步的美好现实。

你可能还会说,领域对象这种实现方式我平时就是这么做的呀!?没错,我们平时编程的很多做法其实已经包含了DDD中的某些思想或实践,因为DDD并不是什么全新的东西要把你所写的代码全部推翻重来,而是很多具有逻辑归因性的东西其实大家都能总结出来,只是那些大牛总结得比我们更早,更系统,更全面而已。

对于以上三种实现方式,我们在前面提到事务脚本和贫血对象只适合一些小型的软件项目,那么问题来了,到底多小才算小呢?这个问题没有标准答案,就像你问微服务多小算小一样,It depends!然而,但凡是企业中立过项的软件项目,都不会是实现一个Code Kata这么简单,都不能被定义为“小型项目”。因此,对于几乎所有企业级软件系统来说,使用领域对象进而DDD都不会是个错误的选择。

真实产品代码 #

由于本文是入门性质的文章,故到目前为止所使用的代码均不是码如云的产品代码。接下来,让我们来看看真实的产品代码,对于“成员修改自己的手机号”的业务功能,码如云代码库中的实现如下:

    @Transactional
    public void changeMyMobile(ChangeMyMobileCommand command, User user) {
        //API限流器,与DDD无关,读者可忽略
        mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);

        //将所有请求相关的数据封装到Command对象中
        String mobile = command.getMobile();

        //修改手机号时,需要验证发往新手机号的验证码
        verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);

        Member member = memberRepository.byId(user.getMemberId());

        //这里调用了MemberDomainService中的方法,而不是直接调用Member,因为需要检查手机号是否重复,而Member自身无法完成该检查
        memberDomainService.changeMyMobile(member, mobile, command.getPassword());

        memberRepository.save(member);
        log.info("Mobile changed by member[{}].", member.getId());
    }

源码出处:com/mryqr/core/member/command/MemberCommandService.java

为了让读者能对代码有更加详尽的了解,我们在源代码中加上了注释,建议读者通过阅读这些注释来理解代码的意图。(真实的码如云代码库中是很少有注释的,因为我们坚持“代码即是设计”的原则,让代码本身直接体现业务意图)

在本例中,首先使用限流器MryRateLimiter对请求进行限流处理,然后使用VerificationCodeChecker对手机号验证码进行检查,最后才调用MemberDomainService完成实际的业务逻辑。你可能有些纳闷儿,为什么不像前文中那样直接调用Member对象中的方法,而是调用MemberDomainService呢?事实上,这里的MemberDomainService在DDD中被称为领域服务,用于处理领域对象自身无法处理的业务逻辑。在本例中,成员在修改手机号时,系统需要检查该手机号是否已经被其他成员所占用,这部分逻辑是无法通过单个Member自身完成的,只能通过一个可以跨多个MemberMemberDomainService完成。

对于诸如限流器MryRateLimiter这些与DDD无关的代码,我们将在后续文章的代码中予以删除,以使代码集中在对DDD的阐述上。

MemberDomainService.changeMyMobile()方法实现如下:

    public void changeMyMobile(Member member, String newMobile, String password) {
        //修改手机号时,需要验证密码
        if (!mryPasswordEncoder.matches(password, member.getPassword())) {
            throw new MryException(PASSWORD_NOT_MATCH, "修改手机号失败,密码不正确。", "memberId", member.getId());
        }

        if (Objects.equals(member.getMobile(), newMobile)) {
            return;
        }

        //检查手机号是否已被占用
        if (memberRepository.existsByMobile(newMobile)) {
            throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手机号失败,手机号对应成员已存在。",
                    mapOf("mobile", newMobile, "memberId", member.getId()));
        }

        //调用Member对象中的方法,完成对手机号的修改
        member.changeMobile(newMobile, member.toUser());
    }

源码出处:com/mryqr/core/member/domain/MemberDomainService.java

可以看到,MemberDomainService调用了MemberRepository.existsByMobile()用于检查手机号是否已经被占用,如果是,则抛出异常。

最后,MemberDomainService调用Member.changeMobile()方法完成对手机号的修改:

public void changeMobile(String mobile, User user) {
        if (Objects.equals(this.mobile, mobile)) {
            return;
        }

        //同时设置mobile字段和mobileIdentified的值,高度内聚
        this.mobile = mobile;
        this.mobileIdentified = true;
        
        this.addOpsLog("修改手机号为[" + mobile + "]", user);
    }

源码出处:com/mryqr/core/member/domain/Member.java

如前文所述,mobilemobileIdentified是高度内聚的,因此放在Member的同一个方法changeMobile()中完成更新。以后,无论通过什么业务渠道修改成员的手机号,都只需要调用相同的Member.changeMobile()方法即可。

DDD书籍推荐 #

我基本上参阅完了市面上所有的DDD书籍(截止到2023年3月份),在这些书籍中,真正值得推崇的有以下4本书:

  • 《领域驱动设计:软件核心复杂性应对之道》(蓝皮书,从左往右第一本,首版时间2003年):DDD的开山之作,对于初学者来说阅读起来有些晦涩,不建议初学者直接阅读该书
  • 《实现领域驱动设计》(红皮书,从左往右第二本,首版时间2013年):这本是讲DDD落地的经典书籍,其中包含大量代码示例,很多人都是通过这本书才真正进入DDD的世界
  • 《领域驱动设计模式、原理与实践》(从左往右第三本,首版时间2015年):这也是一本能够帮你系统的完成DDD落地的书籍
  • 《解构领域驱动设计》(首版时间2021年):国内第一本关于DDD的专著,作者张逸在DDD社区具有比较大的影响力

对于英文书籍,建议大家如果有条件的话,一定阅读英文原版,因为那才是第一手资料,中文翻译始终存在漏译错译等无法表达原书本意的情况。

总结 #

本文从事务脚本、贫血对象和领域对象三种实现业务逻辑的方式为入口,一步一步地引入DDD的概念,希望能让DDD新手们平滑地开启DDD的学习之路。在下一篇:DDD概念大白话文章中,我们将通过大白话的方式给大家讲解DDD中的各种概念,以让读者对DDD有个全景式的认识。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1038131.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【车载开发系列】UDS中Bootloader实现原理

【车载开发系列】UDS中Bootloader实现原理 【车载开发系列】UDS中Bootloader实现原理 【车载开发系列】UDS中Bootloader实现原理一. Bootloader存放位置二. BootLoader的安全机制1)安全访问2)刷新预条件3)完整性校验4)一致性检查5…

NextJS 引入 Ant-Design 样式闪烁问题

按照这里给的样例,抽出关键代码即可 步骤: 安装包: npm i ant-design/static-style-extract引入这俩文件 genAntdCss.tsx: 会帮我们生成 ./public/antd.min.css // src/scripts/genAntdCss.tsximport { extractStyle } from "ant-d…

从锁的类别角度讲,MySQL都有哪些锁

🏆作者简介,黑夜开发者,CSDN领军人物,全栈领域优质创作者✌,CSDN博客专家,阿里云社区专家博主,2023年6月CSDN上海赛道top4。 🏆数年电商行业从业经验,历任核心研发工程师…

2024北京老博会,CBIAIE中国北京国际老年产业博览会

2024第11届中国(北京)国际老年产业博览会,将于4月10-12日盛大举行 2024第11届中国(北京)国际老年产业博览会(CBIAIE北京老博会) The 2024 eleventh China (Beijing) International Aged Indust…

【动手学深度学习-Pytorch版】长短期记忆网络LSTM

LSTM参数说明以及网络架构图 PS:时间仓促,有空补充内容~ LSTM从零开始实现 """ 遗忘门:相当于一个橡皮擦,决定保留昨天的哪些信息 输入门:相当于一个铅笔,再次根据昨天的记忆和今天的输…

【数据结构】list.h 详细使用教程 -- 附带例子代码

目录 一、概述二、详细使用步骤✨2.1 定义结构体包含 struct list_head 成员✨2.2 初始化链表头结点:INIT_LIST_HEAD✨2.3 添加结点:list_add、list_add_tail✨2.4 遍历链表:list_for_each、list_for_each_safe、list_for_each_entry✨2.5 获…

java double类型 向上取整,向下取整,四舍五入

向上取整:Math.ceil(double a) 向下取整:Math.floor(double a) 四舍五入取整:Math.round(double a) 直接取整数:intValue() public static void main(String[] args) {Double number 5.3;Double number1 5.8;//向上取整Doubl…

UE5 虚幻引擎 如何使用构造脚本(Construction Script)? 构造脚本的奥秘!

目录 1 构造脚本(Construction Script)1.1 介绍1.2 案例1:利用样条组件程序化生成树木1.2 案例2:利用样条组件和样条网格体组件程序化生成道路 1 构造脚本(Construction Script) 1.1 介绍 问题&#xff1a…

leetcode top100(20) 搜索二维矩阵 II

编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性: 每行的元素从左到右升序排列。每列的元素从上到下升序排列。 示例 1: 输入:matrix [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,2…

腾讯mini项目-【指标监控服务重构】2023-08-24

今日已办 Jeager 功能 监控分布式工作流程并排除故障识别性能瓶颈追踪根本原因分析服务依赖关系 部署 部署 Deployment — Jaeger documentation (jaegertracing.io) 支持 clickhouse jaegertracing/jaeger-clickhouse: Jaeger ClickHouse storage plugin implementation …

Java8实战-总结34

Java8实战-总结34 重构、测试和调试使用 Lambda 重构面向对象的设计模式观察者模式责任链模式 重构、测试和调试 使用 Lambda 重构面向对象的设计模式 观察者模式 观察者模式是一种比较常见的方案,某些事件发生时(比如状态转变)&#xff0…

Java之转换流的详细解析

2. 转换流 2.1 字符编码和字符集 字符编码 计算机中储存的信息都是用二进制数表示的,而我们在屏幕上看到的数字、英文、标点符号、汉字等字符是二进制数转换之后的结果。按照某种规则,将字符存储到计算机中,称为编码 。反之,将…

docker容器安装MongoDB数据库

一:MongoDB数据库 1.1 简介 MongoDB是一个开源、高性能、无模式的文档型数据库,当初的设计就是用于简化开发和方便扩展,是NoSQL数据库产品中的一种。是最 像关系型数据库(MySQL)的非关系型数据库。 它支持的数据结构…

封装一个高级查询组件

封装一个高级查询组件 背景一,前端相关代码二,后端相关代码三,呈现效果总结 背景 业务有个按照自定义选择组合查询条件,保存下来每次查询的时候使用的需求。查了一下项目里的代码没有现成的组件可以用,于是封装了一个 …

腾讯mini项目-【指标监控服务重构】2023-08-29

今日已办 Collector 指标聚合 由于没有找到 Prometheus 官方提供的可以聚合指定时间区间内的聚合函数,所以自己对接Prometheus的api来聚合指定容器的cpu_avg、cpu_99th、mem_avg 实现成功后对接小组成员测试完提供的时间序列和相关容器,将数据记录在表格…

相机One Shot标定

1 原理说明 原理部分网上其他文章[1][2]也已经说的比较明白了,这里不再赘述。 2 总体流程 参考论文作者开源的Matlab代码[3]和github上的C代码[4]进行说明(不得不说还是Matlab代码更优雅) 论文方法总体分两部,第一部是在画面中找…

李宏毅hw-9:Explainable ML

——欲速则不达,我已经很幸运了,只要珍惜这份幸运就好了,不必患得患失,慢慢来。 ----查漏补缺: 1.关于这个os.listdir的使用 2.从‘num_文件名.jpg’中提取出数值: 3.slic图像分割标记函数的作用&#xf…

【音视频流媒体】4、摄像头:分辨率、光圈|快门|感光度、焦距

文章目录 一、摄像头分辨率二、光圈、快门、感光度2.1 光圈2.1.1 外观2.1.2 光圈在相机中如何表示的2.1.3 对拍照的影响2.1.4 如何选择合适的光圈2.1.5 光圈在相机中如何设置 2.2 快门2.2.1 外观2.2.2 快门在相机中的表示2.2.3 快门对于拍照有什么影响2.2.4 选择合适的快门2.2.…

【C#】.Net基础语法一

目录 一、程序集信息 【1.1】Properties中AssemblyInfo文件 二、.Net程序的两次编译过程 三、.Net中命名空间和类 【3.1】引入命名空间 【3.2】修改默认的命名空间 【3.3】命名空间的总结 四、.Net中数据类型 【4.1】数值型 【4.2】非数值型 五、.Net中变量 【5.1】…

Selenium WebUI 自动化测试框架

框架结构 框架结构 框架基于 PO 模型进行设计,将页面元素与操作进行拆分,减少页面改动时的维护成本;同时使用 xsd 自定义 xml 标签,通过解析 xml 来驱动 selenium 进行执行,减少了一定的语言学习成本。 主要功能 基于…