【EF Core】继承策略——TPC
SQL Server。然后有些实体他设定了 CHECK 约束。众所周知配置 CHECK 约束是直接用 SQL 表达式的。这位同仁比较负责他觉得哪怕用 EF Core 生成数据库也要规范一点字段名也应该用边界字符比如在 SQLite 中边界是双引号表达式应写成 age 15在 SQL Server 中写成 [age] 15。同仁的意思是他不想硬编码EF Core 有没有相关的 API 可以根据不同数据库自动产生边界字符。于是作为“老一辈”老周教了他两招。1、比较笨的方法其实也是硬编码。/*--------------------------------- 实体类 ------------------------------*/ public class Person { public int Id { get; set; } public required string Name { get; set; } public int Age { get; set; } } /*-------------------------------- 数据库上下文 ----------------------------*/ public class TestContext:DbContext { public TestContext(DbContextOptionsTestContext options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { var entity modelBuilder.EntityPerson(); // 配置主键 entity.HasKey(b b.Id).HasName(PK_People); // 配置表映射 entity.ToTable(tb_people, tb { // 列映射 tb.Property(a a.Id).HasColumnName(ps_id); tb.Property(a a.Name).HasColumnName(ps_name); tb.Property(a a.Age).HasColumnName(ps_age); // 配置CHECK约束 string delimiteLeft , delimiteRight ; if(this.Database.IsSqlite()) { delimiteLeft delimiteRight \; } if(this.Database.IsSqlServer()) { delimiteLeft [; delimiteRight ]; } tb.HasCheckConstraint(CK_Age,${delimiteLeft}ps_age{delimiteRight} 20); }); } }这套方案是使用了 IsSqlServer 方法来判断当前配置的是否为 SQL Server 数据库IsSqlite 方法判断当前配置的是否为 SQLite 数据库。实例化上下文时通过构造函数来传递选项以使用不同的数据库。// 用 SQL Server DbContextOptionsBuilderTestContext opbuilder1 new(); opbuilder1.UseSqlServer(Server...); using(var ctx new TestContext(opbuilder1.Options)) { // 这里咱们不是真的建库仅获取生成的 SQL Console.WriteLine(使用 SQL Server 数据); Console.WriteLine(ctx.Database.GenerateCreateScript()); Console.Write(\n); } // 使用 SQLite 数据库 DbContextOptionsBuilderTestContext opbuilder2 new(); opbuilder2.UseSqlite(data source...); using (var ctx new TestContext(opbuilder2.Options)) { Console.WriteLine(使用 SQLite 数据); Console.WriteLine(ctx.Database.GenerateCreateScript()); Console.Write(\n); }结果如下使用 SQL Server 数据 CREATE TABLE [tb_people] ( [ps_id] int NOT NULL IDENTITY, [ps_name] nvarchar(max) NOT NULL, [ps_age] int NOT NULL, CONSTRAINT [PK_People] PRIMARY KEY ([ps_id]), CONSTRAINT [CK_Age] CHECK ([ps_age] 20) ); GO 使用 SQLite 数据 CREATE TABLE tb_people ( ps_id INTEGER NOT NULL CONSTRAINT PK_People PRIMARY KEY AUTOINCREMENT, ps_name TEXT NOT NULL, ps_age INTEGER NOT NULL, CONSTRAINT CK_Age CHECK (ps_age 20) );但这种做法还是不够“老辣”咱们看下一个方案。2、巧用 ISqlGenerationHelper 服务。这个最好用不用去判断数据库是什么能够自动生成带边界字符的名称。entity.ToTable(tb_people, tb { // 列映射 …… // 获取服务 ISqlGenerationHelper sqlHelper this.GetServiceISqlGenerationHelper(); // 生成带边界字符的列名 string ageColName sqlHelper.DelimitIdentifier(ps_age); // 配置CHECK约束 tb.HasCheckConstraint(CK_Age, ${ageColName} 20); });咱们增加一个 PostgreSQL 的 provider 来测试一下。// 用 SQL Server DbContextOptionsBuilderTestContext opbuilder1 new(); opbuilder1.UseSqlServer(Server...); using(var ctx new TestContext(opbuilder1.Options)) { // 这里咱们不是真的建库仅获取生成的 SQL Console.WriteLine(使用 SQL Server 数据); Console.WriteLine(ctx.Database.GenerateCreateScript()); Console.Write(\n); } // 使用 PostgreSQL 数据库 DbContextOptionsBuilderTestContext opbuilder2 new(); opbuilder2.UseNpgsql(Host...); using (var ctx new TestContext(opbuilder2.Options)) { Console.WriteLine(使用 PostgreSQL 数据); Console.WriteLine(ctx.Database.GenerateCreateScript()); Console.Write(\n); } // 使用 SQLite 数据库 DbContextOptionsBuilderTestContext opbuilder3 new(); opbuilder3.UseSqlite(data source...); using (var ctx new TestContext(opbuilder3.Options)) { Console.WriteLine(使用 SQLite 数据); Console.WriteLine(ctx.Database.GenerateCreateScript()); Console.Write(\n); }得到结果如下使用 SQL Server 数据 CREATE TABLE [tb_people] ( [ps_id] int NOT NULL IDENTITY, [ps_name] nvarchar(max) NOT NULL, [ps_age] int NOT NULL, CONSTRAINT [PK_People] PRIMARY KEY ([ps_id]), CONSTRAINT [CK_Age] CHECK ([ps_age] 20) ); GO 使用 PostgreSQL 数据 CREATE TABLE tb_people ( ps_id integer GENERATED BY DEFAULT AS IDENTITY, ps_name text NOT NULL, ps_age integer NOT NULL, CONSTRAINT PK_People PRIMARY KEY (ps_id), CONSTRAINT CK_Age CHECK (ps_age 20) ); 使用 SQLite 数据 CREATE TABLE tb_people ( ps_id INTEGER NOT NULL CONSTRAINT PK_People PRIMARY KEY AUTOINCREMENT, ps_name TEXT NOT NULL, ps_age INTEGER NOT NULL, CONSTRAINT CK_Age CHECK (ps_age 20) );-----------------------------------------------------------------------------------------------------------------------------------------------好了正片开始。今天咱们聊实体继承中的第三种映射策略——TPC。TPC 是地球和平联合组织……我呸是 Table per Concrete Class 的缩写。它与 TPT 挺像共同点是“每个类都有对应的表”但不同点在于“具体类型”啥意思呢至少包含两个意思1、可实例化的类抽象类就不映射了哟2、类中的属性字段成员不管是本类中定义的还是从基类继承过来的都会做列映射。这么一说TPC 的独立性更强。咱们上一次所聊的 TPT 策略由于不映射从基类继承的成员所以需要通过外键与基类所映射的表建立一对一关系查询时需要表联合带来了亿些性能上的问题。而 TPC 是包含了基类成员的它不需要与基类的表建立相对关系不设立外键使用时直接单表查询即可。使查询过程变简单了。TPC 策略很适合那种“开枝散叶”式继承的实体。典型场景是某个抽象作为公共基类然后派生出同级别的 N 多个实现类。比如下面这个继承关系很是经典高考每年必考。/// summary /// 公共基类很抽象的 /// /summary public abstract class Animal { /// summary /// 只是主键无其他含义 /// /summary public int Id { get; set; } /// summary /// 这头野兽叫什么 /// /summary public abstract string Name { get; set; } /// summary /// 这头野兽多大了 /// /summary public abstract int Age { get; set; } } public class Cat : Animal { public override string Name { get; set; } null!; public override int Age { get; set; } /// summary /// 新增成员毛发纹理 /// /summary public string? Texture { get; set; } } public class Dog : Animal { public override string Name { get; set; } John; public override int Age { get; set; } 1; /// summary /// 新增成员喜欢的食物 /// /summary public string? FavFood { get; set; } }按照上述代码基类是 Animal其他两个是它的子类。且按照咱们对前两种映射策略的说明映射策略、主键是必须在基类上配置的。正是如此Id 属性只能定义在基类。也就是说在模型配置时Animal 类是要添加到实体模型中的映射不映射由 EF Core 自己处理。以 SQL Server 数据库为例实现数据库上下文。public class TestDbContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(Server(localdb)\\MY;Database畜生档案馆;MultipleActiveResultSetsTrue); // 配置一下日志好查看SQL optionsBuilder.LogTo((evtid, lv) evtid RelationalEventId.CommandExecuted, evtdata { if(evtdata is CommandEventData cmddata) { // 改变文本颜色 Console.ForegroundColor ConsoleColor.Blue; // 记录SQL Console.WriteLine($ [SQL] {cmddata.Command.CommandText} ); // 记录完日志后恢复颜色为默认 Console.ResetColor(); } }); } protected override void OnModelCreating(ModelBuilder modelBuilder) { var entAnim modelBuilder.EntityAnimal(); // 映射策略 entAnim.UseTpcMappingStrategy(); // 主键 entAnim.HasKey(x x.Id); // 名称的最大字符数 entAnim.Property(x x.Name).HasMaxLength(15); var entDog modelBuilder.EntityDog(); // 表映射 entDog.ToTable(tb_dogs, tb { tb.Property(g g.Id).HasColumnName(dog_id); tb.Property(g g.Name).HasColumnName(dog_name); tb.Property(g g.Age).HasColumnName(dog_age); tb.Property(g g.FavFood).HasColumnName(fav_food); }); var entCat modelBuilder.EntityCat(); // 表映射 entCat.ToTable(tb_cats, tb { tb.Property(y y.Id).HasColumnName(cat_id); tb.Property(y y.Name).HasColumnName(cat_name); tb.Property(y y.Age).HasColumnName(cat_age); tb.Property(y y.Texture).HasColumnName(cat_texture); }); } }比较重要的几点老周逐个说明一下。1、数据库的连接字符串要加上 MultipleActiveResultSetsTrue批量插入数据时会返回多个结果不加这个会报错。和 TPH、TPT 一样使用 TPC 策略也是在配置基类实体时调用 UseTpcMappingStrategy 方法。2、由于 TPC 策略下每个表是独立的因此每个表的名称以及列的名称都可以自定义。注意要调用 ToTable 方法再通过 TableBuilder 对象来配置列名不要在 PropertyBuilder 上配置。在上一篇水文中老周给大伙伴演示过EF Core 在建立数据库 Model 的时候若实体间存在继承关系那么属性元数据是共享的。比如Name 属性从 Animal 到 Cat、Dog 实体都是共享元数据的。如果使用 PropertyBuilder.HasColumnName 来配置列名那么只有最后设置的名称生效就无法做到每个派生类的列名称独立了。因此一定要用 ToTable 方法让表映射变成 Override 版本EF Core 内部会自动保存每个覆盖的属性配置。3、也正因为存在继承关系的成员是共享元数据的所以像 Name 属性那样要配置最大字符数为 15也只能在 Animal 类上配置而且所以派生类所映射的表中各个继承的成员所对应的列其类型和参数也必须相同的。即 cat_name 列和 dog_name 列的类型和所占空间大小是相同的cat_age 与 dog_age 列也是如此。下面咱们来测试一下。由 EF Core 负责创建数据库。然后向数据库插入四条记录。static async Task Main(string[] args) { // 由运行时自动创建数据库 using(var c new TestDbContext()) { _ awaitc.Database.EnsureCreatedAsync(); } // 插入一些记录试试 using(var c new TestDbContext()) { Animal[] chuShengs [ new Cat() { Name Jack, Age 2, Texture 虎斑 }, new Dog() { Name Mike, Age 3, FavFood 鸡屁股 }, new Dog() { Name Peter, Age 2, FavFood 狗粮 }, new Cat() { Name Lily, Age 2, Texture 三花 } ];awaitc.AddRangeAsync(chuShengs); // 保存数据 _ awaitc.SaveChangesAsync(); } }在上述代码中老周用的是异步等待版本。在 ASP.NET Core 项目中推荐这样其他项目就随意吧。咱们看看 EF Core 在创建数据库时生成的 SQL 语句。CREATE DATABASE [畜生档案馆]; CREATE SEQUENCE[AnimalSequence] START WITH 1 INCREMENT BY 1NO CYCLE; CREATE TABLE [tb_cats] ( [cat_id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]), [cat_name] nvarchar(15) NOT NULL, [cat_age] int NOT NULL, [cat_texture] nvarchar(max) NULL, CONSTRAINT [PK_tb_cats] PRIMARY KEY ([cat_id]) ); CREATE TABLE [tb_dogs] ( [dog_id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]), [dog_name] nvarchar(15) NOT NULL, [dog_age] int NOT NULL, [fav_food] nvarchar(max) NULL, CONSTRAINT [PK_tb_dogs] PRIMARY KEY ([dog_id]) );TPC 的情况特殊咱们看到生成的SQL中包含在 SQL Server 中创建递增序列 AnimalSequence。数据表创建了两个tb_cats 和 tb_dogs。而它们的主键不再是 IDENTITY而是由序列来产生下一个值。为什么会这样呢这是为了 EF Core 的实体追踪跟踪。从面向对象的角度看Animal 是公共基类那么一个 Animal 对象的集合它既可以包含 Cat 实例也可以包含 Dog 实例。你猜猜下面代码中数据集合中会有几个实例using(var ctx new TestDbContext()) { // 获取数据集合 var animals ctx.SetAnimal(); foreach(Animal anm in animals) { Console.WriteLine(${anm.Name}\t{anm.Age}); } }答案是Jack 2 Lily 2 Mike 3 Peter 2现在咱们假设一下如果主键由 IDENTITY 生成而不是序列那么就会有一条 Cat 记录的 ID 是 1一条 Dog 记录的 ID 也是1。结果是Animal 类型的集合里有两个实例的主键是 1。同理如果继续插入数据就会出现 ID 同时为 2 的 Cat 和 Dog 实例。EF Core 是通过主键的值来跟踪实体状态的现在出现主键相同的实例就不好搞了。所以才要使用序列保证所有派生类所在的表中主键的值在【全局】层面不会重复。就像这样你看tb_cats 表中的主键值依次为 1、2而 tb_dogs 表中的主键值则依次为 3、4。这样一来在 Animal 集合中这四条记录的 ID 值就不重复了EF Core 就能进行跟踪了。EF Core 在数据集合的查询中是遵守面向对象规则的。比如咱们上面的集合—— SetAnimal它可以包含 Cat 和 Dog 实例这是本着类型兼容性原则Cat 和 Dog 都是派生类可以赋值给声明为 Animal 的对象。如果把代码这样改呢。// 获取数据集合 var animals ctx.SetDog(); foreach(Animal anm in animals) { Console.WriteLine(${anm.Name}\t{anm.Age}); }现在你猜猜数据集合有几个实例答案是Mike 3 Peter 2这时候Dog 集合只能兼容 Dog 类除非有 Dog 的派生类。虽然 TPC 策略中我们不需要配置类型鉴别器但在查询时生成的SQL语句EF Core 也会插入鉴别标识的。比如前面查询 Animal 集合的生成的 SQL 如下SELECT [t].[cat_id], [t].[cat_age], [t].[cat_name], [t].[cat_texture], NULL AS [fav_food],NCat AS [Discriminator]FROM [tb_cats] AS [t] UNION ALL SELECT [t0].[dog_id] AS [cat_id], [t0].[dog_age] AS [cat_age], [t0].[dog_name] AS [cat_name], NULL AS [cat_texture], [t0].[fav_food],NDog AS [Discriminator]FROM [tb_dogs] AS [t0]咱们看到EF Core 加了一个名为 Discriminator 的字段字段的值就是类名。咱们还有一个问题没解决像 SQLite 这样不能用序列的数据库在 TPC 映射策略下如何处理主键呢。最简单粗暴的方法就是插入新记录时直接给它分配一个——我们手动赋值。当然咱们还有简单不粗暴的方法那就是使用客户端生成器即由 EF Core 来生成。就是用 ValueGenerator这货在很多场合还是很有用的。先看本示例的主角——实体类。/// summary /// 抽象类卡牌游戏 /// /summary public abstract class CardGame { /// summary /// 主键 /// /summary public string CardId { get; set; } null!; /// summary /// 名称 /// /summary public abstract string Name { get; set; } /// summary /// 是否为主牌 /// /summary public abstract bool IsMajor { get; set; } } /// summary /// 扑克牌 /// /summary public class Poker : CardGame { public required override string Name { get; set; } public override bool IsMajor { get; set; } /// summary /// 牌上数字新增 /// /summary public int Number { get; set; } } /// summary /// 库洛牌 /// /summary public class ClowCard : CardGame { public required override string Name { get; set; } /// summary /// 是否为四大元素牌 /// /summary public override bool IsMajor { get; set; } }公共基类表示卡牌游戏的共同特征。然后就是扑克牌和库洛牌其实二者还有些像的扑克牌有四大主牌库洛牌有四大元素牌。用当天的日期 GUID。这个我相信就算你一天要插入 10 的 99 次方条记录应该也不会遇上有重复值的。public class MyIDValueGenerator : ValueGeneratorstring { public override string Next(EntityEntry entry) { // 当前日期 DateTime currdt DateTime.Now; string firstPart currdt.ToString(yyMMdd); // GUID string secondPart Guid.NewGuid().ToString(N); // 组成新值返回 return firstPart _ secondPart; } // 此时生成的值可不是临时值而是要存入数据库的所以返回 false public override bool GeneratesTemporaryValues false; }ValueGenerator 是派生自 ValueGenerator 的泛型抽象类。带类型参数的基类继承起来更舒服。我们要实现两个成员1、GeneratesTemporaryValues 属性只读属性表示此生成器生成的值是不是临时的。啥意思呢就是生成的值只在 EF Core 跟踪实体过程用不会存入数据库。比如自增长列每次生成新值都是数据库完成的但是新的实体实例在保存到数据库前是没有生成的值的这时候可以给它临时分配一个值。咱们这里生成的值是要存入数据库的所以要返回 false表示非临时值。2、Next 方法。返回生成的新值。本例中老周用日期和 GUID 组成新值用“_”字符连接。下面写一下 DbContext 的派生类配置数据库模型。public class TestContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // 日志配置 ILoggerFactory logfac LoggerFactory.Create(logbuilder { // 添加控制台日志 logbuilder.AddConsole(); // 过滤 logbuilder.AddFilter((cate, lv) { return cate Microsoft.EntityFrameworkCore.Database.Command lv LogLevel.Information; }); }); // 配置数据库 optionsBuilder.UseSqlite(data sourcecards.db) .EnableSensitiveDataLogging(true) .UseLoggerFactory(logfac); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityCardGame(cgm { // 主键 cgm.HasKey(c c.CardId); // 映射策略 cgm.UseTpcMappingStrategy(); // 配置值生成器 cgm.Property(x x.CardId).HasValueGeneratorMyIDValueGenerator(); }); modelBuilder.EntityPoker(pkt { pkt.ToTable(tb_poker, tb { tb.Property(w w.Name).HasColumnName(pk_name); tb.Property(w w.CardId).HasColumnName(pk_id); tb.Property(w w.IsMajor).HasColumnName(pk_major); tb.Property(k k.Number).HasColumnName(pk_num); }); }); modelBuilder.EntityClowCard(cwt { cwt.ToTable(tb_clowcard, tb { tb.Property(t t.CardId).HasColumnName(cc_id); tb.Property(t t.Name).HasColumnName(cc_name); tb.Property(g g.IsMajor).HasColumnName(cc_major); }); }); } }在配置模型时调用 HasValueGenerator 方法应用我们自己写的值生成器。注意值生成器是针对列的所以你得在属性成员上配置。这一次的日志记录老周玩了点新花样用到了 .NET 的 Logging 功能相信大伙伴在 ASP.NET Core 上都玩得很 6 的了。如果是控制台项目记得引用这个 Nuget 库Microsoft.Extensions.Logging.Console。这里咱们比较关心执行过的 SQL 语句所以在 Logging 的配置中老周做了过滤。// 添加控制台日志 logbuilder.AddConsole(); // 过滤 logbuilder.AddFilter((cate, lv) { return cate Microsoft.EntityFrameworkCore.Database.Command lv LogLevel.Information; });.NET Logging 是按日志类别Category来输出的而不是 EF Core 内部使用的 Event ID输出 SQL 语句的类别是 Microsoft.EntityFrameworkCore.Database.Command。配置之后控制台只打印这个类别且属于“信息”级别的日志错误调试等级别就不打印。EnableSensitiveDataLogging 方法表示在打印日志显示查询参数的值为了安全一般我们不开启它如果你想看到参数的具体的值那就开启投入生产环境后注释掉就好了。运行程序。下面是创建表的 SQL 语句。CREATE TABLE tb_clowcard ( cc_id TEXT NOT NULL CONSTRAINT PK_tb_clowcard PRIMARY KEY, cc_name TEXT NOT NULL, cc_major INTEGER NOT NULL ); CREATE TABLE tb_poker ( pk_id TEXT NOT NULL CONSTRAINT PK_tb_poker PRIMARY KEY, pk_name TEXT NOT NULL, pk_major INTEGER NOT NULL, pk_num INTEGER NOT NULL );