CRUD 为创建、读取、更新、删除的英文首字母的缩写
作者:Tom Dykstra和Rick Anderson
Contoso 大学示例 web 应用程序演示了如何使用 Entity Framework Core 和 Visual Studio 创建 ASP.NET Core MVC web 应用程序。 有关系列教程的信息,请参阅第一个教程。
在前面的教程中,你使用 Entity Framework 和 SQL Server LocalDB 创建了一个用于存储和显示数据的 MVC 应用程序。 在本教程中,你将回顾和自定义MVC 基架自动为你在控制器和视图中创建的 CRUD (创建、 读取、 更新、 删除)代码。
使用仓库模式在控制器和数据访问层之间创建一个抽象层是常见的做法。为了使得本教程更简单和更专注与 EF 本身,这里没有使用仓库模式。查阅本系列最后一个教程可以获取更多有关与仓库模式的信息
在本教程中,您对以下页面进行处理:
学生索引页的基架代码中省略了Enrollments
属性,因为该属性是一个集合。 在详细信息页上,你将会在 HTML 表上显示集合的内容。
在Controllers/StudentsController.cs,在和详细信息视图相关的操作方法中使用SingleOrDefaultAsync
方法来检索单个Student
实体。 如以下突出显示的代码中所示,添加调用Include
。 ThenInclude
和AsNoTracking
方法的代码。
public async Task Details(int? id)
{
if (id == null)
{
return NotFound();
}
var student = await _context.Students
.Include(s => s.Enrollments)
.ThenInclude(e => e.Course)
.AsNoTracking()
.SingleOrDefaultAsync(m => m.ID == id);
if (student == null)
{
return NotFound();
}
return View(student);
}
Include
和ThenInclude
方法使得上下文加载Student.Enrollments
导航属性,并将Enrollment.Course
导航属性添加到每个Enrollment
中。 你将在读取相关的数据教程中了解相关方法。
AsNoTracking
方法返回的实体在当前上下文的生存期不会更新的场景中可以提高性能。 你将在本教程末尾了解更多有关AsNoTracking
的信息。
传递给Details
方法的关键值来自数据路由。 路由数据是模型联编程序在 URL 段中找到的数据。 例如,默认的路由指明了控制器、 操作方法和 id :
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
在下面的 URL 中,根据默认路由获得下面的路由数据,对应的控制器为Instructor
,操作方法为Index
、 id 为1
。
http://localhost:1230/Instructor/Index/1?courseID=2021
URL 的最后部分 (“?courseID=2021”) 是一个查询字符串。 如果将id
作为查询字符串值传递,模型联编程序也会将 ID 值作为参数传递给Details
方法:
http://localhost:1230/Instructor/Index?id=1&CourseID=2021
在Index页中,通过 Razor 视图中的标记帮助器创建 Url。 在以下 Razor 代码中,id
参数与默认路由匹配,因此id
被添加到路由数据中。
<a asp-action="Edit" asp-route-id="@item.ID">Edita>
当item.ID
为 6 时生成以下 HTML :
<a href="/Students/Edit/6">Edita>
在以下 Razor 代码中,studentID
与默认路由中的参数不匹配,因此,它以查询字符串的形式添加到 Url 。
<a asp-action="Edit" asp-route-studentID="@item.ID">Edita>
有关标记帮助的详细信息,请参阅中 ASP.NET Core 标记帮助程序。
打开Views/Students/Details.cshtml。 每个字段都使用DisplayNameFor
和DisplayFor
来显示,如下面的示例中所示:
<dt>
@Html.DisplayNameFor(model => model.LastName)
dt>
<dd>
@Html.DisplayFor(model => model.LastName)
dd>
最后一个字段后和在闭合标记前,添加以下代码以显示修读信息列表:
<dt>
@Html.DisplayNameFor(model => model.Enrollments)
dt>
<dd>
<table class="table">
<tr>
<th>Course Titleth>
<th>Gradeth>
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>
如果粘贴代码后,代码缩进有误,按 CTRL-K-D 格式化代码。
此代码循环访问Enrollments
导航属性中的实体。 对于每个修读信息,显示课程标题和评分。 课程标题在修读信息实体内Course
导航属性中的课程实体中检索。
运行应用程序,选择Student选项卡卡,然后单击详细信息一名学生的链接。 为所选学生查看课程和年级的列表:
在StudentsController.cs,修改 HttpPostCreate
方法,在其中添加 try catch 块和从Bind
特性中删除 ID 值。
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Create(
[Bind("EnrollmentDate,FirstMidName,LastName")] Student student)
{
try
{
if (ModelState.IsValid)
{
_context.Add(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and 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 模型联编程序创建的学生实体添加到学生实体集中,然后将所做的更改保存到数据库。 (模型联编程序是 ASP.NET MVC 的功能,它能让你更轻松地处理表单提交的数据; 模型联编程序将已提交的表单值转换为 CLR 类型,并将其作为参数传递给对应的操作方法。 接着,模型联编程序会使用表单提交的属性实例化学生实体。)
你从Bind
特性特性中删除ID
,因为当插入行时 SQL Server 将自动设置 ID 为主键。 在这里主键应该是来自用户输入的值不不是自动设置的 ID 值。
除Bind
属性,try catch 块是对基架的代码仅有的更改。 如果正在对所做的更改进行保存时捕捉到了继承自DbUpdateException
的异常,将显示通用的错误消息。 DbUpdateException
有时由外部程序造成,而不一定是编程错误,因此建议用户以重试一遍以排查错误来源。 尽管在此示例中没有体现,但在生产环境中运行的应用程序会记录异常。 有关详细信息,请参阅深入探索日志主题中监视和遥测 (使用 Azure构建真实世界云应用程序)。
ValidateAntiForgeryToken
特性有助于防止跨站点请求伪造 (CSRF) 攻击。 令牌通过表单标签帮助器自动注入到页面中,并自动加入到用户提交的表单信息中。 ValidateAntiForgeryToken
特性使得令牌生效。 有关 CSRF 的详细信息,请参阅反请求伪造。
在创建的情景下,在基架的代码中的Create
方法里加入Bind
特性是防止过度发布的一种方法。 例如,假设学生实体包含你不希望此网页更改的Secret
属性。
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; }
}
即使在网页上没有修改Secret
的字段,黑客可以使用 Fiddler 之类的工具或编写一些 JavaScript,发布Secret
的值。 当没有Bind
属性限制时,模型联编程序将使用Secret
表单值并使用它来创建学生实体实例。 然后为黑客指定的任何Secret
表单值都将在你的数据库中更新。 下图显示 Fiddler 工具添加Secret
(值为”OverPost”) 到待发布的表单值。
随后”OverPost”成功添加到新插入行的Secret
列,即使你不希望网页能够设置该属性。
你可以先从数据库读取实体,然后调用TryUpdateModel
方法,并在显式允许的属性列表中传递,这样做能够在编辑场景中有效防止过度发布。 在这系列教程中广泛使用这种方法。
开发人员更喜欢使用另外一种方法来防止过度发布,那就是使用视图模型,而不是绑定实体类与模型。 视图模型中只包含你想更新的属性。 当 MVC 模型联编程序执行完成后,将根据需要使用 AutoMapper 等工具将视图模型属性复制到实体实例。 对实体实例使用_context.Entry
将其状态设置为Unchanged
,然后将每个视图模型中的实体属性的Property("PropertyName").IsModified
设置为 true 。 该方法同时适用于编辑和创建场景。
Views/Students/Create.cshtml中的代码对每个字段使用label
, input
,和span
(用于验证消息)标签帮助器。
运行应用程序中,选择Students卡,然后单击Create。
输入名称和日期。 请尝试输入无效的日期,如果你的浏览器可以做到这一点 (某些浏览器强制你要使用日期选取器)。然后单击Create可查看错误消息。
默认情况下; 你获取到的是服务器端验证,在之后的教程中,你将知道如何通过添加特性来生成客户端验证的代码。 以下高亮代码演示了Create
方法中的模型验证。
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Create(
[Bind("EnrollmentDate,FirstMidName,LastName")] Student student)
{
try
{
if (ModelState.IsValid)
{
_context.Add(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.
ModelState.AddModelError("", "Unable to save changes. " +
"Try again, and if the problem persists " +
"see your system administrator.");
}
return View(student);
}
将日期更改为有效的值并单击Create,查看在Index页上显示的新学生。
在StudentController.cs,正如你在Details
方法中看到的,Edit
的HttpGet方法 (不是HttpPost
特性) 使用SingleOrDefaultAsync
方法来检索所选的学生实体。 在这里你不需要更改此方法。
将HttpPost 编辑操作方法替换为以下代码。
[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public async Task EditPost(int? id)
{
if (id == null)
{
return NotFound();
}
var studentToUpdate = await _context.Students.SingleOrDefaultAsync(s => s.ID == id);
if (await TryUpdateModelAsync(
studentToUpdate,
"",
s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
{
try
{
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
ModelState.AddModelError("", "Unable to save changes. " +
"Try again, and if the problem persists, " +
"see your system administrator.");
}
}
return View(studentToUpdate);
}
这些更改是防止过度发布实现安全的最佳做法。 基架生成了Bind
属性,并将使用Modified
标志模型联编程序所创建的实体添加实体集。 基架生成的代码不建议应用于很多场景,因为Bind
属性将清除任何未在Include
参数列出字段的预先存在的数据。
新的代码读取现有实体然后调用TryUpdateModel
基于用户输入的已发布的表单数据更新检索到的实体的字段。 Entity Framework 的自动跟踪更改机制会根据表单输入更改设置了Modified
标志的字段。 当SaveChanges
方法被调用时, Entity Framework 创建 SQL 语句更新数据库行。 并发冲突将被忽略,只有用户更改了的表格列会更新的奥数据库中。 (后面的教程演示如何处理并发冲突。)
作为防止过度发布的最佳实践,可通过编辑页更新的字段应该包含在TryUpdateModel
的白名单参数中。 (在参数列表中字段列表前的空字符串表示从下面开始就是表单字段。)当前没有额外要保护的字段,但列出你想要模型联编程序绑定的字段,这样可以确保在将来将字段添加到数据模型,它们能够在你显式将其添加到白名单之前自动受到保护。
通过这些更改,HttpPostEdit
方法与 HttpGetEdit
方法的签名相同; 因此重命名方法为EditPost
。
上面展示的建议的 HttpPost 编辑代码可确保仅会更新已更改的列,并保留你不希望包括进模型绑定的属性的数据。 但是,这种方案需要先读取数据导致了额外的数据库读取,甚至导致需要编写复杂的代码来处理并发冲突。 现在介绍一种可选的方法就是将模型联编程序创建的实体附加到 EF 上下文并将其标记为已修改。 (不要将你的项目中的相关代码替换为以下代码,以下代码只用来演示一种可选方法。)
public async Task Edit(int id, [Bind("ID,EnrollmentDate,FirstMidName,LastName")] Student student)
{
if (id != student.ID)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
ModelState.AddModelError("", "Unable to save changes. " +
"Try again, and if the problem persists, " +
"see your system administrator.");
}
}
return View(student);
}
在 web 页 UI 中包含实体中的所有字段并可以随意更新他们时,可以使用此方法。
基架的创建的代码使用的就是创建和附加方法,但仅捕获DbUpdateConcurrencyException
异常并返回 404 错误代码。 这个示例中展示了捕获所有和数据库更新有关异常并显示错误消息。
数据库上下文保持跟踪在内存中的实体是否与数据库中对应的行同步,此信息能够确定在调用SaveChanges
方法时发生了什么情况。 例如,当将一个新实体传递到Add
方法,该实体的状态设置为Added
。 然后当你调用SaveChanges
方法,数据库上下文发出 SQL INSERT 指令令。
实体可能处于以下状态之一:
Added
。 实体在数据库中尚不存在。 SaveChanges
方法发出 INSERT 语句。
Unchanged
。 SaveChanges
方法无需对此实体执行任何操作。 当从数据库读取实体时,该实体从此状态开始。
Modified
。 实体的某些或所有属性值发生了改变。 SaveChanges
方法发出 UPDATE 语句。
Deleted
。 实体已标记为删除。 SaveChanges
方法发出 DELETE 语句。
Detached
。 数据库上下文不跟踪该实体 。
在桌面应用中,状态更改通常会自动设置。 读取实体并对它的一些属性值进行更改将导致其实体状态自动更改为Modified
。 然后调用SaveChanges
, Entity Framework 生成 SQL UPDATE 语句更新你更改的实际属性。
在 web 应用中,读取实体和显示要编辑其数据的DbContext
在页面渲染之后才被处理。 当调用 HttpPostEdit
操作方法时、 会进行新的 web 请求然后您将使用DbContext
的新实例。 如果你重新读取该新上下文中的实体,则相当与模拟桌面应用来处理。
但如果不希望执行额外的读取操作,你必须使用由模型联编程序创建的实体对象。 执行此操作的最简单方法是将实体状态设置为Modified
,和前面可选的 HttpPost 编辑代码中的做法一样。 然后调用SaveChanges
时, Entity Framework 更新数据库的所有列,因为上下文无法知道您更改了哪些属性。
如果你想要避免使用先读取的方法,但你还想要 SQL UPDATE 语句仅更新用户实际更改的字段,则代码会更复杂。 你必须以某种方式保存的原始值 (如通过使用隐藏的字段),以便它们在调用 HttpPostEdit
方法时可用。 然后你可以使用原始值创建一个学生实体,调用原始版本的Attach
方法,使用新值更新实体的值,然后调用SaveChanges
。
运行应用程序中,选择Students选项卡,然后单击Edit超链接。
更改某些数据,再单击Save。 Index页将被打开并可以在其中查看更改后的数据。
在StudentController.cs中,正如你在详细信息视图和编辑方法所看到,HttpGet Delete
方法的模板代码使用SingleOrDefaultAsync
方法来检索所选的学生实体。 但是,若要在调用SaveChanges
失败时抛出自定义错误消息时,需要将某些功能添加到此方法以及相应的视图中。
当你看到的更新,创建和删除操作都需要两个操作方法。 调用响应 GET 请求的方法用于显示相关页面为用户提供普准或取消删除操作的机会。如果用户批准它,则创建 POST 请求。 当发生这种情况,调用HttpPostDelete
方法,然后该方法 执行删除操作。
将 try catch 块添加到 HttpPostDelete
方法以处理更新数据库时可能出现的任何错误。 如果发生错误,HttpPost Delete 方法调用 HttpGet Delete 方法,向其传入指示发生错误的参数。然后 HttpGet Delete 方法重新显示确认页以及错误消息,向用户提供机会取消或重试。
将 HttpGetDelete
替换替换为以下代码,用管理错误报告。
public async Task Delete(int? id, bool? saveChangesError = false)
{
if (id == null)
{
return NotFound();
}
var student = await _context.Students
.AsNoTracking()
.SingleOrDefaultAsync(m => m.ID == id);
if (student == null)
{
return NotFound();
}
if (saveChangesError.GetValueOrDefault())
{
ViewData["ErrorMessage"] =
"Delete failed. Try again, and if the problem persists " +
"see your system administrator.";
}
return View(student);
}
此代码接受可选参数,该参数能指出这个方法是否在出现故障(保存更改失败)后调用的。 此参数为 false 时,HttpGetDelete
在上一次没有失败的情况下调用。 当为了响应 HttpPostDelete
方法中对数据库更新的错误时,该参数是 true,并且将一条错误消息传递给视图。
用以下代码替换 HttpPostDelete
操作方法 (名为DeleteConfirmed
) ,该方法执行实际的删除操作并捕获任何有关数据库更新错误的异常。
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task DeleteConfirmed(int id)
{
var student = await _context.Students
.AsNoTracking()
.SingleOrDefaultAsync(m => m.ID == id);
if (student == null)
{
return RedirectToAction(nameof(Index));
}
try
{
_context.Students.Remove(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
return RedirectToAction(nameof(Delete), new { id = id, saveChangesError = true });
}
}
此代码检索所选的实体,然后调用Remove
方法将实体的状态设置为Deleted
, 然后调用SaveChanges
生成 SQL DELETE 命令。
如果优先考虑提高大容量应用程序的性能,则通过只使用主键来实例化实体使用然后将实体状态设置为deleted
来避免不必要的 SQL 查询密钥值。 这就是 Entity Framework 删除该实体所需要做的所有步骤。 (不要将此代码放在你的项目; 仅用于阐释一种可选的方法。)
[HttpPost]
[ValidateAntiForgeryToken]
public async Task DeleteConfirmed(int id)
{
try
{
Student studentToDelete = new Student() { ID = id };
_context.Entry(studentToDelete).State = EntityState.Deleted;
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
return RedirectToAction(nameof(Delete), new { id = id, saveChangesError = true });
}
}
如果有跟实体相关的数据要删除,请确保数据库中配置了级联删除。 使用此方法删除实体时,EF 可能不知道有相关的实体要被删除。
在Views/Student/Delete.cshtml中,在 h2 标题和 h3 标题之间添加一条错误消息,如下所示:
<h2>Deleteh2>
<p class="text-danger">@ViewData["ErrorMessage"]p>
<h3>Are you sure you want to delete this?h3>
运行应用程序中,选择Student卡,然后单击Delete超链接:
单击Delete。 索引页面没有显示已删除的学生。 (你将在并发教程看到错误处理的操作代码)。
上下文实例在你完成工作的时候必须尽可能快地处理以释放维持数据库连接的资源。 ASP.NET Core 内置的依赖注入会为你完成该任务。
在Startup.cs,你调用AddDbContext 扩展方法想ASP.NET DI 容器中提供DbContext
的类。 该方法在默认情况下将服务生存期设置为Scoped
。 Scoped
表示与 web 请求生命周期和上下文对象生命周期一致,Dispose
方法将 web 请求结束时自动调用。
默认情况下 Entity Framework 隐式实现事务。 在对多个行或表进行更改,然后调用SaveChanges
的场景中, Entity Framework 可自动确保所做的更改要么全部成功要么全部失败。 如果一些更改成功完成,之后发生了错误,则这些更改会自动回滚。 在你需要更多控制的场景中–例如,如果你想要在事务中包含在 Entity Framework 外部的操作,请参阅事务。
当数据库上下文检索表行,并创建表示它们的实体对象时,默认情况下它将跟踪内存中的实体是否与数据库同步。 内存中的数据充当缓存,并在更新实体时使用。 此缓存在 web 应用程序中通常是不必要的因为上下文实例通常生存期较短 (一个新的上下文实力为每个请求创建和释放) 和上下文读取再次使用该实体通常释放实体。
可以通过调用AsNoTracking
方法来禁用对实体对象内存的跟踪。 你可能想要执行此操作的典型场景包括:
在上下文生命周期内无需更新任何实体,并且您不需要 EF 通过单独的查询来自动加载检索到的实体的导航属性。 通常来说控制器的 HttpGet 操作方法满足这些条件。
正在运行检索大量数据的查询,并仅更新返回的数据的一小部分。 在大型查询中关闭跟踪可能会更有效,并在之后为需要更新的少量实体执行查询。
你想要对一个实体附加跟踪以便其进行更新,但出于其他目的之前已经检索过相同的实体。 因为该实体已经在数据库上下文被跟踪,不能将想要更改的实体附加耿总。 处理这种情况的一种方法是在前面的查询调用AsNoTracking
。
有关详细信息,请参阅跟踪 vs 不跟踪。
你现在具有一组完整的对学生实体执行简单 CRUD 操作的页。 在下一步的教程,你将扩展Index页上的功能,实现排序、 筛选和分页。