MVC Web 应用程序的高级实体框架方案 (10/10)

作者 :Tom Dykstra

Contoso University 示例 Web 应用程序演示如何使用 Entity Framework 5 Code First 和 Visual Studio 2012 创建 ASP.NET MVC 4 应用程序。 若要了解教程系列,请参阅本系列中的第一个教程

注意

如果遇到无法解决的问题, 请下载已完成的章节 并尝试重现问题。 通常可以通过将代码与已完成的代码进行比较来找到问题的解决方案。 有关一些常见错误及其解决方法,请参阅 错误和解决方法。

在上一教程中,你实现了存储库和工作单元模式。 本教程涵盖以下主题:

  • 执行原始 SQL 查询。
  • 执行无跟踪查询。
  • 检查发送到数据库的查询。
  • 使用代理类。
  • 禁用自动检测更改。
  • 保存更改时禁用验证。
  • 错误和解决方法

对于其中大多数主题,你将使用已创建的页面。 若要使用原始 SQL 进行批量更新,需要创建一个新页面,用于更新数据库中所有课程的学分数:

显示“更新课程学分”初始页面的屏幕截图。数字 2 在文本字段中输入。

若要使用无跟踪查询,需要向“部门编辑”页添加新的验证逻辑:

显示“Contoso 大学系编辑”页的屏幕截图,其中包含重复的管理员错误消息。

执行原始 SQL 查询

Entity Framework Code First API 包含可用于将 SQL 命令直接传递到数据库的方法。 你有以下选择:

  • 使用DbSet.SqlQuery返回实体类型的查询方法。 返回的对象必须是对象预期的 DbSet 类型,并且除非关闭跟踪,否则数据库上下文会自动跟踪它们。 (请参阅以下部分,了解 AsNoTracking method.)
  • Database.SqlQuery对返回不是实体的类型的查询使用 方法。 数据库上下文不会跟踪返回的数据,即使你使用该方法来检索实体类型也是如此。
  • 对非查询命令使用 Database.ExecuteSqlCommand

使用 Entity Framework 的优点之一是它可避免你编写跟数据库过于耦合的代码 它会自动生成 SQL 查询和命令,使得你无需自行编写。 但是,在需要运行手动创建的特定 SQL 查询时,存在一些异常情况,这些方法使你能够处理此类异常。

在 Web 应用程序中执行 SQL 命令时,请务必采取预防措施来保护站点免受 SQL 注入攻击。 一种方法是使用参数化查询,确保不会将网页提交的字符串视为 SQL 命令。 在本教程中,将用户输入集成到查询中时会使用参数化查询。

调用返回实体的查询

假设你希望 GenericRepository 类提供额外的筛选和排序灵活性,而无需使用其他方法创建派生类。 实现此目的的一种方法是添加接受 SQL 查询的方法。 然后,可以在控制器中指定所需的任何类型的筛选或排序,例如 Where 依赖于联接或子查询的子句。 在本部分中,你将了解如何实现此类方法。

GetWithRawSql通过将以下代码添加到 GenericRepository.cs 来创建 方法:

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 方法,但使用 GetWithRawSql 方法验证方法是否 GetWithRawSQL 有效。

运行“详细信息”页,验证选择查询是否正常工作, (选择“ 课程 ”选项卡,然后选择一个课程) 的详细信息

显示 Contoso University 详细信息页的屏幕截图。

调用返回其他类型的对象的查询

之前你在“关于”页面创建了一个学生统计信息网格,显示每个注册日期的学生数量。 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);

运行“关于”页。 显示的数据和之前一样。

显示 Contoso University“关于”页的屏幕截图。

调用更新查询

假设 Contoso University 管理员希望能够在数据库中执行批量更改,例如更改每个课程的学分数。 如果该大学提供了大量课程,那么将所有课程作为实体来检索并单独更改就非常低效。 在本部分中,你将实现一个网页,该网页允许用户指定更改所有课程的学分数的因素,并且你将通过执行 SQL UPDATE 语句进行更改。 网页的外观类似于下图:

显示“更新课程学分”初始页面的屏幕截图。数字 2 在文本字段中输入。

在上一教程中,你使用了通用存储库来读取和更新 Course 控制器中的 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();
}

此方法将同时用于 HttpGetHttpPostHttpGetUpdateCourseCredits当方法运行时,multiplier变量将为 null,视图将显示一个空文本框和一个提交按钮,如上图所示。

单击“ 更新 ”按钮并 HttpPost 运行 方法时, multiplier 将在文本框中输入值。 然后,代码调用存储库 UpdateCourseCredits 方法,该方法返回受影响的行数,并且该值存储在 对象中 ViewBag 。 当视图收到 对象中 ViewBag 受影响的行数时,它显示该数字而不是文本框和提交按钮,如下图所示:

显示“Contoso University 更新课程学分”行受影响的页面的屏幕截图。

在“更新课程学分”页的 Views\Course 文件夹中创建视图:

显示“添加视图”对话框的屏幕截图。在“视图名称”文本字段中输入更新课程学分。

Views\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>

通过选择Courses选项卡运行UpdateCourseCredits方法,然后在浏览器地址栏中 URL 的末尾添加"/ UpdateCourseCredits"到 (例如: http://localhost:50205/Course/UpdateCourseCredits)。 在文本框中输入数字:

显示“更新课程学分”初始页面的屏幕截图,其中文本字段中输入了数字 2。

单击Update。 你会看到受影响的行数:

显示更新了行数的“更新课程学分”页的屏幕截图。

单击“返回列表”可以查看课程列表,其中学分已替换为修改后的数字。

显示“课程索引”页的屏幕截图。将显示课程列表,其中包含修订后的学分数。

有关原始 SQL 查询的详细信息,请参阅实体框架团队博客上的 原始 SQL 查询

非跟踪查询

当数据库上下文检索数据库行并创建表示它们的实体对象时,默认情况下,它会跟踪内存中的实体是否与数据库中的内容同步。 更新实体时,内存中的数据充当缓存并使用该数据。 在 Web 应用程序中,此缓存通常是不必要的,因为上下文实例通常生存期较短(创建新的实例并用于处理每个请求),并且通常在再次使用该实体之前处理读取实体的上下文。

可以使用 方法指定上下文是否跟踪查询 AsNoTracking 的实体对象。 可能想要执行的典型方案包括以下操作:

  • 查询检索的数据量如此之大,关闭跟踪可能会明显提高性能。
  • 你想要附加一个实体以更新它,但你之前出于不同的目的检索了同一个实体。 由于数据库上下文已跟踪了该实体,因此无法附加要更改的实体。 防止这种情况发生的一种方法是将 AsNoTracking 选项与前面的查询一起使用。

在本部分中,你将实现说明其中第二种方案的业务逻辑。 具体而言,你将强制实施一项业务规则,该规则指出讲师不能是多个部门的管理员。

DepartmentController.cs 中,添加可从 和 Create 方法调用Edit的新方法,以确保没有两个部门具有相同的管理员:

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);
        }
    }
}

在 方法块HttpPostEdit中添加try代码,以在没有验证错误的情况下调用此新方法。 块 try 现在如以下示例所示:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit(
   [Bind(Include = "DepartmentID, Name, Budget, StartDate, RowVersion, PersonID")]
    Department department)
{
   try
   {
      if (ModelState.IsValid)
      {
         ValidateOneAdministratorAssignmentPerInstructor(department);
      }

      if (ModelState.IsValid)
      {
         db.Entry(department).State = EntityState.Modified;
         db.SaveChanges();
         return RedirectToAction("Index");
      }
   }
   catch (DbUpdateConcurrencyException ex)
   {
      var entry = ex.Entries.Single();
      var clientValues = (Department)entry.Entity;

运行“部门编辑”页,并尝试将部门管理员更改为已是不同部门的管理员的讲师。 收到预期的错误消息:

显示“部门编辑”页的屏幕截图,其中包含重复的管理员错误消息。

现在再次运行“部门编辑”页,这次将更改 预算 金额。 单击“ 保存”时,会看到一个错误页:

显示“部门编辑”页的屏幕截图,其中包含对象状态管理器错误消息。

异常错误消息为“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 管理员的所有部门。 这会导致英语系被阅读。 由于这是正在编辑的部门,因此不会报告任何错误。 但是,由于此读取操作,从数据库读取的英语部门实体现在正被数据库上下文跟踪。
  • 方法 Edit 尝试在 MVC 模型绑定器创建的英语部门实体上设置 Modified 标志,但失败,因为上下文已在跟踪英语系的实体。

此问题的一种解决方案是阻止上下文跟踪验证查询检索到的内存中部门实体。 这样做没有缺点,因为不会更新此实体或再次读取该实体,这样会从内存中缓存该实体中获益。

DepartmentController.cs 的 方法中 ValidateOneAdministratorAssignmentPerInstructor ,不指定任何跟踪,如下所示:

var duplicateDepartment = db.Departments
   .Include("Administrator")
   .Where(d => d.PersonID == department.PersonID)
   .AsNoTracking()
   .FirstOrDefault();

重复尝试编辑部门 的预算 金额。 这次操作成功,网站按预期返回到“部门索引”页,显示修订后的预算值。

检查发送到数据库的查询

有时能够以查看发送到数据库的实际 SQL 查询对于开发者来说是很有用的。 为此,可以在调试器中检查查询变量或调用查询的 ToString 方法。 若要尝试此操作,你将查看一个简单的查询,然后查看在添加预先加载、筛选和排序等选项时会发生什么情况。

Controllers/CourseController 中,将 方法替换为 Index 以下代码:

public ViewResult Index()
{
    var courses = unitOfWork.CourseRepository.Get();
    return View(courses.ToList());
}

现在,在 GenericRepository.cs 中对 return query.ToList(); 方法的 Getreturn orderBy(query).ToList(); 语句设置断点。 在调试模式下运行项目,然后选择“课程索引”页。 当代码到达断点时,检查 query 变量。 可以看到发送到SQL Server的查询。 这是一个简单的 Select 语句:

{SELECT 
[Extent1].[CourseID] AS [CourseID], 
[Extent1].[Title] AS [Title], 
[Extent1].[Credits] AS [Credits], 
[Extent1].[DepartmentID] AS [DepartmentID]
FROM [Course] AS [Extent1]}

显示示例 Web 应用程序“通用存储库”选项卡的屏幕截图。已选择查询变量。

查询可能太长,无法在 Visual Studio 的调试窗口中显示。 若要查看整个查询,可以复制变量值并将其粘贴到文本编辑器中:

显示变量值的屏幕截图,其中显示了选择变量值时显示的下拉菜单。突出显示了“复制值”选项。

现在,你将在课程索引页中添加一个下拉列表,以便用户可以筛选特定部门。 你将按标题对课程进行排序,并指定导航属性的 Department 预先加载。 在 CourseController.cs 中,将 方法替换为 Index 以下代码:

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 所有部门的集合将传递到下拉列表的视图。 传递给 SelectList 构造函数的参数指定值字段名称、文本字段名称和所选项。

Get对于存储库的 Course 方法,代码指定筛选表达式、排序顺序以及导航属性的Department预先加载。 如果在下拉列表中未选择任何内容,则筛选器表达式始终返回 true , (SelectedDepartment 为 null) 。

Views\Course\Index.cshtml 中,紧邻开始 table 标记之前,添加以下代码以创建下拉列表和提交按钮:

@using (Html.BeginForm())
{
    <p>Select Department: @Html.DropDownList("SelectedDepartment","All")   
    <input type="submit" value="Filter" /></p>
}

在类中 GenericRepository 仍设置断点的情况下,运行课程索引页。 继续执行代码命中断点的前两次,以便页面显示在浏览器中。 从下拉列表中选择一个部门,然后单击“ 筛选”:

显示“课程索引”页的屏幕截图,其中选择了“经济系”。

这一次,第一个断点将是针对下拉列表的部门查询。 跳过该代码, 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)}

可以看到,查询现在是一个与数据一JOINCourse加载Department数据的查询,并且它包含 子WHERE句。

使用代理类

例如,当实体框架 (创建实体实例时,当你执行查询) 时,它通常会将它们创建为动态生成的派生类型的实例,该派生类型充当实体的代理。 此代理替代实体的一些虚拟属性,以插入挂钩,用于在属性被访问时自动执行操作。 例如,此机制用于支持延迟加载关系。

大多数情况下,你不需要知道代理的这种使用,但有一些例外情况:

  • 在某些情况下,你可能希望阻止实体框架创建代理实例。 例如,序列化非代理实例可能比序列化代理实例更高效。
  • 使用 new 运算符实例化实体类时,不会获得代理实例。 这意味着你不会获得延迟加载和自动更改跟踪等功能。 这通常可以:通常不需要延迟加载,因为要创建不在数据库中的新实体,并且如果显式将实体标记为 Added,则通常不需要更改跟踪。 但是,如果确实需要延迟加载并且需要更改跟踪,则可以使用 Create 类的 DbSet 方法通过代理创建新的实体实例。
  • 你可能想要从代理类型获取实际的实体类型。 可以使用 GetObjectType 类的 ObjectContext 方法来获取代理类型实例的实际实体类型。

有关详细信息,请参阅 Entity Framework 团队博客上的 使用代理

禁用更改的自动检测

Entity Framework 通过比较的实体的当前值与原始值来判断更改实体的方式 (因此需要发送更新到数据库)。 原始值在查询或附加实体时存储。 如下方法会导致自动脏值:

  • DbSet.Find
  • DbSet.Local
  • DbSet.Remove
  • DbSet.Add
  • DbSet.Attach
  • DbContext.SaveChanges
  • DbContext.GetValidationErrors
  • DbContext.Entry
  • DbChangeTracker.Entries

如果要跟踪大量实体,并在循环中多次调用其中一种方法,则通过使用 AutoDetectChangesEnabled 属性暂时关闭自动更改检测,可能会显著提高性能。 有关详细信息,请参阅 自动检测更改

保存更改时禁用验证

调用 SaveChanges 方法时,默认情况下,实体框架将在更新数据库之前验证所有已更改实体的所有属性中的数据。 如果已更新大量实体,并且已验证数据,则不需要执行此工作,并且可以通过暂时关闭验证来减少保存更改的过程所花费的时间。 可以使用 ValidateOnSaveEnabled 属性执行此操作。 有关详细信息,请参阅验证

总结

完成本系列教程,介绍如何在 ASP.NET MVC 应用程序中使用实体框架。 可以在 ASP.NET 数据访问内容映射中找到指向其他实体框架资源的链接。

有关如何在生成 Web 应用程序后部署它的详细信息,请参阅 MSDN 库中 ASP.NET 部署内容映射

有关与 MVC 相关的其他主题(例如身份验证和授权)的信息,请参阅 MVC 建议的资源

致谢

  • Tom Dykstra 撰写了本教程的原始版本,是 Microsoft Web 平台和工具内容团队的高级编程编写者。
  • Rick Anderson (twitter @RickAndMSFT) 共同创作了本教程,并执行了为 EF 5 和 MVC 4 更新它的大部分工作。 Rick 是 Microsoft 的高级编程作家,专注于 Azure 和 MVC。
  • Rowan Miller 和 Entity Framework 团队的其他成员协助进行代码评审,并帮助调试在更新 EF 5 教程时出现的许多迁移问题。

VB

最初生成本教程时,我们提供了已完成的下载项目的 C# 和 VB 版本。 通过此更新,我们将为每个章节提供一个 C# 可下载的项目,以便更轻松地在系列中的任何位置入门,但由于时间限制和其他优先级,我们没有为 VB 执行此操作。 如果你使用这些教程生成 VB 项目,并且愿意与他人共享,请告知我们。

错误和解决方法

无法创建/卷影副本

错误消息:

当该文件已存在时,无法创建/隐藏副本“DotNetOpenAuth.OpenId”。

解决方案:

等待几秒钟,然后刷新页面。

无法识别Update-Database

错误消息:

术语“Update-Database”不能识别为 cmdlet、函数、脚本文件或可操作程序的名称。 请检查名称的拼写,如果包含路径,请验证该路径是否正确,并重试。 (从 Update-Database PMC.) 中的 命令

解决方案:

退出 Visual Studio。 重新打开项目,然后重试。

验证失败

错误消息:

一个或多个实体的验证失败。 有关更多详细信息,请参阅“EntityValidationErrors”属性。 (从 Update-Database PMC.) 中的 命令

解决方案:

此问题的一个原因是方法运行时出现 Seed 验证错误。 有关调试方法的提示,请参阅 将实体框架 (EF) 数据库进行种子设定和 调试 Seed

HTTP 500.19 错误

错误消息:

HTTP 错误 500.19 - 内部服务器错误
无法访问请求的页面,因为该页的相关配置数据无效。

解决方案:

出现此错误的一种方法是具有解决方案的多个副本,其中每个副本使用相同的端口号。 通常可以通过退出 Visual Studio 的所有实例,然后重启正在处理的项目来解决此问题。 如果这不起作用,请尝试更改端口号。 右键单击项目文件,然后单击“属性”。 选择“ Web ”选项卡,然后在“ 项目 URL” 文本框中更改端口号。

定位 SQL Server 实例出错

错误消息:

建立到 SQL Server 的连接时出现与网络相关或特定于实例的错误。 找不到或无法访问服务器。 请验证实例名称是否正确,SQL Server 是否已配置为允许远程连接。 (提供程序:SQL 网络接口,错误:26 - 定位指定服务器/实例出错)

解决方案:

检查连接字符串。 如果已手动删除数据库,请在构造字符串中更改数据库的名称。