前面介紹了Util是如何封裝以降低Angular應用的開發成本。 現在把關註點移到服務端,本文將介紹分層架構各構造塊及基類,並對不同層次的開發人員應如何進行業務開發提供一些建議。 Util分層架構介紹 為了控制業務邏輯複雜性,Util引入了DDD分層架構,這意味著如果你想使用DDD,Util會為你提 ...
前面介紹了Util是如何封裝以降低Angular應用的開發成本。
現在把關註點移到服務端,本文將介紹分層架構各構造塊及基類,並對不同層次的開發人員應如何進行業務開發提供一些建議。
Util分層架構介紹
為了控制業務邏輯複雜性,Util引入了DDD分層架構,這意味著如果你想使用DDD,Util會為你提供一些基礎設施幫助。
如果你對DDD不感興趣,同樣可以使用你所熟悉的類似三層架構等方式來編碼,這並不會造成影響。
表現層構造塊
由於目前的趨勢是前後端分離,所以這裡的表現層是指Api這一層,頁面操作前面已經討論過了。
Api控制器應該足夠簡單,將請求委托給應用服務,並把應用服務返回的結果發回客戶端。
表現層的職責
- 收集數據
- 響應結果
控制器基類
- WebApiControllerBase
Api控制器頂級基類,所有派生控制器將具備異常處理,錯誤日誌,跟蹤日誌等功能。
提供Success方法,以特定格式為客戶端響應成功消息。
提供Fail方法,以特定格式為客戶端響應失敗消息。
將路由設置為標準約定:api/[controller]。
- QueryControllerBase
查詢控制器基類,配合查詢服務,用於解決常規查詢需求,可通過Id進行單個對象查找,也可查找列表或分頁列表結果集。
對於更複雜的查詢需求,應直接從WebApiControllerBase派生,手工編碼將會更加靈活。
- CrudControllerBase
Crud控制器基類,配合Crud服務,用於解決簡單的單表增刪改查需求。
對於有一定業務邏輯的操作,特別是多表操作,不建議使用該基類。
- TreeControllerBase
樹型操作控制器基類,配合樹型服務,用於解決常規樹型操作需求。
對於不同的樹型控制項,可能具有一些差別,需要為該控制項提供專用控制器,比如PrimeNg的樹型表格使用PrimeTreeControllerBase。
樹型控制器的抽象還需要增強,我會在接下來Ng-Zorro的封裝中進一步完善這個基類。
過濾器
- 跟蹤日誌過濾器 – TraceLogAttribute
這個過濾器會在每個請求的進入和退出時記錄跟蹤日誌,以方便你瞭解控制器是否被執行,以及相關參數。
WebApiControllerBase已經設置了該特性。
- 錯誤日誌過濾器 – ErrorLogAttribute
這個過濾器會在發生異常時記錄錯誤日誌。
WebApiControllerBase已經設置了該特性。
- 異常處理過濾器 – ExceptionHandlerAttribute
這個過濾器會在發生異常時設置返回的響應為特定消息格式,以方便客戶端進行處理。
WebApiControllerBase已經設置了該特性。
- 防止重覆提交過濾器 – AntiDuplicateRequestAttribute
如果你希望用戶提交表單時,不要在提交過程中重覆發送請求,一般的做法是使用客戶端腳本禁止他這樣做,另一個選擇是使用該過濾器,這提供了服務端的檢測,當你的客戶端腳本無法奏效時使用該過濾器。
使用該過濾器的一個限制是,你需要使用Identity或Identity Server登錄,從而能夠讓Util知道當前用戶的UserId,從而只對當前用戶進行檢測。
- Html生成過濾器 – HtmlAttribute
當你需要把Razor頁面的Html保存到一個靜態Html文件中時使用它。
它是Util Angular TagHelper封裝的基礎設施之一,這樣可以保證在發佈時可以通過AOT預編譯的方式來打包Angular應用。
應用層構造塊
應用層的職責
- 集成領域層和基礎設施層,為表現層提供簡單清晰的API
- 處理應用邏輯,比如事務控制,導出Excel等
應用服務介面及基類
- IService
應用服務頂級介面,這是一個空介面,它派生自IScopeDependency,這會導致所有應用服務介面和相關實現類的Ioc綁定關係被自動裝配,並且生命周期為每個請求一個實例。
- ServiceBase
應用服務頂級基類,它提供了日誌操作和當前用戶會話的屬性。
對於希望使用三層架構的同學,這個類就是你進行業務處理的主要場所。
針對某個應用場景,你用一個方法接收需要的參數,這個參數就是Dto。
你將在這個方法中處理所有業務邏輯。
這是一種面向過程的業務處理方法,稱為事務腳本。
事務腳本的優勢是不需要學習,是個人就能寫。
對於普通水平的團隊,事務腳本通常是最佳選擇。
使用事務腳本也可以寫出健壯的業務代碼,這裡的關鍵不是架構,而是重構。
如果你的方法很長,那麼你可以提取多個子方法,並且命名不要太隨意,代碼中不要有複雜的嵌套迴圈和嵌套條件,這對於普通業務通常就足夠了。
關於重構的心得,我會用一篇專文來分享。
- IQueryService
查詢服務介面,用於解決常規查詢需求,包括分頁查詢等。
- QueryServiceBase
查詢服務基類,預設採用Ef Core進行查詢。
你需要重寫CreateQuery方法,以提供查詢條件。我會在未來版本中提供高級查詢,自動解析客戶端設置的查詢條件,以省掉這個步驟。
對於更加複雜的查詢,你可以完全重寫介面上定義的方法,使用SqlQuery來完成查詢,這允許你使用Lambda和Sql字元串混合方式來編寫查詢,提供了更高的靈活性。
關於查詢相關的主題,我會在下一篇介紹。
一旦你發現IQueryService定義的方法與你的查詢需求不匹配時,請直接從IService和ServiceBase派生。
- ICrudService
Crud服務介面,它用來解決簡單的單表增刪改查需求。
- CrudServiceBase
Crud服務基類,它來自多年前的一個業務處理基類。
Crud就是增刪改查,當我們面向資料庫編程時,所有操作都是Crud。
但是當我們與客戶交流時,通常使用業務上的概念和術語進行討論。
那麼,我們代碼中的方法應該使用Add,Update這樣的名稱,還是業務術語,比如預訂Booking。顯而易見,業務名稱會更容易理解。
使用ICrudService和CrudServiceBase會形成Crud風格的Api,對於一些簡單場景沒有什麼問題,但對於複雜的場景會導致代碼機械化和混亂。
提供這個類的初衷是為了配合代碼生成器快速掃蕩簡單場景。
CrudServiceBase提供了大量虛方法供你重寫,比如CreateBeforeAsync,當這些簡單場景有一些額外邏輯時,可以重寫這些方法提供自定義邏輯。
當有業務邏輯需要處理時,應該直接從IService和ServiceBase派生,手工編碼,這通常會更快速和健壯,因為沒有什麼束縛著你。
如果你確實希望使用CrudServiceBase進行複雜的業務操作,也是有辦法的,這需要一些技巧,不過很容易把人引入歧途,所以不打算在這裡介紹,有需要可以在群里討論。
- ITreeService
樹型服務介面,用於解決常規樹型操作需求。
- TreeServiceBase
樹型服務基類。
應用服務的粒度
你可以在一個服務中包含大量的方法,比如一個模塊的所有方法,也可以在一個服務中只包含一個方法,僅處理一個場景。
這裡的技巧是根據方法複雜度來規劃。
對於一般的場景,將一個聚合相關的所有操作放在一個服務中。
如果足夠簡單,你也可以把一個模塊的所有操作放到一個服務中,這可以簡化客戶端操作,我曾經使用過這種方式,不過已經拋棄。
如果你的業務邏輯主要在應用服務中編寫,當業務場景比較複雜時,可以考慮讓這個服務僅包含一個操作,這樣可以獲得一定的清晰度。
應用服務開發註意事項
- 註意提交工作單元。
應用服務的一個核心任務是控制事務,當提交工作單元時,工作單元會發起一個資料庫事務,並把所有變更寫入資料庫。
提交工作單元的方式有兩種,一種方式是註入工作單元介面,並調用CommitAsync方法。另一種方式是使用UnitOfWorkAttribute特性。
使用UnitOfWorkAttribute特性方式提交工作單元,除了看上去更加高大上以外,主要一個好處是支持作用域,比如A服務調用了B服務的B1方法和C服務的C2方法,而B1和C2方法都提交了工作單元,那麼如果希望A服務能夠控制工作單元的提交,使用UnitOfWorkAttribute特性就可以做到,分別在每個方法上添加[UnitOfWork]特性,只有最外層的特性生效。
這是基於Ncc開源社區AspectCore這個AOP項目提供的作用域做到的。
ICrudService使用了UnitOfWorkAttribute特性,所以你可以通過組合多個Crud服務來開發更複雜的服務,但這並不是我推薦的方式,業務邏輯儘量手工編寫,這樣才能清晰可控。
對於上述兩種工作單元提交方式,我推薦顯式調用CommitAsync方法,原因是UnitOfWorkAttribute特性是在方法完成後提交,如果你要在提交之後進行某些操作,比如寫一個成功日誌,就會很麻煩。
ICommitAfter介面用於在使用UnitOfWorkAttribute特性提交之後進行處理,當然也可以使用發佈訂閱事件的方式進行處理。
應用服務討論
- 你應該在Api控制器中調用多個應用服務嗎?
在大多數情況下,不應該調用多個應用服務。
不過你有足夠的理由,並且控制器中代碼沒有變得很複雜,也是可以的。
- 可以將控制器和應用服務合併嗎?
經常聽到一些討論,控制器和應用服務都是很薄的一層,那麼是否應該進行合併。
如果你使用應用服務來編寫業務邏輯,那麼不應該進行合併。
如果你的業務邏輯高度內聚到領域層,是否進行合併則根據自己的喜好。
我總是保持控制器和應用服務的分離,雖然這兩個類都沒多少代碼,但職責更加清晰。
- 應用服務可以調用其它應用服務嗎?
可以,但儘量不要這樣做。
剛纔已經說到應用服務的方法會提交工作單元,調用其它應用服務並不是這麼方便。
調用其它應用服務是為了復用某些邏輯,你可以將業務邏輯抽取到領域服務中,將多個領域服務註入應用服務來複用邏輯。
註意:本文討論的都是單體應用,分散式應用不在討論範圍。
Dto(Data Transfer Object )
Dto是一種參數對象,它接收來自外界的數據,並傳遞到應用服務以供處理,當應用服務處理完畢,也會創建用於響應的數據對象,將結果返回調用端。
高大上的術語容易讓初學者膽寒,Dto就是其中之一。
我經常發現一些初學者搞不清楚Dto究竟是什麼東西,一個參數對象而已,但使用它確實有一些技巧。
Dto的粒度
雖然只是一個簡單的參數對象,但怎樣定義參數也需要經驗。
你可以把幾個表,或者說幾個實體的屬性塞進一個Dto參數對象中,這樣你在很多界面都可以復用它,這讓你能夠節省大量力氣,但損失了API清晰度。
如果你的同事使用Swagger這樣的工具來查看Api,看到參數對象上的幾十上百個屬性,會是什麼表情,他應該設置哪些屬性呢?
你可以為某個界面或介面創建專門的參數對象,他需要設置的參數屬性將一目瞭然的呈現出來,這時候Dto實際上承載了ViewModel的職責,這是一個專用的參數對象。
如果你為每個界面或介面創建專用Dto,那麼工作量也將翻N倍。
這裡的關鍵在於,你需要在Api清晰度和工作量之間進行平衡。
一個簡單的原則,如果這個參數對象是你自己使用,儘量粗粒度,省的是力氣。如果給你同事使用,創建細粒度的專用Dto,以免挨罵。
Dto的方向
如果Dto參數對象只用於請求,那麼我們可以約定參數名以Request結尾。
如果Dto參數對象只用於響應,那麼可以約定參數名以Response結尾。
如果Dto參數對象既用於請求,又用來響應,那麼可以約定參數名以Dto結尾。
這並不是什麼標準,只是慣用的一些命名約定。
Dto基類
一個參數對象還需要什麼基類?確實如此。
不過有時候需要進行一些泛型約束,這些介面約束主要在ICrudService中使用。
對於自定義服務,完全不需要它們。
- IRequest
代表請求參數。
繼承自IValidation,這意味著請求參數可以調用Validate方法進行驗證。
- RequestBase
請求參數基類,實現了Validate方法,這讓你可以直接驗證參數屬性上的DataAnnotations,比如[Required]。
Util引入的一個大坑是該類添加了[DataContract]特性,數據契約是WCF年代引入的,我一直沿用至今。
Json.Net支持這個特性,意味著該類中的所有屬性必須添加[DataMember]才能夠序列化到Json中。
Util代碼生成模板已經為Dto屬性正確添加了[DataMember]特性,但如果手工在Dto中添加屬性且沒有添加[DataMember]特性,就會導致客戶端無法接收該數據。
對於這個問題,我可能會在未來某個版本刪除Dto基類上的[DataContract]特性。
- IResponse
代表響應參數。
- IDto
代表請求和響應參數。
- DtoBase
繼承自RequestBase,並擁有一個Id屬性。
Dto討論
- 你真的需要Dto嗎?
一些開發人員告訴我,Dto太麻煩,而且他們的實體只有屬性,也就是個參數對象,沒必要再搞個Dto轉來轉去。
這似乎很有道理,對於簡單的應用,這可能是合適的。
但稍大一點的應用都包含前端,管理後端,App等客戶端,將實體作為參數並不方便,很多時候需要添加額外屬性,或根據界面定製參數,實體無法滿足需求,這種情況下Dto就是必須的。
如果使用充血的領域模型,對象的迴圈引用非常常見,比如實體與策略對象互相持有引用,又或者聚合根與內部實體互相持有引用,訂單Order與訂單項OrderItem就是一個例子,在這種情況下,直接使用實體作為參數將導致Json序列化失敗。
Dto在大多數情況下都是有價值的,應該作為項目標配。
- 你需要在Asp.Net Core Mvc項目中創建ViewModel對象嗎?
一般情況下不需要,Dto已經充當了ViewModel的職責。
不過如果你在某些情況下有需求,也是可以的,以自己方便為主。
查詢參數
你可能奇怪,怎麼查詢又弄了個專門的參數對象,使用Dto不行嗎?
如果你專門為查詢創建了定製的Dto,這當然可行,不過我們把名稱再調整一下,會讓這個參數對象的含義更加清晰。
Util對查詢參數的命名約定是以Query結尾,其他開發人員看見就知道這是一個查詢參數了。
與Dto相比,查詢參數有一些自己的特點:
- 不具有複雜的嵌套結構。Dto可能很複雜,內部可能包含其它的Dto,但查詢參數一定只包含簡單屬性,你需要什麼查詢條件就加上去。
- 另一方面,Util在查詢Api上提供了一個叫WhereIfNotEmpty的方法,能夠自動幫你判斷參數值是否為空值,如果是空值,這個條件不會添加上去,這省去了你的一個判斷。這就要求你的參數值必須可空,查詢參數對象的一個關鍵要求是所有屬性必須可空,否則你就不應該使用WhereIfNotEmpty方法。
領域層構造塊
當你使用事務腳本這種面向過程的方法來進行業務開發時,你的關註點主要是資料庫的表和欄位,你會想辦法向這些表和欄位填充數據,至於業務邏輯,你會胡亂的拼湊以實現功能。
面向對象的開發方法,通過尋找業務關鍵概念和操作,並將它們映射到代碼,這會讓代碼更加容易理解,看代碼就是聊業務。
業務概念大多變成實體,實體的職責決定它擁有哪些屬性和方法。
如果實體只包含屬性,沒有方法,那麼只是一個參數對象,沒多少用。
對於一個業務場景,通常由多個實體互相協作完成,這導致一個複雜的操作被多個對象分解,每一個部分都變得簡單。
協作的實體構成了領域模型,實體成為業務邏輯開發的中心。
實體內部可能包含其它實體和值對象,外層實體稱為聚合根,不論實體還是值對象,都是封裝核心業務邏輯的場所。
使用領域模型的方式開發業務邏輯,具有易理解,易復用,易測試等特點,不過它的毛病也不小,就是學習成本高,上手困難,普通開發團隊不打算下苦功夫看書,隨便找幾篇博客看看就輕易嘗試的結果只不過是三層變種,發揮不出什麼威力,不如直接三層來得簡單,這也是DDD在一般團隊無法落地的原因。
領域層的職責
- 所有業務邏輯都內聚到這裡。
對於沒打算下苦功夫的同學,忽略它,把你的業務邏輯直接寫到應用服務中。
領域層介面和基類
- DomainBase
領域對象頂級基類。
Validate等相關方法提供了領域對象的驗證機制。
GetChanges方法用於比較兩個領域對象的屬性變更情況,當需要瞭解某些屬性發生變化進行處理時使用,目前需要配合代碼生成器來檢測屬性變更。
ToString方法被重寫,用於輸出領域對象的狀態信息,通常用於日誌記錄。
- EntityBase
實體基類。
實體是具有標識的對象,它用來跟蹤某些事物的生命周期。
實體具有一個Id屬性,Id就是標識,它是只讀的,你只有在初始化構造函數時才能給它賦值,因為標識不能隨意修改。
Util在實體上添加了一個Init方法,它用來初始化這個實體,預設情況下會生成Guid初始化Id屬性,你也可以重寫這個方法添加自定義初始化操作,這個Init方法應該在添加到倉儲前調用,不要在修改時調用它。
如果你採用三層架構,那麼不需要這個構造。
- AggregateRoot
聚合根基類。
最外層的實體就是聚合根,它內部還可以包含其它實體和值對象。
不過建議初學者不要隨便將其它實體放到聚合根中,容易導致很多問題,比如由於內部實體沒有倉儲無法全局訪問導致業務操作困難,或性能低下,或經常併發衝突導致業務失敗,儘量保持聚合根小巧。
聚合根包含一個叫Version的屬性,它非常重要,它是用來做併發更新異常處理的樂觀離線鎖。
當兩個人同時編輯一條記錄時,第一個人提交了修改,第二個人的提交將覆蓋前面的修改,這將導致前面的數據丟失,這種情況稱為併發更新異常。
Ef Core提供了樂觀離線鎖來防止這種情況發生,你只需要在表中加上Version欄位,繼承AggregateRoot基類,Util會正確處理Ef Core對不同資料庫在樂觀離線鎖上的差異。
如果你採用三層架構,你的所有表都是聚合根,一個表對應一個聚合根。你也可以嘗試把一些方法寫在它裡面,或者完全只有屬性。
- ValueObjectBase
值對象基類。
值對象是沒有標識的領域對象。
值對象關註的是屬性特征,實體關註這個東西究竟是“誰”。
值對象將一些高度相關而分散的屬性打包成一個概念整體,並將相關的業務邏輯封裝起來。
值對象的一個最佳實踐是不變性,不可變性意味著值對象所有屬性的Set訪問器都被Private,要修改它的唯一方法是重新new一個,整體替換它。這樣做的好處是可以安全的在多個類或方法共用這個值對象。
不過在實踐中,不可變的值對象在表現層等位置使用不便,需要為它添加類似Dto的輔助參數對象,這進一步增加了工作量,有時候我會使用可變對象以減輕對象定義和轉換的工作。
如果你採用三層架構,不需要這個構造。
- TreeEntityBase
樹型實體基類。
樹型實體包含父標識ParentId和物化路徑Path屬性,用於操作樹型結構。
InitPath方法用來初始化物化路徑,物化路徑是”父Id,Id”這樣的格式。
GetParentIdsFromPath方法可以從物化路徑中將所有父標識提取出來。
註意,雖然命名為Entity,它其實是一個聚合根,不應該在它內部再加入其它實體,因為樹型已經很複雜了。
- IRepository
倉儲介面。
倉儲是聚合根的一個模擬集合。它用來對某個聚合根進行數據操作。
倉儲的價值是可用來封裝聚合根特定的數據操作,這要求你的倉儲必須是具體化的,而不是一個抽象的泛型介面或泛型基類。
當你進行複雜業務操作時,當有複雜的數據操作需求,可以在倉儲上定義特定的方法,從而減輕業務邏輯開發的負擔,並讓數據操作更加內聚。
另外,倉儲的一個巨大價值是給單元測試提供援助,特別是特定方法,讓單元測試的模擬變得簡單。
一個值得註意的問題是,倉儲的介面放在領域層,倉儲的實現放到基礎設施層,或叫數據訪問層也行。
倉儲介面提供了常用的數據訪問操作,當開發一些簡單業務模塊,就不需要手工編寫特定方法了。
- ICompactRepository
配合Po(持久化對象,後續會進行介紹)使用的倉儲介面。
該介面的一個特點是沒有任何和Lambda相關的方法,具體原因在後續介紹。
Po會導致項目變得更加複雜,如果你不是真的搞懂或需要,強烈建議不要使用它,請忽視這個介面。
- IDomainService
領域服務介面。
領域服務介面是一個空介面,它從IScopeDependency派生,這樣你就不需要為領域服務配置Ioc。
領域服務的使用要點是不要在它裡面編寫業務邏輯,業務邏輯應該寫到實體和值對象中,領域服務僅用來調度聚合根,封裝聚合根的交互過程,任何業務應該由聚合根來完成。
應該註意到,如果你的業務邏輯主要寫在領域服務,而聚合根里只有屬性,那麼這隻是三層的變種。
領域服務並不是必須的,在簡單的情況下可以降低一點要求,把聚合根的交互放到應用服務中,不過如果需要對聚合根的交互過程復用,就必須封裝到領域服務。
對於使用三層架構的同學,領域服務同樣有用,如果你需要復用一些邏輯,可以考慮使用領域服務封裝一些細粒度的服務,再註入到應用服務來組裝完成功能。
領域服務不應該提交事務,這個工作放到應用服務來完成,所以應用服務組裝領域服務比應用服務調用其它應用服務更加簡單易用。
- IEventBus
事件匯流排介面,它可以用來發送領域事件。
當有某些業務發生變化時,可以發佈領域事件,這樣可以得到松耦合的設計,當有需求變化時可以在不修改原代碼的情況下進行處理。
Util支持兩種事件類型。
一種是簡單記憶體事件,訂閱端的事件處理器與發送端處於同一個進程中。
另一種是消息事件,通過消息隊列發送給另一個系統。
如果使用IEventBus,兩種類型的事件可能被同時觸發,具體依賴事件的類型。
- ISimpleEventBus
基於記憶體的簡單事件匯流排介面。
它僅將事件發送給同一進程的事件處理器。
記憶體事件匯流排是由Ioc來實現的,在系統啟動時掃描所有事件處理器並添加到Ioc中,發佈事件時從Ioc獲取所有匹配的事件處理器,並直接調用它的處理方法。
記憶體事件匯流排的實現主要參考自Nop開源商城。
- IMessageEventBus
基於消息的事件匯流排介面。
它僅將事件發送給消息隊列。
消息事件匯流排,目前集成封裝了Ncc開源社區的Cap項目,Cap不僅提供了消息事件匯流排的功能,還是分散式事務的解決方案。
當將Cap作為分散式事務解決方案時,需要手工using,並把Ef的事務傳遞給Cap,這導致工作單元被破壞,你的關註點被轉移到資料庫的事務中,另外無法使用Ioc來管理Ef工作單元這一最佳實踐。
[Route("~/ef/transaction")] public IActionResult EntityFrameworkWithTransaction([FromServices]AppDbContext dbContext) { using (var trans = dbContext.Database.BeginTransaction(_capBus, autoCommit: true)) { _capBus.Publish("xxx.services.show.time", DateTime.Now); } return Ok(); }
Util對Cap和Ef進行了封裝,你將以更加直觀的方式來使用Cap和Ef,不再需要using。
- IEventHandler
記憶體事件處理器介面。
泛型參數為事件類型,只會接收到特定事件。
記憶體事件處理器所在的文件可以放在項目中的任何位置,建議統一放到某些目錄,以免難以查找,形成隱患。
- Event
記憶體事件基類。
- MessageEvent
消息事件基類。
當使用MessageEvent作為事件或事件基類時,使用IEventBus能觸發記憶體事件處理器併發送到消息隊列。
最佳實踐
下麵分享一些我目前認為的最佳實踐。
實體文件的拆分
實體通常包含大量屬性,所以我會把實體的文件分成兩個,一個用來放屬性,另一個用來放它的方法。
Util的約定是實體名.Base.cs文件用來放置屬性,比如Application.Base.cs。
實體名.cs文件用來放置方法,比如Application.cs,並且我會將Application.Base.cs文件嵌套在Application.cs文件中。
亂78糟的屬性容易讓人心煩,這樣處理以後,編寫業務邏輯就會心情舒暢很多。
如果實體非常簡單,那麼就不需要分開兩個文件,一個文件即可。
私有化重要屬性
如果方法操作的屬性具有Public的Set訪問修飾符,那麼其它開發人員就會繞過你的方法直接給它賦值,如果你擔心這種情況,需要把Set設置成Private。
領域對象的映射方法
領域模型很難掌握的其中一個原因是對象結構異常靈活,一旦業務複雜,對象結構與表結構將有很大不同。
而大部分開發人員都習慣了一個表就是一個類,而這個類也就相當於聚合根,所以業務稍微複雜就會不知所措,設計變得複雜,開發困難,大部分精力都放在了資料庫的操作上,因為操作那些表非常麻煩。
要想使用領域模型的開發方法,需要加強面向對象的設計水平,同時需要瞭解一些對象映射的技巧。
面向對象的表設計,有一個簡單的原則,對象儘量細粒度,但表的設計儘量粗粒度,很多時候需要逆範式設計。
這個道理很簡單,如果表很少,那麼你就能把更多精力放在對象的業務操作上,否則你大部分時候都在考慮該如何連表,另外表連接太多,你的精力又轉移到該如何優化性能上。
當然並不是說資料庫設計就不要範式,這樣又會導致大量的數據冗餘和更新困難,這需要做好平衡。
類的層次關係,也就是繼承體系,可以幫助我們通過多態來使用設計模式。
對於類層次關係映射,我經常使用單表繼承,也就是把整個類繼承體系放進一張表裡,這讓表變得非常簡單,註意力被高度集中在對象結構中,而不是連表操作上。
聚合根內部的值對象如果是單個的,可以使用嵌入值映射,將值對象的屬性存儲到聚合根所在表的多個列中。
聚合根內部的值對象如果是集合,使用序列化映射,存儲Json字元串到聚合根所在表的單一列中。使用Json序列化會導致查詢困難,應儘量少查或不查,或使用資料庫提供的Json查詢方法,另一種辦法是創建專用查詢資料庫,不一定使用關係資料庫,可以使用像MongoDB這樣的NoSql資料庫來查詢。
對領域模型進行單元測試
如果你的業務複雜並且很關鍵,出現邏輯Bug會導致重大損失,那麼單元測試就是你的救命稻草。
如果你對已經想到的所有需求進行了有效的單元測試,那麼這些需求發現Bug的機會將非常渺茫。
當發現Bug時,通過添加單元測試可以永久性修複它,如果沒有單元測試做保障,你可能會一石激起千層浪,一個Bug引發更多Bug,這非常常見。
使用領域模型的開發方法,與三層架構的事務腳本相比,它更容易進行單元測試。因為業務高度內聚到領域層,而領域層沒有外部依賴,所以它更好模擬和測試。
要想真正實施單元測試,最好的方法是進行TDD。
如果項目已經開髮結束,在後面補單元測試,一般都不靠譜,因為人有惰性,業務都做完了誰還願意寫呢。另外在項目開發完成後,代碼耦合高,可能無法進行單元測試,只能做集成測試。
使用TDD可以獲得很高的測試覆蓋率,你想到的需求大部分分支情況可能都被覆蓋到,質量必然有保障。
當然單元測試不是天上掉餡餅,它寫起來不容易,對開發人員的設計和抽象能力有要求,另外測試時需要做大量的前置準備,比如設置和模擬依賴數據,非常麻煩,工作量很大。當需求變更時,有大量測試被拋棄和重寫,他是質量的保障,也是一個包袱。
值得註意的是,不要對Crud進行單元測試,這是大炮打蚊子,用代碼生成器來乾這個活,可以獲得相同的質量,但效率高10萬倍。
對於大部分中小團隊,只應該對核心業務邏輯進行單元測試,其它的手工測試即可。
在領域模型中實施設計模式
我發現使用領域模型的另一個優點是使用設計模式更加簡單自然。
在三層架構中使用設計模式,你能切換的只有服務,這具有相當大的限制。
當我們使用領域模型,情況大有不同。
你可以切換領域服務以獲得不同的行為。
你還可以建立實體的類層次關係結構,這些實體提供不同的行為。
更進一步,你可以使用專門的模式對象,比如策略對象,把策略對象傳遞到實體,實體上的方法委托到策略對象上。
在一些較複雜的業務中,策略對象和狀態對象具有強大的殺傷力,數百個if else被封裝到10幾個狀態和策略對象中,從而將麵條式的業務代碼迅速整理得乾乾凈凈。
基礎設施層構造塊
基礎設施層的職責
- 為其它層提供支持
毫無疑問,最重要的支持就是提供操作資料庫的能力。
除了資料庫操作以外,還包括發簡訊,發郵件等。
甚至像Util這樣的Helper和基類等輔助工具,也是基礎設施這個範圍。
如果沒有特別的需求,你就把它當成數據訪問層。
基礎設施層的介面和基類
- IUnitOfWork
工作單元介面。
Ef Core的DbContext實現了工作單元。
關於工作單元,大多開發人員都把它當成事務,或事務的包裝器。
但為什麼DbContext的提交叫SaveChanges,而不是Commit呢?
如果理解了這個問題,就容易理解工作單元的概念,它是對象的數據操作記錄器,並且在提交時開啟一個事務把所有記錄下來的變更寫入資料庫。
工作單元對使用面向對象的方式開發業務邏輯具有深遠的影響。
很多標榜Orm的SqlHelper都試圖與Ef Core進行功能比較,甚至性能比較,這是很可笑的。
是不是真正的Orm,首先看它有沒有實現健壯的工作單元,而不是簡單的對象映射。
如果實現了對象與資料庫表的簡單映射就叫Orm,那麼我們可以用SqlHelper + AutoMapper花上半小時就能開發一個Orm了。
Ef Core在實現工作單元上進行了大量的工作,性能自然有損耗,但對於業務操作有非常大的幫助。
這裡要吐槽一下,很多人覺得Ef Core不好用,根本原因是面向對象尚未入門,以數據操作思維來使用面向對象的基礎設施,很明顯水土不服。
雖然說領域模型需要儘量持久化無關,但使用好的基礎設施,可以提升大量的生產力,並且將關註點轉移到面向對象的業務操作上來。
關於Ef Core的一些最佳實踐,後續開專文來討論。
- UnitOfWork
工作單元基類。
將Ef Core的DbContext註入到UnitOfWork,並添加其它基礎功能。
UnitOfWork添加了Ef Core日誌功能,這對於使用Ef Core非常重要,你如果不看它生成的Sql,很可能產生嚴重的性能問題。
UnitOfWork添加的另一個功能是自動掃描並載入Ef Core映射配置類。
註意,Util為每種資料庫都提供了UnitOfWork基類,你需要根據使用的資料庫選擇繼承的基類,它們的命名空間不同,類名相同。
- RepositoryBase
倉儲實現的基類。
Ef Core的DbContext實現了倉儲模式,所以我們只需要把DbContext註入進來,再包裝一些常用方法即可解決戰鬥。
前面已經說過,將倉儲作為單獨的構造塊,而不是直接使用DbContext,是需要用倉儲來提供單元測試支持和封裝特定數據操作的功能。
- AggregateRootMap
聚合根映射基類。
你需要將類和表之間有差異的表名或列名進行映射配置。
AggregateRootMap屏蔽了Ef Core對不同資料庫的樂觀離線鎖設置的差異。
註意,Util為每種資料庫都提供了AggregateRootMap基類,你需要根據使用的資料庫選擇繼承的基類,它們的命名空間不同,類名相同。
- EntityMap
實體映射基類。
註意,Util為每種資料庫都提供了EntityMap基類,你需要根據使用的資料庫選擇繼承的基類,它們的命名空間不同,類名相同。
- PersistentObjectBase
持久化對象基類。
Po就是持久化對象,它是另一種參數對象。
在一般情況下,我們直接使用Ef Core操作實體,不過這會導致實體與持久化產生一些耦合。
在大部分情況下,可以通過Ef Core將複雜實體與資料庫表建立映射關係,不過有些映射方式Ef Core並不支持,比如序列化映射。
當你進行序列化映射時,需要在實體上添加一個字元串屬性,再定義一個對象屬性,然後使用這個對象屬性,再讓Ef Core操作這個字元串屬性,這樣來迴轉換。
我最早是在這裡發現了問題,那個字元串屬性污染了實體,這讓領域模型變得不夠純,對於像我這樣有代碼潔癖的人,這並不是一個小問題。
不過我還是這樣忍受了好幾年,直到我從Ef6遷移到Ef Core。
Ef Core作為.Net Core平臺的關鍵數據訪問技術,雖然經過多年努力,始終比Ef6差一大截。特別是之前用得很溜的一些映射技術,比如嵌入值映射,也就是複雜類型映射,在Ef Core用起來非常蹩腳,而多對多這樣的重要映射,盼了很多年也沒有更新上來。
我開始尋找其它方案,很多年前我就聽過Po的大名,不過一直沒有使用它,怕麻煩。
我開始嘗試使用Po,所謂Po,就是與資料庫表相對應的一個對象,與表結構完全一樣。
這樣,你的實體就可以隨便設計了,等到需要持久化的時候,將實體轉成Po,再把Po丟給Ef Core去保存,由於Po與表完全對應,所以不需要使用Ef Core的任何高級映射功能。
我發現手工處理高級映射反而更加簡單清晰,也不用再看Ef Core的臉色做事。
不過Po和Dto完全不同,引入Po並不是將它與實體轉換一下就完事,它大大增加了架構的複雜性。
這裡的問題在於Ef Core操作的對象發生了變化,之前Ef Core直接操作實體,而現在卻操作的是Po。
另外Po不應該放到領域層,它處於基礎設施層,那麼實體還是需要倉儲來操作,Po又由誰來操作呢?
這需要引入一個新的構造,它就是存儲器Store。
- IStore
存儲器介面。
存儲器用於操作Po。
使用Store這個詞,是因為微軟很多數據操作使用它。
- StoreBase
存儲器基類。
將Ef Core的DbContext註入進來實現所有操作。
- CompactRepositoryBase
配合Po使用的倉儲基類。
將存儲器註入到CompactRepositoryBase中完成操作。
對於Po使用的倉儲,一個關鍵問題是不能有任何Lambda操作,因為DbContext操作Po,倉儲操作的是實體,如果倉儲包含Lambda操作,將無法傳遞給DbContext,因為實體不在DbContext中配置。
這樣一來,領域層還是實體和倉儲進行業務操作,Po,存儲器位於基礎設施層,互不幹擾,為使用Po打下良好的基礎。
有更好的設計方案,還請諸大神指教。
討論
- 你需要使用Po嗎?
勸你別用,徒增煩惱,不過當你領悟它,並真的需要時,它會讓你的領域模型變得更加純凈,複雜映射也更加游刃有餘。
小結
本文簡要介紹了Util在業務邏輯開發方面的一些分層構造塊,你不需要全部使用,如果你發現這個構造沒什麼用,並且你也沒有打算持續學習,扔掉它就是最好的選擇。
本文基本沒有例子,你可以看看Demo並自己練習,更完善的教程還沒有計劃,需要Util框架更加完善後進行。
未完待續,查詢的封裝和使用將在下篇介紹。
寫文需要動力,請大家多多支持,點下推薦,Github點下星星。
Util應用框架交流一群: 24791014
Util應用框架交流二群: 184097033
Util應用框架地址:https://github.com/dotnetcore/util