設計模式之美學習(十):如何利用基於充血模型的DDD開發一個虛擬錢包系統?

如何分別用基於貧血模型的傳統開發模式,以及基於充血模型的 DDD 開發模式,設計實現一個錢包系統。算法

錢包業務背景介紹

通常來說,每一個虛擬錢包帳戶都會對應用戶的一個真實的支付帳戶,有多是銀行卡帳戶,也有多是三方支付帳戶(好比支付寶、微信錢包)。爲了方便後續的講解,咱們限定錢包暫時只支持充值、提現、支付、查詢餘額、查詢交易流水這五個核心的功能,其餘好比凍結、透支、轉贈等不經常使用的功能,咱們暫不考慮。爲了讓你理解這五個核心功能是如何工做的,接下來,咱們來一起看下它們的業務實現流程。數據庫

1. 充值編程

用戶經過三方支付渠道,把本身銀行卡帳戶內的錢,充值到虛擬錢包帳號中。這整個過程,咱們能夠分解爲三個主要的操做流程:第一個操做是從用戶的銀行卡帳戶轉帳到應用的公共銀行卡帳戶;第二個操做是將用戶的充值金額加到虛擬錢包餘額上;第三個操做是記錄剛剛這筆交易流水。後端

2. 支付微信

用戶用錢包內的餘額,支付購買應用內的商品。實際上,支付的過程就是一個轉帳的過程,從用戶的虛擬錢包帳戶劃錢到商家的虛擬錢包帳戶上,而後觸發真正的銀行轉帳操做,從應用的公共銀行帳戶轉錢到商家的銀行帳戶(注意,這裏並非從用戶的銀行帳戶轉錢到商家的銀行帳戶)。除此以外,咱們也須要記錄這筆支付的交易流水信息。框架

3. 提現分佈式

除了充值、支付以外,用戶還能夠將虛擬錢包中的餘額,提現到本身的銀行卡中。這個過程實際上就是扣減用戶虛擬錢包中的餘額,而且觸發真正的銀行轉帳操做,從應用的公共銀行帳戶轉錢到用戶的銀行帳戶。一樣,咱們也須要記錄這筆提現的交易流水信息。函數

4. 查詢餘額性能

查詢餘額功能比較簡單,咱們看一下虛擬錢包中的餘額數字便可。this

5. 查詢交易流水

查詢交易流水也比較簡單。咱們只支持三種類型的交易流水:充值、支付、提現。在用戶充值、支付、提現的時候,咱們會記錄相應的交易信息。在須要查詢的時候,咱們只須要將以前記錄的交易流水,按照時間、類型等條件過濾以後,顯示出來便可。

錢包系統的設計思路

根據剛剛講的業務實現流程和數據流轉圖,咱們能夠把整個錢包系統的業務劃分爲兩部分,其中一部分單純跟應用內的虛擬錢包帳戶打交道,另外一部分單純跟銀行帳戶打交道。咱們基於這樣一個業務劃分,給系統解耦,將整個錢包系統拆分爲兩個子系統:虛擬錢包系統和三方支付系統。image.png接來下只聚焦於虛擬錢包系統的設計與實現。對於三方支付系統以及整個錢包系統的設計與實現,你能夠本身思考下。

如今咱們來看下,若是要支持錢包的這五個核心功能,虛擬錢包系統須要對應實現哪些操做。下面有一張圖,列出了這五個功能都會對應虛擬錢包的哪些操做。注意,交易流水的記錄和查詢,暫時在圖中打了個問號,那是由於這塊比較特殊,咱們待會再講。image.png從圖中咱們能夠看出,虛擬錢包系統要支持的操做很是簡單,就是餘額的加加減減。其中,充值、提現、查詢餘額三個功能,只涉及一個帳戶餘額的加減操做,而支付功能涉及兩個帳戶的餘額加減操做:一個帳戶減餘額,另外一個帳戶加餘額。

如今,咱們再來看一下圖中問號的那部分,也就是交易流水該如何記錄和查詢?咱們先來看一下,交易流水都須要包含哪些信息。我以爲下面這幾個信息是必須包含的。image2.png

從圖中咱們能夠發現,交易流水的數據格式包含兩個錢包帳號,一個是入帳錢包帳號,一個是出帳錢包帳號。爲何要有兩個帳號信息呢?這主要是爲了兼容支付這種涉及兩個帳戶的交易類型。不過,對於充值、提現這兩種交易類型來講,咱們只須要記錄一個錢包帳戶信息就夠了,因此,這樣的交易流水數據格式的設計稍微有點浪費存儲空間。

實際上,咱們還有另一種交易流水數據格式的設計思路,能夠解決這個問題。咱們把「支付」這個交易類型,拆爲兩個子類型:支付和被支付。支付單純表示出帳,餘額扣減,被支付單純表示入帳,餘額增長。這樣咱們在設計交易流水數據格式的時候,只須要記錄一個帳戶信息便可。我畫了一張兩種交易流水數據格式的對比圖,你能夠對比着看一下。image3.png

那以上兩種交易流水數據格式的設計思路,你以爲哪個更好呢?

答案是第一種設計思路更好些。由於交易流水有兩個功能:一個是業務功能,好比,提供用戶查詢交易流水信息;另外一個是非業務功能,保證數據的一致性。這裏主要是指支付操做數據的一致性。

支付實際上就是一個轉帳的操做,在一個帳戶上加上必定的金額,在另外一個帳戶上減去相應的金額。咱們須要保證加金額和減金額這兩個操做,要麼都成功,要麼都失敗。若是一個成功,一個失敗,就會致使數據的不一致,一個帳戶明明減掉了錢,另外一個帳戶卻沒有收到錢。

保證數據一致性的方法有不少,好比依賴數據庫事務的原子性,將兩個操做放在同一個事務中執行。可是,這樣的作法不夠靈活,由於咱們的有可能作了分庫分表,支付涉及的兩個帳戶可能存儲在不一樣的庫中,沒法直接利用數據庫自己的事務特性,在一個事務中執行兩個帳戶的操做。固然,咱們還有一些支持分佈式事務的開源框架,可是,爲了保證數據的強一致性,它們的實現邏輯通常都比較複雜、自己的性能也不高,會影響業務的執行時間。因此,更加權衡的一種作法就是,不保證數據的強一致性,只實現數據的最終一致性,也就是咱們剛剛提到的交易流水要實現的非業務功能。

對於支付這樣的相似轉帳的操做,咱們在操做兩個錢包帳戶餘額以前,先記錄交易流水,而且標記爲「待執行」,當兩個錢包的加減金額都完成以後,咱們再回過頭來,將交易流水標記爲「成功」。在給兩個錢包加減金額的過程當中,若是有任意一個操做失敗,咱們就將交易記錄的狀態標記爲「失敗」。咱們經過後臺補漏 Job,拉取狀態爲「失敗」或者長時間處於「待執行」狀態的交易記錄,從新執行或者人工介入處理。

若是選擇第二種交易流水的設計思路,使用兩條交易流水來記錄支付操做,那記錄兩條交易流水自己又存在數據的一致性問題,有可能入帳的交易流水記錄成功,出帳的交易流水信息記錄失敗。因此,權衡利弊,咱們選擇第一種稍微有些冗餘的數據格式設計思路。

如今,咱們再思考這樣一個問題:充值、提現、支付這些業務交易類型,是否應該讓虛擬錢包系統感知?換句話說,咱們是否應該在虛擬錢包系統的交易流水中記錄這三種類型?

答案是否認的。虛擬錢包系統不該該感知具體的業務交易類型。咱們前面講到,虛擬錢包支持的操做,僅僅是餘額的加加減減操做,不涉及複雜業務概念,職責單1、功能通用。若是耦合太多業務概念到裏面,勢必影響系統的通用性,並且還會致使系統越作越複雜。所以,咱們不但願將充值、支付、提現這樣的業務概念添加到虛擬錢包系統中。

可是,若是咱們不在虛擬錢包系統的交易流水中記錄交易類型,那在用戶查詢交易流水的時候,如何顯示每條交易流水的交易類型呢?

從系統設計的角度,咱們不該該在虛擬錢包系統的交易流水中記錄交易類型。從產品需求的角度來講,咱們又必須記錄交易流水的交易類型。聽起來比較矛盾,這個問題該如何解決呢?

咱們能夠經過記錄兩條交易流水信息的方式來解決。咱們前面講到,整個錢包系統分爲兩個子系統,上層錢包系統的實現,依賴底層虛擬錢包系統和三方支付系統。對於錢包系統來講,它能夠感知充值、支付、提現等業務概念,因此,咱們在錢包系統這一層額外再記錄一條包含交易類型的交易流水信息,而在底層的虛擬錢包系統中記錄不包含交易類型的交易流水信息。

爲了讓你更好地理解剛剛的設計思路,下面有一張圖,你能夠對比着上面的講解一起來看。image4.png經過查詢上層錢包系統的交易流水信息,去知足用戶查詢交易流水的功能需求,而虛擬錢包中的交易流水就只是用來解決數據一致性問題。實際上,它的做用還有不少,好比用來對帳等。

整個虛擬錢包的設計思路到此講完了。接下來,咱們來看一下,如何分別用基於貧血模型的傳統開發模式和基於充血模型的 DDD 開發模式,來實現這樣一個虛擬錢包系統?

基於貧血模型的傳統開發模式

這是一個典型的 Web 後端項目的三層結構。其中,ControllerVO 負責暴露接口,具體的代碼實現以下所示。注意,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) { ...} //轉帳
}複製代碼

ServiceBO 負責核心業務邏輯,RepositoryEntity 負責數據存取。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 開發模式來實現這個系統?

基於充血模型的 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 類變得很薄,但在咱們的代碼設計與實現中,並無徹底將 Service 類去掉,這是爲何?或者說,Service 類在這種狀況下擔當的職責是什麼?哪些功能邏輯會放到 Service 類中?

區別於 Domain 的職責,Service 類主要有下面這樣幾個職責。

1.Service 類負責與 Repository 交流。在上面的設計與代碼實現中,VirtualWalletService 類負責與 Repository 層打交道,調用 Respository 類的方法,獲取數據庫中的數據,轉化成領域模型 VirtualWallet,而後由領域模型 VirtualWallet 來完成業務邏輯,最後調用 Repository 類的方法,將數據存回數據庫。

之因此讓 VirtualWalletService 類與 Repository 打交道,而不是讓領域模型 VirtualWalletRepository 打交道,那是由於咱們想保持領域模型的獨立性,不與任何其餘層的代碼(Repository 層的代碼)或開發框架(好比 SpringMyBatis)耦合在一塊兒,將流程性的代碼邏輯(好比從 DB 中取數據、映射數據)與領域模型的業務邏輯解耦,讓領域模型更加可複用。

2.Service 類負責跨領域模型的業務聚合功能。VirtualWalletService 類中的 transfer() 轉帳函數會涉及兩個錢包的操做,所以這部分業務邏輯沒法放到 VirtualWallet 類中,因此,咱們暫且把轉帳業務放到 VirtualWalletService 類中了。固然,雖然功能演進,使得轉帳業務變得複雜起來以後,也能夠將轉帳業務抽取出來,設計成一個獨立的領域模型。

3.Service 類負責一些非功能性及與三方系統交互的工做。好比冪等、事務、發郵件、發消息、記錄日誌、調用其餘系統的 RPC 接口等,均可以放到 Service 類中。

第二個要討論問題是:在基於充血模型的 DDD 開發模式中,儘管 Service 層被改形成了充血模型,可是 Controller 層和 Repository 層仍是貧血模型,是否有必要也進行充血領域建模呢?

答案是沒有必要。Controller 層主要負責接口的暴露,Repository 層主要負責與數據庫打交道,這兩層包含的業務邏輯並很少,前面咱們也提到了,若是業務邏輯比較簡單,就不必作充血建模,即使設計成充血模型,類也很是單薄,看起來也很奇怪。

儘管這樣的設計是一種面向過程的編程風格,但咱們只要控制好面向過程編程風格的反作用,照樣能夠開發出優秀的軟件。那這裏的反作用怎麼控制呢?

就拿 RepositoryEntity 來講,即使它被設計成貧血模型,違反面相對象編程的封裝特性,有被任意代碼修改數據的風險,但 Entity 的生命週期是有限的。通常來說,咱們把它傳遞到 Service 層以後,就會轉化成 BO 或者 Domain 來繼續後面的業務邏輯。Entity 的生命週期到此就結束了,因此也並不會被處處任意修改。

再來講說 Controller 層的 VO。實際上 VO 是一種 DTOData 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 發佈!

更多內容請點擊個人博客 沐晨

相關文章
相關標籤/搜索