自動生成欄位值,咱們首先想到的是主鍵列(帶 IDENTITY 的主鍵)。EF Core 預設的主鍵配置也是啟用 Identity 自增長的,而且可以自動標識主鍵。前提是代表主鍵的實體屬性名要符合以下規則: 1、名字叫 ID、id、或 Id,就是不分大小寫; 2、名字由實體類名 + Id 構成。比如, ...
自動生成欄位值,咱們首先想到的是主鍵列(帶 IDENTITY 的主鍵)。EF Core 預設的主鍵配置也是啟用 Identity 自增長的,而且可以自動標識主鍵。前提是代表主鍵的實體屬性名要符合以下規則:
1、名字叫 ID、id、或 Id,就是不分大小寫;
2、名字由實體類名 + Id 構成。比如,Car 實體類,包含一個屬性叫 CarID 或 CarId;
3、屬性類型是整數類型(int、long、ushort 等,但不是 byte)或 GUID。
這些識別主鍵的規則是由一種叫“約定”(Convension)的東西實現的,具體來說,是一個叫 KeyDiscoveryConvention 的類。老周放一小段源代碼給各位瞧瞧。
public class KeyDiscoveryConvention : IEntityTypeAddedConvention, IPropertyAddedConvention, IKeyRemovedConvention, IEntityTypeBaseTypeChangedConvention, IEntityTypeMemberIgnoredConvention, IForeignKeyAddedConvention, IForeignKeyRemovedConvention, IForeignKeyPropertiesChangedConvention, IForeignKeyUniquenessChangedConvention, IForeignKeyOwnershipChangedConvention, ISkipNavigationForeignKeyChangedConvention { private const string KeySuffix = "Id"; …… public static IEnumerable<IConventionProperty> DiscoverKeyProperties( IConventionEntityType entityType, IEnumerable<IConventionProperty> candidateProperties) { Check.NotNull(entityType, nameof(entityType)); // ReSharper disable PossibleMultipleEnumeration var keyProperties = candidateProperties.Where(p => string.Equals(p.Name, KeySuffix, StringComparison.OrdinalIgnoreCase)); if (!keyProperties.Any()) { var entityTypeName = entityType.ShortName(); keyProperties = candidateProperties.Where( p => p.Name.Length == entityTypeName.Length + KeySuffix.Length && p.Name.StartsWith(entityTypeName, StringComparison.OrdinalIgnoreCase) && p.Name.EndsWith(KeySuffix, StringComparison.OrdinalIgnoreCase)); } return keyProperties; // ReSharper restore PossibleMultipleEnumeration } …… }
這幾個邏輯 And 其實就是查找 <類名>Id 格式的屬性名,如 StudentID、CarId、OrderID…… 外鍵的發現原理也跟主鍵一樣。
用 Sqlite 數據舉一個簡單的例子。下麵是實體類(假設它用來表示輸入法信息):
public class InputMethod { public ushort RecoId { get; set; } public string? MethodDisplay { get; set; } public string? Description { get; set; } public string? Culture { get; set; } }
如你所見,這個類作為主鍵的屬性是 RecoId,但是,它的命名是無法被自動識別的,咱們必須明確地告訴 EF,它是主鍵。方法有二:
1、批註法。直接在屬性上應用相關的特性類。如
public class InputMethod { [Key] public ushort RecoId { get; set; } …… }
2、重寫 DbContext 類的 OnModelCreating 方法。如
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<InputMethod>().HasKey(e => e.RecoId); }
如果使用了上面重寫 OnModelCreating 方法,那麼,你的 DbContext 派生類已經能識別 InputMethod 實體類了。但如果你用的是在屬性上應用 [Key] 特性的方式,那麼 DbContext 的派生類是識別不到實體類的,你需要將它的集合聲明為 DbContext 的屬性。
internal class TestDBContext : DbContext { // 構造函數 public TestDBContext(DbContextOptions<TestDBContext> opt) : base(opt) { } // 將實體集合聲明為屬性 public DbSet<InputMethod> InputMethods { get; set; } }
註意,數據記錄的集合要用 DbSet<>,其他類型的集合是不行的喲。比如,你改成這樣,就會報錯。
public List<InputMethod> InputMethods { get; set; }
說明人家只認 DbSet 集合,其他集合無效。
這裡老周選用服務容器來配置。
static void Main(string[] args) { IServiceCollection services = new ServiceCollection(); // 構建連接字元串 SqliteConnectionStringBuilder constrbd = new(); constrbd.DataSource = "abc.db"; // 添加 Sqlite 功能 services.AddSqlite<TestDBContext>( connectionString: constrbd.ToString(), optionsAction: dcopt => { dcopt.LogTo(msg => Console.WriteLine(msg), LogLevel.Information); } ); // 生成服務列表 var svcProd = services.BuildServiceProvider(); if(svcProd == null) { return; } // 訪問數據上下文 using TestDBContext dbc = svcProd.GetRequiredService<TestDBContext>(); …… }
連接字元串你可以直接用字元串寫,不用 ConnectionStringBuilder。預設的 SQLite 庫是不支持密碼的,所以老周就不設置密碼了。在調用 AddSqlite 方法時,有一個名為 optionsAction 的參數,咱們可以用它配置日誌輸出。LogTo 方法配置簡單,只要提供一個委托,它綁定的方法只要有一個 string 類型的輸入參數就行,這個字元串參數就是日誌文本。
配置日誌功能後,運行程式時,控制台能看到執行的 SQL 語句。
下麵咱們來創建資料庫,然後插入兩條 InputMethod 記錄。
// 訪問數據上下文 using TestDBContext dbc = svcProd.GetRequiredService<TestDBContext>(); // 刪除資料庫 dbc.Database.EnsureDeleted(); // 創建資料庫 dbc.Database.EnsureCreated(); // 嘗試插入兩條記錄 InputMethod[] ents = [ new(){MethodDisplay = "雙拼輸入", Description="按兩個鍵完成一個音節",Culture="zh-CN"}, new() {MethodDisplay = "六指輸入", Description="專供六個指頭的人使用",Culture="zh-CN"} ]; dbc.Set<InputMethod>().AddRange(ents); int result = dbc.SaveChanges(); Console.WriteLine($"更新記錄數:{result}"); // 列印插入的記錄 foreach(InputMethod im in dbc.Set<InputMethod>()) { Console.WriteLine($"ID={im.RecoId}, Display={im.MethodDisplay}, Culture={im.Culture}"); }
這裡是為了測試,調用了 EnsureDeleted 方法,實際應用時一般不要調用。因為這個方法的功能是把現存的資料庫刪除。如果調用了此方法,那應用程式每次啟動都會刪掉資料庫,那用戶肯定會投訴你的。EnsureCreated 方法可以使用,它的功能是如果資料庫不存在,就創建新資料庫;如果資料庫存在,那啥也不做。所以,調用 EnsureCreated 方法不會造成數據丟失,放心用。
插入數據和調用 SaveChanges 方法保存到資料庫的代碼,相信大伙都很熟了,老周就不介紹了。
程式運行之後,將得到這樣的日誌:
info: 2024/8/4 12:48:11.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] PRAGMA journal_mode = 'wal'; info: 2024/8/4 12:48:11.582 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] CREATE TABLE "tb_ims" ( "RecoId" INTEGER NOT NULL CONSTRAINT "PK_tb_ims""MethodDisplay""Description""Culture" TEXT NULL ); info: 2024/8/4 12:48:11.700 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (3ms) [Parameters=[@p0='?' (Size = 5), @p1='?' (Size = 10), @p2='?' (Size = 4)], CommandType='Text', CommandTimeout='30'] INSERT INTO "tb_ims" ("Culture", "Description", "MethodDisplay") VALUES (@p0, @p1, @p2) RETURNING "RecoId"; info: 2024/8/4 12:48:11.712 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 5), @p1='?' (Size = 10), @p2='?' (Size = 4)], CommandType='Text', CommandTimeout='30'] INSERT INTO "tb_ims" ("Culture", "Description", "MethodDisplay") VALUES (@p0, @p1, @p2) RETURNING "RecoId"; 更新記錄數:2 info: 2024/8/4 12:48:11.849 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT "t"."RecoId", "t"."Culture", "t"."Description", "t"."MethodDisplay" FROM "tb_ims" AS "t" ID=1, Display=雙拼輸入, Culture=zh-CN ID=2, Display=六指輸入, Culture=zh-CN
這樣你會發現,對於整數類型的主鍵,預設是自動生成遞增ID的。註意,這個是由資料庫生成的,而不是 EF Core 的生成器。不同資料庫的 SQL 語句會有差異。
為了對比,咱們不防改為 SQL Server,看看輸出的日誌。
// 構建連接字元串 SqlConnectionStringBuilder constrbd = new(); constrbd.DataSource = ".\\SQLTEST"; constrbd.InitialCatalog = "CrazyDB"; constrbd.IntegratedSecurity = true; // 不信任伺服器證書有時候會連不上 constrbd.TrustServerCertificate = true; // 可讀可寫 constrbd.ApplicationIntent = ApplicationIntent.ReadWrite; // 添加 SQL Server 功能 services.AddSqlServer<TestDBContext>( connectionString: constrbd.ToString(), optionsAction: opt => { opt.LogTo(logmsg => Console.WriteLine(logmsg), LogLevel.Information); });
其他代碼不變,再次運行。輸出的日誌如下:
info: 2024/8/4 13:01:06.087 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (115ms) [Parameters=[], CommandType='Text', CommandTimeout='60'] CREATE DATABASE [CrazyDB]; info: 2024/8/4 13:01:06.122 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (31ms) [Parameters=[], CommandType='Text', CommandTimeout='60'] IF SERVERPROPERTY('EngineEdition') <> 5 BEGIN ALTER DATABASE [CrazyDB] SET READ_COMMITTED_SNAPSHOT ON; END; info: 2024/8/4 13:01:06.137 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (5ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT 1 info: 2024/8/4 13:01:06.181 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] CREATE TABLE [tb_ims] ( [RecoId] int NOT NULL IDENTITY, [MethodDisplay] nvarchar(12) NOT NULL, [Description] nvarchar(max) NULL, [Culture] nvarchar(max) NULL, CONSTRAINT [PK_tb_ims] PRIMARY KEY ([RecoId]) ); info: 2024/8/4 13:01:06.317 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (30ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 12), @p3='?' (Size = 4000), @p4='?' (Size = 4000), @p5='?' (Size = 12)], CommandType='Text', CommandTimeout='30'] SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; MERGE [tb_ims] USING ( VALUES (@p0, @p1, @p2, 0), (@p3, @p4, @p5, 1)) AS i ([Culture], [Description], [MethodDisplay], _Position) ON 1=0 WHEN NOT MATCHED THEN INSERT ([Culture], [Description], [MethodDisplay]) VALUES (i.[Culture], i.[Description], i.[MethodDisplay]) OUTPUT INSERTED.[RecoId], i._Position; 更新記錄數:2 info: 2024/8/4 13:01:06.438 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT [t].[RecoId], [t].[Culture], [t].[Description], [t].[MethodDisplay] FROM [tb_ims] AS [t] ID=1, Display=雙拼輸入, Culture=zh-CN ID=2, Display=六指輸入, Culture=zh-CN
A、使用 Sqlite 資料庫時,生成的 CREATE TABLE 語句,主鍵列是 PRIMARY KEY AUTOINCREMENT;
B、使用 SQL Server 時,主鍵列使用的是 IDENTITY,預設以 1 為種子,增量是 1。所以插入記錄的鍵值是1和2。
有時候我們並不希望主鍵列自動生成值,同樣有兩種配置方法:
1、通過特性類來批註。如
public class InputMethod { [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public ushort RecoId { get; set; } public string? MethodDisplay { get; set; } public string? Description { get; set; } public string? Culture { get; set; } }
將 DatabaseGeneratedOption 設置為 None,就取消列的自動生成了。
2、通過模型配置,即重寫 OnModelCreating 方法實現。
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<InputMethod>().HasKey(e => e.RecoId); modelBuilder.Entity<InputMethod>() .Property(k => k.RecoId) .ValueGeneratedNever(); }
這種情況下,插入數據時主鍵列就需要咱們手動賦值了。
======================================================================================
上面的是熱身運動,是比較簡單的應用方案。下麵老周給大伙伴解決一個問題。老周看到在 GitHub 等平臺上有人提問,但沒有得到解決。如果你看到老周這篇水文並且你有此困惑,那你運氣不錯。好,F話不多說,咱們看問題。
需求:主鍵不變,但是我不想讓它帶有 IDENTITY,插入記錄時用我自定義的方式生成主鍵的值。這個需要的本質就是:我不要資料庫給我生成遞增ID,我要在程式里生成。
前面老周提過,預設行為下主鍵列如果是整數類型或 GUID,就會產生自增長的列。所以,咱們有一個很關鍵的步驟——就是怎麼禁止 EF 去產生 IDENTITY 列。如果你看到 EF Core SQL Server 的源代碼,可能你會知道有個約定類叫 SqlServerValueGenerationStrategyConvention。這個約定類預設會設置主鍵列的自動生成策略為 IdentityColumn。
public virtual void ProcessModelInitialized( IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context) => modelBuilder.HasValueGenerationStrategy(SqlServerValueGenerationStrategy.IdentityColumn);
於是,有大伙伴可能會想到,那我從 SqlServerValueGenerationStrategyConvention 派生出一個類,重寫 ProcessModelInitialized 方法,把自動生成策略改為 None,然後在約定集合中替換掉 SqlServerValueGenerationStrategyConvention。
這個思路不是不行,就是工作量大一些。你不僅要定義個新類,還要把它註冊到服務容器中替換 SqlServerValueGenerationStrategyConvention 。畢竟 EF Core 框架內部也是使用了服務容器和依賴註入的方式來組織各種組件的。具體做法是在初始化 DbContext 類(包括你派生的類)時會傳遞一個 DbContextOptions<TContext> 對象,它有一個 ReplaceService 方法,可以替換容器中的服務。在調用 AddSqlServer 方法時就可以配置。
public static IServiceCollection AddSqlServer<TContext>( this IServiceCollection serviceCollection, string? connectionString, Action<SqlServerDbContextOptionsBuilder>? sqlServerOptionsAction = null, Action<DbContextOptionsBuilder>? optionsAction = null) where TContext : DbContext
上述方案太麻煩,故老周未採用。其實,就算服務初始化時設置了生成策略是 Identity,可我們可以在構建模型時修改它呀。做法就是重寫 DbContext 類的 OnModelCreating 方法,然後通過 IConventionModelBuilder.HasValueGenerationStrategy 方法就能修改生成策略。當然,這裡頭是有點波折的,我們不能在 ModelBuilder 實例上調用,因為這貨並不是直接實現 IConventionModelBuilder 介面的,它是這麼搞的:
public class ModelBuilder : IInfrastructure<IConventionModelBuilder>
IInfrastructure<T> 介面的作用是把 T 隱藏,不希望程式代碼訪問類型T。DbContext 類也實現這個介面,但它隱藏的是 IServiceProvider 對象,不想讓咱們訪問裡面註冊的服務。也就是說,IConventionModelBuilder 的實現者被隱藏了。不過,EF Core 並沒有把事情做得太絕,好歹給了一個擴展方法 GetInfrastructure。用這個擴展方法我們能得到 IConventionModelBuilder 類型的引用。
弄清楚這個原理,代碼就好寫了。
protected override void OnModelCreating(ModelBuilder modelBuilder) { IConventionModelBuilder cvbd = modelBuilder.GetInfrastructure(); if (cvbd.CanSetValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None)) { cvbd.HasValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None); } …… }
把生成策略改為 None 後,生成主鍵列時就不會有 IDENTITY 了。
如果你樂意,可以在插入記錄時手動給主鍵列賦值也行的。不過,為了能自動生成值,我們應該寫一個自己的生成類。
public class MyValueGenerator : ValueGenerator<int> { // 返回false表示這個生成的值不是臨時,它最終要存入資料庫的 public override bool GeneratesTemporaryValues => false; private static readonly Random rand = new((int)DateTime.Now.Ticks); public override int Next(EntityEntry entry) { // 獲取所有實體 DbSet<InputMethod> ents = entry.Context.Set<InputMethod>(); int newID = default; do { // 生成隨機ID newID = rand.Next(); } // 保證不重覆 while (ents.Any(x => x.RecoId == newID)); // 返回新值 return newID; } }
我這裡的邏輯是這樣的,值是隨機生成的,但要用一個迴圈去檢查這個值是不是已存在資料庫中,如果存在,繼續生成,直到數值不重覆。
實現自定義生成器,有兩個抽象類可供選擇:
1、如果你生成的值,類型不確定(可能是int,可能是 long,可能是……),那就實現 ValueGenerator 類;
2、如果要生成的值是明確類型的,比如這裡是 int,那就實現帶泛型參數的 ValueGenerator<TValue> 類。
這兩個類有繼承關係,ValueGenerator<TValue> 派生自 ValueGenerator 類。需要實現的抽象成員:
A、GeneratesTemporaryValues 屬性:只讀,返回 bool 值。如果你生成的值是臨時的,返回 true,不是臨時的,返回 false。啥意思呢。臨時的值表示暫時賦值給屬性/欄位,但 INSERT、UPDATE 時,這個值不會存入資料庫;如果不是臨時的值,最終會存進資料庫。上面例子中,老周讓它返回 false,就說明生成的這個值,要寫入資料庫的。
B、如果繼承 ValueGenerator 類,請實現 NextValue 抽象方法,返回類型是 object,就是生成的值;如果繼承的是 ValueGenerator<TValue>,請實現 Next 方法,此方法返回的類型由泛型參數決定。上面例子中是 int。
寫好生成類後,要把它應用到實體模型中,同樣是重寫 DbContext 類的 OnModelCreating 方法。
protected override void OnModelCreating(ModelBuilder modelBuilder) { IConventionModelBuilder cvbd = modelBuilder.GetInfrastructure(); if (cvbd.CanSetValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None)) { cvbd.HasValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None); } modelBuilder.Entity<InputMethod>().HasKey(e => e.RecoId); modelBuilder.Entity<InputMethod>() .Property(k => k.RecoId) .HasValueGenerator<MyValueGenerator>() .ValueGeneratedOnAdd(); modelBuilder.Entity<InputMethod>().ToTable("tb_ims") .Property(x => x.MethodDisplay) .IsRequired() .HasMaxLength(12); }
ValueGeneratedOnAdd 方法表示在記錄插入資料庫時自動生成值,HasValueGenerator 方法設置你自定義的生成器。
現在,有了自定義生成規則,在插入數據時,主鍵不能賦值。一旦賦值,生成器就無效了。
// 嘗試插入兩條記錄 InputMethod[] ents = [ new(){ MethodDisplay = "雙拼輸入", Description="按兩個鍵完成一個音節",Culture="zh-CN"}, new() { MethodDisplay = "六指輸入", Description="專供六個指頭的人使用",Culture="zh-CN"} ]; dbc.Set<InputMethod>().AddRange(ents); int result = dbc.SaveChanges();
運行應用程式,你會發現,這次生成的 CREATE TABLE 語句中,RecoId 列已經沒有 IDENTITY 關鍵字了。
info: 2024/8/4 18:41:24.956 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (12ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT 1 info: 2024/8/4 18:41:24.982 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (4ms) [Parameters=[], CommandType='Text', CommandTimeout='60'] IF SERVERPROPERTY('EngineEdition') <> 5 BEGIN ALTER DATABASE [CrazyDB] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; END; info: 2024/8/4 18:41:25.003 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (21ms) [Parameters=[], CommandType='Text', CommandTimeout='60'] DROP DATABASE [CrazyDB]; info: 2024/8/4 18:41:25.104 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (82ms) [Parameters=[], CommandType='Text', CommandTimeout='60'] CREATE DATABASE [CrazyDB]; info: 2024/8/4 18:41:25.137 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (32ms) [Parameters=[], CommandType='Text', CommandTimeout='60'] IF SERVERPROPERTY('EngineEdition') <> 5 BEGIN ALTER DATABASE [CrazyDB] SET READ_COMMITTED_SNAPSHOT ON; END; info: 2024/8/4 18:41:25.142 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] SELECT 1 info: 2024/8/4 18:41:25.194 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) Executed DbCommand (6ms) [Parameters=[], CommandType='Text', CommandTimeout='30'] CREATE TABLE [tb_ims] ( [RecoId] int NOT NULL, [MethodDisplay] nvarchar(12) NOT NULL, [Description] nvarchar(max) NULL, [Culture] nvarchar(max) NULL, CONSTRAINT [PK_tb_ims] PRIMARY KEY ([RecoId]) ); info: 2024/