DDD入门指南 -- 知识铺
aaaaaa## DDD落地实践文章系列概览 本系列文章旨在通过一个真实的已成功上线的软件项目——码如云,来系统性地讲解领域驱动设计(DDD)在实际开发中的应用。以下是该系列包含的文章列表:
案例项目介绍
码如云 是一个二维码场景应用的SaaS平台,采用“一物一码”的业务模式,可以为每个物品生成二维码,并以二维码作为入口对物品进行相关操作。其核心概念包括应用(App)、页面(Page)或表单、控件(Control)、实例(QR)以及提交(Submission)。 技术上,码如云是一个无代码平台,集成了表单引擎、审批流程和数据报表等功能模块。后端技术栈主要基于Java、Spring Boot和MongoDB等。
DDD入门
对于那些认为DDD是架构师们讨论的话题或者是一种难以实施的概念炒作的人来说,本系列文章将展示如何将DDD作为程序员的工具来编写更好的代码,设计更佳的架构。DDD可以被看作是面向对象编程的一种进阶方法。
实现业务逻辑的三种方式
为了更好地理解DDD如何影响编码方式,我们以码如云中的一项业务需求为例:成员(Member)修改手机号码并标记为“手机号已识别”。以下我们将探讨三种不同的实现方式。
第一种:事务脚本
最直接的方法是使用事务脚本来更新数据库中的member
表。这涉及到两个字段的更新:mobile_number
和 mobile_identified
。事务脚本是一种简单的解决方案,它通过类似于脚本的方式完成业务用例,通常一次事务对应一个业务用例。
@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);
}
在软件开发中,直接通过技术手段实现业务功能的方式通常被认为是不包含软件建模的。这种方式将业务性代码和技术性代码混合在一起,不利于代码的重用和系统的长期维护。因此,这种方式通常只适用于小型软件项目。
第二种:贫血对象
随着面向对象编程的普及,人们开始意识到应该利用面向对象技术来提升代码的可维护性和可重用性。贫血对象是一种只包含数据和基本的setter/getter方法的对象,它们没有包含业务逻辑。虽然这种方式相比直接通过技术手段实现业务功能有所进步,但它仍然存在一些问题:
-
业务逻辑的泄漏:业务逻辑应该封装在对象内部,但贫血对象将业务逻辑泄漏到了对象外部。
-
增加调用者的负担:调用者需要了解对象的内部细节,这增加了调用者的负担。
-
难于维护:如果业务需求发生变化,需要在多个地方修改代码,这使得维护变得困难。 尽管如此,贫血对象在一些小型项目中仍然有一定的应用价值。
@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()
方法要求调用者显式地先后调用setMobileNumber()
和setMobileIdentified()
两个方法来更新成员的手机号码。这种做法存在几个问题:
-
业务逻辑泄漏:设置手机号码并标记为已识别是一个应该由
Member
对象自身管理的职责,但现在这个职责被泄露到了外部,即Member
对象之外。 -
增加调用者的负担:对于使用
Member
对象的方法如updateMyMobile()
和updateMemberMobile()
来说,它们本应将Member
视为一个黑盒,并不需要知道内部细节。然而,在这个例子中,这些方法必须了解Member
的内部结构,并且需要记得按照正确的顺序调用特定的方法。 -
维护难度大:如果将来业务需求发生变化,那么所有直接调用这两个方法的地方都需要相应地进行修改,这在大型或人员流动性大的项目中尤其难以管理和维护。 与事务脚本类似,贫血对象虽然适用于某些小型项目,但在多数情况下被认为是反模式,因为它缺乏封装性,不利于维护和扩展。
领域对象
领域对象与贫血对象相对立,它不仅持有数据,还包含业务行为。理想状态下,所有的业务规则都应该由领域对象自身处理,外界只需向领域对象发送指令(即调用方法)。就本例而言,当设置新的手机号码时自动标记该号码已被识别,这样的逻辑应当完全封装在Member
类中,确保其作为一个整体对外提供服务,从而增强代码的内聚性和可维护性。
@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对象之外,我们还会考虑如何设计和安排这些行为才最得当,比如将高内聚的mobileNumber
和mobileIdentified
放到同一个方法中,此时的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());
}
为了让读者能对代码有更加详尽的了解,我们在源代码中加上了注释,建议读者通过阅读这些注释来理解代码的意图。(真实的码如云代码库中是很少有注释的,因为我们坚持“代码即是设计”的原则,让代码本身直接体现业务意图)
在本例中,首先使用限流器MryRateLimiter
对请求进行限流处理,然后使用VerificationCodeChecker
对手机号验证码进行检查,最后才调用MemberDomainService
完成实际的业务逻辑。你可能有些纳闷儿,为什么不像前文中那样直接调用Member
对象中的方法,而是调用MemberDomainService
呢?事实上,这里的MemberDomainService
在DDD中被称为领域服务,用于处理领域对象自身无法处理的业务逻辑。在本例中,成员在修改手机号时,系统需要检查该手机号是否已经被其他成员所占用,这部分逻辑是无法通过单个Member
自身完成的,只能通过一个可以跨多个Member
的MemberDomainService
完成。
对于诸如限流器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());
}
可以看到,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);
}
在码如云,我们对领域驱动设计(DDD)进行了深入研究,并且已经阅读了市场上几乎所有的DDD相关书籍(截至2023年3月)。基于我们的学习和实践经验,以下是我们特别推荐的四本DDD书籍。
-
《实现领域驱动设计》 这本书详细介绍了如何将DDD原则应用于实际项目中,对于想要深入了解DDD实践的读者来说非常有价值。
-
《领域驱动设计:软件核心复杂性应对之道》 作为DDD概念的开创者Eric Evans所著,本书不仅提出了DDD的基本思想,还提供了大量案例来帮助理解如何通过DDD构建复杂的软件系统。
-
《领域特定语言》 虽然主要聚焦于DSL的设计与实现,但该书同样适用于希望使用特定语言提高代码表达力的DDD实践者。
-
《敏捷软件架构》 本书讨论了如何结合敏捷方法论与DDD来优化软件开发流程,非常适合那些已经在进行或计划采用敏捷方式工作的团队。 通过集中管理和调用
Member.changeMobile()
这一方法,我们可以确保所有关于更新成员手机号的操作都遵循相同逻辑处理。这样不仅简化了维护工作,也增强了代码的一致性和可读性。无论将来通过何种业务渠道需要修改成员信息时,只需调用此统一接口即可完成任务。aaaaaaa
领域驱动设计(DDD)书籍推荐
在领域驱动设计(DDD)的学习过程中,选择合适的资料至关重要。以下是几本推荐的书籍,它们各有侧重点,适合不同阶段的学习者。
1. 《领域驱动设计:软件核心复杂性应对之道》
-
别称:蓝皮书
-
首版时间:2003年
-
特点:作为DDD的开创之作,这本书奠定了领域驱动设计的理论基础。
-
适宜读者:适合已经有一定DDD基础,希望深入理解DDD核心概念的读者。
2. 《实现领域驱动设计》
-
别称:红皮书
-
首版时间:2013年
-
特点:通过大量的代码实例讲解DDD的落地实施,是学习DDD不可或缺的一本书。
-
适宜读者:适合希望通过实践来学习DDD的开发者。
3. 《领域驱动设计模式、原理与实践》
-
首版时间:2015年
-
特点:系统性地讲解DDD的模式和原理,以及如何将DDD应用到实际项目中。
-
适宜读者:适合需要系统性学习DDD的软件工程师。
4. 《Learning Domain Driven Design》
-
首版时间:2021年
-
特点:内容浅显易懂,适合初学者快速入门DDD。
-
适宜读者:DDD新手,或者希望通过英文原版书籍学习DDD的读者。
阅读建议
-
语言选择:推荐阅读英文原版书籍,以获取最准确的信息和理解。
-
学习方法:结合书籍和实际项目练习,可以更有效地掌握DDD。
总结
本文介绍了几种实现业务逻辑的方式,并引出了DDD的概念。希望能够帮助新手平滑地开始DDD的学习之旅。在下一篇文章中,我们将以更通俗易懂的方式讲解DDD中的各种概念,帮助读者全面理解DDD。
- 原文作者:知识铺
- 原文链接:https://index.zshipu.com/geek002/post/20240918/DDD%E5%85%A5%E9%97%A8%E6%8C%87%E5%8D%97--%E7%9F%A5%E8%AF%86%E9%93%BA/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com