簡介: 應用架構,指軟體系統中固定不變的程式碼結構、設計模式、規範和元件間的通訊方式。在應用開發中架構之所以是最重要的第一步,因為一個好的架構能讓系統安全、穩定、快速迭代。但是今天我們在做業務研發時,更多會關注一些巨集觀的架構,而忽略了應用內部的架構設計,希望能通過案例分析和重構,推演出一套高質量的DDD架構。
作者|殷浩
出品|阿里巴巴新零售淘系技術部
架構這個詞源於英文裡的“Architecture“,源頭是土木工程裡的“建築”和“結構”,而架構裡的”架“同時又包含了”架子“(scaffolding)的含義,意指能快速搭建起來的固定結構。而今天的應用架構,意指軟體系統中固定不變的程式碼結構、設計模式、規範和元件間的通訊方式。在應用開發中架構之所以是最重要的第一步,因為一個好的架構能讓系統安全、穩定、快速迭代。在一個團隊內通過規定一個固定的架構設計,可以讓團隊內能力參差不齊的同學們都能有一個統一的開發規範,降低溝通成本,提升效率和程式碼質量。
在做架構設計時,一個好的架構應該需要實現以下幾個目標:
- 獨立於框架簡介**: 應用架構,指軟體系統中固定不變的程式碼結構、設計模式、規範和元件間的通訊方式。在應用開發中架構之所以是最重要的第一步,因為一個好的架構能讓系統安全、穩定、快速迭代。但是今天我們在做業務研發時,更多會關注一些巨集觀的架構,而忽略了應用內部的架構設計,希望能通過案例分析和重構,推演出一套高質量的DDD架構。
架構不應該依賴某個外部的庫或框架,不應該被框架的結構所束縛。 - 獨立於UI:前臺展示的樣式可能會隨時發生變化(今天可能是網頁、明天可能變成console、後天是獨立app),但是底層架構不應該隨之而變化。
- 獨立於底層資料來源:無論今天你用MySQL、Oracle還是MongoDB、CouchDB,甚至使用檔案系統,軟體架構不應該因為不同的底層資料儲存方式而產生巨大改變。
- 獨立於外部依賴:無論外部依賴如何變更、升級,業務的核心邏輯不應該隨之而大幅變化。
- 可測試:無論外部依賴了什麼資料庫、硬體、UI或者服務,業務的邏輯應該都能夠快速被驗證正確性。
這就好像是建築中的樓宇,一個好的樓宇,無論內部承載了什麼人、有什麼樣的活動、還是外部有什麼風雨,一棟樓都應該屹立不倒,而且可以確保它不會倒。但是今天我們在做業務研發時,更多的會去關注一些巨集觀的架構,比如SOA架構、微服務架構,而忽略了應用內部的架構設計,很容易導致程式碼邏輯混亂,很難維護,容易產生bug而且很難發現。今天,我希望能夠通過案例的分析和重構,來推演出一套高質量的DDD架構。
1、案例分析
我們先看一個簡單的案例需求如下:
使用者可以通過銀行網頁轉賬給另一個賬號,支援跨幣種轉賬。
同時因為監管和對賬需求,需要記錄本次轉賬活動。
拿到這個需求之後,一個開發可能會經歷一些技術選型,最終可能拆解需求如下:
1、從MySql資料庫中找到轉出和轉入的賬戶,選擇用 MyBatis 的 mapper 實現 DAO;2、從 Yahoo(或其他渠道)提供的匯率服務獲取轉賬的匯率資訊(底層是 http 開放介面);
3、計算需要轉出的金額,確保賬戶有足夠餘額,並且沒超出每日轉賬上限;
4、實現轉入和轉出操作,扣除手續費,儲存資料庫;
5、傳送 Kafka 審計訊息,以便審計和對賬用;
而一個簡單的程式碼實現如下:
public class TransferController {
private TransferService transferService;
public Result<Boolean> transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
return transferService.transfer(userId, targetAccountNumber, amount, "CNY");
}
}
public class TransferServiceImpl implements TransferService {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1\. 從資料庫讀取資料,忽略所有校驗邏輯如賬號是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);
// 2\. 業務引數校驗
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}
// 3\. 獲取外部資料,並且包含一定的業務邏輯
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
// 4\. 業務引數校驗
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}
if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}
// 5\. 計算新值,並且更新欄位
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);
// 6\. 更新到資料庫
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);
// 7\. 傳送審計訊息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);
return Result.success(true);
}
}
我們可以看到,一段業務程式碼裡經常包含了引數校驗、資料讀取儲存、業務計算、呼叫外部服務、傳送訊息等多種邏輯。在這個案例裡雖然是寫在了同一個方法裡,在真實程式碼中經常會被拆分成多個子方法,但實際效果是一樣的,而在我們日常的工作中,絕大部分程式碼都或多或少的接近於此類結構。在Martin Fowler的 P of EAA書中,這種很常見的程式碼樣式被叫做Transaction Script(事務指令碼)。雖然這種類似於指令碼的寫法在功能上沒有什麼問題,但是長久來看,他有以下幾個很大的問題:可維護性差、可擴充套件性差、可測試性差。
問題1-可維護效能差
一個應用最大的成本一般都不是來自於開發階段,而是應用整個生命週期的總維護成本,所以程式碼的可維護性代表了最終成本。
**可維護性 = 當依賴變化時,有多少程式碼需要隨之改變
**
參考以上的案例程式碼,事務指令碼類的程式碼很難維護因為以下幾點:
- 資料結構的不穩定性:AccountDO類是一個純資料結構,對映了資料庫中的一個表。這裡的問題是資料庫的表結構和設計是應用的外部依賴,長遠來看都有可能會改變,比如資料庫要做Sharding,或者換一個表設計,或者改變欄位名。
- 依賴庫的升級:AccountMapper依賴MyBatis的實現,如果MyBatis未來升級版本,可能會造成用法的不同(可以參考iBatis升級到基於註解的MyBatis的遷移成本)。同樣的,如果未來換一個ORM體系,遷移成本也是巨大的。
- 第三方服務依賴的不確定性:第三方服務,比如Yahoo的匯率服務未來很有可能會有變化:輕則API簽名變化,重則服務不可用需要尋找其他可替代的服務。在這些情況下改造和遷移成本都是巨大的。同時,外部依賴的兜底、限流、熔斷等方案都需要隨之改變。
- 第三方服務API的介面變化:YahooForexService.getExchangeRate返回的結果是小數點還是百分比?入參是(source, target)還是(target, source)?誰能保證未來介面不會改變?如果改變了,核心的金額計算邏輯必須跟著改,否則會造成資損。
- 中介軟體更換:今天我們用Kafka發訊息,明天如果要上阿里雲用RocketMQ該怎麼辦?後天如果訊息的序列化方式從String改為Binary該怎麼辦?如果需要訊息分片該怎麼改?
我們發現案例裡的程式碼對於任何外部依賴的改變都會有比較大的影響。如果你的應用裡有大量的此類程式碼,你每一天的時間基本上會被各種庫升級、依賴服務升級、中介軟體升級、jar包衝突佔滿,最終這個應用變成了一個不敢升級、不敢部署、不敢寫新功能、並且隨時會爆發的炸彈,終有一天會給你帶來驚喜。
問題2-可擴充性差
事務指令碼式程式碼的第二大缺陷是:雖然寫單個用例的程式碼非常高效簡單,但是當用例多起來時,其擴充套件性會變得越來越差。
可擴充套件性 = 做新需求或改邏輯時,需要新增/修改多少程式碼
參考以上的程式碼,如果今天需要增加一個跨行轉賬的能力,你會發現基本上需要重新開發,基本上沒有任何的可複用性:
- 資料來源被固定、資料格式不相容:原有的AccountDO是從本地獲取的,而跨行轉賬的資料可能需要從一個第三方服務獲取,而服務之間資料格式不太可能是相容的,導致從資料校驗、資料讀寫、到異常處理、金額計算等邏輯都要重寫。
- 業務邏輯無法複用:資料格式不相容的問題會導致核心業務邏輯無法複用。每個用例都是特殊邏輯的後果是最終會造成大量的if-else語句,而這種分支多的邏輯會讓分析程式碼非常困難,容易錯過邊界情況,造成bug。
- 邏輯和資料儲存的相互依賴:當業務邏輯增加變得越來越複雜時,新加入的邏輯很有可能需要對資料庫schema或訊息格式做變更。而變更了資料格式後會導致原有的其他邏輯需要一起跟著動。在最極端的場景下,一個新功能的增加會導致所有原有功能的重構,成本巨大。
在事務指令碼式的架構下,一般做第一個需求都非常的快,但是做第N個需求時需要的時間很有可能是呈指數級上升的,絕大部分時間花費在老功能的重構和相容上,最終你的創新速度會跌為0,促使老應用被推翻重構。
問題3-可測試效能差
除了部分工具類、框架類和中介軟體類的程式碼有比較高的測試覆蓋之外,我們在日常工作中很難看到業務程式碼有比較好的測試覆蓋,而絕大部分的上線前的測試屬於人肉的“整合測試”。低測試率導致我們對程式碼質量很難有把控,容易錯過邊界條件,異常case只有線上爆發了才被動發現。而低測試覆蓋率的主要原因是業務程式碼的可測試性比較差。
可測試性 = 執行每個測試用例所花費的時間 * 每個需求所需要增加的測試用例數量
參考以上的一段程式碼,這種程式碼有極低的可測試性:
- 設施搭建困難:當程式碼中強依賴了資料庫、第三方服務、中介軟體等外部依賴之後,想要完整跑通一個測試用例需要確保所有依賴都能跑起來,這個在專案早期是及其困難的。在專案後期也會由於各種系統的不穩定性而導致測試無法通過。
- 執行耗時長:大多數的外部依賴呼叫都是I/O密集型,如跨網路呼叫、磁碟呼叫等,而這種I/O呼叫在測試時需要耗時很久。另一個經常依賴的是笨重的框架如Spring,啟動Spring容器通常需要很久。當一個測試用例需要花超過10秒鐘才能跑通時,絕大部分開發都不會很頻繁的測試。
- 耦合度高:假如一段指令碼中有A、B、C三個子步驟,而每個步驟有N個可能的狀態,當多個子步驟耦合度高時,為了完整覆蓋所有用例,最多需要有N *N *N個測試用例。當耦合的子步驟越多時,需要的測試用例呈指數級增長。
在事務指令碼模式下,當測試用例複雜度遠大於真實程式碼複雜度,當執行測試用例的耗時超出人肉測試時,絕大部分人會選擇不寫完整的測試覆蓋,而這種情況通常就是bug很難被早點發現的原因。
總結分析
我們重新來分析一下為什麼以上的問題會出現?因為以上的程式碼違背了至少以下幾個軟體設計的原則:
- 單一性原則(Single Responsibility Principle):單一性原則要求一個物件/類應該只有一個變更的原因。但是在這個案例裡,程式碼可能會因為任意一個外部依賴或計算邏輯的改變而改變。
- 依賴反轉原則(Dependency Inversion Principle):依賴反轉原則要求在程式碼中依賴抽象,而不是具體的實現。在這個案例裡外部依賴都是具體的實現,比如YahooForexService雖然是一個介面類,但是它對應的是依賴了Yahoo提供的具體服務,所以也算是依賴了實現。同樣的KafkaTemplate、MyBatis的DAO實現都屬於具體實現。
- 開放封閉原則(Open Closed Principle):開放封閉原則指開放擴充套件,但是封閉修改。在這個案例裡的金額計算屬於可能會被修改的程式碼,這個時候該邏輯應該需要被包裝成為不可修改的計算類,新功能通過計算類的擴充實現。
我們需要對程式碼重構才能解決這些問題。
2、重構方案
在重構之前,我們先畫一張流程圖,描述當前程式碼在做的每個步驟:
這是一個傳統的三層分層結構:UI層、業務層、和基礎設施層。上層對於下層有直接的依賴關係,導致耦合度過高。在業務層中對於下層的基礎設施有強依賴,耦合度高。我們需要對這張圖上的每個節點做抽象和整理,來降低對外部依賴的耦合度。
2.1 - 抽象資料儲存層
第一步常見的操作是將Data Access層做抽象,降低系統對資料庫的直接依賴。具體的方法如下:
- 新建Account實體物件:一個實體(Entity)是擁有ID的域物件,除了擁有資料之外,同時擁有行為。Entity和資料庫儲存格式無關,在設計中要以該領域的通用嚴謹語言(Ubiquitous Language)為依據。
- 新建物件儲存介面類AccountRepository:Repository只負責Entity物件的儲存和讀取,而Repository的實現類完成資料庫儲存的細節。通過加入Repository介面,底層的資料庫連線可以通過不同的實現類而替換。
具體的簡單程式碼實現如下:
Account實體類:
@Data
public class Account {
private AccountId id;
private AccountNumber accountNumber;
private UserId userId;
private Money available;
private Money dailyLimit;
public void withdraw(Money money) {
// 轉出
}
public void deposit(Money money) {
// 轉入
}
}
和AccountRepository及MyBatis實現類:
public interface AccountRepository {
Account find(AccountId id);
Account find(AccountNumber accountNumber);
Account find(UserId userId);
Account save(Account account);
}
public class AccountRepositoryImpl implements AccountRepository {
@Autowired
private AccountMapper accountDAO;
@Autowired
private AccountBuilder accountBuilder;
@Override
public Account find(AccountId id) {
AccountDO accountDO = accountDAO.selectById(id.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(AccountNumber accountNumber) {
AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(UserId userId) {
AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account save(Account account) {
AccountDO accountDO = accountBuilder.fromAccount(account);
if (accountDO.getId() == null) {
accountDAO.insert(accountDO);
} else {
accountDAO.update(accountDO);
}
return accountBuilder.toAccount(accountDO);
}
}
Account實體類和AccountDO資料類的對比如下:
- Data Object資料類:AccountDO是單純的和資料庫表的對映關係,每個欄位對應資料庫表的一個column,這種物件叫Data Object。DO只有資料,沒有行為。AccountDO的作用是對資料庫做快速對映,避免直接在程式碼裡寫SQL。無論你用的是MyBatis還是Hibernate這種ORM,從資料庫來的都應該先直接對映到DO上,但是程式碼裡應該完全避免直接操作 DO。
- Entity實體類:Account 是基於領域邏輯的實體類,它的欄位和資料庫儲存不需要有必然的聯絡。Entity包含資料,同時也應該包含行為。在 Account 裡,欄位也不僅僅是String等基礎型別,而應該儘可能用上一講的 Domain Primitive 代替,可以避免大量的校驗程式碼。
DAO 和 Repository 類的對比如下:
- DAO對應的是一個特定的資料庫型別的操作,相當於SQL的封裝。所有操作的物件都是DO類,所有介面都可以根據資料庫實現的不同而改變。比如,insert 和 update 屬於資料庫專屬的操作。
- Repository對應的是Entity物件讀取儲存的抽象,在介面層面做統一,不關注底層實現。比如,通過 save 儲存一個Entity物件,但至於具體是 insert 還是 update 並不關心。Repository的具體實現類通過呼叫DAO來實現各種操作,通過Builder/Factory物件實現AccountDO 到 Account之間的轉化
2.1.1 Repository和Entity
- 通過Account物件,避免了其他業務邏輯程式碼和資料庫的直接耦合,避免了當資料庫欄位變化時,大量業務邏輯也跟著變的問題。
- 通過Repository,改變業務程式碼的思維方式,讓業務邏輯不再面向資料庫程式設計,而是面向領域模型程式設計。
- Account屬於一個完整的記憶體中物件,可以比較容易的做完整的測試覆蓋,包含其行為。
- Repository作為一個介面類,可以比較容易的實現Mock或Stub,可以很容易測試。
- AccountRepositoryImpl實現類,由於其職責被單一出來,只需要關注Account到AccountDO的對映關係和Repository方法到DAO方法之間的對映關係,相對於來說更容易測試。
2.2 - 抽象第三方服務
類似對於資料庫的抽象,所有第三方服務也需要通過抽象解決第三方服務不可控,入參出參強耦合的問題。在這個例子裡我們抽象出 ExchangeRateService 的服務,和一個ExchangeRate的Domain Primitive類:
public interface ExchangeRateService {
ExchangeRate getExchangeRate(Currency source, Currency target);
}
public class ExchangeRateServiceImpl implements ExchangeRateService {
@Autowired
private YahooForexService yahooForexService;
@Override
public ExchangeRate getExchangeRate(Currency source, Currency target) {
if (source.equals(target)) {
return new ExchangeRate(BigDecimal.ONE, source, target);
}
BigDecimal forex = yahooForexService.getExchangeRate(source.getValue(), target.getValue());
return new ExchangeRate(forex, source, target);
}
2.2.1 防腐層(ACL)
這種常見的設計模式叫做Anti-Corruption Layer(防腐層或ACL)。很多時候我們的系統會去依賴其他的系統,而被依賴的系統可能包含不合理的資料結構、API、協議或技術實現,如果對外部系統強依賴,會導致我們的系統被”腐蝕“。這個時候,通過在系統間加入一個防腐層,能夠有效的隔離外部依賴和內部邏輯,無論外部如何變更,內部程式碼可以儘可能的保持不變。
ACL 不僅僅只是多了一層呼叫,在實際開發中ACL能夠提供更多強大的功能:
- 介面卡:很多時候外部依賴的資料、介面和協議並不符合內部規範,通過介面卡模式,可以將資料轉化邏輯封裝到ACL內部,降低對業務程式碼的侵入。在這個案例裡,我們通過封裝了ExchangeRate和Currency物件,轉化了對方的入參和出參,讓入參出參更符合我們的標準。
- 快取:對於頻繁呼叫且資料變更不頻繁的外部依賴,通過在ACL裡嵌入快取邏輯,能夠有效的降低對於外部依賴的請求壓力。同時,很多時候快取邏輯是寫在業務程式碼裡的,通過將快取邏輯嵌入ACL,能夠降低業務程式碼的複雜度。
- 兜底:如果外部依賴的穩定性較差,一個能夠有效提升我們系統穩定性的策略是通過ACL起到兜底的作用,比如當外部依賴出問題後,返回最近一次成功的快取或業務兜底資料。這種兜底邏輯一般都比較複雜,如果散落在核心業務程式碼中會很難維護,通過集中在ACL中,更加容易被測試和修改。
- 易於測試:類似於之前的Repository,ACL的介面類能夠很容易的實現Mock或Stub,以便於單元測試。
- 功能開關:有些時候我們希望能在某些場景下開放或關閉某個介面的功能,或者讓某個介面返回一個特定的值,我們可以在ACL配置功能開關來實現,而不會對真實業務程式碼造成影響。同時,使用功能開關也能讓我們容易的實現Monkey測試,而不需要真正物理性的關閉外部依賴。
2.3 - 抽象中介軟體
類似於2.2的第三方服務的抽象,對各種中介軟體的抽象的目的是讓業務程式碼不再依賴中介軟體的實現邏輯。因為中介軟體通常需要有通用型,中介軟體的介面通常是String或Byte[] 型別的,導致序列化/反序列化邏輯通常和業務邏輯混雜在一起,造成膠水程式碼。通過中介軟體的ACL抽象,減少重複膠水程式碼。
在這個案例裡,我們通過封裝一個抽象的AuditMessageProducer和AuditMessage DP物件,實現對底層kafka實現的隔離:
@Value
@AllArgsConstructor
public class AuditMessage {
private UserId userId;
private AccountNumber source;
private AccountNumber target;
private Money money;
private Date date;
public String serialize() {
return userId + "," + source + "," + target + "," + money + "," + date;
}
public static AuditMessage deserialize(String value) {
// todo
return null;
}
}
public interface AuditMessageProducer {
SendResult send(AuditMessage message);
}
public class AuditMessageProducerImpl implements AuditMessageProducer {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Override
public SendResult send(AuditMessage message) {
String messageBody = message.serialize();
kafkaTemplate.send(TOPIC_AUDIT_LOG, messageBody);
return SendResult.success();
}
}
具體的分析和2.2類似,在此略過。
2.4 - 封裝業務邏輯
在這個案例裡,有很多業務邏輯是跟外部依賴的程式碼混合的,包括金額計算、賬戶餘額的校驗、轉賬限制、金額增減等。這種邏輯混淆導致了核心計算邏輯無法被有效的測試和複用。在這裡,我們的解法是通過Entity、Domain Primitive和Domain Service封裝所有的業務邏輯:
2.4.1 - 用Domain Primitive封裝跟實體無關的無狀態計算邏輯
在這個案例裡使用ExchangeRate來封裝匯率計算邏輯:
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
變為:
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
2.4.2 - 用Entity封裝單物件的有狀態的行為,包括業務校驗
用Account實體類封裝所有Account的行為,包括業務校驗如下:
@Data
public class Account {
private AccountId id;
private AccountNumber accountNumber;
private UserId userId;
private Money available;
private Money dailyLimit;
public Currency getCurrency() {
return this.available.getCurrency();
}
// 轉入
public void deposit(Money money) {
if (!this.getCurrency().equals(money.getCurrency())) {
throw new InvalidCurrencyException();
}
this.available = this.available.add(money);
}
// 轉出
public void withdraw(Money money) {
if (this.available.compareTo(money) < 0) {
throw new InsufficientFundsException();
}
if (this.dailyLimit.compareTo(money) < 0) {
throw new DailyLimitExceededException();
}
this.available = this.available.subtract(money);
}
}
原有的業務程式碼則可以簡化為:
sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);
2.4.3 - 用Domain Service封裝多物件邏輯
在這個案例裡,我們發現這兩個賬號的轉出和轉入實際上是一體的,也就是說這種行為應該被封裝到一個物件中去。特別是考慮到未來這個邏輯可能會產生變化:比如增加一個扣手續費的邏輯。這個時候在原有的TransferService中做並不合適,在任何一個Entity或者Domain Primitive裡也不合適,需要有一個新的類去包含跨域物件的行為。這種物件叫做Domain Service。
我們建立一個AccountTransferService的類:
public interface AccountTransferService {
void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate);
}
public class AccountTransferServiceImpl implements AccountTransferService {
private ExchangeRateService exchangeRateService;
@Override
public void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate) {
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);
}
}
而原始程式碼則簡化為一行:
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
2.5 - 重構後結果分析
這個案例重構後的程式碼如下:
public class TransferServiceImplNew implements TransferService {
private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 引數校驗
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));
// 讀資料
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
// 業務邏輯
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
// 儲存資料
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);
// 傳送審計訊息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);
return Result.success(true);
}
}
可以看出來,經過重構後的程式碼有以下幾個特徵:
- 業務邏輯清晰,資料儲存和業務邏輯完全分隔。
- Entity、Domain Primitive、Domain Service都是獨立的物件,沒有任何外部依賴,但是卻包含了所有核心業務邏輯,可以單獨完整測試。
- 原有的TransferService不再包括任何計算邏輯,僅僅作為元件編排,所有邏輯均delegate到其他元件。這種僅包含Orchestration(編排)的服務叫做Application Service(應用服務)。
我們可以根據新的結構重新畫一張圖:
然後通過重新編排後該圖變為:
我們可以發現,通過對外部依賴的抽象和內部邏輯的封裝重構,應用整體的依賴關係變了:
- 最底層不再是資料庫,而是Entity、Domain Primitive和Domain Service。這些物件不依賴任何外部服務和框架,而是純記憶體中的資料和操作。這些物件我們打包為Domain Layer(領域層)。領域層沒有任何外部依賴關係。
- 再其次的是負責元件編排的Application Service,但是這些服務僅僅依賴了一些抽象出來的ACL類和Repository類,而其具體實現類是通過依賴注入注進來的。Application Service、Repository、ACL等我們統稱為Application Layer(應用層)。應用層 依賴 領域層,但不依賴具體實現。
- 最後是ACL,Repository等的具體實現,這些實現通常依賴外部具體的技術實現和框架,所以統稱為Infrastructure Layer(基礎設施層)。Web框架裡的物件如Controller之類的通常也屬於基礎設施層。
如果今天能夠重新寫這段程式碼,考慮到最終的依賴關係,我們可能先寫Domain層的業務邏輯,然後再寫Application層的元件編排,最後才寫每個外部依賴的具體實現。這種架構思路和程式碼組織結構就叫做Domain-Driven Design(領域驅動設計,或DDD)。所以DDD不是一個特殊的架構設計,而是所有Transction Script程式碼經過合理重構後一定會抵達的終點。
3、DDD的六邊形架構
在我們傳統的程式碼裡,我們一般都很注重每個外部依賴的實現細節和規範,但是今天我們需要敢於拋棄掉原有的理念,重新審視程式碼結構。在上面重構的程式碼裡,如果拋棄掉所有Repository、ACL、Producer等的具體實現細節,我們會發現每一個對外部的抽象類其實就是輸入或輸出,類似於計算機系統中的I/O節點。這個觀點在CQRS架構中也同樣適用,將所有介面分為Command(輸入)和Query(輸出)兩種。除了I/O之外其他的內部邏輯,就是應用業務的核心邏輯。基於這個基礎,Alistair Cockburn在2005年提出了Hexagonal Architecture(六邊形架構),又被稱之為Ports and Adapters(埠和介面卡架構)。
在這張圖中:
- I/O的具體實現在模型的最外層
- 每個I/O的介面卡在灰色地帶
- 每個Hex的邊是一個埠
- Hex的中央是應用的核心領域模型
在Hex中,架構的組織關係第一次變成了一個二維的內外關係,而不是傳統一維的上下關係。同時在Hex架構中我們第一次發現UI層、DB層、和各種中介軟體層實際上是沒有本質上區別的,都只是資料的輸入和輸出,而不是在傳統架構中的最上層和最下層。
除了2005年的Hex架構,2008年 Jeffery Palermo的Onion Architecture(洋蔥架構)和2017年 Robert Martin的Clean Architecture(乾淨架構),都是極為類似的思想。除了命名不一樣、切入點不一樣之外,其他的整體架構都是基於一個二維的內外關係。這也說明了基於DDD的架構最終的形態都是類似的。Herberto Graca有一個很全面的圖包含了絕大部分現實中的埠類,值得借鑑。
3.1 - 程式碼組織結構
為了有效的組織程式碼結構,避免下層程式碼依賴到上層實現的情況,在Java中我們可以通過POM Module和POM依賴來處理相互的關係。通過Spring/SpringBoot的容器來解決執行時動態注入具體實現的依賴的問題。一個簡單的依賴關係圖如下:
3.1.1 - Types 模組
Types模組是儲存可以對外暴露的Domain Primitives的地方。Domain Primitives因為是無狀態的邏輯,可以對外暴露,所以經常被包含在對外的API介面中,需要單獨成為模組。Types模組不依賴任何類庫,純 POJO 。
3.1.2 - Domain 模組
Domain 模組是核心業務邏輯的集中地,包含有狀態的Entity、領域服務Domain Service、以及各種外部依賴的介面類(如Repository、ACL、中介軟體等。Domain模組僅依賴Types模組,也是純 POJO 。
3.1.3 - Application模組
Application模組主要包含Application Service和一些相關的類。Application模組依賴Domain模組。還是不依賴任何框架,純POJO。
3.1.4 - Infrastructure模組
Infrastructure模組包含了Persistence、Messaging、External等模組。比如:Persistence模組包含資料庫DAO的實現,包含Data Object、ORM Mapper、Entity到DO的轉化類等。Persistence模組要依賴具體的ORM類庫,比如MyBatis。如果需要用Spring-Mybatis提供的註解方案,則需要依賴Spring。
3.1.5 - Web模組
Web模組包含Controller等相關程式碼。如果用SpringMVC則需要依賴Spring。
3.1.6 - Start模組
Start模組是SpringBoot的啟動類。
3.2 - 測試
- Types,Domain模組都屬於無外部依賴的純POJO,基本上都可以100%的被單元測試覆蓋。
- Application模組的程式碼依賴外部抽象類,需要通過測試框架去Mock所有外部依賴,但仍然可以100%被單元測試。
- Infrastructure的每個模組的程式碼相對獨立,介面數量比較少,相對比較容易寫單測。但是由於依賴了外部I/O,速度上不可能很快,但好在模組的變動不會很頻繁,屬於一勞永逸。
- Web模組有兩種測試方法:通過Spring的MockMVC測試,或者通過HttpClient呼叫介面測試。但是在測試時最好把Controller依賴的服務類都Mock掉。一般來說當你把Controller的邏輯都後置到Application Service中時,Controller的邏輯變得極為簡單,很容易100%覆蓋。
- Start模組:通常應用的整合測試寫在start裡。當其他模組的單元測試都能100%覆蓋後,整合測試用來驗證整體鏈路的真實性。
3.3 - 程式碼的演進/變化速度
在傳統架構中,程式碼從上到下的變化速度基本上是一致的,改個需求需要從介面、到業務邏輯、到資料庫全量變更,而第三方變更可能會導致整個程式碼的重寫。但是在DDD中不同模組的程式碼的演進速度是不一樣的:
- Domain層屬於核心業務邏輯,屬於經常被修改的地方。比如:原來不需要扣手續費,現在需要了之類的。通過Entity能夠解決基於單個物件的邏輯變更,通過Domain Service解決多個物件間的業務邏輯變更。
- Application層屬於Use Case(業務用例)。業務用例一般都是描述比較大方向的需求,介面相對穩定,特別是對外的介面一般不會頻繁變更。新增業務用例可以通過新增Application Service或者新增介面實現功能的擴充套件。
- Infrastructure層屬於最低頻變更的。一般這個層的模組只有在外部依賴變更了之後才會跟著升級,而外部依賴的變更頻率一般遠低於業務邏輯的變更頻率。
所以在DDD架構中,能明顯看出越外層的程式碼越穩定,越內層的程式碼演進越快,真正體現了領域“驅動”的核心思想。
4、總結
DDD不是一個什麼特殊的架構,而是任何傳統程式碼經過合理的重構之後最終一定會抵達的終點。DDD的架構能夠有效的解決傳統架構中的問題:
- 高可維護性:當外部依賴變更時,內部程式碼只用變更跟外部對接的模組,其他業務邏輯不變。
- 高可擴充套件性:做新功能時,絕大部分的程式碼都能複用,僅需要增加核心業務邏輯即可。
- 高可測試性:每個拆分出來的模組都符合單一性原則,絕大部分不依賴框架,可以快速的單元測試,做到100%覆蓋。
- 程式碼結構清晰:通過POM module可以解決模組間的依賴關係, 所有外接模組都可以單獨獨立成Jar包被複用。當團隊形成規範後,可以快速的定位到相關程式碼。