Entity Framework 6 及 Entity Framework Core 实现多对多关系

15 Feb 2017 , 6685 words

Relational Database 中,实现多对多 (many to many) 主要靠额外添加一个记录对应关系的表格实现。

many2many model

上边的模型里,一个学生可能同时在修很多门课,一门课里同时会有很多学生,是典型的多对多关系。这时候要靠「学生 (Student)」和「课程 (Course)」两个表格来记录哪个学生在哪门课中,就要额外再添加一个表格。也就是上边例子里的「注册 (Enrollment)」。

这时候如果学生 A 注册了 1, 2 这两门课程,学生 B 注册了 2, 3 这两门课程,记录这种多对多关系的「注册 (Enrollment)」表格中就有四条记录:

Enrollment  
Student Course
A 1
A 2
B 2
B 3

这个表格把一个复杂的「多对多」关系变成了两个简单点的「一对多」关系。 比如学生 A 的 Courses 属性 (1),对应的就是 Enrollment 中的 A1 和 A2 这两行 (多)。

Entity Framework

在 Entity Framework 中。建立 many to many 关系非常简单,可以通过以下三种方式。这里仅考虑 Code First 方法,实际上在新版本 EF Core 中,Designer 功能甚至被拿掉了, Code First 是首选的使用方式:

1. 不明确添加 FK,交给 EF

public  class CollegeDb : DbContext
{
    public DbSet<Student> Students { get; set; }
    public DbSet<Course> Courses { get; set; }
}

public class Student
{
    public int Id { get; set; }
    public string LastName { get; set; }
    public string GivenName {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; }
}

只需要在 CollegeDB 中将学生和课程添加为 DbSet 类型的属性,EF 就会自动判断出他们之间多对多的关系,并建立第三个表格。

这里不管是 Student 还是 Course 都没有添加 Foreign Key。但 EF 还是需要记录他们之间的关系的,所以它会自动生成 FK 给它们 (一般命名为 Course_IdStudentId)」。

2. 指定 FK

明确指定 FK 的方法是在 Student 类里多加一个 CourseId 属性;同样,在 Course 类里,可以多加一个 StudentId 属性。这样,就不再是像前边一样依赖 EF 自己添加的 xx_Id。具体来说,当 EF 看到 Student 类中有 x 属性 (如 Courses ),它就会自动认定 xId (如 CourseId) 属性为 FK。对了, EF 中类似自动匹配都不区分大小写。

指定 FK 是一个良好的习惯,具体原因回头另写一篇吧。

这里说下用 annotation 和 Fluent API 进一步明确和细化 EF 映射产生数据库的规则。

Data Annotation 修饰

public  class CollegeDb : DbContext
{
    public DbSet<Student> Students { get; set; }
    public DbSet<Course> Courses { get; set; }
}

public class Student
{
    public int Id { get; set; }
    public string LastName { get; set; }
    public string GivenName {get; set;}
    //多个课程
    public int CourseId { get; set; }
    [Foreign Key("CourseId")] //非必需
    public ICollection<Course> Courses { get; set; }
}

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; }
    //多个学生
    public int StudentId { get; set; }
    [Foreign Key("StudentId")] //非必需
    public ICollection<Student> Students { get; set; }
}

可以看到这段代码里多了 [Foreign Key("...")] 这样的 annotation,添加在某些属性之前。Data Annotation 就是通过在属性上边的添加说明,让最终数据库的样子比 EF 自动生成的更符合自己要求,如指定 Key、自定义 Table/Column 名字、限定某个 Column 里数据长度等等。具体到多对多关系实现上,用到的 annotation 是 [Foreign Key]。顾名思义,它就是标记哪个属性是 FK 的。

比如

public class Student
{
    ...

    public int CourseId { get; set; }
    [Foreign Key("CourseId")]
    public ICollection<Course> Courses { get; set; }
}

的意思就是:Student 类中的 Courses 属性,也就是指向 Course 表格的关系,实际指向的是 Course 中的 Id 一栏,在 Student 这里就记作 CourseId

既然前边说,EF 可以自动识别 CourseId 为 FK,那为什么有的时候还要添加 [Foreign Key("CourseId")] 这样的 annotation 来再次声明?这是因为有的时候 EF 会过于死板。

比如

public class KiwiProfessor
{
    ...

    public int PaperId { get; set; }
    //[Foreign Key("PaperId")]
    public ICollection<Course> Papers { get; set; }
}

KiwiProfessor 们喜欢称呼一门课为一个 Paper,而不是 Course。于是他们的属性中有 Papers (当然使用的还是 Course 类),同时添加了 PaperId 作为 FK。然而在实际使用中,EF 并不会将 PaperId 当成 FK,而是会严格使用 Course 另外建立 一个 Paper_CourseId 作为 FK,多此一举。

这时候使用注释中的 annotation 就能制定直接使用 PaperId 作为 FK。

指定 FK + Fluent API

Fluent API 与 Data Annotation 相比更加灵活强大,它可以在某些情况下更好的隐藏 model,可以指定数据库删除数据时如何处理另一个表格上关联的数据等等……

也有人认为 Fluent API 更加干净,因为它是直接在程序 DbContext 的类里写的规则,指定 DbContext 所对应的数据库的 schema。这样一来, Student、Course 这些类就只是 POCO 了。

public  class CollegeDb : DbContext
{
    public DbSet<Student> Students { get; set; }
    public DbSet<Course> Courses { get; set; }
    //这里是关键
    protected override void OnModelCreating(ModelBuilder modelBuilder){
        modelBuilder.Entity<Student>().HasMany(s => s.Courses).WithMany();
    }
}

//下边与前边代码一样,也可以与 Data Annotation 混用
public class Student
{
    ...
}

public class Course
{
    ...
}

Fluent API 中的 lambda 表达式很重要,它指明了哪个属性是 navigation property,尤其是在下边情况中:

假如研究生们还有一个属性 CoursesTutoring,记录他们担任助教的课程 (一门课只有一个助教)。

public class GraduateStudent
{
    ...

    public ICollection<Course> CoursesEnrolled { get; set; } //同时上多门课
    public ICollection<Course> CoursesTutoring { get; set; } //同时担任多门课的助教
}

学生与课程之间就有了两个关系: 一个多对多关系,一个一对多关系。

这个时候, Fluent API 里边 modelBuilder.Entity<Student>().HasMany(s => s.CoursesEnrolled).WithMany(); 里边的 HasMany(s => s.CoursesEnrolled) 就很有必要了,他指明了这个多对多关系是在说 CoursesEnrolled

Entity Framework Core

EF Core 截止到今天都还不支持自动建立多对多关系。而是必须通过手动的添加第三个表格 (join table) 来实现。

Join table 中应该有四个属性,其中两个是关系两头的 PK,共同组成 join table 的 PK。同时,这两个 PK 又分别是其中一个关系的 FK。 另外两个属性,就是 navigation property 了:

public class StudentCourse{

    public int StudentId { get; set; } //PK, FK
    public int CourseId { get; set; } //PK, FK
    public Student Student { get; set; } // navigation property
    public Course Course { get; set; } // navigation property
}

相应的,在 StudentCourse 中的属性都要调整一下,navigation property 都不再是直接指向对方,而是指向中间的 StudentCourse。否则 EF Core 会懵圈:

public class Student
{
    public int Id { get; set; }
    public string LastName { get; set; }
    public string GivenName {get; set;}

    public ICollection<StudentCourse> StudentCourses { get; set; } //指向 join table
}

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; }
    
    public ICollection<StudentCourse> StudentCourses { get; set; } //指向 join table
}

最后一步,就是使用 Fluent API 明确我们手动添加的 StudentCourseStudentCourse 之间的关系:

public  class CollegeDb : DbContext
{
    public CollegeDb(DbContextOptions options) : base(options)
    {
    //constructor, 在 EF Core 中最好加上。但里边不需要有任何内容,直接继承 base(options) 即可
    }
    
    public DbSet<Student> Students { get; set; }
    public DbSet<Course> Courses { get; set; }
    public DbSet<StudentCourse> StudentCourses { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<StudentCourse>().HasKey(sc => new { sc.StudentId, sc.CourseId }); // 指明用 StudentId 和 CourseId 做联合 PK
    
        //建立与 Student 间的一对多关系
        modelBuilder.Entity<StudentCourse>()
            .HasOne(sc => sc.Student)
            .WithMany(s => s.StudentCourses)
            .HasForeignKey(sc => sc.StudentId);
    
        //建立与 Course 间的一对多关系
        modelBuilder.Entity<StudentCourse>()
            .HasOne(sc => sc.Course)
            .WithMany(c => c.StudentCourses)
            .HasForeignKey(sc => sc.CourseId);
    }
}

由于 EF Core 似乎还不支持通过 Data Annotation 指明联合 PK (composite primary key)。所以这里只能采用 Fluent API。

同时 EF Core 中最好给 DbContext 加一个以 DbContextOptions 为参数的 constructor。如果不加的话,EF Core 有时会搞不清楚你的 Server Provider 等信息在 build 时报错。