DDD實(shí)戰(zhàn)之看看代碼結(jié)構(gòu)長(zhǎng)啥樣
真正開始 DDD 旅程前,我想讓您看到經(jīng)過 DDD 設(shè)計(jì)之后的代碼長(zhǎng)啥樣。我想,這是所有本著“talking is easy, show me your code”理念的程序員都比較在乎的觀念。
為此,我特別將“群買菜”生鮮電商系統(tǒng)服務(wù)端代碼新舊代碼結(jié)構(gòu)都顯示出來(lái),讓您看看原來(lái)的舊代碼——也就是“事務(wù)腳本式”代碼長(zhǎng)啥樣(應(yīng)該是目前大部分 java 程序員寫代碼的樣子),再讓您看看 DDD 改造設(shè)計(jì)后的新代碼長(zhǎng)什么樣子。然后再通過分析,說(shuō)清楚為什么傳統(tǒng)的“事務(wù)腳本”代碼不是對(duì)真實(shí)世界的“同構(gòu)映射”,而 DDD 代碼的“同構(gòu)映射”在哪。
需要提醒您的是:從今天這個(gè)專題開始,可能需要你多花點(diǎn)時(shí)間、深入地閱讀我寫的代碼、和文字的每一句話,反復(fù)對(duì)照著看,甚至來(lái)回反復(fù)多看幾遍,才能真的去理解這些文字了。
1.舊代碼:事務(wù)腳本式(貧血模型)代碼
我們先來(lái)看舊代碼的目錄結(jié)構(gòu)截圖。注意看下面的 1、2、3、4 標(biāo)注位置(解釋下,我這里用的是 spring-boot 開發(fā)框架,MyBatisPlus 數(shù)據(jù)持久框架、MySql5.6 數(shù)據(jù)庫(kù)):
您注意到這里標(biāo)注的 1、2、3、4 代碼位置了嗎?是不是代碼結(jié)構(gòu)很像大部分 spring-boot 應(yīng)用框架下代碼結(jié)構(gòu)?為了避免您可能不太了解這種代碼結(jié)構(gòu),我還是簡(jiǎn)單解釋下。
標(biāo)號(hào) 1 位置:這里放的是 Controller(控制器)層代碼,也就是所有前端訪問的接口都在這里實(shí)現(xiàn)。按照 MVC 的分層原則,一般來(lái)說(shuō),這里只會(huì)放一些客戶端輸入?yún)?shù)的解析、以及對(duì) service 層(見下文)的業(yè)務(wù)方法調(diào)用。一般來(lái)說(shuō),這里的代碼都長(zhǎng)成下面這樣:
標(biāo)號(hào) 2 位置:這里放的是 entity(數(shù)據(jù) bean)層代碼,其實(shí)都是 POJO 代碼,所有類都一一對(duì)應(yīng)到數(shù)據(jù)庫(kù)表。一般來(lái)說(shuō),這里的代碼都長(zhǎng)成這樣:
標(biāo)號(hào) 3 位置:mapper 層,對(duì)于 mybatis 持久層框架來(lái)說(shuō),mapper 和 entity 共同實(shí)現(xiàn)了 ORM(對(duì)象模型到關(guān)系模型的映射)。一般來(lái)說(shuō),這里的代碼長(zhǎng)成這樣(這里 CustomerMapper 類只是定義了 entity 類 Customer 的映射關(guān)系,以及自定義的數(shù)據(jù)操作方法):
以及這樣(在 MP 中,只有需要實(shí)現(xiàn)自定義的 SQL 操作方法,才需要這個(gè) CustomerMapper.xml 文件):
標(biāo)號(hào) 4 位置:Service(服務(wù))層,這里是所有業(yè)務(wù)邏輯實(shí)現(xiàn)的核心代碼處,幾乎所有的業(yè)務(wù)邏輯都是在這里實(shí)現(xiàn)的。一般來(lái)說(shuō),這里會(huì)有 interface+implementation 組合的實(shí)現(xiàn)方式。比如:OrderService 和 OrderServiceImpl,分別長(zhǎng)下面這樣:
OrderService 接口類:
OrderServiceImpl 實(shí)現(xiàn)類;
從上面的代碼中 ,我們可以很明顯地看出如下幾點(diǎn):
- Controller/entity/mapper 基本上都是利用框架的 annonation(注解)和公共工具類代碼(如 json 解析等)實(shí)現(xiàn)的很少的代碼;
- 顯然,大部分業(yè)務(wù)邏輯都是在 Service 層的實(shí)現(xiàn)類里面實(shí)現(xiàn)的;
- Service 層實(shí)現(xiàn)類代碼的邏輯寫的很長(zhǎng),且完全是“平鋪直述”的。我這里展示的 OrderSeriveImple 的 create 方法——?jiǎng)?chuàng)建訂單,就寫了 135 行。從我的代碼截圖中的注釋可以看出來(lái),我是想好了一步一步要怎么對(duì)數(shù)據(jù)庫(kù)進(jìn)行 CRUD,先填寫好注釋,然后寫代碼的。這種代碼,說(shuō)白了就是“CRUD+計(jì)算邏輯”組合的代碼;
- 事實(shí)上,這種“平鋪直述”式的代碼,是很容易被程序理解的,寫起來(lái)也很容易,基本上不用“殺死”太多腦細(xì)胞,所以團(tuán)隊(duì)很容易就開始實(shí)施項(xiàng)目工程,隨便找一個(gè)具有基本 java 編程經(jīng)驗(yàn)(一般一年以上經(jīng)驗(yàn)即可)就能夠開始著手業(yè)務(wù)代碼的開發(fā);
- 這種代碼,我們就叫做”事務(wù)腳本式”代碼,或者說(shuō)叫“貧血模型”代碼。
之所以叫“事務(wù)腳本”,我個(gè)人的理解:本質(zhì)上跟 20 年前寫數(shù)據(jù)庫(kù)存儲(chǔ)過程代碼沒有本質(zhì)區(qū)別(只是換了個(gè)語(yǔ)言書寫、運(yùn)行代碼的位置從數(shù)據(jù)庫(kù)服務(wù)器內(nèi)部提到了應(yīng)用服務(wù)器);
又之所以叫“貧血模型”代碼,是因?yàn)?entity 層的那些 POJO 對(duì)象如 Order 等,沒有任何業(yè)務(wù)行為的封裝(比如:Order 類應(yīng)該自己生成自己的訂單號(hào)、提貨號(hào)等),只有屬性而沒有行為的對(duì)象,就是“貧血”對(duì)象,基于“貧血”對(duì)象實(shí)現(xiàn)的業(yè)務(wù)邏輯代碼,就叫“貧血模型”代碼。
根據(jù)這里的代碼分析,我們是不是能夠發(fā)現(xiàn)一個(gè)關(guān)鍵問題:這里的 Controller/entity/mapper/service,事實(shí)上和真實(shí)世界的業(yè)務(wù)之間關(guān)系,是沒有任何映射的——也就是說(shuō):“代碼世界”和“真實(shí)世界”是異構(gòu)的。具體來(lái)說(shuō),我們可以分以下幾點(diǎn)來(lái)看。
首先,從業(yè)務(wù)模塊劃分這個(gè)“最粗”的粒度來(lái)說(shuō),我們其實(shí)是可以簡(jiǎn)單的、憑直覺進(jìn)行模塊劃分的,不用全部業(yè)務(wù)模塊放在一個(gè)工程項(xiàng)目中,是可以按照業(yè)務(wù)模塊(比如:店鋪管理、訂單管理、商品管理等)進(jìn)行項(xiàng)目目錄劃分、也就是項(xiàng)目團(tuán)隊(duì)分組的。
事實(shí)上,目前市面上的大多數(shù)軟件公司,就是根據(jù)業(yè)務(wù)經(jīng)驗(yàn)或直覺簡(jiǎn)單粗暴的將項(xiàng)目劃分了多個(gè)團(tuán)隊(duì)在進(jìn)行開發(fā)。但這種劃分方式,雖然也可以七七八八準(zhǔn)確——但我們需要意識(shí)到的是,這樣簡(jiǎn)單粗暴的憑經(jīng)驗(yàn)直覺的劃分,跟 DDD 方法論做的設(shè)計(jì)劃分相比(劃分到限界上下文這個(gè)粒度的設(shè)計(jì),在 DDD 中叫做“戰(zhàn)略設(shè)計(jì)”),至少有 3 個(gè)不足:
- 軟件代碼如何劃分是嚴(yán)格的“工程性問題”,而所有工程性問題,往往會(huì)“差之毫厘謬以千里”!這種經(jīng)驗(yàn)直覺的劃分,很可能會(huì)遺漏掉一些很重要的“限界上下文”識(shí)別。而正因?yàn)檫@些重要的“限界上下文”的遺漏,導(dǎo)致了一些模糊地帶,發(fā)現(xiàn)要么是沒必要的模塊間耦合、要么是沒必要的重復(fù)。
- DDD“限界上下文”的識(shí)別,不但要區(qū)分出到底要?jiǎng)澐譃閹讉€(gè)模塊(其實(shí)“模塊”是個(gè)很模糊的詞,可以用來(lái)劃分微服務(wù)、也可以用來(lái)劃分代碼目錄結(jié)構(gòu),視需要而定),還需要識(shí)別這些“限界上下文”之間的協(xié)作關(guān)系和邊界。而這些協(xié)作關(guān)系,才真正“清晰準(zhǔn)確、代碼行級(jí)”定義了哪些代碼歸屬模塊 A、哪些代碼歸屬模塊 B——也就是邊界,以及這些模塊是通過 RPC 或本地調(diào)用關(guān)系在協(xié)作、還是異步消息事件在協(xié)作、甚至直接就沒有協(xié)作。
- 一般來(lái)說(shuō),DDD 的“限界上下文”需要對(duì)應(yīng)到業(yè)務(wù)子領(lǐng)域,而業(yè)務(wù)子領(lǐng)域的重要程度將決定限界上下文的重要程度。業(yè)務(wù)子領(lǐng)域針對(duì)某個(gè)具體的軟件系統(tǒng)來(lái)說(shuō),是可以從業(yè)務(wù)角度判斷出哪些必須建設(shè)為軟件的核心競(jìng)爭(zhēng)力、哪些則可以作為次要模塊甚至通過外包來(lái)實(shí)現(xiàn)。這些對(duì)“限界上下文”模塊的不同“重要程度”定義,將會(huì)促使項(xiàng)目管理層從效率的角度采用不同的技術(shù)棧。比如:目前市面上不同的程序員薪資水平是不同的、招聘難度是不同的;不同技術(shù)棧的成熟程度、可適用的編程特性是不同的(比如:java 比較成熟適合企業(yè)級(jí)應(yīng)用開發(fā),而 python 適合數(shù)據(jù)處理類開發(fā),node.js 適合跟第三方互聯(lián)網(wǎng)系統(tǒng)連接等)。
其次,到模塊內(nèi)部,其代碼的層次結(jié)構(gòu)劃分,如果按照 mvc 思想,最后還是又回到了類似 controller/entity/mapper/service 這樣的劃分方式。而這種劃分方式,又和“真實(shí)世界”有什么同構(gòu)映射關(guān)系呢?可以說(shuō),沒有!
所以,最終我們還是可以得出結(jié)論:這種傳統(tǒng)的代碼架構(gòu),是沒有考慮和真實(shí)世界的“同構(gòu)映射”的。而這種對(duì)“同構(gòu)映射”的缺失,才是導(dǎo)致我們出現(xiàn)“真實(shí)業(yè)務(wù)其實(shí)沒多大變化、但某個(gè)需求卻為什么引起軟件代碼翻天覆地的變化呢?”這樣疑惑的根本原因——DDD 方法論,就是用來(lái)解決這個(gè)問題的!
2.新代碼:DDD 設(shè)計(jì)代碼(充血模型)
我們?cè)賮?lái)看看使用 DDD 設(shè)計(jì)后,新的代碼結(jié)構(gòu)長(zhǎng)什么樣。下面是新代碼的結(jié)構(gòu)截圖(同樣注意下面的 1~8 標(biāo)號(hào)):
對(duì)上面的代碼標(biāo)號(hào)位置,我來(lái)逐個(gè)解釋如下(需要說(shuō)明的是:這里目錄排序是 IDEA 開發(fā)工具自動(dòng)按字母順序排序,不是代碼設(shè)計(jì)先后順序):
標(biāo)號(hào) 1 位置:這里放的是邊緣層(edge)代碼。由于“群買菜”小程序前端界面已經(jīng)開發(fā)完成,并且這是一個(gè)前后端分離項(xiàng)目,前端代碼我并沒有打算修改,所以這里就多了個(gè)“界面適配”的代碼工作。一般來(lái)說(shuō),這種代碼就叫“邊緣層”。邊緣層放的代碼,都是類似這種為了前端界面適配、第三方系統(tǒng)接口適配之類的代碼。這種代碼,也可以叫做“為前端提供的后端”(Backend for Frontend, BFF)。理論上,這種 BFF 層的代碼,可以由前端團(tuán)隊(duì)開發(fā)的,我可以選擇技術(shù)棧是 Node.js,使用 js 或 ts 語(yǔ)言進(jìn)行開發(fā)。
標(biāo)號(hào) 2 位置:這里顯示的是“基礎(chǔ)層”(foudation)。在 DDD 的系統(tǒng)架構(gòu)中,限界上下文(具體概念介紹見后面,這里你只需要理解為它類似于子系統(tǒng)或業(yè)務(wù)模塊劃分就好)是可以根據(jù)“業(yè)務(wù)子域”不是核心層,而分為“基礎(chǔ)層”和“業(yè)務(wù)價(jià)值層”。一般來(lái)說(shuō),“業(yè)務(wù)價(jià)值層”對(duì)應(yīng)到最核心的業(yè)務(wù)模塊,是一個(gè)軟件系統(tǒng)的核心競(jìng)爭(zhēng)力所在,是需要嚴(yán)格按照 DDD 的理念進(jìn)行戰(zhàn)術(shù)設(shè)計(jì)、并采用測(cè)試驅(qū)動(dòng)開發(fā)模式、投入最懂業(yè)務(wù)的程序員去工作的;而“基礎(chǔ)層”一般都是非核心業(yè)務(wù)模塊,比如:業(yè)務(wù)相關(guān)基礎(chǔ)類、工具類、伴生系統(tǒng)的對(duì)接等——需要注意的是:“基礎(chǔ)層”不是“基礎(chǔ)資源層”,基礎(chǔ)層指的是業(yè)務(wù)模塊處于非核心地位、而基礎(chǔ)資源指的是數(shù)據(jù)庫(kù)、中間件這些技術(shù)組件。
標(biāo)號(hào) 3 位置:這里顯示了多個(gè)限界上下文,都是以 xxxcontext 這樣的目錄取名。在“基礎(chǔ)層”和“業(yè)務(wù)價(jià)值層”中,都會(huì)出現(xiàn)多個(gè)“限界上下文”。每個(gè)限界上下文可以分離到不同的項(xiàng)目團(tuán)隊(duì)去負(fù)責(zé)、甚至分離到不同的微服務(wù)中心中。還是那句話,現(xiàn)在你還不用太深入的理解“限界上下文”,暫時(shí)只需要理解它是一種模塊劃分的說(shuō)法就好(后面會(huì)逐步深入解釋)。
標(biāo)號(hào) 4 位置:這里顯示出來(lái)了“業(yè)務(wù)價(jià)值層”的代碼——也就是該軟件系統(tǒng)中需要作為最核心競(jìng)爭(zhēng)力的那些模塊,同樣下面也會(huì)有多個(gè)“限界上下文”。
標(biāo)號(hào) 5 位置:DDD 戰(zhàn)術(shù)設(shè)計(jì)軟件分層的“菱形架構(gòu)”下,“領(lǐng)域”(domain)層的代碼放這里,也是業(yè)務(wù)邏輯最核心的代碼——所有的“充血”模型代碼。從這里開始,我們解釋某個(gè)“限界上下文”內(nèi)的代碼結(jié)構(gòu)。具體這些代碼怎么設(shè)計(jì)的細(xì)節(jié),我們后面會(huì)講,現(xiàn)在你只需要知道這里放的是“業(yè)務(wù)邏輯核心”即可。
標(biāo)號(hào) 6、8 位置:在 DDD 戰(zhàn)術(shù)設(shè)計(jì)軟件分層的“菱形架構(gòu)”下,為了讓“限界上下文”在滿足外部的各種調(diào)用需求、以及需要調(diào)用或與別的“限界上下文”通訊時(shí),不至于因?yàn)榕c本模塊業(yè)務(wù)邏輯無(wú)關(guān)的、各種外在因素變化而引起本模塊內(nèi)代碼邏輯的“動(dòng)蕩不安”,而引入了“北向網(wǎng)關(guān)”、“南向網(wǎng)關(guān)”概念。分別說(shuō)明如下:
標(biāo)號(hào) 6 就里面就是“北向網(wǎng)關(guān)”的代碼,里面又分為 local 和 remote 兩個(gè)典型的目錄。“北向網(wǎng)關(guān)”的作用,就是讓限界上下文可以向外輸出各類應(yīng)用服務(wù)。local 目錄下方的是本限界上下文向外提供的“應(yīng)用服務(wù)”,是將 domain 內(nèi)各種“充血模型”代碼進(jìn)行封裝后的、完整的業(yè)務(wù)邏輯;而 remote 目錄下,放的是對(duì) local 目錄為了滿足“遠(yuǎn)程調(diào)用”而進(jìn)行的代碼封裝——如 RPC 調(diào)用、跨服務(wù)器消息事件訂閱等,并不存在任何業(yè)務(wù)邏輯。
標(biāo)號(hào) 8 里面就是“南向網(wǎng)關(guān)”的代碼,里面又分為“端口(port)”和“適配器(adaper)”兩個(gè)典型的目錄。“南向網(wǎng)關(guān)”的作用,就是是讓本限界上下文通過其請(qǐng)求外部資源。典型的 3 類外部資源請(qǐng)求有:訪問數(shù)據(jù)持久層(關(guān)系或非關(guān)系數(shù)據(jù)庫(kù))、調(diào)用別的限界上下文服務(wù)(在微服務(wù)架構(gòu)中,往往是 RPC 遠(yuǎn)程調(diào)用)、向別的限界上下文發(fā)布消息。我們都知道,這些對(duì)外部資源的請(qǐng)求,可能會(huì)因?yàn)橥獠抠Y源的技術(shù)底層不同,而存在不同的實(shí)現(xiàn)方式。為了能夠隔離“領(lǐng)域?qū)印睂?duì)具體技術(shù)底層的依賴,就分離出來(lái) port 層和 adapter 層。在 java 語(yǔ)言實(shí)現(xiàn)中,port 層就是 interface,沒有任何實(shí)現(xiàn)代碼,只有方法定義;而 adaper 層就是 implemetaion,具體實(shí)現(xiàn)到不同持久層(如不同關(guān)系數(shù)據(jù)庫(kù) oracle/mysql 等、不同 nosql 數(shù)據(jù)庫(kù) redis/mongodb 等)。然后,根據(jù) IoC(依賴倒置)原則在 java 中通過“依賴注入”來(lái)將 adaper 目錄下的具體實(shí)現(xiàn)與 domain 層的代碼連接起來(lái)。
標(biāo)號(hào) 7 位置:這里是“發(fā)布語(yǔ)言”(published language, pl)層。說(shuō)白了,“發(fā)布語(yǔ)言”就是讓“北向網(wǎng)關(guān)”向外輸出服務(wù)時(shí),能與服務(wù)調(diào)用者之間有個(gè)“統(tǒng)一語(yǔ)言”,比如:輸入輸出參數(shù)的結(jié)構(gòu)性定義、事件消息的格式定義等等。因?yàn)椋覀兪遣挥脤⑾藿缟舷挛膬?nèi)部的“領(lǐng)域”層的內(nèi)部對(duì)象結(jié)構(gòu)“泄露”到外部的,所以我們必須要有這個(gè)“發(fā)布語(yǔ)言”層。
3.結(jié)論
好了,解釋完了按照 DDD 進(jìn)行的代碼結(jié)構(gòu)設(shè)計(jì),我們還是要回答一個(gè)問題:DDD 對(duì)真實(shí)世界進(jìn)行“同構(gòu)映射”后的代碼邏輯到底在哪里呢?
答案是:在“領(lǐng)域”(Domain)層里面!所謂的“北向網(wǎng)關(guān)”、“發(fā)布語(yǔ)言”、“南向網(wǎng)關(guān)”層的作用,都只是為了讓外部的請(qǐng)求、被請(qǐng)求資源的底層技術(shù),不要去“打擾”我們“業(yè)務(wù)邏輯”的“同構(gòu)化”映射!
這就相當(dāng)于是說(shuō):領(lǐng)域?qū)硬攀?DDD 對(duì)“業(yè)務(wù)邏輯”映射后的核心,其它都只是對(duì)這些“核心業(yè)務(wù)邏輯”的層次“包裝”而已!
那么,顯然,從技術(shù)角度來(lái)說(shuō),懂得領(lǐng)域?qū)尤绾卧O(shè)計(jì)才是 DDD 戰(zhàn)術(shù)設(shè)計(jì)層面最重要的技能!因?yàn)椋氨毕蚓W(wǎng)關(guān)”、“發(fā)布語(yǔ)言”、“南向網(wǎng)關(guān)”這 3 層的代碼開發(fā),都是常規(guī)套路,沒啥“業(yè)務(wù)知識(shí)”含量,甚至可以用機(jī)器人來(lái)實(shí)現(xiàn)(也就是通過代碼自動(dòng)生產(chǎn)工具)。
最后,解釋下反復(fù)提到的 DDD 戰(zhàn)略設(shè)計(jì)和戰(zhàn)術(shù)設(shè)計(jì)的區(qū)別:
大體上來(lái)說(shuō),DDD 戰(zhàn)略設(shè)計(jì),就是識(shí)別出有哪些限界上下文、以及清晰的定義限界上下文的關(guān)系和邊界,就基本完成了(雖然還有些修修補(bǔ)補(bǔ)的工作,比如要不要邊緣層、本軟件系統(tǒng)跟哪些第三方外部系統(tǒng)接口等,但這些其實(shí)已經(jīng)不能叫“設(shè)計(jì)”了,因?yàn)椴恍枰ǘ嗌倌X子了);
DDD 戰(zhàn)術(shù)設(shè)計(jì),核心就是完成“領(lǐng)域”層內(nèi)的“聚合”、“領(lǐng)域服務(wù)”的設(shè)計(jì),也就是“核心業(yè)務(wù)邏輯”的設(shè)計(jì)。具體怎么玩,我后面會(huì)一點(diǎn)點(diǎn)演示。