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 進行匹配;如果一致,則鑒權成功,允許接口調用,否則就拒絕接口調用。
功能點列表:
- 把 URL、AppID、密碼、時間戳拼接為一個字符串;
- 對字符串通過加密算法加密生成 token;
- 將 token、AppID、時間戳拼接到 URL 中,形成新的 URL;
- 解析 URL,得到 token、AppID、時間戳等信息;
- 從存儲中取出 AppID 和對應的密碼;
- 根據時間戳判斷 token 是否過期失效;
- 驗證兩個 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.");
}
}
}
參考:極客時間《設計模式之美》