这是一个讲解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等。

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

码如云源代码:https://github.com/mryqr-com/mry-backend

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有个全景式的认识。

产品代码都给你看了,可别再说不会DDD(一):DDD入门的更多相关文章

  1. 瞧一瞧,看一看呐,用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!!

    瞧一瞧,看一看呐用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!! 现在要写的呢就是,用MVC和EF弄出一个CRUD四个页面和一个列表页面的一个快速DEMO,当然是在不 ...

  2. iOS开发UI篇—从代码的逐步优化看MVC

    iOS开发UI篇—从代码的逐步优化看MVC 一.要求 要求完成下面一个小的应用程序. 二.一步步对代码进行优化 注意:在开发过程中,优化的过程是一步一步进行的.(如果一个人要吃五个包子才能吃饱,那么他 ...

  3. 每一行代码都有记录—如何用git一步步探索项目的历史

    每一行代码都有一块被隐藏了的文档信息. 下面的代码片段不管是谁写的,其第4行因为某些原因要访问一个DOM结点的clientLeft属性,但却对结果不作任何处理.这十分的莫名其妙,你能告诉我他们为什么要 ...

  4. Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!

    本文原题“从实践角度重新理解BIO和NIO”,原文由Object分享,为了更好的内容表现力,收录时有改动. 1.引言 这段时间自己在看一些Java中BIO和NIO之类的东西,也看了很多博客,发现各种关 ...

  5. 圣诞节,把网站所有的js代码都压缩成圣诞树吧。

    本文分两章节,分别讲解如何使用js2image这个库生成可以运行的圣诞树代码 和 js2image的原理. github地址:https://github.com/xinyu198736/js2ima ...

  6. delphi 动态绑定代码都某个控件

    delphi 动态绑定代码都某个控件 http://docwiki.embarcadero.com/CodeExamples/Berlin/en/Rtti.TRttiType_(Delphi)Butt ...

  7. 产品经理都知道MVP,但是它可能不再是产品研发最好的模型了

    产品经理都知道MVP,但是它可能不再是产品研发最好的模型了 孟小白Aspire • 2017-09-01 • 汽车交通 要简单.讨喜.完整,不要最小可行性产品.这对创业公司的第一个产品来说很重要. M ...

  8. appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题

    appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题(部分安卓机型) 1.修改appium-android-driver\lib下的android-he ...

  9. 使用Junit测试一个 spring静态工厂实例化bean 的例子,所有代码都没有问题,但是出现java.lang.IllegalArgumentException异常

    使用Junit测试一个spring静态工厂实例化bean的例子,所有代码都没有问题,但是出现 java.lang.IllegalArgumentException 异常, 如下图所示: 开始以为是代码 ...

  10. 轻松解决 CSS 代码都在一行的问题

    前言 最近在做博客园的界面美化,用的是博客园[guangzan]的开源项目,配置超级简单,只需要复制粘贴代码就好啦. 但在粘贴 CSS 代码时遇到一个问题,那就是所有代码都挤在了一行,没有一点排板的样 ...

随机推荐

  1. 2021-01-18:java中,HashMap的创建流程是什么?

    福哥答案2021-01-18: jdk1.7创建流程:三种构造器.1.初始容量不能为负数,默认16.2.初始容量大于最大容量时,初始容量等于最大容量.3.负载因子必须大于0,默认0.75.4.根据初始 ...

  2. 2022-03-04:爱吃香蕉的珂珂。 珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。 珂珂可以决定她吃香蕉的速度 K (单位:根

    2022-03-04:爱吃香蕉的珂珂. 珂珂喜欢吃香蕉.这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉.警卫已经离开了,将在 H 小时后回来. 珂珂可以决定她吃香蕉的速度 K (单位:根 ...

  3. 2021-12-03:石子游戏 IV。Alice 和 Bob 两个人轮流玩一个游戏,Alice 先手。 一开始,有 n 个石子堆在一起。每个人轮流操作,正在操作的玩家可以从石子堆里拿走 任意 非零 平

    2021-12-03:石子游戏 IV.Alice 和 Bob 两个人轮流玩一个游戏,Alice 先手. 一开始,有 n 个石子堆在一起.每个人轮流操作,正在操作的玩家可以从石子堆里拿走 任意 非零 平 ...

  4. sipp重放rtp数据测试FreeSWITCH

    环境:CentOS 7.6_x64 FreeSWITCH版本 :1.10.9 sipp版本:3.6.1 一.背景描述 sipp是一款VoIP测试工具,日常开发过程中会使用到该软件,但其自身携带的pca ...

  5. C++温故补缺(二十一):杂项补充2

    杂记2 explicit 在 C++ 中,explicit 是一个关键字,用于修饰类的构造函数,其作用是禁止编译器将一个参数构造函数用于隐式类型转换.具体来说,当一个构造函数被 explicit 修饰 ...

  6. kprobe_events shell模式使用教程

    kprobe_events shell模式使用教程 kprobe 使用前提 需要内核启用以下配置 CONFIG_KPROBES=y CONFIG_HAVE_KPROBES=y CONFIG_KPROB ...

  7. Junit执行器Runner探索之旅

    单元测试是每个程序员必备的技能,而Runner是每个单元测试类必有属性.本文通过解读Junit源码,介绍junit中每个执行器的使用方法,让读者在单元测试时,可以灵活的使用Runner执行器. 一.背 ...

  8. OOP第三阶段题目集总结|课程总结-22201608-柯汶君

      第三阶段的题目集时间跨度大,内容比较充实,对前面学习过的类的继承,多态,接口进行了巩固练习,加深我们对多态的理解,学会更好地改善代码的结构.同时对最后阶段所学习的集合框架体系(Set.Map等)比 ...

  9. 讯飞离线语音合成新版(Aikit)-android sdk合成 demo(Java版本)

    前言:科大讯飞的新版离线语音合成,由于官网demo是kt语言开发的,咱也看不懂kt,搜遍了全网也没看到一个java版的新版离线语音demo,现记录下,留给有缘人参考!!!!!毕竟咱在这上面遇到了不少的 ...

  10. 离线安装mysql报错解决方法:/usr/sbin/mysqld: error while loading shared libraries: libaio.so.1: cannot open sha --九五小庞

    Linux:centos 7.6 64位 mysql:5.6使用离线方式安装:rpm -ivh --nodeps mysql* ,执行 systemctl start mysqld.service发现 ...