天天看點

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構

第二講 - 應用架構

架構這個詞源于英文裡的“Architecture“,源頭是土木工程裡的“建築”和“結構”,而架構裡的”架“同時又包含了”架子“(scaffolding)的含義,意指能快速搭建起來的固定結構。而今天的應用架構,意指軟體系統中固定不變的代碼結構、設計模式、規範群組件間的通信方式。在應用開發中架構之是以是最重要的第一步,因為一個好的架構能讓系統安全、穩定、快速疊代。在一個團隊内通過規定一個固定的架構設計,可以讓團隊内能力參差不齊的同學們都能有一個統一的開發規範,降低溝通成本,提升效率和代碼品質。

在做架構設計時,一個好的架構應該需要實作以下幾個目标:

  1. 獨立于架構:架構不應該依賴某個外部的庫或架構,不應該被架構的結構所束縛。
  2. 獨立于UI:前台展示的樣式可能會随時發生變化(今天可能是網頁、明天可能變成console、後天是獨立app),但是底層架構不應該随之而變化。
  3. 獨立于底層資料源:無論今天你用MySQL、Oracle還是MongoDB、CouchDB,甚至使用檔案系統,軟體架構不應該因為不同的底層資料儲存方式而産生巨大改變。
  4. 獨立于外部依賴:無論外部依賴如何變更、更新,業務的核心邏輯不應該随之而大幅變化。
  5. 可測試:無論外部依賴了什麼資料庫、硬體、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. 重構方案

在重構之前,我們先畫一張流程圖,描述目前代碼在做的每個步驟:

// TODO:Graph

我們需要對這張圖上的每個節點做抽象和整理,來降低對外部依賴的耦合度

2.1 - 抽象資料存儲層

第一步常見的操作是将Data Access層做抽象,降低系統對資料庫的直接依賴。具體的方法如下:

  • 建立Account實體對象:一個實體(Entity)是擁有ID的域對象,除了擁有資料之外,同時擁有行為。Entity和資料庫儲存格式無關,在設計中要以該領域的通用嚴謹語言(Ubiquitous Language)為依據。
  • 建立對象儲存接口類AccountRepository:Repository隻負責Entity對象的存儲和讀取,而Repository的實作類完成資料庫存儲的細節。通過加入Repository接口,底層的資料庫連接配接可以通過不同的實作類而替換。

【Entity、Repository和Entity Builder/Factory的設計規範在後續文章中詳解】

具體的簡單代碼實作如下:

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類:

【ExchangeRate的實作參考上一篇文章】

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、協定或技術實作,如果對外部系統強依賴,會導緻我們的系統被”腐蝕“。這個時候,通過在系統間加入一個防腐層,能夠有效的隔離外部依賴和内部邏輯,無論外部如何變更,内部代碼可以盡可能的保持不變。

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構
https://docs.microsoft.com/en-us/azure/architecture/patterns/_images/anti-corruption-layer.png

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(端口和擴充卡架構)。

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構
https://www.hitsubscribe.com/wp-content/uploads/2018/08/HexagonalArchitecture.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有一個很全面的圖包含了絕大部分現實中的端口類,值得借鑒。

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構
https://docs.google.com/drawings/d/e/2PACX-1vQ5ps72uaZcEJzwnJbPhzUfEeBbN6CJ04j7hl2i3K2HHatNcsoyG2tgX2vnrN5xxDKLp5Jm5bzzmZdv/pub?w=960&h=657

3.1 - 代碼組織結構

為了有效的組織代碼結構,避免下層代碼依賴到上層實作的情況,在Java中我們可以通過POM Module和POM依賴來處理互相的關系。通過Spring/SpringBoot的容器來解決運作時動态注入具體實作的依賴的問題。一個簡單的依賴關系圖如下:

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構
殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構

3.1.1 - Types子產品

Types子產品是儲存可以對外暴露的Domain Primitives的地方。Domain Primitives因為是無狀态的邏輯,可以對外暴露,是以經常被包含在對外的API接口中,需要單獨成為子產品。Types子產品不依賴任何類庫,純POJO。

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構

3.1.2 - Domain子產品

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

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構

3.1.3 - Application子產品

Application子產品主要包含Application Service和一些相關的類。Application子產品依賴Domain子產品。還是不依賴任何架構,純POJO。

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構

3.1.4 - Infrastructure子產品

Infrastructure子產品包含了Persistence、Messaging、External等子產品。比如:Persistence子產品包含資料庫DAO的實作,包含Data Object、ORM Mapper、Entity到DO的轉化類等。Persistence子產品要依賴具體的ORM類庫,比如MyBatis。如果需要用Spring-Mybatis提供的注解方案,則需要依賴Spring。

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構

3.1.5 - Web子產品

Web子產品包含Controller等相關代碼。如果用SpringMVC則需要依賴Spring。

殷浩詳解DDD系列 第二講 - 應用架構第二講 - 應用架構

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%覆寫後,內建測試用來驗證整體鍊路的真實性。

【DDD的測試規範在後續文章中詳解】

3.3 - 代碼的演進/變化速度

在傳統架構中,代碼從上到下的變化速度基本上是一緻的,改個需求需要從接口、到業務邏輯、到資料庫全量變更,而第三方變更可能會導緻整個代碼的重寫。但是在DDD中不同子產品的代碼的演進速度是不一樣的:

  • Domain層屬于核心業務邏輯,屬于經常被修改的地方。比如:原來不需要扣手續費,現在需要了之類的。通過Entity能夠解決基于單個對象的邏輯變更,通過Domain Service解決多個對象間的業務邏輯變更。
  • Application層屬于Use Case(業務用例)。業務用例一般都是描述比較大方向的需求,接口相對穩定,特别是對外的接口一般不會頻繁變更。添加業務用例可以通過新增Application Service或者新增接口實作功能的擴充。
  • Infrastructure層屬于最低頻變更的。一般這個層的子產品隻有在外部依賴變更了之後才會跟着更新,而外部依賴的變更頻率一般遠低于業務邏輯的變更頻率。

是以在DDD架構中,能明顯看出越外層的代碼越穩定,越内層的代碼演進越快,真正展現了領域“驅動”的核心思想。

總結

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

  • 高可維護性:當外部依賴變更時,内部代碼隻用變更跟外部對接的子產品,其他業務邏輯不變。
  • 高可擴充性:做新功能時,絕大部分的代碼都能複用,僅需要增加核心業務邏輯即可。
  • 高可測試性:每個拆分出來的子產品都符合單一性原則,絕大部分不依賴架構,可以快速的單元測試,做到100%覆寫。
  • 代碼結構清晰:通過POM module可以解決子產品間的依賴關系,

    所有外接子產品都可以單獨獨立成Jar包被複用。當團隊形成規範後,可以快速的定位到相關代碼。

在後續的文章中會陸續的講解每個DDD子產品的開發規範。