DDD系列-應(yīng)用架構(gòu)

架構(gòu)這個(gè)詞源于英文里的“Architecture“,源頭是土木工程里的“建筑”和“結(jié)構(gòu)”,而架構(gòu)里的”架“同時(shí)又包含了”架子“(scaffolding)的含義,意指能快速搭建起來的固定結(jié)構(gòu)。而今天的應(yīng)用架構(gòu),意指軟件系統(tǒng)中固定不變的代碼結(jié)構(gòu)、設(shè)計(jì)模式、規(guī)范和組件間的通信方式。在應(yīng)用開發(fā)中架構(gòu)之所以是最重要的第一步,因?yàn)橐粋€(gè)好的架構(gòu)能讓系統(tǒng)安全、穩(wěn)定、快速迭代。在一個(gè)團(tuán)隊(duì)內(nèi)通過規(guī)定一個(gè)固定的架構(gòu)設(shè)計(jì),可以讓團(tuán)隊(duì)內(nèi)能力參差不齊的同學(xué)們都能有一個(gè)統(tǒng)一的開發(fā)規(guī)范,降低溝通成本,提升效率和代碼質(zhì)量。

在做架構(gòu)設(shè)計(jì)時(shí),一個(gè)好的架構(gòu)應(yīng)該需要實(shí)現(xiàn)以下幾個(gè)目標(biāo):

  • 獨(dú)立于框架:架構(gòu)不應(yīng)該依賴某個(gè)外部的庫(kù)或框架,不應(yīng)該被框架的結(jié)構(gòu)所束縛。
  • 獨(dú)立于UI:前臺(tái)展示的樣式可能會(huì)隨時(shí)發(fā)生變化(今天可能是網(wǎng)頁(yè)、明天可能變成console、后天是獨(dú)立app),但是底層架構(gòu)不應(yīng)該隨之而變化。
  • 獨(dú)立于底層數(shù)據(jù)源:無(wú)論今天你用MySQL、Oracle還是MongoDB、CouchDB,甚至使用文件系統(tǒng),軟件架構(gòu)不應(yīng)該因?yàn)椴煌牡讓訑?shù)據(jù)儲(chǔ)存方式而產(chǎn)生巨大改變。
  • 獨(dú)立于外部依賴:無(wú)論外部依賴如何變更、升級(jí),業(yè)務(wù)的核心邏輯不應(yīng)該隨之而大幅變化。
  • 可測(cè)試:無(wú)論外部依賴了什么數(shù)據(jù)庫(kù)、硬件、UI或者服務(wù),業(yè)務(wù)的邏輯應(yīng)該都能夠快速被驗(yàn)證正確性。

這就好像是建筑中的樓宇,一個(gè)好的樓宇,無(wú)論內(nèi)部承載了什么人、有什么樣的活動(dòng)、還是外部有什么風(fēng)雨,一棟樓都應(yīng)該屹立不倒,而且可以確保它不會(huì)倒。但是今天我們?cè)谧鰳I(yè)務(wù)研發(fā)時(shí),更多的會(huì)去關(guān)注一些宏觀的架構(gòu),比如SOA架構(gòu)、微服務(wù)架構(gòu),而忽略了應(yīng)用內(nèi)部的架構(gòu)設(shè)計(jì),很容易導(dǎo)致代碼邏輯混亂,很難維護(hù),容易產(chǎn)生bug而且很難發(fā)現(xiàn)。今天,我希望能夠通過案例的分析和重構(gòu),來推演出一套高質(zhì)量的DDD架構(gòu)。

1、案例分析

我們先看一個(gè)簡(jiǎn)單的案例需求如下:

用戶可以通過銀行網(wǎng)頁(yè)轉(zhuǎn)賬給另一個(gè)賬號(hào),支持跨幣種轉(zhuǎn)賬。

同時(shí)因?yàn)楸O(jiān)管和對(duì)賬需求,需要記錄本次轉(zhuǎn)賬活動(dòng)。

拿到這個(gè)需求之后,一個(gè)開發(fā)可能會(huì)經(jīng)歷一些技術(shù)選型,最終可能拆解需求如下:

1、從MySql數(shù)據(jù)庫(kù)中找到轉(zhuǎn)出和轉(zhuǎn)入的賬戶,選擇用 MyBatis 的 mapper 實(shí)現(xiàn) DAO;2、從 Yahoo(或其他渠道)提供的匯率服務(wù)獲取轉(zhuǎn)賬的匯率信息(底層是 http 開放接口);

3、計(jì)算需要轉(zhuǎn)出的金額,確保賬戶有足夠余額,并且沒超出每日轉(zhuǎn)賬上限;

4、實(shí)現(xiàn)轉(zhuǎn)入和轉(zhuǎn)出操作,扣除手續(xù)費(fèi),保存數(shù)據(jù)庫(kù);

5、發(fā)送 Kafka 審計(jì)消息,以便審計(jì)和對(duì)賬用;

而一個(gè)簡(jiǎn)單的代碼實(shí)現(xiàn)如下:

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\. 從數(shù)據(jù)庫(kù)讀取數(shù)據(jù),忽略所有校驗(yàn)邏輯如賬號(hào)是否存在等
        AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
        AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);

        // 2\. 業(yè)務(wù)參數(shù)校驗(yàn)
        if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
            throw new InvalidCurrencyException();
        }

        // 3\. 獲取外部數(shù)據(jù),并且包含一定的業(yè)務(wù)邏輯
        // 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\. 業(yè)務(wù)參數(shù)校驗(yàn)
        if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
            throw new InsufficientFundsException();
        }

        if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
            throw new DailyLimitExceededException();
        }

        // 5\. 計(jì)算新值,并且更新字段
        BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
        BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
        sourceAccountDO.setAvailable(newSource);
        targetAccountDO.setAvailable(newTarget);

        // 6\. 更新到數(shù)據(jù)庫(kù)
        accountDAO.update(sourceAccountDO);
        accountDAO.update(targetAccountDO);

        // 7\. 發(fā)送審計(jì)消息
        String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
        kafkaTemplate.send(TOPIC_AUDIT_LOG, message);

        return Result.success(true);
    }

}

我們可以看到,一段業(yè)務(wù)代碼里經(jīng)常包含了參數(shù)校驗(yàn)、數(shù)據(jù)讀取存儲(chǔ)、業(yè)務(wù)計(jì)算、調(diào)用外部服務(wù)、發(fā)送消息等多種邏輯。在這個(gè)案例里雖然是寫在了同一個(gè)方法里,在真實(shí)代碼中經(jīng)常會(huì)被拆分成多個(gè)子方法,但實(shí)際效果是一樣的,而在我們?nèi)粘5墓ぷ髦?,絕大部分代碼都或多或少的接近于此類結(jié)構(gòu)。在Martin Fowler的 P of EAA書中,這種很常見的代碼樣式被叫做Transaction Script(事務(wù)腳本)。雖然這種類似于腳本的寫法在功能上沒有什么問題,但是長(zhǎng)久來看,他有以下幾個(gè)很大的問題:可維護(hù)性差、可擴(kuò)展性差、可測(cè)試性差。

問題1-可維護(hù)性能差

一個(gè)應(yīng)用最大的成本一般都不是來自于開發(fā)階段,而是應(yīng)用整個(gè)生命周期的總維護(hù)成本,所以代碼的可維護(hù)性代表了最終成本。

可維護(hù)性 = 當(dāng)依賴變化時(shí),有多少代碼需要隨之改變
參考以上的案例代碼,事務(wù)腳本類的代碼很難維護(hù)因?yàn)橐韵聨c(diǎn):

  • 數(shù)據(jù)結(jié)構(gòu)的不穩(wěn)定性:AccountDO類是一個(gè)純數(shù)據(jù)結(jié)構(gòu),映射了數(shù)據(jù)庫(kù)中的一個(gè)表。這里的問題是數(shù)據(jù)庫(kù)的表結(jié)構(gòu)和設(shè)計(jì)是應(yīng)用的外部依賴,長(zhǎng)遠(yuǎn)來看都有可能會(huì)改變,比如數(shù)據(jù)庫(kù)要做Sharding,或者換一個(gè)表設(shè)計(jì),或者改變字段名。
  • 依賴庫(kù)的升級(jí):AccountMapper依賴MyBatis的實(shí)現(xiàn),如果MyBatis未來升級(jí)版本,可能會(huì)造成用法的不同(可以參考iBatis升級(jí)到基于注解的MyBatis的遷移成本)。同樣的,如果未來?yè)Q一個(gè)ORM體系,遷移成本也是巨大的。
  • 第三方服務(wù)依賴的不確定性:第三方服務(wù),比如Yahoo的匯率服務(wù)未來很有可能會(huì)有變化:輕則API簽名變化,重則服務(wù)不可用需要尋找其他可替代的服務(wù)。在這些情況下改造和遷移成本都是巨大的。同時(shí),外部依賴的兜底、限流、熔斷等方案都需要隨之改變。
  • 第三方服務(wù)API的接口變化:YahooForexService.getExchangeRate返回的結(jié)果是小數(shù)點(diǎn)還是百分比?入?yún)⑹牵╯ource, target)還是(target, source)?誰(shuí)能保證未來接口不會(huì)改變?如果改變了,核心的金額計(jì)算邏輯必須跟著改,否則會(huì)造成資損。
  • 中間件更換:今天我們用Kafka發(fā)消息,明天如果要上阿里云用RocketMQ該怎么辦?后天如果消息的序列化方式從String改為Binary該怎么辦?如果需要消息分片該怎么改?

我們發(fā)現(xiàn)案例里的代碼對(duì)于任何外部依賴的改變都會(huì)有比較大的影響。如果你的應(yīng)用里有大量的此類代碼,你每一天的時(shí)間基本上會(huì)被各種庫(kù)升級(jí)、依賴服務(wù)升級(jí)、中間件升級(jí)、jar包沖突占滿,最終這個(gè)應(yīng)用變成了一個(gè)不敢升級(jí)、不敢部署、不敢寫新功能、并且隨時(shí)會(huì)爆發(fā)的炸彈,終有一天會(huì)給你帶來驚喜。

問題2-可拓展性差

事務(wù)腳本式代碼的第二大缺陷是:雖然寫單個(gè)用例的代碼非常高效簡(jiǎn)單,但是當(dāng)用例多起來時(shí),其擴(kuò)展性會(huì)變得越來越差。

可擴(kuò)展性 = 做新需求或改邏輯時(shí),需要新增/修改多少代碼

參考以上的代碼,如果今天需要增加一個(gè)跨行轉(zhuǎn)賬的能力,你會(huì)發(fā)現(xiàn)基本上需要重新開發(fā),基本上沒有任何的可復(fù)用性:

  • 數(shù)據(jù)來源被固定、數(shù)據(jù)格式不兼容:原有的AccountDO是從本地獲取的,而跨行轉(zhuǎn)賬的數(shù)據(jù)可能需要從一個(gè)第三方服務(wù)獲取,而服務(wù)之間數(shù)據(jù)格式不太可能是兼容的,導(dǎo)致從數(shù)據(jù)校驗(yàn)、數(shù)據(jù)讀寫、到異常處理、金額計(jì)算等邏輯都要重寫。
  • 業(yè)務(wù)邏輯無(wú)法復(fù)用:數(shù)據(jù)格式不兼容的問題會(huì)導(dǎo)致核心業(yè)務(wù)邏輯無(wú)法復(fù)用。每個(gè)用例都是特殊邏輯的后果是最終會(huì)造成大量的if-else語(yǔ)句,而這種分支多的邏輯會(huì)讓分析代碼非常困難,容易錯(cuò)過邊界情況,造成bug。
  • 邏輯和數(shù)據(jù)存儲(chǔ)的相互依賴:當(dāng)業(yè)務(wù)邏輯增加變得越來越復(fù)雜時(shí),新加入的邏輯很有可能需要對(duì)數(shù)據(jù)庫(kù)schema或消息格式做變更。而變更了數(shù)據(jù)格式后會(huì)導(dǎo)致原有的其他邏輯需要一起跟著動(dòng)。在最極端的場(chǎng)景下,一個(gè)新功能的增加會(huì)導(dǎo)致所有原有功能的重構(gòu),成本巨大。

在事務(wù)腳本式的架構(gòu)下,一般做第一個(gè)需求都非常的快,但是做第N個(gè)需求時(shí)需要的時(shí)間很有可能是呈指數(shù)級(jí)上升的,絕大部分時(shí)間花費(fèi)在老功能的重構(gòu)和兼容上,最終你的創(chuàng)新速度會(huì)跌為0,促使老應(yīng)用被推翻重構(gòu)。

問題3-可測(cè)試性能差

除了部分工具類、框架類和中間件類的代碼有比較高的測(cè)試覆蓋之外,我們?cè)谌粘9ぷ髦泻茈y看到業(yè)務(wù)代碼有比較好的測(cè)試覆蓋,而絕大部分的上線前的測(cè)試屬于人肉的“集成測(cè)試”。低測(cè)試率導(dǎo)致我們對(duì)代碼質(zhì)量很難有把控,容易錯(cuò)過邊界條件,異常case只有線上爆發(fā)了才被動(dòng)發(fā)現(xiàn)。而低測(cè)試覆蓋率的主要原因是業(yè)務(wù)代碼的可測(cè)試性比較差。

可測(cè)試性 = 運(yùn)行每個(gè)測(cè)試用例所花費(fèi)的時(shí)間 * 每個(gè)需求所需要增加的測(cè)試用例數(shù)量

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

  • 設(shè)施搭建困難:當(dāng)代碼中強(qiáng)依賴了數(shù)據(jù)庫(kù)、第三方服務(wù)、中間件等外部依賴之后,想要完整跑通一個(gè)測(cè)試用例需要確保所有依賴都能跑起來,這個(gè)在項(xiàng)目早期是及其困難的。在項(xiàng)目后期也會(huì)由于各種系統(tǒng)的不穩(wěn)定性而導(dǎo)致測(cè)試無(wú)法通過。
  • 運(yùn)行耗時(shí)長(zhǎng):大多數(shù)的外部依賴調(diào)用都是I/O密集型,如跨網(wǎng)絡(luò)調(diào)用、磁盤調(diào)用等,而這種I/O調(diào)用在測(cè)試時(shí)需要耗時(shí)很久。另一個(gè)經(jīng)常依賴的是笨重的框架如Spring,啟動(dòng)Spring容器通常需要很久。當(dāng)一個(gè)測(cè)試用例需要花超過10秒鐘才能跑通時(shí),絕大部分開發(fā)都不會(huì)很頻繁的測(cè)試。
  • 耦合度高:假如一段腳本中有A、B、C三個(gè)子步驟,而每個(gè)步驟有N個(gè)可能的狀態(tài),當(dāng)多個(gè)子步驟耦合度高時(shí),為了完整覆蓋所有用例,最多需要有N *N *N個(gè)測(cè)試用例。當(dāng)耦合的子步驟越多時(shí),需要的測(cè)試用例呈指數(shù)級(jí)增長(zhǎng)。

在事務(wù)腳本模式下,當(dāng)測(cè)試用例復(fù)雜度遠(yuǎn)大于真實(shí)代碼復(fù)雜度,當(dāng)運(yùn)行測(cè)試用例的耗時(shí)超出人肉測(cè)試時(shí),絕大部分人會(huì)選擇不寫完整的測(cè)試覆蓋,而這種情況通常就是bug很難被早點(diǎn)發(fā)現(xiàn)的原因。

總結(jié)分析

我們重新來分析一下為什么以上的問題會(huì)出現(xiàn)?因?yàn)橐陨系拇a違背了至少以下幾個(gè)軟件設(shè)計(jì)的原則:

  • 單一性原則(Single Responsibility Principle):單一性原則要求一個(gè)對(duì)象/類應(yīng)該只有一個(gè)變更的原因。但是在這個(gè)案例里,代碼可能會(huì)因?yàn)槿我庖粋€(gè)外部依賴或計(jì)算邏輯的改變而改變。
  • 依賴反轉(zhuǎn)原則(Dependency Inversion Principle):依賴反轉(zhuǎn)原則要求在代碼中依賴抽象,而不是具體的實(shí)現(xiàn)。在這個(gè)案例里外部依賴都是具體的實(shí)現(xiàn),比如YahooForexService雖然是一個(gè)接口類,但是它對(duì)應(yīng)的是依賴了Yahoo提供的具體服務(wù),所以也算是依賴了實(shí)現(xiàn)。同樣的KafkaTemplate、MyBatis的DAO實(shí)現(xiàn)都屬于具體實(shí)現(xiàn)。
  • 開放封閉原則(Open Closed Principle):開放封閉原則指開放擴(kuò)展,但是封閉修改。在這個(gè)案例里的金額計(jì)算屬于可能會(huì)被修改的代碼,這個(gè)時(shí)候該邏輯應(yīng)該需要被包裝成為不可修改的計(jì)算類,新功能通過計(jì)算類的拓展實(shí)現(xiàn)。

我們需要對(duì)代碼重構(gòu)才能解決這些問題。

2、重構(gòu)方案

在重構(gòu)之前,我們先畫一張流程圖,描述當(dāng)前代碼在做的每個(gè)步驟:

image

這是一個(gè)傳統(tǒng)的三層分層結(jié)構(gòu):UI層、業(yè)務(wù)層、和基礎(chǔ)設(shè)施層。上層對(duì)于下層有直接的依賴關(guān)系,導(dǎo)致耦合度過高。在業(yè)務(wù)層中對(duì)于下層的基礎(chǔ)設(shè)施有強(qiáng)依賴,耦合度高。我們需要對(duì)這張圖上的每個(gè)節(jié)點(diǎn)做抽象和整理,來降低對(duì)外部依賴的耦合度。

2.1 - 抽象數(shù)據(jù)存儲(chǔ)層

第一步常見的操作是將Data Access層做抽象,降低系統(tǒng)對(duì)數(shù)據(jù)庫(kù)的直接依賴。具體的方法如下:

  • 新建Account實(shí)體對(duì)象:一個(gè)實(shí)體(Entity)是擁有ID的域?qū)ο?,除了擁有?shù)據(jù)之外,同時(shí)擁有行為。Entity和數(shù)據(jù)庫(kù)儲(chǔ)存格式無(wú)關(guān),在設(shè)計(jì)中要以該領(lǐng)域的通用嚴(yán)謹(jǐn)語(yǔ)言(Ubiquitous Language)為依據(jù)。
  • 新建對(duì)象儲(chǔ)存接口類AccountRepository:Repository只負(fù)責(zé)Entity對(duì)象的存儲(chǔ)和讀取,而Repository的實(shí)現(xiàn)類完成數(shù)據(jù)庫(kù)存儲(chǔ)的細(xì)節(jié)。通過加入Repository接口,底層的數(shù)據(jù)庫(kù)連接可以通過不同的實(shí)現(xiàn)類而替換。

具體的簡(jiǎn)單代碼實(shí)現(xiàn)如下:

Account實(shí)體類:

@Data
public class Account {
    private AccountId id;
    private AccountNumber accountNumber;
    private UserId userId;
    private Money available;
    private Money dailyLimit;

    public void withdraw(Money money) {
        // 轉(zhuǎn)出
    }

    public void deposit(Money money) {
        // 轉(zhuǎn)入
    }
}

和AccountRepository及MyBatis實(shí)現(xiàn)類:

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實(shí)體類和AccountDO數(shù)據(jù)類的對(duì)比如下:

  • Data Object數(shù)據(jù)類:AccountDO是單純的和數(shù)據(jù)庫(kù)表的映射關(guān)系,每個(gè)字段對(duì)應(yīng)數(shù)據(jù)庫(kù)表的一個(gè)column,這種對(duì)象叫Data Object。DO只有數(shù)據(jù),沒有行為。AccountDO的作用是對(duì)數(shù)據(jù)庫(kù)做快速映射,避免直接在代碼里寫SQL。無(wú)論你用的是MyBatis還是Hibernate這種ORM,從數(shù)據(jù)庫(kù)來的都應(yīng)該先直接映射到DO上,但是代碼里應(yīng)該完全避免直接操作 DO。
  • Entity實(shí)體類:Account 是基于領(lǐng)域邏輯的實(shí)體類,它的字段和數(shù)據(jù)庫(kù)儲(chǔ)存不需要有必然的聯(lián)系。Entity包含數(shù)據(jù),同時(shí)也應(yīng)該包含行為。在 Account 里,字段也不僅僅是String等基礎(chǔ)類型,而應(yīng)該盡可能用上一講的 Domain Primitive 代替,可以避免大量的校驗(yàn)代碼。

DAO 和 Repository 類的對(duì)比如下:

  • DAO對(duì)應(yīng)的是一個(gè)特定的數(shù)據(jù)庫(kù)類型的操作,相當(dāng)于SQL的封裝。所有操作的對(duì)象都是DO類,所有接口都可以根據(jù)數(shù)據(jù)庫(kù)實(shí)現(xiàn)的不同而改變。比如,insert 和 update 屬于數(shù)據(jù)庫(kù)專屬的操作。
  • Repository對(duì)應(yīng)的是Entity對(duì)象讀取儲(chǔ)存的抽象,在接口層面做統(tǒng)一,不關(guān)注底層實(shí)現(xiàn)。比如,通過 save 保存一個(gè)Entity對(duì)象,但至于具體是 insert 還是 update 并不關(guān)心。Repository的具體實(shí)現(xiàn)類通過調(diào)用DAO來實(shí)現(xiàn)各種操作,通過Builder/Factory對(duì)象實(shí)現(xiàn)AccountDO 到 Account之間的轉(zhuǎn)化

2.1.1 Repository和Entity

  • 通過Account對(duì)象,避免了其他業(yè)務(wù)邏輯代碼和數(shù)據(jù)庫(kù)的直接耦合,避免了當(dāng)數(shù)據(jù)庫(kù)字段變化時(shí),大量業(yè)務(wù)邏輯也跟著變的問題。
  • 通過Repository,改變業(yè)務(wù)代碼的思維方式,讓業(yè)務(wù)邏輯不再面向數(shù)據(jù)庫(kù)編程,而是面向領(lǐng)域模型編程。
  • Account屬于一個(gè)完整的內(nèi)存中對(duì)象,可以比較容易的做完整的測(cè)試覆蓋,包含其行為。
  • Repository作為一個(gè)接口類,可以比較容易的實(shí)現(xiàn)Mock或Stub,可以很容易測(cè)試。
  • AccountRepositoryImpl實(shí)現(xiàn)類,由于其職責(zé)被單一出來,只需要關(guān)注Account到AccountDO的映射關(guān)系和Repository方法到DAO方法之間的映射關(guān)系,相對(duì)于來說更容易測(cè)試。
image

2.2 - 抽象第三方服務(wù)

類似對(duì)于數(shù)據(jù)庫(kù)的抽象,所有第三方服務(wù)也需要通過抽象解決第三方服務(wù)不可控,入?yún)⒊鰠?qiáng)耦合的問題。在這個(gè)例子里我們抽象出 ExchangeRateService 的服務(wù),和一個(gè)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)

這種常見的設(shè)計(jì)模式叫做Anti-Corruption Layer(防腐層或ACL)。很多時(shí)候我們的系統(tǒng)會(huì)去依賴其他的系統(tǒng),而被依賴的系統(tǒng)可能包含不合理的數(shù)據(jù)結(jié)構(gòu)、API、協(xié)議或技術(shù)實(shí)現(xiàn),如果對(duì)外部系統(tǒng)強(qiáng)依賴,會(huì)導(dǎo)致我們的系統(tǒng)被”腐蝕“。這個(gè)時(shí)候,通過在系統(tǒng)間加入一個(gè)防腐層,能夠有效的隔離外部依賴和內(nèi)部邏輯,無(wú)論外部如何變更,內(nèi)部代碼可以盡可能的保持不變。

image

ACL 不僅僅只是多了一層調(diào)用,在實(shí)際開發(fā)中ACL能夠提供更多強(qiáng)大的功能:

  • 適配器:很多時(shí)候外部依賴的數(shù)據(jù)、接口和協(xié)議并不符合內(nèi)部規(guī)范,通過適配器模式,可以將數(shù)據(jù)轉(zhuǎn)化邏輯封裝到ACL內(nèi)部,降低對(duì)業(yè)務(wù)代碼的侵入。在這個(gè)案例里,我們通過封裝了ExchangeRate和Currency對(duì)象,轉(zhuǎn)化了對(duì)方的入?yún)⒑统鰠?,讓入?yún)⒊鰠⒏衔覀兊臉?biāo)準(zhǔn)。
  • 緩存:對(duì)于頻繁調(diào)用且數(shù)據(jù)變更不頻繁的外部依賴,通過在ACL里嵌入緩存邏輯,能夠有效的降低對(duì)于外部依賴的請(qǐng)求壓力。同時(shí),很多時(shí)候緩存邏輯是寫在業(yè)務(wù)代碼里的,通過將緩存邏輯嵌入ACL,能夠降低業(yè)務(wù)代碼的復(fù)雜度。
  • 兜底:如果外部依賴的穩(wěn)定性較差,一個(gè)能夠有效提升我們系統(tǒng)穩(wěn)定性的策略是通過ACL起到兜底的作用,比如當(dāng)外部依賴出問題后,返回最近一次成功的緩存或業(yè)務(wù)兜底數(shù)據(jù)。這種兜底邏輯一般都比較復(fù)雜,如果散落在核心業(yè)務(wù)代碼中會(huì)很難維護(hù),通過集中在ACL中,更加容易被測(cè)試和修改。
  • 易于測(cè)試:類似于之前的Repository,ACL的接口類能夠很容易的實(shí)現(xiàn)Mock或Stub,以便于單元測(cè)試。
  • 功能開關(guān):有些時(shí)候我們希望能在某些場(chǎng)景下開放或關(guān)閉某個(gè)接口的功能,或者讓某個(gè)接口返回一個(gè)特定的值,我們可以在ACL配置功能開關(guān)來實(shí)現(xiàn),而不會(huì)對(duì)真實(shí)業(yè)務(wù)代碼造成影響。同時(shí),使用功能開關(guān)也能讓我們?nèi)菀椎膶?shí)現(xiàn)Monkey測(cè)試,而不需要真正物理性的關(guān)閉外部依賴。
image

2.3 - 抽象中間件

類似于2.2的第三方服務(wù)的抽象,對(duì)各種中間件的抽象的目的是讓業(yè)務(wù)代碼不再依賴中間件的實(shí)現(xiàn)邏輯。因?yàn)橹虚g件通常需要有通用型,中間件的接口通常是String或Byte[] 類型的,導(dǎo)致序列化/反序列化邏輯通常和業(yè)務(wù)邏輯混雜在一起,造成膠水代碼。通過中間件的ACL抽象,減少重復(fù)膠水代碼。

在這個(gè)案例里,我們通過封裝一個(gè)抽象的AuditMessageProducer和AuditMessage DP對(duì)象,實(shí)現(xiàn)對(duì)底層kafka實(shí)現(xiàn)的隔離:

@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

2.4 - 封裝業(yè)務(wù)邏輯

在這個(gè)案例里,有很多業(yè)務(wù)邏輯是跟外部依賴的代碼混合的,包括金額計(jì)算、賬戶余額的校驗(yàn)、轉(zhuǎn)賬限制、金額增減等。這種邏輯混淆導(dǎo)致了核心計(jì)算邏輯無(wú)法被有效的測(cè)試和復(fù)用。在這里,我們的解法是通過Entity、Domain Primitive和Domain Service封裝所有的業(yè)務(wù)邏輯:

2.4.1 - 用Domain Primitive封裝跟實(shí)體無(wú)關(guān)的無(wú)狀態(tài)計(jì)算邏輯

在這個(gè)案例里使用ExchangeRate來封裝匯率計(jì)算邏輯:

BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
    exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

變?yōu)椋?/p>

ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);

2.4.2 - 用Entity封裝單對(duì)象的有狀態(tài)的行為,包括業(yè)務(wù)校驗(yàn)

用Account實(shí)體類封裝所有Account的行為,包括業(yè)務(wù)校驗(yàn)如下:

@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();
    }

    // 轉(zhuǎn)入
    public void deposit(Money money) {
        if (!this.getCurrency().equals(money.getCurrency())) {
            throw new InvalidCurrencyException();
        }
        this.available = this.available.add(money);
    }

    // 轉(zhuǎn)出
    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);
    }
}

原有的業(yè)務(wù)代碼則可以簡(jiǎn)化為:

sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);

2.4.3 - 用Domain Service封裝多對(duì)象邏輯

在這個(gè)案例里,我們發(fā)現(xiàn)這兩個(gè)賬號(hào)的轉(zhuǎn)出和轉(zhuǎn)入實(shí)際上是一體的,也就是說這種行為應(yīng)該被封裝到一個(gè)對(duì)象中去。特別是考慮到未來這個(gè)邏輯可能會(huì)產(chǎn)生變化:比如增加一個(gè)扣手續(xù)費(fèi)的邏輯。這個(gè)時(shí)候在原有的TransferService中做并不合適,在任何一個(gè)Entity或者Domain Primitive里也不合適,需要有一個(gè)新的類去包含跨域?qū)ο蟮男袨椤_@種對(duì)象叫做Domain Service。

我們創(chuàng)建一個(gè)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);
    }
}

而原始代碼則簡(jiǎn)化為一行:

accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

image

2.5 - 重構(gòu)后結(jié)果分析

這個(gè)案例重構(gòu)后的代碼如下:

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) {
        // 參數(shù)校驗(yàn)
        Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));

        // 讀數(shù)據(jù)
        Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
        Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
        ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());

        // 業(yè)務(wù)邏輯
        accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

        // 保存數(shù)據(jù)
        accountRepository.save(sourceAccount);
        accountRepository.save(targetAccount);

        // 發(fā)送審計(jì)消息
        AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
        auditMessageProducer.send(message);

        return Result.success(true);
    }
}

可以看出來,經(jīng)過重構(gòu)后的代碼有以下幾個(gè)特征:

  • 業(yè)務(wù)邏輯清晰,數(shù)據(jù)存儲(chǔ)和業(yè)務(wù)邏輯完全分隔。
  • Entity、Domain Primitive、Domain Service都是獨(dú)立的對(duì)象,沒有任何外部依賴,但是卻包含了所有核心業(yè)務(wù)邏輯,可以單獨(dú)完整測(cè)試。
  • 原有的TransferService不再包括任何計(jì)算邏輯,僅僅作為組件編排,所有邏輯均delegate到其他組件。這種僅包含Orchestration(編排)的服務(wù)叫做Application Service(應(yīng)用服務(wù))。

我們可以根據(jù)新的結(jié)構(gòu)重新畫一張圖:

image

然后通過重新編排后該圖變?yōu)椋?/p>

image

我們可以發(fā)現(xiàn),通過對(duì)外部依賴的抽象和內(nèi)部邏輯的封裝重構(gòu),應(yīng)用整體的依賴關(guān)系變了:

  • 最底層不再是數(shù)據(jù)庫(kù),而是Entity、Domain Primitive和Domain Service。這些對(duì)象不依賴任何外部服務(wù)和框架,而是純內(nèi)存中的數(shù)據(jù)和操作。這些對(duì)象我們打包為Domain Layer(領(lǐng)域?qū)樱?。領(lǐng)域?qū)記]有任何外部依賴關(guān)系。
  • 再其次的是負(fù)責(zé)組件編排的Application Service,但是這些服務(wù)僅僅依賴了一些抽象出來的ACL類和Repository類,而其具體實(shí)現(xiàn)類是通過依賴注入注進(jìn)來的。Application Service、Repository、ACL等我們統(tǒng)稱為Application Layer(應(yīng)用層)。應(yīng)用層 依賴 領(lǐng)域?qū)?,但不依賴具體實(shí)現(xiàn)。
  • 最后是ACL,Repository等的具體實(shí)現(xiàn),這些實(shí)現(xiàn)通常依賴外部具體的技術(shù)實(shí)現(xiàn)和框架,所以統(tǒng)稱為Infrastructure Layer(基礎(chǔ)設(shè)施層)。Web框架里的對(duì)象如Controller之類的通常也屬于基礎(chǔ)設(shè)施層。

如果今天能夠重新寫這段代碼,考慮到最終的依賴關(guān)系,我們可能先寫Domain層的業(yè)務(wù)邏輯,然后再寫Application層的組件編排,最后才寫每個(gè)外部依賴的具體實(shí)現(xiàn)。這種架構(gòu)思路和代碼組織結(jié)構(gòu)就叫做Domain-Driven Design(領(lǐng)域驅(qū)動(dòng)設(shè)計(jì),或DDD)。所以DDD不是一個(gè)特殊的架構(gòu)設(shè)計(jì),而是所有Transction Script代碼經(jīng)過合理重構(gòu)后一定會(huì)抵達(dá)的終點(diǎn)。

3、DDD的六邊形架構(gòu)

在我們傳統(tǒng)的代碼里,我們一般都很注重每個(gè)外部依賴的實(shí)現(xiàn)細(xì)節(jié)和規(guī)范,但是今天我們需要敢于拋棄掉原有的理念,重新審視代碼結(jié)構(gòu)。在上面重構(gòu)的代碼里,如果拋棄掉所有Repository、ACL、Producer等的具體實(shí)現(xiàn)細(xì)節(jié),我們會(huì)發(fā)現(xiàn)每一個(gè)對(duì)外部的抽象類其實(shí)就是輸入或輸出,類似于計(jì)算機(jī)系統(tǒng)中的I/O節(jié)點(diǎn)。這個(gè)觀點(diǎn)在CQRS架構(gòu)中也同樣適用,將所有接口分為Command(輸入)和Query(輸出)兩種。除了I/O之外其他的內(nèi)部邏輯,就是應(yīng)用業(yè)務(wù)的核心邏輯?;谶@個(gè)基礎(chǔ),Alistair Cockburn在2005年提出了Hexagonal Architecture(六邊形架構(gòu)),又被稱之為Ports and Adapters(端口和適配器架構(gòu))。

image

在這張圖中:

  • I/O的具體實(shí)現(xiàn)在模型的最外層
  • 每個(gè)I/O的適配器在灰色地帶
  • 每個(gè)Hex的邊是一個(gè)端口
  • Hex的中央是應(yīng)用的核心領(lǐng)域模型

在Hex中,架構(gòu)的組織關(guān)系第一次變成了一個(gè)二維的內(nèi)外關(guān)系,而不是傳統(tǒng)一維的上下關(guān)系。同時(shí)在Hex架構(gòu)中我們第一次發(fā)現(xiàn)UI層、DB層、和各種中間件層實(shí)際上是沒有本質(zhì)上區(qū)別的,都只是數(shù)據(jù)的輸入和輸出,而不是在傳統(tǒng)架構(gòu)中的最上層和最下層。

除了2005年的Hex架構(gòu),2008年 Jeffery Palermo的Onion Architecture(洋蔥架構(gòu))和2017年 Robert Martin的Clean Architecture(干凈架構(gòu)),都是極為類似的思想。除了命名不一樣、切入點(diǎn)不一樣之外,其他的整體架構(gòu)都是基于一個(gè)二維的內(nèi)外關(guān)系。這也說明了基于DDD的架構(gòu)最終的形態(tài)都是類似的。Herberto Graca有一個(gè)很全面的圖包含了絕大部分現(xiàn)實(shí)中的端口類,值得借鑒。

image

3.1 - 代碼組織結(jié)構(gòu)

為了有效的組織代碼結(jié)構(gòu),避免下層代碼依賴到上層實(shí)現(xiàn)的情況,在Java中我們可以通過POM Module和POM依賴來處理相互的關(guān)系。通過Spring/SpringBoot的容器來解決運(yùn)行時(shí)動(dòng)態(tài)注入具體實(shí)現(xiàn)的依賴的問題。一個(gè)簡(jiǎn)單的依賴關(guān)系圖如下:

image
image

3.1.1 - Types 模塊

Types模塊是保存可以對(duì)外暴露的Domain Primitives的地方。Domain Primitives因?yàn)槭菬o(wú)狀態(tài)的邏輯,可以對(duì)外暴露,所以經(jīng)常被包含在對(duì)外的API接口中,需要單獨(dú)成為模塊。Types模塊不依賴任何類庫(kù),純 POJO 。

image

3.1.2 - Domain 模塊

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

image

3.1.3 - Application模塊

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

image

3.1.4 - Infrastructure模塊

Infrastructure模塊包含了Persistence、Messaging、External等模塊。比如:Persistence模塊包含數(shù)據(jù)庫(kù)DAO的實(shí)現(xiàn),包含Data Object、ORM Mapper、Entity到DO的轉(zhuǎn)化類等。Persistence模塊要依賴具體的ORM類庫(kù),比如MyBatis。如果需要用Spring-Mybatis提供的注解方案,則需要依賴Spring。

image

3.1.5 - Web模塊

Web模塊包含Controller等相關(guān)代碼。如果用SpringMVC則需要依賴Spring。

image

3.1.6 - Start模塊

Start模塊是SpringBoot的啟動(dòng)類。

3.2 - 測(cè)試

  • Types,Domain模塊都屬于無(wú)外部依賴的純POJO,基本上都可以100%的被單元測(cè)試覆蓋。
  • Application模塊的代碼依賴外部抽象類,需要通過測(cè)試框架去Mock所有外部依賴,但仍然可以100%被單元測(cè)試。
  • Infrastructure的每個(gè)模塊的代碼相對(duì)獨(dú)立,接口數(shù)量比較少,相對(duì)比較容易寫單測(cè)。但是由于依賴了外部I/O,速度上不可能很快,但好在模塊的變動(dòng)不會(huì)很頻繁,屬于一勞永逸。
  • Web模塊有兩種測(cè)試方法:通過Spring的MockMVC測(cè)試,或者通過HttpClient調(diào)用接口測(cè)試。但是在測(cè)試時(shí)最好把Controller依賴的服務(wù)類都Mock掉。一般來說當(dāng)你把Controller的邏輯都后置到Application Service中時(shí),Controller的邏輯變得極為簡(jiǎn)單,很容易100%覆蓋。
  • Start模塊:通常應(yīng)用的集成測(cè)試寫在start里。當(dāng)其他模塊的單元測(cè)試都能100%覆蓋后,集成測(cè)試用來驗(yàn)證整體鏈路的真實(shí)性。

3.3 - 代碼的演進(jìn)/變化速度

在傳統(tǒng)架構(gòu)中,代碼從上到下的變化速度基本上是一致的,改個(gè)需求需要從接口、到業(yè)務(wù)邏輯、到數(shù)據(jù)庫(kù)全量變更,而第三方變更可能會(huì)導(dǎo)致整個(gè)代碼的重寫。但是在DDD中不同模塊的代碼的演進(jìn)速度是不一樣的:

  • Domain層屬于核心業(yè)務(wù)邏輯,屬于經(jīng)常被修改的地方。比如:原來不需要扣手續(xù)費(fèi),現(xiàn)在需要了之類的。通過Entity能夠解決基于單個(gè)對(duì)象的邏輯變更,通過Domain Service解決多個(gè)對(duì)象間的業(yè)務(wù)邏輯變更。
  • Application層屬于Use Case(業(yè)務(wù)用例)。業(yè)務(wù)用例一般都是描述比較大方向的需求,接口相對(duì)穩(wěn)定,特別是對(duì)外的接口一般不會(huì)頻繁變更。添加業(yè)務(wù)用例可以通過新增Application Service或者新增接口實(shí)現(xiàn)功能的擴(kuò)展。
  • Infrastructure層屬于最低頻變更的。一般這個(gè)層的模塊只有在外部依賴變更了之后才會(huì)跟著升級(jí),而外部依賴的變更頻率一般遠(yuǎn)低于業(yè)務(wù)邏輯的變更頻率。

所以在DDD架構(gòu)中,能明顯看出越外層的代碼越穩(wěn)定,越內(nèi)層的代碼演進(jìn)越快,真正體現(xiàn)了領(lǐng)域“驅(qū)動(dòng)”的核心思想。

4、總結(jié)

DDD不是一個(gè)什么特殊的架構(gòu),而是任何傳統(tǒng)代碼經(jīng)過合理的重構(gòu)之后最終一定會(huì)抵達(dá)的終點(diǎn)。DDD的架構(gòu)能夠有效的解決傳統(tǒng)架構(gòu)中的問題:

  • 高可維護(hù)性:當(dāng)外部依賴變更時(shí),內(nèi)部代碼只用變更跟外部對(duì)接的模塊,其他業(yè)務(wù)邏輯不變。
  • 高可擴(kuò)展性:做新功能時(shí),絕大部分的代碼都能復(fù)用,僅需要增加核心業(yè)務(wù)邏輯即可。
  • 高可測(cè)試性:每個(gè)拆分出來的模塊都符合單一性原則,絕大部分不依賴框架,可以快速的單元測(cè)試,做到100%覆蓋。
  • 代碼結(jié)構(gòu)清晰:通過POM module可以解決模塊間的依賴關(guān)系, 所有外接模塊都可以單獨(dú)獨(dú)立成Jar包被復(fù)用。當(dāng)團(tuán)隊(duì)形成規(guī)范后,可以快速的定位到相關(guān)代碼。

作者:阿里云官網(wǎng)
鏈接:http://www.itdecent.cn/p/58101f089ce2
來源:簡(jiǎn)書
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容