教程:在 ASP.NET MVC 中实现基本的 CRUD 功能 EF Core

在上一教程中,你创建了一个 MVC 应用程序,该应用程序使用 Entity Framework (EF) Core 和 SQL Server 本地数据库来存储和显示数据。 在本练习中,你将查看并自定义 MVC 基架自动在控制器和视图中为你创建的 CRUD(创建、读取、更新、删除)代码。

注释

为了在控制器和数据访问层之间创建一个抽象层,常见的做法是实现存储库模式。 为了使示例简单且侧重于演示如何使用 Entity Framework 本身,教程不使用存储库。 有关使用 Entity Framework 的存储库的信息,请参阅 本系列中的最后一篇教程

在本教程中,你将了解:

  • 通过使用预先加载添加注册数据来自定义“详细信息”页面
  • 将“创建”页更新为具备安全和错误处理功能
  • 更新“编辑”页以防止过度发布
  • 用错误报告来更新“删除”页面
  • 关闭数据库连接

先决条件

自定义“详细信息”页

“学生索引”页面的基架代码没有包含Enrollments属性,因为该属性储存一个集合。 “ 详细信息 ”页将集合的内容显示为 HTML 表。

Controllers/StudentsController.cs 文件中,Details 视图的操作方法使用 FirstOrDefaultAsync 方法检索单个 Student 实体。 需要添加调用IncludeThenIncludeAsNoTracking方法的代码,如以下突出显示的代码所示。

public async Task<IActionResult> Details(int? id)
{
    if (id == null)
    {
        return NotFound();
    }

    var student = await _context.Students
        .Include(s => s.Enrollments)
            .ThenInclude(e => e.Course)
        .AsNoTracking()
        .FirstOrDefaultAsync(m => m.ID == id);

    if (student == null)
    {
        return NotFound();
    }

    return View(student);
}

IncludeThenInclude 方法使上下文加载每个注册中的 Student.Enrollments 导航属性和 Enrollment.Course 导航属性。 可以在 阅读相关数据 教程中了解有关这些方法的详细信息。

对于返回的实体未在当前上下文生存期中更新的情况,AsNoTracking 方法将会提升性能。 在本教程结束时,你将了解有关该方法的详细信息 AsNoTracking

配置路由数据

传递给方法的 Details 键值来自 路由数据。 路由数据是模型绑定器在 URL 段中找到的数据。 例如,默认路由指定controlleractionid (标识符、ID)段:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
});

在以下 URL 中,默认路线将 讲师 映射为 controller索引 作为 action,并将 1 映射为 id。 这些值构成路由数据。

http://localhost:1230/Instructor/Index/1?courseID=2021

URL (?courseID=2021) 的最后一部分是查询字符串值。 如果将 ID 值作为查询字符串值传递,则模型绑定器还会将 ID 值 Index 传递给方法 id 参数:

http://localhost:1230/Instructor/Index?id=1&CourseID=2021

“索引” 页中,超链接 URL 由视图中的 Razor 标签助手语句创建。 在以下 Razor 代码中,参数 id 与默认路由匹配,因此该值 id 将添加到路由数据中。

<a asp-action="Edit" asp-route-id="@item.ID">Edit</a>

item.ID 的值为 6 时,此更改将生成以下 HTML:

<a href="/Students/Edit/6">Edit</a>

在以下 Razor 代码中, studentID 文本与默认路由中的参数不匹配,因此文本将添加为查询字符串。

<a asp-action="Edit" asp-route-studentID="@item.ID">Edit</a>

item.ID 值为 6 时,此更改将生成以下 HTML:

<a href="/Students/Edit?studentID=6">Edit</a>

有关标记帮助程序的详细信息,请参阅 ASP.NET Core 中的标记帮助程序

将注册项添加到“详细信息”视图界面

打开 Views/Students/Details.cshtml 文件。 使用 DisplayNameForDisplayFor 辅助功能显示每个字段,如以下示例所示。

<dt class="col-sm-2">
    @Html.DisplayNameFor(model => model.LastName)
</dt>
<dd class="col-sm-10">
    @Html.DisplayFor(model => model.LastName)
</dd>

在最后一个字段之后,紧接在结束 </dl> 标记之前,添加以下代码以显示注册列表:

<dt class="col-sm-2">
    @Html.DisplayNameFor(model => model.Enrollments)
</dt>
<dd class="col-sm-10">
    <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>

如果代码缩进在粘贴代码段后未对齐,请使用 Ctrl+K+D 键盘快捷方式更正代码。

新代码循环遍历 Enrollments 导航属性中的实体。 对于每次课程注册,代码将显示课程名称和成绩。 课程标题从 Course 实体中检索,该实体存储在 Enrollments 实体的 Course 导航属性中。

运行应用,选择 “学生 ”选项卡,然后选择学生 的详细信息 链接。 将看到所选学生的课程和年级列表:

“学生详细信息”页的屏幕截图。

更新“创建”页

StudentsController.cs文件中,修改 HttpPost Create 方法,添加 try-catch 块,并从 Bind 属性中删除 ID 属性。

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 Core MVC 模型绑定器创建的实体添加到StudentStudents实体集,然后将更改保存到数据库。

模型绑定器 是指 ASP.NET 核心 MVC 功能,使你更轻松地处理表单提交的数据。 模型绑定器将已发布的表单值转换为 CLR 类型,并将其传递给参数中的操作方法。 在这种情况下,模型绑定器通过使用Form集合中的属性值为您实例化Student实体。

已从ID属性中删除Bind该属性,因为 ID 是 SQL Server 在插入行时自动设置的主键值。 用户的输入未设置 ID 值。

除了 Bind 属性之外,try-catch 块是基架代码中唯一的更改。 如果在保存更改时捕获派生自 DbUpdateException 的异常,则会显示一般错误消息。 异常的原因 DbUpdateException 可能位于应用程序外部,而不是编程错误,因此建议用户重试。 生产质量应用程序记录此类异常,但此示例中未实现该功能。 有关详细信息,请参阅 .NET 中的日志记录和 ASP.NET Core

ValidateAntiForgeryToken 属性有助于防止跨站点请求伪造(CSRF)攻击。 表单标记帮助程序方法会自动将令牌注入视图中。 当用户提交表单时,令牌也会被包含在内。 该 ValidateAntiForgeryToken 属性检查令牌。 有关详细信息,请参阅 ASP.NET Core 和 FormTagHelper 类参考中的“防止跨站点请求伪造”(XSRF/CSRF)攻击

防止过度发布

基架代码包含在 Create 方法中的 Bind 特性是防止在创建方案中过多发布的一种方法。 例如,假设 Student 实体包含不希望此网页设置的 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属性没有限制模型绑定器在创建Student实例时使用的字段,模型绑定器可以获取该Secret表单值,并用它来创建Student实体实例。 然后,黑客为表单字段指定的值将被更新到您的数据库中。 下图显示 Fiddler 工具将 Secret 字段(值为“OverPost”)添加到已提交的表单值中。

Fiddler 中 Composer 视图的屏幕截图,显示了添加到请求正文中的 Secret 字段。

尽管您从未打算让网页设置该属性,但“OverPost”值已成功添加到Secret插入行的属性中。

你可以通过首先从数据库读取实体,然后调用 TryUpdateModel 方法来防止编辑场景中的过度发布。 传入明确允许的属性列表,这就是这些教程中使用该方法的方式。

防止许多开发人员首选的过度发布的另一种方法是使用视图模型而不是具有模型绑定的实体类。 仅包含要在视图模型中更新的属性。 MVC 模型绑定器完成后,可以选择使用 AutoMapper 等工具将视图模型属性复制到实体实例。 在实体实例上使用_context.Entry设置,将其状态设置为Unchanged,然后在视图模型中包含的每个实体属性上将Property("PropertyName").IsModified设为 true。 此方法适用于编辑和创建方案。

测试创建页面

Views/Students/Create.cshtml 文件中的代码为每个字段使用了labelinputspan(用于验证消息)的标记帮助程序。

运行应用,选择“ 学生 ”选项卡,然后选择“ 新建”。

输入名称和日期。 尝试输入无效日期,如果浏览器允许的话。 (某些浏览器强制使用日期选取器。选择“ 创建 ”以查看错误消息。

“创建”页的屏幕截图,其中显示了无效日期字段值的验证错误消息。

此方案演示了默认服务器端验证。 在后面的教程中,你将了解如何添加为客户端验证生成代码的属性。 以下突出显示的代码显示了方法中的 Create 模型验证检查。

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}

将日期更改为有效值,然后选择“ 创建 ”以查看新学生显示在 “索引 ”页中。

更新“编辑”页

StudentController.cs 文件中,没有 HttpPost 属性的 HttpGet Edit 方法使用该 FirstOrDefaultAsync 方法检索所选的 Student 实体,就像您之前在 Details 方法中查看过的那样。 不需要更改此方法。

将 HttpPost Edit 方法替换为以下代码。

[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditPost(int? id)
{
    if (id == null)
    {
        return NotFound();
    }
    var studentToUpdate = await _context.Students.FirstOrDefaultAsync(s => s.ID == id);
    if (await TryUpdateModelAsync<Student>(
        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,根据已发布表单数据中的用户输入,更新检索的实体中的字段。 (有关详细信息,请参阅 ASP.NET Core 中的模型绑定。)Entity Framework 的自动更改跟踪在由表单输入更新的字段上设置 Modified 标志。 SaveChanges调用该方法时,Entity Framework 将创建 SQL 语句来更新数据库行。 并发冲突将被忽略,并且仅由用户更新的表列也会在数据库中被更新。 (稍后的教程演示如何处理并发冲突。

作为防止过度发布的最佳实践,您要在 “编辑” 页中更新的字段应在 TryUpdateModel 参数中声明。 (参数列表中字段列表前面的空字符串是用于表单字段名称的前缀。目前,没有要保护的额外字段。 如果列出希望模型绑定器绑定的字段,则可以阻止将来添加的字段的过度发布。 列表中的字段会自动受到保护,除非你显式地添加它们。

由于这些更改,HttpPost Edit 方法的方法签名与 HttpGet Edit 方法相同。 这些更改基本上将方法重命名为 EditPost

使用替代的 HttpPost 编辑代码:创建代码并附加

建议的 HttpPost Edit 代码可确保仅更新已更改的列,并将数据保留在不希望包含在模型绑定的属性中。 但是,读取优先方法需要额外的数据库读取,并可能导致更复杂的代码来处理并发冲突。 替代方法是将模型绑定器创建的实体附加到 Entity Framework 上下文,并将其标记为已修改。 (不要使用以下代码更新项目。该代码演示了一种可选方法。

public async Task<IActionResult> 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);
}

当网页 UI 包含实体中的所有字段,并且可以更新其中任何字段时,可以使用此方法。

基架代码使用 create-and-attach 方法,但只捕获 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 对象将在页面呈现后进行处理。 调用 HttpPost Edit 操作方法时,将发出新的 Web 请求,并且你有该类的新实例 DbContext 。 如果重新读取该新上下文中的实体,则模拟桌面处理。

如果不想执行额外的读取操作,则必须使用模型绑定器创建的实体对象。 最简单的方法是按照前面所示的替代 HttpPost Modified 代码中所述将实体状态Edit设置为原样。 调用 SaveChanges 该方法时,Entity Framework 会更新数据库行的所有列,因为上下文无法知道更改的属性。

如果想要避免 读取优先 方法,但还希望 SQL UPDATE 语句仅更新用户更改的字段,则代码更为复杂。 必须以某种方式保存原始值,例如使用隐藏字段。 调用 HttpPost Edit 方法时,这些值必须可用。 可以使用原始值创建 Student 实体,使用实体的原始版本调用 Attach 方法,将实体的值更新为新值,然后调用 SaveChanges 该方法。

测试编辑页

运行应用,选择“ 学生 ”选项卡,然后选择 “编辑 ”超链接。

学生“编辑”页面的屏幕截图。

更改某些数据,然后选择“ 保存”。 此时会打开 “索引 ”页,可以看到已更改的数据。

更新“删除”页

StudentController.cs文件中,HttpGet Delete 方法的模板代码使用FirstOrDefaultAsync方法检索选择的Student实体,如您在Details方法和Edit方法中看到的那样。 但是,若要在调用 SaveChanges 方法失败时实现自定义错误消息,需要向此方法及其相应的视图添加一些功能。

正如你在更新和创建操作中看到的那样,删除操作需要两种操作方法。 响应 GET 请求时调用的方法显示一个视图,该视图使用户有机会批准或取消删除操作。 如果用户批准,则会创建 POST 请求。 然后调用 HttpPost Delete 方法,此方法实际上执行删除操作。

需要将 try-catch 块添加到 HttpPost Delete 方法,以处理更新数据库时可能发生的任何错误。 如果发生错误,HttpPost Delete 方法将调用 HttpGet Delete 方法,并传递指示错误的参数。 HttpGet Delete 方法将重新显示确认页以及错误消息,使用户有机会取消或重试。

将 HttpGet Delete 操作方法替换为以下代码,用于管理错误报告。

public async Task<IActionResult> Delete(int? id, bool? saveChangesError = false)
{
    if (id == null)
    {
        return NotFound();
    }

    var student = await _context.Students
        .AsNoTracking()
        .FirstOrDefaultAsync(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);
}

此代码接受一个可选参数,该参数指示方法调用是否在保存更改失败之后。 调用 HttpGet Delete 方法时,如果未发生以前的故障,此参数为 false。 当 HttpPost Delete 方法调用该方法以响应数据库更新错误时,参数为 true,并将错误消息传递给视图。

对 HttpPost Delete 使用“先读取”方法

将 HttpPost Delete 操作方法(命名 DeleteConfirmed)替换为以下代码。 此代码执行实际删除操作并捕获任何数据库更新错误。

[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
    var student = await _context.Students.FindAsync(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 方法以将实体的状态设置为 DeletedSaveChanges调用该方法时,将生成 SQL DELETE 命令。

对 HttpPost Delete 使用“创建并附加”方法

如果提高大容量应用程序的性能是优先事项,您可以通过仅使用主键值来实例化Student实体,并将实体状态设置为Deleted,以避免不必要的 SQL 查询。 此信息是删除实体所需的所有实体框架。 (不要使用以下代码更新项目。该代码演示了一种替代方法。

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
    }
}

如果实体还包含要删除的相关数据,请确保在数据库中配置级联删除。 使用此方法删除实体时,Entity Framework 可能无法意识到存在要删除的相关实体。

更新“删除”视图

Views/Student/Delete.cshtml 文件中,在 h2 标题和 h3 标题之间添加错误消息,如以下示例所示:

<h2>Delete</h2>
<p class="text-danger">@ViewData["ErrorMessage"]</p>
<h3>Are you sure you want to delete this?</h3>

运行应用,选择“ 学生 ”选项卡,然后选择 “删除 ”超链接:

“删除”操作的“确认”页的屏幕截图。

选择 删除。 “索引”页面显示时不包含已删除的学生。 (您可以在并发教程中看到错误处理代码的实际应用示例。)

关闭数据库连接

若要释放数据库连接包含的资源,完成此操作时必须尽快处理上下文实例。 ASP.NET Core 内置 依赖项注入 负责清理任务。

Startup.cs 文件中,调用 AddDbContext 扩展方法 以在 ASP.NET Core DI 容器中创建 DbContext 类。 此方法默认将服务生存期设置为 ScopedScoped 表示上下文对象生存期与 Web 请求生存期相吻合,并且 Dispose 该方法在 Web 请求结束时自动调用。

处理交易

默认情况下,Entity Framework 隐式实现事务。 在对多个行或表进行更改,然后调用 SaveChanges 该方法的情况下,Entity Framework 会自动确保所有更改都成功或全部失败。 如果首先完成某些更改,然后发生错误,则会自动回滚已完成的更改。 在需要更多控制的情况下,请参阅 “事务”。 这些示例介绍如何在事务中包含实体框架外部完成的操作。

禁用实体对象的跟踪(无跟踪查询)

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

可以通过调用该方法 AsNoTracking 来禁用对内存中的实体对象的跟踪。 下面是此操作的一些常见方案:

  • 在上下文生存期内,不需要更新任何实体,并且不需要实体框架自动加载具有由单独的查询检索的实体的导航属性。 控制器的 HttpGet 操作方法中经常满足这些条件。

  • 你正在运行一个查询来检索大量数据,并且只更新返回的数据的一小部分。 关闭对大型查询的跟踪可能更有效,稍后为少数需要更新的实体运行查询。

  • 你想要附加一个实体进行更新,但之前检索到同一实体是为了其他目的。 由于数据库上下文已跟踪实体,因此无法附加要更改的实体。 处理这种情况的一种方法是在前面的查询上调用 AsNoTracking 该方法。

有关详细信息,请参阅 “跟踪”与“无跟踪”查询

获取代码

下载或查看已完成的应用程序

后续步骤