原文 Contoso 大学 - 10 - 高级 EF 应用场景
By Tom Dykstra, Tom Dykstra is a Senior Programming Writer on Microsoft's Web Platform & Tools Content Team.
原文地址:http://www.asp.net/mvc/tutorials/getting-started-with-ef-using-mvc/advanced-entity-framework-scenarios-for-an-mvc-web-application
全文目录:Contoso 大学 - 使用 EF Code First 创建 MVC 应用
在上一个教程中,你已经实现了仓储和工作单元模式。这个教程涵盖下列主题:
- 执行原始的 SQL 查询
- 执行没有跟踪的查询
- 检查发送到数据库的查询
- 使用代理类
- 禁用修改的自动检测
- 在保存修改时禁用验证
多数内容使用你已经创建的页面。为了使用原始的 SQL 进行批更新,你需要创建一个新的更新数据库中所有课程学分的页面。
以及在 Department 编辑页面中增加新的验证逻辑,使用非跟踪的查询。
10-1 执行原始的 SQL 查询
在 EF 中包含有允许你直接将 SQL 命令发送到数据库的 API 方法,你有以下选择:
- 使用 DbSet.SqlQuery 方法进行查询以返回实体类型。返回的对象类型必须是 DbSet 对象期望的类型。除非你关掉追踪,数据库上下文将自动追踪。( 查看随后关于 AsNoTracking 方法 )
- 使用 DbDatabase.SqlQuery 方法进行查询,返回类型不是实体。返回的数据不会被数据库上下文追踪,即使使用这个方法获取实体类型。
- 使用 DbDatabase.SqlCommand 用于非查询命令。
使用 EF 的好处是使你不用过分关注处理存储数据的特定方法。它通过自动生成需要的查询命令来完成,这样你就不用亲自编写这些代码。但是,一个例外就是你可能需要执行手动创建的特殊命令,这些方法可以为你处理类似的意外场景。
在 Web 中执行 SQL 命令有一点总是对的,你必须注意保护你的站点免于 SQL 注入攻击。一种方法就是使用参数化查询来防止提交的命令没有被注入。在这个教程中,在使用用户的输入生成查询的时候将会使用参数化查询。
10-1-1 调用返回实体的查询
假设你希望 GenericRepository 类需要提供一个额外的过滤和排序灵活方法,而不需要你创建一个派生类,然后增加一个额外的方法。一种方法就是增加一个接受原始 SQL 命令的方法。这样你就可以在控制其中指定任何类型的过滤和排序处理。比如 Where 子句依赖于连接或者子查询。 在这一节中,你将要看到如何实现这样的方法。
通过将如下代码增加到 GenericRepository.cs 中, 创建 GetWithRawSql 方法
public virtual IEnumerable<TEntity> GetWithRawSql(string query, params object[] parameters) { return dbSet.SqlQuery(query, parameters).ToList(); }
在 CourseController.cs 中,在 Details 方法中调用新的方法,如下所示:
public ActionResult Details(int id) { var query = "SELECT * FROM Course WHERE CourseID = @p0"; return View(unitOfWork.CourseRepository.GetWithRawSql(query, id).Single()); }
在这里,你可以使用 GetByID 方法,但是,通过调用 GetWithSql 方法来验证 GetWithRawSQL 方法正常工作。
运行 Details 页面,验证选择查询正确工作 ( 选择 Course 窗格,然后选择某个课程的 Details )
10-1-2 调用查询返回其它类型的对象
早前的时候,你创建过一个关于页面,其中显示了每个注册日注册学生的数量。位于 HomeController.cs 中的代码使用 LINQ 完成。
var data = from student in db.Students group student by student.EnrollmentDate into dateGroup select new EnrollmentDateGroup() { EnrollmentDate = dateGroup.Key, StudentCount = dateGroup.Count() };
假设你希望通过借助编写的 SQL 而不是使用 LINQ 来获取这些数据。要做到这个目的,你需要执行返回一些其他数据而不是实体对象的查询语句,这意味着你需要使用 Database.SqlQuery 方法。
在 HomeController.cs 中,使用下面的代码替换原来 About 方法中的 LINQ。
var query = "SELECT EnrollmentDate, COUNT(*) AS StudentCount " + "FROM Person " + "WHERE EnrollmentDate IS NOT NULL " + "GROUP BY EnrollmentDate"; var data = db.Database.SqlQuery<EnrollmentDateGroup>(query);
运行 About 页面,它会像从前一样显示出来。
10-1-3 调用更新查询
假设 Contoso 大学的管理员希望能够在数据库中执行批量更新,例如修改每门课程的学分。如果大学有大量的课程,就意味着需要低效率地获取全部的课程实体,然后一个一个修改,在这一节中,你将要实现一个 Web 页面,允许用户设定所有课程的学分,通过执行一个 SQL 的更新 UPDATE 语句完成。这个页面如下图所示。
在 Course 控制器中,以前你已经使用泛型的仓储来读取和更新课程实体。这与这次的批更新处理,需要创建一个不在泛型仓储中的新仓储方法。为了达到这个目的,你需要创建一个派生自 GenericRepository 基类的派生类 CourseRepository 。
在 DAL 文件夹中,创建 CourseRepository.cs ,使用下面的代码替换生成的代码。
using System; using ContosoUniversity.Models; namespace ContosoUniversity.DAL { public class CourseRepository : GenericRepository<Course> { public CourseRepository(SchoolContext context) : base(context) { } public int UpdateCourseCredits(int multiplier) { return context.Database.ExecuteSqlCommand("UPDATE Course SET Credits = Credits * {0}", multiplier); } } }
在 UnitOfWork.cs 中,将 Course 仓储类型从 GenericRepository< Course > 修改为 CourseRepository:
private CourseRepository courseRepository;
public CourseRepository CourseRepository { get { if (this.courseRepository == null) { this.courseRepository = new CourseRepository(context); } return courseRepository; } }
在 CourseController.cs 中,增加 UpdateCourseCredits 方法
public ActionResult UpdateCourseCredits(int? multiplier) { if (multiplier != null) { ViewBag.RowsAffected = unitOfWork.CourseRepository.UpdateCourseCredits(multiplier.Value); } return View(); }
这个方法将会同时用于 HttpGet 和 HttpPost 。当调用 GET 方式的 UpdateCourseCredits 方法的时候,变量 multiplier 会是 null,视图将会显示空的文本框和提交按钮,如前所示。
当 Update 按钮被点击之后,将会以 POST 方式调用方法。Multiplier 将会得到在文本框中输入的值。代码调用仓储的 UpdateCourseCredits 方法,方法返回受到影响的行数,这个值存储在 ViewBag 对象中。视图通过 ViewBag 获取受到影响的行数,它将显示这个数字而不是文本框和提交按钮,如下图所示。
在 View\Course 文件夹中创建一个用来更新课程学分的视图。
在 View\Course\UpdateCourseCredits.cshtml 中,使用下面的代码替换原有代码。
@model ContosoUniversity.Models.Course @{ ViewBag.Title = "UpdateCourseCredits"; } <h2>Update Course Credits</h2> @if (ViewBag.RowsAffected == null) { using (Html.BeginForm()) { <p> Enter a number to multiply every course's credits by: @Html.TextBox("multiplier") </p> <p> <input type="submit" value="Update" /> </p> } } @if (ViewBag.RowsAffected != null) { <p> Number of rows updated: @ViewBag.RowsAffected </p> } <div> @Html.ActionLink("Back to List", "Index") </div>
通过选择 Course 窗格运行页面,然后增加 “/UpdateCourseCredits” 到浏览器地址栏中,( 例如:http://localhost:50205/Course/UpdateCourseCredits )。在文本框中输入一个数字。
点击 Update,你会看到受影响的行数。
点击 Back to List 查看修订之后的课程学分列表。
关于执行原始查询的更多内容,请查阅 EF 团队博客的 Raw SQL Queries
10-2 非追踪的查询
当数据库上下文从数据库获取数据行然后表示为实体的时候,默认情况下,会保持对内存中实体对象是否与数据库中数据同步的追踪。内存中的数据作为缓存,当你更新实体的时候被用来更新。这个缓存在 Web 应用程序中通常没有必要,因为上下文对象的实例生命期很短 ( 对于每一次请求创建一个新的,然后释放 ) ,在实体被再次使用之前数据库上下文读取的实体对象已经被释放了。
你可以通过 AsNoTracking 方法来指定上下文对象是否追踪实体对象。使用这个方法常见的场景如下:
- 对于查询大量数据的查询来说,关闭追踪可以提高性能。
- 你更希望重新连接一个对象用来更新,尽管基于不同的目的以前获取过同样的对象。由于数据库上下文已经追踪了这个实体,你就不能连接你希望修改的实体。防止出现这种情况的一种方式就是在原来的查询中使用 AsNoTracking 选项。
在这一节,你将要实现演示第二种场景的业务逻辑。具体来说,希望确认强制的业务规则:一个教师不能成为多个系的管理员。
在 DepartmentController.cs 中,增加一个可以在 Edit 和 Create 方法中调用的方法,确认两个系不能有同一个的管理员。
private void ValidateOneAdministratorAssignmentPerInstructor(Department department) { if (department.PersonID != null) { var duplicateDepartment = db.Departments .Include("Administrator") .Where(d => d.PersonID == department.PersonID) .FirstOrDefault(); if (duplicateDepartment != null && duplicateDepartment.DepartmentID != department.DepartmentID) { var errorMessage = String.Format( "Instructor {0} {1} is already administrator of the {2} department.", duplicateDepartment.Administrator.FirstMidName, duplicateDepartment.Administrator.LastName, duplicateDepartment.Name); ModelState.AddModelError(string.Empty, errorMessage); } } }
增加在 HttpPost Edit 方法中的 try 代码块,如果经过验证的话,调用新的方法。Try 代码块如下所示。
if (ModelState.IsValid) { ValidateOneAdministratorAssignmentPerInstructor(department); } if (ModelState.IsValid) { db.Entry(department).State = EntityState.Modified; db.SaveChanges(); return RedirectToAction("Index"); }
运行 Department 的 Edit 页面,试着将已经是某个系管理员的教师设置为其他系的管理员。你会得到期望中的错误信息。
再次运行 Department 的 Edit 页面,这一次修改 Budget ,当点击 Save 的时候,你会看到如下信息。
异常提示信息是 “An object with the same key already exists in the ObjectStateManager. The ObjectStateManager cannot track multiple objects with the same key.” 这是因为顺序发生了一下事件:
- Edit 方法调用 ValidateOneAdministratorAssignmentPerInstructor 方法,这导致获取了 Kim Abercrombie 作为管理员的所有 Department 。结果是名为 English 的 Department 被读取到内存中。因为这个 Department 被编辑了,不会有错误报告,作为这次读取操作的结果,然而,从数据库读取的 English Department 实体已经被数据库上下文跟踪了。
- Edit 方法试图设置通过模型绑定得到的 English Department 实体的 Modified 标志,但是失败了,因为数据库上下文已经跟踪了 English 实体。
解决这个问题的一个方案是保持数据库上下文,从内存中获取跟踪的 Department 实体。这样做没有什么好处,因为并不需要更新这个实体或者通过从内存中再次读取这个实体来获取好处。
在 DepartmentController.cs 文件的 ValidateOneAdministratorAssignmentPerInstructor 方法中,指定不需要跟踪,如下所示:
var duplicateDepartment = db.Departments .Include("Administrator") .Where(d => d.PersonID == department.PersonID) .AsNoTracking() .FirstOrDefault();
重新编辑Department 的 Budget 。这次操作成功了。站点返回了预期的 Department Index 页面,显示了修改之后的 Budget 值。
10-3 检查发送到数据库的查询
有的时候,能够看到实际发送到数据库的 SQL 很有帮助,如果希望的话,可以通过在调试器中检查查询变量或者调用查询的 ToString 方法。尝试一下,对一个简单的查询增加一些选项,例如预先加载,过滤和排序等等,看看发生了什么。
在 Controller/CourseController.cs 中,使用下面的代码替换 Index 方法。
public ViewResult Index() { var courses = unitOfWork.CourseRepository.Get(); return View(courses.ToList()); }
现在,在 GenericRepository.cs 文件中GET 方法的 return query.ToList() 方法调用语句以及 orderBy( query ).ToList() 语句上增加一个断点,在调试模式运行项目,选择 Course 的 Index 页面。当执行到断点的时候,检查 query 变量。你就会看到发送到数据库的 SQL 语句。是简单的 Select 语句。
{SELECT
[Extent1].[CourseID] AS [CourseID],
[Extent1].[Title] AS [Title],
[Extent1].[Credits] AS [Credits],
[Extent1].[DepartmentID] AS [DepartmentID]
FROM [Course] AS [Extent1]}
在 Visual Studio 的调试器窗口中,查询语句可能很长,不便于显示出来。为了查看完整的查询,你可以复制变量的值粘贴到文本编辑器中。
现在你需要为 Course 的 Index 页面上增加一个下拉列表,用户可以用来过滤特定的 Department。通过标题可以进行排序,对 Department 导航属性使用贪婪加载,在 CourseController.cs 文件中,使用下面的代码替换原有内容。
public ActionResult Index(int? SelectedDepartment) { var departments = unitOfWork.DepartmentRepository.Get( orderBy: q => q.OrderBy(d => d.Name)); ViewBag.SelectedDepartment = new SelectList(departments, "DepartmentID", "Name", SelectedDepartment); int departmentID = SelectedDepartment.GetValueOrDefault(); return View(unitOfWork.CourseRepository.Get( filter: d => !SelectedDepartment.HasValue || d.DepartmentID == departmentID, orderBy: q => q.OrderBy(d => d.CourseID), includeProperties: "Department")); }
这个方法通过 SelectedDepartment 参数获取下拉列表中的选中的值。如果没有任何项目选中,参数为 null。
SelectList 集合中包含传递到视图的下拉列表中的 Department。传递给 SelectList 构造器的参数设置了字段名称,文本域名称,以及选中的项目。
对于 Course 仓储的 Get 方法来说,代码设置了过滤表达式,排序顺序,预先加载 Department 导航属性。如果下拉列表没有选中的话,过滤表达式总是返回 true ( 也就是说 SelectedDepartment 是 null )。
在 View\Course\Index.cshtml 中,在 table 开始标记之前,增加如下的代码创建下拉列表和提交按钮。
@using (Html.BeginForm()) { <p>Select Department: @Html.DropDownList("SelectedDepartment","All") <input type="submit" value="Filter" /></p> }
由于在 GenericRepository 类的断点仍然有效,运行 Course 的 Index 页面,重复上次的操作以命中断点,在浏览器显示页面之后,从下拉列表中选中一个 Department,然后点击 Filter。
这一次第一个断点出现在下拉列表查询 Department 的时候,跳过之后,在下次代码到达断点的时候,查看 query 变量中的 Course 查询,你应该看到类似如下的查询语句。
{SELECT [Extent1].[CourseID] AS [CourseID], [Extent1].[Title] AS [Title], [Extent1].[Credits] AS [Credits], [Extent1].[DepartmentID] AS [DepartmentID], [Extent2].[DepartmentID] AS [DepartmentID1], [Extent2].[Name] AS [Name], [Extent2].[Budget] AS [Budget], [Extent2].[StartDate] AS [StartDate], [Extent2].[PersonID] AS [PersonID], [Extent2].[Timestamp] AS [Timestamp] FROM [Course] AS [Extent1] INNER JOIN [Department] AS [Extent2] ON [Extent1].[DepartmentID] = [Extent2].[DepartmentID] WHERE (@p__linq__0 IS NULL) OR ([Extent1].[DepartmentID] = @p__linq__1)}
你会看到现在是联合查询,通过Where 子句,依据 Course 数据加载了 Department 数据。
10.4 使用代理类
当 EF 创建实体对象的时候 ( 例如,在执行查询的时候 ),EF 经常会创建动态生成的派生自实体的代理对象。代理重写了实体的虚拟属性来插入在访问属性的时候自动执行的钩子。例如,这种机制用来支持延迟加载或者关联。
多数时候你并不能察觉使用了代理,除了下面的情况之外:
- 有一些场景,你需要阻止 EF 创建代理实例。例如,序列化非代理的对象实例可能比序列化代理对象实例更加有效。
- 当使用 new 操作符实例化实体的时候,你并没有得到代理实例。这意味着你不能获得诸如延迟加载以及自动追踪的能力。一般没有问题,通常并不需要延迟加载,因为你在创建数据库中并不存在的实体。在将实体标记为 Added 状态的时候,也不需要追踪。然而,如果你需要延迟加载,需要改变追踪,就可以通过 DbSet 类的 Create 方法来创建实体代理对象。
- 可能需要通过代理类型对象实例获取真实实体类型。可以使用 ObjectContext 类的 GetObjectType 方法来通过代理对象获取实际对象。
更多信息,参看 EF 团队博客的 Working with Proxies
10.5 禁用变化自动跟踪
EF 通过比较实体的当前值与原始值来决定实体如何变化 ( 依此来决定发送到数据库的更新 )。在查询或者连接的时候原始值被保存起来。导致自动变化跟踪监测的一些方法如下:
- DbSet.Find
- DbSet.Local
- DbSet.Remove
- DbSet.Add
- DbSet.Attach
- DbContext.SaveChanges
- DbContext.GetValidationErrors
- DbContext.Entry
- DbChangeTracker.Entries
如果你跟踪大量的实体,而且在循环中多次调用上面提到的方法,通过使用 AutoDetectChangesEnabled 属性暂时关闭变化检测,可以得到显著的性能提升,更多信息,参见 EF 团队博客的 Automatically Detecting Changes。
10-6 在保存的时候禁用验证
在调用 SaveChanges 方法的时候,在更新到数据库之前,EF 验证所有被修改实体的数据的所有属性。如果你更新了大量的实体,而且你已经验证了数据,这些工作就是不必要的,通过 ValidateOnSaveEntity 属性临时关闭验证可以花费更要的时间保存修改。更多信息,参见 EF 团队博客的 Validation。
10-7 EF 资源链接
更多地 EF 资源,参见如下资源:
- Introduction to the Entity Framework 4.1 (Code First)
- The Entity Framework Code First Class Library API Reference
- Entity Framework FAQ
- The Entity Framework Team Blog
- Entity Framework in the MSDN Library
- Entity Framework in the MSDN Data Developer Center
- Entity Framework Forums on MSDN
- Julie Lerman's blog
- Code First DataAnnotations Attributes
- Maximizing Performance with the Entity Framework in an ASP.NET Web Application
- Profiling Database Activity in the Entity Framework
- Entity Framework Power Tools
下面的 EF 团队的博客提供了教程涉及的更多资源。
- Fluent API Samples. How to customize mapping using fluent API method calls.
- Connections and Models. How to connect to different types of databases.
- Pluggable Conventions. How to change conventions.
-
Finding Entities. How to use the
Find
method with composite keys. - Loading Related Entities. Additional options for eager, lazy, and explicit loading.
- Load and AsNoTracking. More on explicit loading.
一些这里提到的博客是关于 CTP 版本的 EF Code First,多数资料是准确的,但是在正式发布版本中会有一些变化。
在 EF 中使用 LINQ 的信息,参见 MSDN 的 LINQ to Entities
使用 MVC 和 EF 的更多信息,参见 MVC Music Store.
在项目创建之后,如何发布的问题,参见 MSDN 的 ASP.NET Deployment Content