【EF Core】继承策略——TPH

既然 EF Core 的设计理念是以面向对象的方式操作数据库,那么,继承问题是绕不过去的。然而大伙伴也知道,数据库是用表来存储数据记录的,表与表之间哪来的继承。很自然地,EF Core 必须在数据库与实体类之间做一些特殊处理,才能让存在继承关系的实体类与数据表之间的映射不被破坏。

EF Core 内置了三种应对策略:

1、TPH:全称 Table Per Hierarchy。其特点是将整个继承链上的实体都存放在一个数据表中。假设有 X、Y 两实体,Y 继承 X,于是,两个实体都存到名为 T_abc 的表中。由于多个类型映射到一个表中,为了区分,EF Core 会向数据表添加一个额外的列,用于标识类型。默认用的是实体类的名称。

2、TPT:Table-Per-Type。从命名可以看出,每个类型对应一个表。假设 B 继承 A,那么,A 映射到 TA 表,B 映射到 TB 表。但,数据表只映射当前类所定义的成员,不包括从基类继承的成员。假如,A 类有 ID、X 两个属性,那么,TA 表就会映射 ID 和 X;B 类定义了 C 属性,映射时只把 C 属性映射到 TB 表,不会映射从 A 类继承的 ID 和 X 属性。

3、TPC:即 Table-Per-Concrete-Type。它强调"具体类型",其实它和 TPT 很像,都每个实体都映射一个表,但是,TPC 策略中,派生类会把从基类继承的成员也映射到表中。引沿上面举的例子,即 A 类会映射 ID 和 X 属性到 TA 表,而 B 类会映射 ID、X、C 属性到 TB 表。

咱们先来研究 TPH 策略。

我们举个例子,有两个类,它们存在继承关系。

复制代码
// 基类
public class BaseType
{
    public int Bid { get; set; }
}

// 派生类
public class DeriveType : BaseType
{
    public double SValue { get; set; }
}

EF Core 的公共约定类具备自动发现继承链的能力,前提是你得先把这些实体类添加到数据库模型中。因此,重写 DbContext 类的 OnModelCreating 方法时,代码可以这样写:

复制代码
// 配置基类实体
modelBuilder.Entity<BaseType>(ent =>
{
    ent.HasKey(x => x.Bid);     // 主键
    ent.ToTable("tb_zbzb");     // 设置表名
});
// 配置派生类实体
modelBuilder.Entity<DeriveType>();

在配置模型时,有些地方得注意:

A、TPH 只映射一个表,基类子类都存放一起,所以,只能在基类的配置中调用 ToTable 方法设置表名,在配置派生类时不要再映射表名了;

B、主键只能在基类的配置中设置,不能在派生类的配置中设置。如上面的例子,如果在配置 DeriveType 类上设置主键,比如这样。

复制代码
// 基类
public class BaseType
{
    public int Bid { get; set; }
}

// 派生类
public class DeriveType : BaseType
{
    public int Mid { get; set; }
    public double SValue { get; set; }
}

//--------------------------------------------------
// 配置基类实体
modelBuilder.Entity<BaseType>(ent =>
{
    ent.ToTable("tb_zbzb");     // 设置表名
});
// 配置派生类实体
modelBuilder.Entity<DeriveType>(d =>
{
    d.HasKey(x=> x.Mid);      // 主键
});

运行后,就会报以下错误:

复制代码
A key cannot be configured on 'DeriveType' because it is a derived type. 
The key must be configured on the root type 'BaseType'. 
If you did not intend for 'BaseType' to be included in the model, 
ensure that it is not referenced by a DbSet property on your context instance, 
referenced in a configuration call to 'ModelBuilder', or referenced from a navigation on a type that is included in the model.

上面例子,构建了以下数据库模型。

复制代码
Model: 
  EntityType: BaseType
    Properties: 
      Bid (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      *Discriminator* (no field, string) Shadow Required AfterSave:Throw MaxLength(8)
    Keys: 
      Bid PK
  EntityType: DeriveType Base: BaseType
    Properties: 
      SValue (double) Required

在 DeriveType 实体的模型信息中,Base: BaseType 表示它的基类是 BaseType。说明 EF Core 已经自动配置好继承关系。如果你想手动配置实体的继承关系,可以在配置派生类实体时调用 HasBaseType 方法。

复制代码
modelBuilder.Entity<DeriveType>()
            .HasBaseType<BaseType>();

调用 HasBaseType 方法所指的基类必须是 .NET 代码中确实存在继承关系的类。比如,如果 DeriveType 不是 BaseType 的派生类,那么,调用 HasBaseType 方法会报错。EF Core 会验证 .NET 类型是否存在继承关系。

我们还会发现,EF Core 为 BaseType 实体添加了一个叫 Discriminator 的影子属性,它会被映射到数据表中的某一列。Discriminator 叫鉴别器,或叫判别器。作用是标识类型的------区分这条数据记录是哪个实体类型的。默认实现是在插入数据时存入实体名称。


接下来,咱们用另一个示例来研究一下如何自定义鉴别器。

先定义一个 Shape 基类。

复制代码
public abstract class Shape
{
    public int ShapeID { get; set; }
}

戴上老花镜看清楚,它,是一个抽象类。你没看错,EF Core 是允许向模型添加抽象类的,但随后你必须添加其实现类。毕竟银河系人都知道,抽象类无法生娃......不,是无法实例化。

然后,有几个类派生自 Shape 类。

复制代码
public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
}

public class Triangle : Shape
{
    public int A { get; set; }
    public int B { get; set; }
    public int C { get; set; }
}

public class Circle : Shape
{
    public float R { get; set; }
}

下面代码定义数据库上下文,实现模型配置。

复制代码
public class MyContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // 配置数据库连接
        SqlConnectionStringBuilder cb = new();
        cb.DataSource = "(localdb)\\mssqllocaldb";
        cb.InitialCatalog = "testdb";
        cb.IntegratedSecurity = true;
        optionsBuilder.UseSqlServer(cb.ConnectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Shape>(ent =>
        {
            // 配置表名
            ent.ToTable("tb_shapes");
            // 继承映射策略(可省略)
            ent.UseTphMappingStrategy();
            // 主键
            ent.HasKey(s => s.ShapeID).HasName("PK_Shapes");
            // 自定义鉴定器
            ent.HasDiscriminator<int>("TypeId")
               .HasValue<Shape>(1)
               .HasValue<Rectangle>(2)
               .HasValue<Triangle>(3)
               .HasValue<Circle>(4);
        });
    }
}

其他代码你已经很熟了,不用看了,咱们重点关心这里:

复制代码
ent.HasDiscriminator<int>("TypeId")
   .HasValue<Shape>(1)
   .HasValue<Rectangle>(2)
   .HasValue<Triangle>(3)
   .HasValue<Circle>(4);

HasDiscriminator 方法用来自定义类型鉴定器,在本示例中,它是 int 类型。名字叫 TypeId,EF Core 会自动帮我们往 Shape 实体添加影子属性(TypeId)。这么一搞,咱们就覆盖了默认的类型标识方法,HasValue 方法用于为各个派生类实体设置鉴别值。
在本示例中,Shape 类的对象我们用1表示,Rectangle 对象我们用 2 表示,Circle 对象用 4 表示。实际上 Shape 类型的标识可能不会出现,我们无法把抽象类的实例添加到数据集合中。
你也看到了,上述代码在配置完 Shape 类后,并没有去添加 Rectangle 等几个派生类。这是因为在自定义类型鉴别器时,HasValue 方法提到了几个派生类,于是,EF Core 就帮我们添加了。
当然,我们也可以像常规实体一样,配置自定义的属性与列之间的映射。只要注意 ToTable 方法只能在基类 Shape 中配置。简单地说就是:表名、主键都必须在基类中配置。

复制代码
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Shape>(ent =>
        {
            // 配置表名
            ent.ToTable("tb_shapes");
            // 继承映射策略(可省略)
            ent.UseTphMappingStrategy();
            // 属性映射
            ent.Property(s => s.ShapeID).HasColumnName("sid");
            // 主键
            ent.HasKey(s => s.ShapeID).HasName("PK_Shapes");
            // 自定义鉴定器
            ent.HasDiscriminator<int>("TypeId")
               .HasValue<Shape>(1)
               .HasValue<Rectangle>(2)
               .HasValue<Triangle>(3)
               .HasValue<Circle>(4);
            ent.Property("TypeId").HasColumnName("_type_id");
        });

        modelBuilder.Entity<Rectangle>(ent =>
        {
            ent.Property(s=>s.Width)
                .HasColumnName("rect_wid")
                .HasColumnType("decimal")
                .HasPrecision(8, 2);        // 精度
            ent.Property(m=>m.Height)
                .HasColumnName("rect_hei")
                .HasColumnType("decimal")
                .HasPrecision(8, 2);
        });

        modelBuilder.Entity<Circle>(ce =>
        {
            ce.Property(b=>b.R).HasColumnName("cir_r")
                .HasColumnType("decimal")
                .HasPrecision(10, 3);
        });

        modelBuilder.Entity<Triangle>(ent =>
        {
           ent.Property(a=>a.A).HasColumnName("a_len"); 
           ent.Property(a=>a.B).HasColumnName("b_len"); 
           ent.Property(a=>a.C).HasColumnName("c_len"); 
        });
    }

然后,创建的数据表如下:

复制代码
CREATE TABLE [tb_shapes] (
    [sid] int NOT NULL IDENTITY,
    [_type_id] int NOT NULL,
    [cir_r] decimal(10,3) NULL,
    [rect_wid] decimal(8,2) NULL,
    [rect_hei] decimal(8,2) NULL,
    [a_len] int NULL,
    [b_len] int NULL,
    [c_len] int NULL,
    CONSTRAINT [PK_Shapes] PRIMARY KEY ([sid])
);

咱们试着每种类型的实体都添加一条记录。

复制代码
using(var context = new MyContext())
{
    // 获取数据集合
    DbSet<Rectangle> rects = context.Set<Rectangle>();
    DbSet<Triangle> triangles = context.Set<Triangle>();
    DbSet<Circle> circles = context.Set<Circle>();
    // 添加数据
    rects.Add(new Rectangle
    {
        Width = 120.0d,
        Height = 90.0d
    });
    circles.Add(new Circle
    {
        R = 19.2f
    });
    triangles.Add(new Triangle
    {
        A = 16,
        B = 14,
        C = 22
    });
    // 提交到数据库
    context.SaveChanges();
}

执行之后,数据库中保存的数据如下图所示。

由于 EF Core 会将整个继承链作为一个组来管理,所以在获取数据集的时候,可以使用 Shape 类,就像这样。

复制代码
using(MyContext context = new())
{
    // 获取兼容性数据集合
    DbSet<Shape> shapes = context.Set<Shape>();
    // 添加数据
    shapes.AddRange(
        new Rectangle
        {
            Width = 120.0d,
            Height = 90.0d
        },
        new Circle
        {
            R = 19.2f
        },
        new Triangle
        {
            A = 16,
            B = 14,
            C = 22
        });
    // 提交数据库
    context.SaveChanges();
}

你可以向 shapes 集合添加 Rectangle、Circle 或 Triangle 类。这是利用了派生类实例可以给基类赋值的特点。


TPH 有两种特殊的列映射。先说第一种:来自不同派生类的属性映射到同一列。大前提是属性的数据类型要一样,这个好理解,如果两个类中的属性,一个是 string,另一个是 double,这映射到同一列,那数据库中这个列要用什么类型?显然是不行的(除非你写了值转器,并且保证数据库中存入这个列的内容可兼容)。

下面是个常规例子,一个抽象基类,两个同级派生类。

复制代码
public abstract class DevBoard
{
    public int BoardID { get; set; }
}

public class PicoBaord : DevBoard
{
    public int UartNum { get; set; }
}

public class BeeBoard : DevBoard
{
    public int I2sNum { get; set; }
}

这里咱们要做一个特殊处理,就是把 PicoBoard的 UartNum 和 BeeBoard 类的 I2sNum 属性都映射到 bus_num 列。两个属性都是 int 类型,可以共享其数据。

下面是模型配置。

复制代码
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<DevBoard>(bde =>
    {
        bde.ToTable("tb_boards");
        bde.HasKey(b => b.BoardID).HasName("PK_Boards");
        // 配置鉴别器
        bde.HasDiscriminator<string>("EntityType")
         .HasValue\<PicoBaord\>("PICO")
         .HasValue\<BeeBoard\>("BEE");
        // 配置属性
        bde.Property(a => a.BoardID).HasColumnName("bid");
        bde.Property("EntityType").HasColumnName("__type")
            .HasMaxLength(5);
    });
    modelBuilder.Entity<PicoBaord>(pb =>
    {
       pb.Property(s=>s.UartNum)
            .HasColumnName("bus_num"); 
    });
    modelBuilder.Entity<BeeBoard>(bet =>
    {
       bet.Property(a=>a.I2sNum)
            .HasColumnName("bus_num"); 
    });
}

在这个例子中,老周使用字符串"PICO"、"BEE"来区分两个类实体。

接下来,咱们为这两个类分别存入两条数据。

复制代码
using MyContext context = new();
context.Database.EnsureCreated();
// 每个类各添加两条记录
DbSet<DevBoard> boards = context.Set<DevBoard>();
boards.AddRange(
    new PicoBaord{ UartNum = 2 },
    new PicoBaord{ UartNum = 1 });
boards.AddRange(
    new BeeBoard{ I2sNum = 1 },
    new BeeBoard{ I2sNum = 3 }
);
// 提交数据库
context.SaveChanges();

最后,数据库中的内容如下图所示。

第二种情况就是:派生类之间有名称相同的属性,且映射的列名不同。这个不要求数据类型相同,因为它们没任何联系,仅仅名字相同罢了,属于不共享状态。

咱们把上面例子的实体类改一下,让每个派生类都有 UartNum 和 I2cNum 属性。

复制代码
public abstract class DevBoard
{
    public int BoardID { get; set; }
}

public class PicoBaord : DevBoard
{
    public int UartNum { get; set; }
    public int I2cNum{get;set;}
}

public class BeeBoard : DevBoard
{
    public int UartNum { get; set; }
    public int I2cNum{get;set;}
}

但配置时注意,它们将映射到不同的列。

复制代码
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 这部分不变
    ......

    modelBuilder.Entity<PicoBaord>(pb =>
    {
        pb.Property(s => s.UartNum)
            .HasColumnName("pico_uart_num");
        pb.Property(k => k.I2cNum)
            .HasColumnName("pico_i2c_num");
    });
    modelBuilder.Entity<BeeBoard>(bet =>
    {
        bet.Property(a => a.UartNum)
            .HasColumnName("bee_uart_num");
        bet.Property(f => f.I2cNum)
            .HasColumnName("bee_i2c_num");
    });
}

同样地,咱们也分别添加两条数据。

复制代码
DbSet<DevBoard> boards = context.Set<DevBoard>();
boards.AddRange(
    new PicoBaord{ UartNum = 2, I2cNum = 2 },
    new PicoBaord{ UartNum = 1, I2cNum = 3 });
boards.AddRange(
    new BeeBoard{ UartNum = 4, I2cNum = 2 },
    new BeeBoard{ UartNum = 3, I2cNum = 3 }
);
// 提交数据库
context.SaveChanges();

数据库中的数据如下图所示。

好了,今天的水文就水到这里了。