Skip to content

外键与导航属性 ​

概述 ​

外键(Foreign Key)和导航属性(Navigation Properties)是 EF Core 关系映射的核心概念。外键是数据库中实际存储的关联字段,而导航属性是 C# 代码中用于访问相关实体的对象引用。理解两者的关系和配置方式对于正确使用 EF Core 至关重要。


基本概念 ​

1. 外键(Foreign Key) ​

外键是数据库表中用于建立表间关系的列,它引用另一个表的主键。

sql
-- 数据库中的外键
CREATE TABLE Orders (
    Id INT PRIMARY KEY,
    CustomerId INT,              -- 外键列
    FOREIGN KEY (CustomerId) REFERENCES Customers(Id)
);

2. 导航属性(Navigation Property) ​

导航属性是实体类中用于访问相关实体的属性,EF Core 通过导航属性实现对象间的关联访问。

csharp
public class Order
{
    public int Id { get; set; }
    
    // 外键属性(可选)
    public int CustomerId { get; set; }
    
    // 导航属性
    public Customer? Customer { get; set; }  // 引用导航
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    
    // 集合导航属性
    public ICollection<Order> Orders { get; set; } = new List<Order>();
}

外键配置方式 ​

方式 1: 约定配置(Convention) ​

EF Core 自动识别符合命名规范的外键:

csharp
public class Order
{
    public int Id { get; set; }
    
    // ✅ 自动识别为外键(导航属性名 + "Id")
    public int CustomerId { get; set; }
    public Customer? Customer { get; set; }
}

命名规则:

  • {NavigationPropertyName}Id → CustomerId
  • {PrincipalEntityName}Id → CustomerId
  • {PrincipalEntityName}{PrimaryKeyName} → CustomerKey

方式 2: 数据注解 ​

csharp
using System.ComponentModel.DataAnnotations.Schema;

public class Order
{
    public int Id { get; set; }
    
    [ForeignKey("Customer")]  // 指定外键对应的导航属性
    public int CustomerId { get; set; }
    
    public Customer? Customer { get; set; }
}

或在导航属性上标注:

csharp
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    
    [ForeignKey("CustomerId")]  // 指定外键字段
    public Customer? Customer { get; set; }
}

方式 3: Fluent API(推荐) ​

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>(entity =>
    {
        // 配置外键
        entity.HasOne(o => o.Customer)
              .WithMany(c => c.Orders)
              .HasForeignKey(o => o.CustomerId)
              .HasConstraintName("FK_Orders_Customers"); // 自定义外键约束名
    });
}

导航属性类型 ​

1. 引用导航(Reference Navigation) ​

指向单个相关实体(多对一或一对一):

csharp
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    
    // 引用导航: 一个订单属于一个客户
    public Customer? Customer { get; set; }
}

2. 集合导航(Collection Navigation) ​

指向多个相关实体(一对多或多对多):

csharp
public class Customer
{
    public int Id { get; set; }
    
    // 集合导航: 一个客户有多个订单
    public ICollection<Order> Orders { get; set; } = new List<Order>();
}

推荐使用接口类型:

csharp
// ✅ 推荐: 使用 ICollection<T>
public ICollection<Order> Orders { get; set; } = new List<Order>();

// ⚠️ 可用: 使用 IEnumerable<T>(但不能直接添加元素)
public IEnumerable<Order> Orders { get; set; } = new List<Order>();

// ❌ 避免: 使用具体类型
public List<Order> Orders { get; set; } = new List<Order>();

外键与导航属性的组合 ​

场景 1: 完整配置(外键 + 两个导航) ​

csharp
public class Order
{
    public int Id { get; set; }
    
    // 外键属性
    public int CustomerId { get; set; }
    
    // 引用导航
    public Customer? Customer { get; set; }
}

public class Customer
{
    public int Id { get; set; }
    
    // 集合导航
    public ICollection<Order> Orders { get; set; } = new List<Order>();
}

// Fluent API
modelBuilder.Entity<Order>(entity =>
{
    entity.HasOne(o => o.Customer)      // Order 有一个 Customer
          .WithMany(c => c.Orders)      // Customer 有多个 Order
          .HasForeignKey(o => o.CustomerId);
});

场景 2: 只有导航,无外键属性 ​

csharp
public class Order
{
    public int Id { get; set; }
    
    // 没有 CustomerId 属性
    // EF Core 会创建影子外键(Shadow FK)
    public Customer? Customer { get; set; }
}

// Fluent API 配置影子外键
modelBuilder.Entity<Order>(entity =>
{
    entity.HasOne(o => o.Customer)
          .WithMany()
          .HasForeignKey("CustomerId"); // 影子外键
});

// 访问影子外键
var orderId = 1;
var order = context.Orders.Find(orderId);
var customerId = context.Entry(order).Property<int>("CustomerId").CurrentValue;

场景 3: 只有外键,无导航属性 ​

csharp
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    
    // 没有 Customer 导航属性
}

// Fluent API
modelBuilder.Entity<Order>(entity =>
{
    entity.HasOne<Customer>()  // 没有导航属性
          .WithMany()
          .HasForeignKey(o => o.CustomerId);
});

适用场景:

  • 只需要外键值,不需要加载完整对象
  • 减少内存占用
  • 提高性能

加载导航属性 ​

1. 预加载(Eager Loading) ​

使用 Include 一次性加载相关数据:

csharp
// 加载订单及其客户
var orders = await context.Orders
    .Include(o => o.Customer)
    .ToListAsync();

// 多层级加载
var customers = await context.Customers
    .Include(c => c.Orders)
        .ThenInclude(o => o.OrderItems)
            .ThenInclude(oi => oi.Product)
    .ToListAsync();

2. 显式加载(Explicit Loading) ​

按需加载相关数据:

csharp
var order = await context.Orders.FindAsync(1);

// 显式加载客户
await context.Entry(order)
    .Reference(o => o.Customer)
    .LoadAsync();

// 显式加载集合
await context.Entry(customer)
    .Collection(c => c.Orders)
    .LoadAsync();

3. 延迟加载(Lazy Loading) ​

首次访问时自动加载(需安装代理包):

bash
dotnet add package Microsoft.EntityFrameworkCore.Proxies
csharp
// 配置
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseLazyLoadingProxies()
           .UseSqlServer(connectionString);
});

// 使用
var order = await context.Orders.FindAsync(1);
var customer = order.Customer; // 自动加载(首次访问)

注意: ⚠️ 可能导致 N+1 查询问题,谨慎使用!


高级配置 ​

1. 复合外键 ​

csharp
public class OrderItem
{
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    
    public Order? Order { get; set; }
    public Product? Product { get; set; }
}

// Fluent API
modelBuilder.Entity<OrderItem>(entity =>
{
    entity.HasKey(oi => new { oi.OrderId, oi.ProductId });
    
    entity.HasOne(oi => oi.Order)
          .WithMany(o => o.Items)
          .HasForeignKey(oi => oi.OrderId);
    
    entity.HasOne(oi => oi.Product)
          .WithMany(p => p.OrderItems)
          .HasForeignKey(oi => oi.ProductId);
});

2. 可选关系(Nullable FK) ​

csharp
public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 可选: 员工可能没有经理
    public int? ManagerId { get; set; }
    public Employee? Manager { get; set; }
}

// Fluent API
modelBuilder.Entity<Employee>(entity =>
{
    entity.HasOne(e => e.Manager)
          .WithMany()
          .HasForeignKey(e => e.ManagerId)
          .IsRequired(false); // 可选
});

3. 自引用关系 ​

csharp
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 父分类
    public int? ParentId { get; set; }
    public Category? Parent { get; set; }
    
    // 子分类
    public ICollection<Category> Children { get; set; } = new List<Category>();
}

// Fluent API
modelBuilder.Entity<Category>(entity =>
{
    entity.HasOne(c => c.Parent)
          .WithMany(c => c.Children)
          .HasForeignKey(c => c.ParentId);
});

最佳实践 ​

✅ 推荐做法 ​

1. 始终初始化集合导航 ​

csharp
public class Customer
{
    public ICollection<Order> Orders { get; set; } = new List<Order>();
}

原因: 避免 NullReferenceException


2. 使用虚拟属性支持延迟加载(如需要) ​

csharp
public class Order
{
    public virtual Customer? Customer { get; set; }
}

3. 优先使用 Include 而非延迟加载 ​

csharp
// ✅ 推荐: 明确的预加载
var orders = await context.Orders
    .Include(o => o.Customer)
    .ToListAsync();

// ❌ 避免: 隐式的延迟加载(可能导致 N+1)
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
    var customer = order.Customer; // N 次额外查询!
}

❌ 避免的陷阱 ​

1. 循环引用导致序列化错误 ​

csharp
// ❌ JSON 序列化时会无限递归
public class Order
{
    public Customer? Customer { get; set; }
}

public class Customer
{
    public ICollection<Order> Orders { get; set; }
}

// ✅ 解决: 使用 DTO 或忽略循环引用
services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
    });

2. 忘记加载导航属性 ​

csharp
// ❌ 导航属性为 null
var order = await context.Orders.FindAsync(1);
var customer = order.Customer; // null!

// ✅ 使用 Include
var order = await context.Orders
    .Include(o => o.Customer)
    .FirstOrDefaultAsync(o => o.Id == 1);

3. 外键类型不匹配 ​

csharp
// ❌ 错误: 类型不一致
public class Order
{
    public Guid CustomerId { get; set; }  // Guid
    public Customer? Customer { get; set; }
}

public class Customer
{
    public int Id { get; set; }  // int - 类型不匹配!
}

// ✅ 正确: 类型一致
public Guid CustomerId { get; set; }
public Guid Id { get; set; }

总结 ​

核心要点 ​

概念说明示例
外键数据库中的关联字段CustomerId
导航属性代码中的对象引用Customer
引用导航指向单个实体public Customer? Customer
集合导航指向多个实体public ICollection<Order> Orders
影子外键不在实体中定义的外键.HasForeignKey("CustomerId")

配置优先级 ​

  1. Fluent API - 最强大灵活 ⭐⭐⭐⭐⭐
  2. 数据注解 - 简单直观 ⭐⭐⭐
  3. 约定配置 - 零配置 ⭐⭐

加载策略选择 ​

场景推荐方式
确定需要关联数据Include (预加载)
偶尔需要关联数据显式加载
不确定是否需要谨慎使用延迟加载
高性能要求AsNoTracking + 投影查询

掌握外键和导航属性,是熟练使用 EF Core 的基础! 🚀

基于 MIT 许可发布