本文作者:来自 MoonWebTeam 的 acejhli
本文编辑:kanedongliu
试想一个场景,如果现在 A 想注销他的微信账号,那么同时账号里的钱包数据也需要清除,钱包里银行卡的绑定关系也需要清除,但同时如果这张银行卡又作为亲属卡绑定到了 B 的钱包,B 钱包的状态也要更新…… 在一个系统中,对一个实体的操作,会导致其他实体的状态更新,这是为了保持状态的一致性。面对这样的层层嵌套,相互影响的情况,DDD(领域驱动设计)又是通过什么方式去解决的呢?
一、 DDD领域划分
在我们深入讨论领域驱动设计(DDD)的“聚合根”概念之前,我们首先需要理解DDD的一些基本概念。DDD是一种软件开发方法,它强调以业务领域为中心来驱动软件设计和开发。在DDD中,我们有几个重要的概念:实体,值对象,界限上下文,聚合和聚合根。
1、 实体
实体是具有唯一标识符的对象,在对象的整个生命周期中,即使其属性发生变化,该标识符也保持不变。例如,一个人的姓名和地址可能会改变,但他们的身份证号码是唯一且不变的。
在 DDD 中,实体类通常会采用充血模型,与实体相关的所有业务逻辑都在实体类的方法中实现,跨多个实体的领域逻辑则在领域服务中实现。
2、 值对象
和实体相对的,值对象是没有唯一标识符的对象,它们通过其属性的值来定义。例如,一个地址对象可以通过街道,城市,州和邮政编码的组合来定义。
当然,值对象和实体在特定边界内的定义,某些时候值也可以成为一个实体,例如:在行政地图信息管理中,街道可能会改名,但是表示的地点需要唯一,所以地址就需要是一个实体。
只是定义成值对象,而不是实体,能大大降低系统的复杂度。为了避免混淆,需要强调的是,我们现在进行的是领域建模,而不是数据建模。在存储的实现上,为了查询效率或存储效率,是可以给地址设计 ID 的。
3、 界限上下文
界限上下文(Bounded Context),直译过来就是“有边界的上下文”。它是用来定义领域边界的通用语言,在当前领域的边界内保证所有的人员(开发人员、业务人员等)认知的统一,如名词的统一,动词的统一,行为描述的统一等等。
如此强调的边界是因为不同域的通用语言是不适用的,例如电商场景中,在订单域中,商品被叫做商品,但是在物流域中,它就变成了货物。在这两个领域的边界定义了交互的接口,通过商品唯一 ID 交互数据。
4、 领域划分
我们可以简单的把一个实体划分成一个领域,在系统的复杂度较低时,这种时候的效果是非常好的。但是随着系统越来越复杂,就会导致划分的实体越来越多,领域也越来越多,领域之间的关系也会错综复杂,管理起来就会很吃力了。
我们会发现有些实体的业务逻辑总是会一起执行,而且执行顺序没什么区别,修改的时候也会一起变化。那就可以把这些关系密切的实体放在一起管理,这就是聚合。后续要执行这些业务逻辑仅需要通过一个实体就可以,这个实体就是聚合根。
二、 聚合和聚合根
1、 聚合
聚合是由业务和逻辑紧密关联的实体和值对象组合而成的,聚合是数据修改和持久化的基本单元。它通过定义实体之间清晰的所属关系和边界来实现模型的内聚,聚合内的实体间关系是明确的。除了聚合根外,其他实体都是通过聚合根间接与外界联系,简化了实体间的关系。
聚合定义了一致性的边界,在一个事务中,只有一个聚合的状态可以被修改。这意味着,聚合作为一个整体,内部所有实体的创建、修改和删除,都应该在一个聚合的范围内完成。
例如在游戏社区中,如果删除了相关的游戏社区,则在该社区内发的帖子也必须被删除。
2、聚合根
在一个聚合中,聚合根是一个特殊的实体,它是聚合的入口点。所有外部对象只能通过聚合根与聚合内的其他对象进行交互。聚合根的主要职责是维护聚合的一致性,它负责执行聚合所有的业务规则,确保聚合内的所有对象都处于有效的状态。
作为一个实体,聚合根本身拥有实体的属性和业务行为,实现自身的业务逻辑;
作为一个根实体(聚合的管理者),也拥有在聚合内部协调实体和值对象按照固定业务规则协同完成共同的业务逻辑。
同时作为聚合唯一对外的接口,负责接收外部的任务和请求,以及提供外部需要的数据。其他领域通常通过聚合根的 ID 来关联某个聚合,外部对象是不能直接访问聚合内除聚合外的其他实体的。
三、 聚合的设计原则
笔者引用了《实现领域驱动设计》一书中对聚合设计原则的定义。
1、在一致性边界之内建模真正的不变条件
"不变条件"是指聚合边界之内的所有内容组成一套不变的业务规则,任何操作都不能违背这些规则。例如,在一个订单系统中,一个不变条件可能是"订单的总金额不能为负"。
这里的一致性要求的是事务一致性,既立即性和原子性。原子性指要么不执行,要么都执行;立即性指在提交事务时,边界之内的所有内容必须保持一致。为了保证一个事务中只修改一个聚合实例,在做聚合设计时,我们需要将事务分析也考虑在内。
2、 设计小聚合
小聚合不仅有性能和可伸缩性上的好处,它还有助于事务成功执行,可减少事务提交冲突。系统可用性也得到增强。
《实现领域驱动设计》中举了下图中的一个例子,在这样的设计中,Product 应用实体关联的实体个数是 0...N,而往往不会时 0 个,而且会随着时间的推移逐渐增多。当要处理一个 Product 聚合时,如果把这么多的内容加载到内存会是多么恐怖的事,哪怕我只需要修改 Product 的名字。
根据 Vaughn Vernon 对某个金融项目的统计,在 70% 的聚合都可以设计成单个实体(根实体),其他 30% 大多数也是 2~3 个实体。
3、 通过唯一标识表示引用其他聚合
在一个聚合内部,我们不应该直接引用其他聚合的实体或值对象,容易导致聚合的边界不清晰,也会增加聚合之间的耦合度。而应该通过唯一标识来表示对其他聚合的引用。
仅通过唯一标识符引用其他聚合的实体,还可以提高系统的性能。因为在一个事务中,我们只需要加载和修改一个聚合即可,不需要加载其他的相关的聚合。同时还简化了领域模型,当前聚合仅知道关联聚合的 ID,而不需要知道关联聚合的实现。
4、 在边界之外使用最终一致性
任何跨聚合的业务规则都不能总是保持处于最新状态。通过事件处理、批处理或者其他更新机制,我们可以在一定时问之内处理好他方依赖。
在一次事务中,最多只能更改一个聚合的状态,根据原则一,聚合内数据需要保证强一致性。在聚合的一致性边界之外,我们不需要立即保证所有的数据都是一致的,而只需要保证数据能达到最终一致即可。
如果一次业务操作涉及多个聚合状态的更改,应采用领域事件的方式异步修改相关的聚合,实现聚合之间的解耦。
四、 微信转账的例子
需求:
- 存在微信账户,微信账户包含用户的手机号、用户昵称、钱包。
- 钱包有余额,和该钱包的账单明细
- 在转账场景中,A 账户给 B 账户转账,从 A 钱包扣款,B 钱包收款,并产生 A 和 B 的转账单明细
1、 分析
通过对需求的分析,我们先进行领域建模。梳理出系统所有相关的实体和值对象,找到聚合和聚合根,并确定聚合和聚合根的依赖关系。
领域的建模是系统开发的相关人员不断拉通对齐的过程,这个过程我们需要统一术语和认知,我们会不断重复使用产生的术语以明确理解的一致。为了后续的内容的顺利进行,我希望读者能接受我在这个例子中使用的概念和术语。
钱包是和账户强绑定的,比如当我删除一个账户的时候,他的钱包也需要删除。当然钱包的余额也就没有了。当账户被删除时,账单是需要保留的,因为即使账户没有了,后续我也需要进行对账来保证系统的正确运行。账单中需要记录转账人和收款人的基础信息,如手机号、名字等。
通过上面的分析,可以提取出参与活动的实体或值对象(账户、钱包、账单等)。然后根据它们的联系划分出两个聚合:账户聚合和账单聚合。
在账户聚合中,我们有账户和钱包两个实体,账户作为根实体,账户被删除时,钱包也就被删除。电话号和余额作为值对象被账户和钱包实体引用。
账单聚合包含账单实体,转账人和收款人是值对象,记录转账操作的关键信息。由于跨聚合,所以它们是通过唯一标识符的方式引用账户实体的。转账人和收款人的值对象可以冗余账户实体的数据,这样即使未来账户不存在了,也不会影响账单的值对象数据。
2、 DDD 相关的基础类
- VO:值对象
- DP:Domain Primitive,如果不了解这个概念,视为 VO 即可
- Entity: 实体
- Identifier:作为实体的唯一标识符,要求是 DP,用于提供隐含的领域上下文信息。Entity 必须用 Identifier 作为唯一标识符,在工程实践中这样的设计并不是必须的,笔者在这只是为了强调在实体的设计中,这个值在这是有业务含义的。
- AggregateRoot:聚合根,聚合根是一种特殊的实体
- Repository:存储类,一般以聚合为单位设计存储
export abstract class VO {
}
export abstract class DP extends VO {
}
export abstract class Identifier extends DP {
}
export abstract class Entity<T extends Identifier> {
protected id: T;
constructor(id: T) {
this.id = id;
}
}
export abstract class AggregateRoot<T extends Identifier> extends Entity<T> {
}
/** 一般是以聚合根为单位设计仓储 */
export abstract class Repository<T extends AggregateRoot<I>, I extends Identifier> {
public abstract save(t: T): Promise<void>;
}
3、 账户聚合
- WechatAccount: 账户实体,是账户聚合的根实体。对外暴露支出(withdraw)和收款(deposit)方法,聚合间都需要通过聚合根和聚合交互,无法直接访问。所以在这里除了账户需要导出,而钱包实体(Wallet)不应该导出。
- Wallet:钱包实体,内部包含余额值对象(Balance),这里账户聚合不变的条件(在一致性边界之内建模真正的不变性)是余额不能为负数,所以在余额小于 0 时,会抛出异常。
- PhoneNumber:手机号DP,这是账户的唯一标识符,隐含了手机号的领域上下文信息,手机号本身有归属地编号的属性。在初始化的时候就保证了手机号的有效性。
- Balance:余额值对象,值对象是不可变的,所以这里增加和减少都要产生新的值对象,而不能直接修改 amount。
export class WechatAccount extends AggregateRoot<PhoneNumber> {
private nickname: string;
private wallet: Wallet;
public withdraw(asset: Asset) {
this.wallet.pay(asset);
}
public deposit(asset: Asset) {
this.wallet.receive(asset);
}
}
export class Wallet extends Entity<WalletNumber> {
constructor(
private balance: Balance,
) {
super(new WalletNumber(String(Math.random())));
}
public pay(asset: Asset) {
this.balance = this.balance.decrease(asset)
}
public receive(asset: Asset) {
try {
this.balance = this.balance.increase(asset)
} catch {
throw new Error('余额不足')
}
}
}
export class PhoneNumber extends Identifier {
private static pattern = /^0?[1-9]{2,3}-?\\d{8}$/;
public number: string;
constructor(number: string) {
super();
if(this.isValid(number)) {
throw new Error('电话号格式错误')
}
this.number = number;
}
private isValid(number: string) {
return PhoneNumber.pattern.test(number);
}
/** 归属地编号 */
public getAreaCode(): string {
return this.number.substring(0, 7);
}
}
class WalletNumber extends Identifier {
public readonly id: string;
}
export class Balance extends VO {
private amount: number;
constructor(amount: number) {
super();
if(this.isValid(amount)) {
throw new Error('余额格式错误')
}
this.amount = amount;
}
/** 余额不能为负数 */
private isValid(amount: number) {
return amount >= 0;
}
public decrease(asset: Asset): Balance {
return new Balance(this.amount + asset.amount);
}
public increase(asset: Asset): Balance {
return new Balance(this.amount - asset.amount);
}
}
4、 账单聚合
- TransferReceipt: 账单实体中包含转账人和收款人的信息,并且 Account 仅通过唯一标识符关联到账户,满足聚合的设计原则(通过唯一标识符引用其他聚合)。
- Account:账户值对象,这里账户 id 属性是指向账户聚合的唯一标识符。同时值对象内定义了冗余的属性,name 和 phone,用于记录转账发生时的信息,这个信息不论账单聚合外的账户聚合如何变化,都不会被影响。
export class TransferReceipt extends AggregateRoot<ReceiveNumber> {
public from: Account;
public to: Account;
public asset: Asset;
constructor(from: Account, to: Account, asset: Asset) {
this.from = from;
this.to = to;
this.asset = asset;
}
}
export class ReceiveNumber extends Identifier {
public readonly id: string;
}
class Account extends VO {
private id: string,
private name: string,
private phone: string,
}
5、 应用层和仓储层
- TransformService:应用层逻辑,负责编排转账服务。这里比较简单,账户聚合修改余额,并持久化,然后生成账单并持久化。由于在一次事务中只有一个聚合的状态会被修改,所以账单聚合的逻辑允许在其他进程/服务中执行,可以通过领域事件触发(在边界之外使用最终一致性)。
export class TransformService {
private accountRepository: AccountRepository;
private transferReceiptRepository: TransferReceiptRepository;
public transfer(payer: WechatAccount, payee: WechatAccount, asset: Asset) {
payer.withdraw(asset);
payee.deposit(asset);
this.accountRepository.save(payer);
this.accountRepository.save(payee);
const transferReceipt = new TransferReceipt(
accountFactory.create(payer.accountId),
accountFactory.create(payee.accountId),
asset
);
this.transferReceiptRepository.save(transferReceipt)
}
}
export class AccountRepository extends Repository<WechatAccount, PhoneNumber> {
public async save(t: WechatAccount): Promise<void> {
database.account.save(convert(t));
database.wallet.save(convertWallet(t));
}
}
export class TransferReceiptRepository extends Repository<TransferReceipt, ReceiveNumber> {
public async save(t: TransferReceipt): Promise<void> {
database.receipt.save(convert(t));
}
}
五、 总结
聚合根是实体,具有全局唯一标识,有独立的生命周期。一个聚合只有一个聚合根,聚合根在聚合内对实体和值对象采用直接对象引用的方式进行组织和协调,聚合之间通过 ID 关联的方式实现聚合之间的协同。
架构设计是一个复杂的工程问题,DDD 是一种通过业务角度来对系统进行设计的思想,笔者在本文中仅对其中的某个要点表达了个人的浅薄的见解,非常欢迎大家提供不同思路和讨论。
六、 参考文献
- DDD领域驱动设计实战-理解聚合(Aggregate)和聚合根(AggregateRoot)-腾讯云开发者社区-腾讯云
- DDD 实战课
- An Alibaba Cloud Technical Expert’s Insight Into Domain-driven Design: Domain Primitive
最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。
七、 关于我们
MoonWebTeam目前成员均来自于腾讯,我们致力于分享有深度的前端技术,有价值的人生思考。