不知道大夥伴們有沒有這樣的想法:如果我不定義實體類,那 EF Core 能建模嗎?能正常映射數據庫嗎?能正常增刪改查嗎?
雖然一般開發場景很少這麼幹,但有時候,尤其是數據庫中的某些視圖,就不太想給它定義實體類。好消息,EF Core 還真支持不定義實體類的。可是,你一定會疑惑了,不定義實體類,那還怎麼面向對象呢?不急,咱們一個個去探尋真相。
先看看這個自定義的上下文類。
public class MyDbContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer("server=..."); } protected override void OnModelCreating(ModelBuilder modelBuilder) { EntityTypeBuilder ent = modelBuilder.Entity("Student"); // 先把它標記為無主鍵,避免報錯 ent.HasNoKey(); } }
這裏我給數據庫模型添加了一個叫 Student 的實體,我可沒有定義對應的類。然後我們打印一下模型信息,看看能不能建模。
internal class Program { static void Main(string[] args) { using var context = new MyDbContext(); // 打印模型信息 Console.WriteLine(context.Model.ToDebugString()); } }
運行一下,好傢伙,你看,還真的能建模了。
Model: EntityType: Student (Dictionary<string, object>) CLR Type: Dictionary<string, object> Keyless
注意 CLR Type 後面的信息。這下懂了,當你不給 EF Core 提供實體類時,它會默認使用字典類型,Key 是字符串,Value 是 object 類型。
我們繼續驗證,既然能建模了,那定義些屬性,包括主鍵。
protected override void OnModelCreating(ModelBuilder modelBuilder) { EntityTypeBuilder ent = modelBuilder.Entity("Student"); // 添加屬性 ent.Property<int>("StuID"); ent.Property<string>("Name").IsRequired(true); ent.Property<int>("Age").IsRequired(); ent.Property<string>("Major").IsRequired(false); // 主鍵 ent.HasKey("StuID"); }
Property 方法要指定屬性的類型,因為沒有對應的 CLR 屬性,不然 EF 不知道這個屬性是什麼類型。這個其實就像影子屬性。
現在再看看模型的信息。
Model: EntityType: Student (Dictionary<string, object>) CLR Type: Dictionary<string, object> Properties: StuID (no field, int) Indexer Required PK AfterSave:Throw ValueGenerated.OnAdd Age (no field, int) Indexer Required Major (no field, string) Indexer Name (no field, string) Indexer Required Keys: StuID PK
看來是沒問題的。
好,進入下一個驗證階段。我們用這個模型去創建數據庫(這裏我用的是 SQL Server)。
public class MyDbContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer("server=(localdb)\\MSSQLLocalDB;database=my_school"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { …… } } internal class Program { static void Main(string[] args) { using var context = new MyDbContext(); …… // 創建數據庫 context.Database.EnsureCreated(); } }
運行後,成功創建數據庫,包含數據表 Student。
CREATE TABLE [dbo].[Student] ( [StuID] INT IDENTITY (1, 1) NOT NULL, [Age] INT NOT NULL, [Major] NVARCHAR (MAX) NULL, [Name] NVARCHAR (MAX) NOT NULL, CONSTRAINT [PK_Student] PRIMARY KEY CLUSTERED ([StuID] ASC) );
默認它使用了 Student 為表名。
咱們的數據庫上下文還少了一個 DbSet<> 屬性,為了能訪問數據,應當定義此屬性。不過,這樣定義是錯誤的。
public class MyDbContext : DbContext { …… public DbSet<Dictionary<string, object>> Students { get; set; } }
雖然在運行的時候沒有拋出異常,但是,Students 屬性在 DbContext 初始化時,DbSet<> 沒有被正確設置,即內部的 InternalDbSet 類初始化失敗。説白了這個 DbSet 類型的屬性你不能正常訪問。
什麼原因?當我們通過 LINQ 查詢時,由於 DbSet 本身就是實現了 IQueryable<> 接口的,因此,EntityQueryable 屬性會被 IEnumerable.GetEnumerator() 等方法訪問。
然後我們再看 EntityQueryable 屬性的代碼。
private EntityQueryable<TEntity> EntityQueryable { get { CheckState(); return NonCapturingLazyInitializer.EnsureInitialized( ref field, this, static internalSet => internalSet.CreateEntityQueryable()); } }
這裏觸發了CheckState 方法。
private void CheckState() // ReSharper disable once AssignmentIsFullyDiscarded => _ = EntityType;
CheckState 方法又訪問了 EntityType 屬性。
public override IEntityType EntityType { get { if (field != null) { return field; } field = _entityTypeName != null ? _context.Model.FindEntityType(_entityTypeName) : _context.Model.FindEntityType(typeof(TEntity)); if (field == null) { if (_context.Model.IsShared(typeof(TEntity))) { throw new InvalidOperationException(CoreStrings.InvalidSetSharedType(typeof(TEntity).ShortDisplayName())); } …… return field; } }
其他地方不用看,重點是發現中間有一段是判斷實體的類型是否為共享類型。IsShared 方法是 Model 類中定義的。
public virtual bool IsShared([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type) => FindIsSharedConfigurationSource(type) != null || Configuration?.GetConfigurationType(type) == TypeConfigurationType.SharedTypeEntityType; public virtual ConfigurationSource? FindIsSharedConfigurationSource(Type type) => _sharedTypes.TryGetValue(type, out var existingTypes) ? existingTypes.ConfigurationSource : null;
_sharedTypes 是 Model 類的字段,從初始化時,它就把 Dictionary<string, object> 設定為共享類型。
public static readonly Type DefaultPropertyBagType = typeof(Dictionary<string, object>); private readonly Dictionary<Type, (ConfigurationSource ConfigurationSource, SortedSet<EntityType> Types)> _sharedTypes = new() { { DefaultPropertyBagType, (ConfigurationSource.Explicit, new SortedSet<EntityType>(TypeBaseNameComparer.Instance)) } };
看到沒,_sharedTypes 字段從 new 那一刻起,它裏面就包含了 Dictionary<string, object> 類型。
共享類型的特點是允許多個實體使用同一個 .NET 類,而字典類型就是默認的共享類型。而使用 DbSet<...> Students { get; set; } 這種格式定義的屬性,在 DbContext 類的內部,只用實體類的 Type 作為索引的 Key,沒有命名。一旦 Type 是多個實體共享的類型,就破壞了唯一性。這時候必須同時用 Type 和 name 來標識 DbSet 才能做到唯一區分。看看 DbContext 類內部的 DbSet 列表是怎麼緩存的。
private Dictionary<(Type Type, string? Name), object>? _sets;
説白了,DbContext 類的內部是用一個字典來緩存 DbSet 的(看,字典類型真是好用,哪兒都能用得上它),其中 Key 是由兩個值構成的:實體的類型 + 實體的名字。對於常見的實體類,因為類是唯一的,不共享的,所以,Name 的值可以忽略;而共享類型則不同,多個實體的 Type 是相同的,不用 Name 的話無法區分。所以,對於咱們這個未定義實體類的 Student 實體,只能調用 Set 方法來返回,並且要顯式地指定一個名字,名字必須和模型中註冊的實體名相同,否則,查詢的時候還是找不到映射的。
廢話了那麼多,正確的屬性定義應當是這樣的。
public DbSet<Dictionary<string, object>> Students => Set<Dictionary<string, object>>("Student");
咱們把表映射完善一下。
protected override void OnModelCreating(ModelBuilder modelBuilder) { EntityTypeBuilder ent = modelBuilder.Entity("Student"); // 添加屬性 ent.Property<int>("StuID") .HasColumnName("f_stuid"); ent.Property<string>("Name") .IsRequired(true) .HasMaxLength(15) .HasColumnName("f_name"); ent.Property<int>("Age") .IsRequired() .HasColumnName("f_age"); ent.Property<string>("Major") .IsRequired(false) .HasMaxLength(30) .HasColumnName("f_major"); // 主鍵 ent.HasKey("StuID").HasName("PK_stu_id"); // 表名 ent.ToTable("tb_students"); }
然後創建的數據庫是這樣的。
逐漸對味了,這一關可以 pass 了。
接下來咱們還要驗證一下,增刪改查是否可行。
A、先插入五條記錄。
// 插入數據 // Name:姓名;Age:年齡;Major:專業 Dictionary<string, object>[] newRecs = [ new() { ["Name"] = "劉桂圓", ["Age"] = 19, ["Major"] = "人力資源管理裁員方向" }, new() { ["Name"] = "方小同", ["Age"] = 20, ["Major"] = "調酒師" }, new() { ["Name"] = "程河洛", ["Age"] = 20, ["Major"] = "獸醫" }, new() { ["Name"] = "史地分", ["Age"] = 22, ["Major"] = "堪輿學" }, new() { ["Name"] = "吳勝隆", ["Age"] = 18, ["Major"] = "行星科學" } ]; context.Students.AddRange(newRecs); // 提交更改 int n = context.SaveChanges(); Console.WriteLine("已更新{0}條數據", n);
結果如下:
B、現在看一下數據更新。把 ID=3 的同學的專業改為“土狗工程”。
int n = context.Students .Where(stu => (int)stu["StuID"] == 3) .ExecuteUpdate(setter => setter.SetProperty(s => s["Major"], "土狗工程")); Console.WriteLine("更新了{0}條記錄", n);
這個寫法可能有大夥伴沒看懂,老周解釋一下。
首先,用 ExecuteUpdate 方法的好處是隻生成一條 UPDATE 語句,提高了效率。如果你先查詢 ID=3 的數據,然後修改其屬性,然後再提交更改。那樣 EF 會先生成 SELECT 語句,返回數據後,在內存中修改,再生成 UPDATE 語句,這樣不太必要。
ExecuteUpdate 方法生成的 UPDATE 語句,它的 Where 子句是和 LINQ 查詢匹配的,由於我們要改 id=3 的記錄,所以要先調用 Where 方法,讓 EF 記住有篩選條件,然後再調用 ExecuteUpdate 方法生成 SET 子句。
ExecuteUpdate 是擴展方法,這裏我調用的以下重載:
public static int ExecuteUpdate<TSource>(this IQueryable<TSource> source, Action<UpdateSettersBuilder<TSource>> setPropertyCalls)
參數是一隻委託,委託有個輸入參數,類型是 UpdateSettersBuilder<TSource>。UpdateSettersBuilder 類的功能是幫助框架生成更新語句的 SET 子句。即調用它的 SetProperty 方法給實體的屬性賦值。為了保持高度的靈活性和可擴展性,SetProperty 方法採用表達樹的方式傳參。此處,我使用的是以下重載:
public UpdateSettersBuilder<TSource> SetProperty<TProperty>( Expression<Func<TSource, TProperty>> propertyExpression, TProperty valueExpression )
根據 C# 語句可以隱式轉化為 Expression 的原則,propertyExpression 參數可以簡化理解為 Func<TSource, TProperty>,即你要告 EF 我要更新實體的哪個屬性,比如,我要更新 Car.Speed 屬性,那麼就是 c => c.Speed,其中,c 是 Car 實例。不過,這裏我們的 Student 實體是沒有定義類的,它是個字典,但沒關係,我們就告訴框架要更新哪個 Key 的值就好了。也就是 s => s["Major"]。
第二個參數 valueExpression 就是屬性的新的值。
所以,綜合起來,整個 ExecuteUpdate 方法的調用就是
ExecuteUpdate( setter => setter.SetProperty(s => s["Major"], "土狗工程" ));
等等啊,各位,別忙着按【F5】,上面代碼還有個問題,如果你直接運行,就會報類型映射失敗的錯誤。問題就出在這個 Lambda 表達式:s => s["Major"]。我們還記得,它的類型是 Dictionary<string, object>,也就是説,s["Major"] 給表達式樹處理引擎提供的類型是 object,而 Major 屬性其實要的是 string。
怎麼解決呢,簡單啊,把它強制轉換為 string 就完事了。
ExecuteUpdate( setter => setter.SetProperty(s => (string)s["Major"], "土狗工程" ));
現在運行代碼就不會出錯了。生成的 SQL 語句如下:
UPDATE [t] SET [t].[f_major] = @p FROM [tb_students] AS [t] WHERE [t].[f_stuid] = 3
咱們驗證一下,看到底改了沒有。
嗯,已經改了。
C、刪除數據。現在咱們把 ID=3 的數據記錄刪除。為了提高效率,我們用 ExecuteDelete 方法。
int n = context.Students .Where(stu => (int)stu["StuID"] == 3) .ExecuteDelete(); Console.WriteLine("已刪除{0}條記錄", n);
ExecuteDelete 方法不需要參數,因為生成 DELTE FROM ... 語句一般只要帶個 WHERE 子句作為篩選條件就可以了。所以,要先調用 Where 方法做篩選,然後才調用 ExecuteDelete 方法。不然會把整個表的記錄刪除。
生成的 SQL 語句如下:
DELETE FROM [t] FROM [tb_students] AS [t] WHERE [t].[f_stuid] = 3
運行代碼後,ID=3 的記錄就沒了。
嚴重注意:凡是調用 ExecuteXXX 方法處理數據的,不要再調用 SaveChanges 方法了。ExecuteXXX 方法不需要跟蹤實體更改,而是直接生成 SQL 發送到數據庫執行的。
D、查詢數據。就剩下最後一個操作了。咱們把所有記錄查詢出來,並輸出到控制枱。
var stuArr = context.Students.ToArray(); // 打印 var q = from s in stuArr select new { StuID = (int)s["StuID"], Name = (string)s["Name"], Age = (int)s["Age"], Major = (string)s["Major"] }; foreach(var x in q) { Console.WriteLine($"{x.Name}({x.StuID})\t{x.Age}歲,{x.Major}專業"); }
context.Students.ToArray 由於調用了 ToArray 擴展方法,所以查詢會執行,從而觸發翻譯為 SQL 語句,併發送到數據庫,返回查詢結果。生成 SQL 如下:
SELECT [t].[f_stuid], [t].[f_age], [t].[f_major], [t].[f_name] FROM [tb_students] AS [t]
控制枱輸出結果如下:
劉桂圓(1) 19歲,人力資源管理裁員方向專業 方小同(2) 20歲,調酒師專業 史地分(4) 22歲,堪輿學專業 吳勝隆(5) 18歲,行星科學專業
在查詢的時候,你也可以直接用 SQL 語句,例如查詢 ID=1 的記錄。
int qid = 1; var stuArr = context .Students .FromSql($"select * from tb_students where f_stuid = {qid}") .ToArray();
建議使用這種格式化的方式來組織 SQL 語句,而不用 Raw SQL 來拼接,因為用格式化字符串構建 SQL 語句默認使用參數化查詢,可以避免特殊字符注入攻擊,保證安全。
經過咱們一系列驗證,表明不定義類的實體也可以正常完成增刪改查操作的。只是使用 Dictionary<string, object> 類型在書寫查詢的時候,Key 的名稱容易寫錯。尤其是項目你做了一半,讓後別人接盤,這種情況很容易把 Key 寫錯,導致各種錯誤。
因此,不定義實體類的方案可以使用,但不建議全用,可以僅在部分表或視圖的映射中使用。
好了,今天咱們就水到這裏了。