萬字長文,結合電商支付業務一文搞懂DDD

作者範鋼,曾任航天信息首席架構師,《大話重構》一書的作者。本文結合電商支付場景詳細描述了領域驅動模型的實際應用。

2004 年,軟件大師 Eric Evans 的不朽著作《領域驅動設計:軟件核心複雜性應對之道》面世,從書名可以看出,這是一本應對軟件系統越來越複雜的方法論的圖書。然而,在當時,中國的軟件業纔剛剛起步,軟件系統還沒有那麼複雜,即使維護了幾年,軟件退化了,不好維護了,推倒重新開發就好了。因此,在過去的那麼多年裏,真正運用領域驅動設計開發(DDD)的團隊並不多。一套優秀的方法論,因爲現實階段的原因而一直不溫不火。

不過,這些年隨着中國軟件業的快速發展,軟件規模越來越大,生命週期也越來越長,推倒重新開發的成本和風險越來越大。這時,軟件團隊急切需要在較低成本的狀態下持續維護一個系統很多年。然而,事與願違。隨着時間的推移,程序越來越亂,維護成本越來越高,軟件退化成了無數軟件團隊的噩夢。

這時,微服務架構成了規模化軟件的解決之道。不過,微服務對設計提出了很高的要求,強調“小而專、高內聚”,否則就不能發揮出微服務的優勢,甚至可能令問題更糟糕。

因此,微服務的設計,微服務的拆分都需要領域驅動設計的指導。那麼,領域驅動爲什麼能解決軟件規模化的問題呢?我們先從問題的根源談起,即軟件退化。

軟件退化的根源

最近 10 年的互聯網發展,從電子商務到移動互聯,再到“互聯網+”與傳統行業的互聯網轉型,是一個非常痛苦的轉型過程。而近幾年的人工智能與 5G 技術的發展,又會帶動整個產業向着大數據與物聯網發展,另一輪的技術轉型已經拉開帷幕。

那麼,在這個過程中,一方面會給我們帶來諸多的挑戰,另一方面又會給我們帶來無盡的機會,它會帶來更多的新興市場、新興產業與全新業務,給我們帶來全新的發展機遇。

然而,在面對全新業務、全新增長點的時候,我們能不能把握住這樣的機遇呢?我們期望能把握住,但每次回到現實,回到正在維護的系統時,卻令人沮喪。我們的軟件總是經歷着這樣的輪迴,軟件設計質量最高的時候是第一次設計的那個版本,當第一個版本設計上線以後就開始各種需求變更,這常常又會打亂原有的設計。

因此,需求變更一次,版本迭代一次,軟件就修改一次,軟件修改一次,質量就下降一次。不論第一次的設計質量有多高,軟件經歷不了幾次變更,就進入一種低質量、難以維護的狀態。進而,團隊就不得不在這樣的狀態下,以高成本的方式不斷地維護下去,維護很多年。

這時候,維護好原有的業務都非常不易,又如何再去期望未來更多的全新業務呢?比如,這是一段電商網站支付功能的設計,最初的版本設計質量還是不錯的:

當第一個版本上線以後,很快就迎來了第一次變更,變更的需求是增加商品折扣功能,並且這個折扣功能還要分爲限時折扣、限量折扣、某類商品的折扣、某個商品的折扣。當我們拿到這個需求時怎麼做呢?很簡單,增加一個 if 語句,if 限時折扣就怎麼怎麼樣,if 限量折扣就怎麼怎麼樣……代碼開始膨脹了。

接着,第二次變更需要增加 VIP 會員,除了增加各種金卡、銀卡的折扣,還要爲會員發放各種福利,讓會員享受各種特權。爲了實現這些需求,我們又要在 payoff() 方法中加入更多的代碼。

第三次變更增加的是支付方式,除了支付寶支付,還要增加微信支付、各種銀行卡支付、各種支付平臺支付,此時又要塞入一大堆代碼。經過這三次變更,你可以想象現在的 payoff() 方法是什麼樣子了吧,變更是不是就可以結束了呢?其實不能,接着還要增加更多的秒殺、預訂、閃購、衆籌,以及各種返券。程序變得越來越亂而難以閱讀和維護,每次變更也變得越來越困難。

問題來了:爲什麼軟件會退化,會隨着變更而設計質量下降呢?在這個問題上,我們必須尋找到問題的根源,才能對症下藥、解決問題。

要探尋軟件退化的根源,先要從探尋軟件的本質及其規律開始,軟件的本質就是對真實世界的模擬,每個軟件都能在真實世界中找到它的影子。因此,軟件中業務邏輯正確與否的唯一標準就是是否與真實世界一致。如果一致,則軟件是 OK 的;不一致,則用戶會提 Bug、提新需求。

在這裏發現了一個非常重要的線索,那就是,軟件要做成什麼樣,既不由我們來決定,也不由用戶來決定,而是由客觀世界決定。用戶爲什麼總在改需求,是因爲他們也不確定客觀世界的規則,只有遇到問題了他們才能想得起來。因此,對於我們來說,與其唯唯諾諾地按照用戶的要求去做軟件,不如在充分理解業務的基礎上去分析軟件,這樣會更有利於我們減少軟件維護的成本。

那麼,真實世界是怎樣的,我們就怎樣開發軟件,不就簡單了嗎?其實並非如此,因爲真實世界是非常複雜的,要深刻理解真實世界中的這些業務邏輯是需要一個過程的。因此,我們最初只能認識真實世界中那些簡單、清晰、易於理解的業務邏輯,把它們做到我們的軟件裏,即每個軟件的第一個版本的需求總是那麼清晰明瞭、易於設計。

然而,當我們把第一個版本的軟件交付用戶使用的時候,用戶卻會發現,還有很多不簡單、不明瞭、不易於理解的業務邏輯沒做到軟件裏。這在使用軟件的過程中很不方便,和真實業務不一致,因此用戶就會提 Bug、提新需求。

在我們不斷地修復 Bug,實現新需求的過程中,軟件的業務邏輯也會越來越接近真實世界,使得我們的軟件越來越專業,讓用戶感覺越來越好用。但是,在軟件越來越接近真實世界的過程中,業務邏輯就會變得越來越複雜,軟件規模也越來越龐大。

你一定有這樣一個認識:簡單軟件有簡單軟件的設計,複雜軟件有複雜軟件的設計。

比如,現在的需求就是將用戶訂單按照“單價 × 數量”公式來計算應付金額,那麼在一個 PaymentBus 類中增加一個 payoff() 方法即可,這樣的設計沒有問題。不過,如果現在的需要在付款的過程中計算各種折扣、各種優惠、各種返券,那麼我們必然會做成一個複雜的程序結構。

但是,真實情況卻不是這樣的。真實情況是,起初我們拿到的需求是那個簡單需求,然後在簡單需求的基礎上進行了設計開發。但隨着軟件的不斷變更,軟件業務邏輯變得越來越複雜,軟件規模不斷擴大,逐漸由一個簡單軟件轉變成一個複雜軟件。

這時,如果要保持軟件設計質量不退化,就應當逐步調整軟件的程序結構,逐漸由簡單的程序結構轉變爲複雜的程序結構。如果我們總是這樣做,就能始終保持軟件的設計質量,不過非常遺憾的是,我們以往在維護軟件的過程中卻不是這樣做的,而是不斷地在原有簡單軟件的程序結構下,往 payoff() 方法中塞代碼,這樣做必然會造成軟件的退化。

也就是說,軟件退化的根源不是版本迭代和需求變更,版本迭代和需求變更只是一個誘因。如果每次軟件變更時,適時地進行解耦,進行功能擴展,再實現新的功能,就能保持高質量的軟件設計。但如果在每次軟件變更時沒有調整程序結構,而是在原有的程序結構上不斷地塞代碼,軟件就會退化。這就是軟件發展的規律,軟件退化的根源。

杜絕軟件退化:兩頂帽子

前面談到,要保持軟件設計質量不退化,必須在每次需求變更的時候,對原有的程序結構適當地進行調整。那麼應當怎樣進行調整呢?還是回到前面電商網站付款功能的那個案例,看看每次需求變更應當怎樣設計。

在交付第一個版本的基礎上,很快第一次需求變更就到來了。第一次需求變更的內容如下。

增加商品折扣功能,該功能分爲以下幾種類型:

  • 限時折扣

  • 限量折扣

  • 對某類商品進行折扣

  • 對某個商品進行折扣

  • 不折扣

以往我們拿到這個需求,就很不冷靜地開始改代碼,修改成瞭如下一段代碼:

這裏增加了的 if else 語句,並不是一種好的變更方式。如果每次都這樣變更,那麼軟件必然就會退化,進入難以維護的狀態。這種變更爲什麼不好呢?因爲它違反了“開放-封閉原則”。

開閉原則(OCP) 分爲開放原則與封閉原則兩部分。

  • 開放原則:我們開發的軟件系統,對於功能擴展是開放的(Open for Extension),即當系統需求發生變更時,可以對軟件功能進行擴展,使其滿足用戶新的需求。

  • 封閉原則:對軟件代碼的修改應當是封閉的(Close for Modification),即在修改軟件的同時,不要影響到系統原有的功能,所以應當在不修改原有代碼的基礎上實現新的功能。也就是說,在增加新功能的時候,新代碼與老代碼應當隔離,不能在同一個類、同一個方法中。

前面的設計,在實現新功能的同時,新代碼與老代碼在同一個類、同一個方法中了,違反了“開閉原則”。怎樣才能既滿足“開閉原則”,又能夠實現新功能呢?在原有的代碼上你發現什麼都做不了!難道“開閉原則”錯了嗎?

問題的關鍵就在於,當我們在實現新需求時,應當採用“兩頂帽子”的方式進行設計,這種方式就要求在每次變更時,將變更分爲兩個步驟。

兩頂帽子:

  • 在不添加新功能的前提下,重構代碼,調整原有程序結構,以適應新功能;

  • 實現新的功能。

按以上案例爲例,爲了實現新的功能,我們在原有代碼的基礎上,在不添加新功能的前提下調整原有程序結構,我們抽取出了 Strategy 這樣一個接口和“不折扣”這個實現類。這時,原有程序變了嗎?沒有。但是程序結構卻變了,增加了這樣一個接口,稱之爲“可擴展點”。在這個可擴展點的基礎上再實現各種折扣,既能滿足“開放-封閉原則”來保證程序質量,又能夠滿足新的需求。當日後發生新的變更時,什麼類型的折扣有變化就修改哪個實現類,添加新的折扣類型就增加新的實現類,維護成本得到降低。

“兩頂帽子”的設計方式意義重大。過去,我們每次在設計軟件時總是擔心日後的變更,就很不冷靜地設計了很多所謂的“靈活設計”。然而,每一種“靈活設計”只能應對一種需求變更,而我們又不是先知,不知道日後會發生什麼樣的變更。最後的結果就是,我們期望的變更並沒有發生,所做的設計都變成了擺設,它既不起什麼作用,還增加了程序複雜度;我們沒有期望的變更發生了,原有的程序依然不能解決新的需求,程序又被打回了原形。因此,這樣的設計不能真正解決未來變更的問題,被稱爲“過度設計”。

有了“兩頂帽子”,我們不再需要焦慮,不再需要過度設計,正確的思路應當是“活在今天的格子裏做今天的事兒”,也就是爲當前的需求進行設計,使其剛剛滿足當前的需求。所謂的“高質量的軟件設計”就是要掌握一個平衡,一方面要滿足當前的需求,另一方面要讓設計剛剛滿足需求,從而使設計最簡化、代碼最少。這樣做,不僅軟件設計質量提高了,設計難點也得到了大幅度降低。

簡而言之,保持軟件設計不退化的關鍵在於每次需求變更的設計,只有保證每次需求變更時做出正確的設計,才能保證軟件以一種良性循環的方式不斷維護下去。這種正確的設計方式就是“兩頂帽子”。

但是,在實踐“兩頂帽子”的過程中,比較困難的是第一步。在不添加新功能的前提下,如何重構代碼,如何調整原有程序結構,以適應新功能,這是有難度的。很多時候,第一次變更、第二次變更、第三次變更,這些事情還能想清楚;但經歷了第十次變更、第二十次變更、第三十次變更,這些事情就想不清楚了,設計開始迷失方向。

那麼,有沒有一種方法,讓我們在第十次變更、第二十次變更、第三十次變更時,依然能夠找到正確的設計呢?有,那就是“領域驅動設計”。

保持軟件質量:領域驅動

前面談到,軟件的本質就是對真實世界的模擬。因此,我們會有一種想法,能不能將軟件設計與真實世界對應起來,真實世界是什麼樣子,那麼軟件世界就怎麼設計。如果是這樣的話,那麼在每次需求變更時,將變更還原到真實世界中,看看真實世界是什麼樣子的,根據真實世界進行變更。這樣,日後不論怎麼變更,經過多少輪變更,都按照這樣的方法進行設計,就不會迷失方向,設計質量就可以得到保證,這就是“領域驅動設計”的思想。

那麼,如何將真實世界與軟件世界對應起來呢?這樣的對應就包括以下三個方面的內容:

  • 真實世界有什麼事物,軟件世界就有什麼對象;

  • 真實世界中這些事物都有哪些行爲,軟件世界中這些對象就有哪些方法;

  • 真實世界中這些事物間都有哪些關係,軟件世界中這些對象間就有什麼關聯。

真實世界與軟件世界的對應圖

在領域驅動設計中,就將以上三個對應,先做成一個領域模型,然後通過這個領域模型指導程序設計;在每次需求變更時,先將需求還原到領域模型中分析,根據領域模型背後的真實世界進行變更,然後根據領域模型的變更指導軟件的變更,設計質量就可以得到提高。

結合電商支付實際演練DDD


現在,我們以電商網站的支付功能爲例,來演練一下基於 DDD 的軟件設計及其變更的過程。

運用 DDD 進行軟件設計

開發人員在最開始收到的關於用戶付款功能的需求描述是這樣的:

  • 在用戶下單以後,經過下單流程進入付款功能;

  • 通過用戶檔案獲得用戶名稱、地址等信息;

  • 記錄商品及其數量,並彙總付款金額;

  • 保存訂單;

  • 通過遠程調用支付接口進行支付。

以往當拿到這個需求時,開發人員往往草草設計以後就開始編碼,設計質量也就不高。

而採用領域驅動的方式,在拿到新需求以後,應當先進行需求分析,設計領域模型。按照以上業務場景,可以分析出:

  • 該場景中有“訂單”,每個訂單都對應一個用戶;

  • 一個用戶可以有多個用戶地址,但每個訂單隻能有一個用戶地址;

  • 此外,一個訂單對應多個訂單明細,每個訂單明細對應一個商品,每個商品對應一個供應商。

最後,我們對訂單可以進行“下單”“付款”“查看訂單狀態”等操作。因此形成了以下領域模型圖:

有了這樣的領域模型,就可以通過該模型進行以下程序設計:

通過領域模型的指導,將“訂單”分爲訂單 Service 與值對象,將“用戶”分爲用戶 Service 與值對象,將“商品”分爲商品 Service 與值對象……然後,在此基礎上實現各自的方法。

商品折扣的需求變更

當電商網站的付款功能按照領域模型完成了第一個版本的設計後,很快就迎來了第一次需求變更,即增加折扣功能,並且該折扣功能分爲限時折扣、限量折扣、某類商品的折扣、某個商品的折扣與不折扣。當我們拿到這個需求時應當怎樣設計呢?很顯然,在 payoff() 方法中去插入 if else 語句是不 OK 的。這時,按照領域驅動設計的思想,應當將需求變更還原到領域模型中進行分析,進而根據領域模型背後的真實世界進行變更。

這是上一個版本的領域模型,現在我們要在這個模型的基礎上增加折扣功能,並且還要分爲限時折扣、限量折扣、某類商品的折扣等不同類型。這時,我們應當怎麼分析設計呢?

首先要分析付款與折扣的關係。

付款與折扣是什麼關係呢?你可能會認爲折扣是在付款的過程中進行的折扣,因此就應當將折扣寫到付款中。這樣思考對嗎?我們應當基於什麼樣的思想與原則來設計呢?這時,另外一個重量級的設計原則應該出場了,那就是“單一職責原則”。

單一職責原則:軟件系統中的每個元素只完成自己職責範圍內的事,而將其他的事交給別人去做,我只是去調用。

單一職責原則是軟件設計中一個非常重要的原則,但如何正確地理解它成爲一個非常關鍵的問題。在這句話中,準確理解的關鍵就在於“職責”二字,即自己職責的範圍到底在哪裏。以往,我們錯誤地理解這個“職責”就是做某一個事,與這個事情相關的所有事情都是它的職責,正因爲這個錯誤的理解,帶來了許多錯誤的設計,而將折扣寫到付款功能中。那麼,怎樣纔是對“職責”正確的理解呢?

“一個職責就是軟件變化的一個原因”是著名的軟件大師 Bob 大叔在他的《敏捷軟件開發:原則、模式與實踐》中的表述。但這個表述過於精簡,很難深刻地理解其中的內涵。這裏我好好解讀一下這句話。

先思考一下什麼是高質量的代碼?你可能立即會想到“低耦合、高內聚”,以及各種設計原則,但這些評價標準都太“虛”。最直接、最落地的評價標準就是,當用戶提出一個需求變更時,爲了實現這個變更而修改軟件的成本越低,那麼軟件的設計質量就越高。當來了一個需求變更時,怎樣才能讓修改軟件的成本降低呢?如果爲了實現這個需求,需要修改 3 個模塊的代碼,完後這 3 個模塊都需要測試,其維護成本必然是“高”。那麼怎樣才能降到最低呢?如果只需要修改 1 個模塊就可以實現這個需求,維護成本就要低很多了。

那麼,怎樣才能在每次變更的時候都只修改一個模塊就能實現新需求呢?那就需要我們在平時就不斷地整理代碼,將那些因同一個原因而變更的代碼都放在一起,而將因不同原因而變更的代碼分開放,放在不同的模塊、不同的類中。這樣,當因爲這個原因而需要修改代碼時,需要修改的代碼都在這個模塊、這個類中,修改範圍就縮小了,維護成本降低了,修改代碼帶來的風險自然也降低了,設計質量也就提高了。

總之,單一職責原則要求我們在維護軟件的過程中需要不斷地進行整理,將軟件變化同一個原因的代碼放在一起,將軟件變化不同原因的代碼分開放。按照這樣的設計原則,回到前面那個案例中,那麼應當怎樣去分析“付款”與“折扣”之間的關係呢?只需要回答兩個問題:

  • 當“付款”發生變更時,“折扣”是不是一定要變?

  • 當“折扣”發生變更時,“付款”是不是一定要變?

當這兩個問題的答案是否定時,就說明“付款”與“折扣”是軟件變化的兩個不同的原因,那麼把它們放在一起,放在同一個類、同一個方法中,合適嗎?不合適,就應當將“折扣”從“付款”中提取出來,單獨放在一個類中。

同樣的道理:

  • 當“限時折扣”發生變更的時候,“限量折扣”是不是一定要變?

  • 當“限量折扣”發生變更的時候,“某類商品的折扣”是不是一定要變?

  • ……

最後發現,不同類型的折扣也是軟件變化不同的原因。將它們放在同一個類、同一個方法中,合適嗎?通過以上分析,我們做出瞭如下設計:

在該設計中,將折扣功能從付款功能中獨立出去,做出了一個接口,然後以此爲基礎設計了各種類型的折扣實現類。這樣的設計,當付款功能發生變更時不會影響折扣,而折扣發生變更的時候不會影響付款。同樣,當“限時折扣”發生變更時只與“限時折扣”有關,“限量折扣”發生變更時也只與“限量折扣”有關,與其他折扣類型無關。變更的範圍縮小了,維護成本就降低了,設計質量提高了。這樣的設計就是“單一職責原則”的真諦。

接着,在這個版本的領域模型的基礎上進行程序設計,在設計時還可以加入一些設計模式的內容,因此我們進行了如下的設計:

顯然,在該設計中加入了“策略模式”的內容,將折扣功能做成了一個折扣策略接口與各種折扣策略的實現類。當哪個折扣類型發生變更時就修改哪個折扣策略實現類;當要增加新的類型的折扣時就再寫一個折扣策略實現類,設計質量得到了提高。

VIP 會員的需求變更

在第一次變更的基礎上,很快迎來了第二次變更,這次是要增加 VIP 會員,業務需求如下。

增加 VIP 會員功能:

  • 對不同類型的 VIP 會員(金卡會員、銀卡會員)進行不同的折扣;

  • 在支付時,爲 VIP 會員發放福利(積分、返券等);

  • VIP 會員可以享受某些特權。

我們拿到這樣的需求又應當怎樣設計呢?同樣,先回到領域模型,分析“用戶”與“VIP 會員”的關係,“付款”與“VIP 會員”的關係。在分析的時候,還是回答那兩個問題:

  • “用戶”發生變更時,“VIP 會員”是否要變;

  • “VIP 會員”發生變更時,“用戶”是否要變。

通過分析發現,“用戶”與“VIP 會員”是兩個完全不同的事物。

  • “用戶”要做的是用戶的註冊、變更、註銷等操作;

  • “VIP 會員”要做的是會員折扣、會員福利與會員特權;

  • 而“付款”與“VIP 會員”的關係是在付款的過程中去調用會員折扣、會員福利與會員特權。

通過以上的分析,我們做出了以下版本的領域模型:

有了這些領域模型的變更,然後就可以以此作爲基礎,指導後面程序代碼的變更了。

支付方式的需求變更

同樣,第三次變更是增加更多的支付方式,我們在領域模型中分析“付款”與“支付方式”之間的關係,發現它們也是軟件變化不同的原因。因此,我們果斷做出了這樣的設計:

而在設計實現時,因爲要與各個第三方的支付系統對接,也就是要與外部系統對接。爲了使第三方的外部系統的變更對我們的影響最小化,在它們中間果斷加入了“適配器模式”,設計如下:

通過加入適配器模式,訂單 Service 在進行支付時調用的不再是外部的支付接口,而是“支付方式”接口,與外部系統解耦。只要保證“支付方式”接口是穩定的,那麼訂單 Service 就是穩定的。比如:

  • 當支付寶支付接口發生變更時,影響的只限於支付寶 Adapter;

  • 當微信支付接口發生變更時,影響的只限於微信支付 Adapter;

  • 當要增加一個新的支付方式時,只需要再寫一個新的 Adapter。

日後不論哪種變更,要修改的代碼範圍縮小了,維護成本自然降低了,代碼質量就提高了。

寫在最後


軟件發展的規律就是逐步由簡單軟件向複雜軟件轉變。簡單軟件有簡單軟件的設計,複雜軟件有複雜軟件的設計。因此,當軟件由簡單軟件向複雜軟件轉變時,就需要通過兩頂帽子適時地對程序結構進行調整,再實現新需求,只有這樣才能保證軟件不退化。然而,在變更的時候,如何調整代碼以適應新的需求呢?

DDD 給了我們思路:在每次變更的時候,先回到領域模型,基於業務進行領域模型的變更。然後,再基於領域模型的變更,指導程序的變更。這樣,不論經歷多少次需求變更,始終能夠保持設計質量不退化。這樣的設計,才能保障系統始終在低成本的狀態下,可持續地不斷維護下去。

本文我們演練了如何運用 DDD 進行軟件的設計與變更,以及在設計與變更的過程中如何分析思考、如何評估代碼、如何實現高質量。後續文章,我們將結合具體案例分析如何將領域模型的設計進一步落實到軟件系統的微服務設計與數據庫設計。

如果你覺得文章不錯,文末的贊 ???? 又回來啦,記得給我「點贊」和「在看」哦~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章