内容来自书籍:
Pro ASP.NET Core 6
Develop Cloud-Ready Web Applications Using MVC, Blazor, and Razor Pages (Ninth Edition)Author: Adam Freeman
需要该电子书的小伙伴,可以留下邮箱,有空看到就会发送的
dotnet new globaljson --output SportsSln/SportsStore dotnet new web --no-https --output SportsSln/SportsStore dotnet new sln -o SportsSln dotnet sln SportsSln add SportsSln/SportsStore dotnet new xunit -o SportsSln/SportsStore.Tests dotnet sln SportsSln add SportsSln/SportsStore.Tests dotnet add SportsSln/SportsStore.Tests reference SportsSln/SportsStore
dotnet add SportsSln/SportsStore.Tests package Moq --version 4.16.1
{ "profiles": { "SportsStore": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:4399", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } }
Models Controllers Views Views/Home Views/Shared
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); var app = builder.Build(); app.UseStaticFiles(); app.MapDefaultControllerRoute(); app.Run();
Service 属性用于设置对象,称为服务,这些对象可以在整个应用程序中使用,并且可以通过一个称为依赖注入的特性访问。AddControllersWithviews 方法使用 MVC 框架和 Razor 视图引擎设置应用程序所需的共享对象。
ASP.NET Core 接收 HTTP 请求并将它们沿着请求管道传递,该管道由使用 app 属性注册的中间件组件填充。每个中间件组件都能够检查请求、修改请求、生成响应或修改其他组件生成的响应。请求管道是 ASP.NET Core的核心
UseStaticFiles 方法支持从 wwwroot 文件夹提供静态内容
一个特别重要的中间件组件提供了端点路由特性,它将 HTTP 请求与能够为它们生成响应的应用程序特性(称为端点)进行匹配。端点路由特性被自动添加到请求管道中,MapDefaultControllerRoute 使用默认约定将请求映射到类和方法,将 MVC Framework 注册为端点源。
添加一个视图_ViewImports.cshtml
在Views中
@using SportsStore.Models @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using 语句允许我使用 SportsStore 中的类型。在视图中建模命名空间,而不需要引用命名空间。@addTagHelper 语句启用了内置的标记助手,稍后我将使用它来创建反映 SportsStore 应用程序配置的 HTML 元素,我将在第15章中详细描述它
添加一个视图_ViewStart.cshtml
在Views中
@{ Layout = "_Layout"; }
Razor View Start 文件告诉 Razor 使用它生成的 HTML 中的布局文件,从而减少视图中的重复数量。
添加一个视图_Layout.cshtml
在Views/Shared中
<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width"/> <title>SportsStore</title> </head> <body> <div> @RenderBody() </div> </body> </html>
该文件定义了一个简单的 HTML 文档,@RenderBody 表达式将其他视图的内容插入到该文档中
添加控制器HomeController.cs
public class HomeController : Controller { public IActionResult Index() => View(); }
添加视图Index.cshtml
到Views/Home
<h4>Welcome to SportsStore</h4>
创建数据模型Product
到Models
public class Product { public long? ProductID { get; set; } public string Name { get; set; } = String.Empty; public string Description { get; set; } = String.Empty; [Column(TypeName = "decimal(8, 2)")] public decimal Price { get; set; } public string Category { get; set; } = String.Empty; }
Price 属性已用 Column 属性修饰,以指定用于存储此属性值的 SQL 数据类型。并非所有的 C # 类型都能巧妙地映射到 SQL 类型,而且这个属性确保数据库为应用程序数据使用适当的类型。
使用EF Core 操作数据,书中使用的SQL Server LocalDB
,我使用的是SQLite
dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet tool uninstall --global dotnet-ef dotnet tool install --global dotnet-ef
appsetting.json
每种数据库的连接信息的格式都不一样,我用的是Sqlite
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "ConnectionStrings": { "SportsStoreConnection": "Data Source=my.db" } }
public class StoreDbContext : DbContext { public StoreDbContext(DbContextOptions<StoreDbContext> options) : base(options) { } public DbSet<Product> Products => Set<Product>(); }
然后是需要将刚刚配置文件里面的连接信息读取出来,然后告诉EF Core,并且StoreDbContext
已经被注册到IoC中了,可以随时注入到其他类中使用
// Program.cs builder.Services.AddDbContext<StoreDbContext>(opts => { opts.UseSqlite( builder.Configuration["ConnectionStrings:SportsStoreConnection"]); });
下一步是创建Repository接口和实现类。Repository模式是使用最广泛的模式之一,它提供了一种一致的方法来访问数据库上下文类提供的特性。并非所有人都认为Repository有用,但我的经验是,它可以减少重复,并确保数据库上的操作得到一致的执行。
public interface IStoreRepository { IQueryable<Product> Products { get; } }
public class EFStoreRepository : IStoreRepository { private StoreDbContext _context; public EFStoreRepository(StoreDbContext ctx) { _context = ctx; } public IQueryable<Product> Products => _context.Products; }
IQueryable < T > 接口非常有用,因为它允许高效地查询一组对象。在本章的后面,我将添加对从数据库中检索 Product 对象子集的支持,并且使用 IQueryable < T > 接口允许我使用标准的 LINQ 语句向数据库询问我需要的对象,而不需要知道哪个数据库服务器存储数据或者它是如何处理查询的。如果没有 IQueryable < T > 接口,我将不得不从数据库中检索所有 Product 对象,然后丢弃那些我不想要的对象,随着应用程序使用的数据量的增加,这将成为一个昂贵的操作。正是由于这个原因,在数据库存储库接口和类中,通常使用 IQueryable < T > 接口来代替 IEnumable < T > 。但是,必须小心使用 IQueryable < T > 接口,因为每次枚举对象集合时,都会重新计算查询,这意味着将向数据库发送一个新的查询。这可能会破坏使用 IQueryable 的效率增益。在这种情况下,您可以使用 ToList 或 ToArray 扩展方法将 IQueryable < T > 接口转换为更可预测的形式。
有了这个Repository实现之后,我们如果需要使用,最好是将它注册到IoC中
// Program.cs builder.Services.AddScoped<IStoreRepository, EFStoreRepository>();
dotnet ef migrations add Initial
using Microsoft.EntityFrameworkCore; namespace SportsStore.Models; public static class SeedData { public static void EnsurePopulated(IApplicationBuilder app) { StoreDbContext context = app.ApplicationServices .CreateScope().ServiceProvider.GetRequiredService<StoreDbContext>(); if (context.Database.GetPendingMigrations().Any()) { context.Database.Migrate(); } if (!context.Products.Any()) { context.Products.AddRange( new Product { Name = "Kayak", Description = "A boat for one person", Category = "Watersports", Price = 275 }, new Product { Name = "Lifejacket", Description = "Protective and fashionable", Category = "Watersports", Price = 48.95m }, new Product { Name = "Soccer Ball", Description = "FIFA-approved size and weight", Category = "Soccer", Price = 19.50m }, new Product { Name = "Corner Flags", Description = "Give your playing field a professional touch", Category = "Soccer", Price = 34.95m }, new Product { Name = "Stadium", Description = "Flat-packed 35,000-seat stadium", Category = "Soccer", Price = 79500 }, new Product { Name = "Thinking Cap", Description = "Improve brain efficiency by 75%", Category = "Chess", Price = 16 }, new Product { Name = "Unsteady Chair", Description = "Secretly give your opponent a disadvantage", Category = "Chess", Price = 29.95m }, new Product { Name = "Human Chess Board", Description = "A fun game for the family", Category = "Chess", Price = 75 }, new Product { Name = "Bling-Bling King", Description = "Gold-plated, diamond-studded King", Category = "Chess", Price = 1200 } ); } context.SaveChanges(); } }
静态 EnsurePopular 方法接收一个 IApplicationBuilder 参数,该参数是 Program.cs 文件中用于注册中间件组件以处理 HTTP 请求的接口。IApplicationBuilder 还提供对应用程序服务的访问,包括实体框架核心数据库上下文服务。
EnsurePopular 方法通过 IApplicationBuilder 接口获得一个 StoredbContext 对象,并调用数据库。如果存在任何挂起的迁移,则迁移方法,这意味着将创建并准备数据库,以便它能够存储 Product 对象。接下来,检查数据库中 Product 对象的数量。如果数据库中没有对象,则使用 AddRange 方法使用 Product 对象集合填充数据库,然后使用 SaveChanges 方法将数据库写入数据库。
最后是使用这个方法
// Program.cs SeedData.EnsurePopulated(app);
到上面为止,MVC的骨架基本上搭建完成了,接下来是创建页面控制器来展示数据
public class HomeController : Controller { private readonly IStoreRepository _repository; public HomeController(IStoreRepository repository) { _repository = repository; } public IActionResult Index() => View(_repository.Products); }
当 ASP.NET Core 需要创建 HomeController 类的一个新实例来处理 HTTP 请求时,它将检查构造函数,并查看它是否需要一个实现 IStoreRepository 接口的对象。为了确定应该使用什么样的实现类,ASP.NET Core 参考了 Program.cs 文件中创建的配置,该配置告诉它应该使用 EFStoreRepository,并且应该为每个请求创建一个新实例。NET Core 创建一个新的 EFStoreRepository 对象,并使用它调用 HomeController 构造函数来创建处理 HTTP 请求的控制器对象。
这就是所谓的依赖注入,它的方法允许 HomeController 对象通过 IStoreRepository 接口访问应用程序的Repository,而不需要知道配置了哪个实现类。我可以重新配置服务,使用不同的实现类(例如,不使用实体框架核心的实现类) ,依赖注入意味着控制器将继续工作,而不会发生变化。
我可以通过创建一个mock repository,将其注入 HomeController 类的构造函数中,然后调用 Index 方法来获取包含产品列表的响应,从而对控制器正确访问repository进行单元测试。然后,我将所获得的 Product 对象与模拟实现中的测试数据所期望的对象进行比较。
public class HomeControllerTests { [Fact] public void Can_Use_Repository() { // Arrange Mock<IStoreRepository> mock = new Mock<IStoreRepository>(); mock.Setup(m => m.Products).Returns((new Product[] { new() { ProductID = 1, Name = "P1" }, new() { ProductID = 2, Name = "P2" } }).AsQueryable<Product>()); HomeController controller = new HomeController(mock.Object); // Act IEnumerable<Product>? result = (controller.Index() as ViewResult)?.ViewData.Model as IEnumerable<Product>; // Assert Product[] prodArray = result?.ToArray() ?? Array.Empty<Product>(); Assert.True(prodArray.Length == 2); Assert.Equal("P1", prodArray[0].Name); Assert.Equal("P2", prodArray[1].Name); } }
Index 操作方法将 Product 对象的集合从存储库传递到 View 方法,这意味着这些对象将是 Razor 从视图生成 HTML 内容时使用的视图模型。对Index视图进行更改,以使用 Product 视图模型对象生成内容。
@model IQueryable<Product> @foreach (var p in Model ?? Enumerable.Empty<Product>()) { <div> <h3>@p.Name</h3> @p.Description <h4>@p.Price.ToString("c")</h4> </div> }
文件顶部的@model 表达式指定视图期望从 action 方法接收一系列 Product 对象作为其模型数据。我使用@foreach 表达式来完成这个序列,并为接收到的每个 Product 对象生成一组简单的 HTML 元素。
Razor 视图的工作方式有一个怪异之处,这意味着模型数据总是可以为null的,即使@model 表达式指定的类型不是null的。出于这个原因,我在@foreach 表达式中使用了空Enumerable的??
运算符。
视图不知道 Product 对象来自哪里,如何获得它们,或者它们是否表示应用程序已知的所有产品。相反,该视图仅处理如何使用 HTML 元素显示每个 Product 的详细信息。
我使用 ToString (“ c”)方法将 Price 属性转换为字符串,该方法呈现/n根据服务器上正在生效的区域性设置将数值作为货币。
添加分页,首先需要给Index方法添加参数
public class HomeController : Controller { private readonly IStoreRepository _repository; public int PageSize = 4; public HomeController(IStoreRepository repository) { _repository = repository; } public IActionResult Index(int productPage = 1) => View(_repository.Products .OrderBy(p => p.ProductID) .Skip((productPage - 1) * PageSize) .Take(PageSize)); }
对分页作单元测试
我可以通过模拟存储库、从控制器请求特定页面并确保获得所期望的数据子集来对分页特性进行单元测试。
[Fact] public void Can_Paginate() { // Arrange var mock = new Mock<IStoreRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new() { ProductID = 1, Name = "P1" }, new() { ProductID = 2, Name = "P2" }, new() { ProductID = 3, Name = "P3" }, new() { ProductID = 4, Name = "P4" }, new() { ProductID = 5, Name = "P5" } }.AsQueryable()); var controller = new HomeController(mock.Object); controller.PageSize = 3; // Act var result = (controller.Index(2) as ViewResult)?.ViewData.Model as IEnumerable<Product> ?? Enumerable.Empty<Product>(); // Assert var prodArray = result.ToArray(); Assert.True(prodArray.Length == 2); Assert.Equal("P4", prodArray[0].Name); Assert.Equal("P5", prodArray[1].Name); }
控制器测试完之后,就可以对view修改了,需要给页面添加分页按钮
首先要做的是,我们需要让view知道,分页的信息,例如总页数,每页个数,当前页等,所以我们需要一个viewModel承载这些信息
// Models/ViewModels public class PagingInfo { public int TotalItems { get; set; } public int ItemsPerPage { get; set; } public int CurrentPage { get; set; } public int TotalPages => (int)Math.Ceiling((decimal)TotalItems / ItemsPerPage); }
现在我有了一个视图模型,是时候创建一个 Tag Helper 类了。
Tag Helper 是 ASP.NET Core开发的重要组成部分
// SportsStore/Infrastructure [HtmlTargetElement("div", Attributes = "page-model")] public class PageLinkTagHelper : TagHelper { private readonly IUrlHelperFactory urlHelperFactory; public PageLinkTagHelper(IUrlHelperFactory helperFactory) { urlHelperFactory = helperFactory; } [ViewContext] [HtmlAttributeNotBound] public ViewContext? ViewContext { get; set; } public PagingInfo? PageModel { get; set; } public string? PageAction { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { if (ViewContext == null || PageModel == null) return; var urlHelper = urlHelperFactory.GetUrlHelper(ViewContext); var result = new TagBuilder("div"); for (var i = 1; i <= PageModel.TotalPages; i++) { var tag = new TagBuilder("a") { Attributes = { ["href"] = urlHelper.Action(PageAction, new { productPage = i }) } }; tag.InnerHtml.Append(i.ToString()); result.InnerHtml.AppendHtml(tag); } output.Content.AppendHtml(result.InnerHtml); } }
这个 Tag Helper 用与产品页面对应的元素填充 div 元素。一个 Tag Helper 的代码可能看起来很折磨人,因为 C # 和 HTML 不容易混合。但是使用标签助手比在视图中包含 C # 代码块更好,因为 Tag Helper 可以很容易地进行单元测试。
大多数 ASP.NET Core 组件(如控制器和视图)都是自动发现的,但是必须注册标记助手。我在视图文件夹中的 _ ViewImport. cshtml 文件中添加了一条语句,该语句告诉 ASP.NET Core 在 SportsStore 项目中查找 Tag Helper 类。我还添加了一个@using 表达式,这样我就可以在视图中引用视图模型类,而不必使用名称空间限定它们的名称
// SportsStore/Views/_ViewImports.cshtml @using SportsStore.Models @using SportsStore.Models.ViewModels @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, SportsStore
最后,对刚刚编写的Tag Helper做单元测试
// SportsStore.Tests public class PageLinkTagHelperTests { [Fact] public void Can_Generate_Page_Links() { // Arrange var urlHelper = new Mock<IUrlHelper>(); urlHelper.SetupSequence(x => x.Action(It.IsAny<UrlActionContext>())) .Returns("Test/Page1") .Returns("Test/Page2") .Returns("Test/Page3"); var urlHelperFactory = new Mock<IUrlHelperFactory>(); urlHelperFactory.Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>())) .Returns(urlHelper.Object); var viewContext = new Mock<ViewContext>(); var helper = new PageLinkTagHelper(urlHelperFactory.Object) { PageModel = new PagingInfo { CurrentPage = 2, TotalItems = 28, ItemsPerPage = 10 }, ViewContext = viewContext.Object, PageAction = "Test" }; var ctx = new TagHelperContext( new TagHelperAttributeList(), new Dictionary<object, object>(), ""); var content = new Mock<TagHelperContent>(); var output = new TagHelperOutput("div", new TagHelperAttributeList(), (_, _) => Task.FromResult(content.Object)); // Act helper.Process(ctx, output); // Assert Assert.Equal(@"<a href=""Test/Page1"">1</a>" + @"<a href=""Test/Page2"">2</a>" + @"<a href=""Test/Page3"">3</a>", output.Content.GetContent()); } }
这个测试的复杂性在于创建和使用 Tag Helper 所需的对象。标记助手使用 IUrlHelperFactory 对象来生成针对应用程序不同部分的 URL,我使用 Moq 来创建这个接口的实现以及相关的 IUrlHelper 接口,该接口提供测试数据。
测试的核心部分通过使用包含双引号的字符串值来验证 Tag Helper 输出。C # 完全能够处理这样的字符串,只要字符串前缀是@,并使用两组双引号(“”)代替一组双引号。必须记住,除非要比较的字符串具有类似的断行,否则不要将文字字符串分成单独的行。例如,我在测试方法中使用的文字已经包装成几行,因为打印页面的宽度很窄。我没有添加换行符; 如果添加了,测试就会失败。
我还没有准备好使用这个 Tag Helper,因为我还没有为视图提供 PagingInfo 视图模型类的实例。
// SportsStore/Models/ViewModels public class ProductsListViewModel { public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>(); public PagingInfo PagingInfo { get; set; } = new(); }
我可以更新 HomeController 类中的 Index 操作方法,使用 ProductsListViewModel 类为视图提供要在页面上显示的产品的详细信息和分页的详细信息
public ViewResult Index(int productPage = 1) => View(new ProductsListViewModel { Products = _repository.Products .OrderBy(p => p.ProductID) .Skip((productPage - 1) * PageSize) .Take(PageSize), PagingInfo = new PagingInfo { CurrentPage = productPage, ItemsPerPage = PageSize, TotalItems = _repository.Products.Count() } });
然后测试这个新加的方法,而且需要注意的是,还需要修改之前编写的测试,因为我们方法的返回值改变了
[Fact] public void Can_Send_Pagination_View_Model() { // Arrange var mock = new Mock<IStoreRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new() { ProductID = 1, Name = "P1" }, new() { ProductID = 2, Name = "P2" }, new() { ProductID = 3, Name = "P3" }, new() { ProductID = 4, Name = "P4" }, new() { ProductID = 5, Name = "P5" } }.AsQueryable()); // Arrange var controller = new HomeController(mock.Object) { PageSize = 3 }; // Act var result = controller.Index(2)?.ViewData.Model as ProductsListViewModel ?? new ProductsListViewModel(); // Assert var pageInfo = result.PagingInfo; Assert.Equal(2, pageInfo.CurrentPage); Assert.Equal(3, pageInfo.ItemsPerPage); Assert.Equal(5, pageInfo.TotalItems); Assert.Equal(2, pageInfo.TotalPages); }
最后,测试完成后,可以在Index页面展示数据了
我已经准备好将页面链接添加到 Index 视图。我创建了包含分页信息的视图模型,更新了控制器,以便将这些信息传递给视图,并更改了@model 指令以匹配新的模型视图类型。剩下的就是添加一个 HTML 元素,Tag Helper 将处理这个元素来创建页面链接
// SportsStore/Views/Home/Index.cshtml @model ProductsListViewModel @foreach (var p in Model?.Products ?? Enumerable.Empty<Product>()) { <div> <h3>@p.Name</h3> @p.Description <h4>@p.Price.ToString("c")</h4> </div> } <div page-model="@Model?.PagingInfo" page-action="Index"></div>
安装libman工具
dotnet tool uninstall --global Microsoft.Web.LibraryManager.Cli dotnet tool install --global Microsoft.Web.LibraryManager.Cli --version 2.1.113
安装Bootstrap包
// 书上写的仓库是cdnjs,但是我这里会报错,用unpkg就没事 libman init -p unpkg libman install bootstrap@5.1.3 -d wwwroot/lib/bootstrap
Razor 布局提供了通用的内容,这样就不必在多个视图中重复使用。将清单7-37中所示的元素添加到 View/Shared 文件夹中的 _Layout.cshtml 文件中,以便在发送到浏览器的内容中包含 Bootstrap CSS 样式表,并定义一个将在整个 SportsStore 应用程序中使用的公共头文件。
<!-- View/Shared/_Layout.cshtml --> <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width"/> <title>SportsStore</title> <!-- <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet"/> --> <!-- 上面是书上的示例, 但是和我下载的路径不一样,看着修改 --> <link href="lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet"/> </head> <body> <div class="bg-dark text-white p-2"> <span class="navbar-brand ml-2">SPORTS STORE</span> </div> <div class="row m-1 p-1"> <div id="categories" class="col-3"> Put something useful here later </div> <div class="col-9"> @RenderBody() </div> </div> </body> </html>
将 Bootstrap CSS 样式表添加到布局意味着我可以使用它在依赖于布局的任何视图中定义的样式
<!-- /Views/Home/Index.cshtml --> @model ProductsListViewModel @foreach (var p in Model?.Products ?? Enumerable.Empty<Product>()) { <div class="card card-outline-primary m-1 p-1"> <div class="bg-faded p-1"> <h4> @p.Name <span class="badge rounded-pill bg-primary text-white" style="float:right"> <small>@p.Price.ToString("c")</small> </span> </h4> </div> <div class="card-text p-1">@p.Description</div> </div> } <div page-model="@Model?.PagingInfo" page-action="Index" page-classes-enabled="true" page-class="btn" page-class-normal="btn-outline-dark" page-class-selected="btn-primary" class="btn-group pull-right m-1"> </div>
我需要设计由 pagelinkTagHelper 类生成的按钮的样式,但是我不想把 Bootstrap 类硬连接到 c # 代码中,因为这会使得在应用程序的其他地方重用 Tag Helper 或者改变按钮的外观变得更加困难。相反,我在 div 元素上定义了自定义属性,这些属性指定了我需要的类,这些属性对应于我添加到 Tag Helper 类中的属性,然后使用这些属性对生成的 a 元素进行样式化
// SportsStore/Infrastructure [HtmlTargetElement("div", Attributes = "page-model")] public class PageLinkTagHelper : TagHelper { private readonly IUrlHelperFactory _urlHelperFactory; public PageLinkTagHelper(IUrlHelperFactory helperFactory) { _urlHelperFactory = helperFactory; } [ViewContext] [HtmlAttributeNotBound] public ViewContext? ViewContext { get; set; } public PagingInfo? PageModel { get; set; } public string? PageAction { get; set; } public bool PageClassesEnabled { get; set; } = false; public string PageClass { get; set; } = String.Empty; public string PageClassNormal { get; set; } = String.Empty; public string PageClassSelected { get; set; } = String.Empty; public override void Process(TagHelperContext context, TagHelperOutput output) { if (ViewContext == null || PageModel == null) return; var urlHelper = _urlHelperFactory.GetUrlHelper(ViewContext); var result = new TagBuilder("div"); for (var i = 1; i <= PageModel.TotalPages; i++) { var tag = new TagBuilder("a") { Attributes = { ["href"] = urlHelper.Action(PageAction, new { productPage = i }) } }; if (PageClassesEnabled) { tag.AddCssClass(PageClass); tag.AddCssClass(i == PageModel.CurrentPage ? PageClassSelected : PageClassNormal); } tag.InnerHtml.Append(i.ToString()); result.InnerHtml.AppendHtml(tag); } output.Content.AppendHtml(result.InnerHtml); } }
属性的值自动用于设置 Tag Helper 属性值,同时考虑到 HTML 属性名称格式(page-class-Normal)和 C # 属性名称格式(pageClassNormal)之间的映射。这允许Tag Helper根据 HTML 元素的属性做出不同的响应,从而为在 ASP.NET Core 应用程序中生成内容创建更灵活的方法
作为本章的收尾工作,我将重构应用程序以简化 Index.cshtml 视图。我将创建一个部分视图,它是可以嵌入到另一个视图中的内容片段,类似于模板。当您需要相同的内容出现在应用程序的不同位置时,它们有助于减少重复。与其将相同的 Razor 标记复制粘贴到多个视图中,不如在部分视图中定义一次
// SportsStore/Views/Shared/ProductSummary.cshtml @model Product <div class="card card-outline-primary m-1 p-1"> <div class="bg-faded p-1"> <h4> @Model?.Name <span class="badge rounded-pill bg-primary text-white" style="float:right"> <small>@Model?.Price.ToString("c")</small> </span> </h4> </div> <div class="card-text p-1">@Model?.Description</div> </div>
片段创建之后,就可以在Index页面使用了
// SportsStore/Views/Home/Index.cshtml @model ProductsListViewModel @foreach (var p in Model?.Products ?? Enumerable.Empty<Product>()) { <partial name="ProductSummary" model="p" /> } <div page-model="@Model?.PagingInfo" page-action="Index" page-classes-enabled="true" page-class="btn" page-class-normal="btn-outline-dark" page-class-selected="btn-primary" class="btn-group pull-right m-1"> </div>
我获取了 Index.cshtml 视图中@foreach 表达式中的标记,并将其移动到新的部分视图中。我使用部分元素调用部分视图,使用 name 和 model 属性指定部分视图及其视图模型的名称。使用部分视图允许在需要显示产品摘要的任何视图中插入相同的标记。
如果客户可以按类别导航产品,则 SportsStore 应用程序将更加有用。我将分三个阶段来做这件事。
我将从增强视图模型类 ProductsListViewModel 开始。我需要将当前类别传递给视图以呈现侧边栏
// SportsStore/Models/ViewModels/ProductsListViewModel.cs public class ProductsListViewModel { public IEnumerable<Product> Products { get; set; } = Enumerable.Empty<Product>(); public PagingInfo PagingInfo { get; set; } = new(); public string? CurrentCategory { get; set; } }
我添加了一个名为 CurrentCategory 的属性。下一步是更新 Home 控制器,这样 Index 操作方法将按类别过滤 Product 对象,并使用我添加到视图模型中的属性来指示选择了哪个类别
public ViewResult Index(string? category, int productPage = 1) => View(new ProductsListViewModel { Products = _repository.Products .Where(p => category == null || p.Category == category) .OrderBy(p => p.ProductID) .Skip((productPage - 1) * PageSize) .Take(PageSize), PagingInfo = new PagingInfo { CurrentPage = productPage, ItemsPerPage = PageSize, TotalItems = _repository.Products.Count() }, CurrentCategory = category });
如果添加了类型过滤之后,分页的计算不正确,后面会修改
然后修改了方法之后,也同样需要修改测试,让旧的测试通过
接着,对新的功能编写新的测试
[Fact] public void Can_Filter_Products() { // Arrange // - create the mock repository var mock = new Mock<IStoreRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new() { ProductID = 1, Name = "P1", Category = "Cat1" }, new() { ProductID = 2, Name = "P2", Category = "Cat2" }, new() { ProductID = 3, Name = "P3", Category = "Cat1" }, new() { ProductID = 4, Name = "P4", Category = "Cat2" }, new() { ProductID = 5, Name = "P5", Category = "Cat3" } }.AsQueryable()); // Arrange - create a controller and make the page size 3 items var controller = new HomeController(mock.Object); controller.PageSize = 3; // Action var result = (controller.Index("Cat2", 1)?.ViewData.Model as ProductsListViewModel ?? new ProductsListViewModel()).Products.ToArray(); // Assert Assert.Equal(2, result.Length); Assert.True(result[0].Name == "P2" && result[0].Category == "Cat2"); Assert.True(result[1].Name == "P4" && result[1].Category == "Cat2"); }
书中作者觉得URL中含有查询字符串比较丑陋,所以他希望更改,所以这小节改不改无所谓
但是因为控制器添加了新的参数,所以对于视图传入的地方也需要修改,我们需要在Tag Helper上生成那个参数
// SportsStore/Infrastructure/PageLinkTagHelper // 这里修改了,添加这个字典,可以范围匹配传进来的参数 [HtmlAttributeName(DictionaryAttributePrefix = "page-url-")] public Dictionary<string, object> PageUrlValues { get; set; } = new(); public override void Process(TagHelperContext context, TagHelperOutput output) { if (ViewContext == null || PageModel == null) return; var urlHelper = _urlHelperFactory.GetUrlHelper(ViewContext); var result = new TagBuilder("div"); for (var i = 1; i <= PageModel.TotalPages; i++) { // 这里修改了,之前是分页参数匿名类型传递,现在是字典 PageUrlValues["productPage"] = i; var tag = new TagBuilder("a") { Attributes = { ["href"] = urlHelper.Action(PageAction, PageUrlValues) } }; if (PageClassesEnabled) { tag.AddCssClass(PageClass); tag.AddCssClass(i == PageModel.CurrentPage ? PageClassSelected : PageClassNormal); } tag.InnerHtml.Append(i.ToString()); result.InnerHtml.AppendHtml(tag); } output.Content.AppendHtml(result.InnerHtml); }
然后是修改视图,传递新的参数
<!-- SportsStore/Views/Home/Index.cshtml --> <div page-model="@Model?.PagingInfo" page-action="Index" page-classes-enabled="true" page-class="btn" page-class-normal="btn-outline-dark" page-class-selected="btn-primary" page-url-category="@Model?.CurrentCategory!" class="btn-group pull-right m-1"> </div>
我需要为用户提供一种方法来选择一个类别,不涉及输入 URL。这意味着显示可用类别的列表,并指示当前选择了哪些类别(如果有的话)。
NET Core 提供了视图组件的概念,非常适合创建可重用的导航控件等项目。View 组件是一个 C # 类,它提供了少量可重用的应用逻辑,能够选择和显示 Razor 部分视图
在这种情况下,我将创建一个视图组件来呈现导航菜单,并通过从共享布局调用该组件将其集成到应用程序中。这种方法为我提供了一个常规的 C # 类,它可以包含我需要的任何应用程序逻辑,并且可以像其他类一样进行单元测试。
// SportsStore/Components public class NavigationMenuViewComponent : ViewComponent { public string Invoke() { return "Hello from the Nav View Component"; } }
当视图组件在 Razor 视图中使用时,调用视图组件的 Invoke 方法,并将 Invoke 方法的结果插入到发送给浏览器的 HTML 中
我希望类别列表出现在所有页面上,因此我将在共享布局中使用视图组件,而不是在特定的视图中。在一个视图中,视图组件使用一个 Tag Helper 来应用
<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width"/> <title>SportsStore</title> <link href="lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet"/> </head> <body> <div class="bg-dark text-white p-2"> <span class="navbar-brand ml-2">SPORTS STORE</span> </div> <div class="row m-1 p-1"> <div id="categories" class="col-3"> <vc:navigation-menu/> </div> <div class="col-9"> @RenderBody() </div> </div> </body> </html>
元素省略了类名的 ViewComponent 部分并用连字符连接它,这样 vc: NavigationMenuViewComponent 菜单指定了 NavigationMenuViewComponent 类。
现在我可以返回到导航视图组件并生成一组真正的类别。我可以通过编程的方式为类别构建 HTML,就像我为页面 Tag Helper 所做的那样,但是使用 view 组件的好处之一是它们可以呈现 Razor 的部分视图。这意味着我可以使用 view 组件来生成组件列表,然后使用更具表现力的 Razor 语法来呈现显示它们的 HTML
// SportsStore/Components public class NavigationMenuViewComponent : ViewComponent { private readonly IStoreRepository _repository; public NavigationMenuViewComponent(IStoreRepository repository) { _repository = repository; } public IViewComponentResult Invoke() { return View(_repository.Products .Select(x => x.Category) .Distinct() .OrderBy(x => x)); } }
Razor 使用不同的约定来定位视图组件所选择的视图。视图的默认名称和搜索视图的位置都不同于用于控制器的位置。为此,我在 SportsStore 项目中创建了 Views/Shared/Components/NavigationMenu 文件夹,并在其中添加了一个名为 Default.cshtml 的 Razor 视图
<!-- Views/Shared/Components/NavigationMenu/Default.cshtml --> @model IEnumerable<string> <div class="d-grid gap-2"> <a class="btn btn-outline-secondary"asp-action="Index" asp-controller="Home" asp-route-category=""> Home </a> @foreach (var category in Model ?? Enumerable.Empty<string>()) { <a class="btn btn-outline-secondary" asp-action="Index" asp-controller="Home" asp-route-category="@category" asp-route-productPage="1"> @category </a> } </div>
然后测试这个组件
// SportsStore.Tests public class NavigationMenuViewComponentTests { [Fact] public void Can_Select_Categories() { // Arrange Mock<IStoreRepository> mock = new Mock<IStoreRepository>(); mock.Setup(m => m.Products).Returns((new Product[] { new Product { ProductID = 1, Name = "P1", Category = "Apples" }, new Product { ProductID = 2, Name = "P2", Category = "Apples" }, new Product { ProductID = 3, Name = "P3", Category = "Plums" }, new Product { ProductID = 4, Name = "P4", Category = "Oranges" }, }).AsQueryable<Product>()); NavigationMenuViewComponent target = new NavigationMenuViewComponent(mock.Object); // Act = get the set of categories string[] results = ((IEnumerable<string>?)(target.Invoke() as ViewViewComponentResult)?.ViewData?.Model ?? Enumerable.Empty<string>()).ToArray(); // Assert Assert.True(Enumerable.SequenceEqual(new string[] { "Apples", "Oranges", "Plums" }, results)); } }
没有给用户的反馈来指示选择了哪个类别。也许可以从列表中的项目推断出类别,但是一些清晰的视觉反馈似乎是个好主意。ASP.NET Core 组件如控制器和视图组件可以通过请求上下文对象来接收当前请求的信息。大多数情况下,您可以依赖用于创建组件的基类来获取上下文对象,比如当您使用 Controller 基类来创建控制器时
ViewComponent 基类也不例外,它通过一组属性提供对上下文对象的访问。其中一个属性称为 RouteData,它提供有关路由系统如何处理请求 URL 的信息。
我使用 RouteData 属性访问请求数据,以获取当前选定类别的值。我可以通过创建另一个视图模型类来将类别传递给视图(这也是我在实际项目中会做的事情) ,但是为了多样化,我将使用 view bag 功能,它允许将非结构化数据传递给视图模型对象旁边的一个视图。
// SportsStore/Components/NavigationMenuViewComponent.cs public IViewComponentResult Invoke() { ViewBag.SelectedCategory = RouteData?.Values["category"]; return View(_repository.Products .Select(x => x.Category) .Distinct() .OrderBy(x => x)); }
在 Invoke 方法中,我动态地为 ViewBag 对象分配了一个 SelectedCategory 属性,并将其值设置为当前类别,该类别是通过 RouteData 属性返回的上下文对象获得的。ViewBag 是一个动态的对象,它允许我简单地通过赋值来定义新的属性。
然后测试这个功能
[Fact] public void Indicates_Selected_Category() { // Arrange var categoryToSelect = "Apples"; var mock = new Mock<IStoreRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new() { ProductID = 1, Name = "P1", Category = "Apples" }, new() { ProductID = 4, Name = "P2", Category = "Oranges" }, }.AsQueryable()); var target = new NavigationMenuViewComponent(mock.Object); target.ViewComponentContext = new ViewComponentContext { ViewContext = new ViewContext { RouteData = new Microsoft.AspNetCore.Routing.RouteData() } }; target.RouteData.Values["category"] = categoryToSelect; // Action var result = (string?)(target.Invoke() as ViewViewComponentResult)?.ViewData?["SelectedCategory"]; // Assert Assert.Equal(categoryToSelect, result); }
现在我提供了关于选择了哪个类别的信息,我可以更新视图组件选择的视图,并改变用于样式化链接的 CSS 类,以便表示当前类别的类别是不同的。
<!-- SportsStore/Views/Shared/Components/NavigationMenu/Default.cshtml --> @model IEnumerable<string> <div class="d-grid gap-2"> <a class="btn btn-outline-secondary"asp-action="Index" asp-controller="Home" asp-route-category=""> Home </a> @foreach (var category in Model ?? Enumerable.Empty<string>()) { <a class="btn @(category == ViewBag.SelectedCategory ? "btn-primary" : "btn-outline-secondary")" asp-action="Index" asp-controller="Home" asp-route-category="@category" asp-route-productPage="1"> @category </a> } </div>
我需要更正网页的链接,以便一个类别被选中时他们正常工作。目前,页面链接的数量是由存储库中的产品总数而不是选定类别中的产品数量决定的。这意味着客户可以点击国际象棋类别第2页的链接,最终得到一个空白页面,因为没有足够的国际象棋产品来填充两个页面。
我可以通过更新 Home 控制器中的 Index 操作方法来解决这个问题,这样分页信息就会考虑到这些类别
public ViewResult Index(string? category, int productPage = 1) => View(new ProductsListViewModel { Products = _repository.Products .Where(p => category == null || p.Category == category) .OrderBy(p => p.ProductID) .Skip((productPage - 1) * PageSize) .Take(PageSize), PagingInfo = new PagingInfo { CurrentPage = productPage, ItemsPerPage = PageSize, TotalItems = category == null ? _repository.Products.Count() : _repository.Products.Count(e => e.Category == category) }, CurrentCategory = category });
然后编写测试
[Fact] public void Generate_Category_Specific_Product_Count() { // Arrange Mock<IStoreRepository> mock = new Mock<IStoreRepository>(); mock.Setup(m => m.Products).Returns(new Product[] { new() { ProductID = 1, Name = "P1", Category = "Cat1" }, new() { ProductID = 2, Name = "P2", Category = "Cat2" }, new() { ProductID = 3, Name = "P3", Category = "Cat1" }, new() { ProductID = 4, Name = "P4", Category = "Cat2" }, new() { ProductID = 5, Name = "P5", Category = "Cat3" } }.AsQueryable()); HomeController target = new HomeController(mock.Object); target.PageSize = 3; Func<ViewResult, ProductsListViewModel?> GetModel = result => result?.ViewData?.Model as ProductsListViewModel; // Action int? res1 = GetModel(target.Index("Cat1"))?.PagingInfo.TotalItems; int? res2 = GetModel(target.Index("Cat2"))?.PagingInfo.TotalItems; int? res3 = GetModel(target.Index("Cat3"))?.PagingInfo.TotalItems; int? resAll = GetModel(target.Index(null))?.PagingInfo.TotalItems; // Assert Assert.Equal(2, res1); Assert.Equal(2, res2); Assert.Equal(1, res3); Assert.Equal(5, resAll); }
“添加到购物车”按钮将显示在目录中的每个产品旁边。单击此按钮将显示客户迄今为止选择的产品的摘要,包括总成本。此时,用户可以单击“继续购物”按钮返回产品目录,或单击“现在结帐”按钮完成订单并完成购物会话。
到目前为止,我已经使用 MVC 框架定义了 SportsStore 项目特性。为了多样化起见,我将使用 Razor 页面ーー ASP.NET Core 支持的另一个应用程序框架ーー来实现购物车
using Microsoft.EntityFrameworkCore; using SportsStore.Models; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddDbContext<StoreDbContext>(opts => { opts.UseSqlite( builder.Configuration["ConnectionStrings:SportsStoreConnection"]); }); builder.Services.AddScoped<IStoreRepository, EFStoreRepository>(); builder.Services.AddRazorPages(); var app = builder.Build(); // app.MapGet("/", () => "Hello World!"); app.UseStaticFiles(); app.MapDefaultControllerRoute(); app.MapRazorPages(); SeedData.EnsurePopulated(app); app.Run();
AddRazorPages 方法设置 Razor 网页使用的服务,而 MapRazorPages 方法将 Razor 网页注册为端点,URL 路由系统可以使用这些端点来处理请求。
向 SportsStore 项目添加一个名为 Pages 的文件夹,这是 Razor Pages 的常规位置。添加一个名为 _ ViewImport. cshtml 的 Razor 视图导入文件到 Pages 文件夹,这些表达式设置了 Razor 页面所属的名称空间,并允许在 Razor 页面中使用 SportsStore 类,而无需指定它们的名称空间。
// SportsStore/Pages/_ViewImports.cshtml @namespace SportsStore.Pages @using Microsoft.AspNetCore.Mvc.RazorPages @using SportsStore.Models @using SportsStore.Infrastructure @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<!-- SportsStore/Pages/_CartLayout.cshtml --> <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width"/> <title>SportsStore</title> <link href="lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet"/> </head> <body> <div class="bg-dark text-white p-2"> <span class="navbar-brand ml-2">SPORTS STORE</span> </div> <div class="m-1 p-1"> @RenderBody() </div> </body> </html>
// SportsStore/Pages/_ViewStart.cshtml @{ Layout = "_CartLayout"; }
创建 Razor 页面,一般是会创建两个名字相似的文件Cart.cshtml 和 Cart.cshtml.cs,一个是html元素一个是c#代码
先写个默认值在页面
<!-- SportsStore/Pages/Cart.cshtml --> @page <h4>This is the Cart Page</h4>
在实现购物车特性之前,我还有一些准备工作要做。首先,我需要创建将产品添加到购物车的按钮
namespace SportsStore.Infrastructure; public static class UrlExtensions { public static string PathAndQuery(this HttpRequest request) => request.QueryString.HasValue ? $"{request.Path}{request.QueryString}" : request.Path.ToString(); }
PathAndQuery 扩展方法操作 HttpRequest 类,ASP.NET Core 使用该类来描述 HTTP 请求。该扩展方法生成一个 URL,浏览器将在购物车更新后返回该 URL,并考虑到查询字符串(如果有的话)。
首先是全局导入这个类
// SportsStore/Views/_ViewImports.cshtml @using SportsStore.Models @using SportsStore.Models.ViewModels @using SportsStore.Infrastructure @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, SportsStore
我添加了一个表单元素,它包含隐藏的输入元素,指定视图模型中的 ProductID 值和浏览器在购物车更新后应返回的 URL。Form 元素和其中一个输入元素使用内置的标记助手进行配置,这是生成包含模型值和目标控制器或 Razor 页面的表单的有用方法,另一个 input 元素使用我创建的扩展方法来设置返回 URL。我还添加了一个按钮元素,它将向应用程序提交表单。
<!-- SportsStore/Views/Shared/ProductSummary.cshtml --> @model Product <div class="card card-outline-primary m-1 p-1"> <div class="bg-faded p-1"> <h4> @Model?.Name <span class="badge rounded-pill bg-primary text-white" style="float:right"> <small>@Model?.Price.ToString("c")</small> </span> </h4> </div> <form id="@Model?.ProductID" asp-page="/Cart" method="post"> <input type="hidden" asp-for="ProductID" /> <input type="hidden" name="returnUrl" value="@ViewContext.HttpContext.Request.PathAndQuery()" /> <span class="card-text p-1"> @Model?.Description <button type="submit" class="btn btn-success btn-sm pull-right" style="float:right"> Add To Cart </button> </span> </form> </div>
我将使用session state来存储用户购物车的详细信息,session state是与用户发出的一系列请求相关联的数据。ASP.NET 提供了一系列不同的方法来存储session state,包括将其存储在内存中,这是我将要使用的方法。这具有简单性的优点,但是这意味着当应用程序停止或重新启动时会话数据将丢失。启用会话需要在 Program.cs 文件中添加服务和中间件
using Microsoft.EntityFrameworkCore; using SportsStore.Models; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddDbContext<StoreDbContext>(opts => { opts.UseSqlite( builder.Configuration["ConnectionStrings:SportsStoreConnection"]); }); builder.Services.AddScoped<IStoreRepository, EFStoreRepository>(); builder.Services.AddRazorPages(); builder.Services.AddDistributedMemoryCache(); builder.Services.AddSession(); var app = builder.Build(); app.UseStaticFiles(); app.UseSession(); app.MapDefaultControllerRoute(); app.MapRazorPages(); SeedData.EnsurePopulated(app); app.Run();
AddDibutedMemoryCache 方法调用设置内存中的数据存储区。AddSession 方法注册用于访问会话数据的服务,而 UseSession 方法允许会话系统在请求从客户端到达时自动将它们与会话关联。
对购物车创建模型数据
namespace SportsStore.Models; public class Cart { public List<CartLine> Lines { get; set; } = new List<CartLine>(); public void AddItem(Product product, int quantity) { var line = Lines.FirstOrDefault(p => p.Product.ProductID == product.ProductID); if (line == null) { Lines.Add(new CartLine { Product = product, Quantity = quantity }); } else { line.Quantity += quantity; } } public void RemoveLine(Product product) => Lines.RemoveAll(l => l.Product.ProductID == product.ProductID); public decimal ComputeTotalValue() => Lines.Sum(e => e.Product.Price * e.Quantity); public void Clear() => Lines.Clear(); } public class CartLine { public int CartLineID { get; set; } public Product Product { get; set; } = new(); public int Quantity { get; set; } }
Cart 类使用同一文件中定义的 CartLine 类来表示客户选择的产品和用户想要购买的数量。我定义了向购物车添加项目、从购物车中删除先前添加的项目、计算购物车中项目的总成本以及通过删除所有项目重置购物车的方法。
然后编写测试
namespace SportsStore.Tests; public class CartTests { [Fact] public void Can_Add_New_Lines() { // Arrange - create some test products var p1 = new Product { ProductID = 1, Name = "P1" }; var p2 = new Product { ProductID = 2, Name = "P2" }; // Arrange - create a new cart var target = new Cart(); // Act target.AddItem(p1, 1); target.AddItem(p2, 1); var results = target.Lines.ToArray(); // Assert Assert.Equal(2, results.Length); Assert.Equal(p1, results[0].Product); Assert.Equal(p2, results[1].Product); } [Fact] public void Can_Add_Quantity_For_Existing_Lines() { // Arrange - create some test products var p1 = new Product { ProductID = 1, Name = "P1" }; var p2 = new Product { ProductID = 2, Name = "P2" }; // Arrange - create a new cart var target = new Cart(); // Act target.AddItem(p1, 1); target.AddItem(p2, 1); target.AddItem(p1, 10); var results = (target.Lines ?? new()) .OrderBy(c => c.Product.ProductID).ToArray(); // Assert Assert.Equal(2, results.Length); Assert.Equal(11, results[0].Quantity); Assert.Equal(1, results[1].Quantity); } [Fact] public void Can_Remove_Line() { // Arrange - create some test products var p1 = new Product { ProductID = 1, Name = "P1" }; var p2 = new Product { ProductID = 2, Name = "P2" }; var p3 = new Product { ProductID = 3, Name = "P3" }; // Arrange - create a new cart var target = new Cart(); // Arrange - add some products to the cart target.AddItem(p1, 1); target.AddItem(p2, 3); target.AddItem(p3, 5); target.AddItem(p2, 1); // Act target.RemoveLine(p2); // Assert Assert.Empty(target.Lines.Where(c => c.Product == p2)); Assert.Equal(2, target.Lines.Count()); } [Fact] public void Calculate_Cart_Total() { // Arrange - create some test products var p1 = new Product { ProductID = 1, Name = "P1", Price = 100M }; var p2 = new Product { ProductID = 2, Name = "P2", Price = 50M }; // Arrange - create a new cart var target = new Cart(); // Act target.AddItem(p1, 1); target.AddItem(p2, 1); target.AddItem(p1, 3); var result = target.ComputeTotalValue(); // Assert Assert.Equal(450M, result); } [Fact] public void Can_Clear_Contents() { // Arrange - create some test products var p1 = new Product { ProductID = 1, Name = "P1", Price = 100M }; var p2 = new Product { ProductID = 2, Name = "P2", Price = 50M }; // Arrange - create a new cart var target = new Cart(); // Arrange - add some items target.AddItem(p1, 1); target.AddItem(p2, 1); // Act - reset the cart target.Clear(); // Assert Assert.Empty(target.Lines); } }
NET Core 中的会话状态特性只存储 int、 string 和 byte []值。因为我想要存储一个对象,我需要定义扩展方法到 ISsession 接口,它提供对会话状态数据的访问,以序列化 Cart 对象到 JSON 并将它们转换回来。
using System.Text.Json; namespace SportsStore.Infrastructure; public static class SessionExtensions { public static void SetJson(this ISession session, string key, object value) { session.SetString(key, JsonSerializer.Serialize(value)); } public static T? GetJson<T>(this ISession session, string key) { var sessionData = session.GetString(key); return sessionData == null ? default(T) : JsonSerializer.Deserialize<T>(sessionData); } }
当用户单击“添加到购物车”按钮时,“购物车”Razor 页将接收浏览器发送的 HTTP POST 请求。它将使用请求表单数据从数据库中获取 Product 对象,并使用它来更新用户的购物车,这些购物车将作为会话数据存储,供以后的请求使用。
首先创建Razor页面的模型
// Cart.cshtml.cs namespace SportsStore.Pages; public class CartModel : PageModel { private IStoreRepository _repository; public CartModel(IStoreRepository repo) { _repository = repo; } public Cart? Cart { get; set; } public string ReturnUrl { get; set; } = "/"; public void OnGet(string returnUrl) { ReturnUrl = returnUrl ?? "/"; Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart(); } public IActionResult OnPost(long productId, string returnUrl) { Product? product = _repository.Products .FirstOrDefault(p => p.ProductID == productId); if (product != null) { Cart = HttpContext.Session.GetJson<Cart>("cart") ?? new Cart(); Cart.AddItem(product, 1); HttpContext.Session.SetJson("cart", Cart); } return RedirectToPage(new { returnUrl = returnUrl }); } }
然后修改页面
<!-- SportsStore/Pages/Cart.cshtml --> @page @model CartModel <h2>Your cart</h2> <table class="table table-bordered table-striped"> <thead> <tr> <th>Quantity</th> <th>Item</th> <th class="text-right">Price</th> <th class="text-right">Subtotal</th> </tr> </thead> <tbody> @foreach (var line in Model?.Cart?.Lines ?? Enumerable.Empty<CartLine>()) { <tr> <td class="text-center">@line.Quantity</td> <td class="text-left">@line.Product.Name</td> <td class="text-right">@line.Product.Price.ToString("c")</td> <td class="text-right"> @((line.Quantity * line.Product.Price).ToString("c")) </td> </tr> } </tbody> <tfoot> <tr> <td colspan="3" class="text-right">Total:</td> <td class="text-right"> @Model?.Cart?.ComputeTotalValue().ToString("c") </td> </tr> </tfoot> </table>
与 Razor 页面相关联的类称为其页面模型类,它定义了为不同类型的 HTTP 请求调用的处理程序方法,这些方法在呈现视图之前更新状态。CartModel 的页面模型类定义了一个 OnPost hander 方法,该方法被调用来处理 HTTP POST 请求。为此,它从数据库中检索 Product,从会话数据中检索用户的 Cart,并使用 Product 更新其内容。修改后的 Cart 被存储,浏览器被重定向到相同的 Razor 页面,它将使用一个 GET 请求(这样可以防止重新加载浏览器触发重复的 POST 请求)。
GET 请求由 OnGet 处理程序方法处理,该方法设置 return nUrl 和 Cart 属性的值,然后呈现页面的 Razor 内容部分。HTML 内容中的表达式使用 CartModel 作为视图模型对象进行计算,这意味着可以在表达式中访问分配给 return nUrl 和 Cart 属性的值。Razor 页面生成的内容详细说明了添加到用户购物车中的产品,并提供了一个按钮来导航到产品添加到购物车中的位置。
处理程序方法使用与 ProductSummary.cshtml 视图生成的 HTML 表单中的输入元素匹配的参数名。这允许 ASP.NET Core 将传入的表单 POST 变量与这些参数关联起来,这意味着我不需要直接处理表单。这就是所谓的模型绑定,它是简化开发的强大工具
然后就可以对Razor页面测试
测试 Razor 页面可能需要大量的模拟来创建页面模型类所需的上下文对象。为了测试由 CartModel 类定义的 OnGet 方法的行为
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Routing; using Moq; using SportsStore.Models; using SportsStore.Pages; using System.Text; using System.Text.Json; namespace SportsStore.Tests; public class CartPageTests { [Fact] public void Can_Load_Cart() { // Arrange // - create a mock repository var p1 = new Product { ProductID = 1, Name = "P1" }; var p2 = new Product { ProductID = 2, Name = "P2" }; var mockRepo = new Mock<IStoreRepository>(); mockRepo.Setup(m => m.Products).Returns(new Product[] { p1, p2 }.AsQueryable()); // - create a cart var testCart = new Cart(); testCart.AddItem(p1, 2); testCart.AddItem(p2, 1); // - create a mock page context and session var mockSession = new Mock<ISession>(); var data = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(testCart)); mockSession.Setup(c => c.TryGetValue(It.IsAny<string>(), out data)); var mockContext = new Mock<HttpContext>(); mockContext.SetupGet(c => c.Session).Returns(mockSession.Object); // Action var cartModel = new CartModel(mockRepo.Object) { PageContext = new PageContext(new ActionContext { HttpContext = mockContext.Object, RouteData = new RouteData(), ActionDescriptor = new PageActionDescriptor() }) }; cartModel.OnGet("myUrl"); //Assert Assert.Equal(2, cartModel.Cart?.Lines.Count()); Assert.Equal("myUrl", cartModel.ReturnUrl); } [Fact] public void Can_Update_Cart() { // Arrange // - create a mock repository var mockRepo = new Mock<IStoreRepository>(); mockRepo.Setup(m => m.Products).Returns(new Product[] { new() { ProductID = 1, Name = "P1" } }.AsQueryable()); var testCart = new Cart(); var mockSession = new Mock<ISession>(); mockSession.Setup(s => s.Set(It.IsAny<string>(), It.IsAny<byte[]>())) .Callback<string, byte[]>((key, val) => { testCart = JsonSerializer.Deserialize<Cart>(Encoding.UTF8.GetString(val)); }); var mockContext = new Mock<HttpContext>(); mockContext.SetupGet(c => c.Session).Returns(mockSession.Object); // Action var cartModel = new CartModel(mockRepo.Object) { PageContext = new PageContext(new ActionContext { HttpContext = mockContext.Object, RouteData = new RouteData(), ActionDescriptor = new PageActionDescriptor() }) }; cartModel.OnPost(1, "myUrl"); //Assert Assert.Single(testCart.Lines); Assert.Equal("P1", testCart.Lines.First().Product.Name); Assert.Equal(1, testCart.Lines.First().Quantity); } }