阿里技術專家詳解DDD系列 第二彈 - 應用架構

image.png

做者|殷浩
出品|阿里巴巴新零售淘系技術部數據庫

架構這個詞源於英文裏的「Architecture「,源頭是土木工程裏的「建築」和「結構」,而架構裏的」架「同時又包含了」架子「(scaffolding)的含義,意指能快速搭建起來的固定結構。而今天的應用架構,意指軟件系統中固定不變的代碼結構、設計模式、規範和組件間的通訊方式。在應用開發中架構之因此是最重要的第一步,由於一個好的架構能讓系統安全、穩定、快速迭代。在一個團隊內經過規定一個固定的架構設計,可讓團隊內能力良莠不齊的同窗們都能有一個統一的開發規範,下降溝通成本,提高效率和代碼質量。編程

在作架構設計時,一個好的架構應該須要實現如下幾個目標:設計模式

  • 獨立於框架:架構不該該依賴某個外部的庫或框架,不該該被框架的結構所束縛。
  • 獨立於UI:前臺展現的樣式可能會隨時發生變化(今天多是網頁、明天可能變成console、後天是獨立app),可是底層架構不該該隨之而變化。
  • 獨立於底層數據源:不管今天你用MySQL、Oracle仍是MongoDB、CouchDB,甚至使用文件系統,軟件架構不該該由於不一樣的底層數據儲存方式而產生巨大改變。
  • 獨立於外部依賴:不管外部依賴如何變動、升級,業務的核心邏輯不該該隨之而大幅變化。
  • 可測試:不管外部依賴了什麼數據庫、硬件、UI或者服務,業務的邏輯應該都可以快速被驗證正確性。

這就好像是建築中的樓宇,一個好的樓宇,不管內部承載了什麼人、有什麼樣的活動、仍是外部有什麼風雨,一棟樓都應該屹立不倒,並且能夠確保它不會倒。可是今天咱們在作業務研發時,更多的會去關注一些宏觀的架構,好比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-可維護性能差

一個應用最大的成本通常都不是來自於開發階段,而是應用整個生命週期的總維護成本,因此代碼的可維護性表明了最終成本。

**可維護性 = 當依賴變化時,有多少代碼須要隨之改變
**
參考以上的案例代碼,事務腳本類的代碼很難維護由於如下幾點:

  • 數據結構的不穩定性: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個測試用例。當耦合的子步驟越多時,須要的測試用例呈指數級增加。

在事務腳本模式下,當測試用例複雜度遠大於真實代碼複雜度,當運行測試用例的耗時超出人肉測試時,絕大部分人會選擇不寫完整的測試覆蓋,而這種狀況一般就是bug很難被早點發現的緣由。

總結分析

咱們從新來分析一下爲何以上的問題會出現?由於以上的代碼違背了至少如下幾個軟件設計的原則:

  • 單一性原則(Single Responsibility Principle):單一性原則要求一個對象/類應該只有一個變動的緣由。可是在這個案例裏,代碼可能會由於任意一個外部依賴或計算邏輯的改變而改變。
  • 依賴反轉原則(Dependency Inversion Principle):依賴反轉原則要求在代碼中依賴抽象,而不是具體的實現。在這個案例裏外部依賴都是具體的實現,好比YahooForexService雖然是一個接口類,可是它對應的是依賴了Yahoo提供的具體服務,因此也算是依賴了實現。一樣的KafkaTemplate、MyBatis的DAO實現都屬於具體實現。
  • 開放封閉原則(Open Closed Principle):開放封閉原則指開放擴展,可是封閉修改。在這個案例裏的金額計算屬於可能會被修改的代碼,這個時候該邏輯應該須要被包裝成爲不可修改的計算類,新功能經過計算類的拓展實現。

咱們須要對代碼重構才能解決這些問題。

二、重構方案

在重構以前,咱們先畫一張流程圖,描述當前代碼在作的每一個步驟:

image.png

這是一個傳統的三層分層結構: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方法之間的映射關係,相對於來講更容易測試。

image.png

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、協議或技術實現,若是對外部系統強依賴,會致使咱們的系統被」腐蝕「。這個時候,經過在系統間加入一個防腐層,可以有效的隔離外部依賴和內部邏輯,不管外部如何變動,內部代碼能夠儘量的保持不變。

image.png

ACL 不只僅只是多了一層調用,在實際開發中ACL可以提供更多強大的功能:

  • 適配器:不少時候外部依賴的數據、接口和協議並不符合內部規範,經過適配器模式,能夠將數據轉化邏輯封裝到ACL內部,下降對業務代碼的侵入。在這個案例裏,咱們經過封裝了ExchangeRate和Currency對象,轉化了對方的入參和出參,讓入參出參更符合咱們的標準。
  • 緩存:對於頻繁調用且數據變動不頻繁的外部依賴,經過在ACL裏嵌入緩存邏輯,可以有效的下降對於外部依賴的請求壓力。同時,不少時候緩存邏輯是寫在業務代碼裏的,經過將緩存邏輯嵌入ACL,可以下降業務代碼的複雜度。
  • 兜底:若是外部依賴的穩定性較差,一個可以有效提高咱們系統穩定性的策略是經過ACL起到兜底的做用,好比當外部依賴出問題後,返回最近一次成功的緩存或業務兜底數據。這種兜底邏輯通常都比較複雜,若是散落在覈心業務代碼中會很難維護,經過集中在ACL中,更加容易被測試和修改。
  • 易於測試:相似於以前的Repository,ACL的接口類可以很容易的實現Mock或Stub,以便於單元測試。
  • 功能開關:有些時候咱們但願能在某些場景下開放或關閉某個接口的功能,或者讓某個接口返回一個特定的值,咱們能夠在ACL配置功能開關來實現,而不會對真實業務代碼形成影響。同時,使用功能開關也能讓咱們容易的實現Monkey測試,而不須要真正物理性的關閉外部依賴。

image.png

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相似,在此略過。

image.png

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

image.png

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(應用服務)。

咱們能夠根據新的結構從新畫一張圖:

image.png

而後經過從新編排後該圖變爲:

image.png

咱們能夠發現,經過對外部依賴的抽象和內部邏輯的封裝重構,應用總體的依賴關係變了:

  • 最底層再也不是數據庫,而是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代碼通過合理重構後必定會抵達的終點。

三、DDD的六邊形架構

在咱們傳統的代碼裏,咱們通常都很注重每一個外部依賴的實現細節和規範,可是今天咱們須要勇於拋棄掉原有的理念,從新審視代碼結構。在上面重構的代碼裏,若是拋棄掉全部Repository、ACL、Producer等的具體實現細節,咱們會發現每個對外部的抽象類其實就是輸入或輸出,相似於計算機系統中的I/O節點。這個觀點在CQRS架構中也一樣適用,將全部接口分爲Command(輸入)和Query(輸出)兩種。除了I/O以外其餘的內部邏輯,就是應用業務的核心邏輯。基於這個基礎,Alistair Cockburn在2005年提出了Hexagonal Architecture(六邊形架構),又被稱之爲Ports and Adapters(端口和適配器架構)。

image.png

在這張圖中:

  • 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有一個很全面的圖包含了絕大部分現實中的端口類,值得借鑑。

image.png

3.1 - 代碼組織結構

爲了有效的組織代碼結構,避免下層代碼依賴到上層實現的狀況,在Java中咱們能夠經過POM Module和POM依賴來處理相互的關係。經過Spring/SpringBoot的容器來解決運行時動態注入具體實現的依賴的問題。一個簡單的依賴關係圖以下:

image.png
image.png

3.1.1 - Types 模塊

Types模塊是保存能夠對外暴露的Domain Primitives的地方。Domain Primitives由於是無狀態的邏輯,能夠對外暴露,因此常常被包含在對外的API接口中,須要單獨成爲模塊。Types模塊不依賴任何類庫,純 POJO 。

image.png

3.1.2 - Domain 模塊

Domain 模塊是核心業務邏輯的集中地,包含有狀態的Entity、領域服務Domain Service、以及各類外部依賴的接口類(如Repository、ACL、中間件等。Domain模塊僅依賴Types模塊,也是純 POJO 。

image.png

3.1.3 - Application模塊

Application模塊主要包含Application Service和一些相關的類。Application模塊依賴Domain模塊。仍是不依賴任何框架,純POJO。

image.png

3.1.4 - Infrastructure模塊

Infrastructure模塊包含了Persistence、Messaging、External等模塊。好比:Persistence模塊包含數據庫DAO的實現,包含Data Object、ORM Mapper、Entity到DO的轉化類等。Persistence模塊要依賴具體的ORM類庫,好比MyBatis。若是須要用Spring-Mybatis提供的註解方案,則須要依賴Spring。

image.png

3.1.5 - Web模塊

Web模塊包含Controller等相關代碼。若是用SpringMVC則須要依賴Spring。

image.png

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架構中,能明顯看出越外層的代碼越穩定,越內層的代碼演進越快,真正體現了領域「驅動」的核心思想。

四、總結

DDD不是一個什麼特殊的架構,而是任何傳統代碼通過合理的重構以後最終必定會抵達的終點。DDD的架構可以有效的解決傳統架構中的問題:

  • 高可維護性:當外部依賴變動時,內部代碼只用變動跟外部對接的模塊,其餘業務邏輯不變。
  • 高可擴展性:作新功能時,絕大部分的代碼都能複用,僅須要增長核心業務邏輯便可。
  • 高可測試性:每一個拆分出來的模塊都符合單一性原則,絕大部分不依賴框架,能夠快速的單元測試,作到100%覆蓋。
  • 代碼結構清晰:經過POM module能夠解決模塊間的依賴關係, 全部外接模塊均可以單獨獨立成Jar包被複用。當團隊造成規範後,能夠快速的定位到相關代碼。


閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索