- Java锁的逻辑(结合对象头和ObjectMonitor)
- 还在用饼状图?来瞧瞧这些炫酷的百分比可视化新图形(附代码实现)⛵
- 自动注册实体类到EntityFrameworkCore上下文,并适配ABP及ABPVNext
- 基于Sklearn机器学习代码实战
这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者 滕云 。本文章系列以一个真实的并已成功上线的软件项目—— 码如云 ( https://www.mryqr.com )为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍.
本系列包含以下文章:
既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云 (不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过.
码如云 是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等.
在使用码如云时,首先需要创建一个 应用 (App),一个 应用 包含了多个 页面 (Page),也可称为表单,一个 页面 又可以包含多个 控件 (Control),比如单选框控件。 应用 创建好后,可在 应用 下创建多个 实例 (QR)用于表示被管理的对象(比如机器设备)。每个 实例 均对应一个二维码,手机扫码便可对 实例 进行相应操作,比如查看 实例 相关信息或者填写页面表单等,对表单的一次填写称为 提交 (Submission);更多概念请参考 码如云术语 .
在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等.
码如云的源代码是开源的,可以通过以下方式访问:
码如云源代码: https://github.com/mryqr-com/mry-backend 。
在上一篇 请求处理流程 中我们讲到,领域模型是DDD的核心,而聚合根又是领域模型的核心。从某种意义上讲,DDD中其它组件均可看作是对聚合根的支撑或辅助。在本文中,我们将对聚合根以及与之密切相关的资源库(Repository)做详细的讲解.
在 DDD概念大白话 一文中,我们讲到了“什么是聚合根”,这里再重复一下。聚合根中的“聚合”即“高内聚,低耦合”中的“内聚”之意;而“根”则是“根部”的意思,也即聚合根是一种统领式的存在。事实上,并不存在一个教科书式的对聚合根的理论定义,你可以将聚合根理解为一个系统中最重要最显著的那些名词,这些名词是其所在的软件系统之所以存在的原因。为了给你一个直观的理解,以下是几个聚合根的例子:
你可能会问,软件中的概念已经很多了,为什么还要搞出个聚合根的概念?我们认为这里至少有2点原因:
在实际项目中识别聚合根时,我们需要对业务有深入的了解,因为只有这样你才知道到底哪些业务逻辑是内聚在一起的。这也是我们一直建议程序员和架构师们不要一味地埋头于技术而要多关注业务的原因.
事实上,如果让一个从来没有接触过DDD的人来建模,十有八九也能设计出上面的订单、客户和交易对象出来。没错,DDD绝非什么颠覆式的发明,依然只是在前人基础上的一种进步而已,这种进步更多的体现在一些设计原则上,对此我们将在下文进行详细阐述.
在代码实现层面,一般的实践是将所有的聚合根都继承自一个公共基类 AggregateRoot :
//AggregateRoot
@Getter
public abstract class AggregateRoot implements Identified {
private String id;//聚合根ID
private String tenantId;//租户ID
private Instant createdAt;//创建时间
private String createdBy;//创建人的MemberId
private String creator;//创建人姓名
private Instant updatedAt;//更新时间
private String updatedBy;//更新人MemberId
private String updater;//更新人姓名
private List<DomainEvent> events;//临时存放领域事件
private LinkedList<OpsLog> opsLogs;//操作日志
@Version
@Getter(PRIVATE)
private Long _version;//版本号,实现乐观锁
//...此处省略了AggregateRoot中行为方法
@Override
public String getIdentifier() {
return id;
}
}
源码出处: com/mryqr/core/common/domain/AggregateRoot.java 。
在 AggregateRoot 中,包含聚合根ID( id )、创建信息( createdAt 和 createdBy )和更新信息( updatedAt 和 updatedBy )等数据。租户ID( tenantId )用于标定聚合根所在的租户(码如云是一个多租户系统)。另外, events 用于临时性存放聚合根中所产生的领域事件,我们将在 领域事件 一文中对此所详细解释.
实际的聚合根继承自 AggregateRoot ,例如,在 码如云 中, 分组 (Group)聚合根的实现如下:
@Getter
@Document(GROUP_COLLECTION)
@TypeAlias(GROUP_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Group extends AggregateRoot {
private String name;//名称
private String appId;//所在的app
private List<String> managers;//管理员
private List<String> members;//普通成员
private boolean archived;//是否归档
private String customId;//自定义编号
private boolean active;//是否启用
private String departmentId;//由哪个部门同步而来
//...此处省略了Group的行为方法
}
源码出处: com/mryqr/core/group/domain/Group.java 。
从上面的代码例子可以看出,聚合根只是普通的Java对象而已,真正使之成为聚合根的是一些特定的设计原则.
这个原则不用我们再细讲了吧,估计你在大学里就学过,只举个例子,对于上面的分组 Group 对象来说,管理员 managers 、普通成员 members 以及启用标志 active 均是 Group 不可分割的属性,这些属性独立于 Group 是无法存在的.
对外黑盒原则讲的是,聚合根的外部(也即聚合根的调用方或客户方)不需要关心聚合根内部的实现细节,而只需要通过调用聚合根向外界暴露的共有业务方法即可。具体表现为,外部对聚合根的调用只能通过根对象完成,而不能调用聚合根内部对象上的方法。举个例子,在码如云中,管理员可以向 分组 (Group)中添加成员,具体的实现代码如下:
//Group
public void addMembers(List<String> memberIds, User user) {
if (isSynced()) {
throw new MryException(GROUP_SYNCED,
"无法添加成员,已设置从部门同步。",
"groupId", this.getId());
}
this.members = concat(members.stream(), memberIds.stream())
.distinct()
.collect(toImmutableList());
addOpsLog("设置成员", user);
}
源码出处: com/mryqr/core/group/domain/Group.java 。
这里,外部在向分组中添加成员时,需要调用 Group 上的 addMembers() 方法,该方法知道将 memberIds 添加到自身的 members 字段中,这个过程对外部是不可见的。与之相对的另一种方式是,外部调用法先拿到 Member 的 members 引用,然后由外部自行向 members 中添加 memberIds :
//外部调用方
@Transactional
public void addGroupMembers(String groupId, List<String> memberIds, User user) {
Group group = groupRepository.byIdAndCheckTenantShip(groupId, user);
if (group.isSynced()) {
throw new MryException(GROUP_SYNCED,
"无法添加成员,已设置从部门同步。",
"groupId", group.getId());
}
List<String> members = group.getMembers();
members.addAll(memberIds);
groupRepository.save(group);
log.info("Added members{} to group[{}].", memberIds, groupId);
}
源码出处: com/mryqr/core/group/command/GroupCommandService.java 。
这种方式是一种反模式,存在以下缺点:
Group
的内部结构,背离了对外黑盒原则,本例中,外部通过 group.getMembers()
获取到了 Group
内部的 members
属性 group.isSynced()
的调用原本应该放在 Group
中的,结果却由外部承担了该职责 在对外黑盒原则的指导下,聚合根自然形成了一个边界,它站在这个边界上向外声明:“我所包围着的内部的所有均由我负责,如果谁想访问我的内部,直接访问是被禁止的,只能通过我这个“根”来访问。” 。
不变条件(Invariants)表示聚合根需要保证其内部在任何时候均处于一种合法的状态(也即数据一致性需要得到保证),一个常见的例子是订单(Order)中有订单项(OrderItem)和订单价格(Price),当订单项发生变化时,其价格应该随之发生变化,并且这两种变化应该在订单的同一个业务方法中完成。这一点是好理解的,既然聚合根对外是一个黑盒,那么外界便不会负责给你聚合根擦屁股,你聚合根自己需要保证自身的正确性.
在码如云中,应用管理员可以向 分组 (Group)中添加分组管理员。这其中有层隐含意思是,既然分组管理员也是分组成员,那么在添加分组管理员的同时需要一并将其添加到分组成员中,具体实现代码如下:
//Group
public void addManager(String memberId, User user) {
if (!this.members.contains(memberId)) {
this.members = concat(members.stream(), Stream.of(memberId))
.distinct()
.collect(toImmutableList());
}
this.managers = concat(this.managers.stream(), Stream.of(memberId))
.distinct()
.collect(toImmutableList());
raiseEvent(new GroupManagersChangedEvent(this.getId(), this.getAppId(), user));
addOpsLog("添加管理员", user);
}
源码出处: com/mryqr/core/group/domain/Group.java 。
在本例的添加分组管理员 addManager() 方法中,我们除了向 managers 中添加成员外,还保证了该成员也出现在 members 中。这里的“分组管理员也是分组成员”即是一种不变条件,我们需要在聚合根内部保证不变条件不被破坏,因为不变条件往往意味着核心的业务逻辑.
当一个聚合根需要引用另一个聚合根时,并不需要维持对另一聚合根的整体引用,而是只需通过ID进行引用即可。这个原则的出发点是:聚合根和聚合根之间是一种平级关系,并不是隶属关系,每个聚合根本身是一个相对独立的模块,其与其他聚合根的关系应该通过ID这种松耦合的方式进行引用,如果整体引用则更像是一种包含关系.
在码如云中, 分组 (Group)通过 appId 引用其所属的 应用 (App),通过 departmentId 引用所同步的 部门 (Department),而在 managers 和 members 字段中,则是以 memberId 引用相应 成员 (Member):
@Getter
@Document(GROUP_COLLECTION)
@TypeAlias(GROUP_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Group extends AggregateRoot {
private String name;//名称
private String appId;//所在的app
private List<String> managers;//管理员
private List<String> members;//普通成员
private boolean archived;//是否归档
private String customId;//自定义编号
private boolean active;//是否启用
private String departmentId;//由哪个部门同步而来
//...省略其他代码
}
源码出处: com/mryqr/core/group/domain/Group.java 。
既然整个领域模型与基础设施无关,那么位于领域模型之内的聚合根自然也不能与基础设施相关,这样好处是将业务复杂度与技术复杂度解耦开来,让业务模型可以独立于技术设施而完成自身的演变。比如,假设一个项目需要从Spring框架迁移到Guice框架,此时如果能够保证领域模型与基础设施的无关性,那么对领域模型的迁移过程讲变得非常简单,基本上无需修改任何代码直接拷贝到新的项目中即可.
事实上,码如云尚未完全做到这一点,从上面的例子中可以看到, AggregateRoot 和 Group 对Spring框架中的 @Version 、 @Document 和 @TypeAlias 3个与持久化相关的注解存在引用。如需解决这个问题,可以考虑在领域模型之外另建专门用于数据库访问的 持久化对象(Persistence Model) 。但是,引入持久化对象是有成本的,比如需要维护领域对象与持久化对象之间的相互转化等。在码如云,我们选择了妥协,一方面考虑到持久化对象的成本,另一方面我们也预见在将来要迁移出Spring框架的几率是非常小的。不过,除了前面提到的3个注解之外,码如云中的聚合根可以做到对基础设施没有任何其他引用。关于持久化对象,在 Stackoverflow 上有过非常有意义的讨论,读者可自行阅览.
通常来讲,一个业务用例只会操作一个(或一种)聚合根。但有时,一个业务用例可能会导致多个(或多种)聚合根对象的更新,此时可分两种情况:
码如云 是一个单体系统,因此属于以上的第2种情况,我们根据聚合根之间的业务紧密程度的不同,在有些场景下选择了同时更新多个聚合根,在另一些场景下则选择通过事件驱动机制解决。比如,在“创建实例”的用例中,除了创建 实例 (QR)之外,还需要创建该实例对应的 码牌 (Plate),由于“有实例就必有码牌”,因此它们之间是紧密联系的,故在码如云中我们选择了在同一个本地事务中同时更新实例和码牌:
//QrCommandService
@Transactional
public CreateQrResponse createQr(CreateQrCommand command, User user) {
String name = command.getName();
String groupId = command.getGroupId();
Group group = groupRepository.cachedByIdAndCheckTenantShip(groupId, user);
String appId = group.getAppId();
App app = appRepository.cachedById(appId);
PlatedQr platedQr = qrFactory.createPlatedQr(name, group, app, user);
QR qr = platedQr.getQr();
Plate plate = platedQr.getPlate();
//同时保存QR和Plate
qrRepository.save(qr);
plateRepository.save(plate);
log.info("Created qr[{}] of group[{}] of app[{}].",
qr.getId(), groupId, appId);
return CreateQrResponse.builder()
.qrId(qr.getId())
.plateId(plate.getId())
.groupId(groupId)
.appId(appId)
.build();
}
源码出处: com/mryqr/core/group/command/GroupCommandService.java 。
可以看到,在用例方法 createQr() 中,我们先后调用 qrRepository.save(qr) 和 plateRepository.save(plate) 分别完成了对 QR 和 Plate 的持久化.
如果你希望了解事件驱动架构相关的知识,请参考本系列的 领域事件 一文.
在DDD中, 资源库 (Repository)以聚合根为单位完成对数据库的访问。这里的重点是“以聚合根为单位”,也即只有聚合根才配得上拥有资源库(毕竟在DDD中大家都是围绕着聚合根转的嘛),其他对象(比如非聚合根实体)是没有对应资源库的,这也是资源库和 DAO 最大的区别。在编码实现时,资源库方法所接受的参数和返回的数据都应该是聚合根对象,例如,在码如云中, 成员 (Member)聚合根对应的资源库定义如下:
public interface MemberRepository {
Member byId(String id); //返回聚合根
Optional<Member> byIdOptional(String id); //返回聚合根
Member byIdAndCheckTenantShip(String id, User user); //返回聚合根
void save(Member member); //聚合根作为参数
void delete(Member member); //聚合根作为参数
}
源码出处: com/mryqr/core/member/domain/MemberRepository.java 。
行业中这么一个现象,很多程序员在面对一个新的业务需求时,首先想到的是如何设计数据库的表结构,然后再编写业务代码。在DDD中,这是一种反模式,既然是“领域驱动”,那么我们首先应该关心的是如何业务建模,而不是数据库建模。事实上,正如Robert C. Martin在《整洁架构》一书中所说,数据库只是一个实现细节而已,不应该成为软件建模的主体.
资源库的作用,在于它在业务复杂度和技术复杂度之间做了一层很好的隔离,让我们可以独立地看待软件的业务模型而不受技术设施的影响。从本质上讲,资源库做的事情只是实现数据在内存和磁盘之间相互传输而已。在编程实现业务逻辑的时候,我们只需关心内存中的那个聚合根对象即可,当聚合根对象的状态由于业务操作发生了改变之后,再调用资源库将新的聚合根状态同步到磁盘中完成持久化,在调用时我们假设并相信资源库一定可以完成其自身的使命.
@Transactional
public void addGroupManager(String groupId, String memberId, User user) {
Group group = groupRepository.byIdAndCheckTenantShip(groupId, user);
group.addManager(memberId, user);
groupRepository.save(group);
log.info("Added manager[{}] to group[{}].", memberId, groupId);
}
源码出处: com/mryqr/core/group/command/GroupCommandService.java 。
在上例的“向分组中添加管理员”用例中,首先通过资源库 GroupRepository 的 byIdAndCheckTenantShip() 方法得到聚合根 Group 对象,然后再完成后续操作。这里的 addGroupManager() 无需知道 Group 是如何加载的,甚至不用知道后台使用的是MySQL还是MongoDB或是其他,反正通过调用 GroupRepository.byIdAndCheckTenantShip() 可以得到一个完整合法的 Group 对象即可.
在资源库中,最重要的方法有以下3个:
public interface GroupRepository {
Group byId(String id);
void save(Group group);
void delete(Group group);
}
源码出处: com/mryqr/core/member/domain/MemberRepository.java 。
其中, byId() 用于根据ID获取指定聚合根, save() 用于保存聚合根, delete() 则用于删除聚合根。除此之外,资源库中还可以包含更多的查询方法,比如在 GroupRepository 中还包含以下方法:
//根据部门ID查找分组
List<Group> byDepartmentId(String departmentId);
//根据ID查找分组,返回Optional
Optional<Group> byIdOptional(String id);
//根据ID查找分组,同时检查租户
Group byIdAndCheckTenantShip(String id, User user);
源码出处: com/mryqr/core/member/domain/MemberRepository.java 。
需要注意的是,这里的查询方法指的是在实现业务逻辑的过程中需要做的查询操作,并不是为了前端显示那种纯粹的查询,因为纯粹的查询操作不见得一定要放到资源库中,而是可以作为一个单独的关注点通过 CQRS 解决.
在DDD项目中,通常将资源库分为接口类和实现类,将接口类放置在领域模型 domain 包中,而将实现类放置在基础设施 infrastructure 包中,这种做法有2点好处:
在本文中,我们讲到了作为DDD核心的聚合根的设计原则及实现,其中包含内聚原则、对外黑盒原则和不变条件原则等。此外,我们也对与聚合根密切相关的资源库做了讲解。在下一篇 实体与值对象 中,我们将讲到实体和值对象之间的区别,以及各自的典型编码实践.
最后此篇关于产品代码都给你看了,可别再说不会DDD(六):聚合根与资源库的文章就讲到这里了,如果你想了解更多关于产品代码都给你看了,可别再说不会DDD(六):聚合根与资源库的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。
这里有一个很大的设计缺陷,但我无法解决它: 业务需要有点涉及,所以我会尽量保持简单。 我们有一张购物表和一张退货表。当进行退货时,我们必须找到与数据库中最早购买的退货匹配,并将其记录在“已应用退货”表
在我的家庭项目中,我遇到了确定域对象类型的问题。 领域:公交时刻表 限界上下文:路由(公共(public)交通基础设施,ctx1)、时间表(调度,ctx2) 对象: Station - 描述一个公交车
我有一个名为“产品类型”的值类型,它被分配给了一个产品。 (一个产品有一个产品类型) 为了允许用户从列表中选择类型,我将填充一个下拉列表。在哪里检索产品类型列表最合适?一个实现存储库模式的类? 编辑:
域这个词在 DDD 中究竟是什么意思?我一直在阅读定义……虽然我看到了域模型之类的东西并理解模型是什么 - 域模型是什么意思? 域实际上是什么意思? 谢谢 最佳答案 域是指您的应用程序解决的主题。 例
DDD 中的域模型应该与持久性无关。 CQRS 要求我为我不想在读取模型中包含的所有内容触发事件。 (顺便说一下,将我的模型拆分为一个写模型和至少一个读模型)。 ES 要求我为所有改变状态的事件触发事
Eric 在他的书中触及了 的主题。模块 很少。他也没有通过示例讨论模块结构与有界上下文的关系。限界上下文是否包含模块或模块包含限界上下文?当应用程序具有 DDD 时,它的可扩展性有多容易? 在我们设
前言 笔者于2021年入职了杭州一家做水务系统的公司,按照部门经理要求,新人需要做一次个人分享(主题随意)。 当时笔者对DDD充满了浓厚的兴趣,之前也牛刀小试过,于是就决定班门弄斧Show一下
上部分 模型驱动设计的构造块 为维护模型和实现之间的关系打下了基础。在开发过程中使用一系列成熟的基本构造块并运用一致的语言,能够使开发工作更加清晰而有条理。 我们面临的真正挑战是找到
3. 领域对象的生命周期 。 每个对象都有生命周期,如下图所示。对象自创建后,可能会经历各种不同的状态,直至最终消亡——要么存档,要么删除。当然很多对象是简单的临时对象,仅通过调用构造函数来创建
为了保证软件实践得简洁并且与模型保持一致,不管实际情况如何复杂,必须运用建模和设计的实践. 某些设计决策能够使模型和程序紧密结合在一起,互相促进对方的效用。这种结合要求我们注意每个元素的细节
大家好,我是 ddd 设计的新手,正在尝试使用这种在 C# 中工作的模式开发我的第一个应用程序 在我的应用程序中,我有一个包含子实体 Assets 的聚合合约,当添加或结算 Assets 时,我应该在
我正在尝试弄清楚如何使项目的一些消费者(业务客户)的不变量保持一致,他们对同一版本的聚合根有自己的要求。让我们以客户为例,提出假设性问题以满足以下愚蠢的逻辑: public class Custome
我见过一些具有实体值对象表示的 DDD 项目。它们通常显示为 EmployeeDetail、EmployeeDescriptor、EmployeeRecord 等。有时它包含实体 ID,有时不包含。
我试图了解如何表示某些 DDD(域驱动设计)规则。 遵循蓝皮书约定,我们有: 根实体具有全局身份并负责检查不变量。 根实体控制访问,并且不会被其内部结构的更改所蒙蔽。 对内部成员的 transient
我对 ddd 中的验证方法有疑问。我已经阅读了相当有争议的意见。有人说这应该在实体之外,其他人说这应该放在实体中。我试图找到一种我可以遵循的方法。 例如,假设我有带有电子邮件和密码的 User 实体。
寻找有关如何解决此问题的建议,并了解域驱动设计是否真的是这里的最佳模式。 我的客户正在重新构建其几近过时的工具和服务堆栈。客户是一个快速扩张的电子商户。它的核心产品是它的大型电子商务网站。围绕该网站,
我很难找出实现依赖于数据库中存储的数据的业务规则验证的最佳方法。在下面的简化示例中,我想确保用户名属性是唯一的。 public class User() { public int Id { g
情况: 要处理域事件,Jimmy Bogart proposed 一种将事件存储在聚合中的方法。 在我看来,这是一种非常方便的方法。但是,域服务中的域事件怎么办? 域服务不应该有状态(stateles
我正在处理遗留项目,试图改进项目结构。我的问题是我应该如何组织代码结构。我看到两个选项: #1 business-domain / layer app/ ----accout/ --------app
根据 DDD 原则,所有处理与特定聚合根对象相关的实体的 CRUD 操作都应该由聚合根进行。 但是我们如何从 aggr 根中仅更改实体的单个属性?我们应该在实体中有 setter 方法吗?这些方法应该
我是一名优秀的程序员,十分优秀!