Appearance
一对多关系配置
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 = @id3. 自定义外键名称
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 = @id2. 过滤 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 查询问题
- ✅ 始终初始化集合属性
下一步: