【轉】DDD-應用架構

簡介: 應用架構,指軟件系統中固定不變的代碼結構、設計模式、規範和組件間的通信方式。在應用開發中架構之所以是最重要的第一步,因為一個好的架構能讓系統安全、穩定、快速迭代。但是今天我們在做業務研發時,更多會關注一些宏觀的架構,而忽略了應用內部的架構設計,希望能通過案例分析和重構,推演出一套高質量的DDD架構。

image.png

作者|殷浩
出品|阿里巴巴新零售淘系技術部

架構這個詞源於英文里的「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、重構方案

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

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代碼經過合理重構後一定會抵達的終點。

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

4、總結

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

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

轉自://developer.aliyun.com/article/719251

Tags: