Skip to content

多对多关系配置 Many-to-Many ​

目录 ​


多对多关系基础 ​

概念理解 ​

多对多关系(Many-to-Many) 是指两个实体集合之间可以相互关联多个实例的关系。

csharp
public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 一个学生可以选修多门课程
    public ICollection<Course> Courses { get; set; }
}

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    // 一门课程可以被多个学生选修
    public ICollection<Student> Students { get; set; }
}

// 关系:
// Student (*) ←→ (*) Course
// 一个学生可以选多门课
// 一门课可以有多个学生

数据库实现 ​

传统方式: 需要联接表(Junction Table) ​

sql
-- Students 表
CREATE TABLE Students (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Name NVARCHAR(100) NOT NULL
);

-- Courses 表
CREATE TABLE Courses (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Title NVARCHAR(200) NOT NULL
);

-- 联接表(StudentCourses)
CREATE TABLE StudentCourses (
    StudentId INT NOT NULL,
    CourseId INT NOT NULL,
    
    -- 复合主键
    CONSTRAINT PK_StudentCourses PRIMARY KEY (StudentId, CourseId),
    
    -- 外键约束
    CONSTRAINT FK_StudentCourses_Students 
        FOREIGN KEY (StudentId) REFERENCES Students(Id) ON DELETE CASCADE,
    CONSTRAINT FK_StudentCourses_Courses 
        FOREIGN KEY (CourseId) REFERENCES Courses(Id) ON DELETE CASCADE
);

-- 唯一索引(防止重复选课)
CREATE UNIQUE INDEX IX_StudentCourses_Student_Course 
ON StudentCourses (StudentId, CourseId);

示例数据:

Students:
| Id | Name   |
|----|--------|
| 1  | Alice  |
| 2  | Bob    |

Courses:
| Id | Title      |
|----|------------|
| 1  | Math       |
| 2  | Physics    |

StudentCourses (联接表):
| StudentId | CourseId |
|-----------|----------|
| 1         | 1        |  ← Alice 选修 Math
| 1         | 2        |  ← Alice 选修 Physics
| 2         | 1        |  ← Bob 选修 Math

.NET 5+ 隐式联接表 ​

自动管理联接表 ​

.NET 5+ / EF Core 5.0+ 引入了简化的多对多配置,无需显式定义联接表实体。

csharp
public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<Course> Courses { get; set; } = new List<Course>();
}

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; }
    public ICollection<Student> Students { get; set; } = new List<Student>();
}

// 配置(最简单的方式)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Student>()
        .HasMany(s => s.Courses)
        .WithMany(c => c.Students);
}

// EF Core 自动创建和管理联接表!
// 表名: CourseStudent (按字母顺序)
// 列名: CoursesId, StudentsId

自定义联接表名称 ​

csharp
modelBuilder.Entity<Student>()
    .HasMany(s => s.Courses)
    .WithMany(c => c.Students)
    .UsingEntity(j => j.ToTable("Enrollments"));  // 自定义表名

// 生成的表: Enrollments(StudentId, CourseId)

自定义联接表列名 ​

csharp
modelBuilder.Entity<Student>()
    .HasMany(s => s.Courses)
    .WithMany(c => c.Students)
    .UsingEntity<Dictionary<string, object>>(
        "Enrollment",  // 表名
        j => j.HasOne<Course>().WithMany().HasForeignKey("CourseId"),
        j => j.HasOne<Student>().WithMany().HasForeignKey("StudentId")
    );

// 生成的表: Enrollment(StudentId, CourseId)

基本 CRUD 操作 ​

创建关系 ​

csharp
// 方式 1: 添加到导航集合
var student = await context.Students.FindAsync(1);
var course = await context.Courses.FindAsync(1);

student.Courses.Add(course);
await context.SaveChangesAsync();

// SQL:
// INSERT INTO StudentCourses (StudentId, CourseId) VALUES (1, 1);

查询关系 ​

csharp
// 查询学生及其课程
var student = await context.Students
    .Include(s => s.Courses)
    .FirstOrDefaultAsync(s => s.Id == 1);

foreach (var course in student.Courses)
{
    Console.WriteLine($"{student.Name} enrolled in {course.Title}");
}

// 查询课程及其学生
var course = await context.Courses
    .Include(c => c.Students)
    .FirstOrDefaultAsync(c => c.Id == 1);

foreach (var student in course.Students)
{
    Console.WriteLine($"{student.Name} is in {course.Title}");
}

删除关系 ​

csharp
// 取消选课
var student = await context.Students
    .Include(s => s.Courses)
    .FirstOrDefaultAsync(s => s.Id == 1);

var course = student.Courses.FirstOrDefault(c => c.Id == 1);
if (course != null)
{
    student.Courses.Remove(course);
    await context.SaveChangesAsync();
}

// SQL:
// DELETE FROM StudentCourses WHERE StudentId = 1 AND CourseId = 1;

显式联接表配置 ​

为什么需要显式联接表? ​

虽然隐式联接表很方便,但以下场景需要显式定义:

✅ 联接表有额外字段(如 enrolment date, grade)
✅ 需要直接查询联接表
✅ 需要对联接表应用过滤器
✅ 复杂的业务逻辑

完整示例: 带成绩的学生选课系统 ​

csharp
// 显式定义联接表实体
public class Enrollment
{
    public int StudentId { get; set; }
    public int CourseId { get; set; }
    
    // 额外字段
    public DateTime EnrollmentDate { get; set; }
    public decimal? Grade { get; set; }
    public string Status { get; set; }  // Active, Dropped, Completed
    
    // 导航属性
    public Student Student { get; set; }
    public Course Course { get; set; }
}

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    // 通过联接表访问课程
    public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
    
    // 便捷属性: 直接获取课程
    public ICollection<Course> Courses 
        => Enrollments.Select(e => e.Course).ToList();
}

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; }
    public int Credits { get; set; }
    
    public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
    
    public ICollection<Student> Students 
        => Enrollments.Select(e => e.Student).ToList();
}

// 配置
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 配置联接表
    modelBuilder.Entity<Enrollment>()
        .HasKey(e => new { e.StudentId, e.CourseId });  // 复合主键
    
    modelBuilder.Entity<Enrollment>()
        .HasOne(e => e.Student)
        .WithMany(s => s.Enrollments)
        .HasForeignKey(e => e.StudentId);
    
    modelBuilder.Entity<Enrollment>()
        .HasOne(e => e.Course)
        .WithMany(c => c.Enrollments)
        .HasForeignKey(e => e.CourseId);
}

CRUD 操作(显式联接表) ​

创建关系 ​

csharp
// 选课并记录成绩
var enrollment = new Enrollment
{
    StudentId = 1,
    CourseId = 1,
    EnrollmentDate = DateTime.UtcNow,
    Status = "Active"
};

context.Enrollments.Add(enrollment);
await context.SaveChangesAsync();

// 或者通过导航属性
var student = await context.Students.FindAsync(1);
student.Enrollments.Add(new Enrollment
{
    CourseId = 2,
    EnrollmentDate = DateTime.UtcNow,
    Status = "Active"
});

await context.SaveChangesAsync();

查询关系 ​

csharp
// 查询学生的所有课程及成绩
var enrollments = await context.Enrollments
    .Include(e => e.Student)
    .Include(e => e.Course)
    .Where(e => e.StudentId == 1)
    .ToListAsync();

foreach (var enrollment in enrollments)
{
    Console.WriteLine($"{enrollment.Student.Name} - {enrollment.Course.Title}: {enrollment.Grade}");
}

// 查询某课程的所有学生
var students = await context.Enrollments
    .Include(e => e.Student)
    .Include(e => e.Course)
    .Where(e => e.CourseId == 1 && e.Status == "Active")
    .Select(e => e.Student)
    .ToListAsync();

更新关系 ​

csharp
// 更新成绩
var enrollment = await context.Enrollments
    .FirstOrDefaultAsync(e => e.StudentId == 1 && e.CourseId == 1);

if (enrollment != null)
{
    enrollment.Grade = 95.5m;
    enrollment.Status = "Completed";
    await context.SaveChangesAsync();
}

删除关系 ​

csharp
// 退课
var enrollment = await context.Enrollments
    .FirstOrDefaultAsync(e => e.StudentId == 1 && e.CourseId == 1);

if (enrollment != null)
{
    context.Enrollments.Remove(enrollment);
    await context.SaveChangesAsync();
}

高级查询与操作 ​

复杂查询 ​

1. 过滤查询 ​

csharp
// 查询学生已完成的课程
var completedCourses = await context.Students
    .Where(s => s.Id == 1)
    .SelectMany(s => s.Enrollments
        .Where(e => e.Status == "Completed")
        .Select(e => e.Course))
    .ToListAsync();

// 查询 GPA > 3.0 的学生
var highGpaStudents = await context.Enrollments
    .GroupBy(e => e.StudentId)
    .Where(g => g.Average(e => e.Grade) > 80)
    .Select(g => g.Key)
    .ToListAsync();

2. 统计查询 ​

csharp
// 每个学生的选课数
var courseCounts = await context.Students
    .Select(s => new 
    {
        s.Name,
        CourseCount = s.Enrollments.Count()
    })
    .ToListAsync();

// 每门课的选课人数
var enrollmentStats = await context.Courses
    .Select(c => new 
    {
        c.Title,
        StudentCount = c.Enrollments.Count(e => e.Status == "Active"),
        AverageGrade = c.Enrollments.Average(e => e.Grade)
    })
    .ToListAsync();

3. 分页查询 ​

csharp
// 查询学生的课程(分页)
var studentId = 1;
var pageNumber = 1;
var pageSize = 10;

var courses = await context.Enrollments
    .Where(e => e.StudentId == studentId)
    .OrderByDescending(e => e.EnrollmentDate)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .Include(e => e.Course)
    .Select(e => e.Course)
    .ToListAsync();

var totalCount = await context.Enrollments
    .CountAsync(e => e.StudentId == studentId);

4. 批量操作 ​

csharp
// 批量更新成绩(EF Core 7+)
await context.Enrollments
    .Where(e => e.CourseId == 1 && e.Status == "Completed")
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(e => e.Grade, e => e.Grade * 1.1m));  // 加分 10%

// 批量退课
await context.Enrollments
    .Where(e => e.StudentId == 1 && e.Status == "Active")
    .ExecuteDeleteAsync();

性能优化 ​

1. 避免 N+1 查询 ​

csharp
// ❌ 错误: N+1 查询
var students = await context.Students.ToListAsync();
foreach (var student in students)
{
    var courses = await context.Courses  // 💥 每次循环都查询
        .Where(c => c.Students.Any(s => s.Id == student.Id))
        .ToListAsync();
}

// ✅ 正确: Include 预加载
var students = await context.Students
    .Include(s => s.Enrollments)
        .ThenInclude(e => e.Course)
    .ToListAsync();

2. 使用投影查询 ​

csharp
// ✅✅ 最佳: 投影查询
var studentCourses = await context.Students
    .Select(s => new 
    {
        s.Name,
        Courses = s.Enrollments.Select(e => new 
        {
            e.Course.Title,
            e.Grade,
            e.Status
        }).ToList()
    })
    .ToListAsync();

3. 为外键创建索引 ​

csharp
modelBuilder.Entity<Enrollment>()
    .HasIndex(e => e.StudentId);

modelBuilder.Entity<Enrollment>()
    .HasIndex(e => e.CourseId);

// 提升 JOIN 查询性能

.NET 8/9/10 新特性 ​

.NET 8: 改进的多对多性能 ​

csharp
// .NET 8 优化了多对多查询的 SQL 生成
// 对于隐式联接表,查询性能提升 20-30%

var students = await context.Students
    .Include(s => s.Courses)
    .ToListAsync();

// .NET 8 生成更优化的 JOIN 语句

.NET 9: 增强的联接表配置 ​

csharp
// .NET 9 支持更灵活的联接表配置
modelBuilder.Entity<Student>()
    .HasMany(s => s.Courses)
    .WithMany(c => c.Students)
    .UsingEntity(j => 
    {
        j.ToTable("Enrollments");
        j.Property<DateTime>("CreatedAt").HasDefaultValueSql("GETUTCDATE()");
        j.HasIndex(new[] { "StudentId" }).IsDescending();
    });

.NET 10: 智能多对多优化(路线图) ​

预计特性:

  • 自动检测多对多性能问题
  • 基于访问模式的加载策略优化
  • 多对多关系可视化分析工具

最佳实践与陷阱 ​

最佳实践 ​

1. 选择合适的实现方式 ​

隐式联接表 vs 显式联接表:

选择隐式如果:
✓ 联接表只有外键,无额外字段
✓ 简单的多对多关系
✓ 快速原型开发

选择显式如果:
✓ 联接表有额外字段(时间、状态、分数等)
✓ 需要直接查询联接表
✓ 复杂的业务逻辑
✓ 需要审计跟踪

2. 初始化集合属性 ​

csharp
// ✅ 推荐: 始终初始化集合
public class Student
{
    public ICollection<Course> Courses { get; set; } = new List<Course>();
}

// ❌ 避免: null 集合导致异常
public class Student
{
    public ICollection<Course> Courses { get; set; }  // 可能是 null
}

3. 防止重复关系 ​

csharp
// ✅ 推荐: 检查是否已存在
public async Task EnrollStudentAsync(int studentId, int courseId)
{
    var exists = await context.Enrollments
        .AnyAsync(e => e.StudentId == studentId && e.CourseId == courseId);
    
    if (exists)
        throw new InvalidOperationException("Student already enrolled");
    
    context.Enrollments.Add(new Enrollment 
    { 
        StudentId = studentId, 
        CourseId = courseId,
        EnrollmentDate = DateTime.UtcNow 
    });
    
    await context.SaveChangesAsync();
}

4. 使用事务保证一致性 ​

csharp
using var transaction = await context.Database.BeginTransactionAsync();

try
{
    // 多个相关操作
    context.Enrollments.Add(enrollment1);
    context.Enrollments.Add(enrollment2);
    await context.SaveChangesAsync();
    
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

常见陷阱 ​

陷阱 1: 循环引用序列化 ​

csharp
// ❌ 错误: JSON 序列化循环引用
var student = await context.Students
    .Include(s => s.Courses)
    .FirstOrDefaultAsync(s => s.Id == 1);

return Results.Json(student);  
// 💥 Student → Courses → Students → ...

// ✅ 解决: 使用 DTO
var dto = new StudentDto
{
    Id = student.Id,
    Name = student.Name,
    Courses = student.Courses.Select(c => new CourseDto 
    { 
        Id = c.Id, 
        Title = c.Title 
    }).ToList()
};
return Results.Json(dto);

陷阱 2: 忘记 Include ​

csharp
// ❌ 错误: 访问未加载的集合
var student = await context.Students.FindAsync(1);
var count = student.Courses.Count;  // 💥 总是 0(未加载)

// ✅ 正确: Include 加载
var student = await context.Students
    .Include(s => s.Courses)
    .FirstOrDefaultAsync(s => s.Id == 1);

陷阱 3: 低效的查询 ​

csharp
// ❌ 错误: 多次数据库往返
var student = await context.Students.FindAsync(1);
var courses = await context.Courses
    .Where(c => c.Students.Any(s => s.Id == 1))
    .ToListAsync();

// ✅ 正确: 单次查询
var student = await context.Students
    .Include(s => s.Courses)
    .FirstOrDefaultAsync(s => s.Id == 1);

陷阱 4: 级联删除冲突 ​

csharp
// ❌ 错误: 双向 Cascade 导致冲突
modelBuilder.Entity<Enrollment>()
    .HasOne(e => e.Student)
    .WithMany(s => s.Enrollments)
    .HasForeignKey(e => e.StudentId)
    .OnDelete(DeleteBehavior.Cascade);  // 💥 可能与另一端冲突

modelBuilder.Entity<Enrollment>()
    .HasOne(e => e.Course)
    .WithMany(c => c.Enrollments)
    .HasForeignKey(e => e.CourseId)
    .OnDelete(DeleteBehavior.Cascade);  // 💥 循环级联!

// ✅ 解决: 一端 Cascade,另一端 Restrict
modelBuilder.Entity<Enrollment>()
    .HasOne(e => e.Student)
    .WithMany(s => s.Enrollments)
    .HasForeignKey(e => e.StudentId)
    .OnDelete(DeleteBehavior.Cascade);

modelBuilder.Entity<Enrollment>()
    .HasOne(e => e.Course)
    .WithMany(c => c.Enrollments)
    .HasForeignKey(e => e.CourseId)
    .OnDelete(DeleteBehavior.Restrict);

总结 ​

核心要点 ​

  1. 隐式联接表: .NET 5+ 简化语法,适合简单场景
  2. 显式联接表: 支持额外字段,灵活可控
  3. 复合主键: 联接表通常使用复合主键
  4. 性能优化: Include 预加载,避免 N+1
  5. 防止重复: 添加唯一约束或业务层检查

实现方式对比 ​

特性隐式联接表显式联接表
配置复杂度简单中等
额外字段❌ 不支持✅ 支持
直接查询❌ 困难✅ 容易
灵活性低高
适用场景简单关联复杂业务

代码模板 ​

csharp
// 模板 1: 隐式联接表(.NET 5+)
modelBuilder.Entity<EntityA>()
    .HasMany(a => a.EntitiesB)
    .WithMany(b => b.EntitiesA)
    .UsingEntity(j => j.ToTable("JoinTableName"));

// 模板 2: 显式联接表
public class JoinEntity
{
    public int EntityAId { get; set; }
    public int EntityBId { get; set; }
    // 额外字段...
    public EntityA EntityA { get; set; }
    public EntityB EntityB { get; set; }
}

modelBuilder.Entity<JoinEntity>()
    .HasKey(j => new { j.EntityAId, j.EntityBId });

下一步 ​

基于 MIT 许可发布