EF Core 的多型標籤要怎麼做
一顆標籤同時貼在筆記、商品、影片上——這些型別彼此沒有共同基底。EF Core 沒有內建解法,而且這不是它偷懶:關聯式資料庫本來就無法讓一個欄位同時指向多張表。以下是四種真正可行的架構,以及各自的代價。
大多數情況選「每個型別一張連結表」,用泛型 join entity 消掉重複設定。它是唯一同時保住型別安全與資料庫外鍵的做法;反向查詢慢是可以用工程手段解決的,外鍵沒了是解不回來的。
EF Core 之所以沒有內建,是因為關聯式資料庫無法對「泛型外鍵」做約束——所以下面四種做法,本質上都是在回答同一個問題:你願意拿什麼換外鍵完整性?
先說明利益關係:§ 07 提到的 Cornhsu.Labeling 是我做的——MIT 授權、免費、我沒有從中獲得任何收入,那一節已標示並寫出它的限制。前面 § 01–§ 06 講的是這個問題本身,不需要用到任何套件也成立。
這篇會回答
- 為什麼搜「EF Core tag」找到的都不是你要的
- 這個需求的正式名稱,以及為什麼 EF Core 不內建
- 四種可行架構的完整取捨
- 怎麼選、以及四個會後悔的坑
- 有沒有現成套件可以裝
先排除一個誤會:TagWith() 不是這個
EF Core 確實有一個叫 TagWith() 的 API,搜尋「EF Core tag」幾乎一定會先撞到它。它做的事情是把一段註解塞進產生的 SQL:
db.Orders .TagWith("每日對帳報表") // 只是把註解寫進 SQL,方便在 DB 側追查慢查詢 .Where(o => o.IsPaid) .ToListAsync();
它跟資料模型完全無關,純粹是可觀測性工具。同理,NuGet 上叫 EF6.TagWith 之類的套件也是同一回事。
你要找的東西,關鍵字是下面這一節。
這件事叫「多型關聯」
你想做的是:一顆標籤,可以貼在多個彼此不相干的型別上。同一個「急件」標籤,同時掛在 Note、Memo、CalendarEvent 上,而這三個類別沒有共同基底、也不該為了標籤而硬湊一個。
這個模式在各個生態系有不同名字,搜資料時這幾個詞都有用:
- polymorphic association —— 最通用的講法,Rails 社群帶起來的
- polymorphic tagging —— 特指標籤這個應用場景
- acts_as_taggable —— Rails 那個著名套件的名字,很多人拿它當代名詞
- 多型關聯 / 多型標籤 —— 中文圈的講法
Rails 有 acts_as_taggable、Laravel 有 morphToMany、Django 有 contenttypes。EF Core 沒有對應的東西——而這是有原因的。
為什麼 EF Core 不給你這個功能
因為關聯式資料庫做不到。
最直覺的做法是開一張連結表,存「型別名稱 + 主鍵」:
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,那條路上外鍵是成立的。
沒有內建,不是因為沒人想要,是因為做出來就得犧牲外鍵。理解這一點很重要,因為接下來四種做法,本質上都是在回答同一個問題:你願意用什麼來換外鍵完整性?
四種做法
A ─ 每個型別一張連結表
推薦度最高Tag 只有一張,但 NoteTags、MemoTags、EventTags 各一張,每張都有兩個真正的外鍵。聽起來很笨,但它是唯一同時保住型別安全與參照完整性的做法。
不用手寫 N 份設定——用泛型 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 來映射這個繼承樹。
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 ─ 泛型外鍵(EntityType + EntityId)
最快,代價最高
就是 § 03 那張表。Rails / Laravel 的 polymorphic association 走的就是這條路,寫起來最快,也最容易後悔。
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。實體種類多、又真的很需要跨型別列舉時,這個代價划算;否則屬於過度設計。
怎麼選
先問自己一個問題:你的主要查詢方向是哪一邊?
| 你的情況 | 選這個 |
|---|---|
| 既有專案,實體已經定型,不想動領域模型 | A(或不在乎完整性就 C) |
| 全新專案,實體種類固定且確實都是「可標籤物件」 | B |
| 主要問「這個東西有哪些標籤」 | A |
| 主要問「這個標籤下有哪些東西」,且型別很多 | B 或 D |
| 實體主鍵型別不一致(有 int 有 Guid) | A,且 join entity 的鍵要一起泛型化(見 § 04)——其餘三個都要求統一 |
| 快速原型,標籤不是核心功能 | C |
| 資料完整性是硬需求(金流、稽核) | A、B 或 D——不要 C |
如果你兩邊都要、又不想動既有模型,那答案是 A + 針對反向查詢做工程優化,而不是換架構。反向查詢慢是可以解的,外鍵沒了是解不回來的。
四個會後悔的坑
用 nameof(T) 或型別全名當 EntityType
看起來很聰明,但重構改個類別名稱、或搬個 namespace,資料庫裡幾萬筆舊值就全部對不上了,而且編譯器不會提醒你。要走方案 C 的話,用一個獨立的、永不改動的字串常數或 enum 值,不要跟程式碼的識別名綁在一起。
清單畫面的 N+1
標籤最常出現的地方就是清單——五十筆資料各自去查一次自己的標籤,就是五十次來回。這件事跟你選哪個方案無關,一定要有批次 API:一次把整頁的標籤查回來再分配。這通常比方案之間的架構差異更影響實際體感。
標籤字串直接當主鍵
「反正標籤就是字串」——直到使用者要改標籤名稱、要合併兩顆標籤、要統計使用次數。標籤該有自己的整數主鍵,名稱只是它的一個欄位。
先優化再量測
方案 A 的「跨 N 張表查詢」聽起來很可怕,很多人因此直接跳去方案 C。實際跑 benchmark 之前,你不知道那到底是 19ms 還是 1.9 秒——而這個數字決定了你該不該為它放棄外鍵。
有沒有現成套件可以裝
老實說:沒有廣泛採用的主流套件。NuGet 上沒有一個像 Rails 的 acts_as_taggable 那樣、裝了就有的標準答案。絕大多數 .NET 專案是自己實作上面四種之一。
我自己是走方案 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 的程式碼寫一次也完全合理,那大概是一百行的事。
MIT 授權開源。抽象層與 EF Core 實作分成兩個套件,支援 EF Core 8 / 9 / 10 與 SQLite · SQL Server · PostgreSQL 三資料庫 CI 矩陣,附 Roslyn Analyzer 在編譯期擋掉沒註冊就使用的型別。無 DI 容器的 WPF / WinForms 應用可以用 LabelStoreFactory 掛載。
一句話總結
EF Core 沒有多型標籤,是因為關聯式資料庫無法對泛型外鍵做約束。四種做法都是在回答「你願意拿什麼換外鍵」——大多數情況下答案是方案 A:每個型別一張連結表,用泛型 join entity 消掉樣板,反向查詢慢就用工程手段解決,不要拿外鍵去換。
寫於 2026-08-18。文中的效能數字來自我自己的 benchmark,環境不同結果會不同,請以你自己的量測為準。