源起
很多年前剛畢業那陣寫過一篇關於Entity Framework的文章,沒發首頁卻得到100+的推薦。可能是當時Entity Framework剛剛發布介紹EF的文章比較少。一晃這么多年過去了,EF6.1已經發布很久,EF7馬上就到來。那篇文章已經顯得相當過時,這期間園子里出現了很多介紹EF4/5/6版本的精彩文章,我的工作中也沒有在持續使用EF,本來也就不准備再寫現在這篇文章了。后來看到之前那篇文章還是有很多朋友在評論里給予鼓勵,再加上自己確實在使用新版EF的過程中也總結了一些心得,解決了一些問題。這里打算將這些新的經驗分享出來,本文不會像之前那片文章一樣從頭到尾完整的講解EF,EF現在覆蓋的面太大了,完整的介紹需要一本很厚的書,本文主要是總結一些我感覺EF中特別重要的組件和一些可以被稱作最佳實踐的使用方式。當然有不對的地方還請各位賜教。(由於對傳說中的領域設計不懂,一小部分內容可能不符合“領域設計”的要求,都是自己項目用着順手的寫法)
本文更沒有完整的示例,大部分代碼都是邊修改邊測試邊粘貼到文章中,下文代碼在EF6.1.3中測試過。我的大部分EF使用經驗都學習自大名鼎鼎的nopCommerce這個開源項目(后文會提到),有興趣的TX可以看一下這個項目的代碼,一定會有很大收獲。當然每家的代碼都有自己的風格,能取長補短也是很不錯的。
另外感謝園友@liulun提供51cnblogs這個顏值很高的博客園博文編輯器。
下面開始正題(文章很長,慢慢看吧。)
EF的發展歷程
還是先來說一下EF從誕生到現在這幾年的發展歷程吧。在EF最初的版本中,作為一個ORM組件其通過EDM文件(里面是一些xml)來配置數據庫與實體類之間的映射,實現數據進出數據庫的控制。最初的版本中只支持Database First,即由已有數據庫結構生成EDM,繼而得到實體類。后來EF在4.0版本起開始支持Model First即先建立EDM,然后生成數據庫。
在4.1版本開始,EF迎來了最大的變化--開始支持Code First模式,值得注意的是Code First不是和Database First或Model First平級的概念,而是和EDM平級的概念。使用Code First不再需要EDM來維護實體與數據庫之間的映射關系,這個映射完全通過代碼來完成,並在程序開始運行時在內存中建立一個映射模型,這也就是Code First這個名稱中Code的含義。
使用Code First一般都是先建立實體然后通過代碼配置實體到數據庫的映射,繼而生成數據庫(如果數據庫已存在,就不需要再生成數據庫,可以直接建立代碼映射模型),這也就是所謂的Model First模式。當然Code First也支持Database First,通過工具由現有數據庫生成實體,及實體映射數據庫的代碼。
選擇
關於EDM的詳細信息可以參考前文提到的那片文章,由於Code First的魅力極大,EDM文件又存在不利於版本管理等天生缺陷,基本上處於一個被拋棄的狀態。而且看園子中一些文章說在EF7版本可能取消EDM的支持,只保留Code First。我在第一次接觸Code Frist后就一直在使用它了,完全不在考慮EDM。
下文中我們將只以Code First為例來介紹EF,而不再涉及EDM。
核心
隨着Code First一起出現的DbContext和DbSet類絕對可以稱得上EF的功能核心,其取代了之前的ObjectContext和ObjectSet類,提供了與數據庫通信,管理內存中實體的重要功能。
DbContext類
主要是負責與數據庫進行通信,管理實體到數據庫的映射模型,跟蹤實體的更改(正如這個類名字Context所示,其維護了一個EF內存中容器,保存所有被加載的實體並跟蹤其狀態)。關於模型映射和更改跟蹤下面都有專門的小節來討論。Dbcontext中最常用的幾個方法如:
-
SaveChanges(和6.0開始增加的異步方法SaveChangesAsync):用於將實體的修改保存到數據庫。
-
Set<T>:獲取實體相應的DbSet對象,我們對實體的增刪改查操作都是通過這個對象來進行的。
還有幾個次常用但很重要的屬性方法:
-
Database屬性:一個數據庫對象的表示,通過其SqlQuery、ExecuteSqlCommand等方法可以直接執行一些Sql語句或SqlCommand;EF6起可以通過Database對象控制事務。
-
Entry:獲取EF Context中的實體的狀態,在更改跟蹤一節會討論其作用。
-
ChangeTracker:返回一個DbChangeTracker對象,通過這個對象的Entries屬性,我們可以查詢EF Context中所有緩存的實體的狀態。
DbSet類
這個類的對象正是通過剛剛提到的Set<T>方法獲取的對象。其中的方法都與操作實體有關,如:
-
Find/FindAsync:按主鍵獲取一個實體,首先在EF Context中查找是否有被緩存過的實體,如果查找不到再去數據庫查找,如果數據庫中存在則緩存到EF Context並返回,否則返回null。
-
Attach:將一個已存在於數據庫中的對象添加到EF Context中,實體狀態被標記為Unchanged。對於已有相同key的對象存在於EF Context的情況,如果這個已存在對象狀態為Unchanged則不進行任何操作,否則將其狀態更改為Unchanged。
-
Add:將一個已存在於數據庫中的對象添加到EF Context中,實體狀態被標記為Added。對於已有相同key的對象存在於EF Context且狀態為Added則不進行任何操作。
-
Remove:將一個已存在於EF Context中的對象標記為Deleted,當SaveChanges時,這個對象對應的數據庫條目被刪除。注意,調用此方法需要對象已經存在於EF Context。
-
Include:詳見下面預加載一節。
-
AsNoTracking:相見變更跟蹤一節。
-
Local屬性:用來跟蹤所有EF Context中狀態為Added,Modified、Unchanged的實體。作用好像不是太大。沒怎么用過。
-
Create:這個方法至今好像沒有用到過,不知道干啥的。有了解的評論中給解釋下吧。
映射
說一千道一萬,EF還是一個ORM工具,映射永遠是最核心的部分。所以接下來詳細介紹Code First模式下EF的映射配置。
通過Code First來實現映射模型有兩種方式Data Annotation和Fluent API。
Data Annotation需要在實體類(我通常的稱呼,一般就是一個Plain Object)的屬性上以Attribute的方式表示主鍵、外鍵等映射信息。這種方式不符合解耦合的要求所以一般不建議使用。
第二種方式就是要重點介紹的Fluent API。Fluent API的配置方式將實體類與映射配置進行解耦合,有利於項目的擴展和維護。
Fluent API方式中的核心對象是DbModelBuilder。
在重寫的DbContext的OnModelCreating方法中,我們可以這樣配置一個實體的映射:
protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Entity<Product>().HasKey(t => t.Id); base.OnModelCreating(modelBuilder); }
使用上面這種方式的一個問題是OnModelCreating方法會隨着映射配置的增多越來越大。一種更好的方式是繼承EntityTypeConfiguration<EntityType>並在這個類中添加映射代碼,如:
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { this.ToTable("Product"); this.HasKey(p => p.Id); this.Property(p => p.Name).IsRequired(); } }
然后將這個類的實例添加到modelBuilder的Configurations就可以了。
modelBuilder.Configurations.Add(new ProductMap());
如果不想手動一個個添加自定的映射配置類對象,還可以使用反射將程序集中所有的EntityTypeConfiguration<>一次性添加到modelBuilder.Configurations集合中,下面的代碼展示了這個操作(代碼來自nopCommerce項目):
var typesToRegister = Assembly.GetExecutingAssembly().GetTypes() .Where(type => !String.IsNullOrEmpty(type.Namespace)) .Where(type => type.BaseType != null && type.BaseType.IsGenericType && type.BaseType.GetGenericTypeDefinition() == typeof(EntityTypeConfiguration<>)); foreach (var type in typesToRegister) { dynamic configurationInstance = Activator.CreateInstance(type); modelBuilder.Configurations.Add(configurationInstance); }
這樣,OnModelCreating就大大簡化,並且一勞永逸的是,以后添加新的實體映射只需要添加新的繼承自EntityTypeConfiguration<>的XXXMap類而不需要修改OnModelCreating方法。
這種方式給實體和映射提供最佳的解耦合,強烈推薦。
EF CodeFirst的自動發現
例如我們的程序中有一個名為Employee的實體類,我們沒有為其定義映射配置(EntityTypeConfiguration<Employee>),但如果我們使用類似下面這樣的代碼去進行調用,EF會自動為Employee創建默認映射並進行遷移等一系列操作。
var employeeList = context.Set<Employee>().ToList();當然為了能更靈活的配置映射,還是建議手動創建EntityTypeConfiguration<Employee>。
另外2種情況下,EF也會自動創建映射。
類A的對象作為類B的一個導航屬性存在,如果類B被包含在EF映射中,則EF也會為類A創建默認映射。
類A繼承自類B,如果類A或類B中的一個被包含在EF映射中,則EF也會為另一個創建默認映射(且使用TPH方式進行,詳見下文映射高級話題)。
通過上面的介紹可以看到EntityTypeConfiguration類正事Fluent API的核心,下面我們以EntityTypeConfiguration的方法為線,依次了解如何進行Fluent API配置。
基本方法
ToTable:指定映射到的數據庫表的名稱。
HasKey:配置主鍵(也用於配置關聯主鍵)
Property:這個方法返回PrimitivePropertyConfiguration的對象,根據屬性不同可能是子類StringPropertyConfiguration的對象。通過這個對象可以詳細配置屬性的信息如IsRequired()或HasMaxLength(400)。
Ignore:指定忽略哪個屬性(不映射到數據表)
對於基本映射這幾個方法幾乎包括了一切,下面是個綜合示例:
ToTable("Product"); ToTable("Product","newdbo");//指定schema,不使用默認的dbo HasKey(p => p.Id);//普通主鍵 HasKey(p => new {p.Id, p.Name});//關聯主鍵 Property(p => p.Id).HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);//不讓主鍵作為Identity自動生成 Property(p => p.Name).IsRequired().HasMaxLength(20).HasColumnName("ProductName").IsUnicode(false);//非空,最大長度20,自定義列名,列類型為varchar而非nvarchar Ignore(p => p.Description);
使用modelBuilder.HasDefaultSchema("newdbo");可以給所有映射實體指定schema。
PrimitivePropertyConfiguration還有許多可配置的選項,如HasColumnOrder指定列在表中次序,IsOptional指定列是否可空,HasPrecision指定浮點數的精度等等,不再列舉。
配置關聯
下面一系列示例的主角是產品,為了配合演示還請了產品小伙伴們,它們將在演示過程中逐一登場。
基本上,下面展示的關聯的配置都可以從關聯類的任意一方的EntityTypeConfiguration<T>開始配置。無論從哪一方起開始配置,不同的寫法最終都能實現相同的效果。下面的示例將只展示其中之一配置的方式,等價的另一種配置不再展示。
產品類的基本結構如下,后面演示過程中將根據需要為其添加新的屬性。
public class Product { public int Id{ get; set; } public string Name { get; set; } public string Description { get; set; } }
1 - 1關聯
(雖然看起來最簡單,但這個好像是理解起來最麻煩的一種配置)
這種關聯從實際關系上來看是兩個類共享相同的值作為主鍵,比如有User表和UserPhoto表,他們都應該使用UserId作為主鍵,並且通過相同的UserId值進行關聯。但這種關系反映在數據庫中必須通過外鍵的概念來實現,這時候就需要一個表的主鍵既作為主鍵又作為關聯表的外鍵。EF中各種配置方式無非就是告訴EF CodeFirst讓那個表的主鍵作為另一個表的外鍵而已,現在不理解的,看一下下面的例子就明白了。(其實,如果用Data Annotation配置反而很簡單,[Key],[ForeignKey]標一標就可以了)
這節使用到的是保修卡這個角色,我們知道一個產品對應一個保修卡,產品和保修卡使用相同的產品編號。這正是我們說的1對1的好例子。
public class WarrantyCard { public int ProductId { get; set; } public DateTime ExpiredDate { get; set; } public virtual Product Product { get; set; } }
我們給Product也增加保修卡屬性:
public virtual WarrantyCard WarrantyCard { get; set; }
下面來看看怎么把Product和WarrantyCard關聯起來。經過“千百”次的嘗試,終於找到了下面這些結果看起來很正確的組合,先列於下方,后面慢慢分析:
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { ToTable("Product"); HasKey(p => p.Id); //第一組(兩條效果完全相同) HasRequired(p => p.WarrantyCard).WithRequiredDependent(i => i.Product); HasRequired(p => p.WarrantyCard).WithOptional(i => i.Product); //第二組(兩條效果完全相同) HasRequired(p => p.WarrantyCard).WithRequiredPrincipal(i => i.Product); HasOptional(p => p.WarrantyCard).WithRequired(i => i.Product); } } public class WarrantyCardMap : EntityTypeConfiguration<WarrantyCard> { public WarrantyCardMap() { ToTable("WarrantyCard"); HasKey(i => i.ProductId); } }
除了以上這些組合,其它組合都沒法達到效果(都會生成多余的外鍵)。
第一組Fluent API生成的遷移代碼:
CreateTable( "dbo.Product", c => new { Id = c.Int(nullable: false), Name = c.String(), Description = c.String(maxLength: 200), }) .PrimaryKey(t => t.Id) .ForeignKey("dbo.WarrantyCard", t => t.Id) .Index(t => t.Id); CreateTable( "dbo.WarrantyCard", c => new { ProductId = c.Int(nullable: false, identity: true), ExpiredDate = c.DateTime(nullable: false), }) .PrimaryKey(t => t.ProductId);
值得注意的是,外鍵指定在Product表的Id列上,Product的主鍵Id不作為標識列。
再來看看第二組Fluent API生成的遷移代碼:
CreateTable( "dbo.Product", c => new { Id = c.Int(nullable: false, identity: true), Name = c.String(), Description = c.String(maxLength: 200), }) .PrimaryKey(t => t.Id); CreateTable( "dbo.WarrantyCard", c => new { ProductId = c.Int(nullable: false), ExpiredDate = c.DateTime(nullable: false), }) .PrimaryKey(t => t.ProductId) .ForeignKey("dbo.Product", t => t.ProductId) .Index(t => t.ProductId);
變化就在於外鍵添加到WarrantyCard表的主鍵ProductId上,而且這個鍵也不做標識列使用了。
對於當前場景這兩組配置應該選擇那一組呢。對於產品和保修卡,肯定是先有產品后有保修卡,保修卡應該依賴於產品而存在。所以第二組配置把外鍵設置到WarrantyCard的主鍵更為合適,讓WarrantyCard依賴Product符合當前場景。即Product作為Principal而WarrantyCard作為Dependent,其實這么多代碼也無非就是明確兩個關聯對象Principal和Dependent的地位而已。
使用第二組配置創建表后,我們可以添加數據:
可以一次性添加保修卡和合格證:
var product = new Product() { Name = "空調", Description = "冰冰涼", WarrantyCard = new WarrantyCard() { ExpiredDate = DateTime.Now.AddYears(3) } }; context.Set<Product>().Add(product); context.SaveChanges();
也可以分開進行:
var product = new Product() { Name = "投影儀", Description = "高分辨率" }; context.Set<Product>().Add(product); context.SaveChanges(); WarrantyCard card = new WarrantyCard() { ProductId = product.Id, ExpiredDate = DateTime.Now.AddYears(3) }; context.Set<WarrantyCard>().Add(card); context.SaveChanges();
對於查詢來說,第一組和第二組配置生成的SQL相同。都是INNER JOIN,這里就不再列出了。
單向1 - *關聯(可為空)
這里新登場角色是和發票,發票有自己的編號,有些產品有發票,有些產品沒有發票。我們希望通過產品找到發票而又不需要由發票關聯到產品。
public class Invoice { public int Id { get; set; } public string InvoiceNo { get; set; } public DateTime CreateDate { get; set; } }
產品類新增的屬性如下:
public virtual Invoice Invoice { get; set; } public int? InvoiceId { get; set; }
可以使用如下代碼創建Product到Invoice的關聯
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { ToTable("Product"); HasKey(p => p.Id); HasOptional(p => p.Invoice).WithMany().HasForeignKey(p => p.InvoiceId); } } public class InvoiceMap : EntityTypeConfiguration<Invoice> { public InvoiceMap() { ToTable("Invoice"); HasKey(i => i.Id); } }
HasOptional表示一個產品可能會有發票,WithMany的參數為空表示我們不需要由發票關聯到產品,HasForeignKey用來指定Product表中的外鍵列。
還可以通過WillCascadeOnDelete()配置是否級聯刪除,這個大家都知道,就不多說了。
運行遷移后,數據庫生成的Product表外鍵可為空(注意實體類中表示外鍵的屬性一定要為Nullable類型,不然遷移代碼不能生成)。
下面寫段代碼來測試下這個映射配置,先是創建一個測試對象
var product = new Product() { Name = "書", Description = "碼農書籍", Invoice = new Invoice()//這里不創建Invoice也可以,因為其可以為null { InvoiceNo = "12345", CreateDate = DateTime.Now } }; context.Set<Product>().Add(product); context.SaveChanges();
然后查詢,注意,創建和查詢要分2次執行,不然不會走數據庫,直接由EF Context返回結果了。
var productGet = context.Set<Product>().Include(p=>p.Invoice).FirstOrDefault();
通過SS Profiler可以看到生成的SQL如下:
SELECT TOP (1) [Extent1].[Id] AS [Id], [Extent1].[Name] AS [Name], [Extent1].[Description] AS [Description], [Extent1].[InvoiceId] AS [InvoiceId], [Extent2].[Id] AS [Id1], [Extent2].[InvoiceNo] AS [InvoiceNo], [Extent2].[CreateDate] AS [CreateDate] FROM [dbo].[Products] AS [Extent1] LEFT OUTER JOIN [dbo].[Invoices] AS [Extent2] ON [Extent1].[InvoiceId] = [Extent2].[Id]
可以看到對於外鍵可空的情況,EF生成的SQL使用了LEFT OUTER JOIN,基本上復合我們的期待。
單向1 - *關聯(不可為空)
為了演示這個關聯,請出一個新對象合格證,合格證有自己的編號,而且一個產品是必須有合格證。
public class Certification { public int Id { get; set; } public string Inspector { get; set; } }
我們給Product添加關聯合格證的屬性:
public virtual Certification Certification { get; set; } public int CertificationId { get; set; }
配置Product到Certification映射的代碼與之前的類似,就是把HasOptional換成了HasRequired:
HasRequired(p => p.Certification).WithMany().HasForeignKey(p=>p.CertificationId);
生成的遷移代碼,外鍵列不能為空。創建對象時Product必須和Certification一起創建。生成的查詢語句除了把LEFT OUTER JOIN換成INNER JOIN外其他都一樣,不再贅述。
雙向1 - *關聯
這是比較常見的場景,如一個產品可以對應多張照片,每張照片關聯一個產品。先來看看新增的照片類:
public class ProductPhoto { public int Id { get; set; } public string FileName { get; set; } public float FileSize { get; set; } public virtual Product Product { get; set; } public int ProductId { get; set; } }
給Product增加ProductPhoto集合:
public virtual ICollection<ProductPhoto> Photos { get; set; }
然后是映射配置:
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { ToTable("Product"); HasKey(p => p.Id); HasMany(p => p.Photos).WithRequired(pp => pp.Product).HasForeignKey(pp => pp.ProductId); } } public class ProductPhotoMap : EntityTypeConfiguration<ProductPhoto> { public ProductPhotoMap() { ToTable("ProductPhoto"); HasKey(pp => pp.Id); } }
代碼很容易理解,HasMany表示Product中有多個ProductPhoto,WithRequired表示ProductPhoto一定會關聯到一個Product。
我們來看另一種等價的寫法(在ProductPhoto中配置關聯):
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { ToTable("Product"); HasKey(p => p.Id); } } public class ProductPhotoMap : EntityTypeConfiguration<ProductPhoto> { public ProductPhotoMap() { ToTable("ProductPhoto"); HasKey(pp => pp.Id); HasRequired(pp => pp.Product).WithMany(p => p.Photos).HasForeignKey(pp => pp.ProductId); } }
有沒有感覺和之前單向1 - *的配置很像?其實就是WithMany多了參數而已。隨着例子越來越多,大家應該對這幾個配置理解的越來越深了。
遷移到數據庫后,我們添加些數據測試下:
var product = new Product() { Name = "投影儀", Description = "高分辨率" }; context.Set<Product>().Add(product); context.SaveChanges(); ProductPhoto pp1 = new ProductPhoto() { FileName = "正面圖", FileSize = 3, ProductId = product.Id }; ProductPhoto pp2 = new ProductPhoto() { FileName = "側面圖", FileSize = 5, ProductId = product.Id }; context.Set<ProductPhoto>().Add(pp1); context.Set<ProductPhoto>().Add(pp2); context.SaveChanges();
試一試一次讀取Product及ProductPhoto:
var productGet = context.Set<Product>().Include(p=>p.Photos).ToList();
生成的SQL如下:
SELECT [Limit1].[Id] AS [Id], [Limit1].[Name] AS [Name], [Limit1].[Description] AS [Description], [Extent2].[Id] AS [Id1], [Extent2].[FileName] AS [FileName], [Extent2].[FileSize] AS [FileSize], [Extent2].[ProductId] AS [ProductId], CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1] FROM (SELECT TOP (1) [c].[Id] AS [Id], [c].[Name] AS [Name], [c].[Description] AS [Description] FROM [dbo].[Product] AS [c] ) AS [Limit1] LEFT OUTER JOIN [dbo].[ProductPhoto] AS [Extent2] ON [Limit1].[Id] = [Extent2].[ProductId]
有點小復雜,用LEFT OUTER JOIN的原因是,可能有的Product沒有ProductPhoto。
* - *關聯
這次輪到產品標簽登場了。一個產品可以有多個標簽,一個標簽也可對應多個產品:
public class Tag { public int Id { get; set; } public string Text { get; set; } public virtual ICollection<Product> Products { get; set; } }
給Product增加標簽集合:
public virtual ICollection<Tag> Tags { get; set; }
映射代碼:
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { ToTable("Product"); HasKey(p => p.Id); HasMany(p => p.Tags).WithMany(t => t.Products).Map(m => m.ToTable("Product_Tag_Mapping")); } } public class TagMap : EntityTypeConfiguration<Tag> { public TagMap() { ToTable("Tag"); HasKey(t => t.Id); } }
比較特殊的就是需要指定一個關聯表保存多對多的映射關系。
CreateTable( "dbo.Product_Tag_Mapping", c => new { Product_Id = c.Int(nullable: false), Tag_Id = c.Int(nullable: false), }) .PrimaryKey(t => new { t.Product_Id, t.Tag_Id }) .ForeignKey("dbo.Product", t => t.Product_Id, cascadeDelete: true) .ForeignKey("dbo.Tag", t => t.Tag_Id, cascadeDelete: true) .Index(t => t.Product_Id) .Index(t => t.Tag_Id);
一般情況下使用自動生成的外鍵就好,也可以自己定義外鍵名稱。
HasMany(p => p.Tags).WithMany(t => t.Products).Map(m => { m.ToTable("Product_Tag_Mapping"); m.MapLeftKey("Pid"); m.MapRightKey("Tid"); });
遷移代碼變成如下:
CreateTable( "dbo.Product_Tag_Mapping", c => new { Pid = c.Int(nullable: false), Tid = c.Int(nullable: false), }) .PrimaryKey(t => new { t.Pid, t.Tid }) .ForeignKey("dbo.Product", t => t.Pid, cascadeDelete: true) .ForeignKey("dbo.Tag", t => t.Tid, cascadeDelete: true) .Index(t => t.Pid) .Index(t => t.Tid);
把映射代碼中的WithMany參數去掉,就是一種單向* - *的映射效果。如我們需要通過Product找到所有Tag,但不需要通過Tag找到有這個標簽的Product。有點類似與單向1 - *。
但這里不管WithMany是否有參數,生成的遷移代碼都是一樣的。
我們也寫點數據進去,測試下:
var product = new Product() { Name = "投影儀", Description = "高分辨率", Tags = new List<Tag> { new Tag(){Text = "性價比高"} } }; context.Set<Product>().Add(product); context.SaveChanges();
使用預加載(Include(p=>p.Tags))時的SQL:
SELECT [Project1].[Id] AS [Id], [Project1].[Name] AS [Name], [Project1].[Description] AS [Description], [Project1].[C1] AS [C1], [Project1].[Id1] AS [Id1], [Project1].[Text] AS [Text] FROM ( SELECT [Limit1].[Id] AS [Id], [Limit1].[Name] AS [Name], [Limit1].[Description] AS [Description], [Join1].[Id] AS [Id1], [Join1].[Text] AS [Text], CASE WHEN ([Join1].[Product_Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1] FROM (SELECT TOP (1) [c].[Id] AS [Id], [c].[Name] AS [Name], [c].[Description] AS [Description] FROM [dbo].[Product] AS [c] ) AS [Limit1] LEFT OUTER JOIN (SELECT [Extent2].[Product_Id] AS [Product_Id], [Extent3].[Id] AS [Id], [Extent3].[Text] AS [Text] FROM [dbo].[Product_Tag_Mapping] AS [Extent2] INNER JOIN [dbo].[Tag] AS [Extent3] ON [Extent3].[Id] = [Extent2].[Tag_Id] ) AS [Join1] ON [Limit1].[Id] = [Join1].[Product_Id] ) AS [Project1] ORDER BY [Project1].[Id] ASC, [Project1].[C1] ASC
如你所料,因為現在存在3個表,所以使用了2次JOIN。
一點補充
之前的示例中用到多次HasForeignKey()方法來指定外鍵,如果實體類中不存在表示外鍵的屬性,我們可以用下面的方式指定外鍵列,這樣這個外鍵列只存在於數據庫,不存在於實體中:
HasOptional(p => p.Invoice).WithMany().Map(m => m.MapKey("DbOnlyInvoiceId"));
對於關聯的映射EF提供了很多方法,可謂讓人眼花繚亂,上面只寫了我了解的一部分,如有沒有覆蓋到的場景,歡迎大家在評論中討論。
dudu老大也曾寫了很多關於EF映射的文章,這應該是EF中最令人迷惑的一點,不知道未來某個版本能否簡化一下呢?
映射高級話題
創建索引
在EF6.1中,沒有原生的方式使用Fluent API創建索引,(Data Annotation配置方式下可以使用IndexAttribute標識一個屬性映射包含索引)我們可以借助Annotation讓Fluent API也可以用上IndexAttribute來實現映射中索引的配置,如下代碼。
this.Property(ls => DepartId).HasColumnAnnotation("DepartId ", new IndexAnnotation(new IndexAttribute("IX_ DepartId ") { IsUnique = true }))
重要說明
上面這段代碼是來自msdn中EF官方文檔的代碼,但我親測不能生成正確的DbMigration配置,其生成的遷移代碼如下(並不能正確生成索引):
AlterColumn("dbo.LineSpecific", "LineBaseId", c => c.Int(nullable: false, annotations: new Dictionary<string, AnnotationValues> { { "LineBaseId", new AnnotationValues(oldValue: null, newValue: "IndexAnnotation: { Name: IX_LineBaseId, IsUnique: False }") }, }));可以使用方式,請繼續往下讀
國外有同行把這個進行了封裝,可以使用Fluent API的方式對映射中索引進行配置:
這個擴展中的代碼很簡單,主要就是通過反射完成了上面代碼(那段不能工作的代碼)的配置:
//調用入口 public static EntityTypeConfiguration<TEntity> HasIndex<TEntity>( this EntityTypeConfiguration<TEntity> entityTypeConfiguration, string indexName, Func<EntityTypeConfiguration<TEntity>, PrimitivePropertyConfiguration> propertySelector, params Func<EntityTypeConfiguration<TEntity>, PrimitivePropertyConfiguration>[] additionalPropertySelectors) where TEntity : class { return entityTypeConfiguration.HasIndex(indexName, IndexOptions.Nonclustered, propertySelector, additionalPropertySelectors); } //一個支持多種參數的重載 public static EntityTypeConfiguration<TEntity> HasIndex<TEntity>( this EntityTypeConfiguration<TEntity> entityTypeConfiguration, string indexName, IndexOptions indexOptions, Func<EntityTypeConfiguration<TEntity>, PrimitivePropertyConfiguration> propertySelector, params Func<EntityTypeConfiguration<TEntity>, PrimitivePropertyConfiguration>[] additionalPropertySelectors) where TEntity : class { AddIndexColumn(indexName, indexOptions, 1, propertySelector(entityTypeConfiguration)); for (int i = 0; i < additionalPropertySelectors.Length; i++) { AddIndexColumn(indexName, indexOptions, i + 2, additionalPropertySelectors[i](entityTypeConfiguration)); } return entityTypeConfiguration; } //將IndexAttribute添加到IndexAnnotation private static void AddIndexColumn( string indexName, IndexOptions indexOptions, int column, PrimitivePropertyConfiguration propertyConfiguration) { var indexAttribute = new IndexAttribute(indexName, column) { IsClustered = indexOptions.HasFlag(IndexOptions.Clustered), IsUnique = indexOptions.HasFlag(IndexOptions.Unique) }; var annotation = GetIndexAnnotation(propertyConfiguration); if (annotation != null) { var attributes = annotation.Indexes.ToList(); attributes.Add(indexAttribute); annotation = new IndexAnnotation(attributes); } else { annotation = new IndexAnnotation(indexAttribute); } propertyConfiguration.HasColumnAnnotation(IndexAnnotation.AnnotationName, annotation); } //對屬性進行反射得到IndexAnnotation的幫助方法 private static IndexAnnotation GetIndexAnnotation(PrimitivePropertyConfiguration propertyConfiguration) { var configuration = typeof (PrimitivePropertyConfiguration) .GetProperty("Configuration", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(propertyConfiguration, null); var annotations = (IDictionary<string, object>) configuration.GetType() .GetProperty("Annotations", BindingFlags.Instance | BindingFlags.Public) .GetValue(configuration, null); object annotation; if (!annotations.TryGetValue(IndexAnnotation.AnnotationName, out annotation)) return null; return annotation as IndexAnnotation; }
這個庫的使用方式很簡單,而且可以用Fluent API編碼,最終代碼顏值很高(代碼來自官方示例):
.HasIndex("IX_Customers_Name", // Provide the index name. e => e.Property(x => x.LastName), // Specify at least one column. e => e.Property(x => x.FirstName)) // Multiple columns as desired. .HasIndex("IX_Customers_EmailAddress", // Supports fluent chaining for more indexes. IndexOptions.Unique, // Supports flags for unique and clustered. e => e.Property(x => x.EmailAddress));
當然最重要的是這個庫可以生成正確的Migration代碼:
CreateIndex("dbo. Customers ", " EmailAddress ", unique: true);
映射包含繼承關系的實體類
對於包含繼承關系的實體類,在使用EF CodeFirst映射時可以采用TPH、TPT和TPC三種方式完成:
TPH:這是EF CodeFirst采用的默認方式,繼承關系中的所有實體會被映射到同一張表。
TPT:所有類型映射到不同的表中,子類型所映射到的表只包含不存在於基類中的屬性。子類映射的表的主鍵同時作為關聯基類表的外鍵。
TPC:每個子類映射到不同的表,表中同時包含基類的屬性。這種情況下查詢非常復雜,真的完全不知道其存在的意義。后文也就不詳細介紹了。
先介紹一下幾演示所用的實體類,我們的產品類依然存在,這次多了幾個孩子。
public class Product { public int Id{ get; set; } public string Name { get; set; } public string Description { get; set; } } public class PaperProduct:Product { public int PageNum { get; set; } } public class ElectronicProduct : Product { public double LifeTime { get; set; } } public class CD : ElectronicProduct { public float Capacity { get; set; } }
它們的關系如圖所示:
圖1. Product類繼承關系圖
TPH(Table-Per-Hierarchy)
由於所有繼承層次的類在一個表中,使用一個列區分這些類就是這種方式最重要的一點。默認情況下,EF CodeFirst使用一個名為Discriminator的列並以類型名字符串作為值來區分不同的類。
我們可以使用如下配置來修改這個默認設置,另外由於TPH是EF CodeFirst的默認選擇,無需附加其他配置。
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { Map<Product>(p => { p.Requires("ProductType").HasValue(0); }).ToTable("Product"); HasKey(p => p.Id); Map<PaperProduct>(pp => { pp.Requires("ProductType").HasValue(1); }); Map<ElectronicProduct>(ep => { ep.Requires("ProductType").HasValue(2); }); Map<CD>(cd => { cd.Requires("ProductType").HasValue(3); }); } }
Requires方法指定區分實體的列的名稱,HasValue指定區分值。
特別注意,如果想要自定義表名的話,ToTable要和Map<Product>()在一行中調用,且ToTable()在后。。
添加點數據做測試:
var product = new Product() { Name = "投影儀", Description = "高分辨率" }; var paperproduct = new PaperProduct() { Name = "《天書》", PageNum = 5 }; var cd = new CD() { Name = "藍光大碟", LifeTime = 50, Capacity = 50 }; context.Set<Product>().Add(product); context.Set<Product>().Add(paperproduct); context.Set<Product>().Add(cd); context.SaveChanges();
看一下數據庫中表結構和數據:
圖2. TPH下的數據表
EF按我們的配置添加了名為ProductType的列。當然我們也看到有很多為NULL的列。對於數據的查詢不存在JOIN,就不再展示了。
TPT(Table-Per-Type)
這種方式下,所有存在於基類的屬性被存儲於一張表,每個子類存儲到一張表,表中只存子類獨有的屬性。子類表的主鍵作為基類表的主鍵的外鍵實現關聯。直接上配置代碼:
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { ToTable("Product"); HasKey(p => p.Id); Map<PaperProduct>(pp => { pp.ToTable("PaperProduct"); }); Map<ElectronicProduct>(ep => { ep.ToTable("ElectronicProduct"); }); Map<CD>(cd => { cd.ToTable("CD"); }); } }
如下是遷移代碼,按我們所想針對基類和子類都生成了表:
CreateTable( "dbo.Product", c => new { Id = c.Int(nullable: false, identity: true), Name = c.String(), Description = c.String(maxLength: 200), }) .PrimaryKey(t => t.Id); CreateTable( "dbo.PaperProduct", c => new { Id = c.Int(nullable: false), PageNum = c.Int(nullable: false), }) .PrimaryKey(t => t.Id) .ForeignKey("dbo.Product", t => t.Id) .Index(t => t.Id); CreateTable( "dbo.ElectronicProduct", c => new { Id = c.Int(nullable: false), LifeTime = c.Double(nullable: false), }) .PrimaryKey(t => t.Id) .ForeignKey("dbo.Product", t => t.Id) .Index(t => t.Id); CreateTable( "dbo.CD", c => new { Id = c.Int(nullable: false), Capacity = c.Single(nullable: false), }) .PrimaryKey(t => t.Id) .ForeignKey("dbo.ElectronicProduct", t => t.Id) .Index(t => t.Id);
我們使用TPH部分那段代碼來插入測試數據,然后看一下查詢生成的SQL。
先來查一下子類對象試試:
var productGet = context.Set<PaperProduct>().Where(r=>r.Id == 2).ToList();
生成的SQL看起來不錯,就是一個INNER JOIN:
SELECT '0X0X' AS [C1], [Extent1].[Id] AS [Id], [Extent2].[Name] AS [Name], [Extent2].[Description] AS [Description], [Extent1].[PageNum] AS [PageNum] FROM [dbo].[PaperProduct] AS [Extent1] INNER JOIN [dbo].[Product] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id] WHERE 2 = [Extent1].[Id]
再來一個基類對象試試:
var productGet = context.Set<Product>().Where(r=>r.Id == 1).ToList();
這次悲劇了:
SELECT CASE WHEN (( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)))) THEN '0X' WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL) AND ( NOT (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)))) THEN '0X0X' WHEN (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)) THEN '0X0X0X' ELSE '0X1X' END AS [C1], [Extent1].[Id] AS [Id], [Extent1].[Name] AS [Name], [Extent1].[Description] AS [Description], CASE WHEN (( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)))) THEN CAST(NULL AS float) WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL) AND ( NOT (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)))) THEN [Project3].[LifeTime] WHEN (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)) THEN [Project3].[LifeTime] END AS [C2], CASE WHEN (( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)))) THEN CAST(NULL AS real) WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL) AND ( NOT (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)))) THEN CAST(NULL AS real) WHEN (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)) THEN [Project3].[Capacity] END AS [C3], CASE WHEN (( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)))) THEN CAST(NULL AS int) WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL) AND ( NOT (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)))) THEN CAST(NULL AS int) WHEN (([Project3].[C2] = 1) AND ([Project3].[C2] IS NOT NULL)) THEN CAST(NULL AS int) ELSE [Project1].[PageNum] END AS [C4] FROM [dbo].[Product] AS [Extent1] LEFT OUTER JOIN (SELECT [Extent2].[Id] AS [Id], [Extent2].[PageNum] AS [PageNum], cast(1 as bit) AS [C1] FROM [dbo].[PaperProduct] AS [Extent2] ) AS [Project1] ON [Extent1].[Id] = [Project1].[Id] LEFT OUTER JOIN (SELECT [Extent3].[Id] AS [Id], [Extent3].[LifeTime] AS [LifeTime], cast(1 as bit) AS [C1], [Project2].[Capacity] AS [Capacity], CASE WHEN (([Project2].[C1] = 1) AND ([Project2].[C1] IS NOT NULL)) THEN cast(1 as bit) WHEN ( NOT (([Project2].[C1] = 1) AND ([Project2].[C1] IS NOT NULL))) THEN cast(0 as bit) END AS [C2] FROM [dbo].[ElectronicProduct] AS [Extent3] LEFT OUTER JOIN (SELECT [Extent4].[Id] AS [Id], [Extent4].[Capacity] AS [Capacity], cast(1 as bit) AS [C1] FROM [dbo].[CD] AS [Extent4] ) AS [Project2] ON [Extent3].[Id] = [Project2].[Id] ) AS [Project3] ON [Extent1].[Id] = [Project3].[Id] WHERE 1 = [Extent1].[Id]
試了幾種寫法,都不能改變把所有表都JOIN一遍的結果。看來是EF的問題。其實想想也對,Product類作為基類可以去引用子類的對象,生成這樣的SQL使我們有機會把得到Product對象轉換成子類對象。但我認為應該提供一種方法明確只獲取基類對象(不用做任何JOIN)。是我不知道呢?還是EF就是沒提供這樣的方法呢?
對於TPH和TPT兩種方式,前者會浪費一些存儲空間,后者因為查詢時JOIN損耗一些時間。個人認為對於子類和父類差別不太大的情況,可以選用TPH,這樣不會浪費太多空間同時也能有很好的查詢速度。而對於子類和父類差別較大的情況,TPT就是一個更好的選擇。
將一個實體映射到多個表
在數據庫設計中這常被稱作垂直分割。還是通過例子來看具體實現。我們給產品類增加2個新屬性:
public class Product { public int Id { get; set; } public string Name { get; set; } public string Description { get; set; } //new property public float Price { get; set; } public float Weight { get; set; } }
我們希望將新屬性存儲在另一張數據表中,可以按如下方式配置:
public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { Map(m => { m.Properties(t => new { t.Id, t.Name, t.Description }); m.ToTable("Product"); }) .Map(m => { m.Properties(t => new { t.Id, t.Price, t.Weight }); m.ToTable("ProductDetail"); }); HasKey(p => p.Id); } }
代碼一目了然,分開指定屬性和相應的表即可。生成的遷移代碼如下:
CreateTable( "sample.Product", c => new { Id = c.Int(nullable: false, identity: true), Name = c.String(), Description = c.String(maxLength: 200), }) .PrimaryKey(t => t.Id); CreateTable( "sample.ProductDetail", c => new { Id = c.Int(nullable: false), Price = c.Single(nullable: false), Weight = c.Single(nullable: false), }) .PrimaryKey(t => t.Id) .ForeignKey("sample.Product", t => t.Id) .Index(t => t.Id);
是不是很眼熟,對!和之前配置1 - 1映射生成的遷移代碼一模一樣。當然生成的查詢語句也是一樣的。
將兩個實體映射到一張表
我們把上一個例子中給Product增加的屬性獨立出來:
public class Product { public int Id { get; set; } public string Name { get; set; } public string Description { get; set; } public virtual ProductDetail ProductDetail { get; set; } } public class ProductDetail { public int Id { get; set; } public float Price { get; set; } public float Weight { get; set; } public virtual Product Product { get; set; } }
現在我們有2個實體類,接下來的配置將把它們映射到一張表:
public class ProductDetailMap : EntityTypeConfiguration<ProductDetail> { public ProductDetailMap() { HasKey(pd=>pd.Id).HasRequired(pd => pd.Product).WithRequiredPrincipal(p=>p.ProductDetail); ToTable("Product"); } } public class ProductMap : EntityTypeConfiguration<Product> { public ProductMap() { HasKey(p => p.Id); ToTable("Product"); } }
生成的遷移代碼可以看出,兩個實體將被保存到一張表:
CreateTable( "dbo.Product", c => new { Id = c.Int(nullable: false, identity: true), Name = c.String(), Description = c.String(maxLength: 200), Price = c.Single(nullable: false), Weight = c.Single(nullable: false), }) .PrimaryKey(t => t.Id);
映射部分就到這里了。休息下吧。
中場休息
借中場休息時間鄙視一下那些轉載不保留原鏈接的網站,尤其像numCTO這種。
變更跟蹤
變更跟蹤指的是對緩存於EF Context中的實體的狀態的跟蹤與改變。所以了解變更跟蹤先看了解一下實體在EF Context中的幾種狀態。下面是國外某網站看到的一幅很不錯的圖,直接拿過來用了。
圖3. EF Context中實體狀態 來源
支持變更跟蹤最關鍵的一點是實體必須有主鍵(如前文介紹通過Fluent API的HasKey<TKey>方法指定主鍵)。這樣實體才能被EF Context這個緩存容器進行維護,並與數據庫中相應的條目實現一一對應來支持增刪改查。
變更跟蹤是默認啟用的,可以通過配置DbContext來關閉這個功能,如下代碼:
context.Configuration.AutoDetectChangesEnabled = false;
注意:
一般來說不建議關閉變更跟蹤,除非是只讀(只讀情況下用AsNoTracking獲取實體並自己做緩存應該更好)。
在關閉變更跟蹤的情況下,可以通過如下方法手動調用一次變更檢測(或者用下文將介紹的手動狀態改變),這樣后續的SavaChanges操作才能正確完成。
context.ChangeTracker.DetectChanges();另外要注意的一點是,變更跟蹤只能在一個上下文內有效。即如果有兩個DbContext的實例,兩個DbContext各自作用域內的變更跟蹤是獨立的。
除了使用自動變更跟蹤,在對性能要求極端的情況下,也可以手動控制實體的狀態(另一種情況是實體本不在當前Context中,要加入當前Context控制下必須手動完成)。
與實體變更控制最密切的就是DBEntityEntry類,這個類的對象正是通過前文介紹的DbContext的Entry<T>方法獲得的。DBEntityEntry最重要的屬性就是獲取實體狀態的State屬性。
var entry = dbCtx.Entry(student); Console.WriteLine("Entity State: {0}", entry.State ); context.Entry(student).State = EntityState.Deleted;
上面幾行代碼展示了查詢與修改EF Context中實體狀態的方法。
最后這段綜合的代碼示例演示了在關閉變更跟蹤的情況下,手動修改實體狀態實現更新。
context.Configuration.AutoDetectChangesEnabled = false; var student = context.Set<Student>().FirstOrDefault(s => s.StudentName == "張三"); student.StudentName = "王五"; var stuEntry = context.Entry(student); stuEntry.State = EntityState.Modified; context.SaveChanges();
AsNoTracking
對於只讀操作,強烈建議使用AsNoTracking進行數據獲取,這樣省去了訪問EF Context的時間,會大大降低數據獲取的時間。
var student = context.Set<Student>().AsNoTracking().FirstOrDefault(s => s.StudentName == "王五");
由於沒有受EF Context管理,對於這樣獲取到的數據,更新的話需要先Attach然后手動修改狀態並SaveChanges。
student.StudentName = "張三"; context.Set<Student>().Attach(student); var stuEntry = context.Entry(student); stuEntry.State = EntityState.Modified; context.SaveChanges();
數據加載
EF中和數據加載關系最密切的方法是IQueryable中名為Load的方法。Load方法執行數據查詢並把獲取的數據放到EF Context中。Load()和我們常用的ToList()很像,只是它不創建列表只是把數據緩存到EF Context中。
var productGet = context.Set<Product>().Where(r=>r.Id == 1).ToList(); context.Set<Product>().Where(r=>r.Id == 1).Load();
第一行代碼我們把數據加載到EF Context中並創建一個列表並返回,第二個方法我們只是把數據加載到EF Context中。默認情況下我們很少會直接用到Load方法,一般ToList或First這樣的方法就幫我們完成加載數據操作了。
延遲加載
EF默認使用延遲加載獲取導航屬性關聯的數據。還是以之前用過的產品和發票為例。通過這個下面代碼和注釋很容易理解這個特性。
//此時不會加載Invoice屬性關聯的對象 var productGet = context.Set<Product>().First(r=>r.Id == 1); //直到用到Invoice時,才會新起一個查詢獲取Invoice var date = productGet.Invoice.CreateDate;
作為默認配置的延遲加載,需要滿足以下幾個條件:
-
context.Configuration.ProxyCreationEnabled = true;
-
context.Configuration.LazyLoadingEnabled = true;
-
導航屬性被標記為virtual
這三個條見缺一不可。
如果不滿足條件,延遲加載則不會啟用,這時候我們必須使用手動加載的方式來獲取關聯數據,否則程序在訪問到導航屬性又沒法進行延遲加載時就會報空引用異常。
手動加載就是通過DbReferenceEntry的Load方法來實現。我們把設置context.Configuration.LazyLoadingEnabled = false;(全局禁用延遲加載)以便在沒有延遲加載的環境進行測試。
把導航屬性virtual去掉可以禁用單個實體的延遲加載。
//此時不會加載Invoice屬性關聯的對象 var productGet = context.Set<Product>().First(r=>r.Id == 1); //手動加載Invoice context.Entry(productGet).Reference(p => p.Invoice).Load(); var date = productGet.Invoice.CreateDate;
與自動延遲加載一樣,手動加載也是兩條獨立的SQL分別獲取數據。手動加載集合屬性也類似,就是把Reference方法換成Collection方法。以ProductPhoto為例:
//此時不會加載Invoice屬性關聯的對象 var productGet = context.Set<Product>().First(r=>r.Id == 1); //手動加載Photos集合 context.Entry(productGet).Collection(p => p.Photos).Load(); var count = productGet.Photos.Count;
預加載
延遲加載包括手動加載這些方式中,獲取關聯數據都需要兩條獨立的SQL。如果我們確實同時需要一個對象及其關聯數據,可以使用預加載以使它們通過一條SQL獲取。在之前測試關聯的代碼中,我們已多次使用到預加載。
var product = context.Set<Product>().Include(p=>p.Invoice).FirstOrDefault();
這是之前用於測試的一條語句。我們同時再加產品及其發票,生成的SQL中使用了JOIN由兩個表獲取數據。
預加載就是使用Include方法並傳入需要同時獲取的關聯屬性。我們也可以使用字符串傳入屬性的名稱,如:
var product = context.Set<Product>().Include("Invoice").FirstOrDefault();
但這樣肯定沒有使用lambda更有利於避免輸入錯誤。
預加載也支持同時加載二級屬性,比如我們給Invoice增加一個開票人屬性,這是一個Employee對象。
public class Invoice { public int Id { get; set; } public string InvoiceNo { get; set; } public DateTime CreateDate { get; set; } public virtual Employee Drawer { get; set; } } public class Employee { public int Id { get; set; } public string Name { get; set; } public string EmpNo { get; set; } }
如下代碼,我們可以在查詢Product同時加載Invoice和Employee。
var product = context.Set<Product>().Include(p=>p.Invoice.Drawer).FirstOrDefault();
同樣字符串參數也是支持的:
var product = context.Set<Product>().Include("Invoice.Drawer").FirstOrDefault();
此時生成的SQL會含有2次JOIN,代碼太長就不列出了。
並發
本着實用的原則(其實主要原因是博主的理論知識也只是自己心里明白,做不到給大家講明白的程度),這部分就不講太多關於數據庫隔離級別以及不同隔離級別並發時出現的結果等等。
我們使用最簡單的Product類進行測試,先寫入一條數據:
var product = new Product() { Name = "投影儀", Description = "高分辨率" }; context.Set<Product>().Add(product); context.SaveChanges();
然后我們編寫一個並發測試類來模擬2個用戶同時編輯同一個Product的情況:
public class ConcurrencyTest : IDisposable { private readonly DbContext _user1Context; private readonly DbContext _user2Context; public ConcurrencyTest() { _user1Context = new CodeFirstForBlogContext(); _user2Context = new CodeFirstForBlogContext(); } public void EditProductConcurrency() { User1Edit(); User2Edit(); User2Save(); User1Save(); } private void User1Edit() { var product = _user1Context.Set<Product>().First(); product.Name = product.Name +" edited by user1 at " + DateTime.Now.ToString("MM-dd HH:mm:ss"); } private void User1Save() { _user1Context.SaveChanges(); } private void User2Edit() { var product = _user2Context.Set<Product>().First(); product.Name = product.Name + " edited by user2 at " + DateTime.Now.ToString("MM-dd HH:mm:ss"); } private void User2Save() { _user2Context.SaveChanges(); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (disposing == true) { _user1Context.Dispose(); _user2Context.Dispose(); } } ~ConcurrencyTest() { Dispose(false); } }
我們之前看到的那些Fluent API配置都沒有啟用並發支持,我們在沒有並發支持的情況下看看這段代碼的執行情況:
using (ConcurrencyTest test = new ConcurrencyTest()) { test.EditProductConcurrency(); }
運行,我們可以看到在User2Save執行后被寫入的數據,完全被User1Save所寫入的數據覆蓋了。也就是說User2的修改丟失了。
怎樣避免呢,這就需要啟用並發支持。EF只支持樂觀並發,以上面情況為例也就是說當出現上面情況時EF會拋出異常(DbUpdateConcurrencyException),使User1無法提交,從而保護User2的修改不被覆蓋。
怎么啟用並發樂觀支持呢?
我們給Product添加一個屬性標識數據版本,屬性名隨意起,類型必須是byte[]。
public byte[] RowStamp { get; set; }
在Fluent API中需要這樣配置以指定RowStamp作為並發標識:
Property(p => p.RowStamp).IsRowVersion();
重新執行遷移,然后在運行之前的並發測試方法,此時User1Save方法調用時就會報異常,如圖:
圖3. 樂觀並發開啟時同時編輯導致的異常
怎樣處理這中情況呢?有很多種策略。我們先修改一下User1Save,在其中捕獲一下DbUpdateConcurrencyException,我們的處理實在這個異常的catch中完成的。
private void User1Save() { try { _user1Context.SaveChanges(); } catch (DbUpdateConcurrencyException concurrencyEx) { //處理異常 } }
策略1:使用數據庫數據
異常處理部分代碼如下:
catch (DbUpdateConcurrencyException concurrencyEx) { concurrencyEx.Entries.Single().Reload(); _user1Context.SaveChanges(); }
Reload表示由數據庫中重新加載數據並覆蓋當前保存失敗的對象。這樣User2的修改會被保存下來,User1的修改丟失。如同不在catch中做任何處理的效果。
策略2:使用客戶端數據
異常處理部分代碼如下:
catch (DbUpdateConcurrencyException concurrencyEx) { var entry = concurrencyEx.Entries.Single(); entry.OriginalValues.SetValues(entry.GetDatabaseValues()); _user1Context.SaveChanges(); }
使用數據庫獲取的值來填充保存失敗的對象的OriginalValues屬性(原始值),這樣這個保存失敗對象(User1的修改)再次提交時,數據庫就不會因為原始值(OriginalValues)與數據庫里現有值不同而產生異常了。最終結果就是User1的修改被保存,User2的修改被覆蓋。這種結果和不啟用樂觀並發是一樣的。
策略3:由用戶決定合並結果
異常處理部分代碼如下:
catch (DbUpdateConcurrencyException concurrencyEx) { var entry = concurrencyEx.Entries.Single(); var databaseValues = entry.GetDatabaseValues(); var currentEntity = (Product)entry.CurrentValues.ToObject(); var databaseEntity = (Product)entry.GetDatabaseValues().ToObject(); // 我們將數據庫的現有值作為默認的合並結果。合並過程中可以在這基礎上修改。 var resolvedEntity = (Product)databaseValues.Clone().ToObject(); // 在這個函數中,用戶實現合並方法決定最終寫入數據庫的值 UserResolveConcurrency(currentEntity, databaseEntity, resolvedEntity); // 同樣要把數據庫的值寫入OriginalValues,以保證不在此觸發並發異常 // 把合並值作為CurrentValues,其將被提交到數據庫 entry.OriginalValues.SetValues(databaseValues); entry.CurrentValues.SetValues(resolvedEntity); _user1Context.SaveChanges(); }
道理很簡單,我們就是分別取出現有值和數據庫值,留給用戶去決定合並結果並提交回數據庫。通過代碼中注釋可以很容易理解。
其中調用的合並函數如下:
private void UserResolveConcurrency(Product currentEntity, Product databaseEntity, Product resolvedEntity) { //由用戶決定 怎樣合並currentEntity和databaseEntity得到resolvedEntity Debug.WriteLine(string.Format("current(user1):Name-{0}",currentEntity.Name)); Debug.WriteLine(string.Format("database(user2):Name-{0}", databaseEntity.Name)); resolvedEntity.Name = resolvedEntity.Name + " Merged by user1"; }
當然這個函數是隨便實現的,大家應該根據實際業務場景仔細設計這個函數的實現。
根據這個函數的實現,程序執行后,最終Product的Name被更新為:投影儀 edited by user2 at 08-07 21:12:18 Merged by user1
除了對整行啟用樂觀並發支持外,還可以針對單個列啟用樂觀並發支持。如我們可以使用下面的代碼把Product的Name屬性配置為受樂觀並發管理。
Property(p => p.Name).IsConcurrencyToken();這樣只有當Name出現並發修改時,才會拋出異常,異常的處理方式與之前介紹的相同。
異步
C#5.0開始增加了async和await關鍵字,配合.NET Framework 4.5大大簡化了異步方法的實現和調用。EF也順應趨勢在6.0起開始支持異步操作。
EF中異步操作分為2部分異步獲取數據及異步提交數據。
異步提交數據只有一種途徑,就是DbContext中的SaveChangesAsync方法。關於異步方法怎么調用本文不細說了,那是另一個大主題。園子也有很多相關文章。
關於異步推薦一本書《C#並發編程經典實例》。這本書還沒有翻譯版的時候我就找英文電子版讀過一遍,受益匪淺。
關於異步獲取數據根據場景不同有很多種選擇,列舉幾個方法在下面:
-
FindAsync
-
LoadAsync
-
FirstAsync
-
FirstOrDefaultAsync
-
ToListAsync
可能還有其他不一一列舉了。
一般現在項目都使用各種結構大致類似的IRepository/ConcreteRepository接口/類包裝EF,我們只需要根據同步方法添加異步方法並調用上面這些EF中提供的異步方法,就可以很輕松的讓我們存儲層支持異步。
異步方法一個很大的特點就是傳播性,基本上我們存儲層的代碼改成異步,上面所有調用代碼也都要以異步實現。所以讓項目支持異步還是一個需要從開始就規划的工作,后期改的話成本有點高。
遷移
我們使用系統中設計好的實體以及映射配置來創建數據庫或將對實體的修改應用到已存在的數據庫都需要用到EF的遷移支持。EF從4.3開始支持自動遷移。我們先來看下怎樣啟用這個功能:
啟用遷移
啟用遷移需要在VS的程序包管理控制台輸入命令來完成,命令如下
Enable-Migrations –EnableAutomaticMigrations
注意:程序包管理控制台中選擇的項目應該是DbContext所在的項目,否則這條PowerShell命令會執行出錯。
命令成功執行后項目目錄中會多出一個名為Migrations的文件夾,里面有個Configuration.cs文件。
internal sealed class Configuration : DbMigrationsConfiguration<SchoolContext> { public Configuration() { AutomaticMigrationsEnabled = true; ContextKey = "EF6Sample.SchoolContext"; } protected override void Seed(SchoolContext context) { } }
這個類是支持遷移的關鍵,PowerShell中的EnableAutomaticMigrations參數會讓Configuration類的構造函數添加AutomaticMigrationsEnabled=true這條語句。
ContextKey屬性用來執行這個遷移關聯的DbContext,這樣多個DbContext共存遷移不會產生沖突。
Seed方法是在遷移過程數據庫Schema成功應用以后執行的操作,可以利用這個方法添加一些初始化數據:
protected override void Seed(SchoolContext context) { context.Set<Student>().AddOrUpdate( p => p.StudentName, new Student { StudentName = "張三" }, new Student { StudentName = "李四" }, new Student { StudentName = "王五" } ); }
自動遷移
開啟遷移支持后,實現遷移,還需要配置下DbContext。需要在DbContext的構造函數中完成:
Database.SetInitializer(new MigrateDatabaseToLatestVersion<SchoolContext , Configuration>("SchoolDBConnectionStr"));
這樣在項目運行時,遷移將自動完成。即如果數據庫不存在,將自動創建數據庫。如果數據庫已存在但Schema不是最新,數據庫也會被自動更新。
EF怎樣選擇使用的數據庫
EF CodeFirst不像ADO.NET2.0那樣必須使用鏈接字符串明確指定數據庫的位置,也不像EDM需要一個冗長的可讀性很低的鏈接字符串。還有用“盜”來的圖說明EF CodeFirst數據庫選擇策略了,這圖真可謂一目了然:
圖4. EF CodeFirst數據庫選擇策略 來源
由圖可知,在完全不指定鏈接字符串的情況下,EF CodeFirst也會自動選擇一個數據庫,當然我們大家項目的配置文件中中都會明確指定鏈接字符串不是嗎?
手動遷移
如果不喜歡自動遷移,可以手工完成這個操作。手工遷移的好處后,可以隨時退回到某個指定的遷移版本。遷移文件也可以進行版本管理有利於團隊開發。
首先把Configuration構造函數中AutomaticMigrationsEnabled置為false,表示不使用自動遷移。
手動遷移的操作也是在程序包管理控制台使用PowerShell來完成,在每次更改實體或映射配置后,我們運行下面這個命令來生成一個遷移文件:
Add-Migration ChangeSet1
命令成功執行后會生成一個遷移文件,其內容就是EF在遷移時執行的操作。
public partial class ChangeSet1 : DbMigration { public override void Up() { AddColumn("dbo.Student", "Age", c => c.Int(nullable: false)); } public override void Down() { DropColumn("dbo.Student", "Age"); } }
成功生成遷移文件后,運行下面命令,EF就開始執行遷移操作,並把數據庫更新到最新的遷移文件對應的版本。
Update-Database
這條命令有幾個常用的參數
Update-Database -Verbose
可以查看遷移在數據庫中執行的詳細操作(SQL等)
Update-Database -TargetMigration ChangeSet1
這個參數可以指定目標遷移版本,對於需要退回到指定版本的情況很有用。
關於數據庫初始化器
在之前的代碼中,我們給Database.SetInitializer方法傳遞一個名為MigrateDatabaseToLatestVersion的初始化器,EF還有其他幾種初始化其的選擇:
CreateDatabaseIfNotExists:如果不存在數據庫則新建。但如果數據庫已存在,且模型變化會出現異常。
DropCreateDatabaseIfModelChanges:當模型變化時刪除並重建數據庫
DropCreateDatabaseAlways:每次實例化DbContext時都刪除並重建數據庫
自定義DB Initializer:實現IDatabaseInitializer,定義自己的初始化數據庫邏輯
對於這幾種只能說然並卵。對於開發環境MigrateDatabaseToLatestVersion足夠好用了。對於生成環境數據庫已經很穩定的情況,可以直接給Database.SetInitializer方法傳null以禁用數據庫初始化。
未涉及
本文未涉及的內容包括
-
映射存儲過程/表值函數
-
執行原生SQL查詢
-
枚舉/空間數據類型
-
自定義實體驗證
-
CodeFirst映射約定/配置映射約定
-
EF6版本DbContext.Database中的事務支持
-
EF6的日志支持
-
EF6基於代碼的配置
不涉及這些的原因是是博主我幾乎沒用過寫出來也誤導人。
未來
據說EF的下一個版本中底層很多部分都是重寫,不知道會不會對API有什么影響。當然我們都希望對使用習慣影響不大的情況下EF可以大大提高性能。
基於.NET跨平台的大趨勢,EF7開始支持Linux平台中常用的PostgreSQL數據庫。基於NoSQL的大趨勢,EF7開始支持Azure Table Service和Redis這樣的NoSQL存儲工具。
一個很值得期待的方面是EF7開始支持Windows Universal App並官方支持SQLite數據,這樣在Windows Universal App中使用EF訪問SQLite數據庫就不需要像現在這樣蛋疼的先找一個WinRT C++/CX對SQLite C Api的封裝,然后再找一個C#調用C++/CX的封裝。總之在給開發者提供開發便利方面MS還是很有進取心的。
最后
最后分享一個網上看到的很好的EF教程,文中的幾幅圖片都是來自這個網站。
作為.NET方面數據訪問的官方力量,EF還是很值得去學習的,僅以此文與大家共勉。
2015-08-02 補充1
這段補充主要是回答61樓網友的問題,正好也把映射部分一些遺漏的細節寫上。
多對多映射中關聯表添加自定義列
有些時候我們需要在關聯表中添加一些自定義列,正如61樓網友的需求。這里我將以前文的例子來介紹這個需求如何實現。
在前文例子中我們通過Product和Tag演示了怎樣配置多對多關聯,現在我們需要給它們的關聯表增加一個列,用來表示Tag的顯示順序。這種情況下需要手工創建一個關聯實體,如下:
public class ProductTag { public int Id { get; set; } public int ProductId { get; set; } public int TagId { get; set; } //給關聯表增加的列,表示標簽的順序 public int Order { get; set; } public virtual Product Product { get; set; } public virtual Tag Tag { get; set; } }
已有的Product和Tag類也需要修改來和這個ProductTag進行關聯:
public class Product { public int Id{ get; set; } public string Name { get; set; } public string Description { get; set; } public virtual ICollection<ProductTag> ProductTags { get; set; } } public class Tag { public int Id { get; set; } public string Text { get; set; } public virtual ICollection<ProductTag> ProductTags { get; set; } }
這種配置的重點在於ProductTag的映射:
public class ProductTagMap:EntityTypeConfiguration<ProductTag> { public ProductTagMap() { ToTable("Product_Tag_Map"); HasKey(pt => pt.Id); Property(pt => pt.Order);//這行可有可無,這里為了演示就加上了。 HasRequired(pt => pt.Product) .WithMany(p => p.ProductTags) .HasForeignKey(pt => pt.ProductId); HasRequired(pt => pt.Tag) .WithMany(t => t.ProductTags) .HasForeignKey(pt => pt.TagId); } }
我們把多對多映射放在了關聯實體映射中,Product和Tag就不需要做任何其他和多對多有關的映射了。
這樣就實現了文初的目的。補充先到此,再有需要再做補充。
本文版權歸hystar與博客園共有,除標記出處的內容外,其他文字代碼都是博主一個字一個字碼出來的,如有雷同肯定是被山寨了哈哈。