DbContext 是 EF Core 的核心,正因为如此,理解它的能力范围非常重要。
DbContext 是一个与数据库(通过特定连接字符串)关联的抽象层。
总体来说,它提供了一种通过这个抽象层来操作数据库的简单方式。
在 DbContext 中,你经常可以看到代表数据库表的属性。通常看起来是这样的:
public sealed class AppDbContext : DbContext
{
public DbSet<User> Users { get; set; }
}通过这些属性,你可以获得非常强大的能力——可以进行查询、添加、删除和更新数据,而且都是通过泛型类型 DbSet<TEntity> 来完成的,无需直接编写 SQL。当然,如果你确实需要,DbContext 也提供了执行原始 SQL 的方式。
从本质上讲,DbContext 是通过特定连接对真实数据库的一个抽象,你通常不需要直接操作数据库。DbContext 几乎已经提供了你避免使用原始 SQL 所需的一切。
但这里存在一个对 DbContext 的常见误解。很多开发者把 DbContext 看作是基础设施层的一部分,然后在它之上再构建一层抽象,比如 Repository 模式和 Unit of Work 模式。
我不隐瞒——几年前我也是这么想的。如果你感兴趣,可以看看我之前的文章。
在 ASP.NET Core 中实现 Repository 和 Unit of Work 模式
Repository 和 Unit of Work 模式旨在在数据访问层和……之间创建一个抽象层
medium.com
但现在,我强烈感觉我们并不是每次都需要这种额外的抽象。DbContext 本身就已经实现了 Repository 模式(通过 DbSet 属性)和 Unit of Work(针对数据库层面)。
如果你想理解为什么 IRepository 和 IUnitOfWork 是多余的,只需要看看下面这张图:
(此处为原文中的示意图)
正如你所看到的,在这里增加一层抽象其实并不合适。
但我大概能猜到你在想什么:如果不使用 IRepository 模式,我们该如何将相关实体分离,让 DbContext 在知识领域上更符合单一职责原则?
通常你会有一个很大的 DbContext,里面包含很多不同的属性(表)。使用 repository 模式时,你会对这些表进行分组或过滤,以遵循单一职责原则。
你可能不知道的是:你可以拥有任意多个 DbContext。如果我们按逻辑模块拆分数据库上下文——比如订单模块——那么它只包含与订单相关的内容,或者任何你想要的数据库结构。
这样就不再只有一个巨大的 AppDbContext,而是可以按领域拆分:UserDbContext、BillingDbContext、FilesDbContext。
基本上会变成这样:
// was
publicclassAppDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<Payment> Payments { get; set; }
public DbSet<Refund> Refunds { get; set; }
public DbSet<File> Files { get; set; }
public DbSet<FileAttribute> FileAttributes { get; set; }
}
//became
publicclassUserDbContext : DbContext
{
public DbSet<User> Users { get; set; }
}
publicclassBillingDbContext : DbContext
{
public DbSet<Payment> Payments { get; set; }
public DbSet<Refund> Refunds { get; set; }
}
publicclassFileDbContext : DbContext
{
public DbSet<File> Files { get; set; }
public DbSet<FileAttribute> FileAttributes { get; set; }
}如果你观察上面的 DbContext,会发现一个共同点——它们都通过属性表示数据库表。这种方式非常方便:当你获取某个特定的 DbContext 时,你已经清楚它关联的所有表。
但想象一下,如果你有非常多的属性——上百甚至上千个——或者你需要类似泛型模式的功能,这时你可能希望这样使用:
dbContext.GetDbSet<TEntity>(); //where TEntity any entity
在构建库或 NuGet 包时,这尤其有用,因为你并不知道具体 DbContext 中有哪些属性。
而真正让人开心的是,在 DbContext 中你可以直接使用 Set<TEntity>(),并像操作 DbSet<TEntity> 一样使用它。
所以下面这两行代码本质上是等价的:
db.Users.FirstOrDefaultAsync();
db.Set<User>().FirstOrDefaultAsync();但要注意:如果实体不存在或不属于该 DbContext,使用 Set<TEntity>() 很容易抛出 InvalidOperationException。
如何配置数据库表
我们还可以一起看看如何配置表结构。
最常见的方式是使用实体类上的特性(attributes)。后端会根据这些特性创建表,这些特性被称为 DataAnnotations。
示例如下:
using System.ComponentModel.DataAnnotations;
publicsealedclassUser
{
[Key]
publiclong Id { get; set; }
[Required]
[MaxLength(255)]
publicstring Email { get; set; } = null!;
[Required]
publicstring PasswordHash { get; set; } = null!;
}我个人并不喜欢在属性上堆满 DataAnnotations,因为当属性很多时,代码会变得非常混乱。
不过在小型或中型项目里,为了快速交付,它仍然是可以接受的。
但 Fluent API(实体配置)几乎在所有情况下都更好。它让代码更清晰,并且能更精细地控制属性(包括 shadow 属性)。
上面的例子可以改写为:
// entity
publicsealedclassUser
{
publiclong Id { get; set; }
publicstring Email { get; set; } = null!;
publicstring PasswordHash { get; set; } = null!;
}
// configuration
publicsealedclassUserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.Email)
.IsRequired()
.HasMaxLength(255);
builder.HasIndex(u => u.Email)
.IsUnique();
builder.Property(u => u.PasswordHash)
.IsRequired();
//Shadow Property
builder.Property<byte[]>("RowVersion")
.IsRowVersion()
.IsConcurrencyToken();
}
}
//using configuration
publicclassUserDbContext : DbContext
{
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(UserConfiguration).Assembly);
}
}一些实用技巧
使用 EF Core + LINQ 非常方便,但正因为方便,人们往往忽略了它的底层工作方式,以及 LINQ 是如何被转换成 SQL 的。这在处理大型查询时尤其重要。
让我们看一个例子。
假设你有一个实体关联到另一个实体,而那个实体又关联到第三个实体。
结构如下:
public sealedclassUser
{
publiclong Id { get; set; }
publicstring Email { get; set; } = null!;
public ICollection<File> Files { get; set; } = [];
}
publicsealedclassFile
{
publiclong Id { get; set; }
publicstring FileName { get; set; } = null!;
publicstring BlobUrl { get; set; } = null!;
public ICollection<FileAttribute> Attributes { get; set; } = [];
}
publicsealedclassFileAttribute
{
publiclong Id { get; set; }
publicstring Key { get; set; } = null!;
publicstring Value { get; set; } = null!;
}现在你有一个简单需求——获取某个用户的所有文件属性并显示在 UI 上。
你可能会这样写:
var userWithFiles = await _context.Users
.Include(user => user.Files)
.ThenInclude(file => file.Attributes)
.FirstOrDefaultAsync(user => uuser.Id == userId);问题在于:这样 EF Core 会生成一个巨大的 JOIN 查询。
如果一个用户有 100 个文件,每个文件 5 个属性,你最终会得到 500 行结果。
更好的方式是使用 .AsSplitQuery(),它会执行 3 个干净的 SELECT 语句。
修改后如下:
var userWithFiles = await _context.Users
.AsSplitQuery()// <-- a new method
.Include(u => u.Files)
.ThenInclude(f => f.Attributes)
.FirstOrDefaultAsync(u => u.Id == userId);Find 和 First 的本质区别
看看下面的代码:
var firstUser = db.Users.First(user => user.Id == userId);
var foundUser = db.Users.Find(userId);Find 用于根据主键查找实体(通常是 ID),而 First 可以根据任意属性查找。
关键区别是:Find 会使用 change tracker(DbContext 内部缓存)。如果实体已经在 DbContext 中被跟踪,它会直接返回,而不会查询数据库。
而 First 每次都会生成查询并访问数据库。
这也是为什么在复杂逻辑(比如重试场景)中更适合使用 First。
链式使用 .Where()
很多人不知道 .Where() 可以链式调用,而这种写法能让复杂的 WHERE 条件更易读。
例如:
// don't cool
var users = db.Users.Where(user => user.Email != null && user.Age >= 21 && user.CreatedAt >= new DateTimeOffset(2026, 02, 05, 0, 0, 0, TimeSpan.Zero));
// cool 💪
var users1 = db.Users
.Where(user => user.Email != null)
.Where(user => user.Age >= 21)
.Where(user => user.CreatedAt >= new DateTimeOffset(2026, 02, 05, 0, 0, 0, TimeSpan.Zero));🤝 我们在 LinkedIn 上连接吧!
如果你喜欢这篇文章,或者想进一步讨论这些内容,欢迎在 LinkedIn 上联系我!我很想听听你的想法并保持交流。
EF Core 是一个非常强大的工具,但也正因为如此,我们常常没有足够认真地思考如何正确使用和配置它。希望这篇文章能给你带来一些新的启发。