基于DDD的虚拟钱包系统设计
基于DDD的虚拟钱包系统设计
-
基于贫血模型的传统开发模式
-
基于充血模型的DDD开发模式
如何分别用这两种开发模式,设计实现一个钱包系统。
1 业务分析
具有支付、购买功能的应用(如京东、哈啰出行)都支持钱包功能。应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水。
钱包功能界面:
每个虚拟钱包账户都对应用户的一个真实的支付账户,如银行卡账户、三方支付账户(支付宝、微信钱包)。本文限定钱包只支持充值、提现、支付、查询余额、查询交易流水。
1.1 充值
用户通过三方支付渠道,把银行卡账户的钱,充值到虚拟钱包账号的流程:
- 从用户【银行卡账户】转账到应用的【公共银行卡账户】
- 用户的虚拟钱包余额+=用户的充值金额
- 记录该笔交易流水
1.2 支付
用户用钱包内的余额,支付购买应用内的商品。支付过程就是个转账过程,从【用户虚拟钱包账户】划钱到【商家虚拟钱包账户】。也需记录该笔支付交易流水。
1.3 提现
- 用户将【虚拟钱包】余额,提现到自己的【银行卡】,即扣减用户虚拟钱包中的余额
- 并触发真正的【银行转账】操作,从应用的【公共银行账户】转账到【用户银行账户】
- 记录该笔提现的交易流水
1.4 查询余额
查询虚拟钱包中的余额数。
1.5 查询交易流水
只支持三种类型的交易流水:充值、支付、提现。用户充值、支付、提现时,记录相应交易信息。
查询时,将之前记录的交易流水,按时间、类型等条件过滤后显示即可。
2 钱包系统设计
根据业务实现流程和数据流转图,可把钱包系统业务分为:
- 单纯跟应用内的虚拟钱包账户打交道
- 单纯跟银行账户打交道
给系统解耦,将整个钱包系统拆分为如下子系统:
本文聚焦虚拟钱包系统的设计与实现。
钱包的核心功能,虚拟钱包系统需对应实现的操作:
钱包 | 虚拟钱包 |
---|---|
充值 | +余额 |
提现 | -余额 |
支付 | ±余额 |
查询余额 | 查询余额 |
查询交易流水 | TODO |
- 充值、提现、查询余额功能,只涉及一个账户余额的±操作
- 支付功能涉及两个账户的余额加减操作
2.1 交易流水记录
交易流水所含信息:
可见,交易流水的数据格式包含两个钱包账号:
- 入账钱包账号
- 出账钱包账号
①为何要有两个账号信息?
仅为兼容【支付】,这种涉及两个账户的交易类型。其实对充值、提现,只需记录一个钱包账户信息,所以,这样的交易流水数据格式的设计稍浪费空间。
还有一种交易流水数据格式设计。把“支付”交易类型,拆为两个子类型:
- 支付,单纯表示出账,余额扣减
- 被支付,单纯表示入账,余额增加
于是,设计交易流水数据格式时,只需记录一个账户信息:
②哪个更好?
第一种设计!因为交易流水有两个功能:
-
业务功能
如提供用户查询交易流水信息
-
非业务功能,保证数据的一致性
此处主要指支付操作数据的一致性
支付就是个转账操作,一个账户加金额,另一个减金额。需保证加、减金额这俩操作要么都成功,要么都失败。
③保证数据一致性
- 依赖数据库事务原子性,将两个操作放在同一个事务。但不够灵活,因为可能做了分库分表,支付涉及的两个账户可能存储在不同库,无法直接利用数据库本身事务特性,在一个事务中执行两个账户的操作
- 分布式事务,但为保证数据强一致性,它们的实现逻辑一般都较复杂、性能不高,影响业务执行时间
- tradeoff后,不保证数据强一致性,只实现数据最终一致性,即交易流水要实现的非业务功能
对支付这样的类似转账操作,在操作两个钱包账户余额前,先记录交易流水,并标记为“待执行”,当两个钱包的加减金额都完成之后,再回调将交易流水标为“成功”。在给两个钱包±金额过程中,若任一操作失败,就将交易记录的状态标为“失败”。通过后台补漏Job,拉取状态为“失败”或长时处于“待执行”状态的交易记录,重新执行或人工处理。
若选择第二种交易流水设计,使用两条交易流水来记录支付操作,则记录两条交易流水本身又存在数据一致性问题,有可能入账交易流水记录成功,出账交易流水信息记录失败。所以,权衡后,选择第一种冗余数据格式设计。
充值、提现、支付这些业务交易类型是否应该让虚拟钱包系统感知?即在虚拟钱包系统的交易流水中记录这三种类型合理吗?
No!虚拟钱包系统不应感知具体业务交易类型。虚拟钱包仅是支持余额±操作,不涉及复杂业务概念,职责单一、功能通用。若耦合太多业务,势必影响系统通用性,导致越做越复杂。
④若不在【交易流水】中记录【交易类型】,用户查询时,如何显示每条【交易流水】的【交易类型】?
系统设计角度,不应在虚拟钱包系统的交易流水记录交易类型;产品需求角度,又必须记录交易流水的交易类型。问题很矛盾,怎么办呢?
记录两条交易流水信息。
整个钱包系统分为两个子系统,上层钱包系统的实现,依赖底层虚拟钱包系统和三方支付系统。钱包系统可感知充值、支付、提现等业务概念,所以,在钱包系统这层额外再记录一条包含交易类型的交易流水信息,而在虚拟钱包系统中记录不包含交易类型的交易流水信息。
- 查询上层的钱包系统的交易流水信息,实现用户查询交易流水
- 虚拟钱包中的交易流水,就只是解决数据一致性。其作用还有对账等
3 基于贫血模型的传统开发模式
Controller和VO负责暴露接口
Service和BO负责核心业务逻辑,Repository和Entity负责数据存取。
4 基于充血模型的DDD开发模式
充血模型DDD和贫血模型开发模式主要区别在Service层,Controller、Repository层代码基本相同。来看Service层充血模型DDD开发模式实现。
把虚拟钱包VirtualWallet类设计成一个充血Domain领域模型,将原来在Service类中的部分业务逻辑移动到VirtualWallet类,让Service类的实现依赖VirtualWallet类:
public class VirtualWallet { // Domain领域模型(充血模型)
private Long id;
private Long createTime = System.currentTimeMillis();
private BigDecimal balance = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public BigDecimal balance() {
return this.balance;
}
public void debit(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}
public class VirtualWalletService {
// 通过构造函数或者IOC框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;
public VirtualWallet getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
return wallet;
}
public BigDecimal getBalance(Long walletId) {
return walletRepo.getBalance(walletId);
}
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.debit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.credit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
//...跟基于贫血模型的传统开发模式的代码一样...
}
}
领域模型VirtualWallet类很单薄,包含的业务逻辑很简单。相对贫血模型设计,这种充血模型貌似并没啥太大优势。的确!这也是大部分业务系统都使用基于贫血模型开发的原因。
不过,若虚拟钱包系统需支持更复杂业务逻辑,充血模型优势就凸显了。比如要支持透支一定额度和冻结部分余额。看VirtualWallet类实现代码。
public class VirtualWallet {
private Long id;
private Long createTime = System.currentTimeMillis();;
private BigDecimal balance = BigDecimal.ZERO;
private boolean isAllowedOverdraft = true;
private BigDecimal overdraftAmount = BigDecimal.ZERO;
private BigDecimal frozenAmount = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public void freeze(BigDecimal amount) { ... }
public void unfreeze(BigDecimal amount) { ...}
public void increaseOverdraftAmount(BigDecimal amount) { ... }
public void decreaseOverdraftAmount(BigDecimal amount) { ... }
public void closeOverdraft() { ... }
public void openOverdraft() { ... }
public BigDecimal balance() {
return this.balance;
}
public BigDecimal getAvailableBalance() {
BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
if (isAllowedOverdraft) {
totalAvaliableBalance += this.overdraftAmount;
}
return totalAvaliableBalance;
}
public void debit(BigDecimal amount) {
BigDecimal totalAvaliableBalance = getAvailableBalance();
if (totoalAvaliableBalance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}
领域模型类添加简单的冻结和透支逻辑后,功能更丰富了,不再那么单薄。若功能继续演进,可新增更细化的冻结策略、透支策略、支持钱包账号(VirtualWallet id字段)自动生成逻辑(不是通过构造函数经外部传入ID,而是通过分布式ID生成算法来自动生成ID)等。随着领域模型类业务逻辑越发复杂,充血模型就更有意义了。
5 DDD设计思考
充血模型DDD开发模式,将业务逻辑移动到Domain,Service类变薄,但代码实现并没有完全将Service类去掉,为何?即Service类的职责到底是啥?哪些逻辑放到Service类?
区别于Domain的职责,Service类主要有如下职责:
与Repository交流
VirtualWalletService类负责与Repository层交互,调用Respository类方法,获取数据库数据,转化成领域模型VirtualWallet,然后由VirtualWallet完成业务逻辑,最后调用Repository类方法,将数据回库。
之所以让VirtualWalletService类而非领域模型VirtualWallet与Repository交互,是因为保持领域模型独立性,不与任何其他层代码(Repository层)或开发框架(如Spring、MyBatis)耦合,将流程性的代码逻辑(比如从DB中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。
Service类负责跨领域模型的业务聚合功能
VirtualWalletService类中的transfer()转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到VirtualWallet类中,所以,我们暂且把转账业务放到VirtualWalletService类中了。当然,虽然功能演进,使得转账业务变得复杂起来之后,我们也可以将转账业务抽取出来,设计成一个独立的领域模型。
Service类负责一些非功能性及与三方系统交互的工作
比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的RPC接口等,都可以放到Service类中。
充血模型DDD开发模式尽管Service层被改造成充血模型,但Controller层和Repository层还是贫血模型,有必要也充血领域建模吗?
没有。Controller层主要负责接口的暴露,Repository层主要负责与数据库打交道,这两层包含的业务逻辑并不多。如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄。
尽管这样的设计是一种面向过程的编程风格,但我们只要控制好面向过程编程风格的副作用,照样可以开发出优秀的软件。那这里的副作用怎么控制呢?
Repository的Entity即便被设计成贫血模型,违反面向对象编程的封装特性,有被任意代码修改数据的风险,但Entity的生命周期是有限。把它传递到Service层之后,就会转化成BO或者Domain来继续后面的业务逻辑。Entity的生命周期到此就结束了,所以也并不会被到处任意修改。
Controller层的VO。实际上VO是一种DTO(Data Transfer Object,数据传输对象)。它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据。所以,我们将它设计成贫血模型也合理。
总结
充血模型DDD开发模式跟基于贫血模型的传统开发模式相比,主要在Service层。在基于充血模型的开发模式下,我们将部分原来在Service类中的业务逻辑移动到了一个充血的Domain领域模型中,让Service类的实现依赖这个Domain类。
在基于充血模型的DDD开发模式下,Service类并不会完全移除,而是负责一些不适合放在Domain类中的功能。比如,负责与Repository层打交道、跨领域模型的业务聚合功能、幂等事务等非功能性的工作。
基于充血模型的DDD开发模式跟基于贫血模型的传统开发模式相比,Controller层和Repository层的代码基本相同。因为,Repository层的Entity生命周期有限,Controller层的VO只是单纯作为一种DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在Service层。所以,Repository层和Controller层继续沿用贫血模型的设计思路是没有问题的。
- 点赞
- 收藏
- 关注作者
评论(0)