Chapter 05

Entity Framework Core

掌握 DbContext 配置、Fluent API 实体映射、查询优化、迁移管理与并发控制,构建 Blog 完整数据模型。

EF Core 核心概念

DbContext
EF Core 的核心类,代表与数据库的"会话"。它跟踪实体的状态变化(Added/Modified/Deleted/Unchanged),通过 SaveChanges() 将变更批量提交到数据库。每个 HTTP 请求应使用独立的 DbContext 实例(Scoped 生命周期)。
变更追踪(Change Tracking)
EF Core 默认追踪从数据库查询出来的所有实体。当你修改这些实体的属性并调用 SaveChanges() 时,EF Core 自动生成 UPDATE 语句。对于只读查询,应使用 AsNoTracking() 禁用追踪以提升性能。
迁移(Migrations)
将 C# 实体模型的变化同步到数据库表结构的机制。每次修改实体后运行 dotnet ef migrations add,生成一个迁移文件描述变更;运行 dotnet ef database update 将迁移应用到数据库。比手写 SQL DDL 更安全、可追溯。

Blog 系统数据模型

// 实体定义
public class Blog
{
    public int    Id       { get; set; }
    public string Title   { get; set; } = string.Empty;
    public string Slug    { get; set; } = string.Empty;
    public DateTimeOffset CreatedAt { get; set; }

    // 导航属性
    public ICollection<Post> Posts { get; } = [];
}

public class Post
{
    public int    Id       { get; set; }
    public string Title   { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public bool   IsPublished { get; set; }
    public DateTimeOffset PublishedAt { get; set; }

    // 外键
    public int    BlogId  { get; set; }
    public Blog   Blog    { get; set; } = null!;
    public int    AuthorId { get; set; }
    public Author Author  { get; set; } = null!;

    // 多对多:文章 ↔ 标签
    public ICollection<Tag> Tags     { get; } = [];
    public ICollection<Comment> Comments { get; } = [];

    // 并发令牌(乐观并发控制)
    [Timestamp]
    public byte[] RowVersion { get; set; } = [];
}

DbContext 与 Fluent API 配置

public class BlogDbContext : DbContext
{
    public BlogDbContext(DbContextOptions<BlogDbContext> options) : base(options) { }

    public DbSet<Blog>    Blogs    => Set<Blog>();
    public DbSet<Post>    Posts    => Set<Post>();
    public DbSet<Tag>     Tags     => Set<Tag>();
    public DbSet<Comment> Comments => Set<Comment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Post 实体详细配置
        modelBuilder.Entity<Post>(entity =>
        {
            entity.ToTable("posts");
            entity.HasKey(p => p.Id);
            entity.Property(p => p.Title)
                .IsRequired()
                .HasMaxLength(200);
            entity.Property(p => p.Content).HasColumnType("text");

            // 索引
            entity.HasIndex(p => p.IsPublished).HasFilter("is_published = true");

            // 一对多关系
            entity.HasOne(p => p.Blog)
                .WithMany(b => b.Posts)
                .HasForeignKey(p => p.BlogId)
                .OnDelete(DeleteBehavior.Cascade);

            // 多对多(EF Core 5+ 自动创建连接表)
            entity.HasMany(p => p.Tags)
                .WithMany(t => t.Posts)
                .UsingEntity("post_tags");
        });

        // 值对象:作者地址(不单独建表)
        modelBuilder.Entity<Author>()
            .OwnsOne(a => a.Address, addr =>
            {
                addr.Property(a => a.City).HasMaxLength(100);
                addr.Property(a => a.Country).HasMaxLength(2);
            });
    }
}

查询优化

AsNoTracking:只读查询

// 不需要修改的查询:加 AsNoTracking() 提升性能
var posts = await _db.Posts
    .AsNoTracking()              // 不追踪,减少内存和 CPU 开销
    .Where(p => p.IsPublished)
    .OrderByDescending(p => p.PublishedAt)
    .Take(20)
    .Select(p => new PostSummaryDto(  // Select 投影:只取需要的字段
        p.Id, p.Title, p.PublishedAt, p.Author.Name
    ))
    .ToListAsync(ct);

Include/ThenInclude:预加载关联数据

// 预加载:避免 N+1 问题
var blog = await _db.Blogs
    .Include(b => b.Posts)                  // 加载文章
        .ThenInclude(p => p.Tags)            // 加载文章的标签
    .Include(b => b.Posts)
        .ThenInclude(p => p.Author)          // 加载文章的作者
    .FirstOrDefaultAsync(b => b.Slug == slug, ct);

// ❌ N+1 问题示例(每篇文章额外查询一次作者)
var posts = await _db.Posts.ToListAsync();
foreach (var post in posts)
    Console.WriteLine(post.Author.Name);  // 每次访问 Author 触发额外查询!

数据迁移

# 安装 EF Core CLI 工具
dotnet tool install --global dotnet-ef

# 创建迁移
dotnet ef migrations add InitialCreate --project src/Data --startup-project src/Api

# 应用迁移到数据库
dotnet ef database update

# 回滚到上一个迁移
dotnet ef database update PreviousMigrationName

# 生成 SQL 脚本(生产部署用)
dotnet ef migrations script --idempotent -o migrations.sql
// 程序启动时自动应用迁移(仅开发/CI 环境推荐)
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<BlogDbContext>();
    await db.Database.MigrateAsync();
}

乐观并发控制

当多个用户同时编辑同一条记录时,乐观并发控制确保只有第一个提交的人成功,其他人收到冲突错误。EF Core 通过 RowVersion[ConcurrencyCheck] 属性实现。

public async Task<Post> UpdatePostAsync(UpdatePostRequest req, CancellationToken ct)
{
    var post = await _db.Posts.FindAsync([req.Id], ct)
        ?? throw new NotFoundException($"Post {req.Id} not found");

    post.Title   = req.Title;
    post.Content = req.Content;

    // 设置客户端传来的 RowVersion,EF Core 在 WHERE 子句中加入版本校验
    _db.Entry(post).OriginalValues["RowVersion"] = req.RowVersion;

    try
    {
        await _db.SaveChangesAsync(ct);
        return post;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        // 并发冲突:通知用户重新加载最新数据
        var current = ex.Entries.First();
        var db = await current.GetDatabaseValuesAsync(ct);
        throw new ConflictException("该文章已被其他人修改,请刷新后重试", db!);
    }
}

原生 SQL 与存储过程

// FromSqlRaw:将 SQL 结果映射到实体
var posts = await _db.Posts
    .FromSqlRaw("SELECT * FROM posts WHERE author_id = {0}", authorId)
    .AsNoTracking()
    .ToListAsync(ct);

// ExecuteSqlRaw:执行 DML(INSERT/UPDATE/DELETE)
int affected = await _db.Database
    .ExecuteSqlRawAsync(
        "UPDATE posts SET view_count = view_count + 1 WHERE id = {0}",
        postId);

// 参数化查询(防 SQL 注入,推荐)
var slug = "hello-world";
var post = await _db.Posts
    .FromSql($"SELECT * FROM posts WHERE slug = {slug}")  // 自动参数化
    .FirstOrDefaultAsync(ct);
本章小结 EF Core 的 Fluent API 提供了类型安全的实体配置,远优于数据注解(更灵活、不污染实体)。查询优化的核心原则:只读场景用 AsNoTracking(),只取需要的字段用 Select() 投影,预加载用 Include() 避免 N+1。乐观并发通过 RowVersion 在数据库层面防止丢失更新。迁移文件应纳入版本控制,生产环境用 SQL 脚本而非程序启动时自动迁移。