Delia's Blog

菜鳥工程師小鈞的筆記

0%

EF Core如何使用Code-First,將繼承實體映射至資料庫

前言

最近因為看了一些有關DDD領域驅動設計的文章,於是就很好奇,如何將充血模型映射至資料庫,其中物件的繼承關係複雜時,在資料庫會是甚麼樣的結構,通過EF做查詢SQL語法會如何做轉換,所以打算親自實驗看看。

範例說明

Github範例程式
https://github.com/DeliaHung/EFcore-Inheritance

本篇範例參考微軟官方文件做一點簡化調整
https://learn.microsoft.com/zh-tw/ef/core/modeling/inheritance

  • 新增Animal、Pet抽象類別。
  • Cat、Dog繼承Pet,Human繼承Animal。
  • Cat、Dog、Human有相同屬性 Species(物種)。
  • Cat、Dog、Human有各自不同屬性。
  • Cat、Dog都有自己的主人,Human也有多個寵物,屬於多對多關係。

建立領域物件

//動物類別(抽象)
public abstract class Animal
{
protected Animal(string name)
{
Name = name;
}

public int Id { get; set; }
public string Name { get; set; }
public abstract string Species { get; } //物種
}

//寵物類別(抽象)
public abstract class Pet : Animal
{
protected Pet(string name)
: base(name)
{
}
public string? Vet { get; set; }

public ICollection<Human> Humans { get; } = new List<Human>();
}
//貓貓
public class Cat : Pet
{
public Cat(string name, int educationLevel)
: base(name)
{
EducationLevel = educationLevel;
}

public int EducationLevel { get; set; }
public override string Species => "Felis catus";

public override string ToString()
=> $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}'";
}

//狗勾
public class Dog : Pet
{
public Dog(string name, string favoriteToy)
: base(name)
{
FavoriteToy = favoriteToy;
}

public string FavoriteToy { get; set; }
public override string Species => "Canis familiaris";

public override string ToString()
=> $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}'";
}

//人類
public class Human : Animal
{
public Human(string name)
: base(name)
{
}

public override string Species => "Homo sapiens";

public Animal? FavoriteAnimal { get; set; }
public ICollection<Pet> Pets { get; } = new List<Pet>();

public override string ToString()
=> $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'";
}

建立DbContext

public class TestContext : DbContext
{
public DbSet<Animal> Animals { get; set; }
public DbSet<Pet> Pets { get; set; }
public DbSet<Cat> Cats { get; set; }
public DbSet<Dog> Dogs { get; set; }
public DbSet<Human> Humans { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
//可準備不同資料庫
optionsBuilder.UseSqlServer("Data Source=DESKTOP-9F0J9EK;Initial Catalog=EFInheritanceTPT;Integrated Security=True;TrustServerCertificate=True");
}
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());

base.OnModelCreating(builder);
}
}

為了方便測試及觀察產生的SQL語法,這邊使用LinqPad做實驗。

LinqPad Insert測試

LinqPad Search測試

Code-First 繼承映射的三種方式

1.TPH(Table per Class Hierarchy)

EF Core預設

一張表存放父類和子類的所有屬性,並自動生成識別器(discriminator)欄位,用來區分基類和各個子類的。優點是查詢速度快, 缺點是數據庫結構有點亂, 因為它把全部東西都塞進一個表, 會有很多nullable。

資料表結構

預設產生出來的Discriminator,預設為string,存入時就是子類的名稱。

設定

使用Fluent API添加配置 在父類別加上UseTphMappingStrategy()。

識別器可以自己指定Type及Value,也可將識別器寫在父類屬性中,依照你的程式設計需求。

如果資料表有識別器無法識別的Type,會造成查詢時發生錯誤,以下例子是我自己去資料庫把Human的Type改成Type2。我們可以在設定加上IsComplete(false),詳細設定請參考下面程式碼。

public class AnimalConfiguration : IEntityTypeConfiguration<Animal>
{
public void Configure(EntityTypeBuilder<Animal> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).UseIdentityColumn(1, 1);

//TPH(預設,所以可以不寫)
builder.UseTphMappingStrategy()
.HasDiscriminator().IsComplete(false);//如果資料表有不存在Type,使用IsComplete(false)可防止查詢發生錯誤;

//識別器指定Type及Value。
builder.HasDiscriminator<int>("AnimalType")
.HasValue<Cat>(1)
.HasValue<Dog>(2)
.HasValue<Human>(3);

//將識別器寫在類別中
builder.HasDiscriminator(d => d.AnimalType)
.HasValue<Cat>(1)
.HasValue<Dog>(2)
.HasValue<Human>(3);
}
}

TPH寫入結果

TPH查詢測試

2.TPT(Table per Type)

在TPT模式中,不管基底類別還是子類別通通都會建立一張表,這種模式下,每個表格都有一個主鍵,並且關聯的資料存放在各自對應的表格中,優點是數據庫結構好看, 不像 TPH 全部塞一個表, 又一堆 nullable. 缺點就是 query 慢. 因為每次 query 都需要 join。

資料表結構

設定

使用Fluent API添加配置,在父類別上加上UseTptMappingStrategy(),或是子類別全部指定Table名稱。

public class AnimalConfiguration : IEntityTypeConfiguration<Animal>
{
public void Configure(EntityTypeBuilder<Animal> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).UseIdentityColumn(1, 1);

builder.UseTptMappingStrategy();
}
}

public class PetConfiguration : IEntityTypeConfiguration<Pet>
{
public void Configure(EntityTypeBuilder<Pet> builder)
{
builder.ToTable(nameof(Pet));
}
}

public class CatConfiguration : IEntityTypeConfiguration<Cat>
{
public void Configure(EntityTypeBuilder<Cat> builder)
{
builder.ToTable(nameof(Cat));
}
}

public class DogConfiguration : IEntityTypeConfiguration<Dog>
{
public void Configure(EntityTypeBuilder<Dog> builder)
{
builder.ToTable(nameof(Dog));
}
}

public class HumanConfiguration : IEntityTypeConfiguration<Human>
{
public void Configure(EntityTypeBuilder<Human> builder)
{
builder.ToTable(nameof(Human));
}
}

TPT寫入結果

TPT查詢測試

3.TPC(Table per Concrete Class)

TPC是EF7.0才推出的,TPC 類似于 TPT 策略,不同資料表是針對階層中的每個類別所建立,但 不會針對抽象類別建立資料表 ,基底類別中的屬性,會存在所有子類資料表裡面。 例如,每個資料表都有 Id、Name 。

資料表結構

設定

使用Fluent API添加配置,在父類別上加上UseTpcMappingStrategy()

注意父類別Id不可使用SQL自增流水號,會造成Id重複導致Insert錯誤。

public class AnimalConfiguration : IEntityTypeConfiguration<Animal>
{
public void Configure(EntityTypeBuilder<Animal> builder)
{
builder.HasKey(t => t.Id);

//TPC
builder.UseTpcMappingStrategy();
}
}

SEQUENCE

EF Core 要求所有實體都有唯一Key值,即使實體的類型不同也一樣,例如,Dog 不能有與 Cat 相同Key值,這表示無法使用簡單的 Identity 資料行。

SQLite 不支援序列或身分識別種子/遞增,因此搭配 TPC 策略使用 SQLite 時,不支援產生整數索引鍵值。 不過,任何資料庫都支援用戶端產生或全域唯一索引鍵,例如 GUID,包括 SQLite。

如果不想用GUID,也可使用Hi-Lo模式。
https://vladmihalcea.com/the-hilo-algorithm/
https://learn.microsoft.com/zh-tw/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-implementation-entity-framework-core

TPC寫入結果

TPC查詢測試

結論

複習一下三種結構差異

Table per Type (TPT):
TPT模式適合在需要對繼承層次結構進行頻繁更改和擴展的情況下。每個類別都有自己獨立的資料表,讓您可以獨立管理和操作每個類別的資料。這種模式較為正規化,因為每個資料表只包含特定類別的屬性。然而,這種模式可能導致較多的資料表格, 需要進行較多的 JOIN 操作 ,並且資料庫結構相對複雜,管理和維護成本較高。

Table per Hierarchy (TPH):
TPH模式適合在繼承層次結構相對穩定且不太會經常更改的情況下。整個繼承層次結構映射到單一資料表格,使用類型標誌(type discriminator)區分不同的類別。這種模式有 較好的查詢效能和正規化特性 ,避免了較多的 JOIN 操作。然而,較大的資料表格可能包含一些不使用或為空的欄位,而且在繼承層次結構變動時,需要調整類型標誌。

以下為官方結論:

總而言之,TPH 通常適用于大部分的應用程式,如果不需要 TPC,請勿新增複雜度。如果程式碼大部分會查詢許多類型的實體,例如針對基底類型查詢,TPH會是比較好的選擇。
換句話說,當您的程式碼大部分會查詢單一類型的實體,TPC 是一個良好的選擇。
只有在受限於外部因素的情況下,才使用 TPT。

效能比較

https://davecallan.com/entity-framework-7-inheritance-mapping-performance-benchmarks/

TPH優先選擇,如果欄位真的太多太臃腫,而且太多nullable不太直覺,可以考慮換成TPC ,尤其在業務需求通常都是指定查詢某個子類別,那麼可以優先考慮TPC.

參考文章

https://learn.microsoft.com/zh-tw/ef/core/modeling/inheritance
https://learn.microsoft.com/zh-tw/ef/core/performance/modeling-for-performance#inheritance-mapping
https://www.entityframeworktutorial.net/code-first/inheritance-strategy-in-code-first.aspx
https://www.entityframeworktutorial.net/code-first/inheritance-strategy-in-code-first.aspx
https://www.cnblogs.com/dotnet261010/p/8018266.html