โหมดมืด
บทที่ 2 — Database + EF Core (code-first, migration, transaction)
EF Core = ORM อย่างเป็นทางการของ .NET ที่เป็น code-first และ query ด้วย LINQ
รองรับฐานข้อมูลผ่าน provider หลายตัว: PostgreSQL, SQL Server, SQLite, MySQL, Oracle, Cosmos DB
📦 ศัพท์ฐานของบทนี้ (ดูครั้งเดียวพอ)
- LINQ = ไวยากรณ์ query ข้อมูลในภาษา C# (เช่น
.Where(...)) — EF แปลงเป็น SQL ให้ (เรียนเต็มใน csharp/09)- ORM (Object-Relational Mapping) = ตัวแปลงระหว่าง object ในโค้ด (เช่น class
User) กับตาราง/แถวในฐานข้อมูล ทำให้เราเขียนโค้ด C# แทนการเขียน SQL ดิบ- code-first = นิยาม schema (โครงสร้างตาราง) จาก class ในโค้ดก่อน แล้วให้ EF สร้างตาราง DB ตาม (ตรงข้ามกับ database-first ที่มี DB ก่อน)
- schema = โครงสร้างของฐานข้อมูล (มีตารางอะไร คอลัมน์อะไร ชนิดอะไร)
- change tracking / tracking = EF จดจำว่า entity ที่โหลดมาถูกแก้ตรงไหนบ้าง พอสั่ง
SaveChangesก็ gen SQLUPDATEเฉพาะส่วนที่เปลี่ยนให้เอง- navigation property = property บน entity ที่ชี้ไป entity ที่เกี่ยวข้อง (เช่น
Post.Authorชี้ไป User เจ้าของ) ใช้เดินตามความสัมพันธ์ระหว่างตาราง
1. ทำไม EF Core
- LINQ → SQL ที่ type-safe (ผูกกับ type ชัด เขียนผิดรู้ตั้งแต่ compile)
- Migration (การปรับโครงสร้าง DB เป็นขั้น ๆ) ที่ track schema change (ติดตามการเปลี่ยนแปลง schema)
- Change tracking (สั่ง SaveChanges ครั้งเดียว apply ทุก change)
- ทำงานข้าม DB ด้วย provider (ตัวเชื่อมกับ DB แต่ละชนิด) ที่เปลี่ยนได้
- มี cache + transaction + concurrency control (การควบคุมการแก้ข้อมูลพร้อมกัน)
ทางเลือก: Dapper (micro-ORM ขนาดเล็กที่เน้นความเร็ว), LinqToDB, RepoDB — เลือกเมื่อ EF Core หนักเกินไปสำหรับเคสที่ต้องการ control SQL เอง
2. Setup
เริ่มใช้ EF Core (ORM ของ .NET) ด้วยการติดตั้ง 3 package: core, design (สำหรับ migration) และ provider ตาม DB ที่ใช้ (Npgsql/SqlServer/SQLite) พร้อมติดตั้ง dotnet-ef tool สำหรับสั่ง migration จาก CLI:
bash
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL # หรือ SQL Server, SQLite
# dotnet tool = เครื่องมือ CLI ที่ติดตั้งแบบ global (ใช้ได้ทุก project)
# ต่างจาก dotnet add package ที่เพิ่ม library เฉพาะ project นี้เท่านั้น
dotnet tool install --global dotnet-ef3. DbContext
DbContext คือหัวใจของ EF Core — เป็นตัวแทนของ database session (ช่วงการเชื่อมต่อกับ DB หนึ่งครั้ง)
ภายใน DbContext มี:
DbSet<T>ต่อ 1 ตาราง — มองเป็น collection ของแถวในตารางนั้นOnModelCreatingสำหรับกำหนด schema และความสัมพันธ์- key = กุญแจหลัก (primary key)
- index = ดัชนีช่วยให้ค้นเร็ว
- foreign key = กุญแจที่ชี้ไปอ้างอิงตารางอื่น
จากนั้น register เข้า DI พร้อม connection string (สตริงบอกที่อยู่ + รหัสผ่านของ DB)
ส่วนนี้แสดงทั้งการนิยามและ register:
csharp
// Data/AppDbContext.cs
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
// DbSet<User> Users = ตัวแทนตาราง users ใน DB — ใช้ query / add / remove ผ่านตัวนี้ได้
// Set<User>() เป็นวิธีที่ปลอดภัยกว่า public auto-property { get; set; } ธรรมดา
public DbSet<User> Users => Set<User>();
public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<User>(b =>
{
b.HasKey(u => u.Id);
b.Property(u => u.Email).IsRequired().HasMaxLength(254);
b.HasIndex(u => u.Email).IsUnique();
b.Property(u => u.Name).IsRequired().HasMaxLength(100);
});
mb.Entity<Post>(b =>
{
b.HasOne(p => p.Author)
.WithMany(u => u.Posts)
.HasForeignKey(p => p.AuthorId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}Register
csharp
// ปกติ — Scoped (1 instance ต่อ request)
// GetConnectionString("Default") อ่านค่าจาก appsettings.json ที่ key ConnectionStrings.Default
// เช่น "ConnectionStrings": { "Default": "Host=localhost;Database=mydb;Username=..." }
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// ถ้าต้องการ throughput สูง — ใช้ DbContext pool (reuse instance ลด allocation)
// builder.Services.AddDbContextPool<AppDbContext>(opt =>
// opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));💡
AddDbContextPool<>เหมาะกับเคส high-throughput — EF จะ pool instance ของDbContextแล้ว reset state ก่อน reuse · ข้อจำกัด: ห้าม inject scoped service (เช่นITenantContext) ตรง ๆ เข้า constructor เพราะ instance reuse ข้าม request
4. Entity Class
entity คือ class ธรรมดาที่ map ไปเป็นตารางใน DB — EF Core อ่าน property มาสร้างคอลัมน์ และ navigation property (เช่น Author, Posts — property ที่ชี้ไป entity ที่เกี่ยวข้อง) มาสร้างความสัมพันธ์ ส่วนนี้แสดง entity User/Post พร้อมเทคนิค = null! ที่บอก compiler ว่า EF จะ populate (เติมค่า) ให้ตอน load:
csharp
public class User
{
// Guid.NewGuid() ใน property initializer: ง่ายและใช้งานได้ แต่มีข้อแลกเปลี่ยน:
// - DB ไม่ได้ gen Guid ให้ → ไม่ใช่ sequential → อาจกระทบ B-tree index performance บน table ขนาดใหญ่
// - ทางเลือก: ใช้ HasDefaultValueSql("gen_random_uuid()") ใน OnModelCreating ให้ PostgreSQL gen ให้
// หรือ .NET 9+ ใช้ Guid.CreateVersion7() ที่เรียงตามเวลา (time-ordered, ดีกว่าสำหรับ index)
public Guid Id { get; set; } = Guid.NewGuid();
public required string Name { get; set; }
public required string Email { get; set; }
public string? PasswordHash { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// navigation: ใช้ ICollection ตามขนบ EF (มี Add/Remove พอ — ไม่ต้อง expose List API เต็ม)
// [] = collection expression (C# 12) เทียบเท่า new List<Post>()
public ICollection<Post> Posts { get; init; } = [];
// optional: สำหรับ §8 (ExecuteUpdate)
public DateTime? LastLogin { get; set; }
public bool Active { get; set; } = true;
}
public class Post
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid AuthorId { get; set; }
public User Author { get; set; } = null!;
public required string Title { get; set; }
public string? Body { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}💡
= null!;คืออะไร? เป็นการบอก compiler ว่า "ตอนนี้เป็นnullก็จริง แต่ EF จะเติมค่าให้ตอน load — อย่าเตือนเลย"ที่ต้องเขียนแบบนี้เพราะ NRT (Nullable Reference Types) เป็น feature ที่ทำให้ compiler เตือนเมื่อเราอาจเผลอใช้ค่า
null— ถ้าไม่ใส่= null!compiler จะเตือนว่า navigation property ที่ไม่ใช่ nullable อาจเป็นnullตอน initialize💡
!ต่อท้ายค่า (null-forgiving operator) — เป็น C# 8+ feature ที่บอก compiler ว่า "ฉันรู้ว่าค่านี้ไม่ใช่ null แน่นอน อย่าเตือน" ใช้ได้ทุกที่ ไม่ใช่เฉพาะใน property initializer
💡
requiredกับ EF Core: EF Core 7+ รองรับrequired memberแล้ว (instantiate ผ่าน reflection ได้โดยไม่เรียก constructor parameter) · ถ้าใช้ EF Core 6 หรือเก่ากว่า ให้เปลี่ยนเป็น= default!แทน
💡 navigation ใช้
ICollection<T>แทนList<T>ตามแนวทางที่ EF แนะนำ —ICollectionพอสำหรับ EF (มี Add/Remove) โดยไม่ผูกกับ concrete type ·init;ช่วยกันไม่ให้ใครแอบเปลี่ยน reference ของ collection หลังสร้าง
5. Migration
migration = กลไกแปลงการเปลี่ยน entity ให้กลายเป็น SQL ที่ปรับ schema ของ DB ให้ตรงกัน เช่น เพิ่ม property PhoneNumber ใน class User → migration จะ gen คำสั่ง ALTER TABLE users ADD COLUMN phone_number TEXT; ให้
คำสั่งหลัก:
migrations add <ชื่อ>— สร้างไฟล์ migration ที่ describe การเปลี่ยนแปลงdatabase update— apply migration จริงกับ DBmigrations script— gen SQL ไฟล์เดียวสำหรับ production (ให้ DBA รัน)
EF Core track ประวัติ schema ไว้ในตาราง __EFMigrationsHistory ทำให้ deploy ฐานข้อมูลได้แบบ version control (มีประวัติย้อนได้เหมือน git)
⚠️ ระวัง rename column — EF ไม่รู้ว่าเปลี่ยนชื่อ จะ gen เป็น drop คอลัมน์เก่า + add คอลัมน์ใหม่ (= ข้อมูลในคอลัมน์เดิมหายหมด) · ต้องแก้ migration ด้วยมือให้เรียก
RenameColumnแทน
bash
dotnet ef migrations add InitialCreate
# → Migrations/<timestamp>_InitialCreate.cs
dotnet ef database update
# → apply schema
# revert — ต้องทำตามลำดับนี้เสมอ:
# 1. ย้อน schema ใน DB ก่อน (ถ้า migration นั้น apply ไปแล้ว)
dotnet ef database update <previous-migration-name>
# ⚠️ database update <previous> = ย้อนกลับ schema แต่ไม่คืน data ที่อยู่ในคอลัมน์ที่ถูกลบ
# อย่ารันบน DB production โดยไม่ backup ก่อน
# 2. ลบไฟล์ migration ออก (ทำหลัง database update เท่านั้น)
dotnet ef migrations remove
# generate SQL (production)
dotnet ef migrations script
dotnet ef migrations script <from> <to>⚠️ Pitfall: rename column = drop + add column (data หาย) — ต้อง override ใน migration ใช้
RenameColumn
6. CRUD ด้วย EF Core
ทีนี้มาดู CRUD จริงผ่าน EF Core — inject DbContext เข้า service แล้วใช้ Add/Remove/Find ตามด้วย SaveChangesAsync() ที่ commit (ยืนยันบันทึก) การเปลี่ยนแปลงทั้งหมด สังเกตว่า EF ทำ change tracking (จดจำการแก้ entity) ให้ จึงแค่แก้ property แล้ว save ก็ได้ UPDATE เอง (DbContext เป็น Scoped — 1 instance ต่อ request):
csharp
public class UsersService
{
private readonly AppDbContext _db;
public UsersService(AppDbContext db) => _db = db;
// ⚠️ ในโค้ดตัวอย่างนี้ return User entity ตรง ๆ เพื่อให้เข้าใจง่าย
// แต่ใน production ควร project เป็น DTO ก่อน (เช่น .Select(u => new UserResponse(...)))
// เพื่อกัน PasswordHash และ field ที่ไม่ควรเปิดเผยรั่วออกไปเมื่อ serialize ส่ง HTTP response
public Task<List<User>> List() => _db.Users.AsNoTracking().ToListAsync();
// FindAsync คืน ValueTask<User?> — เปิดเป็น async/await ให้ตรงไปตรงมา
public async Task<User?> Get(Guid id) => await _db.Users.FindAsync(id);
public async Task<User> Create(CreateUserDto dto)
{
var u = new User { Name = dto.Name, Email = dto.Email };
_db.Users.Add(u);
await _db.SaveChangesAsync();
return u;
}
// หมายเหตุ: pattern นี้ silent overwrite เมื่อสองคนแก้แถวเดียวกันพร้อมกัน
// ดู §13 เรื่อง optimistic concurrency (xmin/RowVersion) ถ้าต้องการ 409 Conflict แทน
public async Task<User?> Update(Guid id, UpdateUserDto dto)
{
var u = await _db.Users.FindAsync(id);
if (u is null) return null;
if (dto.Name is not null) u.Name = dto.Name;
if (dto.Email is not null) u.Email = dto.Email;
await _db.SaveChangesAsync();
return u;
}
public async Task<bool> Delete(Guid id)
{
var u = await _db.Users.FindAsync(id);
if (u is null) return false;
_db.Users.Remove(u);
await _db.SaveChangesAsync();
return true;
}
}AppDbContext มี lifetime = Scoped (1 instance per request) — ไม่ต้องสร้างเอง
7. Query — LINQ to SQL
จุดเด่นของ EF Core คือเขียน query เป็น LINQ (C#) แล้วมันแปลเป็น SQL ให้ — Where/Select/Include/Skip/Take map ไปเป็น WHERE/JOIN/pagination (การแบ่งหน้า) เคล็ดลับ performance สำคัญคือ AsNoTracking() สำหรับ query อ่านอย่างเดียว (ปิด change tracking → เร็วกว่า + ประหยัด memory เพราะไม่ต้องจดจำว่าจะแก้อะไร) ส่วนนี้รวม query pattern ที่ใช้บ่อย:
csharp
// กรอง (where) แล้วเลือกเฉพาะ field ที่ต้องการ (select)
var emails = await _db.Users
.Where(u => u.CreatedAt > DateTime.UtcNow.AddDays(-7))
.Select(u => u.Email)
.ToListAsync();
// join ตารางที่สัมพันธ์กัน ด้วย Include
var users = await _db.Users
.Include(u => u.Posts)
.ToListAsync();
// include ซ้อนหลายชั้น (โหลด Users แล้วโหลด Posts และ Author ของแต่ละ Post)
// หมายเหตุ: ถ้ามี navigation property อื่น เช่น Post.Comments
// ก็ ThenInclude ต่อได้ในลักษณะเดียวกัน
await _db.Users
.Include(u => u.Posts)
.ThenInclude(p => p.Author) // โหลด Author ของแต่ละ Post (เป็น User)
.ToListAsync();
// projection — แปลงผลลัพธ์เป็น DTO ตรง ๆ (ดึงเฉพาะ field ที่ใช้ เร็วกว่า)
var summaries = await _db.Users
.Select(u => new UserSummary(u.Id, u.Name, u.Posts.Count))
.ToListAsync();
// แบ่งหน้า (pagination) — ข้าม 20 แถวแรก เอา 10 แถวถัดไป
var page = await _db.Users
.OrderBy(u => u.CreatedAt)
.Skip(20).Take(10)
.ToListAsync();
// aggregate — สรุปผลรวม เช่น นับจำนวน หาค่าเฉลี่ย
var someDate = DateTime.UtcNow.AddDays(-30);
int count = await _db.Users.CountAsync(u => u.CreatedAt > someDate);
double avgPosts = await _db.Users.AverageAsync(u => u.Posts.Count);Track vs No-Track
csharp
// ค่าตั้งต้น = track (EF จดจำการเปลี่ยนแปลง)
var u = await _db.Users.FindAsync(id);
u.Name = "Bob";
await _db.SaveChangesAsync(); // EF gen คำสั่ง UPDATE ให้เอง
// no-track — สำหรับอ่านอย่างเดียว (เร็วกว่า เพราะไม่ต้องจดจำ)
var u2 = await _db.Users.AsNoTracking().FirstAsync(u => u.Id == id);💡 ทุก query ที่อ่านอย่างเดียวใช้
AsNoTracking()— ลด memory + เร็ว เพราะ EF ไม่ต้องจดจำ entity ใน change tracker💡 ถ้า query join entity เดิมหลายรอบ (เช่น Post → Author + Comment → Author)
AsNoTrackingWithIdentityResolution()จะ dedupe instance ของ Author คนเดียวกันให้ ทำให้ใช้ memory น้อยกว่าAsNoTracking()ธรรมดาในเคสนี้
8. ExecuteUpdate / ExecuteDelete (EF Core 7+)
การ update/delete หลายแถวด้วย pattern ปกติ (load entity → แก้ → save) ช้าเพราะต้องดึงทุก row (แถว) เข้า memory ก่อน — EF Core 7+ มี ExecuteUpdate/ExecuteDelete ที่ gen SQL UPDATE/DELETE ... WHERE ตรง ๆ ไม่ผ่าน change tracker เร็วกว่ามากสำหรับ bulk operation (การจัดการทีละหลายแถว):
csharp
// bulk update — แก้หลายแถวรวดเดียว โดยไม่ load entity เข้ามาก่อน
await _db.Users
.Where(u => u.LastLogin < DateTime.UtcNow.AddYears(-1))
.ExecuteUpdateAsync(s => s.SetProperty(u => u.Active, false));
// bulk delete — ลบหลายแถวรวดเดียว
await _db.Posts
.Where(p => p.CreatedAt < DateTime.UtcNow.AddYears(-5))
.ExecuteDeleteAsync();เร็วกว่ามาก — generate SQL ตรง ๆ ไม่ผ่าน change tracker
⚠️ ข้อควรระวังของ
ExecuteUpdate/ExecuteDelete:
- bypass change tracker —
SaveChangesevents และ interceptors ไม่ทำงาน- bypass query filters — รวมถึง global query filter ที่ใช้ทำ soft delete (§14) และ multi-tenancy (§15) → อาจลบ/แก้ข้อมูลข้ามขอบเขต ถ้าเผลอ
- bypass concurrency token — ไม่ตรวจ
xmin/RowVersionให้ (§13)ใช้สำหรับงาน bulk ที่รู้ขอบเขตชัดเจน · งานปกติยังควรใช้ load → modify → save
9. Raw SQL
บางครั้ง LINQ ทำไม่ได้หรือ performance ไม่พอ — EF Core ให้เขียน raw SQL (SQL ดิบที่เขียนเอง) ผ่าน 3 ช่องทาง:
FromSql(...)— รัน SELECT แล้ว map กลับเป็น entityExecuteSql(...)— รันคำสั่งที่ไม่ต้อง map ผล (UPDATE/DELETE/etc.)- เข้าถึง
DbConnectionตรง ๆ — เมื่อต้องการ control เต็ม
🚨 กฎความปลอดภัย: ห้าม string concat ค่าจาก user ลง SQL!
- SQL injection = การที่ผู้ไม่หวังดีแอบใส่ SQL ผ่าน input (เช่น input ชื่อว่า
' OR '1'='1) → ถ้าเรา concat เข้า SQL ตรง ๆ จะกลายเป็น query ที่อ่านข้อมูลเกินที่ตั้งใจ- ใน EF Core ใช้ string interpolation (
$"...{x}") ผ่านFromSql/ExecuteSql— EF จะแปลง{x}เป็น parameter ให้อัตโนมัติ (ปลอดภัย)- ห้ามใช้
+หรือstring.Formatต่อ SQL — จะกลายเป็น string ธรรมดาที่ไม่ parameterize
csharp
// แบบผูก type ชัดเจน (map ผลกลับเป็น entity User)
var users = await _db.Users
.FromSql($"SELECT * FROM users WHERE email = {email}") // {email} กลายเป็น parameter ปลอดภัย
.ToListAsync();
// คำสั่งที่คืนค่าเดียว (scalar)
var n = await _db.Database.ExecuteSqlAsync($"UPDATE users SET active=true WHERE id={id}");
// query อิสระ — เข้าถึง DbConnection ตรง ๆ
await using var conn = _db.Database.GetDbConnection();
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM users";
var count = (long)(await cmd.ExecuteScalarAsync())!;FormattableString interpolation safe — EF แปลง {email} เป็น parameter ไม่ใช่ string concat
⚠️
FromSqlต้อง return คอลัมน์ครบและตรงกับ mapping ของ entity — ถ้า SQL ที่เขียน return column ไม่ครบ/ผิด type จะ throw ตอน runtime (เห็น error เฉพาะตอนยิงจริง ไม่ใช่ตอน compile)
10. Transaction
transaction = กลุ่มคำสั่งที่ต้องสำเร็จทั้งหมดหรือยกเลิกทั้งหมด
SaveChanges แต่ละครั้ง EF Core ครอบ transaction ในตัวให้อยู่แล้ว — เพียงพอสำหรับงานปกติ
แต่ถ้าต้องการให้ หลาย SaveChanges สำเร็จ/ล้มเหลวพร้อมกัน ต้องใช้ explicit transaction ที่ประกาศเองด้วย BeginTransaction + Commit/Rollback
และเมื่อใช้กับ cloud DB ควรครอบด้วย execution strategy เพื่อ retry อัตโนมัติเมื่อ connection สะดุด:
csharp
// implicit — SaveChanges มี transaction ในตัวอยู่แล้ว
await _db.SaveChangesAsync();
// explicit — ประกาศเอง เมื่อต้องการครอบหลาย SaveChanges
using var tx = await _db.Database.BeginTransactionAsync();
try
{
await _db.Users.AddAsync(user);
await _db.SaveChangesAsync();
await _db.AuditLogs.AddAsync(log);
await _db.SaveChangesAsync();
await tx.CommitAsync();
}
catch (Exception ex)
{
// RollbackAsync อาจ throw เองได้ถ้า connection ขาด — ครอบไว้เพื่อไม่ให้ทับ exception เดิม
try { await tx.RollbackAsync(); }
catch (Exception rbEx) { /* log rbEx ถ้ามี logger: logger.LogError(rbEx, "rollback failed") */ }
throw; // rethrow exception เดิม
}
// EF Core 6+ — execution strategy + retry
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
using var tx = await _db.Database.BeginTransactionAsync();
// ...
await tx.CommitAsync();
});11. N+1 Trap
N+1 (เอ็น-บวก-หนึ่ง) เป็นปัญหา performance ที่เจอบ่อยสุดกับ ORM — query หลัก 1 ครั้งดึง N รายการ แล้ววนเข้าถึง relation (ข้อมูลที่สัมพันธ์กัน) ทำให้ยิง query เพิ่มอีก N ครั้ง (รวมเป็น N+1 ครั้ง) ข่าวดีคือ EF Core ปิด lazy loading (การโหลดข้อมูลที่สัมพันธ์แบบทันทีที่เข้าถึง) โดย default จึงบังคับให้คิดเรื่องนี้ แก้ด้วย Include หรือ projection ให้โหลดมาในครั้งเดียว:
csharp
// ❌ เกิด N+1
var users = await _db.Users.ToListAsync();
foreach (var u in users)
// ถ้าใช้ EF Core ค่าตั้งต้น (ปิด lazy load): Posts.Count = 0 — ไม่โหลดมา ไม่ใช่ N+1
// N+1 เกิดจริงเมื่อเปิด lazy loading (ไม่แนะนำ) — EF จะยิง query แยกต่อ user
Console.WriteLine(u.Posts.Count);
// ✅ ใช้ Include โหลดมาทีเดียว
var users = await _db.Users.Include(u => u.Posts).ToListAsync();
// ✅ ใช้ projection ดึงเฉพาะที่ต้องการ
var data = await _db.Users
.Select(u => new { u.Name, PostCount = u.Posts.Count })
.ToListAsync();⚠️ EF Core ค่าตั้งต้น = ปิด eager loading และไม่มี lazy loading (กัน N+1) — ต้องสั่ง
Includeเองชัด ๆ (eager loading = โหลดข้อมูลสัมพันธ์มาพร้อมกันทันที)
12. Performance Tips
รวมเทคนิครีด performance จาก EF Core — AsNoTracking สำหรับ read, compiled query (query ที่แปลไว้ล่วงหน้า), index ที่เหมาะ และ ToQueryString() ดู SQL จริงที่ gen ออกมา เทคนิคพวกนี้สร้างความต่างมากใน production
cartesian explosion คืออะไร — เมื่อ
Includeหลายความสัมพันธ์ในครั้งเดียว (เช่น user ที่มีทั้ง Posts และ Comments) SQL จะ JOIN ทุกตารางเข้าด้วยกัน ทำให้แถวผลลัพธ์ทวีคูณกันแบบบานปลาย (เช่น 1 user มี 10 posts × 10 comments = 100 แถว ที่ข้อมูล user ซ้ำกัน 100 รอบ) ใช้AsSplitQueryแก้โดยแยกยิงเป็นหลาย query แทนที่จะ JOIN ก้อนเดียว
csharp
// 1. AsNoTracking สำหรับ query ที่อ่านอย่างเดียว
_db.Users.AsNoTracking()
// 2. AsSplitQuery เมื่อ Include หลายระดับ (กัน cartesian explosion ที่อธิบายข้างบน)
_db.Users.Include(u => u.Posts).Include(u => u.Comments).AsSplitQuery()
// 3. compiled query — แปล query ไว้ล่วงหน้า ใช้ซ้ำได้เร็วขึ้น (.NET 7+)
// EF.CompileAsyncQuery + FirstOrDefault คืน IAsyncEnumerable (ไม่ใช่ Task<User?> โดยตรง)
// ใช้งานด้วย: await foreach หรือ await query(db, id).SingleOrDefaultAsync()
// ทางเลือกที่ง่ายกว่าสำหรับ single entity: ใช้ EF.CompileQuery (synchronous) แล้วห่อ Task
private static readonly Func<AppDbContext, Guid, IAsyncEnumerable<User>> GetById =
EF.CompileAsyncQuery((AppDbContext db, Guid id) =>
db.Users.Where(u => u.Id == id));
// 4. ใช้ index ช่วยค้นเร็ว — เพิ่มใน OnModelCreating
b.HasIndex(u => new { u.Status, u.CreatedAt });
// 5. ดู SQL จริงที่ EF จะ gen ออกมา (ช่วย debug performance)
_db.Users.Where(u => u.Email == "x").ToQueryString(); // → ตัวอย่าง SQL13. Concurrency Control
เมื่อสองคนแก้ข้อมูลแถวเดียวกันพร้อมกัน คนที่ save ทีหลังอาจทับงานคนแรกโดยไม่รู้ตัว
optimistic concurrency = การคุมการแก้พร้อมกันแบบ "ไม่ล็อกแถวก่อน แต่ตรวจตอน save"
วิธีคือเก็บ "version" ของแถวไว้ — ถ้า version เปลี่ยนไประหว่างที่อ่านมาแก้ EF จะ throw DbUpdateConcurrencyException ให้จัดการ (reload = โหลดใหม่ หรือ merge = รวมการแก้) แทนที่จะทับเงียบ ๆ:
csharp
// optimistic concurrency ผ่านคอลัมน์ xmin ของ PostgreSQL (ใช้เป็นเลข version ของแถว)
b.Property<uint>("xmin").IsRowVersion();
// ในแอป
try
{
var u = await _db.Users.FindAsync(id);
u.Name = "Bob";
await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// มีคนแก้ตัดหน้าไปก่อน → โหลดใหม่ (reload) หรือรวมการแก้ (merge)
}14. Soft Delete
หลายระบบไม่อยากลบข้อมูลจริง — เก็บไว้สำหรับ audit (ตรวจสอบย้อนหลัง) หรือกู้คืน แต่ทำให้เหมือนถูกลบไปแล้ว
soft delete = การลบแบบ "ทำเครื่องหมายว่าลบ" ไม่ลบจริง
วิธีทำ: ใช้คอลัมน์ DeletedAt แล้วตั้ง global query filter (ตัวกรองที่ใช้กับทุก query อัตโนมัติ) ให้ทุก query ซ่อนแถวที่ถูกลบ · เรียก IgnoreQueryFilters() เมื่อต้องการเห็นรวมแถวที่ลบไป:
csharp
public class User { public DateTime? DeletedAt { get; set; } /*...*/ }
// ตั้ง global filter — ทุก query ซ่อนแถวที่ DeletedAt ไม่ใช่ null
mb.Entity<User>().HasQueryFilter(u => u.DeletedAt == null);
// อยากเห็นรวมที่ถูกลบด้วย ก็สั่งข้าม filter
await _db.Users.IgnoreQueryFilters().ToListAsync();15. Multi-tenancy ผ่าน global filter
คำศัพท์:
- SaaS (Software as a Service) = ซอฟต์แวร์ที่ให้บริการผ่านเว็บ
- multi-tenant = หลายลูกค้า/องค์กรใช้ระบบเดียวกัน แต่ละรายเรียกว่า tenant
ในแอป SaaS แบบ multi-tenant ที่หลายลูกค้าแชร์ DB เดียวกัน global query filter ช่วยกัน "ข้อมูลรั่วข้าม tenant" ได้อัตโนมัติ
ตั้ง filter ตาม TenantId ของ request ปัจจุบัน → ทุก query จะถูกกรองให้เห็นเฉพาะข้อมูลของ tenant นั้น โดยไม่ต้องเติม .Where(t => t.TenantId == ...) เองทุกที่ (ลดความเสี่ยงลืม):
csharp
// AppDbContext.cs — _tenant เป็น field ที่ inject เข้ามาทาง constructor
// ITenantContext เป็น interface ที่เราเขียนเองและ register เข้า DI
// (อ่านค่า tenant จาก JWT claim หรือ HTTP header ใน middleware)
public class AppDbContext : DbContext
{
private readonly ITenantContext _tenant;
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenant)
: base(options) => _tenant = tenant;
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<User>().HasQueryFilter(u => u.TenantId == _tenant.Id);
}
}ทุก query auto filter ตาม tenant ปัจจุบัน
✅
HasQueryFilterlambda ถูกประเมินทุก query ไม่ใช่แค่ตอน build model — EF Core เก็บ expression tree ไว้ แล้วประเมินค่า_tenant.Idทุกครั้งที่ยิง query จริง ดังนั้น filter จะได้ค่า tenant ที่ถูกต้องต่อ request⚠️ ข้อสำคัญ:
DbContextต้องเป็น Scoped (ค่าตั้งต้น) ไม่ใช่ Singleton — เพราะแต่ละ request ต้องได้ instance ใหม่ที่ injectITenantContextของตัวเอง · ถ้าใช้DbContextPoolต้องระวังเป็นพิเศษเพราะ instance อาจถูก reuse ข้าม request
16. Connection Resilience (cloud DB)
cloud DB มี connection สะดุดเป็นครั้งคราว สาเหตุที่พบบ่อย:
- failover = สลับไปใช้เครื่องสำรองตอนเครื่องหลักล่ม
- network blip = เน็ตกระตุกชั่วครู่
- throttling = service จำกัด rate ชั่วคราว
ถ้าไม่จัดการ query จะ fail ทันทีเมื่อเจอเหตุพวกนี้
EF Core มี retry on failure ที่ลองใหม่อัตโนมัติเมื่อเจอ transient error (error ชั่วคราวที่หายเองได้) → แอปทนต่อความสะดุดเล็ก ๆ โดยไม่ต้องเขียน retry เอง:
csharp
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(connString, npg =>
{
npg.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorCodesToAdd: null);
}));จำเป็นกับ Azure SQL/Cosmos ที่ throttle ได้
17. Migration ใน Production
bash
# ใน CI: gen SQL แบบ idempotent (รันซ้ำกี่ครั้งผลเหมือนเดิม ไม่พัง)
dotnet ef migrations script --idempotent --output migration.sql
# ให้ DBA (ผู้ดูแลฐานข้อมูล) หรือเครื่องมือ migration นำไป apply
psql -f migration.sqlหรือ run จาก app ตอน startup (ไม่แนะนำสำหรับ multi-replica — กรณีรันแอปหลายตัวพร้อมกัน):
csharp
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}⚠️ อย่ารัน migration ใน startup hook เมื่อใช้ rolling deploy — อธิบายเป็นข้อ:
- rolling deploy = วิธี deploy แบบทยอยเปลี่ยน instance ทีละตัว (เพื่อไม่ให้บริการขาดช่วง)
- replica = สำเนาของแอปที่รันพร้อมกันหลาย instance
- ระหว่าง rolling: replica ใหม่ migrate schema แล้ว แต่ replica เก่ายังรันด้วย schema เก่าอยู่ → schema ไม่ตรงกับ runtime ระหว่างช่วง deploy
- วิธีที่ดีกว่า: แยก migration เป็น job ต่างหาก (เช่น Kubernetes Job = งานที่รันครั้งเดียวจบบน Kubernetes) ที่เสร็จก่อนค่อย deploy แอป
18. Common Pitfalls
- Tracked DbContext + อายุยาว → memory โต — scoped ดีอยู่แล้ว, อย่าตั้งเป็น singleton
- Include ใหญ่ ๆ = cartesian explosion → ใช้ AsSplitQuery
- SaveChanges ใน loop = เกิด N transactions → รวมเป็น batch (ทำทีเดียวหลายอัน)
- เปิด lazy loading = N+1 ทุกที่ — ปิดไว้
.ToList()แล้วค่อย filter → โหลดทั้งหมดเข้า memory แล้วกรองในแอป → ควรกรองใน DB ก่อน- Decimal precision (จำนวนหลักรวม, จำนวนหลักทศนิยม) = ค่าตั้งต้น
(18, 2)ซึ่ง OK สำหรับเงินทั่วไป — สำหรับเงินที่ต้องการความละเอียดสูง (เช่น forex, crypto) แนะนำ(19, 4):b.Property(x => x.Price).HasPrecision(19, 4) - Migration ที่ rename → EF ไม่รู้ว่าเป็นการเปลี่ยนชื่อ → ใส่ manual override (สั่ง RenameColumn เอง)
🛠️ Checkpoint 2 — ลงมือทำ
- setup PG ด้วย Docker (ต้องติดตั้ง Docker Desktop ก่อน — ช่วยรัน PostgreSQL ในเครื่องโดยไม่ต้องติดตั้ง DB จริง):bash
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=pg postgres:16.4-alpine⚠️ password
pgใช้ได้เฉพาะ local dev — ห้ามใช้ใน environment อื่น - สร้าง
AppDbContext+ entity User/Post + migration - CRUD endpoint จากบทที่ 1 → switch จาก in-memory เป็น EF
- ทำ pagination + sort + filter ที่ endpoint
GET /users?page=1&size=20&sort=createdAt - ทำ bulk update ด้วย
ExecuteUpdate - ลอง
AsNoTracking+ benchmark กับเดิม - enable Resilience strategy + transaction รวม insert user + audit log
สรุปบทที่ 2
- EF Core = code-first ORM ของ .NET — LINQ → SQL + migration
DbContext= scoped (1 per request)Includeทุก relation ที่ใช้ — กัน N+1AsNoTrackingทุก read queryExecuteUpdate/ExecuteDeleteสำหรับ bulk- transaction ที่ explicit + retry strategy สำหรับ cloud DB
- migration ใน CI/CD เป็น job แยก ไม่ใช่ startup hook