充血模型&DDD


What

面向對象編程(Object Oriented Programming - OOP):一種編程范式或編程風格,以類或對象作為組織代碼的基本單元,並將封裝、抽象、繼承、多態四個特性,作為代碼設計和實現的基石。

貧血模型:數據和業務邏輯被分隔到不同的類中。數據與操作分離,破壞了面向對象的封裝特性,是典型的面向過程的編程風格。

充血模型:數據和對應的業務邏輯被封裝到同一個類(領域模型)中。滿足面向對象的封裝特性,是典型的面向對象編程風格。

領域驅動設計(Domain Driven Design - DDD):一種設計思想,主要是用來指導如何解耦業務系統,划分業務模塊,定義業務領域模型及其交互。微服務就是一種典型的實踐。

Why

基於充血模型的 DDD 開發模式相較於貧血模式的開發模式,優勢在哪兒?

我們平時的開發,大部分都是 SQL 驅動(SQL-Driven)的開發模式。我們接到一個后端接口的開發需求的時候,就去看接口需要的數據對應到數據庫中,需要哪張表或者哪幾張表,然后思考如何編寫 SQL 語句來獲取數據。之后就是定義 Entity、BO、VO,然后模板式地往對應的 Repository、Service、Controller 類中添加代碼。service中的業務邏輯或sql基本都是針對特定的嗯業務功能編寫的,復用性差。對於簡單業務系統來說,這種開發方式問題不大。但對於復雜業務系統的開發來說,這樣的開發方式會讓代碼越來越混亂,最終導致無法維護。

基於充血模型的 DDD 開發模式用類來描述業務模型(數據和功能),把原來又重又凌亂的service層邏輯拆分並轉移至各領域(Domain)類內,對不同業務功能的數據和方法進行封裝,提升了代碼內聚性和復用性,也提高了代碼可讀性。由於類等同於業務模型,在功能不斷迭代后就不會像貧血模型的service一樣變得雜亂、模糊、難以維護,整個系統的代碼看起來層次清晰,閱讀邏輯簡單易懂,也方便新人快速上手了解系統邏輯。無論是開發新功能還是老功能的迭代,我們優先考慮的是領域類如何定義、修改,因此稱之為領域驅動設計。

簡而言之:提升了代碼的復用性、擴展性、可維護性、可讀性。這對於復雜系統十分重要。

為什么幾乎所有Web項目都基於貧血模型開發?

原因1:大部分情況下,我們開發的系統業務可能都比較簡單,簡單到就是基於 SQL 的 CRUD 操作,所以,我們根本不需要動腦子精心設計充血模型,貧血模型就足以應付這種簡單業務的開發工作。除此之外,因為業務比較簡單,即便我們使用充血模型,那模型本身包含的業務邏輯也並不會很多,設計出來的領域模型也會比較單薄,跟貧血模型差不多,沒有太大意義。

原因2:充血模型的設計要比貧血模型更加有難度。因為充血模型是一種面向對象的編程風格。我們從一開始就要設計好針對數據要暴露哪些操作,定義哪些業務邏輯。而不是像貧血模型那樣,我們只需要定義數據,之后有什么功能開發需求,我們就在 Service 層定義什么操作,不需要事先做太多設計。

原因3:思維已固化,轉型有成本。基於貧血模型的傳統開發模式經歷了這么多年,已經深得人心、習以為常。你隨便問一個旁邊的大齡同事,基本上他過往參與的所有 Web 項目應該都是基於這個開發模式的,而且也沒有出過啥大問題。如果轉向用充血模型、領域驅動設計,那勢必有一定的學習成本、轉型成本。很多人在沒有遇到開發痛點的情況下,是不願意做這件事情的。

什么項目應該考慮使用基於充血模型的 DDD 開發模式?

復雜業務系統、非業務系統、框架、工具等。

越復雜的系統,對代碼的復用性、易維護性要求就越高,我們就越應該花更多的時間和精力在前期設計上。而基於充血模型的 DDD 開發模式,正好需要我們前期做大量的業務調研、領域模型設計,所以它更加適合這種復雜系統的開發。

對於業務不復雜的系統開發來說,基於貧血模型的傳統開發模式簡單夠用,基於充血模型的 DDD 開發模式有點大材小用,無法發揮作用。相反,對於業務復雜的系統開發來說,基於充血模型的 DDD 開發模式,因為前期需要在設計上投入更多時間和精力,來提高代碼的復用性和可維護性,所以相比基於貧血模型的開發模式,更加有優勢。

引用:
“工作中遇到非crud的需求我就會想盡一切辦法讓他通用,基本需求分析和需求設計的時間占用百分之五十,開發和重構到自認為最優占用百分之五十。。。(略)總之我認為如果有機會遇到非crud的需求,一定要好好珍惜,好好把握,把他打造成屬於自己的產品,這樣會讓自己下意識的去想盡一切辦法把他做到最優,親兒子一樣的待遇,再也不會無腦cv,連變量名可能都要認真的重構一兩遍”

HOW DDD

思考模式:面向過程 → 面向對象

面向過程:一個功能,由上至下,先做什么 、后做什么,如何一步一步地順序執行一系列操作,最后完成整個任務。

面向對象:一個功能,由下至上,先將任務拆解成一個個小模塊(就是類,領域模型),設計類之間 的交互,最后按照流程將 類組裝起來,完成整個任務。

需要涉及經驗和技巧:如何封裝合適的數據和方法到一個類中,如何涉及類之間的關系和交互等等。

面向對象分析(Object Oriented Analysis - OOA):需求分析

面向對象設計(Object Oriented Design - OOD):產出系統和類設計,包含屬性、方法、類之間如何交互

  • 划分職責進而識別出有哪些類
    • 方式一:基於需求中出現的名詞篩選
    • 方式二:基於需求中的功能點歸類
  • 定義類及其屬性和方法;
    • 名詞作屬性,動詞作方法
  • 定義類與類之間的交互關系;
    • 繼承
    • 實現
    • 組合
    • 依賴
  • 將類組裝起來並提供執行入口;

每個人的設計結果都可能不太一樣,需要反復迭代、重構、打破重寫,這個過程也是軟件開發的本質。上面只是指導思想不用照搬,熟練了基本就憑感覺。

Service類的職責是什么?

  • 與Repository 交流。使domain與數據層、開發框架(spring、mybatis)解耦,保證domain的復用性。
  • 跨領域模型的業務聚合功能。保證domain之間的獨立性,也是解耦。如果業務的聚合變得復雜,我們還可以考慮復雜的聚合功能是否可以獨立成一個領域模型。
  • 一些非功能性及與三方系統交互的工作。比如冪等、事務、發郵件、發消息、記錄日志、調用其他系統的 RPC 接口等,都可以放到 Service 類中。

Controller 層和 Repository 層是否有必要也進行充血領域建模?
沒有必要。
Controller 層主要負責接口的暴露,Repository 層主要負責與數據庫打交道,這兩層包含的業務邏輯並不多,前面我們也提到了,如果業務邏輯比較簡單,就沒必要做充血建模,即便設計成充血模型,類也非常單薄,看起來也很奇怪。
就拿 Repository 的 Entity 來說,即便它被設計成貧血模型,違反面向對象編程的封裝特性,有被任意代碼修改數據的風險,但 Entity 的生命周期是有限的。一般來講,我們把它傳遞到 Service 層之后,就會轉化成 BO 或者 Domain 來繼續后面的業務邏輯。Entity 的生命周期到此就結束了,所以也並不會被到處任意修改。
我們再來說說 Controller 層的 VO。實際上 VO 是一種 DTO(Data Transfer Object,數據傳輸對象)。它主要是作為接口的數據傳輸承載體,將數據發送給其他系統。從功能上來講,它理應不包含業務邏輯、只包含數據。所以,我們將它設計成貧血模型也是比較合理的。

示例

示例1 - 虛擬錢包(業務)

/**
  充血
**/

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);
  }
}



/* 貧血*/

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) { ...} //轉賬
}


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


/**  Domain領域模型(充血模型) **/
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) {
    //...跟基於貧血模型的傳統開發模式的代碼一樣...
  }
}

示例2 - 接口鑒權(非業務)

需求:“為了保證接口調用的安全性,我們希望設計實現一個接口調用鑒權功能,只有經過認證之后的系統才能調用我們的接口,沒有認證過的系統調用我們的接口會被拒絕。我希望由你來負責這個任務的開發,爭取盡快上線。”

定義類(領域模型)

需求分析

第一輪:appid+password,明文傳輸。問題:明文容易被截獲,不安全。

第二輪:url+appid+password生成token(sign),傳url+appid+token。問題:每個url的token固定,容易被截獲后偽裝請求,即重放攻擊。

第三輪:url+appid+password+timestamp生成token(sign),傳url+appid+token+timestamp,超過限定時間的請求被視為無效請求。問題:appid、用戶的密碼存哪兒?

第四輪:針對 AppID 和密碼的存取,使用接口進行解耦,保證系統有足夠的靈活性和擴展性,能夠在我們切換存儲方式的時候,盡可能地減少代碼的改動。

確定需求:

  • 調用方進行接口請求的時候,將 URL、AppID、密碼、時間戳拼接在一起,通過加密算法生成 token,並且將 token、AppID、時間戳拼接在 URL 中,一並發送到微服務端。
  • 微服務端在接收到調用方的接口請求之后,從請求中拆解出 token、AppID、時間戳。
  • 微服務端首先檢查傳遞過來的時間戳跟當前時間,是否在 token 失效時間窗口內。如果已經超過失效時間,那就算接口調用鑒權失敗,拒絕接口調用請求。
  • 如果 token 驗證沒有過期失效,微服務端再從自己的存儲中,取出 AppID 對應的密碼,通過同樣的 token 生成算法,生成另外一個 token,與調用方傳遞過來的 token 進行匹配;如果一致,則鑒權成功,允許接口調用,否則就拒絕接口調用。

功能點列表:

  1. 把 URL、AppID、密碼、時間戳拼接為一個字符串;
  2. 對字符串通過加密算法加密生成 token;
  3. 將 token、AppID、時間戳拼接到 URL 中,形成新的 URL;
  4. 解析 URL,得到 token、AppID、時間戳等信息;
  5. 從存儲中取出 AppID 和對應的密碼;
  6. 根據時間戳判斷 token 是否過期失效;
  7. 驗證兩個 token 是否匹配;

從上面的功能列表中,我們發現,1、2、6、7 都是跟 token 有關,負責 token 的生成、驗證;3、4 都是在處理 URL,負責 URL 的拼接、解析;5 是操作 AppID 和密碼,負責從存儲中讀取 AppID 和密碼。

所以,我們可以粗略地得到三個核心的類:AuthToken、Url、CredentialStorage。

  • AuthToken 負責實現 1、2、6、7 這四個操作;
  • Url 負責 3、4 兩個操作;
  • CredentialStorage 負責 5 這個操作。

定義類的屬性和方法

AuthToken

  • 把 URL、AppID、密碼、時間戳拼接為一個字符串;
  • 對字符串通過加密算法加密生成 token;
  • 根據時間戳判斷 token 是否過期失效;
  • 驗證兩個 token 是否匹配。

Url

  • 將 token、AppID、時間戳拼接到 URL 中,形成新的 URL;
  • 解析 URL,得到 token、AppID、時間戳等信息。

CredentialStorage

  • 從存儲中取出 AppID 和對應的密碼。

定義類與類之間的交互關系

MysqlCredentialStorage 實現 CredentialStorage接口

將類組裝起來並提供執行入口

定義ApiAuthenticator 接口,提供給網關過濾器調用

/*
	(鑒權 - 充血模型)ApiAuthenticator.java
**/
package com.heytea.manager.payment.utils;

import sun.plugin2.main.server.AppletID;

import java.util.Map;

public interface ApiAuthenticator {
    void auth(String url);

    void auth(ApiRequest apiRequest);
}

class AuthToken {
    private static final long DEFAULT_EXPIRED_TIME_INTERVAL = 1 * 60 * 1000;
    private String token;
    private long createTime;
    private long expriredTimeInterval = DEFAULT_EXPIRED_TIME_INTERVAL;

    public AuthToken(String token, long createTime) {
        this.token = token;
        this.createTime = createTime;
    }

    public AuthToken(String token, long createTime, long expriredTimeInterval) {
        this.token = token;
        this.createTime = createTime;
        this.expriredTimeInterval = expriredTimeInterval;
    }

    public static AuthToken generate(String baseUtl, long createTime, Map<String, String> params) {
        return null;
    }

    public String getToken() {
        return null;
    }

    public boolean isExpired() {
        return false;
    }

    public boolean match(AuthToken authToken) {
        return false;
    }
}

@Getter
class ApiRequest {
    private String baseUrl;
    private String token;
    private String appId;
    private long timestamp;

    public ApiRequest(String baseUrl, String token, String appId, long timestamp) {
        this.baseUrl = baseUrl;
        this.token = token;
        this.appId = appId;
        this.timestamp = timestamp;
    }

    public static ApiRequest buildFromUrl(String url) {
        return null;
    }

}

public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {
    private CredentialStorage credentialStorage;

    public DefaultApiAuthenticator() {
        this.credentialStorage = new MysqlCredentialStorage();
    }

    public DefaultApiAuthenticator(CredentialStorage credentialStorage) {
        this.credentialStorage = credentialStorage;
    }

    @Override
    public void auth(String url) {
        ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
        auth(apiRequest);
    }

    @Override
    public void auth(ApiRequest apiRequest) {
        String appId = apiRequest.getAppId();
        String token = apiRequest.getToken();
        long timestamp = apiRequest.getTimestamp();
        String originalUrl = apiRequest.getBaselUrl();

        AuthToken clientAuthToken = new AuthToken(token, timestamp);
        if (clientAuthToken.isExpired()) {
            throw new RuntimeException("Token is expired.");
        }

        String password = credentialStorage.getPasswordByAppId(appId);
        AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp);
        if (!serverAuthToken.match(clientAuthToken)) {
            throw new RuntimeException("Token verfication failed.");
        }
    }
}

參考:極客時間《設計模式之美》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM