首页 > 代码库 > MVC5 Entity Framework学习之实现基本的CRUD功能

MVC5 Entity Framework学习之实现基本的CRUD功能

在上一篇文章中,我们使用Entity Framework 和SQL Server LocalDB创建了一个MVC应用程序,并使用它来存储和显示数据。在这篇文章中,你将对由 MVC框架自动创建的CRUD(create, read, update, delete)代码进行修改。

注意:通常我们在控制器和数据访问层之间创建一个抽象层来实现仓储模式,为了将注意力聚焦在如何使用实体框架上,这里暂没有使用仓储模式。

在本篇文章中,要创建的web页面:




1.创建一个Details页面

由框架代码生成的Students Index页面暂没有考虑Enrollments属性,因为该属性是一个集合。在Details页面中,我们将在HTML表格中显示集合中的内容。

打开 Controllers\StudentController.cs,可以看到对应Details视图的Details方法使用Find方法来检索单个学生实体:

public ActionResult Details(int? id)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Student student = db.Students.Find(id);
    if (student == null)
    {
        return HttpNotFound();
    }
    return View(student);
}

Details方法的id参数来自Index页面中Details链接,称为路由数据(route data)。

路由数据是指在路由表中指定,通过URL传递,由模型绑定器接收的数据。如下所示,默认路由指定了controller, action和 id

 routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
在下面的 URL中,默认路由将Instructor 映射为controller, Index映射为action, 1 映射为 id

http://localhost:1230/Instructor/Index/1?courseID=2021
"?courseID=2021" 是查询字符串, 如果你将id作为查询字符串,模型绑定器也能正常解析

http://localhost:1230/Instructor/Index?id=1&CourseID=2021
在Razor视图中,由ActionLink语句来创建URL,如下面的代码中id参数匹配默认路由,所以id被作为进路由数据
 @Html.ActionLink("Select", "Index", new { id = item.PersonID  })
下面的代码中courseID参数 不匹配默认路由,所以courseID被作为查询字符串
@Html.ActionLink("Select", "Index", new { courseID = item.CourseID }) 


打开Views\Student\Details.cshtml,每个字段都使用DisplayFor帮助器来显示数据,如下面的代码所示:

<dt>
    @Html.DisplayNameFor(model => model.LastName)
</dt>
<dd>
    @Html.DisplayFor(model => model.LastName)
</dd>

在EnrollmentData字段之后,</dl>标签之前,添加下面的代码

        <dt>
            @Html.DisplayNameFor(model => model.EnrollmentDate)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.EnrollmentDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Enrollments)
        </dt>
        <dd>
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Enrollments)
                {
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                }
            </table>
        </dd>
    </dl>
</div>
<p>
    @Html.ActionLink("Edit", "Edit", new { id = Model.ID }) |
    @Html.ActionLink("Back to List", "Index")
</p>

如果代码缩进不正确,可以使用Ctrl-K-D快捷键来纠正它。 

上面的代码遍历Enrollments导航属性中的实体,对于每一个Enrollment实体,显示出Course Title 和Grade。Course Title是从Enrollments实体中的Course导航属性中的Course实体中获取的,所有这些数据豆是在需要时自动从数据库检索的。(换句话说,这里使用的是延迟加载。你没有为Courses导航属性指定预先加载,所以在同一次查询中,只检索了Students数据而没有检索enrollments数据。相反,在第一次试图访问Enrollments导航属性时,会创建一个新的查询并发送到数据库。

运行项目,选择Students 选项卡并点击名为Alexander Carson的Details 链接。(如果你按Ctrl+F5,直接打开Details.cshtml,会得到HTTP 400错误,因为Visual Studio会直接打开Details页面却没有指定任何一个studen,路由匹配错误导致程序出错。在这种情况下,你只需要从URL中删除Student/Details然后重试)

可以看到所选学生的courses 和grades



2.更新Create 页面

打开Controllers\StudentController.cs,使用下面的代码替换HttpPost Create方法

[HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Create([Bind(Include = "LastName,FirstMidName,EnrollmentDate")] Student student)
        {
            try
            {
                if (ModelState.IsValid)
                {
                    db.Students.Add(student);
                    db.SaveChanges();
                    return RedirectToAction("Index");
                }
            }
            catch (DataException)
            {
                //Log the error (uncomment dex variable name and add a line here to write a log.
                ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists see your system administrator.");
            }
            return View(student);
        }

上面的代码将ASP.NET MVC模型绑定器创建的Student实体添加到Students 实体集并保存到数据库中。(模型绑定器可以让你更容易的提交表单数据,可以将提交的表单值转换为CLR值并将它们作为参数传递给Controller中的方法。在本项目中,模型绑定器使用了表单集合中的属性值实例化了一个Student 实体)

这里删除了Bind 属性中的ID参数,因为ID是primary key,SQL Server在插入数据时会自动设置该值。

安全注意:ValidateAntiForgeryToken属性有助于防止跨站请求伪造(cross-site request forgery)攻击,但是需要在视图中设置相应的Html.AntiForgeryToken()语句。

Bind属性可以防止过份提交(over-posting)。举例来说,假设Student实体中包含一个Secret 字段,你不希望在Web页面中更新它

 public class Student
   {
      public int ID { get; set; }
      public string LastName { get; set; }
      public string FirstMidName { get; set; }
      public DateTime EnrollmentDate { get; set; }
      public string Secret { get; set; }

      public virtual ICollection<Enrollment> Enrollments { get; set; }
   }

即使在Web页面中没有Secret字段,黑客也可以通过工具例如Fiddler或者JavaScript 将表单数据包括Secret值提交到服务器。如果不使用Bind属性来限制模型绑器需要的字段,模型绑定器会将接收到的Secret值更新至数据库中,下面的截图是通过Fiddler工具来提交表单数据




OverPost值将会被成功的更新至数据库,这是你不希望看到的。

为了安全起见,最好使用Bind属性的Include参数,也可以使用Exclude参数排除那些你不想要更新的属性。但是这里推荐使用Include,因为如果你在实体中添加了一个新的属性,Exclude并不会将这个新添加的属性排除在外。

另一种替代方法是在模型绑定时使用视图模型,视图模型中只包含你想要绑定的属性。

除了Bind属性,上面的代码中只需要加入try-catch块,如果在保存更改时引发DataException异常,就会在页面中显示相应的错误信息。DataException异常有时是由外部事件引发而不是因为程序错误,所以建议用户重试。记住在生产环境下,所有的应用程序错误都应该被记录下来。

Views\Student\Create.cshtml中的代码和Details.cshtml中的很相似,除了DisplayFor被EditorFor和ValidationMessageFor帮助器替代

<div class="form-group">
    @Html.LabelFor(model => model.LastName, new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.EditorFor(model => model.LastName)
        @Html.ValidationMessageFor(model => model.LastName)
    </div>
</div>

Create.cshtml也包含了@Html.AntiForgeryToken()方法以防止跨站请求伪造攻击。

运行项目,选择Students选项卡,并点击Create New

输入姓名和无效的日期,然后单击Create查看错误消息


默认情况下使用的是服务器端验证,以后会教大家通过添加属性来生成客户端验证,下面的代码展示了Create 方法中的模型验证检查

if (ModelState.IsValid)
{
    db.Students.Add(student);
    db.SaveChanges();
    return RedirectToAction("Index");
}

修改日期为一个有效的值,点击Create,可以看到新添加的Student信息


3.更新Edit HttpPost页面

在Controllers\StudentController.cs中,HttpGet Edit方法(没有使用HttpPost属性的那一个)和Details方法一样使用Find方法来检索所选择的Student实体。

使用下面的代码替换HttpPost Edit方法:

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Edit([Bind(Include = "ID,LastName,FirstMidName,EnrollmentDate")] Student student)
        {
            try
            {
                if (ModelState.IsValid)
                {
                    db.Entry(student).State = EntityState.Modified;
                    db.SaveChanges();
                    return RedirectToAction("Index");
                }
            }
            catch (DataException /* dex */)
            {
                //Log the error (uncomment dex variable name and add a line here to write a log.
                ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists see your system administrator.");
            }
            return View(student);
        }

上面的代码类似于HttpPost Create方法,但不同的是这里在实体中设置了一个标志位来指明它已经被更改,而不是将由模型绑定器创建的实体添加到实体集。当调用SaveChanges方法时,Modified标志会导致 Entity Framework创建SQL语句并更新数据库。数据库中该行的所有列都将被更新,包括那些用户并没有更改的,并忽略并发冲突。

实体状态、附加和SaveChanges方法

数据库上下文会一直跟踪内存中的实体是否与数据库中的行保持同步,并由此决定当调用SaveChanges方法时会发生什么,例如,当你调用Add方法添加实体时,该实体的状态会被设置为Added,然后当调用SaveChanges方法时,数据库上下文会生成一个SQL Insert命令。

一个实体可能处于以下状态之一:

  • Added,数据库并不存在该实体,SaveChanges方法必须生成一个Insert语句。
  • Unchanged,对该实体,SaveChanges方法什么都不需要做,当从数据库中读取一个实体时,该实体就为这一状态。
  • Modified,某些或所有实体的属性值被更改,SaveChanges方法必须生成一个Update语句。
  • Deleted。实体已被标志为删除状态,SaveChanges方法必须生成一个Delete语句。
  • Detached,实体没有被数据库上下文跟踪。

在桌面应用程序中,状态变化通常是自动的,当你读取一个实体并更改它的一些属性值,该实体的状态会自动更改为Modified,然后当你调用SaveChanges方法时,Entity Framework 会生成一个SQL Update来更新数据库。

DbContext 在读取一个实体并将其呈现到页面上后就会被销毁,当HttpPost Edit方法被调用,此时会生成一个新的请求和DbContext 实例,所以你必须手动设置实体状态为Modified,然后当你调用SaveChanges方法时,Entity Framework 会更新数据库行的所有列,因为数据库上下文没有办法知道你到底更改了哪些属性。

如果你希望SQL Update语句只更新那些用户实际更改的字段,你可以先将原来的值以某种方法(比如隐藏字段)保存起来,这样在调用HttpPost Edit方法时就可以使用它们,然后你可以使用原来的值来创建一个Student实体,调用Attach方法,并使用新的值更新该实体,最后调用SaveChanges方法。

Views\Student\Edit.cshtml 中的HTML 和Razor代码与Create.cshtml中的很类似。

运行项目,选择Students选项卡,点击其中一个学生的Edit链接


修改其中的值,点击Save,可以在Index页面中看到已经修改过的数据


4.更新Delete页面

在Controllers\StudentController.cs中,由模板生成的HttpGet Delete方法使用Find方法检索所选的Student实体。然而,当调用SaveChanges方法失败时为了显示自定义的错误信息,你需要向该方法和相对应的视图中添加一些功能。

就像update和create操作,delete操作也需要两个动作方法。用于响应Get请求的方法用来显示一个可以让用户<批准或取消delete操作的视图,如果用确认执行delete操作,此时会产生一条POST请求,并调用HttpPost Delete方法,该方法执行真正的delete操作。

在HttpPost Delete方法中添加try-catch块可以用来捕获数据库更新时可能出现的任何错误,如果出现了错误,则HttpPost Delete方法会调用HttpGet Delete方法,并向其传递一个参数指明发生了错误,然后HttpGet Delete会显示一个错误信息,并给用户一个取消或重试的机会。

使用下面的代码替换HttpGet Delete方法:

public ActionResult Delete(int? id, bool? saveChangesError=false)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    if (saveChangesError.GetValueOrDefault())
    {
        ViewBag.ErrorMessage = "Delete failed. Try again, and if the problem persists see your system administrator.";
    }
    Student student = db.Students.Find(id);
    if (student == null)
    {
        return HttpNotFound();
    }
    return View(student);
}

上面的代码接受一个可选择参数,指明该方法在保存更改出现错误后是否被调用。当HttpGet Delete方法不是由于出现错误而被调用的话,该参数值为false,当HttpPost Delete出现了错误而调用HttpGet Delete方法时该参数为true并在相应的视图上显示错误信息。

使用下面的代码替换HttpPost Delete方法(名称为DeleteConfirmed的那个),此方法用来执行真正的delete操作并捕获任何数据库更新错误

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Delete(int id)
{
    try
    {
        Student student = db.Students.Find(id);
        db.Students.Remove(student);
        db.SaveChanges();
    }
    catch (DataException/* dex */)
    {
        //Log the error (uncomment dex variable name and add a line here to write a log.
        return RedirectToAction("Delete", new { id = id, saveChangesError = true });
    }
    return RedirectToAction("Index");
}

上面的代码从数据库中检索要删除的实体,然后调用Remove方法将实体的状态设置为Deleted,最后调用SaveChanges方法并生成一条SQL Delete命令。另外你也可以将方法名DeleteConfirmed改为Delete。框架代码将HttpPost Delete方法命名为DeleteConfirmed是为了为其设置一个独一无二的名称(CLR重载方法需要有不同的参数)。现在遵守MVC的约定,HttpPost和HttpGet delete方法使用了相同的名字,并为它们设置不同的参数。

如果你想提高高访问量应用程序的性能,你要避免使用不必要的SQL查询。使用下面的代码替换Find和Remove方法

Student studentToDelete = new Student() { ID = id };
db.Entry(studentToDelete).State = EntityState.Deleted;

上面的代码使用唯一的主键值实例化了一个学生实体并设置实体状态为Deleted,这便是Entity Framework为了删除一个实体所需要做的动作。

如前所述HttpGet Delete方法并不会执行数据删除操作,在一个Get请求响应中执行delete操作(执行任何edit操作、create操作或者其它对数据进行更改的操作)将带来安全风险。

在Views\Student\Delete.cshtml中添加错误信息

<h2>Delete</h2>
<p class="error">@ViewBag.ErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>

运行项目,点击Students选项卡,点击其中一个学生的Delete链接:


点击Delete,你会看到在Index页面中该学生已经被删除。

5.确保数据库连接适时关闭

要确保数据库连接被正确的关闭并释放所占用的资源,在你使用完数据库上下文时,必须要将其销毁,这就是为什么框架代码在StudentController.cs的最后部分提供了一个Dispose方法

protected override void Dispose(bool disposing)
{
    db.Dispose();
    base.Dispose(disposing);
}

Controller类实现了IDisposeable接口,所以上面的代码通过重写Dispose(bool)方法来显式的销毁数据库上下文实例。

6.处理事务

默认情况下,Entity Framework隐式的实现事务处理。当你对多行或者多个表进行更改后调用SaveChanges方法,Entity Framework会自动确保所有更改要么全部成功要么全部失败。如果已经做完一些更改后发生了一个错误,那么所有的更改包括已做完的都将自动回滚。


项目源码:https://github.com/johnsonz/MvcContosoUniversity

THE END


MVC5 Entity Framework学习之实现基本的CRUD功能