萬億級流量下怎樣保證系統不宕機?
以前有家公司,業務發展特別快,現有的系統功能跟不上市場需求了。于是,他們收購了一家小公司,并讓自己公司的技術團隊去接手被收購公司的系統。結果呢,技術團隊花了好大一番功夫梳理代碼,發現那代碼亂得不行,簡直就是 “意大利面條” 式的代碼,根本沒法維護。沒辦法,他們只能下定決心重構這個系統。
半年后,業務又變了,這個系統要交給公司另一個部門的技術團隊來維護。新接手的團隊同樣先梳理代碼,梳理完后得出的結論是:這代碼可讀性極差,層級關系混亂,完全沒辦法繼續開發新功能了,所以希望再對這個系統進行重構。這時候,公司老板找來了兩個技術團隊的負責人,問他們:“這個系統不是半年前剛重構過的嗎?怎么換了個團隊維護就要重構呢?你們這次重構了,就能保證下次別人接手不再吐槽、不再重構嗎?”
聽完這個故事后,朋友們沉默了一會兒,然后問我:“那我們該怎么辦呢?現在每周因為這些遺留系統導致的問題,搞得我焦頭爛額的。”
圖1 混亂不堪的系統現狀
不只是創業公司,這種情況在大公司也很常見,甚至可以說更加普遍。由于大公司的業務發展時間長、系統復雜度高,遺留下來的老舊系統數量更多,且這些系統的歷史往往更為久遠。
下圖是某知名互聯網公司對其線上事故原因進行統計分析后得出的結果。從圖中可以看出,接近一半的線上事故問題,都可以追溯到業務代碼邏輯的缺陷或不合理之處。這進一步凸顯了代碼質量和架構合理性在軟件系統穩定運行中的重要性。
圖2 某互聯網公司線上事故分析圖
造成系統問題的原因和解決方案
運維抱怨道:“這些歷史遺留系統根本沒法維護,動不動就宕機,三天兩頭出問題。”
開發人員也無奈地說:“老功能動都不敢動,怕出問題,新功能更是沒法添加,每天都在忙著救火,搞穩定性治理,忙得焦頭爛額。”
測試人員則表示:“一個接口或者消息,根本不知道有多少地方在使用,完全不清楚哪些部分會受影響,更別提會不會有漏測的地方,心里一點底都沒有。”
我相信,大家在面對各種歷史遺留問題時,如果詳細羅列出來,恐怕都能寫好幾篇論文了。不過,這些所謂的“歷史遺留問題”,從一開始就是問題嗎?或者說,如果原開發團隊還在維護這些系統,對他們來說這些問題還會存在嗎?我覺得未必。那究竟是什么原因導致這些問題一步步演變成現在這種難以維護的局面呢?顯然,壓死駱駝的絕不是最后一根稻草。
圖3 壓死駱駝的稻草
因此,面對形形色色的歷史遺留問題,我們首先要做的是透過現象看清本質。只有深入挖掘問題的根本原因,才能找到真正有效的解決方案,從而給出切實可行的具體措施來解決這些問題。
圖4 U型思考模型
對于“歷史遺留問題”,我們計劃運用 U 型思考模型的四步法展開分析。我們期望從問題的根本本質出發,盡力擺脫僅針對表面現象進行應急處理的不良循環。
1.定義核心問題
無論是歷史遺留系統引發的訂單丟失、賬務對賬錯誤等資損問題,還是內存泄漏、線程死鎖等系統性能問題,以及老功能修改困難、新需求開發受阻等效率問題,這些可能都只是表面現象。為什么這么說呢?因為這些問題都是當前系統在特定時間、地點和條件下出現的具體表現,而非問題的根源所在。
舉個例子,假設今天出現了一筆訂單,由于支付超時失敗,系統沒能及時提醒用戶重新支付,導致用戶端一直顯示“支付中”。如果我們僅把這些表面現象當作核心問題,可能會通過延長支付超時時間或增加失敗重試機制來修改代碼。然而,明天可能又會出現因支付失敗重試機制導致的重復扣款問題。如此一來,我們就陷入了每天不斷應對各種歷史遺留問題的循環,疲于奔命。
或許有人會說,這都是當初架構設計不合理造成的,現在出現的各種問題都是這個原因。這種說法有其合理性,因為當下的許多問題確實可以追溯到架構設計的缺陷。但同時,這種說法也有局限性。我們不能簡單地將所有問題都歸咎于一個模糊的概念,因為這樣做不利于我們深入挖掘問題的本質。這就像是小區里發生盜竊事件,我們不能僅僅歸因于“人性本惡”,而忽視了小區安保松懈、外來人員隨意進出等更直接的原因。
那么,面對歷史遺留系統,我們真正需要解決的核心問題是什么呢?我認為,關鍵在于如何避免我們的系統在未來成為別人眼中的歷史遺留問題。這個觀點值得我們深入思考。
2.發現問題本質
當我們確定要解決的核心問題后,接下來要深入洞察問題的本質原因。就像我在文章開頭提到的例子,為什么我們接手別人開發的系統時,常常覺得代碼混亂而不敢輕易修改?而當我們重構后的系統交給其他團隊維護時,他們為何也會覺得代碼難以理解?難道是我們比之前的人更厲害,而后面的人又比我們厲害?顯然不是。這主要是因為每個人的編碼習慣和設計風格不同。就像中國小學生和印度小學生做乘法的方式不同,我們可能完全看不懂印度小學生的計算過程,自然也不敢輕易修改。
圖5 傳統乘法 VS 印度乘法
在軟件的開發和維護過程中,其生命周期往往從最初的理想狀態逐漸向復雜、混亂和無序狀態演變,最終因不可維護而被迫下線或需要重構。這種導致軟件質量逐步惡化的因素,被稱為軟件的熵增現象。隨著歷史遺留系統的不斷運行,代碼功能被頻繁修改和補充,時間一長,后續的開發者就越難理解最初的代碼邏輯。
圖6 生活中的熵增
熵的概念最早起源于物理學,用于度量一個熱力學系統的無序程度。熱力學第二定律,又稱“熵增定律”,表明了在自然過程中,一個孤立的系統總是從最初的集中、有序的排列狀態,趨向于分散、混亂和無序;當熵達到最大時,系統就會處于一種靜寂狀態。
因此,想讓歷史遺留系統保持穩定,既能順暢維護,又能順利支持新老功能的開發,關鍵在于以團隊熟悉的風格來設計和開發系統,使代碼與結構清晰直觀,確保代碼能如實體現架構設計,力求達到“代碼即設計”的境界。總的來說,就是要保持架構模式的統一,確保代碼清晰且如實映射架構設計。
3.找到問題的本質解
在探索如何找到歷史遺留系統問題的根本解決方案之前,我們不妨借助時光機,回到上世紀 60 - 70 年代,一同梳理計算機軟件工程的發展脈絡,答案其實就深藏于軟件工程的演變歷程中。當提及軟件工程時,就不得不回顧軟件歷史上兩次極具影響力的 “軟件危機”。
軟件危機是指在軟件開發及維護過程中遭遇的一系列棘手問題,這些問題可能大幅縮短軟件產品的使用壽命,甚至使其徹底報廢。
- 第一次軟件危機(20 世紀 60 - 70 年代) :當時軟件開發主要依賴機器語言或匯編語言,針對特定機器進行設計與編寫。軟件規模較小,無需系統化開發方法,多為個人設計、編碼與使用。程序依賴特定機器硬件特性,代碼復雜難懂且不可移植,難以開發復雜功能軟件。1968 年,北大西洋公約組織的計算機科學家在聯邦德國召開國際會議,首度提出“軟件工程”一詞,標志著這門新興工程學科的誕生,旨在研究和攻克軟件危機。在此階段,更高級的結構化編程語言如 C 語言(1972 年誕生)相繼出現,結構化編程思想占據主導,提升了代碼的可讀性和可維護性。
- 第二次軟件危機(20 世紀 80 - 90 年代) :此次危機源于軟件復雜性的進一步提升。大規模軟件動輒數百萬行代碼,涉及上百名程序員。如何高效、可靠地構建和維護此類規模軟件成為新難題。例如,《人月神話》中提到的IBM 公司開發的OS/360 系統,雖投入巨大資源,仍延期交付且存在大量錯誤。當時人們期望軟件代碼具備可組合性、可擴展性和可維護性。盡管結構化編程改善了代碼可讀性,但從開發者視角出發,主要運用數據流圖(DFD)進行系統分析,難以直觀反映現實場景,導致開發人員與用戶溝通困難,需求分析師應運而生。為應對此次危機,面向對象編程語言(如 C++、C#、Java 等)誕生,同時催生了設計模式、重構、測試、需求分析等更優的軟件工程方法。
鑒于如今系統愈發復雜,需求不確定性增加,在當今 VUCA 時代(易變性、不確定性、復雜性、模糊性),回顧軟件危機及軟件工程發展歷程顯得尤為重要。面對復雜混亂的歷史遺留系統問題,軟件工程宛如一劑良方。維基百科定義軟件工程涵蓋 “軟件開發技術” 和 “軟件項目管理” 兩方面。其中,“軟件項目管理” 主要涉及項目管理知識體系,如 RUP 和敏捷 Scrum 等,暫非本次重點。“軟件開發技術” 才是我們關注的核心,包括結構化編程、面向對象開發、MVC、領域驅動設計、整潔架構等開發方法。以下是按時間維度梳理的部分常見軟件開發方法:
圖7 常見軟件開發方法
在軟件開發中,有多種方法可供選擇,團隊可以根據項目和自身情況靈活運用。重要的是在團隊內保持統一的架構模式,確保代碼風格一致,避免因風格差異導致成員間難以理解彼此代碼。
有些工程師只關注完成功能需求并交付,忽視軟件的維護和擴展性,這是系統一兩年后難以維護的常見原因。其實,軟件開發不僅要寫代碼,更要重視需求分析,理解需求本質,與團隊統一概念和語言,實現有效溝通。
- 需求分析是軟件開發的關鍵階段。開發人員應積極參與需求討論,運用第一性原理思考,洞察需求本質。與團隊對齊專有名詞和關鍵問題,達成統一認知。這一階段產出需求規約文檔,對應面向對象分析(OOA)階段,為后續設計和編碼提供依據。
- 接下來是面向對象設計(OOD)階段。根據需求規約文檔,設計清晰合理的技術方案,確定系統的類結構、對象交互和模塊劃分,確保設計邏輯清晰,為編碼提供明確指導。
- 最后是面向對象編碼(OOP)階段。需按照OOD階段的設計方案編寫代碼,確保代碼完整反映設計邏輯。注重代碼的可讀性和可維護性,遵循團隊統一的編碼規范。
通過遵循OOA、OOD和OOP的流程,團隊可以更好地管理軟件開發項目,提高代碼質量,增強系統的可維護性和擴展性。
4.解決問題的有效方案
軟件開發技術飛速進步,開發方法學也持續更新,比如水平分層從 MVC 三層架構到 DDD 四層架構、CQRS架構,垂直拆分從單體架構到 SOA、微服務架構(MSA)、服務網格(Service Mesh)等。萬變不離其宗,這些方法學旨在應對系統復雜性,確保代碼可開發、易維護,防止軟件系統陷入 “危機”。
以下是某真實項目工程代碼結構(公司類似工程眾多)。正如那句調侃:“三個月前寫這段代碼時,只有上帝和我懂;如今,恐怕只有上帝還看得懂了。”
圖8 某真實工程代碼現狀
軟件開發大師 Bob 大叔(Robert C. Martin)在其著作《整潔架構之道》中,提出了一個衡量軟件架構設計質量的標準:
一個系統架構的優劣可以通過滿足用戶需求的成本來判斷。若在系統整個生命周期內,需求變更成本始終很低,那么這個設計就是好的。反之,如果每次發布都會增加后續變更的成本,那么這個設計就有問題。
關于整潔架構圖的介紹如下:
圖9 整潔架構圖
1.Entities(實體) :包含核心的業務規則,擁有狀態屬性以及業務邏輯操作。
2.Application(應用層) :僅包含業務流程,代表各種用例場景,主要負責編排和調度 Entities 中的業務邏輯操作,具體的實現會在后續案例代碼中展示。
3.接口依賴方向 :所有接口(Controllers/Gateways/Presenters)只能向圓圈內部依賴,不能反向依賴。例如,Controller 可以調用 Application,但 Application 不能調用 Controller 接口。對于數據庫操作,以往常常直接將 Entity 領域對象傳遞到 Mapper 對象中,導致領域對象嚴重依賴數據庫操作對象。在 DDD(領域驅動設計)中,應通過依賴反轉來改變這種現象(整潔架構借鑒了 DDD 的很多概念,比如 Repository 等)。
4.Use Cases(用例) :是梳理和理解需求的重要工具,它代表業務場景,是架構設計中非常關鍵的概念,不懂得用例的架構師難以進行有效的架構設計。
現在,讓我們重新聚焦于核心問題。我認為,大家都能完成功能需求開發,也都會使用各種開發工具,遵守編碼規范。然而,為何仍有眾多遺留系統問題困擾著我們?這些遺留系統,有時并非我們從其他團隊接手的復雜代碼,而是我們自己團隊在半年或一年前編寫的代碼,如今卻難以理解和維護。
因此,我希望每位程序員不僅要掌握“軟件開發的工具和語言環境”,更要靈活運用“軟件開發的方法”,實現知行合一。在此案例中,我們將積極踐行《整潔代碼》和《整潔架構設計之道》,致力于提升代碼的可讀性,降低服務組件的依賴耦合性,使變更的影響局限在最小的單元范圍內。最后,通過一張圖來簡潔地總結我們解決歷史遺留系統問題的思考路徑和方法:
圖10 系統化解決問題思考方法
在系統開發中踐行整潔架構之道
在項目開發中,我們也希望工程代碼結構清晰、整潔舒適。接下來,我將介紹整潔架構在會員系統中的實踐經驗,包括需求分析的用例梳理(OOA)、領域模型設計和程序設計(OOD),以及代碼編寫(OOP)等內容。
1.項目背景簡單介紹
會員系統是企業或商家提升用戶忠誠度和銷售額的有效手段,其主要特點如下。
- 會員注冊:用戶可以通過會員系統注冊為會員,會員系統將收集用戶的個人信息,并為其生成唯一的會員賬號(通常為手機號碼)。
- 會員信息管理:對會員的詳細信息進行管理,包括個人資料、會員狀態和消費歷史等。
- 獎勵和折扣管理:基于會員的忠誠度、購買歷史或其他標準為會員設計獎勵和折扣。
- 積分和獎勵:會員在滿足一定條件的消費條件后可以獲得積分或獎勵,并可在未來的消費中使用這些積分或獎勵。會員系統應在結賬時顯示可用的積分,并方便用戶使用。
- 會員觸達:通過電子郵件或短信等方式向會員發送促銷活動通知。
2.通過用例梳理分析需求
了解項目背景后,通常下一步是編寫需求文檔,也就是PRD文檔。但許多PRD文檔是用自然語言編寫的,這使得閱讀和理解變得較為困難,還容易遺漏一些業務流程和場景。
那么,除了使用PRD這類自然語言編寫的需求文檔外,有沒有更直觀、易懂的方式來梳理業務需求呢?答案是肯定的,我們可以使用用例規約文檔來實現這個目的。
第一步:確定干系人
根據PRD文檔,以及和業務溝通交流,我們首先需要確定使用該系統的相關人員有哪些,以及他們對系統的目標和訴求是什么?以下為簡化的“干系人-目標”圖表:
表 1
干系人 | 目 標 | 描 述 |
企業經營者 | (1)管理配置優惠折扣活動。 (2)查看促銷活動的效果。 (3)制定會員等級規則。 (4)管理會員相關信息 | 企業經營者是會員系統項目的主要干系人,負責制定會員等級規則,設定獎勵和折扣活動,管理會員信息和監督項目的實施 |
收銀員 | (1)查看會員信息和權益。 (2)幫助會員更好地注冊。 (3)幫助會員享受權益或兌換獎勵 | 收銀員或服務員是企業與會員之間的橋梁。他們需要能夠訪問會員系統,以便為會員解決問題和提供支持 |
用戶 | (1)會員注冊。 (2)為會員卡儲值。 (3)享受會員權益,例如折扣等。 (4)參加促銷活動 | 用戶是最終使用會員系統的干系人。他們需要通過會員系統注冊并領取獎勵和折扣 |
技術開發者 | (1)系統日志記錄,便于排查問題。 (2)用戶使用埋點分析 | 技術開發者可以提供對會員系統的技術支持。他們需要理解會員系統的工作原理,并能夠幫助企業解決出現的問題 |
…… | …… | …… |
在梳理項目干系人及其目標時,我們要全面覆蓋所有相關方,不能有所遺漏。例如,技術開發者的目標和需求對系統開發也至關重要,不能忽視。在這一階段,項目干系人的目標可能還比較籠統,需要通過深入訪談來進一步細化和完善。
第二步:設計概要用例圖
在完成“干系人 - 目標”梳理后,接下來可以從這些目標中提取用例名稱。因為每個用例都是為了實現某個干系人的一個具體目標而存在的。同時,一個用例往往包含多個場景,比如系統在成功或失敗等不同場景下的處理方式等。以下是一個簡單的示例用例圖:
圖11 會員系統概要用例圖
有了這樣的概要用例,我們對系統需要具備什么樣的功能和能力就有了一個比較明確的方向了,當然如果覺得這個用例太過于抽象了,那么我們可以再繼續梳理下一層稍微詳細點的用例,這里建議不要超過三層,因為超過三層的會顯得過于細節化了,而我們在這里主要是為了梳理清楚實現用戶目標系統應該具備的能力就足夠了。對于每個用例更詳細的將在接下來的“書寫核心用例”中介紹。
第三步:書寫核心用例
概要用例有助于我們從宏觀上把握系統的功能,但對研發團隊來說,利用它不足以進行研發工作。因此,在確定用例的優先級之后,我們應著手對核心且高價值的用例進行詳細設計,并編寫詳細的用例。一個詳細的用例通常包含以下幾部分。
◎用例名稱:簡單明了地描述用例的主要功能。
◎參與者:與用例交互的用戶或其他系統。
◎前置條件:在執行用例之前,系統必須處于什么狀態。
◎后置條件:在執行用例之后,系統應該處于什么狀態。
◎正常場景:描述用例在正常情況下的執行流程。
◎異常場景:描述用例在異常情況下的執行流程。
在編寫詳細用例時,我們必須特別注意覆蓋所有正常場景和異常場景,以防遺漏場景。當然,我們不必為每個用例都編寫詳細的文檔,只需對復雜的核心用例編寫詳細的文檔,這樣可以顯著提升效率。例如,注冊會員的詳細用例如表2所示。
表 2
名 稱 | 會員注冊 |
描述 | 作為用戶,我希望能夠輕松注冊為會員,以便享受積分獎勵、折扣優惠等會員權益 |
參與者 | 用戶:能夠享受會員權益、折扣及參與促銷活動,能夠購買僅限會員購買的商品。 收銀員:在用戶注冊遇到問題時,及時提供幫助 |
前置條件 | 企業經營者已經在系統中配置好了相應的會員折扣 |
后置條件 | 注冊成功,贈送積分和折扣券 |
正常場景 | (1)系統顯示“注冊”選項。 (2)用戶選擇“注冊”選項。 (3)系統顯示注冊頁面,請求用戶輸入必要的信息,例如姓名、電話號碼、電子郵件等。 (4)用戶輸入所有必要的信息。 (5)系統驗證用戶信息的有效性。異常場景處理參考“異常場景一”。 ?系統驗證用戶的手機號碼格式是否正確。 ?系統驗證用戶的電子郵件格式是否正確。 ?系統驗證用戶的手機號碼是否已被使用。 (6)若用戶提供的信息有效,則系統會保存該信息并生成唯一的用戶ID。 (7)系統向用戶發送一條短信驗證碼,以驗證用戶的手機號碼是否正確。異常場景處理參考“異常場景二”。 (8)用戶輸入收到的短信驗證碼,確認自己的手機號碼正確。 (9)在用戶完成驗證后,系統發送歡迎消息,并提供給用戶其賬戶信息和相關優惠信息 |
異常場景 | 異常場景一如下。 (1)若用戶提供的信息無效,則系統會顯示錯誤的消息,并請求用戶重新輸入必要的信息。 (2)若用戶輸入無效信息超過5次,則系統會提示用戶嘗試其他注冊方式。 異常場景二如下。 (1)若手機號碼驗證失敗,則系統會提示用戶檢查其手機號碼是否正確。 (2)若用戶無法收到短信驗證碼,則系統會提供重新發送驗證碼的選項。 (3)若用戶仍然無法收到短信驗證碼,則系統會提供其他驗證方式,比如郵箱驗證等。 (4)若短信驗證碼過期,則系統會提示用戶重新發送短信驗證碼 |
特殊需求 | 個人信息安全,需要對用戶的手機號碼等做脫敏處理后存儲 |
風險預估 | 無 |
其他 | 討論項:一個人是否可以辦理多張會員卡 |
以上詳細用例展示了用例作為一種簡潔、高效的結構化需求分析工具的價值。在梳理用戶需求時,我們往往更關注正常場景,容易忽視異常場景。因此,在完成詳細用例的編寫后,我們必須重點討論和評審其是否全面覆蓋了所有異常場景。
3.領域模型設計和提煉
完成用例梳理后,很多人可能會直接進入系統設計階段。通常的做法是先設計數據庫表結構和字段,接著根據前端界面和交互需求定義接口及其參數,最后依據流程圖用代碼逐個實現這些接口。這種基于數據庫的開發方式被稱為“事務腳本模式”。這里我們不深入探討事務腳本模式的潛在問題,而是著重介紹如何利用前面梳理的用例來設計和提煉領域模型。領域模型是對領域內概念類或現實世界對象的可視化表示。
首先:領域和子域劃分
通過與業務專家溝通,采用歸納法將功能相近的用例歸納并提煉共性。例如,將用戶和收銀員的注冊會員、查看會員信息等功能歸納為會員管理域;將會員卡儲值、核銷會員權益等功能歸納為會員卡權益管理域;將用戶參加促銷活動、企業配置活動規則等功能歸納為營銷活動管理域。這些領域劃分結果如圖12所示。
圖12 會員系統領域劃分
對領域的劃分其實在與業務專家的溝通過程中就能很清晰地判斷出來,通過用例歸納法大多是對子域進行細化和驗證。而對于各個領域,我們還可以進一步細化。例如,對于營銷活動管理領域,根據用戶和企業經營者職責的不同,可以將其繼續劃分為活動規則配置、券與禮品配置、營銷活動報表三個子域,如圖13所示。
圖13營銷活動管理領域的子域劃分
其次:尋找領域對象
劃分領域是宏觀層面的業務垂直切分,他在DDD中是包含在戰略建模范圍中的,并且領域還可以很好地幫助我們劃分微服務。但是只劃分好微服務不是我們的目的,我們希望能更好地開發出可讀性高、易維護和易推展的代碼。而面向對象編程思想中,最核心的就是如何合理的劃分對領域對象,也就是說面向對象分析的精髓就是從領域到重要概念和對象的分解。
那么如何找到領域中重要的概念和對象呢?這里主要參考Craig Larman的《UML和模式應用》書中的三種方法:
1.重用和修改現有的模型。因為在許多常見的領域中都存在已發布的、繪制精細的領域模型和數據模型,比如像RBAC(Role-Based Access Control)權限管理模型等。這里推薦Martin Fowler的《分析模式》一書,在該書中總結了很多常用通常的領域模型。
2.使用分類列表方式。該方式有興趣可以去研究學習下,這里不過多介紹。
3.通過識別名詞短語尋找概念類,又稱為用例建模法。該方式是我重點推薦的方式,我們可以通過對所有詳細用例的文本進行分析,識別出其中的關鍵名詞和名詞短語,將其作為候選的概念類或屬性。如下圖所示步驟:
圖14 用例建模法
名詞短語法可以比較容易地找出領域對象和對象之間的關系,從而提煉出領域模型,具體可以采用以下步驟來提煉領域模型。
- 從用例集中找出名詞短語。舉例:不同等級的會員卡優惠不一樣,這句話中的名詞短語有“等級”和“會員卡”。
- 根據名詞短語梳理領域對象或屬性。舉例:前文中的“會員卡”可以抽象為領域對象,而像“等級”可能就只是會員卡對象中的一個屬性值了。
- 從用例集中找出動詞和形容詞。舉例:一個人可以辦理多張會員卡。其中“辦理”為動詞。
- 根據動詞和形容梳理領域對象之間的關系。舉例:前文中提到的動詞短語“辦理多張會員卡”,可以推測出“會員”對象和“會員卡”之間存在一對多的關系。
通過以上步驟,最后我們繪制出的會員領域模型如下圖所示:
圖14 用例建模法
說到這里,可能會有人覺得架構師只是寫PPT和文檔、講理論,其實不然。架構師不僅要會畫圖,更要寫代碼,而且代碼要能精準體現設計的領域模型。接下來,讓我們進入大家都很熟悉的代碼實現環節。
4.戰略設計:水平和垂直劃分服務或模塊
我們需要先把架構設計做好水平和垂直劃分,對應于DDD中提出的戰略設計部分,他是架構設計中的核心穩定的主體結構,就像設計房屋大廈中的主體結構一樣,一經設計,未來如果需要大變動,那么將會付出非常大的代價的。在軟件的架構設計中,這個主體主要包括微服務如何劃分,各服務之間的依賴關系是什么樣的,以及各微服務內部的層次結構是什么樣的。在會員系統項目中,各微服務和模塊設計如下圖所示:
圖16 會員系統邏輯架構圖
而微服務內部層次結構按照DDD的四層結構劃分的。分層架構的一個重要原則是每層只能與位于其下方的層發生耦合。分層架構可以簡單分為兩種,即嚴格分層架構和松散分層架構。在嚴格分層架構中,某層只能與位于其直接下方的層發生耦合,而在松散分層架構中,則允許某層與它的任意下方層發生耦合。我們采用的是DDD的松散分層架構圖如下。
圖17 DDD架構分層
其中各層介紹如下:
- 用戶接口層(User Interface):這是用戶與系統進行交互的界面層,是系統的最外層,負責處理用戶輸入和展示系統輸出。
- 應用層(Application):負責展現層與領域層之間的協調,協調業務對象來執行特定的應用程序任務。它不包含業務邏輯,所以相對來說是較“薄”的一層。
- 領域層(Domain):負責表達業務概念,實現全部業務邏輯并且通過各種校驗手段保證業務正確性,是最核心關鍵的部分。而什么是業務邏輯呢?它包括業務的流程、策略、規則、狀態、以及完整性約束等,所以領域層是較“胖”的一層。
- 基礎設施層(Infrastructure):這一層是系統的支撐層,提供了系統運行所需的基礎服務和設施,為其他層的實現提供技術支撐。
其對應會員系統工程代碼結構如下圖所示:
圖18 會員系統工程結構圖
5.戰術設計:使用DDD工具實現領域模型
在前文中介紹了如何根據用例來分析和劃分領域和子域,根據領域和子域創建了微服務和服務內的模塊,并且提煉了對應領域的領域模型,接下來將根據核心詳細用例繼續設計系統內部的具體實現邏輯。根據詳細用例,我們可以找到用戶和系統之間的交互關系。在會員系統中,根據會員注冊這個詳細用例,我們采用UML時序圖來設計用戶和系統之間的交互關系,如下圖所示:
圖19 會員注冊交互圖
在上圖中只反映了用戶與系統之間的交互關系。因為用戶只關心如何使用系統達到自己的目的,所以我們在前期優先設計用戶與系統之間的交互關系,而且此時系統實現對于用戶來說還是“黑盒”,后續可以繼續對該“黑盒”(新會員注冊對象NewMemberRegister)進行完善。在這里,用戶和新會員注冊對象之間的兩次交互對應分層架構中的接口層(可能有兩個接口),而新會員注冊對象對應分層架構中的應用層。為了實現用戶與系統之間的兩次交互,在應用層的新會員注冊對象中就需要具備對應的業務流程,對于其詳細的業務流程,可以繼續使用UML流程圖或時序圖來完善設計。這里使用UML時序圖來完善新用戶注冊的詳細業務流程,下圖所示為關于新用戶注冊的業務流程時序圖。
在通過UML時序圖和流程圖完成“新會員注冊”用例對應的系統設計后,接下來我們將使用DDD中提供的戰術設計工具來設計高內聚和低耦合的具體代碼。Eric Evans在他最經典的《領域驅動設計:軟件核心復雜性應對之道》一書中提供了以下具體的工具來實現這一目標。
- 實體(Entity):實體是一個不由自身屬性定義而由它自身身份定義的對象,它是具有狀態和行為的。實體對象具有唯一性并且是可持續變化的,也就是說在實體的生命周期內,無論其如何變化,其仍舊是同一個實體。它的唯一性由唯一的身份標識來決定的,而它的可變性也正反映了實體本身的狀態和行為。
- 值對象(Value Object):只包含元素屬性的不可變對象。值對象是將一個值用對象的方式來表述,進而表達一個具體的固定不變的概念。比如某個地址(Address)對象,它不用唯一身份標識id來決定它的唯一性,它只用通過固定不變的概念來表示一個具體的地址就好。
- 應用服務(Application Service),是用來表達用戶故事(User Story)和用例(User Case)的主要手段。應用層通過應用服務接口來暴露系統的全部功能。在應用服務的實現中,它負責編排和轉發,它將要實現的功能委托給一個或多個領域對象來實現,它本身只負責處理業務用例的執行順序以及結果的拼裝。通過這樣的方式能很好的隱藏領域層的復雜性及其內部實現機制。應用層除了定義應用服務之外,在該層我們可以進行安全認證,權限校驗,持久化事務控制,調用外部系統或者向其他系統發送事件消息等。另外,應用層作為展示層與領域層的橋梁,展示層使用VO(視圖模型)進行界面展示,與應用層通過DTO(數據傳輸對象)進行數據交互,從而達到展示層與DO(領域對象)解耦的目的。
- 領域服務(Domain Service),當領域中的某個操作過程或轉換過程不是實體或值對象的職責時(比如跨多個領域對象的操作),我們便應該將該操作放在一個單獨的接口中,即領域服務。領域服務是用來協調領域對象完成某個操作,用來處理業務邏輯的,它本身是一個行為,所以是無狀態的,狀態由領域對象(具有狀態和行為)保存。
- 模塊(Module):是指提供特定功能的相對獨立的單元,也就是對功能的分解和組合。模塊的用途是通過分解領域模型為不同的模塊,以降低領域模型的復雜性,提高領域模型的可讀性。
- 聚合(Aggregate):聚合是由聚合根(ROOT ENTITY) 綁定在一起的對象的集合,是領域對象的顯示分組,來表達整體的概念(也可以是單一的領域對象),它的宗旨是為了支持領域模型的行為和不變性,同時充當一致性和事務性邊界。聚合根通過禁止外部對象對其成員的引用來保證在聚合內進行的更改是一致性的,所以它的難點一般在于一致性的維護上:聚合內實現事務一致性,聚合外實現最終一致性。
- 工廠(Factory):工廠是用來封裝對象創建所必需的知識,它們對創建聚合特別有用。一個對象的創建可能是它自身的主要操作,但是復雜的組裝操作不應該成為被創建對象的職責,因為組裝這樣的職責會產生笨拙的設計,也很難讓人理解。而工廠可以幫助封裝復雜對象的創建過程,并且當聚合根建立時,所有聚合包含的對象也隨之建立了,整個過程是又是原子化的。
- 倉儲(Repository):倉儲是對聚合的管理,它介于領域模型和數據模型之間,主要用于聚合的持久化和檢索,同時對領域模型和數據模型進行了隔離,以便我們關注于領域模型而不需要考慮如何進行持久化。
接下來使用這些領域驅動設計中提供的戰術建模工具來完成會員系統領域模型的代碼開發。
首先創建微服務的項目工程,然后在項目工程的領域層創建會員領域模型的領域對象,最后將各個領域對象按照不同的職責分配到對應的微服務中,如下圖所示。
圖21 會員系統工程結構圖
在上圖中創建了會員(Member)對象與會員卡(Card)對象等領域對象,并且會員對象與會員卡對象為一對多的關系。在具體的實現過程中,代碼必須真實地反映領域模型,若在代碼實現中發現之前設計的領域模型不合理,就需要及時地調整設計中的模型結構,盡量保證模型和代碼始終一致。在創建領域實體對象后,我們還需要在應用層實現業務流程,并調用相應的領域對象來完成業務邏輯處理。會員系統案例中會員注冊用例場景的業務流程編排邏輯代碼示例如下:
public class NewMemberRegister
{
@Resource
private RegisterRepository registerRepo;
/**
*
會員注冊業務流程實現
* @param memberInfoDTO
注冊信息
* @return
注冊結果
*/
public RegisterResultDTO registerMember(MemberInfoDTO memberInfoDTO)
{
RegisterResultDTO resultDTO = null;
/
/
第
1
步,判斷用戶是否已是會員
boolean isExist = registerRepo.isMemberExist(memberInfoDTO.getPhone());
if(isExist)
{
resultDTO = new RegisterResultDTO(RegisterConst.MEMBER_EXIST,RegisterConst.MEMBER_EXIST_MSG);
return resultDTO;
}
//
第
2
步,創建會員聚合對象
Member member = MemberFactory.createMember(memberInfoDTO);
//
第
3
步,執行注冊會員業務邏輯。例如,根據儲值金額的不同,開通不同等級的會員卡
boolean isSuccess = member.applyMemberCard(memberInfoDTO.getMoney());
if(!isSuccess)
{
resultDTO = new RegisterResultDTO(RegisterConst.MEMBER_APPLY,RegisterConst.MEMBER_APPLY_MSG);
return resultDTO;
}
//
第
4
步,持久化聚合根數據
boolean isSave = registerRepo.saveMember(member);
if(!isSave)
{
resultDTO = new RegisterResultDTO(RegisterConst.MEMBER_SAVE_ERROR,RegisterConst.MEMBER_SAVE_MSG);
return resultDTO;
}
//
第
5
步,返回會員卡辦理成功的消息
resultDTO = assembleRegisterResultDTO(member);
return resultDTO;
}
private RegisterResultDTO assembleRegisterResultDTO(Member member)
{
//
把領域對象轉換為傳輸對象
DTO
,此處省略代碼
return resultDTO;
}
}
該實現代碼對應時序設計圖(圖20),主要在應用層的 NewMemberRegister 對象的registerMember()方法中實現會員注冊的業務流程編排。對領域對象的創建則由具體的工廠類實現,因為會員實體和會員卡實體為一對多的關系,所以這里使用了單獨的工廠類(MemberFactory)來實現,同時,會員實體充當聚合根對象。代碼示例如下:
public class MemberFactory
{
/**
*
工廠方法,默認綁定一張會員卡,若創建多張會員卡,則請使用
createMoreMember()
工廠方法
* @param memberInfoDTO
* @return Member
對象
*/
public static Member createMember(MemberInfoDTO memberInfoDTO)
{
//
創建
Member
對象,并為其賦值
Member member = new Member();
//
省略
Member
賦值代碼
Card card = new Card();
//
省略
Card
賦值代碼
//
綁定一張會員卡
member.bindCard(card);
return member;
}
}
具體的會員注冊業務邏輯主要被封裝在Member和Card實體中。例如,在新用戶注冊場景中,根據儲值金額開通不同等級的會員卡并為之賦予相應權益的業務邏輯,對應的實現代碼被封裝在Member實體中。代碼示例如下:
public class Member
{
/*
電話號碼
*
/
private String phone;
/*
姓名
*/
private String name;
/*
會員卡列表
*
/
private List<Card> cards;
/**
*
新會員注冊業務邏輯,根據用戶儲值金額的不同,開通不同等級的會員卡
* @param money
* @return
*/
public boolean applyMemberCard(int money)
{
switch (money)
{
case CardLevelConst.LEVEL1_MONEY :
//
普通卡,設置普通卡權益,例如打
9
折
//
此處省略代碼
break;
case CardLevelConst.LEVEL2_MONEY:
//
金卡,設置金卡權益。例如,打
88
折,同時贈送一張
10
元代金券
//
此處省略代碼
break;
case CardLevelConst.LEVEL3_MONEY:
//
黑金卡,設置黑金卡權益。例如,打
6
折,同時贈送一張
50
元代金券
//
此處省略代碼
break;
default:
//
不符合辦卡條件
return false;
}
return true;
}
/**
*
為會員創建一張會員卡片,并將其與會員綁定
* @param card
*/
public void bindCard(Card card)
{
if(CollectionUtils.isEmpty(cards))
{
cards = new ArrayList<>();
}
cards.add(card);
}
}
對實體對象的持久化操作,則采用RegisterRepository實現,從而使領域對象和數據庫操作解耦,也保證了聚合根內對象的數據一致性。代碼如下:
public class RegisterRepositoryImpl implements RegisterRepository
{
private MemberMapper memberMapper; // MyBatis
的
mapper
對象
private CardMapper cardMapper;
// MyBatis
的
mapper
對象
@Override
public boolean isMemberExist(String phone)
{
return memberMapper.findByPhone(phone);
}
@Override
public boolean saveMember(Member member)
{
MemberPO memberPO = assembleMemberPO(member);
List<CardPO> cardPOList = assembleCardPO(member.getCards());
//
在同一個事務中保存
MemberPO
和
CardPO
數據
memberMapper.save(memberPO);
cardMapper.saveAll(cardPOList);
return false;
}
private MemberPO assembleMemberPO(Member member)
{
//
該方法把領域對象轉換為數據庫存儲對象,此處省略代碼
}
private List<CardPO> assembleCardPO(List<Card> cards)
{
//
該方法把領域對象轉換為數據庫存儲對象,此處省略代碼
}
}
至此,我們利用 DDD 工具實現了會員系統中的會員注冊用例。通過這種方式編寫的代碼,不僅整潔清晰,完整體現了設計模型,還能追溯到對應的需求分析,從而確保了實現與設計的一致性。
整潔架構實踐總結
本文在對歷史遺留系統問題深入分析后,找到問題的本質解,制定切實有效的解決方案。通過運用整潔架構設計理念,對歷史遺留系統進行全面的模型分析和架構重新設計,并借助DDD戰術工具實現代碼開發。在此過程中,整個團隊嚴格保持統一的代碼風格,從而使歷史遺留系統煥然一新,變得整潔、清晰且易于維護。這不僅顯著提升了系統的可讀性和可擴展性,還為未來的功能開發和系統升級奠定了堅實基礎。