表達式樹是 .net 中一系列非常好用的類型。在一些場景中使用表達式樹可以獲得更好的性能和更佳的擴展性。本篇我們將通過構建一個 “模型驗證器” 來理解和應用表達式樹在構建動態調用方面的優勢。
Newbe.Claptrap 是一個用於輕松應對並發問題的分布式開發框架。如果您是首次閱讀本系列文章。建議可以先從本文末尾的入門文章開始了解。
開篇摘要
前不久,我們發布了《如何使用 dotTrace 來診斷 netcore 應用的性能問題》,經過網友投票之后,網友們表示對其中表達式樹的內容很感興趣,因此本篇我們將展開講講。
動態調用是在 .net 開發是時常遇到的一種需求,即在只知道方法名或者屬性名等情況下動態的調用方法或者屬性。最廣為人知的一種實現方式就是使用 “反射” 來實現這樣的需求。當然也有一些高性能場景會使用 Emit 來完成這個需求。
本文將介紹 “使用表達式樹” 來實現這種場景,因為這個方法相較於 “反射” 將擁有更好的性能和擴展性,相較於 Emit 又更容易掌握。
我們將使用一個具體的場景來逐步使用表達式來實現動態調用。
在該場景中,我們將構建一個模型驗證器,這非常類似於 aspnet mvc 中 ModelState 的需求場景。
這不是一篇簡單的入門文章,初次涉足該內容的讀者,建議在空閑時,在手邊有 IDE 可以順便操作時邊看邊做。同時,也不必在意樣例中出現的細節方法,只需要了解其中的大意,能夠依樣畫瓢即可,掌握大意之后再深入了解也不遲。
為了縮短篇幅,文章中的樣例代碼會將沒有修改的部分隱去,想要獲取完整的測試代碼,請打開文章末尾的代碼倉庫進行拉取。
為什么要用表達式樹,為什么可以用表達式樹?
首先需要確認的事情有兩個:
- 使用表達式樹取代反射是否有更好的性能?
- 使用表達式樹進行動態調用是否有很大的性能損失?
有問題,做實驗。我們采用兩個單元測試來驗證以上兩個問題。
調用一個對象的方法:
以上測試中,我們對第三種調用方式一百萬次調用,並記錄每個測試所花費的時間。可以得到類似以下的結果:
Method | Time |
---|---|
RunReflection | 217ms |
RunExpression | 20ms |
Directly | 19ms |
可以得出以下結論:
- 使用表達式樹創建委托進行動態調用可以得到和直接調用近乎相同的性能。
- 使用表達式樹創建委托進行動態調用所消耗的時間約為十分之一。
所以如果僅僅從性能上考慮,應該使用表達式樹,也可以是用表達式樹。
不過這是在一百萬調用下體現出現的時間,對於單次調用而言其實就是納秒級別的區別,其實無足輕重。
但其實表達式樹不僅僅在性能上相較於反射更優,其更強大的擴展性其實采用最為重要的特性。
此處還有一個對屬性進行操作的測試,此處將測試代碼和結果羅列如下:
耗時情況:
Method | Time |
---|---|
RunReflection | 373ms |
RunExpression | 19ms |
Directly | 18ms |
由於反射多了一份裝拆箱的消耗,所以比起前一個測試樣例顯得更慢了,使用委托是沒有這種消耗的。
第〇步,需求演示
先通過一個測試來了解我們要創建的 “模型驗證器” 究竟是一個什么樣的需求。
從上而下,以上代碼的要點:
- 主測試方法中,包含有三個基本的測試用例,並且每個都將執行一萬次。后續所有的步驟都將會使用這樣的測試用例。
- Validate 方法是被測試的包裝方法,后續將會調用該方法的實現以驗證效果。
- ValidateCore 是 “模型驗證器” 的一個演示實現。從代碼中可以看出該方法對 CreateClaptrapInput 對象進行的驗證,並且得到驗證結果。但是該方法的缺點也非常明顯,這是一種典型的 “寫死”。后續我們將通過一系列改造。使得我們的 “模型驗證器” 更加的通用,並且,很重要的,保持和這個 “寫死” 的方法一樣的高效!
- ValidateResult 是驗證器輸出的結果。后續將不斷重復的用到該結果。
第一步,調用靜態方法
首先我們構建第一個表達式樹,該表達式樹將直接使用上一節中的靜態方法 ValidateCore。
從上而下,以上代碼的要點:
- 增加了一個單元測試的初始化方法,在單元測試啟動時創建的一個表達式樹將其編譯為委托保存在靜態字段 _func 中。
- 省略了主測試方法 Run 中的代碼,以便讀者閱讀時減少篇幅。實際代碼沒有變化,后續將不再重復說明。可以在代碼演示倉庫中查看。
- 修改了 Validate 方法的實現,不再直接調用 ValidateCore ,而調用 _func 來進行驗證。
- 運行該測試,開發者可以發現,其消耗的時間和上一步直接調用的耗時,幾乎一樣,沒有額外消耗。
- 這里提供了一種最為簡單的使用表達式進行動態調用的思路,如果可以寫出一個靜態方法(例如:ValidateCore)來表示動態調用的過程。那么我們只要使用類似於 Init 中的構建過程來構建表達式和委托即可。
- 開發者可以試着為 ValidateCore 增加第三個參數 name 以便拼接在錯誤信息中,從而了解如果構建這種簡單的表達式。
第二步,組合表達式
雖然前一步,我們將直接調用轉變了動態調用,但由於 ValidateCore 還是寫死的,因此還需要進一步修改。
本步驟,我們將會把 ValidateCore 中寫死的三個 return 路徑拆分為不同的方法,然后再采用表達式拼接在一起。
如果我們實現了,那么我們就有條件將更多的方法拼接在一起,實現一定程度的擴展。
注意:演示代碼將瞬間邊長,不必感受太大壓力,可以輔助后面的代碼要點說明進行查看。
代碼要點:
- ValidateCore 方法被拆分為了 ValidateNameRequired 和 ValidateNameMinLength 兩個方法,分別驗證 Name 的 Required 和 MinLength。
- Init 方法中使用了 local function 從而實現了方法 “先使用后定義” 的效果。讀者可以自上而下閱讀,從頂層開始了解整個方法的邏輯。
- Init 整體的邏輯就是通過表達式將 ValidateNameRequired 和 ValidateNameMinLength 重新組合成一個形如 ValidateCore 的委托
Func<CreateClaptrapInput, int, ValidateResult>
。 - Expression.Parameter 用於標明委托表達式的參數部分。
- Expression.Variable 用於標明一個變量,就是一個普通的變量。類似於代碼中的
var a
。 - Expression.Label 用於標明一個特定的位置。在該樣例中,主要用於標定 return 語句的位置。熟悉 goto 語法的開發者知道, goto 的時候需要使用 label 來標記想要 goto 的地方。而實際上,return 就是一種特殊的 goto。所以想要在多個語句塊中 return 也同樣需要標記后才能 return。
- Expression.Block 可以將多個表達式順序組合在一起。可以理解為按順序寫代碼。這里我們將 CreateDefaultResult、CreateValidateNameRequiredExpression、CreateValidateNameMinLengthExpression 和 Label 表達式組合在一起。效果就類似於把這些代碼按順序拼接在一起。
- CreateValidateNameRequiredExpression 和 CreateValidateNameMinLengthExpression 的結構非常類似,因為想要生成的結果表達式非常類似。
- 不必太在意 CreateValidateNameRequiredExpression 和 CreateValidateNameMinLengthExpression 當中的細節。可以在本樣例全部閱讀完之后再嘗試了解更多的 Expression.XXX 方法。
- 經過這樣的修改之后,我們就實現了擴展。假設現在需要對 Name 增加一個 MaxLength 不得超過 16 的驗證。只需要增加一個 ValidateNameMaxLength 的靜態方法,添加一個 CreateValidateNameMaxLengthExpression 的方法,並且加入到 Block 中即可。讀者可以嘗試動手操作一波實現這個效果。
第三步,讀取屬性
我們來改造 ValidateNameRequired 和 ValidateNameMinLength 兩個方法。因為現在這兩個方法接收的是 CreateClaptrapInput 作為參數,內部的邏輯也被寫死為驗證 Name,這很不優秀。
我們將改造這兩個方法,使其傳入 string name 表示驗證的屬性名稱,string value 表示驗證的屬性值。這樣我們就可以將這兩個驗證方法用於不限於 Name 的更多屬性。
代碼要點:
- 正如前文所述,我們修改了 ValidateNameRequired ,並重命名為 ValidateStringRequired。 ValidateNameMinLength -> ValidateStringMinLength。
- 修改了 CreateValidateNameRequiredExpression 和 CreateValidateNameMinLengthExpression,因為靜態方法的參數發生了變化。
- 通過這樣的改造,我們便可以將兩個靜態方法用於更多的屬性驗證。讀者可以嘗試增加一個 NickName 屬性。並且進行相同的驗證。
第四步,支持多個屬性驗證
接下來,我們通過將驗證 CreateClaptrapInput 所有的 string 屬性。
代碼要點:
- 在 CreateClaptrapInput 中增加了一個屬性 NickName ,測試用例也將驗證該屬性。
- 通過
List<Expression>
我們將更多動態生成的表達式加入到了 Block 中。因此,我們可以對 Name 和 NickName 都生成驗證表達式。
第五步,通過 Attribute 決定驗證內容
盡管前面我們已經支持驗證多種屬性了,但是關於是否進行驗證以及驗證的參數依然是寫死的(例如:MinLength 的長度)。
本節,我們將通過 Attribute 來決定驗證的細節。例如被標記為 Required 是屬性才會進行必填驗證。
代碼要點:
- 在構建
List<Expression>
時通過屬性上的 Attribute 上的決定是否加入特定的表達式。
第六步,將靜態方法換為表達式
ValidateStringRequired 和 ValidateStringMinLength 兩個靜態方法的內部實際上只包含一個判斷三目表達式,而且在 C# 中,可以將 Lambda 方法賦值個一個表達式。
因此,我們可以直接將 ValidateStringRequired 和 ValidateStringMinLength 改換為表達式,這樣就不需要反射來獲取靜態方法再去構建表達式了。
代碼要點:
- 將靜態方法換成了表達式。因此 CreateXXXExpression 相應的位置也進行了修改,代碼就更短了。
第七步,柯里化
柯理化,也稱為函數柯理化,是函數式編程當中的一種方法。簡單的可以表述為:通過固定一個多參數函數的一個或幾個參數,從而得到一個參數更少的函數。術語化一些,也可以表述為將高階函數(函數的階其實就是說參數的個數)轉換為低階函數的方法。
例如,現在有一個 add (int,int) 的函數,它實現了將兩個數相加的功能。假如我們固定集中第一個參數為 5 ,則我們會得到一個 add (5,int) 的函數,它實現的是將一個數加 5 的功能。
這有什么意義?
函數降階可以使得函數變得一致,得到了一致的函數之后可以做一些代碼上的統一以便優化。例如上面使用到的兩個表達式:
Expression<Func<string, string, ValidateResult>> ValidateStringRequiredExp
Expression<Func<string, string, int, ValidateResult>> ValidateStringMinLengthExp
這兩個表達式中第二個表達式和第一個表達式之間僅僅區別在第三參數上。如果我們使用柯理化固定第三個 int 參數,則可以使得兩個表達式的簽名完全一樣。這其實和面向對象中的抽象非常類似。
代碼要點:
- CreateValidateStringMinLengthExp 靜態方法,傳入一個參數創建得到一個和 CreateValidateStringRequiredExp 返回值一樣的表達式。對比上一節中的 ValidateStringMinLengthExp ,實現了固定 int 參數而得到一個新表達式的操作。這就是一種柯理化的體現。
- 為了統一都采用靜態方法,我們將上一節中的 ValidateStringRequiredExp 也改為 CreateValidateStringRequiredExp 靜態方法,這僅僅只是為了看起來一致(但實際上增加了一點點開銷,因為沒必要重復創建一個不變的表達式)。
- 相應的調整一下
List<Expression>
組裝過程的代碼。
第八步,合並重復代碼
本節,我們將合並 CreateValidateStringRequiredExpression 和 CreateValidateStringMinLengthExpression 中重復的代碼。
其中只有 requiredMethodExp 的創建方式不同。因此,只要將這個參數從方法外面傳入就可以抽離出公共部分。
代碼要點:
- CreateValidateExpression 就是被抽離出來的公共方法。
- 如果沒有前一步柯理化,CreateValidateExpression 的第二個參數 validateFuncExpression 將很難確定。
- CreateValidateStringRequiredExpression 和 CreateValidateStringMinLengthExpression 內部調用了 CreateValidateExpression,但是固定了幾個參數。這其實也可以被認為是一種柯理化,因為返回值是表達式其實可以被認為是一種函數的表現形式,當然理解為重載也沒有問題,不必太過糾結。
第九步,支持更多模型
到現在,我們已經得到了一個支持驗證 CreateClaptrapInput 多個 string 字段的驗證器。並且,即使要擴展多更多類型也不是太難,只要增加表達式即可。
本節,我們將 CreateClaptrapInput 抽象為更抽象的類型,畢竟沒有模型驗證器是專門只能驗證一個 class 的。
代碼要點:
- 將
Func<CreateClaptrapInput, ValidateResult>
替換為了Func<object, ValidateResult>
,並且將寫死的 typeof (CreateClaptrapInput) 都替換為了 type。 - 將對應類型的驗證器創建好之后保存在 ValidateFunc 中。這樣就不需要每次都重建整個 Func。
第十步,加入一些細節
最后的最后,我們又到了令人愉快的 “加入一些細節” 階段:按照業務特性對抽象接口和實現進行調整。於是我們就得到了本示例最終的版本。
代碼要點:
- IValidatorFactory 模型驗證器工廠,表示創建特定類型的驗證器委托
- IPropertyValidatorFactory 具體屬性的驗證表達式創建工廠,可以根據規則的增加,追加新的實現。
- 使用 Autofac 進行模塊管理。
可以通過《在 C# 中使用依賴注入》來了解如何添加一些細節。
隨堂小練
別走!您還有作業。
以下有一個按照難度分級的需求,開發者可以嘗試完成這些任務,進一步理解和使用本樣例中的代碼。
增加一個驗證 string max length 的規則
難度:D
思路:
和 min length 類似,別忘記注冊就行。
增加一個驗證 int 必須大於等於 0 的規則
難度:D
思路:
只是多了一個新的屬性類型,別忘記注冊就行。
增加一個 IEnumerable<T>
對象必須包含至少一個元素的規則
難度:C
思路:
可以用 Linq 中的 Any 方法來驗證
增加一個 IEnumerable<T>
必須已經 ToList 或者 ToArray,類比 mvc 中的規則
難度:C
思路:
其實只要驗證是否已經是 ICollection 就可以了。
支持空對象也輸出驗證結果
難度:C
思路:
如果 input 為空。則也要能夠輸出第一條不滿足條件的規則。例如 Name Required。
增加一個驗證 int? 必須有值的規則
難度:B
思路:
int? 其實是語法糖,實際類型是 Nullable<int>
。
增加一個驗證枚舉必須符合給定的范圍
難度:B
思路:
枚舉是可以被賦值以任意數值范圍的,例如定義了 Enum TestEnum {None = 0;} 但是,強行賦值 233 給這樣的屬性並不會報錯。該驗證需要驗證屬性值只能是定義的值。
也可以增加自己的難度,例如支持驗證標記為 Flags 的枚舉的混合值范圍。
添加一個驗證 int A 屬性必須和 int B 屬性大
難度:A
思路:
需要有兩個屬性參與。啥都別管,先寫一個靜態函數來比較兩個數值的大小。然后在考慮如何表達式化,如何柯理化。可以參考前面思路。
額外限定條件,不能修改現在接口定義。
添加一個驗證 string A 屬性必須和 string B 屬性相等,忽略大小寫
難度:A
思路:
和前一個類似。但是,string 的比較比 int 特殊,並且需要忽略大小寫。
支持返回全部的驗證結果
難度:S
思路:
調整驗證結果返回值,從返回第一個不滿足的規則,修改為返回所有不滿足的規則,類比 mvc model state 的效果。
需要修改組合結果的表達式,可以有兩種辦法,一種是內部創建 List 然后將結果放入,更為簡單的一種是使用 yield return 的方法進行返回。
需要而外注意的是,由於所有規則都運行,一些判斷就需要進行防御性判斷。例如在 string 長度判斷時,需要先判斷其是否為空。至於 string 為空是否屬於滿足最小長度要求,開發者可以自由決定,不是重點。
支持對象的遞歸驗證
難度:SS
思路:
即如果對象包含一個屬性又是一個對象,則子對象也需要被驗證。
有兩種思路:
一是修改 ValidatorFactory 使其支持從 ValidateFunc 中獲取驗證器作為表達式的一部分。該思路需要解決的主要問題是,ValidateFunc 集合中可能提前不存在子模型的驗證器。可以使用 Lazy 來解決這個問題。
二是創建一個 IPropertyValidatorFactory 實現,使其能夠從 ValidatorFactory 中獲取 ValidateFunc 來驗證子模型。該思路主要要解決的問題是,直接實現可能會產生循環依賴。可以保存和生成 ValidateFunc 划分在兩個接口中,解除這種循環依賴。該方案較為簡單。
另外,晉級難度為 SSS,驗證 IEnumerable<>
中所有的元素。開發者可以嘗試。
支持鏈式 API
難度:SSS
思路:
形如 EntityFramework 中同時支持 Attribute 和鏈式 API 一樣,添加鏈式設置驗證的特性。
這需要增加新的接口以便進行鏈式注冊,並且原來使用 Attribute 直接生成表達式的方法也應該調整為 Attribute -> 注冊數據 -> 生成表達式。
實現一個屬性修改器
難度:SSS
思路:
實現一條規則,手機號碼加密,當對象的某個屬性是滿足長度為 11 的字符串,並且開頭是 1。則除了前三位和后四位之外的字符全部替換為 *
。
建議從頭開始實現屬性修改器,不要在上面的代碼上做變更。因為驗證和替換通常來說是兩個不同的業務,一個是為了輸入,一個是為了輸出。
這里有一些額外的要求:
- 在替換完成后,將此次被替換的所有值的前后情況輸出在日志中。
- 注意,測試的性能要與直接調用方法相當,否則肯定是代碼實現存在問題。
本文總結
在.net 中,表達式樹可以用於兩種主要的場景。一種是用於解析結果,典型的就是 EntityFramework,而另外一種就是用於構建委托。
本文通過構建委托的方式實現了一個模型驗證器的需求。生產實際中還可以用於很多動態調用的地方。
掌握表達式樹,就掌握了一種可以取代反射進行動態調用的方法,這種方法不僅擴展性更好,而且性能也不錯。
本篇內容中的示例代碼,均可以在以下鏈接倉庫中找到:
最后但是最重要!
如果讀者對該內容感興趣,歡迎轉發、評論、收藏文章以及項目。
最近作者正在構建以反應式
、Actor模式
和事件溯源
為理論基礎的一套服務端開發框架。希望為開發者提供能夠便於開發出 “分布式”、“可水平擴展”、“可測試性高” 的應用系統 ——Newbe.Claptrap
本篇文章是該框架的一篇技術選文,屬於技術構成的一部分。
聯系方式:
- Github Issue
- Gitee Issue
- 公開郵箱 newbe-claptrap@googlegroups.com (發送到該郵箱的內容將被公開)
- Gitter
- QQ 群 610394020
您還可以查閱本系列的其他選文:
理論入門篇
術語介紹篇
- Actor 模式
- 事件溯源(Event Sourcing)
- Claptrap
- Minion
- 事件 (Event)
- 狀態 (State)
- 狀態快照 (State Snapshot)
- Claptrap 設計圖 (Claptrap Design)
- Claptrap 工廠 (Claptrap Factory)
- Claptrap Identity
- Claptrap Box
- Claptrap 生命周期(Claptrap Lifetime Scope)
- 序列化(Serialization)
實現入門篇
- Newbe.Claptrap 框架入門,第一步 —— 創建項目,實現簡易購物車
- Newbe.Claptrap 框架入門,第二步 —— 簡單業務,清空購物車
- Newbe.Claptrap 框架入門,第三步 —— 定義 Claptrap,管理商品庫存
- Newbe.Claptrap 框架入門,第四步 —— 利用 Minion,商品下單
樣例實踐篇
其他番外篇
- 談反應式編程在服務端中的應用,數據庫操作優化,從 20 秒到 0.5 秒
- 談反應式編程在服務端中的應用,數據庫操作優化,提速 Upsert
- 十萬同時在線用戶,需要多少內存?——Newbe.Claptrap 框架水平擴展實驗
- docker-mcr 助您全速下載 dotnet 鏡像
- 十多位全球技術專家,為你獻上近十個小時的.Net 微服務介紹
- 年輕的樵夫喲,你掉的是這個免費 8 核 4G 公網服務器,還是這個隨時可用的 Docker 實驗平台?
- 如何使用 dotTrace 來診斷 netcore 應用的性能問題
- 只要十步,你就可以應用表達式樹來優化動態調用
GitHub 項目地址:https://github.com/newbe36524/Newbe.Claptrap
Gitee 項目地址:https://gitee.com/yks/Newbe.Claptrap
您當前查看的是先行發布於 www.newbe.pro 上的博客文章,實際開發文檔隨版本而迭代。若要查看最新的開發文檔,需要移步 claptrap.newbe.pro。