ASP.NET Core 微服务之路 第二部分:视图组件





5.00/5 (22投票s)
这一系列文章中的第二篇,将介绍通往 ASP.NET Core 微服务之路的设计模式、架构设计、框架和技术。
文章系列
- ASP.NET Core 微服务之路 第一部分:构建视图
- ASP.NET Core 微服务之路 第二部分:视图组件
- ASP.NET Core 微服务之路 第三部分:ASP.NET Core 身份验证
- 第四部分:SQLite
- 第五部分:Dapper
- 第六部分:SignalR
- 第七部分:Web API 单元测试
- 第八部分:Web MVC 应用单元测试
- 第九部分:监控运行状况检查
- 第十部分:Redis 数据库
- 第十一部分:IdentityServer4
- 第十二部分:订单 Web API
- 第十三部分:购物车 Web API
- 第十四部分:目录 Web API
- 第十五部分:使用 Polly 实现有弹性的 HTTP 客户端
- 第十六部分:使用 Swagger 文档化 Web API
- 第十七部分:Docker 容器
- 第十八部分:Docker 配置
- 第十九部分:使用 Kibana 进行集中式日志记录
引言
欢迎来到“ASP.NET Core 微服务之路”系列文章的第二期。
在上篇文章中,我们学习了如何使用视图和部分视图构建电子商务应用程序的基本视图。今天,我们将深入探讨 ASP.NET Core 中的视图组件。
什么是视图组件?它们与部分视图有何区别?以及它们如何应用于我们的电子商务项目?
部分视图 vs. 视图组件
我们在上一篇文章中介绍的部分视图足以胜任电子商务应用程序的视图组合角色。
我们已经看到部分视图如何让我们将大型标记文件分解成更小的组件,并减少跨标记文件的常见标记内容的重复。
视图组件是 ASP.NET Core 引入的一个概念,它类似于部分视图。视图组件与部分视图一样,都能分解大型视图和减少重复,但它们的构建方式不同,并且功能更强大。
部分视图和常规视图一样,都使用模型绑定,即模型数据必须由特定的控制器操作提供。而视图组件仅依赖于作为参数传递给它们的数据。
尽管我们在电子商务应用程序中实现了视图组件,该应用程序基于控制器和视图,但也可以为Razor Pages 开发视图组件。
用视图组件替换购物车的部分视图
在上一篇文章中,我们将Basket
视图分解成更小的部分视图,如下面的文件夹结构所示。
图 1:与购物车相关的部分视图
这些标记文件中的每一个都负责渲染购物车视图内不同层级的元素。
- Basket/Index (视图)
- Basket Controls (部分视图)
- Basket List (部分视图)
- Basket Item (部分视图)
<partial name="_BasketControls" />
<h3>My Basket</h3>
<partial name="_BasketList" for="@items" />
<br />
<partial name="_BasketControls" />
列表 1:如何使用部分视图的示例 (\Views\Basket\Index.cshtml)
但是部分视图在某些方面受到限制,不支持视图组件中的一些有趣功能,例如:
- 独立于宿主视图的行为
- 与控制器/视图类似的关注点分离
- 参数
- 业务逻辑
- 可测试性
但是,这些不错的特性也意味着我们需要做更多的工作来创建视图组件。除了标记文件,我们还需要为视图组件创建一个专门的类。但它必须位于我们必须先创建的ViewComponents文件夹中。
现在,我们在ViewComponents文件夹中创建一个名为BasketListViewComponent
的类。
这个类只需要有一个Invoke()
方法,调用并返回Default
视图。
public class BasketListViewComponent : ViewComponent
{
public IViewComponentResult Invoke()
{
return View("Default");
}
}
列表 2:ViewComponents\BasketListViewComponent.cs 文件
但请注意,我们之前的 Basket List 部分视图有一个模型属性。
<partial name="_BasketList" for="@items" />
这个@items
属性现在将通过BasketListViewComponent
类中Invoke()
方法的一个 items 参数传递给新的BasketListViewComponent
,然后作为Default
标记文件的模型传递。
public class BasketListViewComponent : ViewComponent
{
public IViewComponentResult Invoke(List<BasketItem> items)
{
return View("Default", items);
}
}
列表 3:ViewComponents\BasketListViewComponent.cs 文件
默认情况下,视图组件类名必须以-ViewComponent
后缀结尾。但您可以使用ViewComponentAttribute
并通过设置组件名称来覆盖此规则(请注意,这允许您使用任何您想要的类名)。
[ViewComponent(Name = "BasketList")]
public class BasketList : ViewComponent
{
public IViewComponentResult Invoke(List<BasketItem> items)
{
return View("Default", items);
}
}
列表 4:使用属性设置视图组件名称
现在,我们来创建组件的标记(视图)文件。首先,我们必须在\Views\Basket文件夹下创建一个\Components文件夹,然后在\Components文件夹下创建一个\BasketList文件夹。然后我们创建Default.cshtml文件(顺便说一句,这是任何组件的默认名称),它看起来就像一个常规的视图文件。添加一个新的 MVC 视图(脚手架),不带模板、模型和布局。
图 2:添加新的视图组件视图
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Default</title>
</head>
<body>
</body>
</html>
列表 5:\Views\Components\BasketList\Default.cshtml 文件
请注意,新的BasketList
视图组件旨在替换当前的BasketList
部分视图。因此,我们将用后者来覆盖前者的内容。
@using MVC.Controllers
@model List<BasketItem>;
@{
var items = Model;
}
<div class="card">
<div class="card-header">
<div class="row">
<div class="col-sm-6">
Item
</div>
<div class="col-sm-2 text-center">
Unit Price
</div>
<div class="col-sm-2 text-center">
Quantity
</div>
<div class="col-sm-2">
<span class="pull-right">
Subtotal
</span>
</div>
</div>
</div>
<div class="card-body">
@foreach (var item in items)
{
<partial name="_BasketItem" for="@item" />
}
</div>
<div class="card-footer">
<div class="row">
<div class="col-sm-10">
<span numero-items>
Total: @items.Count
item@(items.Count > 1 ? "s" : "")
</span>
</div>
<div class="col-sm-2">
Total: <span class="pull-right" total>
@(items.Sum(item => item.Quantity * item.UnitPrice).ToString("C"))
</span>
</div>
</div>
</div>
</div>
列表 6:BasketList 视图组件,包含 BasketList 部分视图的内容 (\Views\Components\BasketList\Default.cshtml)
现在,我们来更新购物车视图,用视图组件的标签助手替换部分视图的标签助手。
打开\Views\Basket\Index.cshtml文件。我们现在必须让这个文件可用标签助手。因此,添加以下指令:
@addTagHelper *, MVC
@addTagHelper
指令将允许我们使用视图组件的标签助手。“*
”参数表示所有标签助手都可用,“MVC”部分表示 MVC 命名空间中找到的所有视图组件都可用。
现在注释掉这一行:
<!--THIS LINE WILL BE COMMENTED OUT-->
@*<partial name="_BasketList" for="@items" />*@
列表 7:删除 BasketList 的 PartialTagHelper (\Views\Basket\Index.cshtml)
现在,让我们引用我们的视图组件标签助手。当您键入“vc:
”前缀时,视图组件将可用。
请注意BasketList
视图组件显示为“basket-list
”。这就是所谓的“kebab-case
”样式(因为它看起来像烤肉串)。
您可能还注意到了@_Generated_BasketListViewComponentTagHelper
这个名字,这是编译视图组件时在程序集中自动生成类的名称。
现在,我们也为视图组件提供items
参数。
@*<partial name="_BasketList" for="@items" />*@
<vc:basket-list items="@items"></vc:basket-list>
列表 8:BasketList 的视图组件标签助手 (\Views\Basket\Index.cshtml)
此时,我们可以运行应用程序,并验证我们的视图组件现在可以像被替换的部分视图一样工作。
将 BasketItem 移至 ViewModels
在上面的部分,我们使用BasketItem
作为视图模型。因此,作为重构步骤,让我们将其移至Models\ViewModels文件夹。
public class BasketItem
{
public int Id { get; set; }
public int ProductId { get; set; }
public string Name { get; set; }
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
}
列表 9:将 BasketItem.cs 移至 Models\ViewModels
由于这会产生副作用,我们还必须在以下文件中修复命名空间:
using MVC.Models.ViewModels
- /ViewComponents/BasketListViewComponent.cs
- Components/BasketList/Default.cshtml
- /Views/Basket/Index.cshtml
- _BasketItem.cshtml
定义视图组件类的逻辑
当前,BasketList
视图组件类相当“笨拙”。但现在,我们有一个新任务来实现类的业务逻辑。
- 如果列表参数为空,组件必须显示一个空视图。
- 如果列表参数包含购物车项目,组件必须显示默认视图。
视图组件单元测试
与部分视图不同,视图组件支持测试。与任何常规类一样,视图组件类可以进行单元测试。让我们向解决方案添加一个新的单元测试项目。
添加新的单元测试项目
转到文件菜单,选择:* 新建 > 项目 > 测试 > xUnit 测试项目 (.NET Core)*
图 3:添加新的 xUnit 项目
新的xUnit测试项目始终包含一个空的测试类(UnitTest1
)。
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
xUnit 是 Visual Studio 附带的单元测试项目模板之一。
[Fact]
属性告诉 xUnit 框架,一个无参数的测试方法必须由测试运行器运行。(我们将在下一节中看到测试运行器)。
由于我们希望测试BasketListViewComponent
类,我们将测试类重命名为BasketListViewComponentTest
。
public class BasketListViewComponentTest
{
[Fact]
public void Test1()
{
}
}
列表 10:将测试类重命名为 BasketListViewComponentTest
我们还将Test1()
方法重命名为一个更具描述性的名称,一个能描述我们正在验证的行为的名称:“调用带有 items 的 Invoke() 方法应显示默认视图”。
public class BasketListViewComponentTest
{
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
}
}
列表 11:使用 xUnit 创建我们的第一个单元测试
作为良好的实践,每个单元测试方法都必须分为 3 个部分,称为“Arrange-Act-Assert”。
- 单元测试方法的Arrange部分初始化对象并设置要传递给被测方法的数据的值。
- Act部分使用已安排的参数调用被测方法。
- Assert部分验证被测方法的操作是否按预期执行。
让我们在代码中明确地介绍这些部分:
public class BasketListViewComponentTest
{
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
//arrange
//act
//assert
}
}
列表 12:单元测试的 AAA:Arrange, Act 和 Assert
在 Arrange 部分,我们必须初始化对象。
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
//arrange
var vc = new BasketListViewComponent();
//act
//assert
}
在 Act 部分,我们使用已安排的参数调用被测方法。
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
//arrange
var vc = new BasketListViewComponent();
//act
var result = vc.Invoke();
//assert
}
但是Invoke()
方法会产生一个编译错误。
error CS0012: The type 'ViewComponent' is defined in an assembly that is not referenced.
You must add a reference to assembly 'Microsoft.AspNetCore.Mvc.ViewFeatures,
Version=2.2.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60'.
按Ctrl + DOT打开上下文菜单,然后选择:安装程序包 'Microsoft.AspNetCore.Mvc.ViewFeatures'
。
但请记住,BasketListViewComponent.Invoke()
方法需要一个items
参数。
Invoke With Items => Display Default View
ACTION => ASSERT
因此,让我们使用 Arrange 部分来声明一个 items 变量,并用一些购物车项目填充它。
列表 13:安排测试并调用 Invoke() 方法
public class BasketListViewComponentTest
{
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
//arrange
var vc = new BasketListViewComponent();
List<BasketItem> items =
new List<BasketItem>
{
new BasketItem { Id = 1, ProductId = 1, Name = "Broccoli",
UnitPrice = 59.90m, Quantity = 2 },
new BasketItem { Id = 2, ProductId = 5, Name = "Green Grapes",
UnitPrice = 59.90m, Quantity = 3 },
new BasketItem { Id = 3, ProductId = 9, Name = "Tomato",
UnitPrice = 59.90m, Quantity = 4 }
};
//act
var result = vc.Invoke(items);
//assert
}
}
列表 14:为 Invoke() 方法提供参数
现在是实现我们单元测试的 Assert 部分的时候了。Assert 部分是所有验证发生的地方,以确保被测方法按预期执行。
CAUSE => EFFECT
=========================================
Invoke With Items => Display Default View
ACTION => ASSERT
BasketListViewComponent.Invoke()
方法返回一个IViewComponentResult
,这是一个接口。但我们必须确保方法返回的对象是一个视图,或者更具体地说,是ViewViewComponentResult
的一个实例。
当使用 xUnit 测试框架时,我们可以使用方法 Assert.IsAssignableFrom<T>(object)
来验证一个变量是否属于特定类型。
//act
var result = vc.Invoke(items);
//assert
Assert.IsAssignableFrom<ViewViewComponentResult>(result);
现在我们有了第一个可测试的 Arrange-Act-Assert 方法。让我们使用 Test Explorer 来执行它。
测试 > 窗口 > 测试资源管理器
或者
Ctrl+E, T
当您第一次打开 Test Explorer 时,您会看到应用程序的测试结构。
MVC.Test
(程序集)MVC.Test.ViewComponents
(命名空间)BasketListViewComponentTest
(测试类)Invoke_With_Items_Should_Display_Default_View
(测试方法 - Fact)
这种结构在保持 Test Explorer 有序方面非常有帮助,因为我们实现了越来越多的测试。否则,Test Explorer 可能会因纯列表的不断增长而显得杂乱。
当我们点击全部运行菜单时,应用程序将在需要时重新编译,然后 xUnit 测试框架将执行到目前为止存在的唯一一个测试。
正如我们所见,测试已成功通过。这种执行方式不允许在测试执行时检查对象、参数、变量等。如果您想调试测试的执行,您应该
- 在可测试方法(以及可能的其余受影响代码)中放置您想要的断点。
- 右键单击测试名称,然后选择调试选定测试菜单。
- 现在您可以像调试常规应用程序一样调试执行的代码。
此时,我们的测试只是进行一个简单的测试,即检查结果变量是否包含一个视图。但这还不够:我们还必须检查结果中的视图是否实际上是Default
视图。我们可以通过比较ViewViewComponentResult
对象的ViewName
属性与“Default
”字符串来实现。在单元测试方法中,我们通过调用Assert.Equal(expected, actual)
来做到这一点。
//assert
ViewViewComponentResult vvcResult = Assert.IsAssignableFrom<ViewViewComponentResult>(result);
Assert.Equal("Default", vvcResult.ViewName);
现在,我们有了完整的测试实现。
public class BasketListViewComponentTest
{
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
//arrange
var vc = new BasketListViewComponent();
List<BasketItem> items =
new List<BasketItem>
{
new BasketItem { Id = 1, ProductId = 1, Name = "Broccoli",
UnitPrice = 59.90m, Quantity = 2 },
new BasketItem { Id = 2, ProductId = 5, Name = "Green Grapes",
UnitPrice = 59.90m, Quantity = 3 },
new BasketItem { Id = 3, ProductId = 9, Name = "Tomato",
UnitPrice = 59.90m, Quantity = 4 }
};
//act
var result = vc.Invoke(items);
//assert
ViewViewComponentResult vvcResult =
Assert.IsAssignableFrom<ViewViewComponentResult>(result);
Assert.Equal("Default", vvcResult.ViewName);
}
}
列表 15:检查结果类型和结果视图名称
任何时候您更改测试方法,都必须再次运行它。再次运行测试,我们可以看到它仍然通过。
现在,测试实现可以认为是完整的,并且不应再次更改,除非被测方法或其依赖的对象发生更改。
但请注意不要在每个单元测试中测试过多的内容。在这里,始终应用KISS 原则:(保持简单,愚蠢)。每个测试只做一件事。如果单个测试方法累积了多个职责,则重构它并将其拆分为具有单一职责的多个测试方法。
没有项目的购物车组件应显示空视图
现在是时候实现关于购物车列表视图组件的第二个规则了。
- 如果列表参数包含购物车项目,组件必须显示默认视图。
在同一个BasketListViewComponentTest
类中,让我们为这个规则实现第二个单元测试方法。
[Fact]
public void Invoke_Without_Items_Should_Display_Empty_View()
{
//arrange
//act
//assert
}
列表 16:新的 Invoke_Without_Items_Should_Display_Empty_View 测试方法
在这种情况下,BasketListViewComponent.Invoke()
方法将被调用,并带有一个空列表。
[Fact]
public void Invoke_Without_Items_Should_Display_Empty_View()
{
//arrange
var vc = new BasketListViewComponent();
//act
var result = vc.Invoke(new List<BasketItem>());
//assert
}
列表 17:调用带有空列表的 Invoke() 方法
测试的其余部分与我们写的第一个测试非常相似,不同之处在于我们现在检查的是返回的视图是否为“Empty
”。
[Fact]
public void Invoke_Without_Items_Should_Display_Empty_View()
{
//arrange
var vc = new BasketListViewComponent();
//act
var result = vc.Invoke(new List<BasketItem>());
//assert
ViewViewComponentResult vvcResult =
Assert.IsAssignableFrom<ViewViewComponentResult>(result);
Assert.Equal("Empty", vvcResult.ViewName);
}
列表 18:测试购物车列表视图组件在购物车为空时的情况
现在,让我们编译并查看 Test Explorer 中显示的新测试。
然后,我们运行所有测试,或者只运行指定的测试。
注意整个结构是如何用失败图标标记的,除了我们创建的第一个单元测试,它仍然是绿色的。
尽可能先创建一个测试,然后实现业务类中的规则,直到测试通过。我们现在就来做。让测试通过。
我们应该修改BasketListViewComponent
类,以包含一个验证购物车中项目数量的条件。如果没有项目,我们应该返回一个 Empty 视图。
public IViewComponentResult Invoke(List<BasketItem> items)
{
if (items.Count == 0) // these 3 lines were added
{ // so that we can return
return View("Empty"); // a different view in case
} // of empty basket
return View("Default", items);
}
列表 19:在购物车为空的情况下返回不同的视图
再次运行测试,我们可以注意到一切都通过了。
但是,尽管第二个测试通过了,我们仍然没有这个购物车列表条件的 Empty 视图。我们可以通过在\MVC\Views\Basket\项目文件夹下添加一个新的Empty.cshtml标记文件来解决这个问题。
<div class="card">
<div class="card-body">
<!--https://bootstrap.ac.cn/docs/4.0/components/alerts/-->
<div class="alert alert-warning" role="alert">
There are no items in your basket yet!
Click <a asp-controller="catalog"><b>here</b></a> to start shopping!
</div>
</div>
</div>
列表 20:新的 Empty.cshtml 视图显示 Bootstrap 4 警报组件
我们可以暂时停止进行单元测试,然后开始一个手动测试,其中我们尝试模拟一个空的购物车列表。
第一步是注释掉\MVC\Views\Basket\Index.cshtml文件中的BasketItem
实例,以便购物车列表为空。
List<BasketItem> items = new List<BasketItem>
{
@*new BasketItem { Id = 1, ProductId = 1, Name = "Broccoli",
UnitPrice = 59.90m, Quantity = 2 },
new BasketItem { Id = 2, ProductId = 5, Name = "Green Grapes",
UnitPrice = 59.90m, Quantity = 3 },
new BasketItem { Id = 3, ProductId = 9, Name = "Tomato",
UnitPrice = 59.90m, Quantity = 4 }*@
};
列表 21:注释掉项目以在运行应用程序时检查警报
再次运行应用程序,我们可以看到 Bootstrap Alert 组件,显示警报消息:“您的购物车中还没有商品!点击此处开始购物!”
为 BasketItem 创建视图组件
不仅是购物车列表,购物车项目的部分视图也可以转换为视图组件。
这需要一些步骤,与我们之前看到的类似。
- 在ViewComponents\文件夹下创建一个新的
BasketItemViewComponent
类。public class BasketItemViewComponent : ViewComponent { public BasketItemViewComponent() { } public IViewComponentResult Invoke(BasketItem item) { return View("Default", item); } }
列表 22:新的 BasketItemViewComponent 类 (\ViewComponents\BasketItemViewComponent.cs)
- 在 Components 下创建一个新的BasketItem文件夹。
- 将部分视图文件:_BasketItem.cshtml移动到Components/BasketItem/文件夹。
- 将其重命名为Default.cshtml。
- 修改\Views\Basket\Components\BasketList\Default.cshtml文件以添加
@addTagHelper
指令。@addTagHelper *, MVC
列表 23:添加 @addTagHelper 指令
- 删除对
_BasketItem
部分视图标签助手的引用。<partial name="_BasketItem" for="@item" />
- 用新的视图组件标签助手替换它。
<vc:basket-item item="@item"></vc:basket-item>
- 这将给我们以下标记:
<div class="card"> <div class="card-header"> <div class="row"> <div class="col-sm-6"> Item </div> <div class="col-sm-2 text-center"> Unit Price </div> <div class="col-sm-2 text-center"> Quantity </div> <div class="col-sm-2"> <span class="pull-right"> Subtotal </span> </div> </div> </div> <div class="card-body"> @foreach (var item in items) { <vc:basket-item item="@item"></vc:basket-item> } </div> <div class="card-footer"> <div class="row"> <div class="col-sm-10"> <span numero-items> Total: @items.Count item@(items.Count > 1 ? "s" : "") </span> </div> <div class="col-sm-2"> Total: <span class="pull-right" total> @(items.Sum(item => item.Quantity * item.UnitPrice).ToString("C")) </span> </div> </div> </div> </div>
列表 24:Components/BasketItem/Default.cshtml 文件
- 现在,重新激活包含
BasketItem
实例的代码行,我们之前为了测试购物车列表而注释掉了它们。new BasketItem { Id = 1, ProductId = 1, Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 }, new BasketItem { Id = 2, ProductId = 5, Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 }, new BasketItem { Id = 3, ProductId = 9, Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 }
列表 25:恢复 3 个购物车项目 (\MVC\Views\Basket\Index.cshtml)
- 最后一步:删除_BasketList.cshtml部分视图文件。
转换为视图组件会产生与部分视图完全相同的结果。
图 4:购物车项目视图组件屏幕截图
BasketItemViewComponent 单元测试
既然我们有了一个BasketItem
视图组件……让我们为新的BasketItemViewComponent
实现单元测试。这些是我们要实现的新业务规则:
- 默认情况下,
Invoke()
方法应显示默认视图。 - 当明确要求时,
Invoke()
方法应显示摘要视图。
实现第一个规则的测试
让我们首先在MVC.Test
类中创建BasketItemViewComponentTest
类。然后我们实现Invoke_Should_Display_Default_View()
来添加 Arrange-Act-Assert 循环。这个方法与我们之前实现的第一个单元测试非常相似。
public class BasketItemViewComponentTest
{
[Fact]
public void Invoke_Should_Display_Default_View()
{
//arrange
var vc = new BasketItemViewComponent();
BasketItem item =
new BasketItem { Id = 1, ProductId = 1,
Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 };
//act
var result = vc.Invoke(item);
//assert
ViewViewComponentResult vvcResult =
Assert.IsAssignableFrom<ViewViewComponentResult>(result);
Assert.Equal("Default", vvcResult.ViewName);
BasketItem resultModel =
Assert.IsAssignableFrom<BasketItem>(vvcResult.ViewData.Model);
Assert.Equal(item.ProductId, resultModel.ProductId);
}
}
列表 26:设置第一个 BasketItemViewComponent 单元测试
您可以在上面的代码片段中注意到,这与我们实现的第一个单元测试最显著的区别在于,我们现在不仅仅检查视图的名称。我们还验证视图的模型内容,以确保它属于BasketItem
类型,并且对象包含与作为模型传递的产品 ID 相同的 ID。
请始终保持每个单元测试尽可能小而整洁。
运行测试,我们得到结果:所有三个测试都通过了。
红/绿/重构周期
在实现单元测试时,您可以内化应用红/绿/重构周期进行每个单元测试的良好习惯。
红/绿/重构是著名的敏捷模式,用于测试,并由 3 个步骤组成:
- 红:每个单元测试最初都会失败。这是个好消息,因为被测方法还没有实现,并且测试正在运行并正确检测到缺失/损坏的规则。此时,您必须实现或修复被测功能。实现后,您将运行测试,如果再次失败,这意味着您的实现是错误的,或者测试本身是错误的。将单元测试视为安全网,或监视器,保护您的业务规则免受可能的开发错误。
- 绿:一旦实现正确,测试就应该通过。这意味着单元测试方法的目的已经达到。
- 重构:更改代码可能会很危险,如果您没有单元测试作为安全网来保护您免受错误。这就是为什么它是在每个测试通过后完成的:现在您有机会重构代码,即通过重命名类/方法/变量、删除不必要的注释、拆分大型方法和类、消除重复代码以及进行增强以提高代码质量来使代码更具可读性。重构代码后,您必须再次运行测试以确保一切仍然完美工作。
只有在重构步骤之后,您才会转到下一个单元测试业务规则,并为新单元测试开始“Red
”步骤。
启用摘要模式
当前,我们的BasketItem
视图组件仅显示Default
标记,这意味着,该项目包含添加/删除按钮以及允许直接更新数量的输入框。
但是,新的业务规则要求BasketItem
视图组件应准备好以只读样式显示信息。我们在此列出此新功能所需的更改:
- 向
Invoke()
方法添加一个新的布尔isSummary
参数。此参数仅指示组件样式是摘要(只读)还是非摘要(启用数量,完整模式)。public IViewComponentResult Invoke(BasketItem item, bool isSummary = false)
列表 27:向 Invoke() 方法添加 isSummary 参数 (ViewComponents/BasketItemViewComponent.cs)
- 实现一个新测试:
Invoke_Should_Display_SummaryItem_View
。现在,我们将参数(isSummary
)作为Invoke()
方法调用的参数传递。[Fact] public void Invoke_Should_Display_SummaryItem_View() { //arrange var vc = new BasketItemViewComponent(); BasketItem item = new BasketItem { Id = 2, ProductId = 5, Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 }; //act var result = vc.Invoke(item, true); //assert ViewViewComponentResult vvcResult = Assert.IsAssignableFrom<ViewViewComponentResult>(result); Assert.Equal("SummaryItem", vvcResult.ViewName); BasketItem resultModel = Assert.IsAssignableFrom<BasketItem>(vvcResult.ViewData.Model); Assert.Equal(item.ProductId, resultModel.ProductId); }
列表 28:测试调用摘要风格 ViewComponent 时的行为
- 再次运行测试,新测试将按预期失败。
- 现在,在
BasketItemViewComponent
类的Invoke()
中实现所需规则。您可以通过包含一个条件来验证新参数并返回SummaryItem
视图(该视图尚未实现)。if (isSummary == true) { return View("SummaryItem", item); }
列表 29:为摘要演示模式返回不同的视图 (ViewComponents/BasketItemViewComponent.cs)
- 再次运行测试,测试将通过。
- 为
SummaryItem
模式创建新的视图(SummaryItem.cshtml文件),位于Views/Basket/Components/BasketItem/文件夹下。@using MVC.Controllers @model BasketItem @{ var item = Model; } <div class="row row-center"> <div class="col-sm-2">@item.ProductId</div> <input type="hidden" name="productId" value="012" /> <div class="col-sm-4">@item.Name</div> <div class="col-sm-2 text-center">@item.UnitPrice.ToString("C")</div> <div class="col-sm-2 text-center">@item.Quantity</div> <div class="col-sm-2"> <div class="pull-right"> <span class="pull-right" subtotal> @((item.Quantity * item.UnitPrice).ToString("C")) </span> </div> </div> </div> <br />
列表 30:新的摘要项目视图组件 (Views/Basket/Components/BasketItem/SummaryItem.cshtml)
- 我们必须将
isSummary
信息传递给购物车项目组件。怎么做?我们必须通过容器提供它,该容器是购物车列表视图组件。但是购物车列表(仍然)没有isSummary
信息。我们也应该提供它。因此,让我们创建一个新类,它作为购物车列表组件的新模型,也就是说,这样的类将是一个“视图模型”。这个类将在一个名为ViewModels的新文件夹中创建。public class BasketItemList { public List<BasketItem> List { get; set; } public bool IsSummary { get; set; } }
列表 31:新的 BasketItemList 类 (ViewModels\BasketItemList.cs)
- 在上面的代码中,我们引入了一个新的
isSummary
参数,它对应用程序的其他部分(例如,购物车)有副作用。我们还必须在BasketListViewComponent
类的Invoke()
方法中引入相同的参数。public IViewComponentResult Invoke(List<BasketItem> items, bool isSummary)
列表 32:向 Invoke() 方法添加 isSummary 参数 (/ViewComponents/BasketListViewComponent.cs)
- 同样,
BasketList
组件的Invoke()
方法应该将新的视图模型(BasketItemList
类)作为参数传递给View()
方法。return View("Default", new BasketItemList { List = items, IsSummary = isSummary });
列表 33:将新视图模型传递给 View() 方法
- 修改
Basket
视图以提供新的isSummary
参数。<vc:basket-list items="@items" is-summary="false"></vc:basket-list>
列表 34:向视图组件标签助手添加新的 is-summary 参数 (/Views/Basket/Index.cshtml)
请注意,我们在这里硬编码了
isSummary
,因为Basket
视图必须始终以其完整模式,而不是摘要模式显示BasketList
视图组件。 - 修改
BasketList
Default
视图以使用BasketItemList
类作为模型。@using MVC.Models.ViewModels @addTagHelper *, MVC @model BasketItemList;
列表 35:BasketList Default 视图 (Views\Basket\Components\BasketList\Default.cshtml)
- 修改
BasketList
视图组件标签助手以提供新的isSummary
参数。<vc:basket-item item="@item" is-summary="Model.IsSummary"></vc:basket-item>
......并修改同一文件的其余部分以反映新的模型。
<div class="card-body"> @foreach (var item in Model.List) { <vc:basket-item item="@item" is-summary="@Model.IsSummary"></vc:basket-item> } </div> <div class="card-footer"> <div class="row"> <div class="col-sm-10"> <span numero-items> Total: @Model.List.Count item@(Model.List.Count > 1 ? "s" : "") </span> </div> <div class="col-sm-2"> Total: <span class="pull-right" total> @Model.List.Sum(item => item.Quantity * item.UnitPrice).ToString("C")) </span> </div> </div> </div>
列表 36:向视图组件标签助手添加新的 is-summary 参数 (/Views/BasketItem/Index.cshtml)
- 运行应用程序,并确保购物车视图正确显示数据。
- 现在,让我们重用
BasketList
视图组件到结账视图。首先,让我们修改该结账视图以提供一些虚拟的购物车列表数据。@using MVC.Controllers @addTagHelper *, MVC @model string @{ ViewData["Title"] = "Checkout"; var email = "alice@smith.com"; List<BasketItem> items = new List<BasketItem> { new BasketItem { Id = 1, ProductId = 1, Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 }, new BasketItem { Id = 2, ProductId = 5, Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 }, new BasketItem { Id = 3, ProductId = 9, Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 } }; }
列表 37:添加摘要数据到结账视图 (/Views/Checkout/Index.cshtml)
现在,让我们在标记代码后面附加以下内容,以使用
Summary
模式开启实现BasketList
视图组件。<h4>Summary</h4> <vc:basket-list items="@items" is-summary="true"></vc:basket-list>
列表 38:将摘要购物车视图组件添加到结账视图 (/Views/Checkout/Index.cshtml)
- 再次运行应用程序,填写注册表单并验证
Checkout
视图。不幸的是,这会产生一个异常。An unhandled exception occurred while processing the request. InvalidOperationException: The view 'Components/BasketList/Default' was not found. The following locations were searched: /Views/Checkout/Components/BasketList/Default.cshtml /Views/Shared/Components/BasketList/Default.cshtml /Pages/Shared/Components/BasketList/Default.cshtml
为什么会发生这个异常?问题在于调用视图位于Checkout文件夹内,该文件夹不包含/Components/BasketList/Default.cshtml路径下的文件。我们可以通过重构我们的应用程序并将每个与购物车相关的视图组件移动到/Views/Shared项目文件夹下来解决这个问题。
图 5:在 Checkout 视图中显示的 Basket List 视图组件的摘要模式
修复 IBasketService 的所有测试
到目前为止,我们一直在处理虚拟数据,声明和初始化将最终由视图用于渲染和显示用户界面的变量,例如在Catalog、Basket 和 Checkout视图中。
然而,随着我们在这个系列文章中进展,我们将逐步进入一个更现实的场景,即这些数据由一组服务提供,这些服务可能会从某种数据库或 Web 服务检索数据。
因此,从现在开始,我们将删除声明/初始化虚拟数据的这些行,并用对专用服务的请求替换它们。
首先,我们创建一个/Services文件夹。在这里,我们将放置我们的服务类的接口和具体实现。
其次,我们创建一个名为IBasketService
的新接口。这个接口提供了返回购物车项目集合的方法的“契约”。
public interface IBasketService
{
List<BasketItem> GetBasketItems();
}
列表 39:新的 IBasketService 接口 (/Services/IBasketService.cs)
然后,我们实现继承自IBasketService
的具体类,但从GetBasketItems()
方法返回虚拟数据。
public class BasketService : IBasketService
{
public List<BasketItem> GetBasketItems()
{
return new List<BasketItem>
{
new BasketItem { Id = 1, ProductId = 1,
Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 },
new BasketItem { Id = 2, ProductId = 5,
Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 },
new BasketItem { Id = 3, ProductId = 9,
Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 }
};
}
}
列表 40:新的 BasketService 类 (/Services/BasketService.cs)
您可能在想“数据库在哪里?”我们仍然没有处理持久化/数据库逻辑。这需要大量工作,并且会使本文的重点模糊。但在接下来的文章中,将有足够的时间来实现我们所需的数据检索/持久化。
为了在我们的应用程序中使用这项服务,我们可以在控制器/视图组件中创建我们的服务类实例,然后使用它们,从而在这些控制器/组件和我们的服务之间创建依赖关系。但我们不是直接创建实例,而是采用依赖注入 (DI) 设计模式。依赖注入意味着组件通过构造函数参数明确描述它所依赖的服务,但是任何服务的实例都是在创建它的组件之外创建的,也就是说,每个实例都是在依赖注入容器中创建的,它是 ASP.NET Core 的内置组件。因此,我们避免了通过 new 运算符创建实例,并依赖于依赖注入容器为我们创建实例。
让我们配置BasketService
类的依赖注入,添加一个瞬态服务。所谓“瞬态”,意味着每次组件需要它时都应该创建一个新实例。也就是说,永远不会重用服务实例。
...
using MVC.Services;
...
public void ConfigureServices(IServiceCollection services)
{
...
services.AddTransient<IBasketService, BasketService>();
...
}
...
列表 41:Startup 类中添加的新行 (MVC/Startup.cs)
现在,我们修改BasketListViewComponent
,使其依赖于IBasketService
实例。
using MVC.Services;
.
.
.
private readonly IBasketService basketService;
public BasketListViewComponent(IBasketService basketService)
{
this.basketService = basketService;
}
我们还将删除List
items 参数,因为这些数据现在将来自basketService
对象。
public IViewComponentResult Invoke(bool isSummary)
{
List<BasketItem> items = basketService.GetBasketItems();
.
.
.
列表 42:通过依赖注入使用 IBasketService
请注意,public BasketListViewComponent(IBasketService basketService)
构造函数需要一个接口类型的参数,而不是具体的类类型。这是可取的,因为我们应该尽可能“面向接口编程”。依赖注入容器将使用我们之前定义的应用程序配置,以根据给定的接口发现应该实例化哪个具体类。
现在我们从 Catalog index 视图中删除这些行:
//List<BasketItem> items = new List<BasketItem>
//{
// new BasketItem { Id = 1, ProductId = 1,
Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 },
// new BasketItem { Id = 2, ProductId = 5,
Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 },
// new BasketItem { Id = 3, ProductId = 9,
Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 }
//};
然后更改此行以删除items
属性...
<!--REMOVE OR COMMENT OUT THIS LINE-->
<!--<vc:basket-list items="@items" is-summary="false"></vc:basket-list>-->
<vc:basket-list is-summary="false"></vc:basket-list>
列表 43:不带items
属性的视图组件标签助手
还从结账视图中删除这些行:
//List<BasketItem> items = new List<BasketItem>
//{
// new BasketItem { Id = 1, ProductId = 1,
Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 },
// new BasketItem { Id = 2, ProductId = 5,
Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 },
// new BasketItem { Id = 3, ProductId = 9,
Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 }
//};
并更改此行以删除items
属性...
<!--REMOVE OR COMMENT OUT THIS LINE-->
<!--<vc:basket-list items="@items" is-summary="true"></vc:basket-list>-->
<vc:basket-list is-summary="true"></vc:basket-list>
列表 44:不带items
属性的视图组件标签助手
此时,我们通常只会运行应用程序来检查一切是否顺利,但不幸的是,编译器报告了一些我们必须先纠正的错误。
单元测试中的 Mocking
目前,我们的测试正在调用Invoke()
方法并将一个 items 集合作为参数传递。
var result = vc.Invoke(items);
.
.
.
var result = vc.Invoke(new List<BasketItem>());
然而,我们之前已经从Invoke()
方法中删除了 items 参数。所以让我们也从方法调用中删除它。
var result = vc.Invoke();
.
.
.
var result = vc.Invoke();
但是现在BasketListViewComponent
类有一个新的IBasketService
构造函数参数,而测试还没有提供。我们可以简单地提供BasketService
类的一个新实例并将其作为构造函数的参数传递,但在单元测试的 Arrange 部分使用具体类实例是一种不好的做法。我们应该通过一种称为“mocking”的技术来提供这种依赖。mock
是一个对象,它在单元测试中替换被测对象的某些依赖项。这使得测试条件更可控且自成一体。
我们将引入mock
对象来为所需位置的IBasketService
接口提供一个替代品。
市面上有许多 .NET 兼容的 Mock 框架,我们正在使用 Moq 库,这是一个流行的 .NET Core Mock 框架。
通过工具 > Nuget 包管理器 > 包管理器控制台,通过命令行安装Moq库。
Install-Package Moq -Version 4.10.1
现在我们在BasketListViewComponentTest
类中添加命名空间引用。
添加这些行:
using Moq;
using MVC.Services;
列表 45:BasketListViewComponentTest.cs 文件
目前,Arrange 部分看起来像:
//arrange
var vc = new BasketListViewComponent();
List<BasketItem> items =
new List<BasketItem>
{
new BasketItem { Id = 1, ProductId = 1,
Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 },
new BasketItem { Id = 2, ProductId = 5,
Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 },
new BasketItem { Id = 3, ProductId = 9,
Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 }
};
列表 46:BasketListViewComponentTest.cs 文件
但是使用 Moq,我们引入了一个新的 mock 对象,称为basketServiceMock
,使用泛型Mock
类。
//arrange
Mock<IBasketService> basketServiceMock =
new Mock<IBasketService>();
var vc = new BasketListViewComponent();
List<BasketItem> items =
new List<BasketItem>
{
new BasketItem { Id = 1, ProductId = 1,
Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 },
new BasketItem { Id = 2, ProductId = 5,
Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 },
new BasketItem { Id = 3, ProductId = 9,
Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 }
};
现在,我们可以很大程度上控制这个mock
对象。这非常有用,因为我们不再依赖于BasketService
类的具体实现来为我们提供数据。相反,我们配置GetBasketItems()
方法来返回我们在单元测试中早期初始化的确切 items 对象。我们通过.Setup()
方法配置方法的返回。
basketServiceMock.Setup(m => m.GetBasketItems())
.Returns(items);
现在我们可以轻松地将mock
对象作为参数传递给被测类的构造函数。
var vc = new BasketListViewComponent(basketServiceMock.Object);
这是完整的 Arrange 部分:
//arrange
Mock<IBasketService> basketServiceMock =
new Mock<IBasketService>();
List<BasketItem> items =
new List<BasketItem>
{
new BasketItem { Id = 1, ProductId = 1,
Name = "Broccoli", UnitPrice = 59.90m, Quantity = 2 },
new BasketItem { Id = 2, ProductId = 5,
Name = "Green Grapes", UnitPrice = 59.90m, Quantity = 3 },
new BasketItem { Id = 3, ProductId = 9,
Name = "Tomato", UnitPrice = 59.90m, Quantity = 4 }
};
basketServiceMock.Setup(m => m.GetBasketItems())
.Returns(items);
var vc = new BasketListViewComponent(basketServiceMock.Object);
列表 47:BasketListViewComponentTest 的 Invoke_With_Items_Should_Display_Default_View 方法的 Arrange 部分
同样,我们也为另一个方法(Invoke_Without_Items_Should_Display_Empty_View()
)实现了mock
对象,但这次,我们让Setup()
方法返回一个空列表。
//arrange
Mock<IBasketService> basketServiceMock =
new Mock<IBasketService>();
basketServiceMock.Setup(m => m.GetBasketItems())
.Returns(new List<BasketItem>());
var vc = new BasketListViewComponent(basketServiceMock.Object);
列表 48:使用 mock 对象对 BasketListViewComponent 进行操作
再次运行测试,所有测试都将毫无问题地通过。
这意味着我们的 Moq 对象被正确实现、配置和使用。
运行应用程序,我们看到由新的BasketService
类提供的购物车列表数据。
替换 Catalog 部分视图为视图组件
现在,让我们再重构一批部分视图,以将它们替换为视图组件。这次,由于我们已经知道动机并且在购物车部分视图的情况下已经做过一次,我们将更快地展示步骤序列,而不会过多详细说明。
这些更改的动机是为了保持 Razor 标记文件(.cshtml)更简洁、更小且更易于测试。
为 Categories 创建视图组件
在本节中,我们将Catalog
标记文件(Views/Catalog/_Categories.cshtml)的最外层替换为视图组件。
- 首先,让我们创建
CategoriesViewComponent
类。public class CategoriesViewComponent : ViewComponent { public CategoriesViewComponent() { } public IViewComponentResult Invoke(List<Product> products) { return View("Default", products); } }
列表 49:新的 CategoriesViewComponent 类 (/ViewComponents/CategoriesViewComponent.cs)
- 然后我们将Views/Catalog/_Categories.cshtml文件移动到/Catalog/Components/Categories/位置,然后将其重命名为Default.cshtml。
- 接下来,我们在
/Views/Catalog/Index.cshtml
addTagHelper
指令。@addTagHelper *, MVC @model List<Product>;
- 另外,我们替换部分标签助手...
<partial name="_Categories" for="@Model" />
......用
Categories
视图组件标签助手。<vc:categories products="@Model"></vc:categories>
为 ProductCard 创建视图组件
在本节中,我们将
Catalog
标记的内层替换为用于显示产品卡的视图组件。 - 我们从在/MVC/ViewComponents/ProductCardViewComponent.cs文件中创建一个新类开始。
public class ProductCardViewComponent : ViewComponent { public ProductCardViewComponent() { } public IViewComponentResult Invoke(Product product) { return View("Default", product); } }
列表 50:新的 ProductCardViewComponent 类 (/ViewComponents/ProductCardViewComponent.cs)
- 然后我们在/Views/Catalog/Components/Categories/Default.cshtml中添加
addTagHelper
指令。@addTagHelper *, MVC @model List<Product>;
- 接下来,我们替换这个
foreach
指令...foreach (var productIndex in productsInPage) { <partial name="_ProductCard" for="@productIndex" /> }
......用
ProductCard
视图组件的foreach
指令。foreach (var product in productsInPage) { <vc:product-card product="@product"></vc:product-card> }
- 然后,我们将/MVC/Views/Catalog/_ProductCard.cshtml
为 CarouselPage 创建视图组件
每个类别都显示在一个不同的轮播控件中,每组四个产品,我们称之为“轮播页”。这里,我们展示了如何为
carousel
页面创建一个视图组件。- 首先,我们创建文件
/Models/ViewModels/CarouselPageViewModel.cs。
public class CarouselPageViewModel { public CarouselPageViewModel() { } public CarouselPageViewModel(List<Product> products, int pageIndex) { Products = products; PageIndex = pageIndex; } public List<Product> Products { get; set; } public int PageIndex { get; set; } }
列表 51:新的 CarouselPageViewModel 类 (/Models/ViewModels/CarouselPageViewModel.cs)
- 首先,我们创建文件
- 然后我们创建视图模型文件在/MVC/Models/ViewModels/CarouselViewModel.cs。
public class CarouselViewModel { public CarouselViewModel() { } public CarouselViewModel(Category category, List<Product> products, int pageCount, int pageSize) { Category = category; Products = products; PageCount = pageCount; PageSize = pageSize; } public Category Category { get; set; } public List<Product> Products { get; set; } public int PageCount { get; set; } public int PageSize { get; set; } }
列表 52:新的 CarouselViewModel 类 (/Models/ViewModels/CarouselViewModel.cs)
- 接下来,我们在/MVC/Models/ViewModels/CategoriesViewModel.cs创建另一个视图模型。
public class CategoriesViewModel { public CategoriesViewModel() { } public CategoriesViewModel (List<Category> categories, List<Product> products, int pageSize) { Categories = categories; Products = products; PageSize = pageSize; } public List<Category> Categories { get; set; } public List<Product> Products { get; set; } public int PageSize { get; set; } }
列表 53:新的 CategoriesViewModel 类 (/Models/ViewModels/CategoriesViewModel.cs)
- 只有这样,我们才创建视图组件类,在/ViewComponents/CarouselPageViewComponent.cs。
public class CarouselPageViewComponent : ViewComponent { public CarouselPageViewComponent() { } public IViewComponentResult Invoke(List<Product> productsInCategory, int pageIndex, int pageSize) { var productsInPage = productsInCategory .Skip(pageIndex * pageSize) .Take(pageSize) .ToList(); return View("Default", new CarouselPageViewModel(productsInPage, pageIndex)); } }
列表 54:新的 CarouselPageViewComponent 类 (/ViewComponents/CarouselPageViewComponent.cs)
- 并且我们创建另一个视图组件类在/MVC/ViewComponents/CarouselViewComponent.cs。这个组件负责分组所有轮播页。
public class CarouselViewComponent : ViewComponent { public CarouselViewComponent() { } public IViewComponentResult Invoke (Category category, List<Product> products, int pageSize) { var productsInCategory = products .Where(p => p.Category.Id == category.Id) .ToList(); int pageCount = (int)Math.Ceiling((double)productsInCategory.Count() / pageSize); return View("Default", new CarouselViewModel(category, productsInCategory, pageCount, pageSize)); } }
列表 55:新的 CarouselViewComponent 类 (/ViewComponents/CarouselViewComponent.cs)
请注意上面代码段中我们如何将 C# 代码从 razor catalog 视图移动到视图组件。这允许我们保持标记更简洁、更小。
- 现在我们修改
/ViewComponents/CategoriesViewComponent.cs以包含关于在视图 Razor 标记中存在的分页逻辑的 C# 代码。
public class CategoriesViewComponent : ViewComponent { const int PageSize = 4; public CategoriesViewComponent() { } public IViewComponentResult Invoke(List<Product> products) { var categories = products .Select(p => p.Category) .Distinct() .ToList(); return View("Default", new CategoriesViewModel(categories, products, PageSize)); } }
列表 56:更新后的 CategoriesViewComponent 类 (/ViewComponents/CategoriesViewComponent.cs)
- 现在是时候实现与视图组件相关的标记文件了。
- 我们创建的第一个文件在:/Views/Catalog/Components/Carousel/Default.cshtml。这个视图组件标记包含一个单独的 Bootstrap Carousel 组件,对应一个类别。
@using MVC.Models.ViewModels @addTagHelper *, MVC @model CarouselViewModel <h3>@Model.Category.Name</h3> <div id="carouselExampleIndicators-@Model.Category.Id" class="carousel slide" data-ride="carousel"> <div class="carousel-inner"> @{ for (int pageIndex = 0; pageIndex < Model.PageCount; pageIndex++) { <vc:carousel-page products-in-category="@Model.Products" page-index="@pageIndex" page-size="@Model.PageSize"> </vc:carousel-page> } } </div> <a class="carousel-control-prev" href="#carouselExampleIndicators-@Model.Category.Id" role="button" data-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="#carouselExampleIndicators-@Model.Category.Id" role="button" data-slide="next"> <span class="carousel-control-next-icon" aria-hidden="true"></span> <span class="sr-only">Next</span> </a> </div>
列表 57:新的 Carousel/Default 视图 (/Views/Catalog/Components/Carousel/Default.cshtml)
- 然后我们创建一个新的标记文件在/Catalog/Components/CarouselPage/Default.cshtml。
- 它对应一个
Carousel
页,即一组四个产品。@using MVC.Models.ViewModels @addTagHelper *, MVC @model CarouselPageViewModel <div class="carousel-item @(Model.PageIndex == 0 ? "active" : "")"> <div class="container"> <div class="row"> @{ foreach (var product in Model.Products) { <vc:product-card product="@product"></vc:product-card> } } </div> </div> </div>
列表 58:新的 CarouselPage/Default 视图 (/Views/Catalog/Components/CarouselPage/Default.cshtml)
- 现在我们修改类别视图组件的视图标记文件,在Views/Catalog/Components/Categories/Default.cshtml。现在这个文件变得更干净、更易读,正如我们所见。
@using MVC.Models.ViewModels @addTagHelper *, MVC @model CategoriesViewModel <div class="container"> @foreach (var category in Model.Categories) { <vc:carousel category="@category" products="@Model.Products" page-size="@Model.PageSize"></vc:carousel> } </div>
列表 59:更新后的 Categories/Default 标记文件 (/Views/Catalog/Components/Categories/Default.cshtml)
用户通知计数器
视图组件通常从布局页面调用。这是因为布局页面允许组件显示在应用程序的多个视图中。也就是说,使用视图组件,您的 Web 应用程序可以拥有可重用的渲染逻辑,否则这些逻辑会使您的控制器、视图或部分视图变得杂乱。视图组件的典型用例包括:
- 导航菜单
- 登录面板
- 购物车
- 侧边栏内容/菜单 与部分视图不同,视图组件可以提供一个自包含的黑盒子,其业务逻辑独立于其插入的视图。
在接下来的部分中,我们将使用视图组件来渲染显示以下内容的导航栏图标:
- 用户通知计数
- 购物车项目计数
创建导航栏通知图标
再次,我们将使用Font Awesome来显示我们应用程序的图标。
我们首先创建用户通知图标的 HTML 元素。
<div class="navbar-collapse collapse justify-content-end">
<ul class="nav navbar-nav">
<li>
<div class="container-notification">
<a asp-controller="notifications"
title="Notifications">
<div class="user-count notification show-count fa fa-bell"
data-count="2">
</div>
</a>
</div>
</li>
<li>
<span>
</span>
</li>
<li>
<div class="container-notification">
<a asp-action="index" asp-controller="basket"
title="Basket">
<div class="user-count userbasket show-count fa fa-shopping-cart"
data-count="3">
</div>
</a>
</div>
</li>
</ul>
</div>
列表 60:通知栏中的通知元素 (/Views/Shared/_Layout.cshtml)
请注意,两个通知具有几乎相同的 HTML 元素。稍后,我们将重构它们以消除这种重复。
运行应用程序,这两个图标都显示在任何应用程序页面的右上角。这是因为布局标记包含跨多个视图共享的元素。
现在,我们通过将以下片段添加到site.css来配置计数器的数字样式。
/*change the default link color from blue to black*/
.user-count::before,
.user-count::after {
color: #000;
}
/*create a yellow circle for the count number*/
.user-count::after {
font-family: Arial;
font-size: 0.7em;
font-weight: 700;
position: absolute;
top: -10px;
right: -10px;
padding: 4px 6px;
line-height: 100%;
border-radius: 60px;
background: #ffcc00;
opacity: 0;
content: attr(data-count);
opacity: 0;
-webkit-transform: scale(0.5);
transform: scale(0.5);
transition: transform, opacity;
transition-duration: 0.3s;
transition-timing-function: ease-out;
}
/*define the circle to be as large as the icon*/
.user-count.show-count::after {
-webkit-transform: scale(1);
transform: scale(1);
opacity: 1;
}
列表 61:包含用户计数控件样式的级联样式表 (/wwwroot/css/site.css)
现在我们可以看到通知数字插入在一个黄色的圆圈中,根据我们添加的新 CSS 样式。
图 6:已应用样式的通知图标
创建 UserCounter 视图组件
我们知道每个viewcomponent
通常有:
- 一个
ViewComponent
类 - 一个 Default 标记文件
- 一个模型
让我们先实现模型。在这种情况下,我们正在创建一个新的UserCountViewModel
,它将保存通知计数器使用的数据。
- 控制器名称
- 标题(工具提示文本)
- CSS 类
- 图标(Font Awesome 图标类)
- Count
public class UserCountViewModel
{
public UserCountViewModel(string title, string controllerName,
string cssClass, string icon, int count)
{
Title = title;
ControllerName = controllerName;
CssClass = cssClass;
Icon = icon;
Count = count;
}
public string ControllerName { get; set; }
public string Title { get; set; }
public string CssClass { get; set; }
public string Icon { get; set; }
public int Count { get; set; }
}
列表 62:新的 UserCountViewModel 类 (/Models/ViewModels/UserCountViewModel.cs)
像往常一样,视图组件需要一个视图组件类。
public class UserCounterViewComponent : ViewComponent
{
public UserCounterViewComponent()
{
}
public IViewComponentResult Invoke
(string title, string controllerName, string cssClass, string icon, int count)
{
var model = new UserCountViewModel(title, controllerName, cssClass, icon, count);
return View("Default", model);
}
}
列表 63:新的 UserCounterViewComponent 类 (/ViewComponents/UserCounterViewComponent.cs)
请注意上面的视图组件类如何接受视图模型所需的许多参数。
@using MVC.Models.ViewModels
@addTagHelper *, MVC
@model UserCountViewModel;
<div class="container-notification">
<a asp-controller="@Model.ControllerName"
title="@Model.Title">
<div class="user-count @(Model.CssClass)
show-count fa fa-@(Model.Icon)" data-count="@(Model.Count)">
</div>
</a>
</div>
列表 64:新的 UserCounter/Default 标记文件 (/Views/Shared/Components/UserCounter/Default.cshtml)
现在是时候将我们的UserCounter
视图组件标签助手应用于布局页面了。但在此之前,我们必须删除以下现有行...
<div class="container-notification">
<a asp-controller="notifications"
title="Notifications">
<div class="user-count notification show-count fa fa-bell" data-count="2">
</div>
</a>
</div>
.
.
.
<div class="container-notification">
<a asp-action="index" asp-controller="basket"
title="Basket">
<div class="user-count userbasket show-count fa fa-shopping-cart" data-count="3">
</div>
</a>
</div>
......并用这些行替换它们。
@addTagHelper *, MVC
.
.
.
<vc:user-counter
title="Notifications"
controller-name="notifications"
css-class="notification"
icon="bell"
count="2">
</vc:user-counter>
.
.
.
<vc:user-counter
title="Basket"
controller-name="basket"
css-class="basket"
icon="shopping-cart"
count="3">
</vc:user-counter>
.
.
.
列表 65:UserCounter 标签助手已添加到布局文件 (/Views/Shared/_Layout.cshtml)
从上面的标记可以看出,我们的布局页面变得更简洁、更易读。
再次运行我们的应用程序,我们可以确保视图组件已成功替换旧的 HTML 元素,而不会破坏布局。
图 7:由视图组件渲染的通知图标
创建 UserCounterService
在本篇文章的前面,我们展示了如何为购物车视图组件创建一个服务。该服务必须首先在Startup
类中配置,以便任何服务接口类型的参数都可以作为相应的具体类实现提供。
现在我们将为用户通知组件创建一个类似的服务类和接口,遵循相同的步骤。首先,让我们创建一个接口,其中包含两个方法:每个方法检索一个不同的计数号。
public interface IUserCounterService
{
int GetBasketCount();
int GetNotificationCount();
}
列表 66:新的 IUserCounterService 接口 (/Services/IUserCounterService.cs)
然后将创建UserCounterService
类来提供具体类。
public class UserCounterService : IUserCounterService
{
public int GetNotificationCount()
{
return 7;
}
public int GetBasketCount()
{
return 9;
}
}
列表 67:新的 UserCounterService 类 (/Services/UserCounterService.cs)
请注意计数号是如何硬编码的。不用担心,在接下来的文章中,我们将有足够的时间来实现此功能的业务规则和数据库逻辑。
接下来,我们配置服务的依赖注入规则。在这种情况下,任何IUserCounterService
参数都将通过 ASP.NET Core 的内置依赖注入容器作为UserCounterService
类的实例提供。
services.AddTransient<IUserCounterService, UserCounterService>();
列表 68:新的依赖注入说明 (/Startup.cs)
请注意,我们有两种类型的通知,但只有一个视图组件。因此,我们必须使用某种代码来区分它们。我们将创建一个新的UserCounterType
枚举,以便对我们的用户计数器类型进行编码。
public enum UserCounterType
{
Notification = 1,
Basket = 2
}
列表 69:新的 UserCounterType 枚举 (/ViewComponents/UserCounterViewComponent.cs)
现在,我们可以重构UserCounterViewComponent
类,将IUserCounterService
作为构造函数参数传递,并修改Invoke()
方法以接受UserCounterType
参数。
protected readonly IUserCounterService userCounterService;
public UserCounterViewComponent(IUserCounterService userCounterService)
{
this.userCounterService = userCounterService;
}
.
.
.
public IViewComponentResult Invoke(string title, string controllerName,
string cssClass, string icon, UserCounterType userCounterType)
{
int count = 0;
if (userCounterType == UserCounterType.Notification)
{
count = userCounterService.GetNotificationCount();
}
else if (userCounterType == UserCounterType.Basket)
{
count = userCounterService.GetBasketCount();
}
...
列表 70:修改 UserCounterViewComponent 类以使用枚举 (/ViewComponents/UserCounterViewComponent.cs)
现在,我们必须重构布局文件中的UserCounter
视图组件标签助手,删除count
属性并提供新的 user-counter-type 属性。
<vc:user-counter title="Notifications"
controller-name="notifications"
css-class="notification"
icon="bell"
user-counter-type="Notification">
</vc:user-counter>
.
.
.
<vc:user-counter title="Basket"
controller-name="basket"
css-class="basket"
icon="shopping-cart"
user-counter-type="Basket">
</vc:user-counter>
列表 71:带有适当 UserCounterType 枚举的用户计数器标签助手 (/Views/Shared/_Layout.cshtml)
创建 NotificationCounter、BasketCounter 子类
在前一节中,我们学习了如何创建一个具有双重行为的单个视图组件:它可以作为用户通知计数器或购物车计数器使用。
然而,使用编码类型通常需要大量使用条件结构,如if
和switch
,这被认为是一种“代码异味”,换句话说,是一种糟糕的编程实践,因为它违背了面向对象编程的目的。即使代码中没有很多if
/switch
指令,它也可以被视为 OOP 利用不足的情况,因为这种情况表明代码应该重构和多态。
有一个已知技术称为用子类替换类型代码,我们将在这里应用它。
它包括为每个不同的行为组创建不同的子类,从而我们可以消除编码类型和if
/switch
语句的使用。
让我们首先将UserCounterViewComponent
设为abstract
类。这样,我们可以避免直接实例化,迫使开发人员从继承自UserCounterViewComponent
超类的类创建对象。
public abstract class UserCounterViewComponent : ViewComponent
{
protected readonly IUserCounterService userCounterService;
public UserCounterViewComponent(IUserCounterService userCounterService)
{
this.userCounterService = userCounterService;
}
protected IViewComponentResult Invoke
(string title, string controllerName, string cssClass, string icon, int count)
{
var model = new UserCountViewModel(title, controllerName, cssClass, icon, count);
return View("~/Views/Shared/Components/UserCounter/Default.cshtml", model);
}
}
列表 72:UserCounterViewComponent 类成为超类 (/ViewComponents/UserCounterViewComponent.cs)
现在我们创建一个新的NotificationCounterViewComponent
类,继承自UserCounterViewComponent
。您可以看到我们是如何消除编码类型和if
语句的使用。
public class NotificationCounterViewComponent : UserCounterViewComponent
{
public NotificationCounterViewComponent(IUserCounterService userCounterService) :
base(userCounterService) { }
public IViewComponentResult Invoke(string title, string controllerName,
string cssClass, string icon)
{
int count = userCounterService.GetNotificationCount();
return Invoke(title, controllerName, cssClass, icon, count);
}
}
列表 73:更新以使 NotificationCounterViewComponent 类成为子类
同样,BasketCounterViewComponent
类也必须继承自基类。
public class BasketCounterViewComponent : UserCounterViewComponent
{
public BasketCounterViewComponent(IUserCounterService userCounterService) :
base(userCounterService) { }
public IViewComponentResult Invoke(string title, string controllerName,
string cssClass, string icon)
{
int count = userCounterService.GetBasketCount();
return Invoke(title, controllerName, cssClass, icon, count);
}
}
列表 74:更新以使 BasketCounterViewComponent 类成为子类
现在是时候重新编译项目并用专门的视图组件标签助手替换标签助手了。
<vc:notification-counter
title="Notifications"
controller-name="notifications"
css-class="notification"
icon="bell">
</vc:notification-counter>
.
.
.
<vc:basket-counter
title="Basket"
controller-name="basket"
css-class="basket"
icon="shopping-cart">
</vc:basket-counter>
列表 75:用专门的计数器标签助手替换旧的 UserCounter 标签助手 (/Views/Shared/_Layout.cshtml)
最后,我们再次运行应用程序以检查新视图组件是否已正确渲染。
图 8:由子类视图组件渲染的通知图标
结论
我们在本文中看到了如何使用 ASP.NET Core 2.2+ 的视图组件。我们首先将我们之前的方法与视图组件的优势进行了比较,讨论了我们如何受益于一系列重构和升级到视图组件。
文章继续为视图组件提供了业务逻辑,并且由于使用了单元测试,这些组件规则得到了验证。我们使用 xUnit 测试框架创建了一个简单的测试项目,使用了 Arrange/Act/Assert 方法,以及 Moq 框架提供的 Mock 对象。
我们了解到视图组件具有专用的 C# 类,该类可以接收参数,并受益于依赖注入技术。由于内置的 ASP.NET Core 依赖注入机制,可以为组件构造函数提供服务。我们已经看到视图组件是如何嵌套的,以便同一视图的不同层可以由不同的组件显示。
最后,我们看到视图组件如何用于创建自包含的、与视图无关的组件,这些组件托管在应用程序布局页面上,以便在应用程序的多个视图中显示。此外,我们探讨了多态性和继承与视图组件,表明这项技术即使在应用程序视图层也有助于强制执行良好的编程实践。
就是这样!这样我们就完成了文章系列的第二部分。如果您看到了这里,非常感谢您的耐心。如果您喜欢这篇文章,或者有任何投诉或建议,请在下方留言。我很乐意收到您的反馈!
历史
- 2019年5月1日:初始版本