
上一次老周已介绍了 EF Core 框架自动发现实体和实体成员的原理。涉及到对源码的分析,可能大伙伴们都看得气压升高了。故这一次老周不带各位去分析源码了,咱们聊一聊熟悉又陌生的关键词——主键。说它熟悉,是因为只要咱们创建数据表,99%会用到;说它陌生,是指在 EF Core 中与主键相关的细节。
Primary Key,翻译为“主键”(这个翻译老周没意见,但 Thread 翻译成“线程”感觉莫名其妙)。按其命名,即是一张表中主要的键,用于表明某行记录在表中是唯一的。有大伙伴会说,那 Unique 约束也可以啊。是的,但还要有一个条件,就是不能为空值,所以,可以说主键是 UNIQUE 和 NOT NULL 的结合。
数据表的主键可以是一列,也可以是多列。
好了,概念说完了,咱们说回 EF。按照预置的约定(老周上一文中介绍),将属性发现为主键的原则有:
1、属性名为 Id;
2、属性名为实体类名 + Id,如 ProductId、OrderId 等。
最常用的类型是 int,自动增长。也可以用 GUID,GUID 属性的类型可以定义为 Guid,也可以是 string。老周,有例子吗?有,咱们玩几个,咱们使用 Sqlite 数据库来演示。
1、创建一个控制台应用。
dotnet new console -n Demo -o .
有伙伴会问:这个用 Copilot 能不能执行?可以,比如这样:
它生成的命令少了 -o . ,你可以手动补上。
如果你不想它自动执行命令,那不要点“继续”,复制命令文本后,点“取消”就好。若继续,它会直接执行命令。
尽管可以这样用,但这样做特愚蠢!你直接打个命令都比这个快了。写实体类的时候,如果你不想重复敲 get 和 set,倒可以用它辅助。当然,VS其实也会提示的,你按个 Tab 就会生成了。这个东西虽然好用,但有时候也挺烦的,按个 Tab 就出一堆东西(如果不想禁掉它,可以按 Esc 键取消提示)。如果你真不想用它,可以到设置里面找到【文本编辑器】-【建议】,去掉 Inline Suggest: Enabled 的选项即可。
2、定义实体类。这次咱们用一个 Pet 类,表示你家的宠物。
public class Pet { public int id { get; set; } public string Name { get; set; } public int Age { get; set; } public string Cate { get; set; } }
这个你倒可以用辅助工具写。注意这里老周故意把标识属性改为小写,即 id,而不是 Id。待会咱们看看 EF 能不能识别。
3、为项目添加包。
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
4、写数据上下文类。
public class MyDbContext : DbContext { public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) { } public DbSet<Pet> Pets { get; set; } }
5、这一次咱们的连接字符串不在 MyDbContext 内部配置,而是外部构建 Options 来配置。
// 创建选项类实例 DbContextOptions<MyDbContext> options = new DbContextOptionsBuilder<MyDbContext>() .UseSqlite("Data Source=mydb.db") .Options; // 实例化 DbContext using var dc = new MyDbContext(options);
6、这个例子中,咱们不创建数据库,只是验证一下,全小写的 id 属性是否能被识别为主键。
/* 此处我们不创建数据库,只是看看它能不能识别出主键 */ // 获取实体列表 foreach (var ent in dc.Model.GetEntityTypes()) { Console.Write($"表名: {ent.GetTableName()}"); // 查找主键 var rmykey = ent.FindPrimaryKey(); if (rmykey != null) { Console.WriteLine($",主键: {string.Join(", ", rmykey.Properties.Select(p => p.Name))}"); } // 实体中的列(属性) foreach (var property in ent.GetProperties()) { Console.WriteLine($"\t列名: {property.Name} 类型: {property.ClrType.Name}"); } }
dc.Model.GetEntityTypes 方法能够返回模型中所有实体的信息。GetTableName 返回实体对应的数据表名,FindPrimaryKey 方法找出此实体类的主键。最后,GetProperties 方法获取实体类属性对应的列。
上述代码运行后得到的结果如下:
表名: Pets,主键: id
列名: id 类型: Int32
列名: Age 类型: Int32
列名: Cate 类型: String
列名: Name 类型: String
好,看来,小写的 id 属性是可以被识别为主键的(老周不再分析 EF Core 源代码了,不然这博文就没人看了,其实是通过约定实现的)。同理,我们还可以验证一下,全小写的 petid 能不能识别。把 Pet 类改为:
public class Pet { public int petid { get; set; } …… }
至少咱们知道,PetId 是肯定能被识别为主键的,现在验证一下全小写的<类名>id的属性。再次运行程序,得到:
表名: Pets,主键: petid 列名: petid 类型: Int32
列名: Age 类型: Int32
列名: Cate 类型: String
列名: Name 类型: String
这个示例证明:Id 和 <类名>Id 都能被约定识别为主键,并且不区分大小写。
那么,如果属性的名称不是 <类名>Id 呢,比如这样改:
public class Pet { public int BugId { get; set; } public string Name { get; set; } = string.Empty; public int Age { get; set; } public string? Cate { get; set; } }
再次运行一下,结果不出所料。
这段鸟语说了啥?它说 Pet 这厮必须定义主键,如果你不想要主键,那得明确地把实体配置为无主键。怎么配置为无主键咱们后文再说,现在先说说“预制菜”约定无法自动识别出主键,咱们如何手动配置。
1、简单做法,用特性在 BugId 属性上批注一下。
[PrimaryKey(nameof(BugId))] public class Pet { …… }
这种方法最简单,但老周个人不推荐,因为不集中配置,不好管理。当然,只是老周不推荐,没说不可以用啊。
2、通过 ModelBuilder 来配置,这个在 DbContext 的派生类中重写 OnModelCreating 方法。
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Pet>().HasKey(x => x.BugId); }
老周比较推荐这种方法,因为它把所有实体的配置全集中一处,将来有改动也好搞,也不容易忘这个忘那个的。两种方法任选其一,不需要同时用。
再次运行程序,看到想要的结果了。
表名: Pets,主键: BugId 列名: BugId 类型: Int32
列名: Age 类型: Int32
列名: Cate 类型: String
列名: Name 类型: String
有时候你可能会想:我的代码中并不需要访问主键,主键仅留给 EF 自己用于生成 SQL 语句,那我能不能把影子属性作为主键呢?答案是 Yes 的。先简单说说影子属性(Shadow Property)是什么,一句话斯基:你的实体类中未定义的,但模型中定义了的属性。
同理,你的 DbContext 子类需要重写 OnModelCreating 方法。
protected override void OnModelCreating(ModelBuilder modelBuilder) { // 这一行很重要 modelBuilder.Entity<Pet>().Property(typeof(int), "HideId"); // 设置主键 modelBuilder.Entity<Pet>() .HasKey("HideId"); }
Pet 类可以去掉作为主键的属性。
public class Pet { public string Name { get; set; } = string.Empty; public int Age { get; set; } public string? Cate { get; set; } }
由于影子属性在实体类未定义,EF Core 并不能确定其类型能不能成为主键。因此,在定义主键前应该让 EF 知道作为主键的影子属性是支持的类型,如 int。
modelBuilder.Entity<Pet>().Property(typeof(int), "HideId");
上面的例子就是把影子属性 HideId 作为 Pet 实体的主键。运行结果如下:
表名: Pets,主键: HideId 列名: HideId 类型: Int32
列名: Age 类型: Int32
列名: Cate 类型: String
列名: Name 类型: String
主键也可以由多个属性(列)组成。比如,咱们让 HideId 和 Name 组成主键。
protected override void OnModelCreating(ModelBuilder modelBuilder) { // 这一行很重要 modelBuilder.Entity<Pet>().Property(typeof(int), "HideId"); // 设置主键 modelBuilder.Entity<Pet>() .HasKey("HideId", nameof(Pet.Name)); }
得到的运行结果如下:
表名: Pets,主键: HideId, Name 列名: HideId 类型: Int32
列名: Name 类型: String
列名: Age 类型: Int32
列名: Cate 类型: String
下面咱们演示一下把 string 类型的属性映射到 SQL Server 数据表的 unique identifier 列。
1、用以下 SQL 脚本(使用的是 SQL Server)创建数据库和数据表。
-- 创建数据库 CREATE DATABASE Test; GO -- 切换到刚刚创建的数据库 USE Test; GO -- 创建表 CREATE TABLE Productions ( -- 这个是主键,插入时如果未提供值,则用 NEWID() 产生的值 Pid UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(), -- 产品名称 ProdName NVARCHAR(40) NOT NULL, -- 生产年份 Year INT, -- 产品尺寸 Size DECIMAL(6,2), -- 产品颜色 Color NVARCHAR(10), -- 备注 Remark NVARCHAR(MAX) );
2、创建控制台 .NET 项目(此处省略250个字)。
3、定义实体类(这个可以用 dotnet ef dbcontext 命令生成,不过老周一向习惯纯手写,生成的实体类有时候要回头修改)。
public class Production { public string Pid { get; set; } = null!; public string ProdName { get; set; } = string.Empty; public int? Year { get; set; } public decimal? Size { get; set; } public string? Color { get; set; } public string? Remark { get; set; } }
Pid 属性要作为主键用的,注意这里老周故意让其默认值为 null,这样在 EF 上下文添加实体时使用数据库生成的值(否则会报错)。null 后面有个感叹号(!)这个可以避免编译器的 Nullable 警告,具体情况你可以找微软官方文档,有详细说明。就是微软文档写得太好了,导致很多基础知识老周都不必重复介绍了。
4、派生 DbContext 的子类。
public class MyContext : DbContext { public DbSet<Production> Productions { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // 配置连接字符串 optionsBuilder.UseSqlServer(@"Server=(localdb)\MSSQLLocalDB;Database=Test;Trusted_Connection=True"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Production>(entbd => { entbd.Property(p => p.Pid) .ValueGeneratedOnAdd(); // 产品名称为必须,且有长度限制 entbd.Property(p => p.ProdName) .IsRequired() .HasMaxLength(40); // 产品尺寸的精度要求 entbd.Property(p => p.Size) .HasPrecision(6, 2); // 产品颜色的长度限制 entbd.Property(p => p.Color) .HasMaxLength(10); // 主键 entbd.HasKey(p => p.Pid); }); } }
ModelBuilder 的 Entity 方法可以获得一个 EntityTypeBuilder 对象(上面老周是调用了带 Action 委托的重载,方便多次调用 EntityTypeBuilder 实例的成员)。EntityTypeBuilder 类内部封装了 InternalEntityTypeBuilder 对象,各种配置方法实际调用了此 InternalEntityTypeBuilder 对象的成员。
5、在 Program.cs 文件中,写一下测试代码。咱们向数据库存入两条记录。
// 实例化上下文 MyContext dc = new MyContext(); // 新建两条记录 Production p1 = new() { ProdName = "五角裤", Year = 2025, Size = 67.33m, Color = "白色", Remark = "纯化学合成纤维,无自然成份" }; Production p2 = new() { ProdName = "无领衬衫", Year = 2025, Size = 47.00m, Color = "黄色", Remark = "冰丝,炎炎夏日,如同把冰块披在身上" }; dc.Productions.AddRange(p1, p2); // 保存到数据库 dc.SaveChanges(); dc.Dispose();
如果代码顺利运行,则数据库中就有两条新记录了。
select * from dbo.Productions
当然了,对于 UNIQUE IDENTIFIER 类型的主键,.NET CLR 实体类的属性除了可以用字符串类型,也可以用 Guid 类型。原理也是一样的,这里老周就不演示了,相信大伙伴们都会的。
===========================================================================================================
接下来看看无主键的实体。这个其实没什么特别的知识要掌握的,但你得记住一条:无主键的实体只能 SELECT,不能用于 INSERT、UPDATE、DELETE 操作。一句话斯基总结就是:只能查询不能更新。
咱们还是整个例子吧。
1、用以下SQL脚本创建数据库和数据表。
create database DemoSome; GO use DemoSome; GO -- 创建表 create table HandsomeBoys ( BoyID int IDENTITY, [Name] NVARCHAR(25) not null, Age int, City NVARCHAR(10), PhoneNo NVARCHAR(11), Email NVARCHAR(40), CONSTRAINT [PK_HandsomeBoys] PRIMARY KEY CLUSTERED (BoyID ASC) ); GO
2、向数据表 INSERT 几条数据用于测试,随便写,略。
3、创建.NET控制台应用程序,略。
4、定义实体类(可以用 dotnet ef 工具生成,也可以纯手打)。
public class HandsomBoy { public int ID { get; set; } public string Name { get; set; } = string.Empty; public string? Email { get; set; } public string? City { get; set; } public int? Age { get; set; } public string? PhoneNo { get; set; } }
5、写数据库上下文类,构建模型。
public class DemoDB : DbContext { public DemoDB(DbContextOptions<DemoDB> options) :base(options) { } public DbSet<HandsomBoy> HandsomeBoys { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // 无主键 modelBuilder.Entity<HandsomBoy>().HasNoKey(); // 表映射 var entbd = modelBuilder.Entity<HandsomBoy>().ToTable("HandsomeBoys"); // 属性映射 entbd.Property(p => p.ID).HasColumnName("BoyID"); entbd.Property(p => p.Name) .IsRequired() .HasMaxLength(25); entbd.Property(p => p.City).HasMaxLength(10); entbd.Property(p => p.Email).HasMaxLength(40); entbd.Property(p => p.PhoneNo).HasMaxLength(11); } }
注意要调用 HasNoKey 方法配置实体为无主键,不然会报错。
6、测试。
// 配置选项 DbContextOptions<DemoDB> options = new DbContextOptionsBuilder<DemoDB>() .UseSqlServer("Server=(localdb)\\MSSQLLocalDB;Database=DemoSome;Trusted_Connection=True") // 记录日志 .LogTo(msg => Console.WriteLine(msg)) .EnableSensitiveDataLogging() .Options; using var dc = new DemoDB(options); // 查询数据 var q = from b in dc.HandsomeBoys select b; foreach (var x in q) { Console.WriteLine($"ID={x.ID}, Name={x.Name}, Age={x.Age}, City={x.City}, Phone={x.PhoneNo}"); }
结果如下:
ID=1, Name=小陈, Age=35, City=珠海, Phone=15562021200 ID=2, Name=老周, Age=105, City=东莞, Phone=13888582588 ID=3, Name=老丁, Age=45, City=中山, Phone=15840991234
无主键实体也可以用特性批注。
[Keyless] public class HandsomBoy { …… }
两种方法,二选一。
好了,今天就聊到这儿了。