深入Dapper.NET源碼

繁體中文版本連接 : 深入Dapper.NET源碼 简体中文版本连接 : 深入Dapper.NET源码 (文长) - 暐翰 - 博客园


深入Dapper.NET源碼

目錄


   

1.前言、目錄、安裝環境

經過業界前輩、StackOverflow多年推廣,「Dapper搭配Entity Framework」成為一種功能強大的組合,它滿足「安全、方便、高效、好維護」需求。

但目前中文網路文章,雖然有很多關於Dapper的文章但都停留在如何使用,沒人系統性解說底層原理。所以有了此篇「深入Dapper源碼」想帶大家進入Dapper底層,了解Dapper的精美細節設計、高效原理,並學起來實際應用在工作當中。


建立Dapper Debug環境

  1. Dapper Github 首頁 Clone最新版本到自己本機端

  2. 建立.NET Core Console專案 20191003173131.png

  3. 需要安裝NuGet SqlClient套件、添加Dapper Project Reference 20191003173438.png

  4. 下中斷點運行就可以Runtime查看邏輯 20191003215021.png

個人環境

  • 數據庫 : MSSQLLocalDB

  • Visaul Studio版本 : 2019

  • LINQ Pad 5 版本

  • Dapper版本 : V2.0.30

  • 反編譯 : ILSpy

   

2.Dynamic Query 原理 Part1

在前期開發階段因為表格結構還在調整階段,或是不值得額外宣告類別輕量需求,使用Dapper dynamic Query可以節省下來回修改class屬性的時間。當表格穩定下來後使用POCO生成器快速生成Class轉成強型別維護。

為何Dapper可以如此方便,支援dynamic?

追溯Query方法源碼可以發現兩個重點

  1. 實體類別其實是DapperRow再隱性轉型為dynamic。 20191003180501.png

  2. DapperRow繼承IDynamicMetaObjectProvider並且實作對應方法。

20191003044133.png

此段邏輯我這邊做一個簡化版本的Dapper dynamic Query讓讀者了解轉換邏輯 :

  1. 建立dynamic類別變量,實體類別是ExpandoObject

  2. 因為有繼承關係可以轉型為IDictionary<string, object>

  3. 使用DataReader使用GetName取得欄位名稱,藉由欄位index取得值,並將兩者分別添加進Dictionary當作key跟value。

  4. 因為ExpandoObject有實作IDynamicMetaObjectProvider介面可以轉換成dynamic

20191003044145.png

 

3.Dynamic Query 原理 Part2

有了前面簡單ExpandoObject Dynamic Query例子的概念後,接著進到底層來了解Dapper如何細節處理,為何要自訂義DynamicMetaObjectProvider。

首先掌握Dynamic Query流程邏輯 :

假設使用下面代碼

取值的過程會是 : 建立動態Func > 保存在緩存 > 使用result.Name > 轉成呼叫 ((DapperRow)result)["Name"] > 從DapperTable.Values陣列中以"Name"欄位對應的Index取值

接著查看源碼GetDapperRowDeserializer方法,它掌管dynamic如何運行的邏輯,並動態建立成Func給上層API呼叫、緩存重複利用。 20191003190836.png

此段Func邏輯 :

  1. DapperTable雖然是方法內的局部變量,但是被生成的Func引用,所以不會被GC一直保存在內存內重複利用。 20191003182219.png

  2. 因為是dynamic不需要考慮類別Mapping,這邊直接使用GetValue(index)向數據庫取值

  1. 將資料保存到DapperRow內

  1. DapperRow 繼承 IDynamicMetaObjectProvider 並實作 GetMetaObject 方法,實作邏輯是返回DapperRowMetaObject物件。

  1. DapperRowMetaObject主要功能是定義行為,藉由override BindSetMember、BindGetMember方法,Dapper定義了Get、Set的行為分別使用IDictionary<string, object> - GetItem方法DapperRow - SetValue方法 20191003210351.png 20191003210547.png

  2. 最後Dapper利用DataReader的欄位順序性,先利用欄位名稱取得Index,再利用Index跟Values取得值

20191003211448.png

為何要繼承IDictionary<string,object>?

可以思考一個問題 : 在DapperRowMetaObject可以自行定義Get跟Set行為,那麼不使用Dictionary - GetItem方法,改用其他方式,是否代表不需要繼承IDictionary<string,object>?

Dapper這樣做的原因之一跟開放原則有關,DapperTable、DapperRow都是底層實作類別,基於開放封閉原則不應該開放給使用者,所以設為private權限。

那麼使用者想要知道欄位名稱怎麼辦? 因為DapperRow實作IDictionary所以可以向上轉型為IDictionary<string, object>,利用它為公開介面特性取得欄位資料。

舉個例子,筆者有做一個小工具HtmlTableHelper就是利用這特性,自動將Dapper Dynamic Query轉成Table Html,如以下代碼跟圖片

20191003212846.png

   

4. Strongly Typed Mapping 原理 Part1 : ADO.NET對比Dapper

接下來是Dapper關鍵功能 Strongly Typed Mapping,因為難度高,這邊會切分成多篇來解說。

第一篇先以ADO.NET DataReader GetItem By Index跟Dapper Strongly Typed Query對比,查看兩者IL的差異,了解Dapper Query Mapping的主要邏輯。

有了邏輯後,如何實作,我這邊依序用三個技術 :Reflection、Expression、Emit 從頭實作三個版本Query方法來讓讀者漸進式了解。


ADO.NET對比Dapper

首先使用以下代碼來追蹤Dapper Query邏輯

這邊需要重點來看Dapper.SqlMapper.GenerateDeserializerFromMap方法,它負責Mapping的邏輯,可以看到裡面大量使用Emit IL技術。

20191004012713.png

要了解這段IL邏輯,我的方式 :「不應該直接進到細節,而是先查看完整生成的IL」,至於如何查看,這邊需要先準備 il-visualizer 開源工具,它可以在Runtime查看DynamicMethod生成的IL。

它預設支持vs 2015、2017,假如跟我一樣使用vs2019的讀者,需要注意

  1. 需要手動解壓縮到 %USERPROFILE%\Documents\Visual Studio 2019路徑下面 20191004005622.png

  2. .netstandard2.0專案,需要建立netstandard2.0並解壓縮到該資料夾 20191003044307.png

最後重開visaul studio並debug運行,進到GetTypeDeserializerImpl方法,對DynamicMethod點擊放大鏡 > IL visualizer > 查看Runtime生成的IL代碼 20191003044320.png

可以得出以下IL

要了解這段IL之前需要先了解ADO.NET DataReader快速讀取資料方式會使用GetItem By Index方式,如以下代碼

接著查看此Demo - CastToUser方法生成的IL代碼

跟Dapper生成的IL比對可以發現大致是一樣的(差異部分後面會講解),代表兩者在運行的邏輯、效率上都會是差不多的,這也是為何Dapper效率接近原生ADO.NET的原因之一。

   

5. Strongly Typed Mapping 原理 Part2 : Reflection版本

在前面ADO.NET Mapping例子可以發現嚴重問題「沒辦法多類別共用方法,每新增一個類別就需要重寫代碼」。要解決這個問題,可以寫一個共用方法在Runtime時期針對不同的類別做不同的邏輯處理。

實作方式做主要有三種Reflection、Expression、Emit,這邊首先介紹最簡單方式:「Reflection」,我這邊會使用反射方式從零模擬Query寫代碼,讓讀者初步了解動態處理概念。(假如有經驗的讀者可以跳過本篇)

邏輯 :

  1. 使用泛型傳遞動態類別

  2. 使用泛型的條件約束new()達到動態建立物件

  3. DataReader需要使用屬性字串名稱當Key,可以使用Reflection取得動態類別的屬性名稱,在藉由DataReader this[string parameter]取得數據庫資料

  4. 使用PropertyInfo.SetValue方式動態將數據庫資料賦予物件

最後得到以下代碼 :

Reflection版本優點是代碼簡單,但它有以下問題

  1. 不應該重複屬性查詢,沒用到就要忽略 舉例 : 假如類別有N個屬性,SQL指查詢3個欄位,土炮ORM每次PropertyInfo foreach還是N次不是3次。而Dapper在Emit IL當中特別優化此段邏輯 : 「查多少用多少,不浪費」(這段之後講解)。 https://ithelp.ithome.com.tw/upload/images/20191003/20105988Y7jmgF76Wd.png https://ithelp.ithome.com.tw/upload/images/20191003/20105988nHZMb3Copc.png

  2. 效率問題 :

  • 反射效率會比較慢,這點之後會介紹解決方式 : 「查表法 + 動態建立方法」以空間換取時間。

  • 使用字串Key取值會多呼叫了GetOrdinal方法,可以查看MSDN官方解釋,效率比Index取值差https://ithelp.ithome.com.tw/upload/images/20191003/20105988ABufu55xes.png https://ithelp.ithome.com.tw/upload/images/20191003/20105988TqMlMbAIls.png

 

6.Strongly Typed Mapping 原理 Part3 : 動態建立方法重要概念「結果反推程式碼」優化效率

接著使用Expression來解決Reflection版本問題,主要是利用Expression特性 : 「可以在Runtime時期動態建立方法」來解決問題。

在這之前需要先有一個重要概念 : 「從結果反推最簡潔代碼」優化效率,舉個例子 : 以前初學程式時一個經典題目「打印正三角型星星」做出一個長度為3的正三角,常見作法會是迴圈+遞迴方式

但其實這個題目在已經知道長度的情況下,可以被改成以下代碼

這個概念很重要,因為是從結果反推代碼,所以邏輯直接、效率快,而Dapper就是使用此概念來動態建立方法。

舉例 : 假設有一段代碼如下,我們可以從結果得出

  • User Class的Name屬性對應Reader Index 0 、類別是String 、 預設值是null

  • User Class的Age屬性對應Reader Index 1 、類別是int 、 預設值是0

假如系統能幫忙生成以下邏輯方法,那麼效率會是最好的

另外上面例子可以看出對Dapper來說SQL Select對應Class屬性順序很重要,所以後面會講解Dapper在緩存的算法特別針對此優化。

 

7.Strongly Typed Mapping 原理 Part4 : Expression版本

有了前面的邏輯,就著使用Expression實作動態建立方法。

為何先使用 Expression 實作而不是 Emit ?

除了有能力動態建立方法,相比Emit有以下優點 :

  • 可讀性好,可用熟悉的關鍵字,像是變量Variable對應Expression.Variable、建立物件New對應Expression.New https://ithelp.ithome.com.tw/upload/images/20190920/20105988rkSmaILTw7.png

  • 方便Runtime Debug,可以在Debug模式下看到Expression對應邏輯代碼 https://ithelp.ithome.com.tw/upload/images/20190920/201059882EODD9OdnD.png https://ithelp.ithome.com.tw/upload/images/20190920/201059882gSYyfUduS.png

所以特別適合介紹動態方法建立,但Expression相比Emit無法作一些細節操作,這點會在後面Emit講解到。

改寫Expression版本

邏輯 :

  1. 取得sql select所有欄位名稱

  2. 取得mapping類別的屬性資料 > 將index,sql欄位,class屬性資料做好對應封裝在一個變量內方便後面使用

  3. 動態建立方法 : 從數據庫Reader按照順序讀取我們要的資料,其中代碼邏輯 :

最後得出以下Exprssion版本代碼

查詢效果圖 : 20191004205645.png

最後查看Expression.Lambda > DebugView(注意是非公開屬性)驗證代碼 :

20191005035640.png

 

8. Strongly Typed Mapping 原理 Part5 : Emit IL反建立C#代碼

有了前面Expression版本概念後,接著可以進到Dapper底層最核心的技術 : Emit。

首先要有個概念,MSIL(CIL)目的是給JIT編譯器看的,所以可讀性會很差、難Debug,但比起Expression來說可以做到更細節的邏輯操作。

在實際環境開發使用Emit,一般會先寫好C#代碼後 > 反編譯查看IL > 使用Emit建立動態方法,舉例 :

1.首先建立一個簡單打印例子 :

2.反編譯查看IL

3.使用DynamicMethod + Emit建立動態方法

https://ithelp.ithome.com.tw/upload/images/20190924/20105988bD9GSXyjNt.png

但是對已經寫好的專案來說就不是這樣流程了,開發者不一定會好心的告訴你當初設計的邏輯,所以接著討論此問題。

如果像是Dapper只有Emit IL沒有C# Source Code專案怎麼辦?

我的解決方式是 : 「既然只有Runtime才能知道IL,那麼將IL保存成靜態檔案再反編譯查看」

這邊可以使用MethodBuild + Save方法將IL保存成靜態exe檔案 > 反編譯查看,但需要特別注意

  1. 請對應好參數跟返回類別,否則會編譯錯誤。

  2. netstandard不支援此方式,Dapper需要使用region if 指定版本來做區分,否則不能使用,如圖片 20191004230125.png

代碼如下 :

接著使用此方式在GetTypeDeserializerImpl方法反編譯Dapper Query Mapping IL,可以得出C#代碼 :

20191004230548.png

有了C#代碼後再來了解Emit邏輯會快很多,接著就可以進到Emit版本Query實作部分。

 

9.Strongly Typed Mapping 原理 Part6 : Emit版本

以下代碼是Emit版本,我把C#對應IL部分都寫在註解。

這邊Emit的細節概念非常的多,這邊無法全部都講解,先挑出重要概念講解

Emit Label

在Emit if/else需要使用Label定位,告知編譯器條件為true/false時要跳到哪個位子,舉例 : 「boolean轉整數」,假設要簡單將Boolean轉換成Int,C#代碼可以用「如果是True返回1否則返回0」邏輯來寫:

當轉成Emit寫法的時候,需要以下邏輯 :

  1. 考慮Label動態定位問題

  2. 先要建立好Label讓Brtrue_S知道符合條件時要去哪個Label位子 (注意,這時候Label位子還沒確定)

  3. 繼續按順序由上而下建立IL

  4. 等到了符合條件要運行區塊的前一行,使用MarkLabel方法標記Label的位子

最後寫出的C# Emit代碼 :


這邊可以發現Emit版本 優點 :

  1. 能做更多細節的操作

  2. 因為細節顆粒度小,可以優化的效率更好

缺點 :

  1. 難以Debug

  2. 可讀性差

  3. 代碼量變大、複雜度增加

接著來看Dapper作者的建議,現在一般專案當中沒有必要使用Emit,使用Expression + Func/Action已經可以解決大部分動態方法的需求,尤其是Expression支援Block等方法情況。連結 c# - What's faster: expression trees or manually emitting IL

20190927163441.png

話雖如此,但有一些厲害的開源專案就是使用Emit管理細節,如果想看懂它們,就需要基礎的Emit IL概念

   

10.Dapper 效率快關鍵之一 : Cache 緩存原理

為何Dapper可以這麼快?

前面介紹到動態使用 Emit IL 建立 ADO.NET Mapping 方法,但單就這功能無法讓 Dapper 被稱為輕量ORM效率之王。

因為動態建立方法是需要成本、並耗費時間的動作,單純使用反而會拖慢速度。但當配合 Cache 後就不一樣,將建立好的方法保存在 Cache 內,可以用『空間換取時間』概念加快查詢的效率,也就是俗稱查表法

接著追蹤Dapper源碼,這次需要特別關注的是QueryImpl方法下的Identity、GetCacheInfo https://ithelp.ithome.com.tw/upload/images/20191005/20105988cCwaS7ejnY.png

Identity、GetCacheInfo

Identity主要封裝各緩存的比較Key屬性 :

  • sql : 區分不同SQL字串

  • type : 區分Mapping類別

  • commandType : 負責區分不同數據庫

  • gridIndex : 主用用在QueryMultiple,後面講解。

  • connectionString : 主要區分同數據庫廠商但是不同DB情況

  • parametersType : 主要區分參數類別

  • typeCount : 主要用在Multi Query多映射,需要搭配override GetType方法,後面講解

接著搭配GetCacheInfo方法內Dapper使用的緩存類別ConcurrentDictionary<Identity, CacheInfo>,使用TryGetValue方法時會去先比對HashCode接著比對Equals特性,如圖片源碼。 https://ithelp.ithome.com.tw/upload/images/20191005/20105988tOgZiBCwly.png

將Key類別Identity藉由override Equals方法實現緩存比較算法,可以看到以下Dapper實作邏輯,只要一個屬性不一樣就會建立一個新的動態方法、緩存。

以此概念拿之前Emit版本修改成一個簡單Cache Demo讓讀者感受:

效果圖 : https://ithelp.ithome.com.tw/upload/images/20191005/20105988mKud6Ejzqe.png

 

11.錯誤SQL字串拼接方式,會導致效率慢、內存洩漏

了解實作邏輯後,接著延伸一個Dapper使用的重要觀念,SQL字串為緩存重要Key值之一,假如不同的SQL字串,Dapper會為此建立新的動態方法、緩存,所以使用不當情況下就算使用StringBuilder也會造成效率慢、內存洩漏問題

https://ithelp.ithome.com.tw/upload/images/20190916/20105988lAFBAWbhS6.png

至於為何要以SQL字串當其中一個關鍵Key,而不是單純使用Mapping類別的Handle,其中原因之一是跟查詢欄位順序有關,在前面有講到,Dapper使用「結果反推程式碼」方式建立動態方法,代表說順序跟資料都必須要是固定的,避免SQL Select欄位順序不一樣又使用同一組動態方法,會有A欄位值給B屬性錯值大問題。

最直接解決方式,對每個不同SQL字串建立不同的動態方法,並保存在不同的緩存。

舉例,以下代碼只是簡單的查詢動作,查看Dapper Cache數量卻達到999999個,如Gif動畫顯示

zeiAPVJ

要避免此問題,只需要保持一個原則重複利用SQL字串,而最簡單方式就是參數化, 舉例 : 將上述代碼改成以下代碼,緩存數量降為1,達到重複利用目的 :

4IR5M47

 

12.Dapper SQL正確字串拼接方式 : Literal Replacement

假如遇到必要拼接SQL字串需求的情況下,舉例 : 有時候值使用字串拼接會比不使用參數化效率好,特別是該欄位值只會有幾種固定值

這時候Dapper可以使用Literal Replacements功能,使用方式 : 將要拼接的值字串以{=屬性名稱}取代,並將值保存在Parameter參數內,舉例 :

為什麼Literal Replacement可以避免緩存問題

首先追蹤源碼GetCacheInfo下GetLiteralTokens方法,可以發現Dapper在建立緩存之前會抓取SQL字串內符合{=變量名稱}規格的資料。

接著在CreateParamInfoGenerator方法生成Parameter參數化動態方法,此段方法IL如下 :

接著再生成Mapping動態方法,要了解此段邏輯我這邊做一個模擬例子方便讀者理解 :

看完以上例子,可以發現Dapper Literal Replacements底層原理就是字串取代,同樣屬於字串拼接方式,為何可以避免緩存問題?

這是因為取代的時機點在SetParameter動態方法內,所以Cache的SQL Key是沒有變動過的,可以重複利用同樣的SQL字串、緩存。

也因為是字串取代方式,所以只支持基本Value類別,假如使用String類別系統會告知The type String is not supported for SQL literals.,避免SQL Injection問題。

   

13.Query Multi Mapping 使用方式

接著講解Dapper Multi Mapping(多對應)實作跟底層邏輯,畢竟工作當中不可能都是一對一概念。

使用方式 :

  • 需要自己編寫Mapping邏輯,使用方式 : Query<Func邏輯>(SQL,Parameter,Mapping邏輯Func)

  • 需要指定泛型參數類別,規則為Query<Func第一個類別,Func第二個類別,..以此類推,Func最後返回類別> (最多支持六組泛型參數)

  • 指定切割欄位名稱,預設使用ID,假如不一樣需要特別指定 (這段後面特別講解)

  • 以上順序都是由左至右

舉例 : 有訂單(Order)跟會員(User)表格,關係是一對多關係,一個會員可以有多個訂單,以下是C# Demo代碼 :

20191001145311.png

支援dynamic Multi Mapping

在初期常變動表格結構或是一次性功能不想宣告Class,Dapper Multi Mapping也支援dynamic方式

20191002023135.png

SplitOn區分類別Mapping組別

Split預設是用來切割主鍵,所以預設切割字串是Id,假如當表格結構PK名稱為Id可以省略參數,舉例 20191001151715.png

假如主鍵名稱是其他名稱,請指定splitOn字串名稱,並且對應多個可以使用,做區隔,舉例,添加商品表格做Join :

 

14.Query Multi Mapping 底層原理

Multiple Mapping 底層原理

這邊先以一個簡單Demo帶讀者了解Dapper Multi Mapping 概念

  1. 按照泛型類別參數數量建立對應數量的Mapping Func集合

  2. Mapping Func建立邏輯跟Query Emit IL一樣

  3. 呼叫使用者的Custom Mapping Func,其中參數由前面動態生成的Mapping Func而來

以上概念就是此方法的主要邏輯,接著講其他細節部分

支持多組類別 + 強型別返回值

Dapper為了強型別多類別Mapping使用多組泛型參數方法方式,這方式有個小缺點就是沒辦法動態調整,需要以寫死方式來處理。

舉例,可以看到圖片GenerateMapper方法,依照泛型參數數量,寫死強轉型邏輯,這也是為何Multiple Query有最大組數限制,只能支持最多6組的原因。 20191001173320.png

多類別泛型緩存算法

  • 這邊Dapper使用泛型類別強型別保存多類別的資料 20191001175139.png

  • 並配合繼承共用Identity大部分身分驗證邏輯

  • 提供可override的GetType方法,來客製泛型比較邏輯,避免造成跟Non Multi Query緩存衝突

20191001175600.png
20191001175707.png

Dapper Query Multi Mapping的Select順序很重要

因為SplitOn分組基礎依賴於Select的順序,所以順序一錯就有可能屬性值錯亂情況。

舉例 : 假如上面例子的SQL改成以下,會發生User的ID變成Order的ID;Order的ID會變成User的ID。

原因可以追究到Dapper的切割算法

  1. 首先倒序方式處理欄位分組(GetNextSplit方法可以看到從DataReader Index大到小查詢) 20191002022109.png

  2. 接著倒序方式處理類別的Mapping Emit IL Func

  3. 最後反轉為正序,方便後面Call Func對應泛型使用 20191002021750.png 20191002022208.png 20191002022214.png

   

15.QueryMultiple 底層原理

使用方式例子 :

使用QueryMultiple優點 :

  • 主要減少Reqeust次數

  • 可以將多個查詢共用同一組Parameter參數

QueryMultiple的底層實作邏輯 :

  1. 底層技術是ADO.NET - DataReader - MultipleResult

  2. QueryMultiple取得DataReader並封裝進GridReader

  3. 呼叫Read方法時才會建立Mapping動態方法,Emit IL動作跟Query方法一樣

  4. 接著使用ADO.NET技術呼叫DataReader NextResult取得下一組查詢結果

  5. 假如沒有下一組查詢結果才會將DataReader釋放


緩存算法

緩存的算法多增加gridIndex判斷,主要對每個result mapping動作做一個緩存,Emit IL的邏輯跟Query一樣。

20190930183038.png

沒有延遲查詢特性

注意Read方法使用的是buffer = true = 返回結果直接ToList保存在內存,所以沒有延遲查詢特性。

20190930183212.png
20190930183219.png

記得管理DataReader的釋放

Dapper 呼叫QueryMultiple方法時會將DataReader封裝在GridReader物件內,只有當最後一次Read動作後才會回收DataReader

20190930183447.png

所以沒有讀取完再開一個GridReader > Read會出現錯誤:已經開啟一個與這個 Command 相關的 DataReader,必須先將它關閉

20190930183532.png

要避免以上情況,可以改成using區塊方式,運行完區塊代碼後就會自動釋放DataReader

閒話 :

感覺Dapper GridReader好像有機會可以實作是否有NextResult方法,這樣就可以配合while方法一次讀取完多組查詢資料,等之後有空來想想有沒有機會做成。

概念代碼 :

 

16.TypeHandler 自訂Mapping邏輯使用、底層邏輯

遇到想要客製某些屬性Mapping邏輯時,在Dapper可以使用TypeHandler

使用方式 :

  • 建立類別繼承SqlMapper.TypeHandler

  • 將要客製的類別指定給泛型,e.g : JsonTypeHandler<客製類別> : SqlMapper.TypeHandler<客製類別>

  • 查詢的邏輯使用override實作Parse方法,增刪改邏輯實作SetValue方法

  • 假如多個類別Parse、SetValue共用同樣邏輯,可以將實作類別改為泛型方式,客製類別在AddTypeHandler時指定就可以,可以避免建立一堆類別,e.g : JsonTypeHandler<T> : SqlMapper.TypeHandler<T> where T : class

舉例 : 想要特定屬性成員在數據庫保存Json,在AP端自動轉成對應Class類別,這時候可以使用SqlMapper.AddTypeHandler<繼承實作TypeHandler的類別>

以下例子是User資料變更時會自動在Log欄位紀錄變更動作。

效果圖 : 20190929231937.png


接著追蹤TypeHandler源碼邏輯,需要分兩個部份來追蹤 : SetValue,Parse

SetValue底層原理

  1. AddTypeHandlerImpl方法管理緩存的添加

  2. 在CreateParamInfoGenerator方法Emit建立動態AddParameter方法時,假如該Mapping類別TypeHandler緩存內有資料,Emit添加呼叫SetValue方法動作。

  1. 在Runtime呼叫AddParameters方法時會使用LookupDbType,判斷是否有自訂TypeHandler 20191006151723.png 20191006151614.png

  2. 接著將建立好的Parameter傳給自訂TypeHandler.SetValue方法 20191006151901.png

最後查看IL轉成的C#代碼

可以發現生成的Emit IL會去從TypeHandlerCache取得我們實作的TypeHandler,接著呼叫實作SetValue方法運行設定的邏輯,並且TypeHandlerCache特別使用泛型類別依照不同泛型以Singleton方式保存不同handler,這樣有以下優點 :

  1. 只要傳遞泛型類別參數就可以取得同一個handler避免重複建立物件

  2. 因為是泛型類別,取handler時可以避免了反射動作,提升效率

https://ithelp.ithome.com.tw/upload/images/20190929/20105988x970H6xWXC.png
https://ithelp.ithome.com.tw/upload/images/20190929/20105988S7VZLLXLZo.png
https://ithelp.ithome.com.tw/upload/images/20190929/20105988Q1mWkL0GP6.png

Parse對應底層原理

主要邏輯是在GenerateDeserializerFromMap方法Emit建立動態Mapping方法時,假如判斷TypeHandler緩存有資料,以Parse方法取代原本的Set屬性動作。 https://ithelp.ithome.com.tw/upload/images/20190930/20105988JvCw5z207s.png

查看動態Mapping方法生成的IL代碼 :

轉成C#代碼來驗證 :

 

17. CommandBehavior的細節處理

這篇將帶讀者了解Dapper如何在底層利用CommandBehavior優化查詢效率,如何選擇正確Behavior在特定時機。

我這邊整理了各方法對應的Behavior表格 :

方法
Behavior

Query

CommandBehavior.SequentialAccess & CommandBehavior.SingleResult

QueryFirst

CommandBehavior.SequentialAccess & CommandBehavior.SingleResult & CommandBehavior.SingleRow

QueryFirstOrDefault

CommandBehavior.SequentialAccess & CommandBehavior.SingleResult & CommandBehavior.SingleRow

QuerySingle

CommandBehavior.SingleResult & CommandBehavior.SequentialAccess

QuerySingleOrDefault

CommandBehavior.SingleResult & CommandBehavior.SequentialAccess

QueryMultiple

CommandBehavior.SequentialAccess


SequentialAccess、SingleResult優化邏輯

首先可以看到每個方法都使用CommandBehavior.SequentialAccess,該標籤主要功能 使DataReader順序讀取行和列,行和列不緩衝,讀取一列後,它會從內存中刪除。,有以下優點 :

  1. 可按順序分次讀取資源,避免二進制大資源一次性讀取到內存,尤其是Blob或是Clob會配合GetBytes 或 GetChars 方法限制緩衝區大小,微軟官方也特別標註注意 : 20191003014421.png

  2. 實際環境測試,可以加快查詢效率

但它卻不是DataReader的預設行為,系統預設是CommandBehavior.Default 20191003015853.png CommandBehavior.Default有著以下特性 :

  1. 可傳回多個結果集(Multi Result)

  2. 一次性讀取行資料到內存

這兩個特性跟生產環境情況差滿多,畢竟大多時刻是只需要一組結果集配合有限的內存,所以除了SequentialAccess外Dapper還特別在大多方法使用了CommandBehavior.SingleResult,滿足只需一組結果就好避免浪費資源。

這段還有一段細節的處理,查看源碼可以發現除了標記SingleResult外,Dapper還特別加上一段代碼在結尾while (reader.NextResult()){},而不是直接Return(如圖片)

20191003021109.png

早些前我有特別發Issue(連結#1210)詢問過作者,這邊是回答 : 主要避免忽略錯誤,像是在DataReader提早關閉情況


QueryFirst搭配SingleRow,

有時候我們會遇到select top 1知道只會讀取一行資料的情況,這時候可以使用QueryFirst。它使用CommandBehavior.SingleRow可以避免浪費資源只讀取一行資料。

另外可以發現此段除了while (reader.NextResult()){}外還有while (reader.Read()) {},同樣是避免忽略錯誤,這是一些公司自行土炮ORM會忽略的地方。 20191003024206.png

與QuerySingle之間的差別

兩者差別在QuerySingle沒有使用CommandBehavior.SingleRow,至於為何沒有使用,是因為需要有多行資料才能判斷是否不符合條件並拋出Exception告知使用者

這段有一個特別好玩小技巧可以學,錯誤處理直接沿用對應LINQ的Exception,舉例:超過一行資料錯誤,使用new int[2].Single(),這樣不用另外維護Exceptiono類別,還可以擁有i18N多國語言化。 20191003025631.png 20191003025334.png

 

18.Parameter 參數化底層原理

接著進到Dapper的另一個關鍵功能 : 「Parameter 參數化」

主要邏輯 : GetCacheInfo檢查是否緩存內有動態方法 > 假如沒有緩存,使用CreateParamInfoGenerator方法Emit IL建立AddParameter動態方法 > 建立完後保存在緩存內

接著重點來看CreateParamInfoGenerator方法內的底成邏輯跟「精美細節處理」,使用了結果反推代碼方法,忽略「沒使用的欄位」不生成對應IL代碼,避免資源浪費情況。這也是前面緩存算法要去判斷不同SQL字串的原因。

以下是我挑出的源碼重點部分 :

接著查看IL來驗證,查詢代碼如下

CreateParamInfoGenerator AddParameter 動態方法IL代碼如下 :

IL轉成對應C#代碼:

可以發現雖然傳遞Age參數,但是SQL字串沒有用到,Dapper不會去生成該欄位的SetParameter動作IL。這個細節處理真的要給Dapper一個讚!

 

19. IN 多集合參數化底層原理

為何ADO.NET不支援IN 參數化,Dapper支援 ?

原理

  1. 判斷參數的屬性是否為IEnumerable類別子類別

  2. 假如是,以該參數名稱為主 + Parameter正則格式找尋SQL內的參數字串 (正則格式 : ([?@:]參數名)(?!\w)(\s+(?i)unknown(?-i))?)

  3. 將找到的字串以() + 多個屬性名稱+流水號方式替換

  4. 依照流水號順序依序CreateParameter > SetValue

關鍵程式部分 https://ithelp.ithome.com.tw/upload/images/20190925/20105988ouMJ6GRB7F.png

以下用sys.objects來查SQL Server的表格跟視圖當追蹤例子 :

Dapper會將SQL字串改成以下方式執行

查看Emit IL可以發現跟之前的參數化IL很不一樣,非常的簡短

轉成C#代碼來看,會很驚訝地發現:「這段根本不需要使用Emit IL簡直多此一舉」

沒錯,是多此一舉,甚至 IDataParameterCollection parameter = P_0.Parameters;這段代碼根本不會用到。

Dapper這邊做法是有原因的,因為要能跟非集合參數配合使用,像是前面例子加上找出訂單Orders名稱的資料邏輯

對應生成的IL轉換C#代碼就會是以下代碼,達到能搭配使用目的 :

另外為何Dapper這邊Emit IL會直接呼叫工具方法PackListParameters,是因為IN的參數化數量是不固定,所以不能由固定結果反推程式碼方式動態生成方法。

該方法裡面包含的主要邏輯:

  1. 判斷集合參數的類型是哪一種 (假如是字串預設使用4000大小)

  2. 正則判斷SQL參數以流水號參數字串取代

  3. DbCommand的Paramter的創建

https://ithelp.ithome.com.tw/upload/images/20190925/20105988KgYZmlciZJ.png SQL參數字串的取代邏輯也寫在這邊,如圖片 https://ithelp.ithome.com.tw/upload/images/20190925/20105988Rhner7LZPA.png

 

20.DynamicParameter 底層原理、自訂實作

這邊用個例子帶讀者了解DynamicParameter原理,舉例現在有一段代碼如下 :

前面已經知道String型態Dapper會自動將轉成數據庫Nvarchar並且長度為4000的參數,數據庫實際執行的SQL如下 :

這是一個方便快速開發的貼心設計,但假如遇到欄位是varchar型態的情況,有可能會因為隱性轉型導致索引失效,導致查詢效率變低。

這時解決方式可以使用Dapper DynamicParamter指定數據庫型態跟大小,達到優化效能目的


接著往底層來看如何實現,首先關注GetCacheInfo方法,可以看到DynamicParameters建立動態方法方式代碼很簡單,就只是呼叫AddParameters方法

代碼可以這麼簡單的原因,是Dapper在這邊特別使用「依賴於介面」設計,增加程式的彈性,讓使用者可以客制自己想要的實作邏輯。這點下面會講解,首先來看Dapper預設的實作類別DynamicParametersAddParameters方法的實作邏輯

可以發現Dapper在AddParameters為了方便性跟兼容其他功能,像是Literal Replacement、EnumerableMultiParameter功能,做了許多判斷跟動作,所以代碼量會比以前使用ADO.NET版本多,所以效率也會比較慢。

假如有效率苛求的需求,可以自己實作想要的邏輯,因為Dapper此段特別設計成「依賴於介面」,只需要實作IDynamicParameters介面就可以。

以下是我做的一個Demo,可以使用ADO.NET SqlParameter建立參數跟Dapper配合

https://ithelp.ithome.com.tw/upload/images/20191005/20105988qzCAsa5KZu.png

 

21. 單次、多次 Execute 底層原理

查詢、Mapping、參數講解完後,接著講解在增、刪、改情況Dapper我們會使用Execute方法,其中Execute Dapper分為單次執行、多次執行

單次Execute

以單次執行來說Dapper Execute底層是ADO.NET的ExecuteNonQuery的封裝,封裝目的為了跟Dapper的Parameter、緩存功能搭配使用,代碼邏輯簡潔明瞭這邊就不做多說明,如圖片 20191002144453.png

「多次」Execute

這是Dapper一個特色功能,它簡化了集合操作Execute之間的操作,簡化了代碼,只需要 : connection.Execute("sql",集合參數);

至於為何可以這麼方便,以下是底層的邏輯 :

  1. 確認是否為集合參數 20191002150155.png

  2. 建立一個共同DbCommand提供foreach迭代使用,避免重複建立浪費資源 20191002151237.png

  3. 假如是集合參數,建立Emit IL動態方法,並放在緩存內利用 20191002150349.png

  4. 動態方法邏輯是CreateParameter > 對Parameter賦值 > 使用Parameters.Add添加新建的參數,以下是Emit IL轉成的C#代碼 :

  1. foreach該集合參數 > 除了第一次外,每次迭代清空DbCommand的Parameters > 重新呼叫同一個動態方法添加Parameter > 送出SQL查詢


實作方式簡潔明瞭,並且細節考慮共用資源避免浪費(e.g共用同一個DbCommand、Func),但遇到大量執行追求效率需求情況,需要特別注意此方法每跑一次對數據庫送出一次reqesut,效率會被網路傳輸拖慢,所以這功能被稱為「多次執行」而不是「批量執行」的主要原因。

舉例,簡單Execute插入十筆資料,查看SQL Profiler可以看到系統接到10次Reqeust:

20191002151658.png

   

22. ExecuteScalar應用

ExecuteScalar因為其只能讀取第一組結果、第一筆列、第一筆資料特性,是一個常被遺忘的功能,但它在特定需求下還是能派上用場,底下用「查詢資料是否存在」例子來做說明。

首先,Entity Framwork如何高效率判斷資料是否存在?

假如有EF經驗的讀者會答使用Any而不是Count() > 1

使用Count系統會幫轉換SQL為 :

SQL Count 是一個匯總函數,會迭代符合條件的資料行判斷每列該資料是否為null,並返回其行數。

而Any語法轉換SQL使用EXISTS,它只在乎是否有沒有資料,代表不用檢查到每列,只需要其中一筆有資料就有結果,所以效率快。

Dapper如何做到同樣效果?

SQL Server可以使用SQL格式select top 1 1 from [表格] where 條件 搭配 ExecuteScalar 方法,接著在做一個擴充方法,如下 :

效果圖 : 20191003043825.png

使用如此簡單原因,是利用Dapper ExecuteScalar會去呼叫ExecuteScalarImpl其底層Parse邏輯

使用 Convert.ChangeType 轉成 bool : 「0=false,非0=true」 特性,讓系統可以簡單轉型為bool值。

注意

不要QueryFirstOrDefault代替,因為它需要在SQL額外做Null的判斷,否則會出現「NullReferenceException」。 20191003043931.png

這原因是兩者Parse實作方式不一樣,QueryFirstOrDefault判斷結果為null時直接強轉型 20191003043941.png

而ExecuteScalar的Parce實作多了為空時使用default值的判斷 20191003043953.png

   

23.總結

Dapper系列到這邊,重要底層原理差不多都講完了,這系列總共花了筆者連續25天的時間,除了想幫助讀者外,最大的收穫就是我自己在這期間更了解Dapper底層原理,並且學習Dapper精心的細節、框架處理。

另外想提Dapper作者之一Marc Gravell,真的非常熱心,在寫文章的期間有幾個概念疑問,發issue詢問,他都會熱心、詳細的回覆。並且也發現他對代碼的品質要求之高,舉例 : 在S.O發問,遇到他在底下留言 : 「他對目前Dapper IL的架構其實是不滿意的,甚至覺得粗糙,想搭配protobuf-net技術打掉重寫」 (謎之聲 : 真令人敬佩 )

連結 : c# - How to remove the last few segments of Emit IL at runtime - Stack Overflow https://ithelp.ithome.com.tw/upload/images/20190925/201059884hfaioQATW.png

最後筆者想說 : 寫這篇的初衷,是希望本系列可以幫助到讀者

  1. 了解底層邏輯,知其所以然,避免寫出吃掉效能的怪獸,更進一步完整的利用Dapper優點開發專案

  2. 可以輕鬆面對Dapper的面試,比起一般使用Dapper工程師回答出更深層的概念

  3. 從最簡單Reflection到常用Expression到最細節Emit從頭建立Mapping方法,帶讀者漸進式了解Dapper底層強型別Mapping邏輯

  4. 了解動態建立方法的重要概念「結果反推程式碼」

  5. 有基本IL能力,可以利用IL反推C#代碼方式看懂其他專案的底層Emit邏輯

  6. 了解Dapper因為緩存的算法邏輯,所以不能使用錯誤字串拼接SQL

Last updated