为ASP.NET MVC应用程序使用高级功能

这是微软官方教程Getting Started with Entity Framework 6 Code First using MVC 5 系列的翻译,这里是第十二篇:为ASP.NET MVC应用程序使用高级功能

原文: Advanced Entity Framework 6 Scenarios for an MVC 5 Web Application

在之前的教程中,您已经实现了继承。本教程引入了当你在使用实体框架Code First来开发ASP.NET web应用程序时可以利用的高级功能。

对于本教程中所介绍的大多数主题,您将使用您已经创建的网页,使用原始的SQL进行批量更新。然后您将创建一个新的页面来更新数据库中所有课程的学分。

以及使用非跟踪的查询,你将在系编辑页面添加一个新的验证逻辑。

执行原始的SQL查询

实体框架Code First API包含的方法使您可以直接发送SQL命令到数据库中。您有以下几种选择:

  • 使用DbSet.SqlQuery方法来进行查询并返回实体类型。返回的对象类型必须是预期的DbSet对象,它们会由数据库上下文自动跟踪。除非您关闭跟踪。(参见下一节的AsNoTracking方法)

  • 使用Database.SqlQuery方法来进行查询并返回非实体类型。返回的对象不会被数据库上下文跟踪,即使您使用该方法来检索实体类型。

  • Database.ExecuteSqlCommand用于非查询类型的命令。

使用实体框架的优点之一是它可以让你无需手工输入大量代码来实现存取数据的特定方法。通过自动生成SQL查询及命令,将你从繁琐的手工编码中解放出来。但在特殊情况下,您可能需要执行手工创建的特定的SQL查询,这些方法能够实现这一功能并为你提供异常处理。

当你经常性地在web应用程序中执行SQL命令时,你必须采取必要的预防措施来保护你的站点不受SQL注入攻击。其中的一个办法就是使用参数化的查询,确保来自web页的的字符串不会被解释为SQL命令。在本教程中,当您使用用户输入查询时,您将使用参数化的查询。

调用一个查询来返回实体

DbSet<TEntity> 类提供了一个方法,您可以使用该方法来执行一个查询并返回一个实体类型。要观察该方法是如何工作的,你需要对Department控制器中的Details方法进行一些更改。

在DepartmentController.cs中,使用下面的代码替换Details方法,高亮部分显示了需要进行的更改:

        public async Task<ActionResult> Details(int? id)
        {
            if (id == null)
            {
                return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
            }
            //Department department = await db.Departments.FindAsync(id);
            string query = "select * from department where departmentid = @p0";
            Department department = await db.Departments.SqlQuery(query, id).SingleOrDefaultAsync();

            if (department == null)
            {
                return HttpNotFound();
            }
            return View(department);
        }

要验证新代码是否工作正常,请运行应用程序,转到系页面并点击某个系的详情。

你可以看到一切如之前一样正常工作。

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

在较早的教程中您创建了一个学生统计网格用来显示每个注册日期中注册的学生数目。这段代码使用了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方法,高亮部分显示了需要进行的更改:

        public ActionResult About()
        {
            //var data = from student in db.Students
            //           group student by student.EnrollmentDate into dateGroup
            //           select new EnrollmentDateGroup()
            //           {
            //               EnrollmentDate = dateGroup.Key,
            //               StudentCount = dateGroup.Count()
            //           };
            string query = "select EnrollmentDate,count(*) as studentCount "
                + "From Person "
                + "where discriminator = 'Student' "
                + "group by EnrollmentDate ";
            var data = db.Database.SqlQuery<EnrollmentDateGroup>(query);
            return View(data.ToList());
        }

运行页面,它会显示和之前一样的数据。

调用更新查询

假设管理员想要能够在数据库进行批量操作,例如为每一门课程更改学分。如果学校有大量的课程,针对每一门课程分别进行更新无疑是效率非常低下的做法。在本节中你会实现一个web页面使用户能够修改全部课程的学分,通过使用SQL Update语句来进行这一更改,如下图:

在CourseController.cs,添加HttpGet和HttpPost的UpdateCourseCredits方法:

        public ActionResult UpdateCourseCredits()
        {
            return View();
        }

        [HttpPost]
        public ActionResult UpdateCourseCredits(int ? multiplier)
        {
            if (multiplier != null)
            {
                ViewBag.RowsAffected = db.Database.ExecuteSqlCommand("update course Set Credits = Credits * {0}", multiplier);
            }
            return View();
        }

当控制器处理HttpGet请求时,ViewBag.RowsAffected将不返回任何值。视图将显示一个空的文本框及提交按钮。

当点击更新按钮时,调用HttpPost方法,获取在文本框中输入的值,代码执行SQL来更新课程并在ViewBag.RowsAffected中返回受影响的行数。当视图获取该变量的值,它将显示一条信息来说明已经更新的课程数目,而不是文本框和提交按钮,如下图所示:

在CourseController.cs,右键点击UpdateCourseCredits方法,然后添加一个视图:

使用下面的代码替换视图中的:

@model ContosoUniversity.Models.Course
@{
    ViewBag.Title = "UpdateCourseCredits";
}

<h2>UpdateCourseCredits</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="su" name="name" value="Update" /></p>
    }
}
@if (ViewBag.RowsAffected != null)
{
    <p>
        Number of rows updated: @ViewBag.RowsAffected
    </p>
}
<div>
    @Html.ActionLink("Back to List", "Index")
</div>

运行应用程序,添加"/UpdateCourseCredits"到浏览器地址栏中的末尾,如Http://localhost:40675/UpdateCourseCredits,打开页面,并在文本框中输入一个数字:

点击更新,你会看到受影响的课程:

然后返回列表,你会看到所有课程都进行了更新:

有关更多使用原始SQL查询的信息,请参阅MSDN上的 Raw SQL Queries 。

非跟踪查询

当数据库上下文检索数据行并创建实体对象时,默认情况下它会跟踪内存中的实体是否与数据库中的同步。当您更新一个实体时,内存中的数据作为缓存。这种缓存在web应用程序中经常是不可用的,因为上下文实例通常是短生命期的(每个请求都会创建一个新实例),并且上下文经常在读取过实体并使用后就将它们销毁了。

您可以使用AsNoTracking方法来来禁用跟踪内存中的实体对象。在以下几种典型场景中,你可能需要这样做:

  • 需要检索大量的数据,而关闭跟踪可能会显著提高性能。

  • 您需要附加一个实体来更新它,但它是之前基于不同的目的获取的同一个实体对象。因为该实体已经被数据库的上下文跟踪,你无法附加该实体以进行更改。这种情况下,你需要对较早的查询使用AsNoTracking选项。

在本节中你会实现上面第二个方案的业务逻辑。具体来说,你会强制执行一名教师不能在多个系中担任主任的规则。

在DepartmentController.cs,添加一个新方法,使您可以从Edit和Create方法来调用它以确保没有两个系有相同的主任:

        private void ValidateOneAdministratorAssignmentPerInstructor(Department department)
        {
            if (department.InstructorID != null)
            {
                Department duplicateDepartment = db.Departments
                    .Include("Administrator")
                    .Where(d => d.InstructorID == department.InstructorID)
                    .FirstOrDefault();
                if (duplicateDepartment != null && duplicateDepartment.DepartmentID != department.DepartmentID)
                {
                    string errorMessage = string.Format(
                        "教师{0} {1}已经是{2}系的主任。",
                        duplicateDepartment.Administrator.FirstMidName,
                        duplicateDepartment.Administrator.LastName,
                        duplicateDepartment.Name);
                    ModelState.AddModelError(string.Empty, errorMessage);
                }
            }
        }

在HttpPost的Edit方法中的try代码块中调用该方法来验证,如下面的代码:

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> Edit([Bind(Include = "DepartmentID,Name,Budget,StartDate,RowVersion,InstructorID")] Department department)
        {
            try
            {
                if (ModelState.IsValid)
                {
                    ValidateOneAdministratorAssignmentPerInstructor(department);
                }
                if (ModelState.IsValid)
                {
                    db.Entry(department).State = EntityState.Modified;
                    await db.SaveChangesAsync();
                    return RedirectToAction("Index");
                }
            }
            catch (DbUpdateConcurrencyException ex)
            {
                var entry = ex.Entries.Single();
                var clientValues = (Department)entry.Entity;
                var databaseEntry = entry.GetDatabaseValues();
                if (databaseEntry == null)
                {
                    ModelState.AddModelError(string.Empty, "无法保存更改,系已经被其他用户删除。");
                }
                else
                {
                    var databaseValues = (Department)databaseEntry.ToObject();
                    if (databaseValues.Name != clientValues.Name)
                        ModelState.AddModelError("Name", "当前值: "
                            + databaseValues.Name);
                    if (databaseValues.Budget != clientValues.Budget)
                        ModelState.AddModelError("Budget", "当前值: "
                            + String.Format("{0:c}", databaseValues.Budget));
                    if (databaseValues.StartDate != clientValues.StartDate)
                        ModelState.AddModelError("StartDate", "当前值: "
                            + String.Format("{0:d}", databaseValues.StartDate));
                    if (databaseValues.InstructorID != clientValues.InstructorID)
                        ModelState.AddModelError("InstructorID", "当前值: "
                            + db.Instructors.Find(databaseValues.InstructorID).FullName);
                    ModelState.AddModelError(string.Empty, "当前记录已经被其他人更改。如果你仍然想要保存这些数据,"
                    + "重新点击保存按钮或者点击返回列表撤销本次操作。");
                    department.RowVersion = databaseValues.RowVersion;
                }
            }
            catch (RetryLimitExceededException)
            {
                ModelState.AddModelError(string.Empty, "无法保存更改,请重试或联系管理员。");
            }
            ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", department.InstructorID);
            return View(department);
        }

运行系编辑页面,尝试将已经是系主任的教师更改为另一个系的主任,你会收到预期的错误消息:

再次运行系编辑页面,更改预算金额并保存,您会看到一个错误:

该错误的出现是以下原因导致的:

  • 该Edit方法调用了ValidateOneAdministratorAssignmentPerInstructor方法,用来在全部系中检索系主任。这会导致要编辑的系被读取,由于此读取操作,该系实体正在被数据库上下文跟踪。

  • Edit方法尝试设置由模型绑定器创建的该实体的标志位为已修改的,并使用上下文隐式地尝试附加该实体。但上下文无法附加该实体,因为它已经被上下文跟踪了。

解决这一问题的一个办法是保持内存中用于验证查询的跟踪系实体的上下文,但这样做没有必要,因为你不需要更新该实体,或者重新从内存中读取它,但这样不会带来任何好处。

在验证方法中,指定不跟踪,如下面的代码所示:

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

重复之前的操作,这一次更新被成功保存。

检查发送到数据库的SQL

有时候,查看实际被发送到数据库的SQL查询是很有帮助的,在较早的教程中,您看到了如何使用拦截器代码来执行这一工作,现在你将看到如何不使用拦截器的方法。要尝试该方法,你会检查一个简单查询并观察添加比如预先加载、过滤及排序,看看到底发生了什么。

在CourseController.cs,使用下面的代码替换原先的,以停止预先加载。

        public ActionResult Index()
        {
            var courses = db.Courses;
            var sql = courses.ToString();
            return View(courses.ToList());
        }

然后在return语句上设置一个断点,并按下F5在调试模式下运行该项目,选择课程索引页,当代码到达断点时,检查query变量,你将看到被发送的SQL的查询,它是一个简单的select语句。

你可以在监视窗口中使用文本可视化工具来检视SQL。

现在将一个下拉列表添加到课程索引页面,用户可以用来筛选特定的系。你会使用标题来进行排序,并指定系导航属性的预先加载。

在CourseController.cs,使用下面的代码替换Index方法:

        public ActionResult Index(int? SelectedDepartment)
        {
            var departments = db.Departments.OrderBy(q => q.Name).ToList();
            ViewBag.SelectedDepartment = new SelectList(departments, "DepartmentID", "Name", SelectedDepartment);
            int departmentID = SelectedDepartment.GetValueOrDefault();

            IQueryable<Course> courses = db.Courses
                .Where(c => !SelectedDepartment.HasValue || c.DepartmentID == departmentID)
                .OrderBy(d => d.CourseID)
                .Include(d => d.Department);
            var sql = courses.ToString();
            return View(courses.ToList());
        }

仍然在return上设置断点。

该方法接收下拉列表中选择的值,如果没有任何项目被选择,该参数为null。

一个包含所有系的SelectList集合被传递给视图的下拉列表。传递给SelectList的构造器的参数指定了值字段名,文本字段名和所选择的项目。

对于课程仓库的Get方法,代码指定了Department导航属性的筛选器表达式,一个排序和延迟加载。如果下拉下表中没有选择任何项,筛选表达式总是返回true。

在Views\Course\Index.cshtml中,在table开始标记之前,插入下面的代码来创建下拉列表和提交按钮。

@using (Html.BeginForm())
{
    <p>选择系:@Html.DropDownList("SelectedDepartment", "All")</p>
    <input type="submit" name="name" value="筛选" />
}

运行索引页,在一次遇到断点时继续运行以便显示页面,从下拉列表中选择一个系并点击筛选:

按照刚才的方法查看SQL语句,你会看到一个包含内连接查询的SQL。

SELECT 
    [Project1].[CourseID] AS [CourseID], 
    [Project1].[Title] AS [Title], 
    [Project1].[Credits] AS [Credits], 
    [Project1].[DepartmentID] AS [DepartmentID], 
    [Project1].[DepartmentID1] AS [DepartmentID1], 
    [Project1].[Name] AS [Name], 
    [Project1].[Budget] AS [Budget], 
    [Project1].[StartDate] AS [StartDate], 
    [Project1].[InstructorID] AS [InstructorID], 
    [Project1].[RowVersion] AS [RowVersion]
    FROM ( 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].[InstructorID] AS [InstructorID], 
        [Extent2].[RowVersion] AS [RowVersion]
        FROM  [dbo].[Course] AS [Extent1]
        INNER JOIN [dbo].[Department] AS [Extent2] ON [Extent1].[DepartmentID] = [Extent2].[DepartmentID]
        WHERE @p__linq__0 IS NULL OR [Extent1].[DepartmentID] = @p__linq__1
    )  AS [Project1]
    ORDER BY [Project1].[CourseID] ASC

删除代码中的var sql = conrses.ToString();

仓储和单元工作模式

许多开发人员编写代码作为包装来实现实体框架的仓储和单元工作模式。这些模式在商业逻辑层和数据存取层之间创建了一个抽象层。实施这些模式可以帮助你的应用程序从数据存储的改变中隔离出来,并且促进自动化的单元测试开发。但是,对使用实体框架的程序编写额外的代码来实现这些模式并不是最佳的选择,有以下几个原因:

  • 实体框架上下文类本身就可以将你的代码从特定代码的数据存储中隔离。

  • 当你使用实体框架时,对于数据库更新操作实体框架上下文类可以作为一个工作单元类。

  • 在实体框架6版本中引入的功能使它在无需编写仓储代码的情况下来实现单元测试驱动。

有关如何执行仓储及单元工作模式的详细信息,请参阅 the Entity Framework 5 version of this tutorial series 。有关如何在实体框架6版本中执行单元测试驱动,请参阅:

  • How EF6 Enables Mocking DbSets more easily

  • Testing with a mocking framework

  • Testing with your own test doubles

代理类

在实体框架创建实体实例时(例如当你执行一个查询时),它总是创建作为动态生成的派生自实体的实体对象的代理。例如下面的两个调试器截图,在第一个图像中,您看到了一个预期为Student类型的student变量,在实例化实体后,第二个图像中你会看到该代理类。

代理类重写了实体的一些虚属性用来插入在访问属性时自动执行动作的钩子。其中一个使用这种机制就功能就是延迟加载。

大多数时候你并不会察觉到代理,但也有例外:

  • 某些情况下,你可能想要阻止实体框架创建代理实例。例如,通常你希望对一个POCO类的实体进行序列化,而不是代理类。一种避免序列化问题的方法是序列化数据传输对象(DTOs)而不是实体对象,比如Using Web API with Entity Framework。另一种方法就是disable proxy creation。

  • 当你使用new运算符实例化一个实体类时,你得到的不是代理实例。这意味着你无法获得诸如延迟加载和自动跟踪的能力。这通常是好的:你一般不需要延迟加载,因为你需要创建一个并不在数据库中存在的新的实体,当你显式地将实体标记为Added时,你通常不需要修改跟踪。然而,如果你需要延迟加载,你需要更改跟踪,你可以通过使用DbSet类的Create方法通过代理来创建一个新实体对象。

  • 你可能会想要从一种代理类型获得一个真是的实体类型。ObjectContext类的GetObjectType方法可以用于获得代理类型的实际实体类型。

更多的信息,请参阅MSDN上的 Working with Proxies 。

自动变化监测

实体框架使用比较实体的当前值和原始值来确定一个实体是否被更改(以及因此而需要发送到数据库执行的更新)。实体在查询或附加时,原始值被保存起来。一些会导致自动变化监测的方法如下:

  • DbSet.Find

  • DbSet.Local

  • DbSet.Remove

  • DbSet.Add

  • DbSet.Attach

  • DbContext.Savechanges

  • DbContext.GetValidationErrors

  • DbContext.Entry

  • DbChangeTracker.Entries

如果您正在跟踪大量实体,同时您在一个循环中调用了这些方法多次,您可能会通过使用 AutoDetectChangesEnabled 属性来暂时关闭自动变化监测,从而获得程序性能的改进。

自动验证

当您调用SaveChanges方法时,在默认情况下,实体框架会在更新到数据库之前对所有已更改的实体中的全部属性进行验证。如果您更新了大量的实体并且已经对数据进行了验证,该工作是不必要的,你可以通过暂时关闭验证来获得更少的处理保存时间。你可以使用 ValidateOnSaveEnabled 属性。

Entity Framework Power Tools

Entity Framework Power Tools 是一个简单的VS扩展,你可以使用它来创建本教程中展示的数据模型图。该工具还可以做其他一些工作比如当你使用Code First时基于现有数据库的表来生成实体类。安装该工具后,你会在上下文菜单看到一些附加选项,例如,当你右键单击解决方案资源管理器的上下文类,你会得到一个选项来生成一个图表。当你使用Code First时无法修改关系图中的数据模型,但你可以移动图示使它更容易理解。

实体框架的源代码

你可以在 http://entityframework.codeplex.com/ 获得实体框架6的源代码,除了源代码,你可以生成、跟踪问题、探查功能等更多,你可以提交bug并贡献你自己的增强功能给实体框架源代码。

虽然源代码是开放的,但实体框架是由微软完全支持的产品。微软实体框架团队会不断地接收反馈及测试更改,以确保每个版本的质量。

总结

这样,在ASP.NET MVC应用程序中使用实体框架这一系列教程就全部完成了。有关如何使用实体框架的更多信息,请参阅 EF documentation page on MSDN 和 ASP.NET Data Access - Recommended Resources 。

有关如何在你建立应用程序后部署它,请参阅 ASP.NET Web Deployment - Recommended Resources 。

关于更多MVC的信息,请参阅 ASP.NET MVC - Recommended Resources 。

致谢

  • Tom Dykstra基于实体框架5编写了本教程的原始版本,并在之基础上编写了该教程。他是微软Web平台和工具团队的高级程序员作家。

  • Rick Anderson在实体框架5和MVC4教程中做了大量工作并合著了实体框架6更新,他是微软Azure和MVC的资深程序员作家。

  • Rowan Miller和其他的实体框架团队审查该教程并调试了大量的bug。

作者信息

Tom Dykstra - Tom Dykstra 是微软Web平台及工具团队的高级程序员,作家。