3. 自定义 ASP.NET Core MVC 网站
现在您已经了解了基本 MVC 网站的结构,您将对其进行自定义和扩展。您已经为 Northwind 数据库注册了一个 EF Core 模型,因此下一个任务是在主页上输出一些数据。
3.1 定义自定义样式
主页将显示 Northwind 数据库中77 种产品的列表。为了有效利用空间,我们希望以三列显示列表。为此,我们需要为网站自定义样式表:
在 wwwroot\css 文件夹中,打开 site.css 文件。
在文件的底部,添加将应用于具有product-columns(产品列) ID 的元素的新样式,如以下代码所示:
#product-columns { column-count: 3; }
3.2 设置类别图像
Northwind 数据库包括一个包含八个类别的表格,但它们没有图像,并且网站上有一些彩色图片看起来更好:
在 wwwroot 文件夹中,创建一个名为 images 的文件夹。
在images文件夹中添加category1.jpeg、category2.jpeg等八个图片文件,直至category8.jpeg。
您可以通过以下链接从本书的 GitHub 存储库下载图像:https://github.com/markjprice/cs10dotnet6/tree/master/Assets/Categories
3.3 了解 Razor 语法
在我们自定义主页视图之前,让我们回顾一个示例 Razor 文件,该文件具有一个初始 Razor 代码块,该代码块使用价格和数量实例化订单,然后在网页上输出有关订单的信息,如以下标记所示:
@{
Order order = new()
{
OrderId = 123,
Product = "Sushi",
Price = 8.49M,
Quantity = 3
};
}
<div>Your order for @order.Quantity of @order.Product has a total cost of $@order.Price * @order.Quantity
</div>
前面的 Razor 文件会导致以下不正确的输出:
Your order for 3 of Sushi has a total cost of $8.49 * 3
尽管 Razor 标记可以使用 @object 包含任何单个属性的值。属性语法,您应该将表达式括在括号中,如以下标记所示:
<div>Your order for @order.Quantity of @order.Product has a total cost of $@(order.Price * order.Quantity)</div>
前面的Razor 表达式导致以下正确输出:
Your order for 3 of Sushi has a total cost of $25.47
3.4 定义typed view类型视图
要在编写视图时改进 IntelliSense,您可以使用顶部的 @model 指令定义视图可以期望的类型:
在 Views\Home 文件夹中,打开 Index.cshtml。
在文件的顶部,添加一条语句来设置模型类型以使用
HomeIndexViewModel,如下代码所示:
@model HomeIndexViewModel现在,每当我们在此视图中键入 Model 时,您的代码编辑器都会知道模型的正确类型,并会为其提供 IntelliSense。在视图中输入代码时,请记住以下几点:
让我们继续自定义主页的视图。
声明模型的类型,使用@model(带有小写的m)
与模型实例交互,使用@Model(带有大写的M)
在最初的 Razor 代码块中,添加一条语句为当前项目声明一个字符串变量,并在现有的 <div> 元素下添加新markup标记,以在轮播中输出类别和产品输出为无序列表,如下标记所示:
@using Packt.Shared
@using Northwind.Common
@model HomeIndexViewModel //小写model
@{
ViewData["Title"] = "Home Page";
string currentItem = "";
WeatherForecast[]? weather = ViewData["weather"] as WeatherForecast[];
}
@if (Model is not null) // 大写Model
{ <!-- div 标签上的 id 为 div 元素分配一个标识符 https://www.dofactory.com/html/div-->
<div id="categories" class="carousel slide" data-bs-ride="carousel"
data-bs-interval="3000" data-keyboard="true"> <!--data-* 定义 JavaScript 可以使用的其他数据-->
<ol class="carousel-indicators"> <!-- 轮播指示器-->
@for (int c = 0; c < Model.Categories.Count; c++)
{
if (c == 0)
{
currentItem = "active";
}
else
{
currentItem = "";
}
<li data-bs-target="#categories" data-bs-slide-to="@c"
class="@currentItem"></li>
}
</ol>
<div class="carousel-inner"> <!--内部轮播器-->
@for (int c = 0; c < Model.Categories.Count; c++)
{
if (c == 0)
{
currentItem = "active";
}
else
{
currentItem = "";
}
<div class="carousel-item @currentItem"> <!--轮播项-->
<img class="d-block w-100" src="~/images/category@(Model.Categories[c].CategoryId).jpeg"
alt="@Model.Categories[c].CategoryName" /> <!--图片img src="img_girl.jpg" alt="Girl in a jacket" width="500" height="600"-->
<!--alt - 如果由于某种原因无法显示图像,则指定图像的替代文本-->
<div class="carousel-caption d-none d-md-block">
<h2>@Model.Categories[c].CategoryName</h2>
<h3>@Model.Categories[c].Description</h3>
<p>
<a class="btn btn-primary"
href="/home/categorydetail/@Model.Categories[c].CategoryId">View
</a><!--超文本链接:右键图片在新标签页中打开-->
</p>
</div>
</div>
}
</div> <!--轮播控制-->
<a class="carousel-control-prev" href="#categories"
role="button" data-bs-slide="prev">
<span class="carousel-control-prev-icon"
aria-hidden="true"></span><!--定义文档中的节-->
<span class="sr-only">Previous</span> <!--定义文档中的节-->
</a> <!--超文本链接-->
<a class="carousel-control-next" href="#categories"
role="button" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a><!--超文本链接-->
</div>
}
<div class="row">
<div class="col-md-12">
<h1>Northwind</h1> <!-- 标题 -->
<p class="lead">
We have had @Model?.VisitorCount visitors this month.
</p>
<h3>Query customers from a service</h3>
<form asp-action="Customers" method="get">
<input name="country" placeholder="Enter a country" /> <!-- 输入框 -->
<input type="submit" /> <!-- 提交按钮 -->
</form>
<h3>Query products by price</h3>
<form asp-action="ProductsThatCostMoreThan" method="GET">
<input name="price" placeholder="Enter a product price" />
<input type="submit" /> <!-- 提交按钮 -->
</form> <!--HTML 表单用于收集用户输入。用户输入最常被发送到服务器进行处理。-->
@if (Model is not null)
{
<h2>Products</h2> <!-- 标题 -->
<div id="product-columns">
<ul> <!--定义无序列表-->
@foreach (Product p in @Model.Products)
{
<li> <!-- 定义列表的项目 -->
<a asp-controller="Home"
asp-action="ProductDetail"
asp-route-id="@p.ProductId">
@p.ProductName costs
@(p.UnitPrice is null ? "zero" : p.UnitPrice.Value.ToString("C"))
</a> <!--指定单击链接时将执行的控制器名称和操作名称-->
</li>
}
</ul>
</div>
}
</div>
</div>
查看前面的 Razor 标记时,请注意以下事项:
• 很容易将<ul> 和<li> 等静态HTML 元素与C# 代码混合,以输出类别轮播和产品名称列表。
• 具有 product-columns id 属性的<div> 元素将使用我们之前定义的自定义样式,因此该元素中的所有内容将显示在三列中。
• 每个类别的 <img> 元素使用括号括起 Razor 表达式以确保编译器不会将 .jpeg 作为表达式的一部分包含在内,如以下标记所示:“~/images/category@(Model.Categories[c].CategoryID).jpeg"
• 产品链接的<a> 元素使用标签助手生成URL 路径。单击这些超链接将由 HomeController 及其 ProductDetail 操作方法处理。此操作方法尚不存在,但您将在本章稍后添加它。产品的 ID 作为名为 id 的路由段传递,如 Ipoh Coffee 的以下 URL 路径所示:https://localhost:5001/Home/ProductDetail/43
3.5 查看自定义主页
让我们看看我们定制的主页的结果:
启动Northwind.Mvc网站项目。
请注意主页有一个旋转的轮播图,显示类别、随机数量的访问者和三列的产品列表,如图 15.4 所示:
现在,单击任何类别或产品链接都会出现 404 Not Found 错误,所以让我们看看如何实现使用传递的参数查看产品或类别详细信息的页面。
3. 关闭 Chrome 并关闭网络服务器。
3.6 使用路由值传递参数
传递简单参数的一种方法是使用默认路由中定义的 id 段:
在 HomeController 类中,添加一个名为 ProductDetail 的 action 方法,如下代码所示:
public async Task<IActionResult> ProductDetail(int? id) { if (!id.HasValue) { return BadRequest("You must pass a product ID in the route, for example, /Home/ProductDetail/21"); } Product? model = await db.Products .SingleOrDefaultAsync(p => p.ProductId == id); if (model == null) { return NotFound($"ProductId {id} not found."); } return View(model); // 将模型传递给视图,然后返回结果 }
请注意以下事项:
• 此方法使用称为模型绑定的ASP.NET Core 功能自动将路由中传递的id 与方法中名为id 的参数相匹配。
• 在该方法内部,我们检查id 是否没有值,如果是,我们调用BadRequest 方法返回一个400 状态代码,其中包含解释正确URL 路径格式的自定义消息。
• 否则,我们可以连接到数据库并尝试使用 id 值检索产品。
• 如果我们找到一个产品,我们将它传递给一个视图;否则,我们调用 NotFound 方法返回 404 状态代码和一条自定义消息,说明在数据库中未找到具有该 ID 的产品。在 Views/Home 文件夹中,添加一个名为 ProductDetail.cshtml 的新文件。
修改内容,如下标记所示:
@model Packt.Shared.Product @{ ViewData["Title"] = "Product Detail - " + Model.ProductName; } <h2>Product Detail</h2> <hr /> <div> <!--dl, dd, and dt这三个元素用于创建描述列表:--> <dl class="dl-horizontal"> <!--<dl> 标签定义了一个描述列表。--> <dt>Product Id</dt> <!--标题1--> <dd>@Model.ProductId</dd> <!--数据--> <dt>Product Name</dt><!--标题2--> <dd>@Model.ProductName</dd> <dt>Category Id</dt><!--标题3--> <dd>@Model.CategoryId</dd> <dt>Unit Price</dt><!--标题4--> <dd>@Model.UnitPrice.Value.ToString("C")</dd> <dt>Units In Stock</dt><!--标题5--> <dd>@Model.UnitsInStock</dd> </dl> </div>
启动 Northwind.Mvc 项目。
当主页出现产品列表时,单击其中一个,例如,第二个产品 Chang。
注意浏览器地址栏中的URL路径,浏览器选项卡中显示的页面标题,以及商品详情页,如图15.5所示:
查看开发者工具。
在Chrome地址栏编辑URL,请求一个不存在的产品ID,比如99,注意404 Not Found状态码和自定义错误响应。
3.7 更详细地了解模型绑定器
模型绑定器功能强大,默认的绑定器可以为您做很多事情。在默认路由标识要实例化的控制器类和要调用的操作方法之后,如果该方法有参数,则需要设置这些参数的值。
模型绑定器通过查找在 HTTP 请求中作为以下任何类型的参数传递的参数值来执行此操作:
• 路由参数,如上一节我们做的id,如下URL路径所示:/Home/ProductDetail/2
• 查询字符串参数,如下URL路径所示:/Home/ProductDetail?id=2
• 表单参数,如以下标记所示:
<form action="post" action="/Home/ProductDetail">
<input type="text" name="id" value="2" />
<input type="submit" />
</form>
模型绑定器几乎可以填充任何类型:
• 简单类型,如 int、string、DateTime 和bool。
• 类、记录或结构定义的复杂类型。
• 集合类型,如数组和列表。
让我们创建一个有点人为的例子来说明使用默认模型绑定器可以实现什么:
在 Models 文件夹中,添加一个名为 Thing.cs 的新文件。
修改内容,定义一个类,该类具有两个属性,一个为可为空的整数Id,一个为字符串,名为Color,如下代码所示:
using System.ComponentModel.DataAnnotations; // [Range], [Required], [EmailAddress] namespace Northwind.Mvc.Models; public class Thing { [Range(1, 10)] public int? Id { get; set; } [Required] public string? Color { get; set; } [EmailAddress] public string? Email { get; set; } }
在 HomeController 中,添加两个新的操作方法,一个用于显示带有表单的页面,一个用于使用您的新模型类型显示带有参数的事物,如以下代码所示:
public IActionResult ModelBinding() { return View(); // the page with a form to submit } [HttpPost] public IActionResult ModelBinding(Thing thing) { HomeModelBindingViewModel model = new( thing, !ModelState.IsValid, ModelState.Values .SelectMany(state => state.Errors) .Select(error => error.ErrorMessage) ); return View(model); }
在 Views\Home 文件夹中,添加一个名为 ModelBinding.cshtml 的新文件。
修改其内容,如以下标记所示:
@model Northwind.Mvc.Models.HomeModelBindingViewModel @{ ViewData["Title"] = "Model Binding Demo"; } <h1>@ViewData["Title"]</h1> <!--模型绑定演示--> <div> Enter values for your thing in the following form: </div> <!--表单--> <form method="POST" action="/home/modelbinding/2?id=3"> <!--action="/home/modelbinding?id=3"--> <input name="id" value="1" /> <input name="color" value="Red" /> <input name="email" value="test@example.com" /> <input type="submit" /> <!--提交--> </form> @if (Model != null) { <h2>Submitted Thing</h2> <hr /> <div> <!--dl, dd, and dt这三个元素用于创建描述列表:--> <dl class="dl-horizontal"> <dt>Model.Thing.Id</dt> <dd>@Model.Thing.Id</dd> <dt>Model.Thing.Color</dt> <dd>@Model.Thing.Color</dd> <dt>Model.Thing.Email</dt> <dd>@Html.DisplayFor(model => model.Thing.Email)</dd> </dl> </div> @if (Model.HasErrors) { <div> @foreach (string errorMessage in Model.ValidationErrors) { <div class="alert alert-danger" role="alert">@errorMessage</div> } </div> } }
在 Views/Home 中,打开 Index.cshtml,在第一个 <div> 中,添加一个新段落,其中包含指向模型绑定页面的链接,如以下标记所示:
<p><a asp-action="ModelBinding" asp-controller="Home">Binding</a></p>
启动网站。
在首页点击绑定。
注意关于不明确匹配的未处理异常,如图 15.6 所示:
关闭 Chrome 并关闭网络服务器。
3.8 消除动作方法的歧义
虽然 C# 编译器可以通过注意到签名signatures 的不同来区分这两种方法,但从 HTTP 请求的路由角度来看,这两种 (ModelBinding) 方法都是潜在的匹配。我们需要一种特定于 HTTP 的方法来消除操作方法的歧义。
为此,我们可以为操作 (actions)创建不同的名称,或者指定一种方法应用于特定的 HTTP 动词,如 GET、POST 或 DELETE。这就是我们解决问题的方法:
在HomeController中,装饰第二个ModelBinding action方法,指明它应该用于处理HTTP POST请求,即提交表单时,如下代码高亮显示:
[HttpPost] public IActionResult ModelBinding(Thing thing)
其他 ModelBinding 操作方法将隐式用于所有其他类型的 HTTP 请求,如 GET、PUT、DELETE 等。
启动网站。
在首页点击绑定。
单击提交按钮并注意 Id 属性的值是从查询字符串参数设置的,颜色属性的值是从表单参数设置的,如图 15.7 所示:
关闭 Chrome 并关闭网络服务器。
3.9 传递路由参数
现在我们将使用路由参数设置属性:
修改表单的操作以将值 2 作为路由参数传递,如以下标记中突出显示的所示:
<form method="POST" action="/home/modelbinding/2?id=3">
启动网站。
在首页点击 Binding。
单击 Submit 按钮,注意 Id 属性的值是从路由参数设置的,Color 属性的值是从表单参数设置的。
关闭 Chrome 并关闭网络服务器。
3.10 传递表单参数
现在我们将使用表单参数设置属性:
修改表单的操作以将值 1 作为表单参数传递,如以下标记中突出显示的所示:
<form method="POST" action="/home/modelbinding/2?id=3"> <input name="id" value="1" /> <input name="color" value="Red" /> <input name="email" value="test@example.com" /> <input type="submit" /> </form>
启动网站。
在首页点击 Binding.
单击 Submit 按钮并注意 Id 和 Color 属性的值都是从表单参数中设置的。
良好实践:如果您有多个同名参数,请记住表单参数具有最高优先级,查询字符串参数具有自动模型绑定的最低优先级。
3.11 验证模型 Validating the model
模型绑定的过程可能会导致错误,例如,如果模型已使用验证规则进行修饰,则数据类型转换或验证错误。绑定了哪些数据以及任何绑定或验证错误都存储在 ControllerBase.ModelState 中。
让我们通过对绑定模型应用一些验证规则然后在视图中显示无效数据消息来探索我们可以对模型状态做些什么:
在模型文件夹中,打开 Thing.cs。
导入 System.ComponentModel.DataAnnotations 命名空间。
用验证属性(validation attribute)装饰 Id 属性,将允许的数字范围限制在 1 到 10,一个确保访问者提供颜色,并添加一个新的 Email 属性,使用正则表达式进行验证,如以下代码中突出显示的 :
using System.ComponentModel.DataAnnotations; // [Range], [Required], [EmailAddress] namespace Northwind.Mvc.Models; public class Thing { [Range(1, 10)] public int? Id { get; set; } [Required] public string? Color { get; set; } [EmailAddress] public string? Email { get; set; } }
在 Models 文件夹中,添加一个名为 HomeModelBindingViewModel.cs 的新文件。
修改其内容以定义一个记录,该记录具有用于存储绑定模型的属性、一个指示存在错误的标志以及一系列错误消息,如以下代码所示:
namespace Northwind.Mvc.Models; public record HomeModelBindingViewModel //定义一个记录 ( Thing Thing, //用于存储绑定模型的属性 bool HasErrors, //指示存在错误的标志 IEnumerable<string> ValidationErrors //一系列错误消息 );
在 HomeController 中,在处理HTTP POST的 ModelBinding 方法中,将之前传递东西给视图的语句注释掉,改为添加创建视图模型实例的语句。 验证模型并存储错误消息数组,然后将视图模型传递给视图,如以下代码中突出显示的所示:
[HttpPost] public IActionResult ModelBinding(Thing thing) { HomeModelBindingViewModel model = new( thing, !ModelState.IsValid, ModelState.Values .SelectMany(state => state.Errors) .Select(error => error.ErrorMessage) ); return View(model); }
在 Views\Home 中,打开 ModelBinding.cshtml。
修改模型类型声明以使用视图模型类,如以下标记所示:
@model Northwind.Mvc.Models.HomeModelBindingViewModel
添加 <div> 以显示任何模型验证错误,并更改事物属性的输出,因为视图模型已更改,如以下标记中突出显示的所示:
@model Northwind.Mvc.Models.HomeModelBindingViewModel @{ ViewData["Title"] = "Model Binding Demo"; } <h1>@ViewData["Title"]</h1> <!--模型绑定演示--> <div> Enter values for your thing in the following form: </div> <!--表单--> <form method="POST" action="/home/modelbinding/2?id=3"> <!--action="/home/modelbinding?id=3"--> <input name="id" value="1" /> <input name="color" value="Red" /> <input name="email" value="test@example.com" /> <input type="submit" /> <!--提交--> </form> @if (Model != null) { <h2>Submitted Thing</h2> <hr /> <div> <!--dl, dd, and dt这三个元素用于创建描述列表:--> <dl class="dl-horizontal"> <dt>Model.Thing.Id</dt> <dd>@Model.Thing.Id</dd> <dt>Model.Thing.Color</dt> <dd>@Model.Thing.Color</dd> <dt>Model.Thing.Email</dt> <dd>@Html.DisplayFor(model => model.Thing.Email)</dd> </dl> </div> @if (Model.HasErrors) { <div> <!--显示任何模型验证错误--> @foreach (string errorMessage in Model.ValidationErrors) { <div class="alert alert-danger" role="alert">@errorMessage</div> } </div> } }
启动网站。
在首页点击Binding。
单击 Submit 按钮并注意 1、红色和 test@example.com 是有效值。
输入Id为13,清除颜色文本框,删除邮箱地址中的@,点击提交按钮,注意错误信息,如图15.8所示:
关闭 Chrome 并关闭网络服务器。
最佳实践:Microsoft 使用什么正则表达式来实现 EmailAddress 验证属性?在以下链接中查找:https://github.com/microsoft/referencesource/blob/5697c29004a34d80acdaf5742d7e699022c64ecd/System.ComponentModel.DataAnnotations/DataAnnotations/EmailAddressAttribute.cs#L54
3.12 理解视图助手方法
Understanding view helper methods
在为 ASP.NET Core MVC 创建视图时,您可以使用 Html 对象及其方法来生成标记。
一些有用的方法包括:
• ActionLink:使用它来生成一个锚点 <a> 元素,其中包含指定控制器和操作的 URL 路径。例如,Html.ActionLink(linkText: "Binding", actionName: "ModelBinding", controllerName: "Home") 将生成 <a href="/ home/modelbinding">Binding。您可以使用锚标记助手实现相同的结果:<a asp-action="ModelBinding" asp-controller="Home">Binding。
• AntiForgeryToken:在<form> 中使用它来插入一个<hidden> 元素,其中包含将在提交表单时验证的防伪令牌。
• Display 和 DisplayFor:使用它为相对于使用显示模板的当前模型的表达式生成HTML 标记。有用于 .NET 类型的内置显示模板,并且可以在 DisplayTemplates 文件夹中创建自定义模板。文件夹名称在区分大小写的文件系统上区分大小写。
• DisplayForModel:使用它为整个模型而不是单个表达式生成 HTML 标记。
• Editor 和EditorFor:使用它为相对于使用编辑器模板的当前模型的表达式生成HTML 标记。有用于使用 <label> 和 <input> 元素的 .NET 类型的内置编辑器模板,并且可以在 EditorTemplates 文件夹中创建自定义模板。文件夹名称在区分大小写的文件系统上区分大小写。
• EditorForModel:使用它为整个模型而不是单个表达式生成HTML 标记。
• Encode:使用它可以安全地将对象或字符串编码为HTML。例如,字符串值“<script>”将被编码为 "<script>"。这通常不是必需的,因为 Razor @ 符号默认对字符串值进行编码。
• Raw:使用它来呈现一个字符串值而不编码为HTML。
• PartialAsync 和 RenderPartialAsync:使用它们为局部视图生成 HTML 标记。您可以选择传递模型并查看数据。
让我们看一个例子:
在 Views/Home 中,打开 ModelBinding.cshtml。
修改 Email 属性的呈现以使用 DisplayFor,如以下标记所示:
<dd>@Html.DisplayFor(model => model.Thing.Email)</dd>启动网站。
点击绑定。
单击提交。
请注意,电子邮件地址是一个可点击的超链接,而不仅仅是文本。
关闭 Chrome 并关闭网络服务器。
在 Models/Thing.cs 中,注释掉 Email 属性上方的 [EmailAddress] 属性。
启动网站。
单击绑定。
单击提交。
注意电子邮件地址只是文本。
关闭 Chrome 并关闭网络服务器。
在 Models/Thing.cs 中,取消注释 [EmailAddress] 属性。
它是使用 [EmailAddress] 验证属性装饰 Email 属性并使用 DisplayFor 呈现它的组合,通知 ASP.NET Core 将值视为电子邮件地址,因此将其呈现为可点击链接。
3.13 查询数据库和使用显示模板
让我们创建一个新的操作方法,可以将查询字符串参数传递给它,并使用它来查询 Northwind 数据库中价格超过指定价格的产品。在前面的示例中,我们定义了一个视图模型,其中包含需要在视图中呈现的每个值的属性。在此示例中,将有两个值:产品列表 和 访问者输入的价格。为避免必须为视图模型定义类或记录,我们将产品列表作为模型传递,并将最高价格存储在 ViewData 集合中。
让我们来实现这个功能:
在 HomeController 中,导入 Microsoft.EntityFrameworkCore 命名空间。我们需要它来添加 Include 扩展方法,以便我们可以包含相关实体,正如您在第 10 章使用 Entity Framework Core 处理数据中学到的那样。
添加一个新的action方法,如下代码所示:
public IActionResult ProductsThatCostMoreThan(decimal? price) { if (!price.HasValue) { // 创建生成 StatusCodes 的 BadRequestObjectResult。Status400BadRequest 响应。 return BadRequest("You must pass a product price in the query string, for example, /Home/ProductsThatCostMoreThan?price=50"); } IEnumerable<Product> model = db.Products .Include(p => p.Category) .Include(p => p.Supplier) .Where(p => p.UnitPrice > price); //产品列表作为模型传递 if (!model.Any()) { return NotFound( $"No products cost more than {price:C}."); } //最高价格存储在 ViewData 集合 ViewData["MaxPrice"] = price.Value.ToString("C"); return View(model); // pass model to view }
在 Views/Home 文件夹中,添加一个名为 productsThatCostMoreThan.cshtml 的新文件。
修改内容,如下代码所示:
@using Packt.Shared @model IEnumerable<Product> @{ string title ="Products That Cost More Than " + ViewData["MaxPrice"]; ViewData["Title"] = title; } <h2>@title</h2> @if (Model is null) { <div>No products found.</div> } else { <table class="table"> <thead> <tr> <th>Category Name</th> <th>Supplier's Company Name</th> <th>Product Name</th> <th>Unit Price</th> <th>Units In Stock</th> <!--库存单位--> </tr> </thead> <tbody> @foreach (Product p in Model) { <tr> <td> @Html.DisplayFor(modelItem => p.Category.CategoryName) </td> <td> @Html.DisplayFor(modelItem => p.Supplier.CompanyName) </td> <td> @Html.DisplayFor(modelItem => p.ProductName) </td> <td> @Html.DisplayFor(modelItem => p.UnitPrice) </td> <td> @Html.DisplayFor(modelItem => p.UnitsInStock) </td> </tr> } <tbody> </table> }
HTML 表格有两种单元格:
标题单元格 - 包含标题信息(使用 <th> 元素创建)
数据单元格 - 包含数据(使用 <td> 元素创建)
<td> 元素中的文本是规则的,默认情况下是左对齐的。
<th> 元素中的文本默认为粗体且居中HTML <td> Tag
Attribute Value Description colspan number 指定单元格应跨越的列数 headers header_id 指定一个或多个与单元格相关的标题单元格 rowspan number 设置单元格应跨越的行数 在 Views/Home 文件夹中,打开 Index.cshtml。
在访问者计数下方和产品标题及其产品列表上方添加以下表单元素。这将为用户提供一个表格来输入价格。然后用户可以单击提交来调用仅显示价格高于输入价格的产品的操作方法:
<h3>Query products by price</h3> <form asp-action="ProductsThatCostMoreThan" method="GET"> <input name="price" placeholder="Enter a product price" /> <input type="submit" /> <!-- 提交按钮 --> </form> <!--HTML 表单用于收集用户输入。用户输入最常被发送到服务器进行处理。-->
启动网站。
在首页的表格中输入一个价格,例如50,然后点击提交。
注意价格高于输入价格的产品表,如图 15.9 所示:
关闭 Chrome 并关闭网络服务器。
3.14 使用异步任务提高可扩展性
在构建桌面或移动应用程序时,可以使用多个任务(及其底层线程)来提高响应能力,因为当一个线程忙于任务时,另一个线程可以处理与用户的交互。
任务及其线程在服务器端也很有用,尤其是对于处理文件的网站,或者从商店 或 Web 服务请求数据可能需要一段时间才能响应。但它们不利于受 CPU 限制的复杂计算,因此让这些计算像往常一样同步处理。
当 HTTP 请求到达 Web 服务器时,会从其池中分配一个线程来处理该请求。但是,如果该线程必须等待资源,则它会被阻止处理任何更多传入请求。如果网站接收到的同时请求多于其池中的线程,那么其中一些请求将以服务器超时错误响应,503 Service Unavailable。
被锁定的线程没有做有用的工作。他们可以处理其他请求之一,但前提是我们在我们的网站中实施异步代码。
每当线程在等待它需要的资源时,它可以返回线程池并处理不同的传入请求,从而提高网站的可扩展性,即增加它可以处理的并发请求数。
为什么不只是有一个更大的线程池?在现代操作系统中,池中的每个线程都有一个 1 MB 的堆栈。异步方法使用较少的内存。它还消除了在池中创建新线程(这需要时间)的需要。新线程添加到池中的速率通常是每两秒一个,与在异步线程之间切换相比,这是一个很长的时间。
好的做法:使您的控制器异步地操作方法。
3.15 使控制器操作方法异步
使现有的操作方法异步很容易:await 、 async
修改Index action方法为异步,返回任务,等待调用异步方法获取类目和商品,如下代码高亮显示:
public async Task<IActionResult> Index() { HomeIndexViewModel model = new ( VisitorCount = (new Random()).Next(1, 1001), Categories = await db.Categories.ToListAsync(), Products = await db.Products.ToListAsync() ); return View(model); //将模型传递给视图 }
以类似的方式修改 ProductDetail 操作方法,如以下代码中突出显示:
public async Task<IActionResult> ProductDetail(int? id) { if (!id.HasValue) { return BadRequest("You must pass a product ID in the route, for example, /Home/ProductDetail/21"); } Product? model = await db.Products.SingleOrDefaultAsync(p => p.ProductId == id); if (model == null) { return NotFound($"ProductId {id} not found."); } return View(model); // pass model to view and then return result }
启动网站并注意网站的功能是相同的,但相信它现在可以更好地扩展。
关闭 Chrome 并关闭网络服务器。
实践与探索
通过回答一些问题来测试您的知识和理解力,进行一些动手实践,并通过更深入的研究探索本章的主题。
练习 15.1 – 测试你的知识
回答下列问题:
在Views文件夹中创建特殊名称_ViewStart和_ViewImports 的文件有什么作用?
_ViewStart:
用于设置视图页面的布局。所有视图页面都会使用这个文件指定的布局。
可以设置一些全局变量,这些变量会应用到所有视图页面中。
_ViewImports:
用于导入一些命名空间,这些命名空间会应用到同一文件夹下的所有视图页面。
可以设置一些全局标签辅助方法,这些方法会应用到同一文件夹下的所有视图页面。
简而言之,_ViewStart 和 _ViewImports 允许我们对同一文件夹下的所有视图页面做一些全局设置,避免每个视图页面都重复导入命名空间和配置布局。
所以在 ASP.NET Core 视图开发中,_ViewStart 和**_ViewImports** 两个文件可以有效地提高开发效率。默认的 ASP.NET Core MVC 路由中定义的三个段的名称是什么,它们代表什么,哪些是可选的?
controller: 指定该路由被哪个控制器处理。该段是必须的。
action: 指定该路由对应的具体action方法。该段也是必须的。
route: 额外的路由参数。该段是可选的。
controller 和 action 段指定了路由对应的控制器和操作方法,route段可以用于提供额外的参数。默认模型绑定器是做什么的,它可以处理什么数据类型?
默认模型绑定器(DefaultModelBinder)有两个主要作用:
将从请求(GET 或 POST)中获取到的参数,绑定到控制器 Action 方法的形参上。
可以处理和绑定很多常见的数据类型,包括:
基本类型:int、float、string 等
复杂类型:List、数组、Dictonary
模型类型:自定义的类模型
FormUrlEncodedContent 类型
JSON 类型
模型绑定器会根据控制器 Action 方法的参数类型,知道如何从请求中读取和解析参数,并将其绑定到对应的类型上。
例如:public IActionResult Index(string name, int age) {} public IActionResult Index(Product product) {} public IActionResult Index(List<int> ids) {}
对于上面的三个 Action 方法,默认模型绑定器会自动识别:
name、age 是基本类型,从查询字符串或 FORM 数据中读取
product 是模型类型,从请求读取后自动映射到 Product 对象
ids 是 List 类型,从请求中读取后自动解析到 List
所以默认模型绑定器可以处理很多常见的数据类型,解决将请求参数绑定到 Action 方法形参上的问题。在_Layout.cshtml这样的共享布局文件中,如何输出当前视图的内容?
在_Layout.cshtml 这样的共享布局文件中,可以使用下面的代码输出当前视图的内容:@RenderBody()
@RenderBody() 会渲染当前视图对应的 CSHTML 页面,也就是说在 _Layout.cshtml 中使用 @RenderBody(),会输出使用该布局文件的具体视图内容。
举例来说:
_Layout.cshtml:
<!DOCTYPE html> <html> <body> <!-- Layout content --> @RenderBody() </body> </html>
Index.cshtml:
@section Scripts { <script> // Index page scripts </script> } <h1>This is the Index view</h1> <p>This content will render inside @RenderBody()</p>
这里 Index.cshtml 使用了 _Layout.cshtml 作为布局,那么当渲染 Index.cshtml 时,最终输出的 HTML 会是:
<!DOCTYPE html> <html> <body> <!-- Layout content --> <h1>This is the Index view</h1> <p>This content will render inside @RenderBody()</p> </body> </html>
就是说 @RenderBody() 输出了 Index.cshtml 的实际内容。
所以总的来说,@RenderBody() 是在共享布局文件中输出具体视图内容的关键。在_Layout.cshtml这样的共享布局文件中,如何输出当前视图可以为其提供内容的部分section ,视图如何为该部分提供内容?
在_Layout.cshtml 这样的共享布局文件中,可以使用 @RenderSection() 来输出当前视图可以为其提供内容的部分。视图通过 @section 来为该部分提供内容。比如:
_Layout.cshtml:
<!DOCTYPE html> <html> <body> @RenderBody() @RenderSection("Scripts", required: false) </html>
Index.cshtml:
@section Scripts { <script src="..."></script> }
在这里,_Layout.cshtml 中使用 @RenderSection("Scripts", required: false) 来定义了一个可选的"Scripts"部分。
Index.cshtml 则通过 @section Scripts { ... } 为该部分提供了内容。
最终显示 HTML 会是:
<!DOCTYPE html> <html> <body> <!-- Index view content --> <script src="..."></script> </html>
也就是说,@section 定义的内容最终会输出在 @RenderSection() 的位置。
通过这个方法,你可以在布局文件中定义section 供视图来填充,视图通过 @section { ... } 的形式来为这些部分提供内容。
@RenderSection() 的 required 参数指定该部分是否为必需的。如果是必需的,没有视图提供内容就会报错。
在controller的action方法中调用View方法时,按照惯例会在什么路径下查找view?
controller的action调用view时,按惯例会在与controller同名的文件夹下查找view。
例如:
UsersController 下的Users方法会查找 views/Users 文件夹下的 Users.cshtml 文件。
ProductsController 下的Products方法会查找 views/Products 文件夹下的 Products.cshtml 文件。
也就是说,默认是根据控制器名来查找对应的视图文件名。您如何指示访问者的浏览器将响应缓存 24 小时?
您可以使用 [OutputCache] 属性来在控制器 action 方法上指示响应缓存:[OutputCache(Duration = 86400)] public ActionResult SomeAction() { // ... }
您也可以使用 [ResponseCache] 属性来为 action 方法配置响应缓存:
[ResponseCache(Duration = 86400)] public ActionResult SomeAction() { // ... }
[ResponseCache] 属性与 [OutputCache] 属性具有相同的功能和配置选项。
区别在于:
[OutputCache] 是针对 MVC 输出结果缓存的。
[ResponseCache] 是针对 HTTP 响应缓存的。
实际效果是一样的,它们都会添加 Cache-Control 和 Expires 头,让浏览器缓存响应 24 小时。
建议使用哪个属性呢?如果你仅仅想缓存 action 的输出结果,则[OutputCache]更合适。
如果你想基于响应缓存指定的复杂策略(比如 VaryByHeaders),则[ResponseCache]提供了更多配置。
总的来说,[OutputCache]更简单,[ResponseCache]提供了更多配置选项。你可以根据你的需求来选择使用哪个属性。
为什么即使您没有自己创建 Razor Pages 也可以启用?
Razor Pages 是ASP.NET Core 用于创建 Web UI 的一种方式。
即使你自己没有创建 Razor Pages,ASP.NET Core 也会默认为你启用 Razor Pages 功能。
这是因为 Razor Pages 已经内置到 ASP.NET Core 中,默认情况下就启用了。
ASP.NET Core初始化时,会自动添加下列 Razor Pages 相关的服务:
Razor Pages 页面中间件
Razor Pages 页面渲染器
Razor Pages 模型绑定器
Razor Pages 其他内部服务
这样,即使你没有自己创建 Razor Pages,系统也会默认支持Razor Pages 页的运行。启用 Razor Pages的主要作用是:
允许系统识别 *.cshtml 页面
允许系统使用内置的 Razor Pages 服务对其进行渲染
所以,即使您自己没有创建 Razor Pages,系统仍然默认启用 Razor Pages 功能,这是为了向下兼容。
如果你想完全禁用 Razor Pages,可以移除相关的中间件和服务。
但对于大部分项目来说,默认启用 Razor Pages 是非常方便的。ASP.NET Core MVC 如何识别可以充当控制器的类?
ASP.NET Core MVC 通过以下方式识别可以充当控制器的类:命名约定:类名以"Controller"为后缀,例如HomeController。这是最常见的识别方法。
继承:类可以继承自Controller基类。这样的类会被自动识别为控制器,即使它们的名称没有以"Controller"为后缀。
特性:在类上应用[Controller]特性。这允许在不遵循命名约定或继承Controller基类的情况下将类识别为控制器。
请注意,控制器类必须是 public 的,并且不能是 abstract、sealed 或 static 的。以下是一个简单的控制器示例:
using Microsoft.AspNetCore.Mvc; namespace MyWebApp.Controllers { public class HomeController : Controller { public IActionResult Index() { return View(); } } }
在这个例子中,HomeController 类遵循命名约定(以 "Controller" 结尾),并且继承自 Controller 基类。当 ASP.NET Core MVC 搜索控制器时,它会将此类识别为一个控制器。
ASP.NET Core MVC 以哪些方式使网站测试变得更容易?
ASP.NET Core MVC 通过以下几个方式使网站测试变得更容易:依赖注入 - ASP.NET Core 支持构造函数注入和方法注入,这使得在测试中轻松地 mock 依赖项成为可能。
模拟 - ASP.NET Core 提供了 Moq 和 NSubstitute 等框架,用于编写 mock 对象和存根。这使测试代码可以隔离被测试的代码。
中间件和服务可以单独测试 - 中间件和服务都是独立的组件,可以单独测试,不依赖于 MVC 框架。
控制器可以单独测试 - 控制器只依赖于模型和服务,可以脱离 MVC 框架进行测试。这使得单元测试控制器变得非常简单。
视图组件可测试 - Tag Helpers、视图组件和 Razor Pages 都可以在没有启动服务器的情况下进行测试。
内置测试工具 - ASP.NET Core 提供了一个内置的测试框架,用于编写和运行集成测试、单元测试等。
环境模拟 - ASP.NET Core 可以在各种环境(开发、生产、单元测试)中运行,这使得在单元测试环境中模拟生产设置变得容易。
所以,总的来说,ASP.NET Core 通过依赖注入、模拟、解耦组件、内置测试工具以及环境模拟等手段,使网站测试变得更加轻松。
练习 15.2 – 通过实现类别详细信息页面来练习实现 MVC
Northwind.Mvc 项目有一个显示类别的主页,但是当单击“查看”按钮时,该网站返回 404 Not Found 错误,例如,对于以下 URL:
https://localhost:5001/category/1
通过添加显示类别详细信息页面的功能来扩展 Northwind.Mvc 项目。
在HomeController.cs 控制器添加操作
// Matches /home/categorydetail/{id} by default so to // match /category/{id}, decorate with the following: // [Route("category/{id}")] public async Task<IActionResult> CategoryDetail(int? id) { if (!id.HasValue) { return BadRequest("You must pass a category ID in the route, for example, /Home/CategoryDetail/6"); } Category? model = await db.Categories.Include(p => p.Products) .SingleOrDefaultAsync(p => p.CategoryId == id); if (model is null) { return NotFound($"CategoryId {id} not found."); } return View(model); // pass model to view and then return result }
在View/Home文件夹下,添加 CategoryDetail.cshtml
@model Packt.Shared.Category @{ ViewData["Title"] = "Category Detail - " + Model.CategoryName; } <h2>Category Detail</h2> <div> <dl class="dl-horizontal"> <dt>Category Id</dt> <dd>@Model.CategoryId</dd> <dt>Product Name</dt> <dd>@Model.CategoryName</dd> <dt>Products</dt> <dd>@Model.Products.Count</dd> <dt>Description</dt> <dd>@Model.Description</dd> </dl> </div>
练习 15.3 – 练习通过理解和实现异步操作方法来提高可扩展性
几年前,Stephen Cleary 为 MSDN 杂志撰写了一篇出色的文章,解释了为 ASP.NET 实现异步操作方法的可扩展性优势。相同的原则适用于 ASP.NET Core,但更是如此,因为与文章中描述的旧 ASP.NET 不同,ASP.NET Core 支持异步过滤器和其他组件。通过以下链接阅读文章:
https://docs.microsoft.com/en-us/archive/msdn-magazine/2014/october/asyncprogramming-introduction-to-async-await-on-asp-net
练习 15.4 – 练习单元测试 MVC 控制器
控制器是网站业务逻辑运行的地方,因此使用单元测试来测试该逻辑的正确性很重要,正如您在第 4 章“编写、调试和测试功能”中学到的那样。
为 HomeController 编写一些单元测试。
良好做法:您可以在以下链接中阅读有关如何对控制器进行单元测试的更多信息:https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/testing
练习 15.5 – 探索主题
使用下一页上的链接了解有关本章所涵盖主题的更多信息:
https://github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-15--building-websites-using-the-model-view-controller-pattern
总结
在本章中,您学习了如何通过注册和注入数据库上下文和记录器等依赖服务以易于单元测试的方式构建大型复杂网站,并且更易于使用 ASP.NET Core MVC 的程序员团队进行管理。您了解了配置、身份验证、路由、模型、视图和控制器。
在下一章中,您将学习如何构建和使用使用 HTTP 作为通信层的服务,即 Web 服务。
附:页面: