【EF Core】带主键实体与无主键实体

上一次老周已介绍了 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
{
    ......
}

两种方法,二选一。

好了,今天就聊到这儿了。