EF Core 進階操作
上一章學了 EF Core 基礎,現在要深入了解Fluent API、載入策略、進階設定。
Fluent API vs Data Annotations
兩種設定 Entity 的方式,Fluent API 更強大、更靈活。
// === Data Annotations 方式(用 Attribute)===
public class Student // 學生 Entity
{
public int Id { get; set; } // 主鍵(慣例自動識別)
[Required] // 必填
[MaxLength(100)] // 最大長度 100
public string Name { get; set; } = ""; // 學生姓名
[EmailAddress] // Email 格式驗證
public string Email { get; set; } = ""; // 學生信箱
}
// === Fluent API 方式(在 DbContext 中設定)===
protected override void OnModelCreating(ModelBuilder modelBuilder) // 覆寫模型建構方法
{
modelBuilder.Entity<Student>(entity => // 設定 Student Entity
{
entity.HasKey(s => s.Id); // 設定主鍵
entity.Property(s => s.Name) // 設定 Name 屬性
.IsRequired() // 必填
.HasMaxLength(100); // 最大長度 100
entity.HasIndex(s => s.Email) // 在 Email 上建立索引
.IsUnique(); // 唯一索引
entity.Property(s => s.Email) // 設定 Email 屬性
.HasMaxLength(200); // 最大長度 200
});
}
選哪個? 簡單驗證用 Data Annotations,複雜關聯設定用 Fluent API。團隊中建議統一風格。
Navigation Properties(導航屬性)
導航屬性讓你用 C# 物件的方式存取關聯資料。
// 一對多關係:一個學生有多筆選課記錄
public class Student // 學生 Entity
{
public int Id { get; set; } // 主鍵
public string Name { get; set; } = ""; // 姓名
// 導航屬性:一個學生 → 多筆選課
public ICollection<Enrollment> Enrollments { get; set; } // 選課集合
= new List<Enrollment>(); // 初始化空集合
}
public class Enrollment // 選課 Entity
{
public int Id { get; set; } // 主鍵
public int StudentId { get; set; } // 外鍵:學生 ID
public int CourseId { get; set; } // 外鍵:課程 ID
public int Score { get; set; } // 分數
// 導航屬性:多對一
public Student Student { get; set; } = null!; // 所屬學生
public Course Course { get; set; } = null!; // 所屬課程
}
載入策略:Eager / Lazy / Explicit
想像你去圖書館借書:
- Eager Loading:一次把主書和所有參考書都搬回來
- Lazy Loading:先拿主書,需要參考書時再跑一趟圖書館
- Explicit Loading:先拿主書,你明確說要參考書時才去拿
Eager Loading(預先載入)— 推薦 ✅
// 一次查詢就把關聯資料載入
var students = await db.Students // 查詢學生
.Include(s => s.Enrollments) // 同時載入選課記錄
.ThenInclude(e => e.Course) // 再載入每筆選課的課程資料
.Where(s => s.Score > 80) // 篩選分數大於 80
.ToListAsync(); // 執行查詢並轉為 List
// 只產生 1 個 SQL 查詢(含 JOIN)
Lazy Loading(延遲載入)— 小心使用 ⚠️
// 需要安裝套件並設定
// dotnet add package Microsoft.EntityFrameworkCore.Proxies
// 在 DbContext 中啟用
optionsBuilder.UseLazyLoadingProxies(); // 啟用延遲載入代理
// 導航屬性必須加 virtual
public class Student // 學生 Entity
{
public int Id { get; set; } // 主鍵
public virtual ICollection<Enrollment> Enrollments { get; set; } // 加 virtual!
= new List<Enrollment>(); // 初始化
}
// 存取時自動查詢(但可能產生 N+1 問題!)
foreach (var s in students) // 迴圈每個學生
{
Console.WriteLine(s.Enrollments.Count); // 每次存取都會發一個 SQL 查詢!
}
Explicit Loading(明確載入)
var student = await db.Students // 先查詢學生
.FirstAsync(s => s.Id == 1); // 取得 ID=1 的學生
await db.Entry(student) // 取得該 Entity 的追蹤資訊
.Collection(s => s.Enrollments) // 指定要載入的集合
.LoadAsync(); // 明確載入選課記錄
// 分兩次查詢,但你可以控制何時載入
Shadow Properties(影子屬性)
影子屬性是不出現在 Entity 類別中,但存在於資料庫的欄位。
// 在 Fluent API 中定義影子屬性
modelBuilder.Entity<Student>() // 設定 Student Entity
.Property<DateTime>("CreatedAt") // 定義影子屬性 CreatedAt
.HasDefaultValueSql("GETDATE()"); // 預設值為目前時間
modelBuilder.Entity<Student>() // 設定 Student Entity
.Property<DateTime>("UpdatedAt"); // 定義影子屬性 UpdatedAt
// 在 SaveChanges 中自動更新
public override int SaveChanges() // 覆寫 SaveChanges
{
foreach (var entry in ChangeTracker.Entries()) // 遍歷所有追蹤的 Entity
{
if (entry.State == EntityState.Modified) // 如果是修改狀態
{
entry.Property("UpdatedAt") // 設定 UpdatedAt 影子屬性
.CurrentValue = DateTime.Now; // 更新為目前時間
}
}
return base.SaveChanges(); // 呼叫原始的 SaveChanges
}
Value Conversions(值轉換)
把 C# 型別自動轉換成資料庫型別。
// 把 Enum 存成字串
public enum StudentStatus // 學生狀態列舉
{
Active, // 在學
Graduated, // 畢業
Suspended // 休學
}
modelBuilder.Entity<Student>() // 設定 Student Entity
.Property(s => s.Status) // Status 屬性
.HasConversion<string>(); // 存到資料庫時轉成字串
// 資料庫存 "Active",C# 讀取時自動轉回 Enum
Global Query Filters(全域查詢篩選)
自動在每次查詢時加上篩選條件,適合做軟刪除。
// 在 OnModelCreating 中設定
modelBuilder.Entity<Student>() // 設定 Student Entity
.HasQueryFilter(s => !s.IsDeleted); // 自動過濾已刪除的資料
// 之後所有查詢都會自動加上 WHERE IsDeleted = 0
var students = await db.Students.ToListAsync(); // 只會回傳未刪除的學生
// 需要查看已刪除資料時,用 IgnoreQueryFilters
var allStudents = await db.Students // 查詢學生
.IgnoreQueryFilters() // 忽略全域篩選
.ToListAsync(); // 包含已刪除的學生
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:N+1 問題
// ❌ 沒有用 Include,每次存取導航屬性都會查一次資料庫
var students = await db.Students.ToListAsync(); // 查詢 1:取得所有學生
foreach (var s in students) // 迴圈 N 個學生
{
var count = s.Enrollments.Count; // 查詢 2~N+1:每個學生各查一次!
}
// 如果有 100 個學生 → 101 次 SQL 查詢 😱
// ✅ 用 Include 一次載入
var students = await db.Students // 查詢學生
.Include(s => s.Enrollments) // 同時載入選課記錄
.ToListAsync(); // 只有 1 次 SQL 查詢 ✅
foreach (var s in students) // 迴圈 N 個學生
{
var count = s.Enrollments.Count; // 已經載入,不需要額外查詢
}
❌ 錯誤 2:Lazy Loading 效能陷阱
// ❌ 開啟 Lazy Loading 後,在迴圈中存取導航屬性
var students = await db.Students.ToListAsync(); // 取得所有學生
var result = students.Select(s => new // 投影
{
s.Name, // 學生姓名
CourseCount = s.Enrollments.Count // 這裡觸發 Lazy Loading!
}).ToList(); // N+1 問題再次出現
// ✅ 用投影(Select)在資料庫端完成計算
var result = await db.Students // 查詢學生
.Select(s => new // 在資料庫端投影
{
s.Name, // 學生姓名
CourseCount = s.Enrollments.Count // 在 SQL 中計算 COUNT
})
.ToListAsync(); // 一次查詢完成 ✅
💡 重點整理
| 概念 | 說明 |
|---|---|
| Fluent API | 在 DbContext 中用程式碼設定模型 |
| Eager Loading | 用 Include 一次載入關聯資料 |
| Lazy Loading | 存取時自動載入(有 N+1 風險) |
| Shadow Property | 不在 Entity 中但存在於 DB 的屬性 |
| Global Query Filter | 每次查詢自動套用的篩選條件 |