上一次老周已介绍了 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
{
......
}
两种方法,二选一。
好了,今天就聊到这儿了。