如何分别用基于贫血模型的传统开发模式,以及基于充血模型的 DDD
开发模式,设计实现一个钱包系统。算法
通常来说,每一个虚拟钱包帐户都会对应用户的一个真实的支付帐户,有多是银行卡帐户,也有多是三方支付帐户(好比支付宝、微信钱包)。为了方便后续的讲解,咱们限定钱包暂时只支持充值、提现、支付、查询余额、查询交易流水这五个核心的功能,其余好比冻结、透支、转赠等不经常使用的功能,咱们暂不考虑。为了让你理解这五个核心功能是如何工做的,接下来,咱们来一起看下它们的业务实现流程。数据库
1. 充值编程
用户经过三方支付渠道,把本身银行卡帐户内的钱,充值到虚拟钱包帐号中。这整个过程,咱们能够分解为三个主要的操做流程:第一个操做是从用户的银行卡帐户转帐到应用的公共银行卡帐户;第二个操做是将用户的充值金额加到虚拟钱包余额上;第三个操做是记录刚刚这笔交易流水。后端
2. 支付微信
用户用钱包内的余额,支付购买应用内的商品。实际上,支付的过程就是一个转帐的过程,从用户的虚拟钱包帐户划钱到商家的虚拟钱包帐户上,而后触发真正的银行转帐操做,从应用的公共银行帐户转钱到商家的银行帐户(注意,这里并非从用户的银行帐户转钱到商家的银行帐户)。除此以外,咱们也须要记录这笔支付的交易流水信息。框架
3. 提现分布式
除了充值、支付以外,用户还能够将虚拟钱包中的余额,提现到本身的银行卡中。这个过程实际上就是扣减用户虚拟钱包中的余额,而且触发真正的银行转帐操做,从应用的公共银行帐户转钱到用户的银行帐户。一样,咱们也须要记录这笔提现的交易流水信息。函数
4. 查询余额性能
查询余额功能比较简单,咱们看一下虚拟钱包中的余额数字便可。this
5. 查询交易流水
查询交易流水也比较简单。咱们只支持三种类型的交易流水:充值、支付、提现。在用户充值、支付、提现的时候,咱们会记录相应的交易信息。在须要查询的时候,咱们只须要将以前记录的交易流水,按照时间、类型等条件过滤以后,显示出来便可。
根据刚刚讲的业务实现流程和数据流转图,咱们能够把整个钱包系统的业务划分为两部分,其中一部分单纯跟应用内的虚拟钱包帐户打交道,另外一部分单纯跟银行帐户打交道。咱们基于这样一个业务划分,给系统解耦,将整个钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统。接来下只聚焦于虚拟钱包系统的设计与实现。对于三方支付系统以及整个钱包系统的设计与实现,你能够本身思考下。
如今咱们来看下,若是要支持钱包的这五个核心功能,虚拟钱包系统须要对应实现哪些操做。下面有一张图,列出了这五个功能都会对应虚拟钱包的哪些操做。注意,交易流水的记录和查询,暂时在图中打了个问号,那是由于这块比较特殊,咱们待会再讲。从图中咱们能够看出,虚拟钱包系统要支持的操做很是简单,就是余额的加加减减。其中,充值、提现、查询余额三个功能,只涉及一个帐户余额的加减操做,而支付功能涉及两个帐户的余额加减操做:一个帐户减余额,另外一个帐户加余额。
如今,咱们再来看一下图中问号的那部分,也就是交易流水该如何记录和查询?咱们先来看一下,交易流水都须要包含哪些信息。我以为下面这几个信息是必须包含的。
从图中咱们能够发现,交易流水的数据格式包含两个钱包帐号,一个是入帐钱包帐号,一个是出帐钱包帐号。为何要有两个帐号信息呢?这主要是为了兼容支付这种涉及两个帐户的交易类型。不过,对于充值、提现这两种交易类型来讲,咱们只须要记录一个钱包帐户信息就够了,因此,这样的交易流水数据格式的设计稍微有点浪费存储空间。
实际上,咱们还有另一种交易流水数据格式的设计思路,能够解决这个问题。咱们把“支付”这个交易类型,拆为两个子类型:支付和被支付。支付单纯表示出帐,余额扣减,被支付单纯表示入帐,余额增长。这样咱们在设计交易流水数据格式的时候,只须要记录一个帐户信息便可。我画了一张两种交易流水数据格式的对比图,你能够对比着看一下。
那以上两种交易流水数据格式的设计思路,你以为哪个更好呢?
答案是第一种设计思路更好些。由于交易流水有两个功能:一个是业务功能,好比,提供用户查询交易流水信息;另外一个是非业务功能,保证数据的一致性。这里主要是指支付操做数据的一致性。
支付实际上就是一个转帐的操做,在一个帐户上加上必定的金额,在另外一个帐户上减去相应的金额。咱们须要保证加金额和减金额这两个操做,要么都成功,要么都失败。若是一个成功,一个失败,就会致使数据的不一致,一个帐户明明减掉了钱,另外一个帐户却没有收到钱。
保证数据一致性的方法有不少,好比依赖数据库事务的原子性,将两个操做放在同一个事务中执行。可是,这样的作法不够灵活,由于咱们的有可能作了分库分表,支付涉及的两个帐户可能存储在不一样的库中,没法直接利用数据库自己的事务特性,在一个事务中执行两个帐户的操做。固然,咱们还有一些支持分布式事务的开源框架,可是,为了保证数据的强一致性,它们的实现逻辑通常都比较复杂、自己的性能也不高,会影响业务的执行时间。因此,更加权衡的一种作法就是,不保证数据的强一致性,只实现数据的最终一致性,也就是咱们刚刚提到的交易流水要实现的非业务功能。
对于支付这样的相似转帐的操做,咱们在操做两个钱包帐户余额以前,先记录交易流水,而且标记为“待执行”,当两个钱包的加减金额都完成以后,咱们再回过头来,将交易流水标记为“成功”。在给两个钱包加减金额的过程当中,若是有任意一个操做失败,咱们就将交易记录的状态标记为“失败”。咱们经过后台补漏 Job
,拉取状态为“失败”或者长时间处于“待执行”状态的交易记录,从新执行或者人工介入处理。
若是选择第二种交易流水的设计思路,使用两条交易流水来记录支付操做,那记录两条交易流水自己又存在数据的一致性问题,有可能入帐的交易流水记录成功,出帐的交易流水信息记录失败。因此,权衡利弊,咱们选择第一种稍微有些冗余的数据格式设计思路。
如今,咱们再思考这样一个问题:充值、提现、支付这些业务交易类型,是否应该让虚拟钱包系统感知?换句话说,咱们是否应该在虚拟钱包系统的交易流水中记录这三种类型?
答案是否认的。虚拟钱包系统不该该感知具体的业务交易类型。咱们前面讲到,虚拟钱包支持的操做,仅仅是余额的加加减减操做,不涉及复杂业务概念,职责单1、功能通用。若是耦合太多业务概念到里面,势必影响系统的通用性,并且还会致使系统越作越复杂。所以,咱们不但愿将充值、支付、提现这样的业务概念添加到虚拟钱包系统中。
可是,若是咱们不在虚拟钱包系统的交易流水中记录交易类型,那在用户查询交易流水的时候,如何显示每条交易流水的交易类型呢?
从系统设计的角度,咱们不该该在虚拟钱包系统的交易流水中记录交易类型。从产品需求的角度来讲,咱们又必须记录交易流水的交易类型。听起来比较矛盾,这个问题该如何解决呢?
咱们能够经过记录两条交易流水信息的方式来解决。咱们前面讲到,整个钱包系统分为两个子系统,上层钱包系统的实现,依赖底层虚拟钱包系统和三方支付系统。对于钱包系统来讲,它能够感知充值、支付、提现等业务概念,因此,咱们在钱包系统这一层额外再记录一条包含交易类型的交易流水信息,而在底层的虚拟钱包系统中记录不包含交易类型的交易流水信息。
为了让你更好地理解刚刚的设计思路,下面有一张图,你能够对比着上面的讲解一起来看。经过查询上层钱包系统的交易流水信息,去知足用户查询交易流水的功能需求,而虚拟钱包中的交易流水就只是用来解决数据一致性问题。实际上,它的做用还有不少,好比用来对帐等。
整个虚拟钱包的设计思路到此讲完了。接下来,咱们来看一下,如何分别用基于贫血模型的传统开发模式和基于充血模型的 DDD
开发模式,来实现这样一个虚拟钱包系统?
这是一个典型的 Web
后端项目的三层结构。其中,Controller
和 VO
负责暴露接口,具体的代码实现以下所示。注意,Controller
中,接口实现比较简单,主要就是调用 Service 的方法
,因此,我省略了具体的代码实现。
public class VirtualWalletController {
// 经过构造函数或者IOC框架注入
private VirtualWalletService virtualWalletService;
public BigDecimal getBalance(Long walletId) { ... } //查询余额
public void debit(Long walletId, BigDecimal amount) { ... } //出帐
public void credit(Long walletId, BigDecimal amount) { ... } //入帐
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} //转帐
}复制代码
Service
和 BO
负责核心业务逻辑,Repository
和 Entity
负责数据存取。Repository
这一层的代码实现比较简单,不是讲解的重点,因此也省略掉了。Service
层的代码以下所示。注意,这里省略了一些不重要的校验代码,好比,对 amount
是否小于 0
、钱包是否存在的校验等等。
public class VirtualWalletBo {//省略getter/setter/constructor方法
private Long id;
private Long createTime;
private BigDecimal balance;
}
public class VirtualWalletService {
// 经过构造函数或者IOC框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;
public VirtualWalletBo getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWalletBo walletBo = convert(walletEntity);
return walletBo;
}
public BigDecimal getBalance(Long walletId) {
return virtualWalletRepo.getBalance(walletId);
}
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
if (balance.compareTo(amount) < 0) {
throw new NoSufficientBalanceException(...);
}
walletRepo.updateBalance(walletId, balance.subtract(amount));
}
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
walletRepo.updateBalance(walletId, balance.add(amount));
}
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setFromWalletId(fromWalletId);
transactionEntity.setToWalletId(toWalletId);
transactionEntity.setStatus(Status.TO_BE_EXECUTED);
Long transactionId = transactionRepo.saveTransaction(transactionEntity);
try {
debit(fromWalletId, amount);
credit(toWalletId, amount);
} catch (InsufficientBalanceException e) {
transactionRepo.updateStatus(transactionId, Status.CLOSED);
...rethrow exception e...
} catch (Exception e) {
transactionRepo.updateStatus(transactionId, Status.FAILED);
...rethrow exception e...
}
transactionRepo.updateStatus(transactionId, Status.EXECUTED);
}
}复制代码
以上即是利用基于贫血模型的传统开发模式来实现的虚拟钱包系统。尽管咱们对代码稍微作了简化,但总体的业务逻辑就是上面这样子。其中大部分代码逻辑都很是简单,最复杂的是 Service
中的 transfer()
转帐函数。咱们为了保证转帐操做的数据一致性,添加了一些跟 transaction
相关的记录和状态更新的代码,理解起来稍微有点难度,你能够对照着以前讲的设计思路,本身多思考一下。
再来看一下,如何利用基于充血模型的 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 virtualWalletRepo.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 getAvaliableBalance() {
BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
if (isAllowedOverdraft) {
totalAvaliableBalance += this.overdraftAmount;
}
return totalAvaliableBalance;
}
public void debit(BigDecimal amount) {
BigDecimal totalAvaliableBalance = getAvaliableBalance();
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
类添加了简单的冻结和透支逻辑以后,功能看起来就丰富了不少,代码也没那么单薄了。若是功能继续演进,咱们能够增长更加细化的冻结策略、透支策略、支持钱包帐号(VirtualWallet id
字段)自动生成的逻辑(不是经过构造函数经外部传入 ID
,而是经过分布式 ID
生成算法来自动生成 ID
)等等。VirtualWallet
类的业务逻辑会变得愈来愈复杂,也就很值得设计成充血模型了。
对于虚拟钱包系统的设计与两种开发模式的代码实现,你应该有个比较清晰的了解了。不过,还有两个问题值得讨论一下。
第一个要讨论的问题是:在基于充血模型的 DDD
开发模式中,将业务逻辑移动到 Domain
中,Service
类变得很薄,但在咱们的代码设计与实现中,并无彻底将 Servic
e 类去掉,这是为何?或者说,Service
类在这种状况下担当的职责是什么?哪些功能逻辑会放到 Service
类中?
区别于 Domain
的职责,Service
类主要有下面这样几个职责。
1.Service
类负责与 Repository
交流。在上面的设计与代码实现中,VirtualWalletService
类负责与 Repository
层打交道,调用 Respository
类的方法,获取数据库中的数据,转化成领域模型 VirtualWallet
,而后由领域模型 VirtualWallet
来完成业务逻辑,最后调用 Repository
类的方法,将数据存回数据库。
之因此让 VirtualWalletService
类与 Repository
打交道,而不是让领域模型 VirtualWallet
与 Repository
打交道,那是由于咱们想保持领域模型的独立性,不与任何其余层的代码(Repository
层的代码)或开发框架(好比 Spring
、MyBatis
)耦合在一块儿,将流程性的代码逻辑(好比从 DB
中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。
2.Service
类负责跨领域模型的业务聚合功能。VirtualWalletService
类中的 transfer()
转帐函数会涉及两个钱包的操做,所以这部分业务逻辑没法放到 VirtualWallet
类中,因此,咱们暂且把转帐业务放到 VirtualWalletService
类中了。固然,虽然功能演进,使得转帐业务变得复杂起来以后,也能够将转帐业务抽取出来,设计成一个独立的领域模型。
3.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
层继续沿用贫血模型的设计思路是没有问题的。
DDD
的见解。 参考:实战一(下):如何利用基于充血模型的DDD开发一个虚拟钱包系统?
本文由博客一文多发平台 OpenWrite 发布!
更多内容请点击个人博客 沐晨