Skip to content

一对多关系配置 ​

EF Core 中最常用的关系类型,完整配置指南

📖 概念理解 ​

什么是一对多关系? ​

一对多(One-to-Many) 是指一个父实体可以关联多个子实体,但每个子实体只能属于一个父实体。

Category (1) ←→ (*) Product

电子产品分类
  ├─ iPhone
  ├─ MacBook
  └─ iPad

书籍分类
  ├─ C# 入门
  └─ EF Core 详解

🏗️ 基础配置 ​

1. 约定配置(最简单) ​

EF Core 自动识别一对多关系:

csharp
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 导航属性(集合)
    public ICollection<Product> Products { get; set; } = new List<Product>();
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    
    // 外键(约定:<导航属性名>Id)
    public int CategoryId { get; set; }
    
    // 导航属性(引用)
    public Category Category { get; set; }
}

生成的数据库表:

sql
CREATE TABLE Categories (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Name NVARCHAR(MAX)
);

CREATE TABLE Products (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Name NVARCHAR(MAX),
    Price DECIMAL(18,2),
    CategoryId INT NOT NULL,
    FOREIGN KEY (CategoryId) REFERENCES Categories(Id)
);

2. Fluent API 配置(推荐) ​

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Category>(entity =>
    {
        entity.HasKey(e => e.Id);
        entity.Property(e => e.Name).IsRequired().HasMaxLength(50);
    });

    modelBuilder.Entity<Product>(entity =>
    {
        entity.HasKey(e => e.Id);
        entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
        entity.Property(e => e.Price).HasColumnType("decimal(18,2)");
        
        // 配置一对多关系
        entity.HasOne(e => e.Category)          // Product 有一个 Category
              .WithMany(c => c.Products)        // Category 有多个 Products
              .HasForeignKey(e => e.CategoryId) // 外键字段
              .OnDelete(DeleteBehavior.Cascade); // 级联删除行为
    });
}

3. 数据注解配置 ​

csharp
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    
    [ForeignKey("Category")]
    public int CategoryId { get; set; }
    
    public Category Category { get; set; }
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    public ICollection<Product> Products { get; set; } = new List<Product>();
}

🔧 高级配置 ​

1. 可选关系(外键可为空) ​

csharp
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 可空外键
    public int? CategoryId { get; set; }
    
    public Category Category { get; set; }
}

// Fluent API
modelBuilder.Entity<Product>(entity =>
{
    entity.HasOne(e => e.Category)
          .WithMany(c => c.Products)
          .HasForeignKey(e => e.CategoryId)
          .IsRequired(false); // 可选关系
});

生成的 SQL:

sql
CategoryId INT NULL -- 允许为空

2. 级联删除行为 ​

csharp
modelBuilder.Entity<Product>(entity =>
{
    entity.HasOne(e => e.Category)
          .WithMany(c => c.Products)
          .HasForeignKey(e => e.CategoryId)
          .OnDelete(DeleteBehavior.Cascade); // 配置删除行为
});

删除行为对比:

行为说明适用场景
Cascade删除父实体时自动删除所有子实体订单-订单项
Restrict如果有子实体,禁止删除父实体分类-产品
SetNull删除父实体时将子实体的外键设为 NULL用户-文章(作者删除后保留文章)
NoAction不执行任何操作(数据库默认)手动管理

Cascade(级联删除) ​

csharp
.OnDelete(DeleteBehavior.Cascade)

// 删除分类时自动删除所有产品
var category = await context.Categories
    .Include(c => c.Products)
    .FirstOrDefaultAsync(c => c.Id == categoryId);

context.Categories.Remove(category);
await context.SaveChangesAsync();
// Products 表中相关记录也会被删除

Restrict(限制删除) ​

csharp
.OnDelete(DeleteBehavior.Restrict)

// 如果分类下有产品,删除会失败
var category = await context.Categories.FindAsync(categoryId);
context.Categories.Remove(category);
await context.SaveChangesAsync();
// 抛出异常:The DELETE statement conflicted with the REFERENCE constraint

处理方式:

csharp
try
{
    context.Categories.Remove(category);
    await context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
    // 先处理或删除相关产品
    var products = await context.Products
        .Where(p => p.CategoryId == categoryId)
        .ToListAsync();
    
    context.Products.RemoveRange(products);
    await context.SaveChangesAsync();
    
    // 再删除分类
    context.Categories.Remove(category);
    await context.SaveChangesAsync();
}

SetNull(设为空) ​

csharp
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 必须是可空外键
    public int? CategoryId { get; set; }
    
    public Category Category { get; set; }
}

// 配置
.OnDelete(DeleteBehavior.SetNull)

// 删除分类时,产品的 CategoryId 变为 NULL
context.Categories.Remove(category);
await context.SaveChangesAsync();
// UPDATE Products SET CategoryId = NULL WHERE CategoryId = @id

3. 自定义外键名称 ​

csharp
modelBuilder.Entity<Product>(entity =>
{
    entity.HasOne(e => e.Category)
          .WithMany(c => c.Products)
          .HasForeignKey("CatId"); // 自定义外键字段名
});

// 或使用影子属性
entity.HasOne(e => e.Category)
      .WithMany(c => c.Products)
      .HasForeignKey("CategoryIdShadow");

4. 必需关系 vs 可选关系 ​

csharp
// 必需关系(外键 NOT NULL)
entity.HasOne(e => e.Category)
      .WithMany(c => c.Products)
      .HasForeignKey(e => e.CategoryId)
      .IsRequired(); // 默认值

// 可选关系(外键 NULL)
entity.HasOne(e => e.Category)
      .WithMany(c => c.Products)
      .HasForeignKey(e => e.CategoryId)
      .IsRequired(false);

💻 使用示例 ​

1. 查询关联数据 ​

csharp
// Include - 预加载相关产品
var category = await context.Categories
    .Include(c => c.Products)
    .FirstOrDefaultAsync(c => c.Id == categoryId);

Console.WriteLine($"Category: {category.Name}");
foreach (var product in category.Products)
{
    Console.WriteLine($"  - {product.Name}: ${product.Price}");
}

生成的 SQL:

sql
-- 查询 1: Categories
SELECT * FROM Categories WHERE Id = @id

-- 查询 2: Products
SELECT * FROM Products WHERE CategoryId = @id

2. 过滤 Include ​

csharp
// EF Core 5.0+ 支持
var category = await context.Categories
    .Include(c => c.Products.Where(p => p.IsActive && p.Price > 100))
    .FirstOrDefaultAsync(c => c.Id == categoryId);

// 只加载激活且价格大于 100 的产品

3. 添加关联实体 ​

csharp
// 方式 1:先查询分类,再添加产品
var category = await context.Categories.FindAsync(categoryId);
var product = new Product 
{ 
    Name = "Laptop", 
    Price = 999.99m,
    CategoryId = category.Id // 设置外键
};

context.Products.Add(product);
await context.SaveChangesAsync();

// 方式 2:通过导航属性
var category = await context.Categories
    .Include(c => c.Products)
    .FirstOrDefaultAsync(c => c.Id == categoryId);

category.Products.Add(new Product 
{ 
    Name = "Mouse", 
    Price = 29.99m 
});

await context.SaveChangesAsync();

4. 移除关联 ​

csharp
// 方式 1:修改外键
var product = await context.Products.FindAsync(productId);
product.CategoryId = null; // 或改为其他分类 ID
await context.SaveChangesAsync();

// 方式 2:从导航属性中移除
var category = await context.Categories
    .Include(c => c.Products)
    .FirstOrDefaultAsync(c => c.Id == categoryId);

var product = category.Products.FirstOrDefault(p => p.Id == productId);
if (product != null)
{
    category.Products.Remove(product);
    await context.SaveChangesAsync();
}

5. 统计查询 ​

csharp
// 每个分类的产品数量
var categoryStats = await context.Categories
    .Select(c => new
    {
        c.Id,
        c.Name,
        ProductCount = c.Products.Count(),
        TotalValue = c.Products.Sum(p => p.Price),
        AvgPrice = c.Products.Average(p => p.Price)
    })
    .ToListAsync();

foreach (var stat in categoryStats)
{
    Console.WriteLine($"{stat.Name}: {stat.ProductCount} products, Avg: ${stat.AvgPrice:F2}");
}

生成的 SQL:

sql
SELECT 
    [c].[Id], 
    [c].[Name],
    COUNT([p].[Id]) AS [ProductCount],
    SUM([p].[Price]) AS [TotalValue],
    AVG([p].[Price]) AS [AvgPrice]
FROM [Categories] AS [c]
LEFT JOIN [Products] AS [p] ON [c].[Id] = [p].[CategoryId]
GROUP BY [c].[Id], [c].[Name]

🎯 实际应用场景 ​

场景 1:博客系统(分类-文章) ​

csharp
public class BlogCategory
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Slug { get; set; } // URL 友好名称
    
    public ICollection<BlogPost> Posts { get; set; } = new List<BlogPost>();
}

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedAt { get; set; }
    
    public int CategoryId { get; set; }
    public BlogCategory Category { get; set; }
}

// 查询某个分类下的所有文章
var category = await context.BlogCategories
    .Include(c => c.Posts.Where(p => p.PublishedAt <= DateTime.UtcNow))
    .OrderByDescending(p => p.PublishedAt)
    .FirstOrDefaultAsync(c => c.Slug == "technology");

场景 2:电商系统(订单-订单项) ​

csharp
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public DateTime OrderDate { get; set; }
    public decimal TotalAmount { get; set; }
    
    public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}

public class OrderItem
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    
    public Order Order { get; set; }
    public Product Product { get; set; }
}

// 配置级联删除
modelBuilder.Entity<Order>(entity =>
{
    entity.HasMany(o => o.Items)
          .WithOne(i => i.Order)
          .HasForeignKey(i => i.OrderId)
          .OnDelete(DeleteBehavior.Cascade); // 删除订单时自动删除订单项
});

场景 3:组织架构(部门-员工) ​

csharp
public class Department
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    public ICollection<Employee> Employees { get; set; } = new List<Employee>();
}

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Position { get; set; }
    
    public int? DepartmentId { get; set; } // 可空(允许未分配部门)
    public Department Department { get; set; }
}

// 配置可选关系
modelBuilder.Entity<Employee>(entity =>
{
    entity.HasOne(e => e.Department)
          .WithMany(d => d.Employees)
          .HasForeignKey(e => e.DepartmentId)
          .IsRequired(false)
          .OnDelete(DeleteBehavior.SetNull); // 删除部门时员工部门设为 NULL
});

⚠️ 常见陷阱 ​

1. N+1 查询问题 ​

csharp
// ❌ 错误:N+1 查询
var categories = await context.Categories.ToListAsync();
foreach (var category in categories)
{
    var products = await context.Products
        .Where(p => p.CategoryId == category.Id)
        .ToListAsync(); // 每次循环都查询数据库!
}

// ✅ 正确:使用 Include
var categories = await context.Categories
    .Include(c => c.Products)
    .ToListAsync();

// ✅ 更好:投影查询
var result = await context.Categories
    .Select(c => new
    {
        c.Name,
        Products = c.Products.Select(p => new { p.Name, p.Price }).ToList()
    })
    .ToListAsync();

2. 忘记初始化集合 ​

csharp
// ❌ 错误
public class Category
{
    public ICollection<Product> Products { get; set; } // null
}

var category = new Category();
category.Products.Add(product); // NullReferenceException!

// ✅ 正确
public ICollection<Product> Products { get; set; } = new List<Product>();

3. 循环引用序列化问题 ​

csharp
// JSON 序列化时会循环引用
var category = await context.Categories
    .Include(c => c.Products)
    .FirstOrDefaultAsync();

return Json(category); 
// Category → Products → Category → Products ...

// 解决 1:使用 DTO
var dto = new CategoryDto
{
    Id = category.Id,
    Name = category.Name,
    Products = category.Products.Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name
    }).ToList()
};

// 解决 2:配置序列化选项
builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
    });

💡 最佳实践 ​

1. 始终初始化集合属性 ​

csharp
public ICollection<Product> Products { get; set; } = new List<Product>();

2. 使用虚拟属性谨慎 ​

csharp
// ❌ 不推荐:延迟加载性能差
public virtual ICollection<Product> Products { get; set; }

// ✅ 推荐:显式使用 Include
var category = await context.Categories
    .Include(c => c.Products)
    .FirstOrDefaultAsync();

3. 合理选择级联删除 ​

csharp
// 订单-订单项:Cascade(必须一起删除)
.OnDelete(DeleteBehavior.Cascade)

// 分类-产品:Restrict(防止误删)
.OnDelete(DeleteBehavior.Restrict)

// 作者-文章:SetNull(保留文章)
.OnDelete(DeleteBehavior.SetNull)

4. 使用索引优化查询 ​

csharp
modelBuilder.Entity<Product>(entity =>
{
    // 为外键添加索引
    entity.HasIndex(e => e.CategoryId);
});

📊 性能优化 ​

1. 避免加载不必要的关联数据 ​

csharp
// ❌ 不需要产品数据时不要 Include
var categories = await context.Categories.ToListAsync();

// ✅ 只需要分类名称
var categoryNames = await context.Categories
    .Select(c => c.Name)
    .ToListAsync();

2. 分页加载关联数据 ​

csharp
var category = await context.Categories
    .Include(c => c.Products
        .OrderByDescending(p => p.CreatedAt)
        .Take(10)) // 只加载最新 10 个产品
    .FirstOrDefaultAsync(c => c.Id == categoryId);

3. 使用 AsNoTracking ​

csharp
// 只读查询
var categories = await context.Categories
    .AsNoTracking()
    .Include(c => c.Products)
    .ToListAsync();

📚 延伸阅读 ​


💡 小结 ​

关键点:

  • ✅ EF Core 自动识别一对多关系(遵循约定)
  • ✅ 使用 Fluent API 进行高级配置
  • ✅ 合理选择级联删除行为
  • ✅ 避免 N+1 查询问题
  • ✅ 始终初始化集合属性

下一步:

  1. 学习 多对多关系配置
  2. 掌握 级联删除行为
  3. 理解 外键与导航属性

基于 MIT 许可发布