在C#项目中使用Entity Framework Core进行数据访问时,合理的关系映射直接决定了数据库表结构的清晰度与查询代码的可维护性。关系映射的核心是把业务领域里的实体关联,转换成关系型数据库中的外键约束与连接表,同时让开发者能用导航属性在内存对象图中自由穿梭。

一、EF Core关系映射基础概念
EF Core通过约定、数据注解和Fluent API三种方式建立关系。约定是最轻量的做法:只要一个类里包含另一个类的引用或集合,EF Core就会尝试推断关系。比如Customer类里有List<Order> Orders,框架默认认为这是一对多,并在Order表加CustomerId外键。
但当项目变复杂,约定往往不够精确。这时候要在DbContext的OnModelCreating方法里用Fluent API显式配置。Fluent API以Entity<T>()为起点,通过HasOne、HasMany、WithOne、WithMany等链式方法描述两端 multiplicity(多重性),再用HasForeignKey指定外键属性,避免生成意料之外的影子属性。
1.1 导航属性与外键
导航属性分两类:引用导航(如Order.Customer)和集合导航(如Customer.Orders)。如果依赖实体公开了外键属性(如CustomerId),EF Core会优先使用它;若没写,则自动创建影子外键。显式写出外键能提升可读性与迁移可控性。
下面代码展示了一个最基础的一对多实体定义,注意外键命名虽可随意,但建议贴合主体名以防混淆:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
// 集合导航属性
public List<Order> Orders { get; set; }
}
public class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
// 外键
public int CustomerId { get; set; }
// 引用导航属性
public Customer Customer { get; set; }
}
二、一对多关系配置实战
一对多是最常见的关系。在EF Core中,即使上面已经靠约定建立了关系,我们仍建议用Fluent API固化配置,尤其是指定级联行为。以下代码在DbContext里完成Customer与Order的一对多映射,并设为级联删除。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.HasMany(c => c.Orders) // 主体端集合
.WithOne(o => o.Customer) // 依赖端引用
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Cascade); // 删客户时连带删订单
}
上述配置中,HasMany说明一个客户拥有多个订单,WithOne说明订单属于一个客户。OnDelete传入Cascade后,当从数据库移除Customer,其下Order记录也会被删除。若业务要求保留历史订单,可改为Restrict或SetNull。
查询时利用Include可一次性加载关联数据,避免N+1问题:
var customers = context.Customers
.Include(c => c.Orders)
.Where(c => c.Id == 1)
.ToList();
这种写法生成的SQL会采用LEFT JOIN把订单接回来。如果只需要部分字段,可以用Select投影,减少传输量。需要留意的是,Include不支持后续条件过滤,要先Where再Include,或者用ThenInclude处理更深层级。
2.1 无外键属性的约定映射
如果不想写CustomerId,可只保留导航属性,EF Core会建影子属性。但迁移文件里外键列名可能变成CustomerId1之类,排查麻烦。因此生产项目推荐显式外键。
三、多对多关系配置实战
多对多描述两个实体互相拥有集合,例如商品Product和订单Order:一个订单含多个商品,一个商品出现在多个订单里。EF Core 5之后不再强制要求中间类,框架自动建连接表。
public class Product
{
public int Id { get; set; }
public string Title { get; set; }
public List<Order> Orders { get; set; }
}
public class Order
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public List<Product> Products { get; set; }
}
在ModelBuilder里用双向HasMany-WithMany即可:
modelBuilder.Entity<Order>()
.HasMany(o => o.Products)
.WithMany(p => p.Orders)
.UsingEntity(j => j.ToTable("OrderProduct")); // 指定连接表名
UsingEntity可进一步定制连接表列名与键。若需要记录购买数量,就得退回显式中间实体:定义OrderItem类,存OrderId、ProductId与Count,然后Order与Product都改成通过OrderItem建立一对多,这其实是把多对多拆成两个一对多,最灵活。
查询多对多同样用Include:
var order = context.Orders
.Include(o => o.Products)
.FirstOrDefault(o => o.Id == 5);
此时EF Core自动 join 连接表取回商品。注意多对多导航的修改:往order.Products.Add(pro)后调用SaveChanges,框架会自动在连接表插记录,不用手动管中间表。
3.1 显式中间实体的写法
当关联本身带属性,推荐如下结构,关系更透明:
public class OrderItem
{
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public Order Order { get; set; }
public Product Product { get; set; }
}
// 配置
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.OrderId, oi.ProductId });
modelBuilder.Entity<Order>()
.HasMany(o => o.Items)
.WithOne(oi => oi.Order)
.HasForeignKey(oi => oi.OrderId);
modelBuilder.Entity<Product>()
.HasMany(p => p.Items)
.WithOne(oi => oi.Product)
.HasForeignKey(oi => oi.ProductId);
这种写法把多对多降级为两个一对多,方便加字段、做审计,也易于写原生SQL优化。缺点是查询要多跳一层Include(oi => oi.Items).ThenInclude(oi => oi.Product)。
四、常见坑与优化建议
第一,误用双向导航却没配置反向关系,会造成迁移生成重复外键。务必两端都在Fluent里描述,或用[InverseProperty]注解对齐。
第二,级联删除循环。若A删带B,B删带A,迁移会报错。可用HasPrincipalKey改主体键,或调整OnDelete为NoAction,在业务层手动清理。
第三,过度Include导致笛卡尔爆炸。一对多再Include多对多时行数相乘,可用AsSplitQuery分查:
var data = context.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.Products)
.AsSplitQuery()
.ToList();
AsSplitQuery让EF发多条SQL而非单条大JOIN,缓解内存膨胀。关系映射不是配完就忘,要结合慢查询日志持续审视。掌握一对多与多对多的Fluent写法,基本能覆盖大部分C#后端建模场景。
EF_Core关系映射Entity_Framework修改时间:2026-08-07 05:24:34