做者|殷浩
出品|阿里巴巴新零售淘系技術部數據庫
架構這個詞源於英文裏的「Architecture「,源頭是土木工程裏的「建築」和「結構」,而架構裏的」架「同時又包含了」架子「(scaffolding)的含義,意指能快速搭建起來的固定結構。而今天的應用架構,意指軟件系統中固定不變的代碼結構、設計模式、規範和組件間的通訊方式。在應用開發中架構之因此是最重要的第一步,由於一個好的架構能讓系統安全、穩定、快速迭代。在一個團隊內經過規定一個固定的架構設計,可讓團隊內能力良莠不齊的同窗們都能有一個統一的開發規範,下降溝通成本,提高效率和代碼質量。編程
在作架構設計時,一個好的架構應該須要實現如下幾個目標:設計模式
這就好像是建築中的樓宇,一個好的樓宇,不管內部承載了什麼人、有什麼樣的活動、仍是外部有什麼風雨,一棟樓都應該屹立不倒,並且能夠確保它不會倒。可是今天咱們在作業務研發時,更多的會去關注一些宏觀的架構,好比SOA架構、微服務架構,而忽略了應用內部的架構設計,很容易致使代碼邏輯混亂,很難維護,容易產生bug並且很難發現。今天,我但願可以經過案例的分析和重構,來推演出一套高質量的DDD架構。跨域
咱們先看一個簡單的案例需求以下:緩存
用戶能夠經過銀行網頁轉帳給另外一個帳號,支持跨幣種轉帳。安全
同時由於監管和對帳需求,須要記錄本次轉帳活動。網絡
拿到這個需求以後,一個開發可能會經歷一些技術選型,最終可能拆解需求以下:session
一、從MySql數據庫中找到轉出和轉入的帳戶,選擇用 MyBatis 的 mapper 實現 DAO;二、從 Yahoo(或其餘渠道)提供的匯率服務獲取轉帳的匯率信息(底層是 http 開放接口);數據結構
三、計算須要轉出的金額,確保帳戶有足夠餘額,而且沒超出每日轉帳上限;架構
四、實現轉入和轉出操做,扣除手續費,保存數據庫;
五、發送 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-可維護性能差
一個應用最大的成本通常都不是來自於開發階段,而是應用整個生命週期的總維護成本,因此代碼的可維護性表明了最終成本。
**可維護性 = 當依賴變化時,有多少代碼須要隨之改變
**
參考以上的案例代碼,事務腳本類的代碼很難維護由於如下幾點:
咱們發現案例裏的代碼對於任何外部依賴的改變都會有比較大的影響。若是你的應用裏有大量的此類代碼,你每一天的時間基本上會被各類庫升級、依賴服務升級、中間件升級、jar包衝突佔滿,最終這個應用變成了一個不敢升級、不敢部署、不敢寫新功能、而且隨時會爆發的炸彈,終有一天會給你帶來驚喜。
問題2-可拓展性差
事務腳本式代碼的第二大缺陷是:雖然寫單個用例的代碼很是高效簡單,可是當用例多起來時,其擴展性會變得愈來愈差。
可擴展性 = 作新需求或改邏輯時,須要新增/修改多少代碼
參考以上的代碼,若是今天須要增長一個跨行轉帳的能力,你會發現基本上須要從新開發,基本上沒有任何的可複用性:
在事務腳本式的架構下,通常作第一個需求都很是的快,可是作第N個需求時須要的時間頗有多是呈指數級上升的,絕大部分時間花費在老功能的重構和兼容上,最終你的創新速度會跌爲0,促使老應用被推翻重構。
問題3-可測試性能差
除了部分工具類、框架類和中間件類的代碼有比較高的測試覆蓋以外,咱們在平常工做中很難看到業務代碼有比較好的測試覆蓋,而絕大部分的上線前的測試屬於人肉的「集成測試」。低測試率致使咱們對代碼質量很難有把控,容易錯過邊界條件,異常case只有線上爆發了才被動發現。而低測試覆蓋率的主要緣由是業務代碼的可測試性比較差。
可測試性 = 運行每一個測試用例所花費的時間 * 每一個需求所須要增長的測試用例數量
參考以上的一段代碼,這種代碼有極低的可測試性:
在事務腳本模式下,當測試用例複雜度遠大於真實代碼複雜度,當運行測試用例的耗時超出人肉測試時,絕大部分人會選擇不寫完整的測試覆蓋,而這種狀況一般就是bug很難被早點發現的緣由。
總結分析
咱們從新來分析一下爲何以上的問題會出現?由於以上的代碼違背了至少如下幾個軟件設計的原則:
咱們須要對代碼重構才能解決這些問題。
在重構以前,咱們先畫一張流程圖,描述當前代碼在作的每一個步驟:
這是一個傳統的三層分層結構:UI層、業務層、和基礎設施層。上層對於下層有直接的依賴關係,致使耦合度太高。在業務層中對於下層的基礎設施有強依賴,耦合度高。咱們須要對這張圖上的每一個節點作抽象和整理,來下降對外部依賴的耦合度。
2.1 - 抽象數據存儲層
第一步常見的操做是將Data Access層作抽象,下降系統對數據庫的直接依賴。具體的方法以下:
具體的簡單代碼實現以下:
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數據類的對好比下:
DAO 和 Repository 類的對好比下:
2.1.1 Repository和Entity
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可以提供更多強大的功能:
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); } }
能夠看出來,通過重構後的代碼有如下幾個特徵:
咱們能夠根據新的結構從新畫一張圖:
而後經過從新編排後該圖變爲:
咱們能夠發現,經過對外部依賴的抽象和內部邏輯的封裝重構,應用總體的依賴關係變了:
若是今天可以從新寫這段代碼,考慮到最終的依賴關係,咱們可能先寫Domain層的業務邏輯,而後再寫Application層的組件編排,最後才寫每一個外部依賴的具體實現。這種架構思路和代碼組織結構就叫作Domain-Driven Design(領域驅動設計,或DDD)。因此DDD不是一個特殊的架構設計,而是全部Transction Script代碼通過合理重構後必定會抵達的終點。
在咱們傳統的代碼裏,咱們通常都很注重每一個外部依賴的實現細節和規範,可是今天咱們須要勇於拋棄掉原有的理念,從新審視代碼結構。在上面重構的代碼裏,若是拋棄掉全部Repository、ACL、Producer等的具體實現細節,咱們會發現每個對外部的抽象類其實就是輸入或輸出,相似於計算機系統中的I/O節點。這個觀點在CQRS架構中也一樣適用,將全部接口分爲Command(輸入)和Query(輸出)兩種。除了I/O以外其餘的內部邏輯,就是應用業務的核心邏輯。基於這個基礎,Alistair Cockburn在2005年提出了Hexagonal Architecture(六邊形架構),又被稱之爲Ports and Adapters(端口和適配器架構)。
在這張圖中:
在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 - 測試
3.3 - 代碼的演進/變化速度
在傳統架構中,代碼從上到下的變化速度基本上是一致的,改個需求須要從接口、到業務邏輯、到數據庫全量變動,而第三方變動可能會致使整個代碼的重寫。可是在DDD中不一樣模塊的代碼的演進速度是不同的:
因此在DDD架構中,能明顯看出越外層的代碼越穩定,越內層的代碼演進越快,真正體現了領域「驅動」的核心思想。
DDD不是一個什麼特殊的架構,而是任何傳統代碼通過合理的重構以後最終必定會抵達的終點。DDD的架構可以有效的解決傳統架構中的問題:
本文爲雲棲社區原創內容,未經容許不得轉載。