原文 Contoso 大学 - 4 - 创建更加复杂的数据模型
全文目录:Contoso 大学 - 使用 EF Code First 创建 MVC 应用
在前面的课程中,你已经创建了一个简单的由三个实体组成的数据模型。在这个课程中,你将要增加更多的实体,以及关系,使用数据标注特性来控制模型类的行为。
在完成的时候,实体类表示的完整数据模型如下所示:
在这一节中,你将会看到如何使用特性来控制数据模型的格式化、验证以及数据库映射。然后在后继的节中,将要通过为已经创建的类、新创建的类增加特性,来创建完整的 School 数据模型。
对于学生的注册日期来说,虽然你只关心注册的日期,但是现在的页面在日期之后还显示了时间。通过使用数据标注特性,可以通过一点代码就可以在所有的地方修补这个问题。看一下示例,你就可以为 Student 类的 EnrollmentDate 属性增加一个特性了。
在 Models\Student.cs ,在开始部分为命名空间 System.ComponentModel.DataAnnotations 增加一个 using 语句,然后在 EnrollmentDate 属性上增加一个 DisplayFormat 的特性。如下所示:
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class Student { public int StudentID { get; set; } public string LastName { get; set; } public string FirstMidName { get; set; } [DisplayFormat(DataFormatString = "{0:d}", ApplyFormatInEditMode = true)] public DateTime EnrollmentDate { get; set; } public virtual ICollection<Enrollment> Enrollments { get; set; } } }
格式化串指定了在显示这个属性的时候仅仅使用短日期格式。ApplyFormatInEditMode 指定即使在将这个属性的值显示在文本框中进行编辑的时候也应用这个特性。( 有些字段不需要这些特殊设置,比如,在文本框中编辑货币的时候,就不会希望出现货币符号 )。
再次运行程序,你会注意到注册时间不再是长日期格式了,如果你查看其他的学生页面也会看到同样的结果。
还可以通过特性来指定数据验证规则和错误提示信息。假设你希望用户在输入名字的时候不能超过 50 个字符长度,对于这个限制,可以为 LastName 属性和 FirstName 属性增加 MaxLength 特性,如下所示.
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class Student { public int StudentID { get; set; } [MaxLength(50)] public string LastName { get; set; } [MaxLength(50, ErrorMessage = "First name cannot be longer than 50 characters.")] public string FirstMidName { get; set; } [DisplayFormat(DataFormatString = "{0:d}", ApplyFormatInEditMode = true)] public DateTime EnrollmentDate { get; set; } public virtual ICollection<Enrollment> Enrollments { get; set; } } }
如果用户为 LastName 输入一个超长的名字,默认的错误信息就会显示出来,如果输入一个超长的 FirstName,你定制的错误信息会被显示出来。
运行程序,输入两个超过 50 个字符的超长名字,然后点击 Create 来查看错误信息。( 但是你需要输入一个正确的日期来通过验证 )
对于字符串属性总是指定最大长度是个好主意。如果没有这样做,在 CodeFirst 创建数据库的时候,相关的数据库中的列宽度就会被设置为字符串的最大长度,这可能会导致低效的数据库结构。
还可以通过特性来控制你的模型类及属性如何映射到数据库。假设你已经使用 FirstMidName 来表示名字,因为属性也包含了中间名,但是你希望数据库中的列名为 FirstName,因为编写本地查询的用户习惯使用这个名字,要完成这种映射,你可以使用Column 特性。
Column 特性在数据库创建的时候被使用。Student 的属性 FirstMidName 在数据库中将被命名为 FirstName。也就是说,当你的代码使用 Student.FirstMidName 的时候,数据将来自数据库中 Student 表的 FirstName 列 ( 如果你没有指定列名,默认将与属性同名 )
为 FirstMidName 增加列名的映射特性,如下所示。
[Column("FirstName")] public string FirstMidName { get; set; }
再次运行程序,你会发现没有任何变化。( 不能仅仅运行程序,然后查看主页。你需要选择 Student 的 Index 页面,因为这将导致对数据库的访问,这会使数据库首先被自动删除,然后重建 )。实际上,如果你在服务器资源管理器中打开数据库,展开 Student 表,就会发现列名 FirstName。
在属性窗口,你还会发现与此列相关的字段被定义为 50 个字符长度,与你在前面增加的 MaxLength 特性相一致。
在另外一些情况下,你还可以通过方法调用进行映射,后面就会看到。
在后面的段落中,你使用到更多的数据标注来扩展学校数据模型。在每节中,你要为实体创建一个类,或者修改前面创建的类。
注意:如果在完成所有的实体之间,你试图编译,可能会遇到编译错误。
创建 Models\Instructor.cs, 使用下面的代码替换生成的代码。
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class Instructor { public Int32 InstructorID { get; set; } [Required(ErrorMessage = "Last name is required.")] [Display(Name="Last Name")] [MaxLength(50)] public string LastName { get; set; } [Required(ErrorMessage = "First name is required.")] [Column("FirstName")] [Display(Name = "First Name")] [MaxLength(50)] public string FirstMidName { get; set; } [DisplayFormat(DataFormatString = "{0:d}", ApplyFormatInEditMode = true)] [Required(ErrorMessage = "Hire date is required.")] [Display(Name = "Hire Date")] public DateTime? HireDate { get; set; } public string FullName { get { return LastName + ", " + FirstMidName; } } public virtual ICollection<Course> Courses { get; set; } public virtual OfficeAssignment OfficeAssignment { get; set; } } }
注意Student 和 Instructor 实体多数属性是类似的。在这个系列后面 实现继承 的部分,会对代码进行重构,使用继承来消除重复。
在 LastName 属性上的特性指定这是一个必须字段,在编辑的文本框上的提示文本应该为 “Last Name” ( 代替属性名称,属性名中间没有空格 ),而且字段的值不能超过 50 个字符。
[Required(ErrorMessage = "Last name is required.")] [Display(Name="Last Name")] [MaxLength(50)] public string LastName { get; set; }
FullName 是计算属性,返回其他两个属性计算出的结果。因此它只有 get 访问方法,而且在数据库中没有名为 FullName 的字段。
public string FullName { get { return LastName + ", " + FirstMidName; } }
Courses 和 OfficeAssignment 属性是导航属性,像我们在前面说明的,它们典型地被定义为虚拟的 virtual,以便 EF 的延迟加载特性可以提供帮助,另外,如果导航属性可以包含多个实体,它的类型必须为 ICollection。
一个教师可以教授多门课程,所以 Courses 被定义为 Course 的集合。另一方面,一个教师仅有一间办公室,所以 OfficeAssignment 被定义为单个的 OfficeAssignment 实体 ( 如果没有办公室的话,可能为 null )。
public virtual ICollection<Course> Courses { get; set; } public virtual OfficeAssignment OfficeAssignment { get; set; }
创建 Models\OfficeAssignment.cs, 将生成的代码替换为以下代码。
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class OfficeAssignment { [Key] public int InstructorID { get; set; } [MaxLength(50)] [Display(Name = "Office Location")] public string Location { get; set; } public virtual Instructor Instructor { get; set; } } }
在 Instructor 和 OfficeAssignment 实体之间存在一对一或者一对零的关系。一个办公室分配仅仅存在于被分配给教师的时候。进而,它的主键也应该是 Instructor 实体的外键。但是 EF 并不能自动将 InstructorID 作为分配的主键,因为名字不符合约定,既不是 ID ,也不是类名加上 ID,因此,使用 Key 特性来标识这是一个主键。
[Key] public int InstructorID { get; set; }
在主键的属性名既不是 Id 也不是类名加上 ID 的时候,可以通过 Key 特性。
Instructor 实体有一个可空的 OfficeAssignment 导航属性 ( 因为教师可能没有分配办公室 ),在 OfficeAssignment 实体上则有一个不可为空的 Instructor 导航属性 ( 因为办公室分配不可能在没有教师的情况下存在 ),当 Instructor 实体关联到 OfficeAssignment 实体的时候,它们可以通过导航属性相互引用对方。
在 Models\Course.cs, 将原来的代码替换为如下代码。
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class Course { [DatabaseGenerated(DatabaseGeneratedOption.None)] [Display(Name = "Number")] public int CourseID { get; set; } [Required(ErrorMessage = "Title is required.")] [MaxLength(50)] public string Title { get; set; } [Required(ErrorMessage = "Number of credits is required.")] [Range(0,5,ErrorMessage="Number of credits must be between 0 and 5.")] public int Credits { get; set; } [Display(Name = "Department")] public int DepartmentID { get; set; } public virtual Department Department { get; set; } public virtual ICollection<Enrollment> Enrollments { get; set; } public virtual ICollection<Instructor> Instructors { get; set; } } }
在 CourseID 属性上的DatabaseGenerated 特性,使用了 None 参数,指定主键将由用户提供,而不是通过数据库自动生成。
[DatabaseGenerated(DatabaseGeneratedOption.None)] [Display(Name = "Number")] public int CourseID { get; set; }
默认情况下,EF 假设主键由数据库自动生成。这可以用于大多数场景下,但是,对于 Course 实体来说,你希望使用自定义的课程编号,例如:1000 序列表示一个系,2000 序列表示另外一个系等等。
在 Course 实体中的外键属性和导航属性,反射了如下的关系:
public int DepartmentID { get; set; } public virtual Department Department { get; set; }
public virtual ICollection Enrollments { get; set; }
public virtual ICollection<Instructor> Instructors { get; set; }
创建 Models\Department.cs, 使用下面的代码替换生成的内容, 如下所示。
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class Department { public int DepartmentID { get; set; } [Required(ErrorMessage = "Department name is required.")] [MaxLength(50)] public string Name { get; set; } [DisplayFormat(DataFormatString="{0:c}")] [Required(ErrorMessage = "Budget is required.")] [Column(TypeName="money")] public decimal? Budget { get; set; } [DisplayFormat(DataFormatString="{0:d}", ApplyFormatInEditMode=true)] [Required(ErrorMessage = "Start date is required.")] public DateTime StartDate { get; set; } [Display(Name="Administrator")] public int? InstructorID { get; set; } public virtual Instructor Administrator { get; set; } public virtual ICollection<Course> Courses { get; set; } } }
前面我们使用 Column 特性改变了默认的列名映射。在 Department 实体中,Column 特性被用来改变 SQL 数据类型映射,以便在数据库中使用 SQL Server 的 money 数据类型 。
[Column(TypeName="money")] public decimal? Budget { get; set; }
通常并不需要,因为 EF 会基于属性的 CLR 数据类型来选择合适的 SQL Server 数据类型。对于 CLR 中的 decimal 类型会映射到 SQL Server 中的 decimal 类型。但在这里,你知道这个列将会保存合计,所以 money 数据类型更加合适。
外键和导航属性反映了如下关系:
public int? InstructorID { get; set; } public virtual Instructor Administrator { get; set; }
public virtual ICollection Courses { get; set; }
注意:
默认情况下, EF 允许非空外键和多对多关系的级联删除。这可能会导致环形删除,在你的初始化代码运行的时候导致异常。例如,如果你没有定义 Department.InstructorID 属性作为可空类型,在初始化的时候,你会得到如下的异常:"The referential relationship will result in a cyclical reference that's not allowed."
打开 Models\Student.cs, 将早前的代码替换为如下代码。
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class Student { public int StudentID { get; set; } [Required(ErrorMessage = "Last name is required.")] [Display(Name="Last Name")] [MaxLength(50)] public string LastName { get; set; } [Required(ErrorMessage = "First name is required.")] [Column("FirstName")] [Display(Name = "First Name")] [MaxLength(50)] public string FirstMidName { get; set; } [Required(ErrorMessage = "Enrollment date is required.")] [DisplayFormat(DataFormatString = "{0:d}", ApplyFormatInEditMode = true)] [Display(Name = "Enrollment Date")] public DateTime? EnrollmentDate { get; set; } public string FullName { get { return LastName + ", " + FirstMidName; } } public virtual ICollection<Enrollment> Enrollments { get; set; } } }
这段代码中仅仅增加了一些你已经见过的特性。
打开 Models\Enrollment.cs,使用如下代码替换原来的代码。
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace ContosoUniversity.Models { public class Enrollment { public int EnrollmentID { get; set; } public int CourseID { get; set; } public int StudentID { get; set; } [DisplayFormat(DataFormatString="{0:#.#}",ApplyFormatInEditMode=true,NullDisplayText="No grade")] public decimal? Grade { get; set; } public virtual Course Course { get; set; } public virtual Student Student { get; set; } } }
外键和导行属性反映了如下的关系:
public int CourseID { get; set; } public virtual Course Course { get; set; }
public int StudentID { get; set; } public virtual Student Student { get; set; }
在学生实体和课程实体之间存在多对多的关系。在数据库中,Enrollment 实体在两张表之间承担了多对多的关联。这意味着 Enrollment 表中还包含了关联表中额外的数据 ( 在这里, 一个主键和一个成绩 Grade 属性 )。
下图展示了在实体图中的关系。( 这张图使用 EF 的设计器生成,创建这种图不是这个系列关注的内容,这里仅仅用来说明 )。
每个关联线的两端有一个 1 和一个星 *,表示一对多的关系。
如果注册表中不包含成绩信息,那就可以仅仅包含两个外键 CourseId 和 StudentId。在这中情况下,在关联表中就没有额外的信息 ( 或者是纯的链接表 ),你也就完全没有必要创建这个注册的模型。教师 Instructor 和课程 Course 实际直接使用多对多关联,如你所见,在它们之间没有其他的实体。
在数据库中需要一个关联表,如下所示。
EF 将会自动创建 CourseInstructor 表,你可以间接地读取或者更新其中的数据,通过读写 Instructor.Courses 和 Course.Instructors 导航属性。
在成绩 Grade 属性上的 DisplayFormat 特性指定数据如何被格式化。
[DisplayFormat(DataFormatString="{0:#.#}",ApplyFormatInEditMode=true,NullDisplayText="No grade")] public decimal? Grade { get; set; }
下面是通过 EF 设计器创建的学校实体关系图。
除了多对多的关联线,以及一对多的关联线。还可以看到在 Instructor 与 OfficeAssignment之间的一对一或一对零的关联线,Instructor 与 Department 实体之间的零或一对多的关联线。
下一步将要为 SchoolContext 增加一些新的实体,通过 Fluent API 调用定义一些映射关系( 这个 API 被称为流畅的,是因为经常将一连串的调用写在一行语句中 )。有些时候你需要通过调用方法而不是使用特性,因为对于某些特定的功能没有合适的特性可用,如果存在的话,可以在使用特性和方法之间进行选择。( 有些人不喜欢使用特性 )
将 DAL\SchoolContext.cs 中的代码替换为如下内容:
using System; using System.Collections.Generic; using System.Data.Entity; using ContosoUniversity.Models; using System.Data.Entity.ModelConfiguration.Conventions; namespace ContosoUniversity.Models { public class SchoolContext : DbContext { public DbSet<Course> Courses { get; set; } public DbSet<Department> Departments { get; set; } public DbSet<Enrollment> Enrollments { get; set; } public DbSet<Instructor> Instructors { get; set; } public DbSet<Student> Students { get; set; } public DbSet<OfficeAssignment> OfficeAssignments { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Conventions.Remove<PluralizingTableNameConvention>(); modelBuilder.Entity<Instructor>() .HasOptional(p => p.OfficeAssignment).WithRequired(p => p.Instructor); modelBuilder.Entity<Course>() .HasMany(c => c.Instructors).WithMany(i => i.Courses) .Map(t => t.MapLeftKey("CourseID") .MapRightKey("InstructorID") .ToTable("CourseInstructor")); modelBuilder.Entity<Department>() .HasOptional(x => x.Administrator); } } }
在 OnModelCreating 方法中的新的语句指定了如下关系:
modelBuilder.Entity<Instructor>()
.HasOptional(p => p.OfficeAssignment).WithRequired(p => p.Instructor);
modelBuilder.Entity<Course>() .HasMany(c => c.Instructors).WithMany(i => i.Courses) .Map(t => t.MapLeftKey("CourseID") .MapRightKey("InstructorID") .ToTable("CourseInstructor"));
modelBuilder.Entity<Department>()
.HasOptional(x => x.Administrator);
关于更加详细的 Fluent API 语法说明,可以参见博客 Fluent API 。
在前面的课程中,你已经创建了 DAL\SchoolInitializer.cs 用测试数据初始化数据库,现在使用下面的代码替换掉原有的代码,以便使用新创建的实体。
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Data.Entity; using ContosoUniversity.Models; namespace ContosoUniversity.DAL { public class SchoolInitializer : DropCreateDatabaseIfModelChanges<SchoolContext> { protected override void Seed(SchoolContext context) { var students = new List<Student> { new Student { FirstMidName = "Carson", LastName = "Alexander", EnrollmentDate = DateTime.Parse("2005-09-01") }, new Student { FirstMidName = "Meredith", LastName = "Alonso", EnrollmentDate = DateTime.Parse("2002-09-01") }, new Student { FirstMidName = "Arturo", LastName = "Anand", EnrollmentDate = DateTime.Parse("2003-09-01") }, new Student { FirstMidName = "Gytis", LastName = "Barzdukas", EnrollmentDate = DateTime.Parse("2002-09-01") }, new Student { FirstMidName = "Yan", LastName = "Li", EnrollmentDate = DateTime.Parse("2002-09-01") }, new Student { FirstMidName = "Peggy", LastName = "Justice", EnrollmentDate = DateTime.Parse("2001-09-01") }, new Student { FirstMidName = "Laura", LastName = "Norman", EnrollmentDate = DateTime.Parse("2003-09-01") }, new Student { FirstMidName = "Nino", LastName = "Olivetto", EnrollmentDate = DateTime.Parse("2005-09-01") } }; students.ForEach(s => context.Students.Add(s)); context.SaveChanges(); var instructors = new List<Instructor> { new Instructor { FirstMidName = "Kim", LastName = "Abercrombie", HireDate = DateTime.Parse("1995-03-11") }, new Instructor { FirstMidName = "Fadi", LastName = "Fakhouri", HireDate = DateTime.Parse("2002-07-06") }, new Instructor { FirstMidName = "Roger", LastName = "Harui", HireDate = DateTime.Parse("1998-07-01") }, new Instructor { FirstMidName = "Candace", LastName = "Kapoor", HireDate = DateTime.Parse("2001-01-15") }, new Instructor { FirstMidName = "Roger", LastName = "Zheng", HireDate = DateTime.Parse("2004-02-12") } }; instructors.ForEach(s => context.Instructors.Add(s)); context.SaveChanges(); var departments = new List<Department> { new Department { Name = "English", Budget = 350000, StartDate = DateTime.Parse("2007-09-01"), InstructorID = 1 }, new Department { Name = "Mathematics", Budget = 100000, StartDate = DateTime.Parse("2007-09-01"), InstructorID = 2 }, new Department { Name = "Engineering", Budget = 350000, StartDate = DateTime.Parse("2007-09-01"), InstructorID = 3 }, new Department { Name = "Economics", Budget = 100000, StartDate = DateTime.Parse("2007-09-01"), InstructorID = 4 } }; departments.ForEach(s => context.Departments.Add(s)); context.SaveChanges(); var courses = new List<Course> { new Course { CourseID = 1050, Title = "Chemistry", Credits = 3, DepartmentID = 3, Instructors = new List<Instructor>() }, new Course { CourseID = 4022, Title = "Microeconomics", Credits = 3, DepartmentID = 4, Instructors = new List<Instructor>() }, new Course { CourseID = 4041, Title = "Macroeconomics", Credits = 3, DepartmentID = 4, Instructors = new List<Instructor>() }, new Course { CourseID = 1045, Title = "Calculus", Credits = 4, DepartmentID = 2, Instructors = new List<Instructor>() }, new Course { CourseID = 3141, Title = "Trigonometry", Credits = 4, DepartmentID = 2, Instructors = new List<Instructor>() }, new Course { CourseID = 2021, Title = "Composition", Credits = 3, DepartmentID = 1, Instructors = new List<Instructor>() }, new Course { CourseID = 2042, Title = "Literature", Credits = 4, DepartmentID = 1, Instructors = new List<Instructor>() } }; courses.ForEach(s => context.Courses.Add(s)); context.SaveChanges(); courses[0].Instructors.Add(instructors[0]); courses[0].Instructors.Add(instructors[1]); courses[1].Instructors.Add(instructors[2]); courses[2].Instructors.Add(instructors[2]); courses[3].Instructors.Add(instructors[3]); courses[4].Instructors.Add(instructors[3]); courses[5].Instructors.Add(instructors[3]); courses[6].Instructors.Add(instructors[3]); context.SaveChanges(); var enrollments = new List<Enrollment> { new Enrollment { StudentID = 1, CourseID = 1050, Grade = 1 }, new Enrollment { StudentID = 1, CourseID = 4022, Grade = 3 }, new Enrollment { StudentID = 1, CourseID = 4041, Grade = 1 }, new Enrollment { StudentID = 2, CourseID = 1045, Grade = 2 }, new Enrollment { StudentID = 2, CourseID = 3141, Grade = 4 }, new Enrollment { StudentID = 2, CourseID = 2021, Grade = 4 }, new Enrollment { StudentID = 3, CourseID = 1050 }, new Enrollment { StudentID = 4, CourseID = 1050, }, new Enrollment { StudentID = 4, CourseID = 4022, Grade = 4 }, new Enrollment { StudentID = 5, CourseID = 4041, Grade = 3 }, new Enrollment { StudentID = 6, CourseID = 1045 }, new Enrollment { StudentID = 7, CourseID = 3141, Grade = 2 }, }; enrollments.ForEach(s => context.Enrollments.Add(s)); context.SaveChanges(); var officeAssignments = new List<OfficeAssignment> { new OfficeAssignment { InstructorID = 1, Location = "Smith 17" }, new OfficeAssignment { InstructorID = 2, Location = "Gowan 27" }, new OfficeAssignment { InstructorID = 3, Location = "Thompson 304" }, }; officeAssignments.ForEach(s => context.OfficeAssignments.Add(s)); context.SaveChanges(); } } }
像在前面一样,大多数的代码是简单地创建实体对象,通过必须的属性加载简单的测试数据,注意 Course 实体,它和 Instructor 实体之间存在多对多的关联。
var courses = new List { new Course { CourseID = 1050, Title = "Chemistry", Credits = 3, DepartmentID = 3, Instructors = new List() }, ... }; courses.ForEach(s => context.Courses.Add(s)); context.SaveChanges(); courses[0].Instructors.Add(instructors[0]); ... context.SaveChanges();
当创建 Course 对象的时候,通过创建空的集合初始化了 Instructor 导航属性,使用代码 Instructors = new List()。这使得为课程相关的教师可以通过 Instructors.Add 方法加入,如果没有创建空的集合列表,你就不能增加这些关系,因为 Instructors 属性为 null,不能调用 Add 方法。
注意:记住,在发布应用到 Web 服务器上的时候,你必须删除所有的在数据库中的种子数据。
重新运行,选择 Student 页面。
页面像从前一样,但是底层的数据库已经被重建了。
如果你没有看到学生页面,而是看到一个错误页面,提示说 School.sdf 文件正在被使用 ( 见下图 ),你需要重新打开服务器资源管理器,然后关闭与数据库的连接,然后重试一下。
在查看学生页面之后,重新打开服务器资源管理器,展开表,可以看到新创建的所有表。
另外,EdmMetadata 表不是我们创建的,还有 CourseInstructor,如前所述,这是在 Instructor 和 Course 实体之间的链接表。
鼠标右击 CourseInstructor 表,选择显示数据,可以看到通过课程 Course.Instructors 导航属性增加的教师实体。
现在你已经获得了一个更加复杂的数据模型以及关联的数据库,在后继的课程中,你可以学到通过不同的途径访问关联数据。