前言
在 .NET 中主要有兩種動態產生並編譯的方式,一種是通過 System.Linq.Expressions
命名空間中的 LambdaExpression
類的 CompileToMethod(...)
方法,但是這種方法只支援動態編譯到靜態方法,因為這個限制我們只能放棄它而採用 Emitting 產生編譯方案,雖然 Emitting 方案強大但是實現起來麻煩不少,必須要手動處理底層 IL 的各種細節,腦補一些 C# 編譯器的實現機理,同時還要瞭解一些基本的 IL(Intermediate Language) 和 CLR(JVM) 執行方面的知識。
基礎知識
因為要採用 Emitting 技術方案,必然需要瞭解 IL,如果你之前沒有怎麼接觸過,也不用灰心,網上有大量關於 IL 的入門文章,“30分鐘入門”還是沒問題的哈,畢竟 IL 相對 8086/8088 彙編來說,真的平易近人太多了。
首先你需要一個類似 ILSpy(http://ilspy.net) 這樣的工具來查看產生的IL或反編譯器集(ILSpy 最新版本還提供了 IL 與對應 C# 的比照解釋,使用者體驗真是體貼得不要不要的)。
一、不同於 8086/8088 這樣基於寄存器的指令集,IL 和 Java 位元組碼一樣都是基於棧的指令集,它們最明顯的區別就是指令的參數指定方式的差異。以“int x = 100+200”操作為例,IL 的指令序列大致是:
ldc.i4 100ldc.i4 200addstlocl.0
- 前兩行代碼分別將100和200這兩個32位整數載入到運算棧(Evaluation Stack)中;
- 第3行的 add 是加法運算指令,它會從運算棧彈出(Pop)兩次以得到它需要的兩個運算元(Operand),計算完成後又會將自己的計算結果壓入(Push)到計算棧中,這時棧頂的元素就是累加的結果(即整數300);
- 第4行的 stloc.0 是設定本地變數的指令,它會從計算棧彈出(Pop)一個元素,然後將該元素儲存到特定本地變數中(本樣本是第一個本地變數)。註:本地變數必須由方法預先聲明。
二、基本上組合語言或類似 IL 這樣的中間指令集都沒有進階語言中天經地義的 if/else、switch/case、do/while、for/foreach 這樣的基礎語言結構,它們只有類似 goto/jump/br 這樣的無條件跳轉和 br.true/br.false/beq/blt/bgt/ceq/clt/cgt 等之類的條件跳轉指令,進階語言中的很多基礎語言結構都是由編譯器或解譯器轉換成底層的跳轉結構的,所以在 Emitting 中我們也需要腦補編譯器中這樣的翻譯機制,將那些 if/else、while、for 之類的翻譯成對應的跳轉結構。
需要特別指出的是,因為 C/C++/C#/JAVA 之類的進階語言的邏輯運算中有“短路”的內建約定,所以在轉換成跳轉結構時,必須留意處理這個問題,否則會破壞語義並可能導致執行階段錯誤。
三、因為 IL 支援類名、欄位、屬性、方法等元素名稱中包含除字母、數字、底線之外的其他字元,所有各進階語言編譯器都會利用該特性,主要是為了避免與特定進階語言中使用者代碼發生命名衝突,我們亦會採用該策略。
有了上面的基礎知識,自己稍微花點時間閱讀一些 IL 代碼,再來翻閱 Zongsoft.Data.Entity 類的源碼就簡單了。另外,在反編譯閱讀 IL 代碼的時候,如果你反編譯的是 Debug 版本,會發現產生的 IL 對本地變數的處理非常囉嗦,重複儲存又緊接著載入本地變數的操作,這是因為編譯器沒有做最佳化導致,不用擔心,換成用 Release 編譯就好很多了,但是依然還是有一些手動最佳化的空間。
介面說明
實體動態產生器類的源碼位於 Zongsoft.CoreLibrary 項目中(https://github.com/Zongsoft/Zongsoft.CoreLibrary/blob/feature-data/src/Data/Entity.cs),這是一個靜態類,其主要公用方法定義如下:
public static Entity{ public static T Build<T>(); public static T Build<T>(Action<T> map); public static IEnumerable<T> Build<T>(int count, Action<T, int> map = null); public static object Build(Type type); public static object Build(Type type, Action<object> map); public static IEnumerable Build(Type type, int count, Action<object, int> map = null);}
公用的 Save()
方法是一個供調試之用的方法,它會將動態編譯的程式集儲存到檔案中,以便使用 ILSpy 這樣的工具反編譯查看,待 feature-data 合并到 master 分支之後會被移除。
關於跑分
在 https://github.com/Zongsoft/Zongsoft.CoreLibrary/blob/feature-data/samples/Zongsoft.Samples.Entities/Program.cs 類中的 PerformanceDynamic(int count)
是動態產生的跑分(效能測試)代碼,需要注意的是,如果是首次動態建立某個實體介面,內部會先進行動態編譯。
下面這兩種方式跑分測試方式會有不同的效能表現,大家先琢磨下原因再接著往下閱讀。
private static void PerformanceDynamic(int count){ // 擷取構建委託,可能會觸發內部的預先編譯(即預熱) var creator = Data.Entity.GetCreator(typeof(Models.IUserEntity)); // 建立跑分計時器 var stopwatch = new Stopwatch(); stopwatch.Start(); //開始計時 /* 第一種跑分 */ for(int i = 0; i < count; i++) { // 調用構建委託來建立實體類執行個體 var user = (Models.IUserEntity)creator(); user.UserId = (uint)i; user.Avatar = ":smile:"; user.Name = "Name: " + i.ToString(); user.FullName = "FullName"; user.Namespace = "Zongsoft"; user.Status = (byte)(i % byte.MaxValue); user.StatusTimestamp = (i % 11 == 0) ? DateTime.Now : DateTime.MinValue; user.CreatedTime = DateTime.Now; } stopwatch.Restart(); //重新計時 /* 第二種跑分 */ int index = 0; // 動態構建指定 count 個實體類執行個體(懶構建) var entities = Data.Entity.Build<Models.IUserEntity>(count); foreach(var user in entities) { user.UserId = (uint)index; user.Avatar = ":smile:"; user.Name = "Name: " + index.ToString(); user.FullName = "FullName"; user.Namespace = "Zongsoft"; user.Status = (byte)(index % byte.MaxValue); user.StatusTimestamp = (index++ % 11 == 0) ? DateTime.Now : DateTime.MinValue; user.CreatedTime = DateTime.Now; } stopwatch.Stop(); //停止計時}
在我的老台式機上跑一百萬(即count=1,000,000)次,第二種跑分代碼比第一種差不多要慢50~100毫秒左右,兩者區別就在於 for 迴圈與 Enumerable/Enumerator
模式的區別,我曾嘗試對 Build<T>(int count)
方法內部的 yield return
(由C#編譯器將該語句翻譯成 Enumerable/Enumerator
模式)改為手動實現,最佳化的思路是:因為在這個情境中,我們已知 count
數量,基於這個必要條件可以剔除 Enumerator
迴圈中一些不必要的條件判斷代碼。但是手動寫了 Enumerable/Enumerator
後發現,為了代碼安全性還是無法省略一些必要的條件判斷,因為不能確定使用者是否會採用 entities.GetEnumerator() + while 的方式來調用,也就是說即使在確定 count
的條件下也占不到任何效能上的便宜,畢竟基本的代碼安全性還是要優先保障的。
如上述所述,動態產生的程式碼並無效能問題,只是在應對一次性建立上百萬個實體執行個體並遍曆的情境下,為了排除 Enumerable/Enumerator
模式對效能的一點點“幹擾”(這是必須的)採取了一點最佳化手段,在實際業務中通常不需這麼處理,特此說明。
使用說明
將原有業務系統中各種實體類改為介面,這些介面可以繼承自 Zongsoft.Data.IEntity
也可以不用,不管實體介面是否從 Zongsoft.Data.IEntity
介面繼承,動態產生的實體類都會實現該介面,因此依然可以將動態建立的實體執行個體強制轉換為該介面。
注意:實體介面中不能含有事件、方法定義,即只能包含屬性定義。
變更通知
如果實體需要支援屬性變更通知,則實體介面必須增加對 System.ComponentModel.INotifyPropertyChanged
介面的繼承,但這樣的支援需要付出一點點效能成本,以下是動態產生後的部分C#代碼。
public interface IPerson{ string Name { get; set; }}// 不支援的屬性變更通知版本public class Person : IPerson, IEntity{ public string Name { get => _name; set => { _name = value; _MASK_ |= 1; } }}/* 增加對屬性變更通知的特性 */public interface IPerson : INotifyPropertyChanged{ string Name { get; set; }}// 支援屬性變更通知版本public class Person : IPerson, IEntity, INotifyPropertyChanged{ // 事件聲明 public event PropertyChangedEventHandler PropertyChanged; public string Name { get => _name; set => { if(_name == value) // 新舊值比對判斷 return; _name = value; _MASK_ |= 1; this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Name")); } }}
所謂一點點效能成本有兩點:①需要對新舊值進行比對,比對方法的實現效能對此處有至關影響;②對 PropertyChanged
事件的有效性判斷並呼叫事件委託。當然,如果這是必須的 feature 需求,那就無所謂成本了。
提示:關於新舊值比對的說明,如果屬性類型是基元類型,動態產生器會產生 bne/be 這樣的特定 IL 指令;否則如果該類型重寫了 == 操作符則會使用該操作符的實現;否則會調用 Object.Equals(...) 靜態方法來比對。
擴充屬性
在某些情境,需要手動處理屬性的 getter 或 setter 的商務邏輯,那該如何在動態產生中植入這些邏輯代碼呢?在 Zongsoft.Data.Entity
類中有個 PropertyAttribute
自訂屬性類別,可以利用它來聲明擴充屬性的實現。譬如下面的樣本:
public static UserExtension{ public static string GetAvatarUrl(IUser user) { if(string.IsNullOrEmpty(user.Avatar)) return null; return "URL:" + user.Avatar; }}public interface IUser{ string Avatar { get; set; } [Entity.Property(Entity.PropertyImplementationMode.Extension, typeof(UserExtension))] string AvatarUrl { get; }}/* 以下的 User 實體類為動態產生器產生的部分示意代碼。*/public class User : IUser, IEntity{ private string _avatar; public string Avatar { get => _avatar; set { _avatar = value; _MASK_ |= xxx; } } public string AvatarUrl { get { return UserExtension.GetAvatarUrl(this); } }}
上面的代碼比較好理解,就不多說,如果 IUser
介面中的 AvatarUrl
屬性是可讀寫屬性或者有 System.ComponentModel.DefaultValueAttribute
自訂特性修飾,那麼該屬性就會有對應的欄位,對應的屬性擴充方法也可以擷取該欄位值。
public static class UserExtension{ public static string GetAvatarUrl(IUser user, string value) { if(string.IsNullOrEmpty(value)) return $"http://...{user.Avatar}..."; return value; }}public interface IUser{ string Avatar { get; set; } [Entity.Property(Entity.PropertyImplementationMode.Extension, typeof(UserExtension))] string AvatarUrl { get; set; }}/* 以下的 User 實體類為動態產生器產生的部分示意代碼。*/public class User : IUser, IEntity{ private string _avatar; private string _avatarUrl; public string Avatar { get => _avatar; set { _avatar = value; _MASK_ |= xxx; } } // 只有讀取擷取擴充方法 public string AvatarUrl { get => Extension.GetAvatarUrl(this, _avatarUrl); set { _avatarUrl = value; _MASK_ |= xxx; } }}
當然擴充屬性方法支援讀寫兩種,下面是同時實現了兩個版本的擴充方法的樣子:
public static class UserExtension{ public static string GetAvatarUrl(IUser user, string value) { throw new NotImplementedException(); } public static bool SetAvatarUrl(IUser user, string value) { throw new NotImplementedException(); }}/* 以下的 User 實體類為動態產生器產生的部分示意代碼。*/public class User : IUser, IEntity{ public string AvatarUrl { get => UserExtension.GetAvatarUrl(this, _avatarUrl); set { if(UserExtension.SetAvatarUrl(this, _avatarUrl)) { _avatarUrl = value; _MASK_ |= xxx; } } }}
擴充屬性方法的定義約定:
- 必須是一個公用的靜態方法;
- 讀取方法名以 Get 打頭,後面接擴充屬性名並區分大小寫;
- 讀取方法的第一個參數必須是要擴充實體介面類型,第二個參數可選,如果有的話必須是擴充屬性的類型;傳回型別必須是擴充屬性的類型;
- 設定方法名以 Set 打頭,後面接擴充屬性名並區分大小寫;
- 設定方法的第一個參數必須是要擴充實體介面類型,第二參數是擴充屬性的類型,表示設定的新值;傳回型別必須是布爾類型,返回真(True)表示設定成功否則返回失敗(False),只有返回真對應的成員欄位才會被設定更新。
單例模式
某些情境中,屬性需要採用單例模式來實現,譬如一些集合類型的屬性。
public interface IDepartment{ [Entity.Property(Entity.PropertyImplementationMode.Singleton)] ICollection<IUser> Users { get; }}/* 以下的 Department 實體類為動態產生器產生的部分示意代碼。*/public class Department : IDepartment, IEntity{ private readonly object _users_LOCK; private ICollection<IUser> _users; public Department() { _users_LOCK = new object(); } public ICollection<IUser> Users { get { if(_users == null) { lock(_users_LOCK) { if(_users == null) { _users = new List<IUser>(); } } } return _users; } }}
實現採用的是雙檢鎖模式,必須注意到,每個單例屬性都會額外佔用一個用於雙檢鎖的 object
類型變數。
如果屬性類型是集合介面,那麼動態產生器會選擇一個合適的實現該介面的集合類;當然,你也可以自訂一個Factory 方法來建立對應的執行個體,在實體屬性中通過 PropertyAttribute
自定特性中聲明Factory 方法所在的類型即可。
注意:Factory 方法必須是一個公用的靜態方法,有一個可選的參數,參數類型為實體介面類型。
public static class DepartmentExtension{ public static ICollection<IUser> GetUsers(IDepartment department) { return new MyUserCollection(department); }}public interface IDepartment{ [Entity.Property(Entity.PropertyImplementationMode.Singleton, typeof(DepartmentExtension))] ICollection<IUser> Users { get; }}/* 以下的 Department 實體類為動態產生器產生的部分示意代碼。*/public class Department : IDepartment, IEntity{ private readonly object _users_LOCK; private ICollection<IUser> _users; public Department() { _users_LOCK = new object(); } public ICollection<IUser> Users { get { if(_users == null) { lock(_users_LOCK) { if(_users == null) { _users = DepartmentExtension.GetUsers(this); } } } return _users; } }}
預設值和自訂初始化
有時我們需要唯讀屬性,但又不需要單例模式這種相對較重的實現機制,可以採用 DefaultValueAttribute
這個自訂特性來處理這種情況。
提示:實體介面或屬性聲明的所有自訂特性都會被產生器添加到實體類的對應元素中,後面的示範代碼可能會省略這些產生的自訂特性,特此說明。
public interface IDepartment{ [DefaultValue("Popeye")] string Name { get; set; } [DefaultValue] ICollection<IUser> Users { get; }}/* 以下的 Department 實體類為動態產生器產生的部分示意代碼。*/public class Department : IDepartment, IEntity{ private string _name; private ICollection<IUser> _users; public Department() { _name = "Popeye"; _users = new List<IUser>(); } [DefaultValue("Popeye")] public string Name { get => _name; set { _name = value; _MASK_ |= xxx; } } [DefaultValue()] public ICollection<IUser> Users { get => _users; }}
除了支援固定(Mutable)預設值,還支援動態(Immutable)的,所謂動態值是指它的值不在 DefaultValueAttribute
中被固化,即指定 DefaultValueAttribute
的值為一個靜態類的類型,該靜態類中必須有一個名為 Get 打頭並以屬性名稱結尾的方法,該方法可以沒有參數,也可以有一個實體介面類型的參數,如下所示。
public static DepartmentExtension{ public static DateTime GetCreationDate() { return DateTime.Now; }}public interface IDepartment{ [DefaultValue(typeof(DepartmentExtension))] DateTime CreationDate { get; }}/* 以下的 Department 實體類為動態產生器產生的部分示意代碼。*/public class Department : IDepartment, IEntity{ private DateTime _creationDate; public Department() { _creationDate = DepartmentExtension.GetCreationDate(); } public DateTime CreationDate { get => _creationDate; }}
如果 DefaultValueAttribute
預設值自訂特性中指定的是一個類型(即 System.Type
),並且該類型不是一個靜態類的類型,並且屬性類型也不是 System.Type
的話,那則表示該類型為屬性的實際類型,這對於某些屬性被聲明為介面或基類的情況下尤為有用,如下所示。
public interface IDepartment{ [DefaultValue(typeof(MyManager))] IUser Manager { get; set; } [DefaultValue(typeof(MyUserCollection))] ICollection<IUser> Users { get; }}/* 以下的 Department 實體類為動態產生器產生的部分示意代碼。*/public class Department : IDepartment, IEntity{ private IUser _manager; private ICollection<IUser> _users; public Department() { _managert = new MyManager(); _users = new MyUserCollection(); } public IUser Manager { get => _manager; set => _manager = value; } public ICollection<IUser> Users { get => _users; }}
其他說明
預設產生的實體屬性為公用屬性(即非顯式實現方式),當出現實體介面在繼承中發生了屬性重名,或因為某些特殊需求導致必須對某個實體屬性以顯式方式實現,則可通過 Entity.PropertyAttribute
自定特性中的 IsExplicitImplementation=true
來開啟顯式實現機制。
在實體介面中聲明的各種自訂特性(Attribute),都會被動態產生器原樣添加到產生的實體類中。因此之前範例中,凡是介面以及介面的屬性聲明的各種自訂特性(包括:DefaultValueAttribute
、 Entity.PropertyAttribute
)都會被添加到動態產生的實體類的相應元素中,這對於某些應用是一個必須被支援的特性。
效能測試
在《實體類的動態產生(二)》中,我們已經驗證過設計方案的執行效能了,但結合上面介紹的功能特性細節,還需再提醒的是:因為開啟 DefaultValueAttribute
、擴充屬性方法、單例屬性、屬性變更通知都會導致產生的程式碼與最基本欄位訪問方式有所功能增強,對應要跑的代碼量增多,因此對跑分是有影響,但這種影響是確定可知的,它們是 feature 所需並非實現方案、演算法缺陷所致,敬請知曉。
譬二就是增加了屬性變更通知(即實體介面繼承了 INotifyPropertyChanged
)導致的效能影響(Dynamic Entity 所在行)。
寫在最後的話
該實體類動態產生器簡單易用、運行效能和記憶體利用率都非常不錯(包括提供 IEntiy 介面的超贊功能),將會成為今後我們所有業務系統的基礎結構之一,所以後續的文章中(如果還有的話)應該會經常看到它的應用。
算下來花了整整三天時間(白天晚上都在寫)才完成《實體類的動態產生》系列文章,真心覺得寫文章比寫代碼還累,而且這還是省略了應該配有的一些流程圖、架構圖的情況下。計劃接下來我會為 Zongsoft(https://github.com/Zongsoft) 系列開源項目撰寫該有的所有文檔,照這次這個寫法,心底不由升起一絲莫名恐懼和淡淡憂傷來。
最後,因為寫這個東西耽擱了不少造 Zongsoft.Data 這個輪子的時間,所以接下來得全力去造輪子了。打算每盩厔少一篇乾貨滿滿的技術文章在公眾號首發,希望不會讓自己失望吧。
關於 Zongsoft.Data 它一定會是一款效能滿血、易用且足夠靈活的資料引擎,首發即會支援四大關係型資料庫,後續會加入對 Elasticsearch 的支援,總之,它應該是不同於市面上任何一款 ORM 資料引擎的開源產品。我會陸續與大家分享有關它的一些設計思考以及實現中遇到的問題,當然,也可以在 github 上圍觀我的進展。
如果你覺得這次的文章對你有所協助,又或者你覺得我們的開源項目做的還不錯,請務必為我們點贊並關注我們的公眾號,這或許是我堅持寫下去的最大動力來源了。
提示
本文可能會更新,請閱讀原文: https://zongsoft.github.io/blog/zh-cn/zongsoft/entity-dynamic-generation-3,以避免因內容陳舊而導致的謬誤,同時亦有更好的閱讀體驗。
本作品採用 知識共用署名-非商業性使用-相同方式共用 4.0 國際許可協議 進行許可。歡迎轉載、使用、重新發布,但必須保留本文的署名 鐘峰(包含連結:http://zongsoft.github.io),不得用於商業目的,基於本文修改後的作品務必以相同的許可發布。如有任何疑問或授權方面的協商,請致信給我 (zongsoft@qq.com)。