第 2 部分,ASP.NET Core 中的 Razor Pages 和 EF Core - CRUD

作者:Tom DykstraJeremy LiknessJon P Smith

Contoso University Web 应用演示了如何使用 EF Core 和 Visual Studio 创建 Razor Pages Web 应用。 若要了解系列教程,请参阅第一个教程

如果遇到无法解决的问题,请下载已完成的应用,然后对比该代码与按教程所创建的代码。

本教程将介绍和自定义已搭建基架的 CRUD (创建、读取、更新、删除)代码。

无存储库

某些开发人员使用服务层或存储库模式在 UI (Razor Pages) 和数据访问层之间创建抽象层。 本教程不会这样做。 为最大程度降低复杂性并让本教程重点介绍 EF Core,将直接在页面模型类中添加 EF Core 代码。

更新“详细信息”页

“学生”页的基架代码不包括注册数据。 本部分将向 Details 页添加注册。

读取注册

若要在页面上显示学生的注册数据,必须读取注册数据。 Pages/Students/Details.cshtml.cs 中的基架代码仅读取 Student 数据,不读取 Enrollment 数据:

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

    Student = await _context.Students.FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

使用以下代码替换 OnGetAsync 方法以读取所选学生的注册数据。 突出显示所作更改。

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

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

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

IncludeThenInclude 方法使上下文加载 Student.Enrollments 导航属性,并在每个注册中加载 Enrollment.Course 导航属性。 这些方法将在与数据读取相关的教程中进行详细介绍。

对于返回的实体未在当前上下文中更新的情况,AsNoTracking 方法将会提升性能。 AsNoTracking 将在本教程的后续部分中讨论。

显示注册

使用以下代码替换 Pages/Students/Details.cshtml 中的代码以显示注册列表。 突出显示所作更改。

@page
@model ContosoUniversity.Pages.Students.DetailsModel

@{
    ViewData["Title"] = "Details";
}

<h1>Details</h1>

<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.Enrollments)
        </dt>
        <dd class="col-sm-10">
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Student.Enrollments)
                {
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                }
            </table>
        </dd>
    </dl>
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

上面的代码循环通过 Enrollments 导航属性中的实体。 它将针对每个注册显示课程标题和成绩。 课程标题从 Course 实体中检索,该实体存储在 Enrollments 实体的 Course 导航属性中。

运行应用,选择“学生”选项卡,然后单击学生的“详细信息”链接 。 随即显示出所选学生的课程和成绩列表。

读取一个实体的方法

生成的代码使用 FirstOrDefaultAsync 读取一个实体。 如果未找到任何内容,则此方法返回 NULL;否则,它将返回满足查询筛选条件的第一行。 FirstOrDefaultAsync 通常是比以下备选方案更好的选择:

  • SingleOrDefaultAsync - 如果有多个满足查询筛选器的实体,则引发异常。 若要确定查询是否可以返回多行,SingleOrDefaultAsync 会尝试提取多个行。 如果查询只能返回一个实体,就像它在搜索唯一键时一样,那么该额外工作是不必要的。
  • FindAsync - 查找具有主键 ( PK) 的实体。 如果具有 PK 的实体正在由上下文跟踪,会返回该实体且不向数据库发出请求。 此方法经过优化,可查找单个实体,但无法通过 FindAsync 调用 Include。 如果需要相关数据,FirstOrDefaultAsync 则是更好的选择。

路由数据与查询字符串

“详细信息”页的 URL 是 https://localhost:<port>/Students/Details?id=1。 实体的主键值在查询字符串中。 某些开发人员偏向于在路由数据中传递键值:https://localhost:<port>/Students/Details/1。 有关详细信息,请参阅更新生成的代码

更新“创建”页

“创建”页面的基架 OnPostAsync 代码容易受到过多发布攻击。 将 Pages/Students/Create.cshtml.cs 中的 OnPostAsync 方法替换为以下代码。

public async Task<IActionResult> OnPostAsync()
{
    var emptyStudent = new Student();

    if (await TryUpdateModelAsync<Student>(
        emptyStudent,
        "student",   // Prefix for form value.
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        _context.Students.Add(emptyStudent);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

TryUpdateModelAsync

前面的代码将创建一个 Student 对象,然后使用发布的表单域更新 Student 对象的属性。 TryUpdateModelAsync 方法:

  • 使用 PageModelPageContext 属性的已发布的表单值。
  • 仅更新列出的属性 (s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate)。
  • 查找带有“student”前缀的表单值。 例如 Student.FirstMidName。 该自变量不区分大小写。
  • 使用模型绑定系统将字符串中的表单值转换为 Student 模型中的类型。 例如,EnrollmentDate 转换为 DateTime

运行应用,并创建一个学生实体以测试“创建”页。

过多发布

使用 TryUpdateModel 更新具有已发布值的字段是一种最佳的安全做法,因为这能阻止过多发布。 例如,假设 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; }
}

即使应用的创建或更新 Razor 页面上没有 Secret 字段,黑客仍可利用过多发布设置 Secret 值。 黑客也可使用 Fiddler 等工具或通过编写某个 JavaScript 来发布 Secret 表单值。 原始代码不会限制模型绑定器在创建“学生”实例时使用的字段。

黑客为 Secret 表单域指定的任何值都会在数据库中更新。 下图显示 Fiddler 工具正在将 Secret 字段(值为“OverPost”)添加到已发布的表单值。

Fiddler adding Secret field

值“OverPost”已成功添加到所插入行的 Secret 属性中。 即使应用设计器从未打算用“创建”页设置 Secret 属性,也会发生这种情况。

视图模型

视图模型还提供了一种防止过度发布的方法。

应用程序模型通常称为域模型。 域模型通常包含数据库中对应实体所需的全部属性。 视图模型仅包含 UI 页(例如“创建”页)所需的属性。

除视图模型外,某些应用使用绑定模型或输入模型在 Razor Pages 页面模型类和浏览器之间传递数据。

请考虑以下 StudentVM 视图模型:

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

以下代码使用 StudentVM 视图模型创建新的学生:

[BindProperty]
public StudentVM StudentVM { get; set; }

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var entry = _context.Add(new Student());
    entry.CurrentValues.SetValues(StudentVM);
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}

SetValues 方法通过从另一个 PropertyValues 对象读取值来设置此对象的值。 SetValues 使用属性名称匹配。 视图模型类型:

  • 无需与模型类型相关。
  • 需要具有匹配的属性。

使用 StudentVM 要求“创建”页使用 StudentVM,而不是 Student

@page
@model CreateVMModel

@{
    ViewData["Title"] = "Create";
}

<h1>Create</h1>

<h4>Student</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="StudentVM.LastName" class="control-label"></label>
                <input asp-for="StudentVM.LastName" class="form-control" />
                <span asp-validation-for="StudentVM.LastName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.FirstMidName" class="control-label"></label>
                <input asp-for="StudentVM.FirstMidName" class="form-control" />
                <span asp-validation-for="StudentVM.FirstMidName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.EnrollmentDate" class="control-label"></label>
                <input asp-for="StudentVM.EnrollmentDate" class="form-control" />
                <span asp-validation-for="StudentVM.EnrollmentDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-page="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

更新“编辑”页

Pages/Students/Edit.cshtml.cs 中,使用以下代码替换 OnGetAsyncOnPostAsync 方法。

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

    Student = await _context.Students.FindAsync(id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

public async Task<IActionResult> OnPostAsync(int id)
{
    var studentToUpdate = await _context.Students.FindAsync(id);

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

    if (await TryUpdateModelAsync<Student>(
        studentToUpdate,
        "student",
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

代码更改与“创建”页类似,但有少数例外:

  • FirstOrDefaultAsync 已替换为 FindAsync。 不需要包含相关数据时,FindAsync 效率更高。
  • OnPostAsync 具有 id 参数。
  • 当前学生是从数据库中提取的,而非通过创建空学生获得。

运行应用,并通过创建和编辑学生进行测试。

实体状态

数据库上下文会随时跟踪内存中的实体是否已与其在数据库中的对应行进行同步。 此跟踪信息可确定调用 SaveChangesAsync 后的行为。 例如,将新实体传递到 AddAsync 方法时,该实体的状态设置为 Added。 调用 SaveChangesAsync 时,数据库上下文会发出 SQL INSERT 命令。

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

  • Added:数据库中尚不存在实体。 SaveChanges 方法发出 INSERT 语句。

  • Unchanged:无需保存对该实体所做的任何更改。 从数据库中读取实体时,该实体具有此状态。

  • Modified:已修改实体的部分或全部属性值。 SaveChanges 方法发出 UPDATE 语句。

  • Deleted:已标记该实体进行删除。 SaveChanges 方法发出 DELETE 语句。

  • Detached:数据库上下文未跟踪该实体。

在桌面应用中,通常会自动设置状态更改。 读取实体并执行更改后,实体状态自动更改为 Modified。 调用 SaveChanges 会生成仅更新已更改属性的 SQL UPDATE 语句。

在 Web 应用中,读取实体并显示数据的 DbContext 将在页面呈现后进行处理。 调用页面 OnPostAsync 方法时,将发出具有 DbContext 的新实例的 Web 请求。 如果在这个新的上下文中重新读取实体,则会模拟桌面处理。

更新“删除”页

在此部分中,当对 SaveChanges 的调用失败时,将实现自定义错误消息。

Pages/Students/Delete.cshtml.cs 中的代码替换为以下代码:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Students
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;
        private readonly ILogger<DeleteModel> _logger;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context,
                           ILogger<DeleteModel> logger)
        {
            _context = context;
            _logger = logger;
        }

        [BindProperty]
        public Student Student { get; set; }
        public string ErrorMessage { get; set; }

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

            Student = await _context.Students
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.ID == id);

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

            if (saveChangesError.GetValueOrDefault())
            {
                ErrorMessage = String.Format("Delete {ID} failed. Try again", id);
            }

            return Page();
        }

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

            var student = await _context.Students.FindAsync(id);

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

            try
            {
                _context.Students.Remove(student);
                await _context.SaveChangesAsync();
                return RedirectToPage("./Index");
            }
            catch (DbUpdateException ex)
            {
                _logger.LogError(ex, ErrorMessage);

                return RedirectToAction("./Delete",
                                     new { id, saveChangesError = true });
            }
        }
    }
}

前面的代码:

  • 添加日志记录
  • 将可选参数 saveChangesError 添加到 OnGetAsync 方法签名中。 saveChangesError 指示学生对象删除失败后是否调用该方法。

删除操作可能由于暂时性网络问题而失败。 数据库在云中时,更可能出现暂时性网络错误。 通过 UI 调用“删除”页 OnGetAsync 时,saveChangesError 参数为 false。 当 OnPostAsync 调用 OnGetAsync(由于删除操作失败)时,saveChangesError 参数为 true

OnPostAsync 方法检索所选实体,然后调用 Remove 方法将实体的状态设置为 Deleted。 调用 SaveChanges 时生成 SQL DELETE 命令。 如果 Remove 失败:

  • 捕获数据库异常。
  • 通过 saveChangesError=true 调用“删除”页 OnGetAsync 方法。

将错误消息添加到 Pages/Students/Delete.cshtml

@page
@model ContosoUniversity.Pages.Students.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h1>Delete</h1>

<p class="text-danger">@Model.ErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
    </dl>

    <form method="post">
        <input type="hidden" asp-for="Student.ID" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

运行应用并删除学生以测试“删除”页。

后续步骤

本教程将介绍和自定义已搭建基架的 CRUD (创建、读取、更新、删除)代码。

无存储库

某些开发人员使用服务层或存储库模式在 UI (Razor Pages) 和数据访问层之间创建抽象层。 本教程不会这样做。 为最大程度降低复杂性并让本教程重点介绍 EF Core,将直接在页面模型类中添加 EF Core 代码。

更新“详细信息”页

“学生”页的基架代码不包括注册数据。 本部分将向 Details 页添加注册。

读取注册

若要在页面上显示学生的注册数据,必须读取注册数据。 Pages/Students/Details.cshtml.cs 中的基架代码仅读取 Student 数据,不读取 Enrollment 数据:

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

    Student = await _context.Students.FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

使用以下代码替换 OnGetAsync 方法以读取所选学生的注册数据。 突出显示所作更改。

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

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

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

IncludeThenInclude 方法使上下文加载 Student.Enrollments 导航属性,并在每个注册中加载 Enrollment.Course 导航属性。 这些方法将在与数据读取相关的教程中进行详细介绍。

对于返回的实体未在当前上下文中更新的情况,AsNoTracking 方法将会提升性能。 AsNoTracking 将在本教程的后续部分中讨论。

显示注册

使用以下代码替换 Pages/Students/Details.cshtml 中的代码以显示注册列表。 突出显示所作更改。

@page
@model ContosoUniversity.Pages.Students.DetailsModel

@{
    ViewData["Title"] = "Details";
}

<h1>Details</h1>

<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.Enrollments)
        </dt>
        <dd class="col-sm-10">
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Student.Enrollments)
                {
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                }
            </table>
        </dd>
    </dl>
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

上面的代码循环通过 Enrollments 导航属性中的实体。 它将针对每个注册显示课程标题和成绩。 课程标题从 Course 实体中检索,该实体存储在 Enrollments 实体的 Course 导航属性中。

运行应用,选择“学生”选项卡,然后单击学生的“详细信息”链接 。 随即显示出所选学生的课程和成绩列表。

读取一个实体的方法

生成的代码使用 FirstOrDefaultAsync 读取一个实体。 如果未找到任何内容,则此方法返回 NULL;否则,它将返回满足查询筛选条件的第一行。 FirstOrDefaultAsync 通常是比以下备选方案更好的选择:

  • SingleOrDefaultAsync - 如果有多个满足查询筛选器的实体,则引发异常。 若要确定查询是否可以返回多行,SingleOrDefaultAsync 会尝试提取多个行。 如果查询只能返回一个实体,就像它在搜索唯一键时一样,那么该额外工作是不必要的。
  • FindAsync - 查找具有主键 ( PK) 的实体。 如果具有 PK 的实体正在由上下文跟踪,会返回该实体且不向数据库发出请求。 此方法经过优化,可查找单个实体,但无法通过 FindAsync 调用 Include。 如果需要相关数据,FirstOrDefaultAsync 则是更好的选择。

路由数据与查询字符串

“详细信息”页的 URL 是 https://localhost:<port>/Students/Details?id=1。 实体的主键值在查询字符串中。 某些开发人员偏向于在路由数据中传递键值:https://localhost:<port>/Students/Details/1。 有关详细信息,请参阅更新生成的代码

更新“创建”页

“创建”页面的基架 OnPostAsync 代码容易受到过多发布攻击。 将 Pages/Students/Create.cshtml.cs 中的 OnPostAsync 方法替换为以下代码。

public async Task<IActionResult> OnPostAsync()
{
    var emptyStudent = new Student();

    if (await TryUpdateModelAsync<Student>(
        emptyStudent,
        "student",   // Prefix for form value.
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        _context.Students.Add(emptyStudent);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

TryUpdateModelAsync

前面的代码将创建一个 Student 对象,然后使用发布的表单域更新 Student 对象的属性。 TryUpdateModelAsync 方法:

  • 使用 PageModelPageContext 属性的已发布的表单值。
  • 仅更新列出的属性 (s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate)。
  • 查找带有“student”前缀的表单值。 例如 Student.FirstMidName。 该自变量不区分大小写。
  • 使用模型绑定系统将字符串中的表单值转换为 Student 模型中的类型。 例如,EnrollmentDate 转换为 DateTime

运行应用,并创建一个学生实体以测试“创建”页。

过多发布

使用 TryUpdateModel 更新具有已发布值的字段是一种最佳的安全做法,因为这能阻止过多发布。 例如,假设 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; }
}

即使应用的创建或更新 Razor 页面上没有 Secret 字段,黑客仍可利用过多发布设置 Secret 值。 黑客也可使用 Fiddler 等工具或通过编写某个 JavaScript 来发布 Secret 表单值。 原始代码不会限制模型绑定器在创建“学生”实例时使用的字段。

黑客为 Secret 表单域指定的任何值都会在数据库中更新。 下图显示 Fiddler 工具正在将 Secret 字段(值为“OverPost”)添加到已发布的表单值。

Fiddler adding Secret field

值“OverPost”已成功添加到所插入行的 Secret 属性中。 即使应用设计器从未打算用“创建”页设置 Secret 属性,也会发生这种情况。

视图模型

视图模型还提供了一种防止过度发布的方法。

应用程序模型通常称为域模型。 域模型通常包含数据库中对应实体所需的全部属性。 视图模型仅包含 UI 页(例如“创建”页)所需的属性。

除视图模型外,某些应用使用绑定模型或输入模型在 Razor Pages 页面模型类和浏览器之间传递数据。

请考虑以下 StudentVM 视图模型:

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

以下代码使用 StudentVM 视图模型创建新的学生:

[BindProperty]
public StudentVM StudentVM { get; set; }

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var entry = _context.Add(new Student());
    entry.CurrentValues.SetValues(StudentVM);
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}

SetValues 方法通过从另一个 PropertyValues 对象读取值来设置此对象的值。 SetValues 使用属性名称匹配。 视图模型类型:

  • 无需与模型类型相关。
  • 需要具有匹配的属性。

使用 StudentVM 要求“创建”页使用 StudentVM,而不是 Student

@page
@model CreateVMModel

@{
    ViewData["Title"] = "Create";
}

<h1>Create</h1>

<h4>Student</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="StudentVM.LastName" class="control-label"></label>
                <input asp-for="StudentVM.LastName" class="form-control" />
                <span asp-validation-for="StudentVM.LastName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.FirstMidName" class="control-label"></label>
                <input asp-for="StudentVM.FirstMidName" class="form-control" />
                <span asp-validation-for="StudentVM.FirstMidName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StudentVM.EnrollmentDate" class="control-label"></label>
                <input asp-for="StudentVM.EnrollmentDate" class="form-control" />
                <span asp-validation-for="StudentVM.EnrollmentDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-page="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

更新“编辑”页

Pages/Students/Edit.cshtml.cs 中,使用以下代码替换 OnGetAsyncOnPostAsync 方法。

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

    Student = await _context.Students.FindAsync(id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

public async Task<IActionResult> OnPostAsync(int id)
{
    var studentToUpdate = await _context.Students.FindAsync(id);

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

    if (await TryUpdateModelAsync<Student>(
        studentToUpdate,
        "student",
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

代码更改与“创建”页类似,但有少数例外:

  • FirstOrDefaultAsync 已替换为 FindAsync。 不需要包含相关数据时,FindAsync 效率更高。
  • OnPostAsync 具有 id 参数。
  • 当前学生是从数据库中提取的,而非通过创建空学生获得。

运行应用,并通过创建和编辑学生进行测试。

实体状态

数据库上下文会随时跟踪内存中的实体是否已与其在数据库中的对应行进行同步。 此跟踪信息可确定调用 SaveChangesAsync 后的行为。 例如,将新实体传递到 AddAsync 方法时,该实体的状态设置为 Added。 调用 SaveChangesAsync 时,数据库上下文会发出 SQL INSERT 命令。

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

  • Added:数据库中尚不存在实体。 SaveChanges 方法发出 INSERT 语句。

  • Unchanged:无需保存对该实体所做的任何更改。 从数据库中读取实体时,该实体具有此状态。

  • Modified:已修改实体的部分或全部属性值。 SaveChanges 方法发出 UPDATE 语句。

  • Deleted:已标记该实体进行删除。 SaveChanges 方法发出 DELETE 语句。

  • Detached:数据库上下文未跟踪该实体。

在桌面应用中,通常会自动设置状态更改。 读取实体并执行更改后,实体状态自动更改为 Modified。 调用 SaveChanges 会生成仅更新已更改属性的 SQL UPDATE 语句。

在 Web 应用中,读取实体并显示数据的 DbContext 将在页面呈现后进行处理。 调用页面 OnPostAsync 方法时,将发出具有 DbContext 的新实例的 Web 请求。 如果在这个新的上下文中重新读取实体,则会模拟桌面处理。

更新“删除”页

在此部分中,当对 SaveChanges 的调用失败时,将实现自定义错误消息。

Pages/Students/Delete.cshtml.cs 中的代码替换为以下代码:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Students
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;
        private readonly ILogger<DeleteModel> _logger;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context,
                           ILogger<DeleteModel> logger)
        {
            _context = context;
            _logger = logger;
        }

        [BindProperty]
        public Student Student { get; set; }
        public string ErrorMessage { get; set; }

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

            Student = await _context.Students
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.ID == id);

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

            if (saveChangesError.GetValueOrDefault())
            {
                ErrorMessage = String.Format("Delete {ID} failed. Try again", id);
            }

            return Page();
        }

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

            var student = await _context.Students.FindAsync(id);

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

            try
            {
                _context.Students.Remove(student);
                await _context.SaveChangesAsync();
                return RedirectToPage("./Index");
            }
            catch (DbUpdateException ex)
            {
                _logger.LogError(ex, ErrorMessage);

                return RedirectToAction("./Delete",
                                     new { id, saveChangesError = true });
            }
        }
    }
}

前面的代码:

  • 添加日志记录
  • 将可选参数 saveChangesError 添加到 OnGetAsync 方法签名中。 saveChangesError 指示学生对象删除失败后是否调用该方法。

删除操作可能由于暂时性网络问题而失败。 数据库在云中时,更可能出现暂时性网络错误。 通过 UI 调用“删除”页 OnGetAsync 时,saveChangesError 参数为 false。 当 OnPostAsync 调用 OnGetAsync(由于删除操作失败)时,saveChangesError 参数为 true

OnPostAsync 方法检索所选实体,然后调用 Remove 方法将实体的状态设置为 Deleted。 调用 SaveChanges 时生成 SQL DELETE 命令。 如果 Remove 失败:

  • 捕获数据库异常。
  • 通过 saveChangesError=true 调用“删除”页 OnGetAsync 方法。

将错误消息添加到 Pages/Students/Delete.cshtml

@page
@model ContosoUniversity.Pages.Students.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h1>Delete</h1>

<p class="text-danger">@Model.ErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
    </dl>

    <form method="post">
        <input type="hidden" asp-for="Student.ID" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

运行应用并删除学生以测试“删除”页。

后续步骤

本教程将介绍和自定义已搭建基架的 CRUD (创建、读取、更新、删除)代码。

无存储库

某些开发人员使用服务层或存储库模式在 UI (Razor Pages) 和数据访问层之间创建抽象层。 本教程不会这样做。 为最大程度降低复杂性并让本教程重点介绍 EF Core,将直接在页面模型类中添加 EF Core 代码。

更新“详细信息”页

“学生”页的基架代码不包括注册数据。 本部分将向“详细信息”页添加注册。

读取注册

若要在页面上显示学生的注册数据,需要读取注册数据。 Pages/Students/Details.cshtml.cs 中的基架代码仅读取学生数据,但不读取注册数据:

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

    Student = await _context.Students.FirstOrDefaultAsync(m => m.ID == id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

使用以下代码替换 OnGetAsync 方法以读取所选学生的注册数据。 突出显示所作更改。

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

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

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

IncludeThenInclude 方法使上下文加载 Student.Enrollments 导航属性,并在每个注册中加载 Enrollment.Course 导航属性。 这些方法将在与数据读取相关的教程中进行详细介绍。

对于返回的实体未在当前上下文中更新的情况,AsNoTracking 方法将会提升性能。 AsNoTracking 将在本教程的后续部分中讨论。

显示注册

使用以下代码替换 Pages/Students/Details.cshtml 中的代码以显示注册列表。 突出显示所作更改。

@page
@model ContosoUniversity.Pages.Students.DetailsModel

@{
    ViewData["Title"] = "Details";
}

<h1>Details</h1>

<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.Enrollments)
        </dt>
        <dd class="col-sm-10">
            <table class="table">
                <tr>
                    <th>Course Title</th>
                    <th>Grade</th>
                </tr>
                @foreach (var item in Model.Student.Enrollments)
                {
                    <tr>
                        <td>
                            @Html.DisplayFor(modelItem => item.Course.Title)
                        </td>
                        <td>
                            @Html.DisplayFor(modelItem => item.Grade)
                        </td>
                    </tr>
                }
            </table>
        </dd>
    </dl>
</div>
<div>
    <a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
    <a asp-page="./Index">Back to List</a>
</div>

上面的代码循环通过 Enrollments 导航属性中的实体。 它将针对每个注册显示课程标题和成绩。 课程标题从 Course 实体中检索,该实体存储在 Enrollments 实体的 Course 导航属性中。

运行应用,选择“学生”选项卡,然后单击学生的“详细信息”链接 。 随即显示出所选学生的课程和成绩列表。

读取一个实体的方法

生成的代码使用 FirstOrDefaultAsync 读取一个实体。 如果未找到任何内容,则此方法返回 NULL;否则,它将返回满足查询筛选条件的第一行。 FirstOrDefaultAsync 通常是比以下备选方案更好的选择:

  • SingleOrDefaultAsync - 如果有多个满足查询筛选器的实体,则引发异常。 若要确定查询是否可以返回多行,SingleOrDefaultAsync 会尝试提取多个行。 如果查询只能返回一个实体,就像它在搜索唯一键时一样,那么该额外工作是不必要的。
  • FindAsync - 查找具有主键 ( PK) 的实体。 如果具有 PK 的实体正在由上下文跟踪,会返回该实体且不向数据库发出请求。 此方法经过优化,可查找单个实体,但无法通过 FindAsync 调用 Include。 如果需要相关数据,FirstOrDefaultAsync 则是更好的选择。

路由数据与查询字符串

“详细信息”页的 URL 是 https://localhost:<port>/Students/Details?id=1。 实体的主键值在查询字符串中。 某些开发人员偏向于在路由数据中传递键值:https://localhost:<port>/Students/Details/1。 有关详细信息,请参阅更新生成的代码

更新“创建”页

“创建”页面的基架 OnPostAsync 代码容易受到过多发布攻击。 将 Pages/Students/Create.cshtml.cs 中的 OnPostAsync 方法替换为以下代码。

public async Task<IActionResult> OnPostAsync()
{
    var emptyStudent = new Student();

    if (await TryUpdateModelAsync<Student>(
        emptyStudent,
        "student",   // Prefix for form value.
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        _context.Students.Add(emptyStudent);
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

TryUpdateModelAsync

前面的代码将创建一个 Student 对象,然后使用发布的表单域更新 Student 对象的属性。 TryUpdateModelAsync 方法:

  • 使用 PageModelPageContext 属性的已发布的表单值。
  • 仅更新列出的属性 (s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate)。
  • 查找带有“student”前缀的表单值。 例如 Student.FirstMidName。 该自变量不区分大小写。
  • 使用模型绑定系统将字符串中的表单值转换为 Student 模型中的类型。 例如,EnrollmentDate 必须转换为 DateTime。

运行应用,并创建一个学生实体以测试“创建”页。

过多发布

使用 TryUpdateModel 更新具有已发布值的字段是一种最佳的安全做法,因为这能阻止过多发布。 例如,假设 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; }
}

即使应用的创建或更新 Razor 页面上没有 Secret 字段,黑客仍可利用过多发布设置 Secret 值。 黑客也可使用 Fiddler 等工具或通过编写某个 JavaScript 来发布 Secret 表单值。 原始代码不会限制模型绑定器在创建“学生”实例时使用的字段。

黑客为 Secret 表单域指定的任何值都会在数据库中更新。 下图显示 Fiddler 工具正在将 Secret 字段(值为“OverPost”)添加到已发布的表单值。

Fiddler adding Secret field

值“OverPost”已成功添加到所插入行的 Secret 属性中。 即使应用设计器从未打算用“创建”页设置 Secret 属性,也会发生这种情况。

视图模型

视图模型还提供了一种防止过度发布的方法。

应用程序模型通常称为域模型。 域模型通常包含数据库中对应实体所需的全部属性。 视图模型只包含它所用于的 UI(例如,“创建”页)所需的属性。

除视图模型外,某些应用使用绑定模型或输入模型在 Razor Pages 页面模型类和浏览器之间传递数据。

请考虑以下 Student 视图模型:

using System;

namespace ContosoUniversity.Models
{
    public class StudentVM
    {
        public int ID { get; set; }
        public string LastName { get; set; }
        public string FirstMidName { get; set; }
        public DateTime EnrollmentDate { get; set; }
    }
}

以下代码使用 StudentVM 视图模型创建新的学生:

[BindProperty]
public StudentVM StudentVM { get; set; }

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var entry = _context.Add(new Student());
    entry.CurrentValues.SetValues(StudentVM);
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}

SetValues 方法通过从另一个 PropertyValues 对象读取值来设置此对象的值。 SetValues 使用属性名称匹配。 视图模型类型不需要与模型类型相关,它只需要具有匹配的属性。

使用 StudentVM 时需要更新 Create.cshtml 才能使用 StudentVM 而非 Student

更新“编辑”页

Pages/Students/Edit.cshtml.cs 中,使用以下代码替换 OnGetAsyncOnPostAsync 方法。

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

    Student = await _context.Students.FindAsync(id);

    if (Student == null)
    {
        return NotFound();
    }
    return Page();
}

public async Task<IActionResult> OnPostAsync(int id)
{
    var studentToUpdate = await _context.Students.FindAsync(id);

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

    if (await TryUpdateModelAsync<Student>(
        studentToUpdate,
        "student",
        s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }

    return Page();
}

代码更改与“创建”页类似,但有少数例外:

  • FirstOrDefaultAsync 已替换为 FindAsync。 如果不需要包含的相关数据,FindAsync 更高效。
  • OnPostAsync 具有 id 参数。
  • 当前学生是从数据库中提取的,而非通过创建空学生获得。

运行应用,并通过创建和编辑学生进行测试。

实体状态

数据库上下文会随时跟踪内存中的实体是否已与其在数据库中的对应行进行同步。 此跟踪信息可确定调用 SaveChangesAsync 后的行为。 例如,将新实体传递到 AddAsync 方法时,该实体的状态设置为 Added。 调用 SaveChangesAsync 时,数据库上下文会发出 SQL INSERT 命令。

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

  • Added:数据库中尚不存在实体。 SaveChanges 方法发出 INSERT 语句。

  • Unchanged:无需保存对该实体所做的任何更改。 从数据库中读取实体时,该实体具有此状态。

  • Modified:已修改实体的部分或全部属性值。 SaveChanges 方法发出 UPDATE 语句。

  • Deleted:已标记该实体进行删除。 SaveChanges 方法发出 DELETE 语句。

  • Detached:数据库上下文未跟踪该实体。

在桌面应用中,通常会自动设置状态更改。 读取实体并执行更改后,实体状态自动更改为 Modified。 调用 SaveChanges 会生成仅更新已更改属性的 SQL UPDATE 语句。

在 Web 应用中,读取实体并显示数据的 DbContext 将在页面呈现后进行处理。 调用页面 OnPostAsync 方法时,将发出具有 DbContext 的新实例的 Web 请求。 如果在这个新的上下文中重新读取实体,则会模拟桌面处理。

更新“删除”页

在此部分中,当对 SaveChanges 的调用失败时,将实现自定义错误消息。

Pages/Students/Delete.cshtml.cs 中的代码替换为以下代码。 更改将突出显示(而不是清除 using 语句)。

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Students
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Student Student { get; set; }
        public string ErrorMessage { get; set; }

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

            Student = await _context.Students
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.ID == id);

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

            if (saveChangesError.GetValueOrDefault())
            {
                ErrorMessage = "Delete failed. Try again";
            }

            return Page();
        }

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

            var student = await _context.Students.FindAsync(id);

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

            try
            {
                _context.Students.Remove(student);
                await _context.SaveChangesAsync();
                return RedirectToPage("./Index");
            }
            catch (DbUpdateException /* ex */)
            {
                //Log the error (uncomment ex variable name and write a log.)
                return RedirectToAction("./Delete",
                                     new { id, saveChangesError = true });
            }
        }
    }
}

前面的代码将可选参数 saveChangesError 添加到 OnGetAsync 方法签名中。 saveChangesError 指示学生对象删除失败后是否调用该方法。 删除操作可能由于暂时性网络问题而失败。 数据库在云中时,更可能出现暂时性网络错误。 通过 UI 调用“删除”页 OnGetAsync 时,saveChangesError 参数为 false。 当 OnPostAsync 调用 OnGetAsync(由于删除操作失败)时,saveChangesError 参数为 true。

OnPostAsync 方法检索所选实体,然后调用 Remove 方法将实体的状态设置为 Deleted。 调用 SaveChanges 时生成 SQL DELETE 命令。 如果 Remove 失败:

  • 捕获数据库异常。
  • 通过 saveChangesError=true 调用“删除”页的 OnGetAsync 方法。

将错误消息添加到“删除”Razor 页面 (Pages/Students/Delete.cshtml):

@page
@model ContosoUniversity.Pages.Students.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h1>Delete</h1>

<p class="text-danger">@Model.ErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Student</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.LastName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.LastName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.FirstMidName)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.FirstMidName)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Student.EnrollmentDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Student.EnrollmentDate)
        </dd>
    </dl>

    <form method="post">
        <input type="hidden" asp-for="Student.ID" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

运行应用并删除学生以测试“删除”页。

后续步骤