10k

设计模式之美-课程笔记5-贫血模型案例(虚拟钱包)

业务开发常用的基于贫血模型的MVC架构违背OOP吗?

  1. 当下流行的MVC架构其实是一种反模式,因为它是彻底的面向过程的编程风格。

  2. 什么是贫血模型?什么是充血模型?

  3. 为什么说基于贫血模型的传统开发模式违反 OOP?
  4. 基于贫血模型的传统开发模式既然违反 OOP,那又为什么如此流行?
  5. 什么情况下我们应该考虑使用基于充血模型的 DDD 开发模式?

什么是基于贫血模型的传统开发模式?

  1. 什么是MVC三层架构

    1. Model, View, Controller. 整个项目分为三层,逻辑层,展示层,数据层。
    2. 但是这个分层也比较笼统。会做一定调整。比如前后端分离的项目,后端一般被分为Repository 层、Service 层、Controller 层。Repository层负责数据访问,Service层负责业务逻辑,Controller层负责暴露接口。
  2. 什么是贫血模型

    看段代码:

////////// Controller+VO(View Object) //////////
public class UserController {
  private UserService userService; //通过构造函数或者IOC框架注入

  public UserVo getUserById(Long userId) {
    UserBo userBo = userService.getUserById(userId);
    UserVo userVo = [...convert userBo to userVo...];
    return userVo;
  }
}

public class UserVo {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

////////// Service+BO(Business Object) //////////
public class UserService {
  private UserRepository userRepository; //通过构造函数或者IOC框架注入

  public UserBo getUserById(Long userId) {
    UserEntity userEntity = userRepository.getUserById(userId);
    UserBo userBo = [...convert userEntity to userBo...];
    return userBo;
  }
}

public class UserBo {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

////////// Repository+Entity //////////
public class UserRepository {
  public UserEntity getUserById(Long userId) { //... }
}

public class UserEntity {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

看起来Controller层接口层,对应数据结构树UserVo (View Object),可能就是一个map;Service层和Business Object是内存中数据处理时候数据的组织方式(也许是一个对象?);对应的Repository层数据交互层以及UserEntity也可能对应的是一个对象。

  1. 平时开发 Web 后端项目的时候,基本上都是这么组织代码的。其中,UserEntity 和 UserRepository 组成了数据访问层,UserBo 和 UserService 组成了业务逻辑层,UserVo 和 UserController 在这里属于接口层。

  2. UserBo只是一个数据结构,只包含数据,不包含任何业务逻辑。业务逻辑在Service层中。(反过来讲, Service层中操作的对象,数据在UserBo,操作在Service层定义)。这种UserBo就叫做贫血模型(Anemic Domain Model)。同理还有UserEntity, UserVo. 这种设计破坏了面向对象的封装。

什么是基于充血模型的DDD开发模式?

被推崇的一种开发模式。

  1. 什么是充血模型

    与贫血模型对应,操作和数据被封装到同一个类中。

  2. 什么是DDD开发模式(领域驱动设计开发模式)

    用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。(老概念,但是被微服务带起来了)。

  3. 微服务:针对公司业务合理的做一些拆分。这个DDD刚好就是指导微服务拆分的。

  4. 领域拆分概念最重要归于熟悉业务,对业务不熟悉是无法做合理拆分的。DDD只是个名称。

  5. 充血模型还是MVC分层的,和贫血模型的区别在于Service层。充血模型中,Service层包含Service类和Domain类两部分,Domain相当于贫血中的BO,区别在于Domain既包含数据,也包含业务逻辑,所以Service会变的轻薄。

为什么基于贫血模型的传统开发模式如此受欢迎?

我猜和大家倾向于写面向过程是一样的道理逻辑-》想起来简单。

  1. 大部分业务系统很简单, 就是CRUD操作,不需要精心设计充血模型。即使我们使用充血模型,Domain中也没有什么业务逻辑,跟贫血模型差不多,没有太大意义。
  2. 如我所说,充血模型需要前期更多的思考设计模型,定义业务逻辑。
  3. 贫血模型已经很久了,所以比较难转型。

什么项目应该考虑使用基于充血模型的DDD开发模式?

对应上面贫血受欢迎的原因

  1. 复杂的业务系统
    1. 为什么只是单纯的将操作放在了Domain中,从Service抽离,就可以应对复杂系统?
    2. 这两种开发模式会导向两种开发流程:
      1. 贫血:需求-》找数据表-》思考如何CRUD-》定义Entity、BO/VO-》往对应的Repository,Service,Controller中添加代码。
      2. 这种情况导致业务逻辑在SQL中,Service层可以做的事很少,新的需求又需要这么来一遍,可能SQL只是很小的区别。
      3. 充血:沥青所有的业务,定义Domain包含的属性和方法,Domain相当于可复用的业务中间层。

如何利用基于充血模型的DDD开发一个虚拟钱包系统?

业务背景

很多具有支付、购买功能的应用都支持钱包功能。应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户阅、查询交易流水等操作。如下是一个典型的钱包功能界面:

img

一般来说一个虚拟钱包都对应一个真实的支付账户(银行卡、支付宝等),在这个案例中我们只支持和讨论支付、充值、提现、查询余额、打印流水这个几个核心功能。

充值

  1. 通过第三方支付渠道,将银行卡账户的钱充值到虚拟账号中。
  2. 过程分三步:1. 从用户银行卡转钱到应用的公共银行卡账户;2. 将用户的充值金额加到虚拟钱包;3. 记录交易到交易流水。

支付

  1. 用户用钱包余额,支付购买应用内的商品。
    1. 从用户的虚拟钱包划钱到商家的虚拟账户上,2. 并记录交易流水。

提现

  1. 将虚拟账户的钱,提现到自己的银行卡中。
    1. 扣减虚拟账户的余额; 2. 触发真正的银行转账操作,从应用的公共银行账户转钱到用户的银行账户; 3. 记录交易流水。

查询余额

展示虚拟钱包的余额。

查询流水

我们只支持三种交易:充值、提现、支付,在做相应操作的时候我们会记录交易信息。查询流水需要根据条件和时间过滤和排序,然后显示。

设计思路

  1. 根据业务实现流程和数据流转图,整个钱包系统可以被划分为两部分一部分是跟应用内的虚拟账号打交道,另一部分单纯跟银行账户打交道。

  2. 这个案例中只详细讨论虚拟钱包部分。

    img

  3. 功能对应对虚拟钱包的操作:

    钱包 虚拟钱包
    充值 + 余额
    提现 - 余额
    支付 + - 余额
    查询余额 查询余额
    查询交易流水
  4. 对于交易流水的设计

交易流水ID,交易时间,交易金额,交易类型(ENUM(支付,充值,提现),入账钱包账号,出账钱包账号

基于贫血模型

  1. Controller和VO,负责接口暴露。(省略实现)
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) { ...} //转账
  //省略查询transaction的接口
}
  1. Service层和BO负责核心业务逻辑。
public class VirtualWalletBo {//省略getter/setter/constructor方法
  private Long id;
  private Long createTime;
  private BigDecimal balance;
}

public Enum TransactionType {
  DEBIT,
  CREDIT,
  TRANSFER;
}

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 walletRepo.getBalance(walletId);
  }

  @Transactional
  public void debit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    BigDecimal balance = walletEntity.getBalance();
    if (balance.compareTo(amount) < 0) {
      throw new NoSufficientBalanceException(...);
    }
    VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
    transactionEntity.setAmount(amount);
    transactionEntity.setCreateTime(System.currentTimeMillis());
    transactionEntity.setType(TransactionType.DEBIT);
    transactionEntity.setFromWalletId(walletId);
    transactionRepo.saveTransaction(transactionEntity);
    walletRepo.updateBalance(walletId, balance.subtract(amount));
  }

  @Transactional
  public void credit(Long walletId, BigDecimal amount) {
    VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
    transactionEntity.setAmount(amount);
    transactionEntity.setCreateTime(System.currentTimeMillis());
    transactionEntity.setType(TransactionType.CREDIT);
    transactionEntity.setFromWalletId(walletId);
    transactionRepo.saveTransaction(transactionEntity);
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    BigDecimal balance = walletEntity.getBalance();
    walletRepo.updateBalance(walletId, balance.add(amount));
  }

  @Transactional
  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
    VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
    transactionEntity.setAmount(amount);
    transactionEntity.setCreateTime(System.currentTimeMillis());
    transactionEntity.setType(TransactionType.TRANSFER);
    transactionEntity.setFromWalletId(fromWalletId);
    transactionEntity.setToWalletId(toWalletId);
    transactionRepo.saveTransaction(transactionEntity);
    debit(fromWalletId, amount);
    credit(toWalletId, amount);
  }
}
  1. Repository是数据存取,被省略掉了。

基于充血模型

  1. 区别主要在Service层。(操作和数据)

  2. 在这种开发模式下,虚拟钱包类被设计成充血的Domain领域类型,原来在Service中的部分业务逻辑移动到虚拟钱包类VirtualWallet中,让Service类实现依赖这个类。代码如下:

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 = this.balance.subtract(amount);
  }

  public void credit(BigDecimal amount) {
    if (amount.compareTo(BigDecimal.ZERO) < 0) {
      throw new InvalidAmountException(...);
    }
    this.balance = 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);
  }

  @Transactional
  public void debit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWallet wallet = convert(walletEntity);
    wallet.debit(amount);
    VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
    transactionEntity.setAmount(amount);
    transactionEntity.setCreateTime(System.currentTimeMillis());
    transactionEntity.setType(TransactionType.DEBIT);
    transactionEntity.setFromWalletId(walletId);
    transactionRepo.saveTransaction(transactionEntity);
    walletRepo.updateBalance(walletId, wallet.balance());
  }

  @Transactional
  public void credit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWallet wallet = convert(walletEntity);
    wallet.credit(amount);
    VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
    transactionEntity.setAmount(amount);
    transactionEntity.setCreateTime(System.currentTimeMillis());
    transactionEntity.setType(TransactionType.CREDIT);
    transactionEntity.setFromWalletId(walletId);
    transactionRepo.saveTransaction(transactionEntity);
    walletRepo.updateBalance(walletId, wallet.balance());
  }

  @Transactional
  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
    //...跟基于贫血模型的传统开发模式的代码一样...
  }
}
  1. 看起来其实更新balance的核心代码都是一行,只是位置不同,充血模型此时的优势并不明显。但是如果我们需要支持更复杂的业务逻辑,比如也支持透支一定额度和冻结部分余额。
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 = this.balance.subtract(amount);
  }

  public void credit(BigDecimal amount) {
    if (amount.compareTo(BigDecimal.ZERO) < 0) {
      throw new InvalidAmountException(...);
    }
    this.balance = this.balance.add(amount);
  }
}
  1. 看起来丰富一些。对比贫血模型要开发这两个新加的功能(说实话还是不太能感受到,因为还是差不多)。领域模型 VirtualWallet 类添加了简单的冻结和透支逻辑之后,功能看起来就丰富了很多,代码也没那么单薄了。如果功能继续演进,我们可以增加更加细化的冻结策略、透支策略、支持钱包账号(VirtualWallet id 字段)自动生成的逻辑(不是通过构造函数经外部传入 ID,而是通过分布式 ID 生成算法来自动生成 ID)等等。VirtualWallet 类的业务逻辑会变得越来越复杂,也就很值得设计成充血模型了。

辩证思考

  1. 为什么没有在充血模型中将单薄的Service类去掉?(或者说他此时的作用是什么)

    • 解耦。与Repository交流,获取数据库中数据转化成Domain模型VirtualWallet,这个类来处理业务逻辑,然后在调Repository回存数据。
    • 协调。负责跨领域的协调。VirtualWalletService 类中的 transfer() 转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到 VirtualWallet 类中,所以,我们暂且把转账业务放到 VirtualWalletService 类中了。当然,虽然功能演进,使得转账业务变得复杂起来之后,我们也可以将转账业务抽取出来,设计成一个独立的领域模型。
    • 系统功能。负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中。
  2. Controller 层和Repository层也需要充血Domain建模吗? 没必要,这两层本身就没有也不应该有很多的业务逻辑,所以没有必要去做。

Thoughts? Leave a comment