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

作者 | 殷浩

出品 | 阿里巴巴新零售淘系技術部

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

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

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

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

可維護性 = 當依賴變化時,有多少代碼需要隨之改變

參考以上的案例代碼,事務腳本類的代碼很難維護因爲以下幾點:

我們發現案例裏的代碼對於任何外部依賴的改變都會有比較大的影響。如果你的應用裏有大量的此類代碼,你每一天的時間基本上會被各種庫升級、依賴服務升級、中間件升級、jar 包衝突佔滿,最終這個應用變成了一個不敢升級、不敢部署、不敢寫新功能、並且隨時會爆發的炸彈,終有一天會給你帶來驚喜。

問題 2 - 可拓展性差

事務腳本式代碼的第二大缺陷是:雖然寫單個用例的代碼非常高效簡單,但是當用例多起來時,其擴展性會變得越來越差。

可擴展性 = 做新需求或改邏輯時,需要新增 / 修改多少代碼

參考以上的代碼,如果今天需要增加一個跨行轉賬的能力,你會發現基本上需要重新開發,基本上沒有任何的可複用性:

在事務腳本式的架構下,一般做第一個需求都非常的快,但是做第 N 個需求時需要的時間很有可能是呈指數級上升的,絕大部分時間花費在老功能的重構和兼容上,最終你的創新速度會跌爲 0,促使老應用被推翻重構。

問題 3 - 可測試性能差

除了部分工具類、框架類和中間件類的代碼有比較高的測試覆蓋之外,我們在日常工作中很難看到業務代碼有比較好的測試覆蓋,而絕大部分的上線前的測試屬於人肉的 “集成測試”。低測試率導致我們對代碼質量很難有把控,容易錯過邊界條件,異常 case 只有線上爆發了才被動發現。而低測試覆蓋率的主要原因是業務代碼的可測試性比較差。

可測試性 = 運行每個測試用例所花費的時間 * 每個需求所需要增加的測試用例數量

參考以上的一段代碼,這種代碼有極低的可測試性:

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

總結分析

我們重新來分析一下爲什麼以上的問題會出現?因爲以上的代碼違背了至少以下幾個軟件設計的原則:

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

2

重構方案

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

這是一個傳統的三層分層結構: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 代碼經過合理重構後一定會抵達的終點。

3

DDD 的六邊形架構

在我們傳統的代碼裏,我們一般都很注重每個外部依賴的實現細節和規範,但是今天我們需要敢於拋棄掉原有的理念,重新審視代碼結構。在上面重構的代碼裏,如果拋棄掉所有 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 架構中,能明顯看出越外層的代碼越穩定,越內層的代碼演進越快,真正體現了領域 “驅動” 的核心思想。

4

總結

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/MU1rqpQ1aA1p7OtXqVVwxQ