基礎問題不簡單 | 怎麼合理使用值物件,讓你的程式碼更清晰、更安全?

韓楠發表於2022-07-06




寫在前面:


你好,今天我想與你聊聊如何在Go語言中落 地DDD。這部分內容,預計三分鐘左右可看完。就此,大體上了解下就行。


這裡面,其實主要說了我是基於什麼問題背景和思考,才作此分享的,你可以更有針對性 地結合具體的業務問題來思考。


尤其是實踐過程中的業務難點等,後面我將帶著大家, 最終交付一套Go語言層面切實可行的 比較完整且具有實際指導意義的方法論,助力解決實際業務中的開發問題。


正文部分,我打算分成9講與大家共同探討,並於後續幾周陸續釋出,預計每週可以看1~2兩篇。今天釋出的是前言,以及第一講(其開頭也有放前言部分,若看了這部分, 可直接跳過下拉至正文部分看)。


好,就先提這些。我們言歸正傳,接著聊。


DDD中文又叫領域驅動設計,是我們解決複雜業務問題時非常有效的一個手段,但其本身過於陡峭的學習曲線,也讓很多初學者知難而退。


網上雖然充斥著很多關於DDD的學習資料,但 大多隻是偏向於基本概念的介紹,缺少一個完整的落地實踐。


筆者所在的技術團隊也曾做過一些戰略設計,通過這些設計過程,很多同學對DDD有了更深入的瞭解。


在戰略設計後,我們識別出了很多的值物件、實體、領域事件等等元素,但仍然讓大家比較困惑的是,這眾多的領域元素要如何跟具體的程式碼對應起來呢?如果做不到程式碼跟領域模型的同步更迭,那麼DDD對於開發的意義又是什麼呢?


我第一次接觸DDD應該是在2014年,這一年正好是《實現領域驅動設計》一書在國內出版,依稀還記得當時公司群裡異常興奮的討論。


從 03 年 Eric Evans 的 Domain Driven Design 到14年 Vaughn Vernon 的 Implementing DDD, 整整過去了10多年的時間,業界才算有了一個真正意義上的指導DDD落地的思想。


IDDD一書出版的時候,也正是Java語言大行其道的時候,再加上書中的一些程式碼示例也是基於Java的,這就造成了人們的認知一度認為只有Java才是最適合實踐DDD的。


近些年來,Go語言被越來越多的公司及個人採納,很多人也開始了在Go語言中落地 DDD 的嘗試。


但其實,DDD本身只是一種思想,就像耗子叔曾經在《從物件導向的設計模式看軟體設計》一文中提到的,說起設計模式也並非就一定是OO的。


因此,實踐DDD其實無所謂使用什麼語言,但同時我們也要看到Go與Java在語言層面的差異,一些實現細節就必須做出調整。


是否有一套切實可行的程式碼結構與規範,來指導實際業務中的開發呢? 遺憾的是,目前在 網上貌似是找不到在Go語言層面 比較完整且具有實際指導意義的資料。


所以從今天開始,我會通過一系列的文章,來介紹DDD如何在Go語言中落地。希望 通過儘量系統且詳細的描述,來降低你實踐DDD的門檻,同時 能夠 幫助你解決一些在實際開發中可能遇到的問題。


最後,我還會 以一個虛構的系統作為案例,通過對這個Demo的講解,幫助你更徹底地掌握DDD的核心思想以及落地操作。


在具體的內容安排上,未來會涵蓋下圖中的一些主題。 現在你也可以停下一兩分鐘看看框架圖裡,你更為感興趣的 部分,在此先道聲感謝,謝謝你對本系列內容的關注與支援,期待我們留言區裡可以進一步交流:

不過在這之前,因為本系列文章的重點,不在於對DDD相關概念的講解,以及戰略建 模的分享。

因此,在繼續閱讀後續的內容前,你最好對DDD有一些基本的瞭解,如果還不清楚,建議可以在網上先搜尋一些資料閱讀。

好,前言部分,我們就先聊到這裡,非常感謝你耐心的閱讀。



責編 | 韓楠

約 4265 字 | 8 分鐘閱讀







 以下,Enjoy~ 



接下來,就讓我們從最簡單也是最基礎的領域元素 - 值物件說起吧。

正式說值物件之前,我們先來思考這樣一個問題, 什麼樣的程式碼算是好程式碼呢?

相信大家或多或少,都接手過一些遺留的業務系統,這些系統的程式碼大多都能正常工作,或許執行的還是某個關鍵業務。

某一天,你接到了一個產品需求,經過評估,你認為只需改動很少的幾行程式碼就可以實現。 於是,你很快就完成了開發,並進行了充分的自測,你認為肯定不會出錯。 但不幸的是,上線後還是引發了事故。  

剛提到的場景,相信對於很多做業務的同學都深有感觸。

這種問題之所以常見,很多時候是因為我們的程式碼不夠清晰,沒有很好地表達業務,看程式碼的人就只能靠猜。比如我們定義一個註冊的方法,需要用到使用者名稱、手機號和密碼,很多同學可能會將這個方法定義成這個樣 子:func Register(string, string, string) User。

那麼問題就來了,作為使用方,三個引數要按什麼順序傳入呢? 這也是程式碼不夠清晰的表現。

在DDD中, 值物件通過將相關聯的屬性組合在一起,構成了一個完整的概念整體,同時使用業務域中的統一語言,可以將一些隱性的概念顯性化。 正確地使用值物件進行建模,不但能夠大幅地提升程式碼的清晰度、可讀性,在一定程度上也會降低系統出錯的概率。


01   先從怎麼理解值物件說起

值物件本質上就是一個屬性的集合,但這些屬性並不是隨便湊到一起的,它們通常是為了某個共同的目的或概 念而存在著。

除此以外,值物件還具有下面兩個顯著的特點:

•  不變性 ,值物件在建立出來後是不應該被修改的,如果必須修改物件的某個屬性,則需要整體替換成一個新的值物件;


•  無身份標識 ,這也是與實體相比非常重要的一個不同點。缺少了唯一標識,怎麼判斷兩個值物件是否相等呢?就要看它們所包含的屬性是否全部都是相等的了。這就好比我們在現實生活中使用現金,我們關心的只是貨幣的面值,是100的還是50的,而不會關心這個紙幣的編號是多少。

同時,值物件應當具有一定的行為,但同時也要避免過於複雜。


02   實現值物件


▶︎   比較嚴謹(教條)的實現方案

我們以一個描述貨幣 價值的值物件為例,來看下程式碼:

在這個例子中,有這麼幾點需要特別注意:

•  值物件需要使用大駝峰,也就是這個 MonetaryValue 應該是全域性可見的,這樣一來在領域層之外也是可以訪問的。


•  值物件裡的各成員應該小寫,這樣做有兩方面的原因:

a.一方面可以避免在包外對屬性值的直接修改。假設有個更新數額的操作,因為沒法直接在原值上賦值,例如這樣的程式碼val.amount = 2 也就行不通了。這麼做的好處是可以避免錯誤的賦值導致的一些問題。

b.另一方面,在包外雖然可以對 MonetaryValue 例項化,但是因為成員不能賦值,也就強迫了使用者必須呼叫 NewMonetaryValue 方法,從而避免了構造出一個不符合規範的物件


•  NewMonetaryValue 是一個工廠函式,並一次性傳入構建該值物件所需的所有引數,在這個函式中可以對引數進行合法性校驗,這樣保證了所有建立出來的物件都是合法的。


•  包外如果有對值物件內部成員進行訪問的需要,可以定義一個同名的、採用大駝峰定義的方法,比如這裡的 Amount 方法。

種實現方式,雖然嚴格滿足了值物件的一些要求,但是在某些情況下使用起來就不太方便了。

比如,如果我們需要對這個值物件進行json序列化與反序列化。這個時候,因為所有的成員都是未匯出的,會導致 Golang 預設的 json 庫直接將其忽略,這明顯與我們的期望是不符的。

因此, 我們在進行編碼的時候,還需要充分考慮實現的成本, 例如上述需要序列化的場景,直接定義成如下形式可能更合適些:

▶︎   謹慎(最好不)持有指標、slice等型別

在一個值物件中可能持有另外的值物件,比如這裡的 Currency,雖然是作為一個列舉使用,但本質上仍然是一個值物件。

當值物件中包含了其他非基本型別(例如指標、struct、slice、map等)的屬性時,就要特別注意了,即使我們在值物件裡沒有對這類成員進行修改,但是仍然無法保證外部不會修改它們的值。

看下面的例子:

雖然 BadDemo 在內部沒有任何方法對 s 進行增刪操作,但是,如果外部對 ss 進行了修改,比如 ss[0] = "abcd",同樣會反應到 s 上。

這樣一來也就破壞了值物件的不變性, 這種破壞性,有的時候可能會給你帶來不可預知的Bug,並且不是特容易發覺。

▶︎   通過新建來對值物件進行修改

值物件也是能夠擁 有一些行為的,比如上面的 MonetaryValue,可能有一個Add 方法:

這裡的 Add 方法跟我們通常的實現可能不太一樣,主要在於方法的返回值。

值物件因為要保證不變性,因此, 我們不能直接對值物件的內部屬性進行修改,而是採用了新建一個值物件的方式。

另外,Add 方法的接收者是一個值接收者,而非指標接收者,使用指標接收者的問題在於可能不小心就修改了內部屬性,而造成一些隱式的錯誤。當然這裡也可以不用這麼絕對,對於大型的值物件,如果使用值接收者會帶來一定的效能損耗,這個時候也可以使用指標接收者。

▶︎   為什麼要保證值物件的不變性

費了這麼大勁,又是限制接收者型別,又是強調必須返回一個新的值物件,為什麼呢? 

我們考慮下面這個場景。

比如說,我們現在在開發一個多人遊戲,每個人在一開始的時候都有固定的等值籌碼,隨著遊戲的進行,你可能花費一些籌碼,又或者賺取一些籌碼。

我們在初始化籌碼時,程式碼可能如下:

之後 player A 通過賣出裝備而賺取了更多籌碼,假設說這裡是按照直接修改值物件內部屬性的方式來實現:

那麼問題就出現了,我們雖然只給 player A 增加了籌碼,但是發現所有 player 都莫名多了10籌碼。

一起來思考下,問題的原因是什麼,其實就在於,我們共享了這個值物件,但是沒有保證它的不可變性。而返回新的值物件的方式,則不存在這個問題:


03   實現列舉


列舉,通常被認為是值物件的一種特化形式,它也屬於領域中的元素。

▶︎   以值物件的形式來實現列舉

比如有一個叫 SomeStatus 的列舉,對應有兩個列舉值 SomeStatusOne 和 SomeStatusTwo, 那麼可以採用如下的形式來定義:

上述程式碼大部分都遵循了值物件的實現方法,但是也有兩點不同:

•  定義一個預設的零值,並提供了一個判斷當前列舉是否為零值的方法。零值的作用,是保證在使用到列舉的地方都不會有 nil 的出現,這種保證可以一定程度地避免程式 panic 的發生。

•  所有的列舉值,放到一個 Slice 或 Map 中,便於在建立列舉時進行合法性校驗。

可以看到,這種實現方式還是比較麻煩,但是能夠很大程度地在程式碼層面 保證程式的正確性。而且,列舉值的變動頻率一般都不會太高,所以成本也僅僅是一次性的。

▶︎   使用原始型別表示列舉

另外一種偷懶的形式,類似下面這種:

跟上面值物件的形式相比,的確是簡單了不少,但是 最大問題在於程式碼的不可控性

比如下面這樣一個函式:

UpdateStatus 方法接收一個 AnyStatus 型別的引數,之所以這裡是一個 AnyStatus 型別而不是一個 int 型別,大概率是希望呼叫者 能傳遞一個合法的 AnyStatus 進來,但是這一點是得不到保證的。

呼叫方可以傳入任意一個整數值,而編譯器並不會報錯:

如果希望程式碼足夠的嚴謹,那麼在 UpdateStatus 方法內部就不得不對傳入的值進行校驗。這種校驗邏輯也會散落到所有用到 AnyStatus 的地方。

另外,從全域性來看,任何人在任何地方,都可以直接呼叫類似這樣的程式碼 AnyStatus(6) 來生成一個列舉值。很顯然,這是一個無效的列舉值,也是一個完全不應該存在於領域的物件。

綜合看前面兩種方式,各有優缺,可以根據各自的情況,在團隊內大家做到統一即可。


04   總結


今天,我向你介紹了值物件在Go語言中的實現方式,以及如何正確地定義列舉。

值物件這個概念並不複雜,但是在實現的過程中涉及到的細節會比較多,這也跟它自身的一些特徵是分不開的。

值物件最重要的特徵是不變性,我們所有的實現,都是圍繞這一點展開的。也正因為這種不變性,讓我們在業務中可以放心地對其進行復用。

當你在決定一個領域概念 是否要建模成一個值物件時,就要考慮是否具有 下面的一些特徵:

在程式碼落地的過程中,你還要注意:

•  值物件的建立必須通過一個簡單的工廠函式來實現,這樣可以避免不符合業務約束的對 象產生;

•  對值物件的修改不能直接修改屬性,需要構造一個新的值物件出來;

•  值物件裡的成員最好不要包含Map、Slice、指標等結構,否則值物件的不變性可能會遭到破壞;

•  如果需要比較兩個值物件,可以定義一個 Equals 函式,在函式裡判斷是否所有屬性都相等;

•  Golang 裡的一些原始型別,比如 int64、string 等,都可以看做是最簡單的值物件來使用。


如果說,是一磚一瓦構建起了高樓和大廈,那麼,值物件就是DDD裡的磚和瓦。因此,只有保證了值物件實現的正確性,才能在更高的維度去構建實體、聚合根等領域元素。

▶︎   延伸思考

最後,留給你一個思考題。

為了程式碼足夠健壯,我們將值物件裡的屬性設計成了未匯出的,當值物件需要持久化到資料庫時,該如何做呢?從資料庫中讀出資料要重建值物件,又要怎麼做呢?

話不多說,下一節,繼續和大家聊聊如何實現實體、聚合根。

這一篇,到這裡我們就要結束了,非常感謝你耐心的閱讀。

很期待我與你能夠有更多思想上的共鳴、碰撞。如果願意分享,這一講也歡迎轉發給你的朋友,和他一起討論。

同時,若你對這一講的內容,有相關思考或是疑問,可以多多提出來,留言區裡,我們多交流。 後續分享再見。




THE END 

轉載請聯絡ITPUB官方公眾號獲得授權

—————————————————————————————————

歡迎各領域技術人員投稿

投稿郵箱 |   hannan@it168.com






來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70016482/viewspace-2904545/,如需轉載,請註明出處,否則將追究法律責任。

相關文章