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
};
}
Your order for @order.Quantity of @order.Product has a total cost of $@order.Price * @order.Quantity
前面的 Razor 文件会导致以下不正确的输出:
Your order for 3 of Sushi has a total cost of $8.49 * 3
尽管 Razor 标记可以使用 @object 包含任何单个属性的值。属性语法,您应该将表达式括在括号中,如以下标记所示:
Your order for @order.Quantity of @order.Product has a total cost of $@(order.Price * order.Quantity)
前面的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 代码块中,添加一条语句为当前项目声明一个字符串变量,并在现有的
@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
{
Northwind
We have had @Model?.VisitorCount visitors this month.
Query customers from a service
Query products by price
@if (Model is not null)
{
Products
@foreach (Product p in @Model.Products)
{
-
@p.ProductName costs
@(p.UnitPrice is null ? "zero" : p.UnitPrice.Value.ToString("C"))
}
}
}
查看前面的 Razor 标记时,请注意以下事项:
• 很容易将
3.5 查看自定义主页
让我们看看我们定制的主页的结果:
启动Northwind.Mvc网站项目。
请注意主页有一个旋转的轮播图,显示类别、随机数量的访问者和三列的产品列表,如图 15.4 所示:
现在,单击任何类别或产品链接都会出现 404 Not Found 错误,所以让我们看看如何实现使用传递的参数查看产品或类别详细信息的页面。
3. 关闭 Chrome 并关闭网络服务器。
3.6 使用路由值传递参数
传递简单参数的一种方法是使用默认路由中定义的 id 段:
在 HomeController 类中,添加一个名为 ProductDetail 的 action 方法,如下代码所示:
public async Task 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;
}
Product Detail
- Product Id
- @Model.ProductId
- Product Name
- @Model.ProductName
- Category Id
- @Model.CategoryId
- Unit Price
- @Model.UnitPrice.Value.ToString("C")
- Units In Stock
- @Model.UnitsInStock
启动 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
• 表单参数,如以下标记所示:
模型绑定器几乎可以填充任何类型:
• 简单类型,如 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";
}
@ViewData["Title"]
Enter values for your thing in the following form:
@if (Model != null)
{
Submitted Thing
- Model.Thing.Id
- @Model.Thing.Id
- Model.Thing.Color
- @Model.Thing.Color
- Model.Thing.Email
- @Html.DisplayFor(model => model.Thing.Email)
@if (Model.HasErrors)
{
@foreach (string errorMessage in Model.ValidationErrors)
{
@errorMessage
}
}
}
在 Views/Home 中,打开 Index.cshtml,在第一个
启动网站。
在首页点击绑定。
注意关于不明确匹配的未处理异常,如图 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 作为路由参数传递,如以下标记中突出显示的所示:
启动网站。
在首页点击 Binding。
单击 Submit 按钮,注意 Id 属性的值是从路由参数设置的,Color 属性的值是从表单参数设置的。
关闭 Chrome 并关闭网络服务器。
现在我们将使用表单参数设置属性:
修改表单的操作以将值 1 作为表单参数传递,如以下标记中突出显示的所示:
启动网站。
在首页点击 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 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
添加
@model Northwind.Mvc.Models.HomeModelBindingViewModel
@{
ViewData["Title"] = "Model Binding Demo";
}
@ViewData["Title"]
Enter values for your thing in the following form:
@if (Model != null)
{
Submitted Thing
- Model.Thing.Id
- @Model.Thing.Id
- Model.Thing.Color
- @Model.Thing.Color
- Model.Thing.Email
- @Html.DisplayFor(model => model.Thing.Email)
@if (Model.HasErrors)
{
@foreach (string errorMessage in Model.ValidationErrors)
{
@errorMessage
}
}
}
启动网站。
在首页点击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:使用它来生成一个锚点 元素,其中包含指定控制器和操作的 URL 路径。例如,Html.ActionLink(linkText: "Binding", actionName: "ModelBinding", controllerName: "Home") 将生成 Binding。您可以使用锚标记助手实现相同的结果:Binding。
• AntiForgeryToken:在
在_Layout.cshtml这样的共享布局文件中,如何输出当前视图可以为其提供内容的部分section ,视图如何为该部分提供内容?
在_Layout.cshtml 这样的共享布局文件中,可以使用 @RenderSection() 来输出当前视图可以为其提供内容的部分。视图通过 @section 来为该部分提供内容。
比如:
_Layout.cshtml:
@RenderBody()
@RenderSection("Scripts", required: false)
Index.cshtml:
@section Scripts {
}
在这里,_Layout.cshtml 中使用 @RenderSection("Scripts", required: false) 来定义了一个可选的"Scripts"部分。
Index.cshtml 则通过 @section Scripts { ... } 为该部分提供了内容。
最终显示 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 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;
}
Category Detail
- Category Id
- @Model.CategoryId
- Product Name
- @Model.CategoryName
- Products
- @Model.Products.Count
- Description
- @Model.Description
练习 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
控制器是网站业务逻辑运行的地方,因此使用单元测试来测试该逻辑的正确性很重要,正如您在第 4 章“编写、调试和测试功能”中学到的那样。
为 HomeController 编写一些单元测试。
良好做法:您可以在以下链接中阅读有关如何对控制器进行单元测试的更多信息:https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/testing
使用下一页上的链接了解有关本章所涵盖主题的更多信息:
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 服务。
附:页面: