回文章列表
技術文章 · .NET / EF Core

EF Core 的多型標籤要怎麼做

一顆標籤同時貼在筆記、商品、影片上——這些型別彼此沒有共同基底。EF Core 沒有內建解法,而且這不是它偷懶:關聯式資料庫本來就無法讓一個欄位同時指向多張表。以下是四種真正可行的架構,以及各自的代價。

先講結論

大多數情況選「每個型別一張連結表」,用泛型 join entity 消掉重複設定。它是唯一同時保住型別安全與資料庫外鍵的做法;反向查詢慢是可以用工程手段解決的,外鍵沒了是解不回來的。

EF Core 之所以沒有內建,是因為關聯式資料庫無法對「泛型外鍵」做約束——所以下面四種做法,本質上都是在回答同一個問題:你願意拿什麼換外鍵完整性?

先說明利益關係:§ 07 提到的 Cornhsu.Labeling 是我做的——MIT 授權、免費、我沒有從中獲得任何收入,那一節已標示並寫出它的限制。前面 § 01–§ 06 講的是這個問題本身,不需要用到任何套件也成立。

這篇會回答

  1. 為什麼搜「EF Core tag」找到的都不是你要的
  2. 這個需求的正式名稱,以及為什麼 EF Core 不內建
  3. 四種可行架構的完整取捨
  4. 怎麼選、以及四個會後悔的坑
  5. 有沒有現成套件可以裝
§ 01

先排除一個誤會:TagWith() 不是這個

EF Core 確實有一個叫 TagWith() 的 API,搜尋「EF Core tag」幾乎一定會先撞到它。它做的事情是把一段註解塞進產生的 SQL:

這不是你要的東西
db.Orders
  .TagWith("每日對帳報表")   // 只是把註解寫進 SQL,方便在 DB 側追查慢查詢
  .Where(o => o.IsPaid)
  .ToListAsync();

它跟資料模型完全無關,純粹是可觀測性工具。同理,NuGet 上叫 EF6.TagWith 之類的套件也是同一回事。

你要找的東西,關鍵字是下面這一節。

§ 02

這件事叫「多型關聯」

你想做的是:一顆標籤,可以貼在多個彼此不相干的型別上。同一個「急件」標籤,同時掛在 NoteMemoCalendarEvent 上,而這三個類別沒有共同基底、也不該為了標籤而硬湊一個。

這個模式在各個生態系有不同名字,搜資料時這幾個詞都有用:

  • polymorphic association —— 最通用的講法,Rails 社群帶起來的
  • polymorphic tagging —— 特指標籤這個應用場景
  • acts_as_taggable —— Rails 那個著名套件的名字,很多人拿它當代名詞
  • 多型關聯 / 多型標籤 —— 中文圈的講法

Rails 有 acts_as_taggable、Laravel 有 morphToMany、Django 有 contenttypesEF Core 沒有對應的東西——而這是有原因的。

§ 03

為什麼 EF Core 不給你這個功能

因為關聯式資料庫做不到。

最直覺的做法是開一張連結表,存「型別名稱 + 主鍵」:

Taggings —— 看起來很合理,但有個致命問題
TagId       EntityType      EntityId
------      -----------     --------
1           "Note"          42
1           "Memo"          7
2           "Note"          42

問題出在 EntityId 這一欄。你沒辦法對它建立外鍵——因為 42 這個值,要看隔壁 EntityType 欄位的內容,才知道它指的是 Notes.Id 還是 Memos.Id

SQL 的外鍵約束是靜態的:一個外鍵只能指向一張確定的表。它不能「依照另一欄的值決定要指向哪裡」。這不是哪個資料庫的限制,是關聯模型本身的定義。

所以 EF Core 的立場是:與其提供一個保證會產生孤兒資料的功能,不如不提供。它給你的是繼承映射(TPH / TPT / TPC)——也就是下面的方案 B,那條路上外鍵是成立的。

沒有內建,不是因為沒人想要,是因為做出來就得犧牲外鍵。

理解這一點很重要,因為接下來四種做法,本質上都是在回答同一個問題:你願意用什麼來換外鍵完整性?

§ 04

四種做法

A ─ 每個型別一張連結表

推薦度最高

Tag 只有一張,但 NoteTagsMemoTagsEventTags 各一張,每張都有兩個真正的外鍵。聽起來很笨,但它是唯一同時保住型別安全與參照完整性的做法。

不用手寫 N 份設定——用泛型 join entity 一次搞定:

方案 A —— 泛型 join entity
public class EntityTag<TEntity> where TEntity : class
{
    public int TagId { get; set; }
    public Tag Tag { get; set; } = null!;
    public int EntityId { get; set; }
    public TEntity Entity { get; set; } = null!;
}

protected override void OnModelCreating(ModelBuilder b)
{
    ConfigureTagJoin<Note>(b, "NoteTags");
    ConfigureTagJoin<Memo>(b, "MemoTags");
}

static void ConfigureTagJoin<T>(ModelBuilder b, string table) where T : class
{
    b.Entity<EntityTag<T>>(e =>
    {
        e.ToTable(table);
        e.HasKey(x => new { x.TagId, x.EntityId });
        e.HasOne(x => x.Tag).WithMany().HasForeignKey(x => x.TagId);
        e.HasOne(x => x.Entity).WithMany().HasForeignKey(x => x.EntityId);
    });
}

上面的範例把主鍵寫死成 int 是為了好讀。如果你的實體主鍵型別不一致(有 int 也有 Guid),把鍵也一起泛型化即可——EntityTag<TEntity, TKey>,註冊時寫 Join<Note, int>()Join<Memo, Guid>()。實測 EF Core 會為兩者各生一張連結表、兩端外鍵都在。§ 05 表格裡「主鍵型別不一致」那一列指的就是這個寫法。

外鍵完整性
完整。刪除實體時 cascade delete 會自己清掉標籤連結,不可能留下孤兒。
型別安全
完整。EntityTag<Note>.Entity 就是 Note,編譯期就擋得住。
「這個實體有哪些標籤」
快。單表查詢,一個索引就夠。
「這個標籤貼在哪些東西上」
這是它的痛點——要跨 N 張表查,然後在應用層合併。
新增一個可標籤型別
多一張表、多一次 migration。表數量會隨型別線性成長。

最後那個痛點是方案 A 唯一真正的弱點,但它是可以工程解決的——每個型別各發一次查詢再合併,實務上比想像中便宜(見 § 07 的實測數字),而且大部分應用的主要查詢方向其實是「這個實體有哪些標籤」,不是反過來。


B ─ 共同基底 + 繼承映射

新專案可考慮

讓所有可貼標籤的實體繼承一個抽象基底,標籤只認那個基底。EF Core 用 TPH(預設,全部塞一張表加 discriminator)、TPT 或 TPC 來映射這個繼承樹。

方案 B —— 共同基底
public abstract class TaggableEntity
{
    public long Id { get; set; }
    public ICollection<TagLink> TagLinks { get; set; } = [];
}

public class Note : TaggableEntity { public string Title { get; set; } = ""; }
public class Memo : TaggableEntity { public string Body  { get; set; } = ""; }

// TagLink 的外鍵指向 TaggableEntity —— 一個確定的表,外鍵成立
public class TagLink
{
    public long TaggableEntityId { get; set; }
    public TaggableEntity Entity { get; set; } = null!;
    public int TagId { get; set; }
    public Tag Tag { get; set; } = null!;
}
外鍵完整性
完整,而且只有一個外鍵要管。
「這個標籤貼在哪些東西上」
一次查詢就好,這是方案 B 相對 A 的最大優勢。
侵入性
很高。你的領域模型被迫長出一棵繼承樹,而且是為了「標籤」這個附屬功能而長的。
既有專案
幾乎不可行。要改所有實體的基底類別,還要搬移主鍵。
主鍵
所有可標籤實體共用同一個 ID 空間與型別。原本各自用 int / Guid 的話要統一。

如果選 TPH,還要注意所有子型別的欄位會擠在同一張表裡,非共用欄位全部得可為 null——實體種類一多,那張表會很難看。


C ─ 泛型外鍵(EntityTypeEntityId

最快,代價最高

就是 § 03 那張表。Rails / Laravel 的 polymorphic association 走的就是這條路,寫起來最快,也最容易後悔。

方案 C —— 沒有外鍵的自由
public class Tagging
{
    public int TagId { get; set; }
    public Tag Tag { get; set; } = null!;

    public string EntityType { get; set; } = "";   // "Note" / "Memo"
    public long   EntityId   { get; set; }        // 無法建立外鍵
}

// 至少加上唯一索引,擋掉重複貼同一顆標籤
b.Entity<Tagging>()
 .HasIndex(x => new { x.TagId, x.EntityType, x.EntityId })
 .IsUnique();
外鍵完整性
沒有。刪掉一筆 Note,它的標籤連結會留在表裡變成孤兒,cascade delete 幫不上忙。
孤兒清理
得自己來——覆寫 SaveChanges、寫 trigger,或跑排程清理。三種都是你要維護的東西。
型別安全
沒有。EntityType 是字串,打錯不會編譯失敗,重構改類別名稱時資料庫裡的舊值也不會跟著改。
導覽屬性
不能 Include(x => x.Entity)。EF Core 無法把這種欄位映射成關聯,要自己查兩次再組。
主鍵型別
所有實體必須用同一種主鍵型別,否則 EntityId 這欄放不下。
優點
零侵入。既有實體一行都不用改,新增可標籤型別也不用 migration。

什麼時候值得選它?標籤是純附屬資料、髒資料可以接受、而且你確定不會拿標籤當查詢主軸的時候。快速原型很適合。要進正式系統的話,先想清楚孤兒資料誰來清。


D ─ 中介註冊表

折衷方案

建一張 Taggables 表持有全域 ID,每個可標籤實體用 1:1 指向它,標籤只認這張表。等於用一層間接,同時買到方案 A 的外鍵和方案 B 的單次查詢。

代價是每次新增實體都要一併建立一筆 Taggable(可以在 SaveChanges 覆寫裡自動處理),而且每次查詢多一次 join。實體種類多、又真的很需要跨型別列舉時,這個代價划算;否則屬於過度設計。

§ 05

怎麼選

先問自己一個問題:你的主要查詢方向是哪一邊?

你的情況選這個
既有專案,實體已經定型,不想動領域模型A(或不在乎完整性就 C)
全新專案,實體種類固定且確實都是「可標籤物件」B
主要問「這個東西有哪些標籤」A
主要問「這個標籤下有哪些東西」,且型別很多BD
實體主鍵型別不一致(有 int 有 Guid)A,且 join entity 的鍵要一起泛型化(見 § 04)——其餘三個都要求統一
快速原型,標籤不是核心功能C
資料完整性是硬需求(金流、稽核)ABD——不要 C

如果你兩邊都要、又不想動既有模型,那答案是 A + 針對反向查詢做工程優化,而不是換架構。反向查詢慢是可以解的,外鍵沒了是解不回來的。

§ 06

四個會後悔的坑

1

nameof(T) 或型別全名當 EntityType

看起來很聰明,但重構改個類別名稱、或搬個 namespace,資料庫裡幾萬筆舊值就全部對不上了,而且編譯器不會提醒你。要走方案 C 的話,用一個獨立的、永不改動的字串常數或 enum 值,不要跟程式碼的識別名綁在一起。

2

清單畫面的 N+1

標籤最常出現的地方就是清單——五十筆資料各自去查一次自己的標籤,就是五十次來回。這件事跟你選哪個方案無關,一定要有批次 API:一次把整頁的標籤查回來再分配。這通常比方案之間的架構差異更影響實際體感。

3

標籤字串直接當主鍵

「反正標籤就是字串」——直到使用者要改標籤名稱、要合併兩顆標籤、要統計使用次數。標籤該有自己的整數主鍵,名稱只是它的一個欄位。

4

先優化再量測

方案 A 的「跨 N 張表查詢」聽起來很可怕,很多人因此直接跳去方案 C。實際跑 benchmark 之前,你不知道那到底是 19ms 還是 1.9 秒——而這個數字決定了你該不該為它放棄外鍵。

§ 07

有沒有現成套件可以裝

老實說:沒有廣泛採用的主流套件。NuGet 上沒有一個像 Rails 的 acts_as_taggable 那樣、裝了就有的標準答案。絕大多數 .NET 專案是自己實作上面四種之一。

我自己是走方案 A,而且因為在兩個桌面應用裡重寫了兩次,就把它抽成套件了——這是我做的,以下請自行打折

Cornhsu.Labeling —— 方案 A 的封裝
services.AddLabeling<AppDbContext>(r =>
{
    r.Labelable<Note>(n => n.Title);   // int 主鍵
    r.Labelable<Memo>(m => m.Title);   // Guid 主鍵,同一個 App 可以混用
});

await store.AttachAsync(note, "論文", "急件");
var hits = await store.FindByLabelsAsync(new[] { "論文", "急件" }, LabelMatch.All);

核心是 LabelLink<TEntity,TKey> 封閉泛型——每個註冊的型別由 EF Core 自動生成一張專屬連結表,兩端都是真外鍵,所以孤兒資料在資料庫層就不可能出現,而不是靠應用程式紀律。主鍵型別由 ILabelable<TKey> 推斷,所以 § 05 表格裡「主鍵型別不一致」那一列它處理得了。

§ 04 提到方案 A 的痛點是反向查詢,這裡的處理方式是照坑 4 講的:先量再說。實測「每型別各查一次再合併」在 5 型別 × 1 萬筆是 約 19ms,夠用,所以沒有為它換架構;力氣花在真正的瓶頸——清單畫面的批次 API,實測快 6 倍、省掉 49 次來回。

你該知道的限制:單人維護、下載數還很小,你會是早期使用者。它解的是方案 A 的樣板程式碼,不是什麼魔法——如果你的專案只有兩三個可標籤型別,自己照 § 04 的程式碼寫一次也完全合理,那大概是一百行的事。

一句話總結

EF Core 沒有多型標籤,是因為關聯式資料庫無法對泛型外鍵做約束。四種做法都是在回答「你願意拿什麼換外鍵」——大多數情況下答案是方案 A:每個型別一張連結表,用泛型 join entity 消掉樣板,反向查詢慢就用工程手段解決,不要拿外鍵去換。

寫於 2026-08-18。文中的效能數字來自我自己的 benchmark,環境不同結果會不同,請以你自己的量測為準。