目录
文章系列
- ASP.NET核心之路微服务第01部分:构建视图
- ASP.NET核心之路微服务第02部分:查看组件
- ASP.NET核心之路微服务第03部分:ASP.NET核心身份
- 第04部分:SQLite
- 第05部分:Dapper
- 第06部分:SignalR
- 第07部分:单元测试Web API
- 第08部分:单元测试Web MVC应用程序
- 第09部分:监测健康检查
- 第10部分:Redis数据库
- 第11部分:IdentityServer4
- 第12部分:订购Web API
- 第13部分:Basket Web API
- 第14部分:Catalog Web API
- 第15部分:具有Polly的弹性HTTP客户端
- 第16部分:使用Swagger记录Web API
- 第17部分:Docker容器
- 第18部分:Docker配置
- 第19部分:使用Kibana进行中心日志
介绍
欢迎阅读“ASP.NET核心路线图微服务”系列文章的第二部分。
在上一篇文章中,我们了解了如何使用视图和部分视图构建电子商务应用程序的基本视图。今天我们将探讨 ASP.NET Core 中View组件的主题 。
什么是视图组件?他们如何与部分视图比较 ?它们如何应用于我们的电子商务项目?
部分视图vs. 视图组件
我们在上一篇文章中介绍的部分视图足以执行电子商务应用程序的视图合成角色。
我们已经看到部分视图如何允许我们将大型标记文件分解为更小的组件,并减少标记文件中常见标记内容的重复。
视图组件(View Component)是ASP.NET Core引入的概念,类似于部分视图。虽然视图组件在分解大视图和减少重复方面与部分视图一样强大,但它们的构建方式却不同,并且功能更强大。
部分视图与常规视图一样,使用 模型绑定,即必须由特定控制器操作提供的模型数据。另一方面,视图组件仅取决于作为参数提供给它们的数据。
虽然我们在基于控制器和视图的电子商务应用程序中实现视图组件,但也可以为Razor Pages开发视图组件 。
用视图组件替换购物篮(Basket) 部分视图
在上一篇文章中,我们将购物篮(Basket)视图拆分为较小的部分视图,我们可以在下面的文件夹结构中看到:
图1:与购物篮(Basket)相关的部分视图
这些标记文件中的每一个都负责在购物篮(Basket)视图中呈现不同的元素层:
- Basket/Index (视图)
- 购物篮(Basket)控制(部分视图)
- 购物篮(Basket)列表(部分视图)
- 购物篮(Basket)项目(部分视图)
<partial name="_BasketControls" />
<h3>My Basket</h3>
<partial name="_BasketList" for="@items" />
<br />
<partial name="_BasketControls" />
清单1:如何使用部分视图的示例 (\Views\Basket\Index.cshtml)
但部分视图在某种程度上是有限的,并且不允许我们可以在视图组件中找到一些有趣的功能,例如:
- 独立于托管视图的行为
- 分离与控制器/视图类似的问题
- 参数
- 商业逻辑
- 可测性
但是这些不错的功能也意味着我们还需要做更多工作才能创建视图组件。除了标记文件,我们还必须为视图组件创建一个专用类。但这必须位于 ViewComponents文件夹中,我们必须先创建该文件夹。
现在让我们在ViewComponents文件夹中创建一个名为BasketListViewComponent 的类 。
这个类只需要调用并返回Default 视图的Invoke()方法 :
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
,然后将其作为 标记文件的模型传递 :
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 :using属性设置视图组件名称
现在让我们为组件创建标记(视图)文件。首先,我们必须在\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)
现在让我们更新一下购物篮(Basket)视图,用视图组件标签助手替换部分视图标签助手。
打开 \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_BasketList ViewComponentTagHelper 名称,它是编译视图组件时自动生成到程序集中的类的名称。
现在让我们为视图组件提供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/BasketList ViewComponent.cs
- Component/BasketList/Default.cshtml
- /Views/Basket/Index.cshtml
- _BasketItem.cshtml
为视图组件类定义逻辑
目前,BasketList视图组件类非常愚蠢的。但是现在我们有了一个新任务来为类实现一个新的业务逻辑:
- 如果list参数为空,则组件必须显示空视图
- 如果list参数包含basket项,则组件必须显示默认视图
单元测试查看组件
与部分视图不同,视图组件允许可测试性。就像任何常规类一样,视图组件类可以进行单元测试。让我们为我们的解决方案添加一个新的单元测试项目。
添加新的单元测试项目
转到文件菜单并选择:*新建>项目>测试> xUnit测试项目(.NET Core) *
图3:添加新的xUnit项目
新的xUnit测试项目总是包含一个空的测试类(UnitTest1):
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
xUnit是Visual Studio附带的单元测试项目模板之一。
[Fact] 属性告诉xUnit框架必须由Test Runner运行无参数测试方法。(我们将在下一节中看到Test Runner)。
由于我们希望测试 BasketListViewComponent类,我们将测试类重命名为 BasketListViewComponentTest:
public class BasketListViewComponentTest
{
[Fact]
public void Test1()
{
}
}
清单10:将测试类重命名为BasketListViewComponentTest
让我们将Test1()方法重命名为表示性的,描述我们正在验证的行为的方法:“使用项调用Invoke()方法应该显示默认视图 ”
public class BasketListViewComponentTest
{
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
}
}
清单11:使用xUnit创建我们的第一个单元测试
作为一种好的做法,每个单元测试方法必须分为3个部分,称为“Arrange-Act-Assert”:
- 单元测试方法的Arrange部分初始化对象并设置传递给被测方法的数据的值。
- Act 部分使用arranged 的参数调用正在测试的方法。
- Assert 部分验证被测试方法的操作是否按预期进行。
让我们在代码中明确介绍这些部分:
public class BasketListViewComponentTest
{
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
//arrange
//act
//assert
}
}
清单12:单元测试的三重A:Arrange,Act和Assert
在编译部分,我们必须初始化对象:
[Fact]
public void Invoke_With_Items_Should_Display_Default_View()
{
//arrange
var vc = new BasketListViewComponent();
//act
//assert
}
在act部分中,我们使用arranged 的参数调用测试中的方法:
[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
清单13:安排测试并调用Invoke()方法
因此,让我们使用arrange部分来声明一个items变量,并用一些购物篮(Basket)项填充它:
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()方法提供参数
现在是时候实现单元测试的断言部分了。断言部分是所有验证发生的地方,以确保测试中的方法按预期运行:
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)来执行它:
测试> Windows>测试资源管理器
或者
Ctrl + E,T
当您第一次打开测试资源管理器(Test Explorer)时,您会看到应用程序的测试结构:
- MVC.Test(程序集)
- MVC.Test.ViewComponents(命名空间)
- BasketList ViewComponentTest(测试类)
- Invoke_With_Items_Should_Display_Default_View(测试方法——Fact)
这个结构非常有助于保持测试资源管理器(Test Explorer)的有序性,同时我们实现了越来越多的测试。否则,测试资源管理器(Test Explorer)可能会因普通列表的数量不断增加而混乱。
当我们单击Run All菜单时,如果需要,将重新编译应用程序,然后xUnit测试框架将执行到目前为止唯一的现有测试:
我们可以看到,测试成功通过。这种执行不允许在测试执行时检查对象,参数,变量等。如果要调试测试的执行,您应该:
1)根据需要在可测试方法中(可能在受影响的其他代码中)放置断点:
2)右键单击测试名称,然后选择Debug Selected Test菜单:
3)现在您可以像调试常规应用程序一样调试执行代码:
此时我们的测试正在进行一个简单的测试,它只是检查结果变量是否包含视图。但这还不够:我们还必须检查结果中的视图是否实际上是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原则 :( 保持简单,愚蠢)。每项测试只能做一件事,且仅一件事。如果单个测试方法正在累积多个职责,则重构它并将其拆分为多个具有单一职责的测试方法。
没有项的购物篮(Basket)组件应显示空视图
是时候实现关于购物篮列表视图组件的第二条规则:
- 如果list参数包含购物篮(Basket)项,则组件必须显示默认视图
在同一个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:测试空购物篮(Basket)的购物篮(Basket)列表视图组件
现在,让我们编译并查看测试资源管理器中显示的新测试:
然后我们运行所有测试,或者我们只运行指定的测试:
注意整个结构如何用失败图标标记,除了我们创建的第一个单元测试,它仍然是绿色的:
只要有可能,首先创建一个测试,然后在业务类中实现规则,直到测试通过。让我们现在就开始做吧。让我们通过测试。
我们应该修改BasketListViewComponent类以包括验证购物篮(Basket)中项数量的条件。如果没有项(items),我们应该返回一个空视图:
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:在空购物篮(Basket)的情况下返回不同的视图
再次运行测试,我们可以注意到一切都通过了:
但是当第二个测试通过时,我们仍然没有针对此购物篮(Basket)列表条件的空视图。我们可以通过 在\MVC\Views\Basket\ 项目文件夹中添加一个新的Empty.cshtml标记文件来解决这个问题 :
<div class="card">
<div class="card-body">
<!--https://getbootstrap.com/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:显示Bootstrap 4警报组件的新Empty.cshtml视图
我们现在可以停止进行单元测试一段时间并开始手动测试,我们尝试模拟空购物篮(Basket)列表。
这里的第一步是注释掉\MVC\Views\Basket\Index.cshtml文件中的BasketItem实例,以便购物篮(Basket)列表为空:
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:注释项以便在运行应用程序时检查警报
再次运行应用程序,我们可以注意到Bootrap Alert组件,显示警告消息:“您的购物篮中还没有商品!点击此处开始购物!”
为BasketItem创建视图组件
不仅购物篮(Basket)列表,而且购物篮(Basket)项部分视图也可以转换为视图组件。
这需要一些步骤,类似于我们之前看到的:
1)在ViewComponents\文件夹下创建一个新的BasketItemViewComponent类
public class BasketItemViewComponent : ViewComponent
{
public BasketItemViewComponent()
{
}
public IViewComponentResult Invoke(BasketItem item)
{
return View("Default", item);
}
}
清单22:新的BasketItemViewComponent类(\ViewComponents\BasketItemViewComponent.cs)
2)在“Components”下创建一个新的BasketItem文件夹
3)将部分视图_BasketItem.cshtml文件移动到文件夹中:Components/BasketItem/
4)将此文件重命名为Default.cshtml
5)更改\Views\Basket\Components\BasketList\Default.cshtml文件以添加@addTagHelper指令:
@addTagHelper *, MVC
清单23:添加@addTagHelper指令
6)删除对_BasketItem部分视图标记助手的引用:
<partial name="_BasketItem" for="@item" />
7)将其替换为新的视图组件标记助手
<vc:basket-item item="@item"></vc:basket-item>
8)这将给我们以下标记:
<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文件
9)现在,重新激活包含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个购物篮(Basket)项 (\MVC\Views\Basket\Index.cshtml)
10)最后一步:删除_BasketList .cshtml部分视图文件。
对视图组件的这种转换产生与部分视图完全相同的结果:
图4:购物篮(Basket)项视图组件截图
单元测试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。
请始终保持每个单元测试尽可能小巧。
运行测试,我们得到结果:所有3个测试都通过了。
红/绿/重构循环
在实现单元测试时,您可以内化为每个单元测试应用红/绿/重构循环的好习惯。
Red/Green/Refactor 是众所周知的敏捷测试模式,包括3个步骤:
- 红色:每个单元测试最初失败。这是个好消息,因为测试中的方法仍然没有实现,测试正在运行并正确检测丢失/损坏的规则。此时,您必须实现或修复测试中的功能。在实现之后,您将运行thet测试,如果它再次失败,这意味着您的实现是错误的,或者测试本身是错误的。将单元测试视为安全网,或作为监督您的业务规则以防止可能出现的错误的监督机构。
- 绿色:一旦实现正确,测试应该通过。这意味着已经实现了单元测试方法的目的。
- 重构:如果您没有单元测试的安全网来保护您免受错误,更改代码总是危险的。这就是为什么在每次测试变为绿色之后完成的原因:现在你有一个很好的机会来重构代码,即通过重命名类/方法/变量,删除不必要的注释,拆分大型方法和类,使代码更具可读性,消除重复的代码,以及为代码增加质量的增强功能。在重构代码之后,必须再次运行测试以确保一切仍然完美。
只有在重构步骤之后,您才会进入下一个单元测试业务规则并启动新单元测试的“红色”步骤。
启用摘要模式
目前,我们的BasketItem视图组件仅显示Default标记,这意味着该项目包含添加/删除按钮和允许直接更新数量的输入框。
但是,新业务规则要求BasketItem视图组件应准备以只读样式显示信息。我们在此列出了此新功能所需的更改。
1)向Invoke()方法添加一个新的布尔isSummary参数。此参数仅指示组件样式是摘要(只读)还是不启用(启用数量,完整模式)
public IViewComponentResult Invoke(BasketItem item, bool isSummary = false)
清单27:向Invoke()方法添加isSummary参数(ViewComponents/BasketItemViewComponent.cs)
2)实现新的测试: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的摘要样式时的测试行为
3)再次运行测试,新测试将失败,如预期的那样:
4)现在,在BasketItemViewComponent类中的Invoke()中实现所需的规则。您可以通过包含条件来验证新参数并返回SummaryItem视图(仍未实现):
if (isSummary == true)
{
return View("SummaryItem", item);
}
清单29:为摘要表示模式返回不同的视图(ViewComponents/BasketItemViewComponent.cs)
5)再次运行测试,测试将通过:
6)在Views/Basket/Components/BasketItem/文件夹下为SummaryItem模式(SummaryItem.cshtml文件)创建新视图:
@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)
7)我们必须将isSummary信息传递给购物篮(Basket)项组件。我们该怎么做呢?我们必须通过容器提供它,这是购物篮(Basket)列表视图组件。但是购物篮(Basket)列表(仍然)没有isSummary信息。我们也应该提供它。因此,让我们创建一个新类,它作为一个购物篮(Basket)列表组件的新模型,也就是说,这个类将是一个“视图模型”。此类将在名为ViewModels的新文件夹中创建。
public class BasketItemList
{
public List<BasketItem> List { get; set; }
public bool IsSummary { get; set; }
}
清单31:新的BasketItemList类(ViewModels\BasketItemList.cs)
8)在上面的代码中,我们引入了一个新isSummary参数,该参数在应用程序的其他部分中具有副作用,例如在购物篮(Basket)列表中。我们还必须在BasketListViewComponent类的Invoke()方法中引入相同的参数 :
public IViewComponentResult Invoke(List<BasketItem> items, bool isSummary)
清单32:向Invoke()方法添加isSummary参数(/ViewComponents/BasketListViewComponent.cs)
9)此外,Invoke()方法的BasketList组件应该传递新的视图模型(BasketItemList类)作为View()方法的参数:
return View("Default", new BasketItemList
{
List = items,
IsSummary = isSummary
});
清单33:将新视图模型传递给View()方法
10)修改Basket视图以提供新的isSummary参数。
<vc:basket-list items="@items" is-summary="false"></vc:basket-list>
清单34:将新的is-summary参数添加到视图组件标记帮助器 (/Views/Basket/Index.cshtml)
请注意,我们在isSummary这里进行硬编码,因为购物篮(Basket)视图必须始终以完整模式而不是摘要模式显示BasketList视图组件。
11)修改BasketList Default视图以将BasketItemList类用作Model
@using MVC.Models.ViewModels
@addTagHelper *, MVC
@model BasketItemList;
清单35:BasketList 默认视图(Views\Basket\Components\BasketList\Default.cshtml)
12)修改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)
13)运行应用程序并确保购物篮(Basket)视图正确显示数据:
14)现在,让我们使用重新利用结帐视图中的BasketList 视图组件。首先,让我们修改该结账视图,为购物篮(Basket)列表提供一些虚拟数据:
@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:将摘要basket 视图组件添加到结帐视图 (/Views/Checkout/Index.cshtml)
15)再次运行应用程序,填写注册表并验证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路径中的文件。我们可以通过重构我们的应用程序并将每个与购物篮(Basket)相关的视图组件移动到/Views/Shared项目文件夹下来解决这个问题:
图5:Checkout视图中显示的购物篮(Basket) 列表视图组件的Summary模式
修复IBasketService的所有测试
到目前为止,我们一直在处理虚拟数据,声明和初始化最终将由视图用于呈现和显示用户界面的变量,例如在Catalog,Basket和Checkout视图中。
但是,随着我们在本系列文章中的进展,我们将采取更现实的方案,其中这些数据由一组服务提供,可以从某种数据库或Web服务中检索数据。
因此,从现在开始,我们将删除这些我们声明/初始化虚拟数据的行,并将其替换为对专用服务的请求。
首先,我们创建一个/Services文件夹。在这里,我们将为我们的服务类提供接口和具体实现。
其次,我们创建了一个名为IBasketService的新的接口。此接口为返回一个购物篮(Basket)项集合的方法提供“契约”:
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项参数,因为这些数据现在来自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 属性的视图组件标记助手
另外,从checkout 视图中删除以下行:
//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 属性的视图组件标记助手
此时,我们通常只运行应用程序来检查一切是否正常,但遗憾的是编译器指责了一些我们必须首先纠正的错误。
模拟单元测试
目前,我们的测试调用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类实例并将其作为构造函数的参数传递,但是在单元测试的编译部分中使用具体的类实例是一种不好的做法。我们应该通过一种称为“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()方法配置为准确返回我们之前在单元测试中初始化的项对象。我们通过以下.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())实现模拟对象,但这次我们使该Setup()方法返回一个空列表:
//arrange
Mock<IBasketService> basketServiceMock =
new Mock<IBasketService>();
basketServiceMock.Setup(m => m.GetBasketItems())
.Returns(new List<BasketItem>());
var vc = new BasketListViewComponent(basketServiceMock.Object);
清单48:使用模拟对象对BasketListViewComponent进行操作
再次运行测试,所有测试都会顺利通过:
这意味着我们的Moq对象得到了很好的实现,配置和使用。
运行应用程序后,我们会看到新BasketService类提供的购物篮(Basket)列表数据:
用视图组件替换Catalog部分视图
现在让我们重构另一批部分视图,以便用视图组件替换它们。这一次,因为我们已经知道了动机,并且在购物篮(Basket)部分视图的情况下已经完成了一次,我们将更快速地显示步骤的顺序而没有太多细节。
这种变化的动机是保持Razor标记文件(.cshtml)更清晰、更小、更可测试。
为类别创建ViewComponent
在本节中,我们使用视图组件替换Catalog标记文件(Views/Catalog/_Categories.cshtml
) 的最外层。
1)首先,让我们创建一个CategoriesViewComponent类:
public class CategoriesViewComponent : ViewComponent
{
public CategoriesViewComponent()
{
}
public IViewComponentResult Invoke(List<Product> products)
{
return View("Default", products);
}
}
清单49:新的CategoriesViewComponent类(/ViewComponents/CategoriesViewComponent.cs)
2)然后我们将Views/Catalog/_Categories.cshtml
文件移动到/Catalog/Components/Categories/位置,然后将其重命名为Default.cshtml。
3)接下来,我们将addTagHelper指令添加到/Views/Catalog/Index.cshtml文件中。
@addTagHelper *, MVC
@model List<Product>;
4)另外,我们替换部分标签助手......
<partial name="_Categories" for="@Model" />
...使用Categories视图组件标记帮助器:
<vc:categories products="@Model"></vc:categories>
为ProductCard创建ViewComponent
在本节中,我们将Catalog标记的最内层替换为用于显示产品卡的视图组件。
5)我们首先在/MVC/ViewComponents/ProductCardViewComponent.cs文件中创建一个新类。
public class ProductCardViewComponent : ViewComponent
{
public ProductCardViewComponent()
{
}
public IViewComponentResult Invoke(Product product)
{
return View("Default", product);
}
}
清单50:新的ProductCardViewComponent类(/ViewComponents/ProductCardViewComponent.cs)
6)然后我们将addTagHelper指令添加到 /Views/Catalog/Components/Categories/Default.cshtml
@addTagHelper *, MVC
@model List<Product>;
7)接下来,重新替换这个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>
}
8)然后我们将文件/MVC/Views/Catalog/_ProductCard.cshtml移动到Catalog/Components/ProductCard/位置,然后将其重命名为Default.cshtml。
为CaouselPage创建ViewComponent
每个类别显示在不同的轮播控件中,一次由4个产品组成,我们称之为“轮播页面”。这里我们展示如何为轮播页面创建视图组件。
- 首先,我们创建文件/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)
9)然后我们创建视图模型文件/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)
10)接下来,我们创建另一个视图模型 /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)
11)只有这样我们才能创建视图组件类 /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)
12)我们创建了另一个视图组件类/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视图转变到视图组件。这使我们能够保持标记更清晰,更小。
13)现在我们修改/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)
14)现在是时候实现与视图组件相关的标记文件了。
15)首先,我们创建:/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)
16)然后我们在创建一个新的标记文件/Catalog/Components/CarouselPage/Default.cshtml
17)与Carousel页面的对应关系,即一组4个产品。
@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)
18)现在我们修改类别的视图组件视图标记文件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应用程序可以具有可重用的渲染逻辑,否则会使控制器,视图或部分视图混乱。视图组件的典型案例是:
- 导航菜单
- 登录面板
- 购物车
- 侧边栏内容/菜单与部分视图不同,视图组件可以提供一个独立的黑盒子,该业务逻辑独立于插入的视图中并与之隔离。
在以下部分中,我们将使用视图组件来显示导航栏图标:
- 用户通知计数
- 购物篮(Basket)项个数
创建导航栏通知图标
我们将再次使用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类
- 默认标记文件
- 一个模型
让我们先实现模型。在这种情况下,我们正在创建一个新的UserCountViewModel,它将保存通知计数器使用的数据:
- 控制器名称
- 标题(工具提示文字)
- Css类
- 图标(Font Awesome图标类)
- 计数
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
在本文之前,我们展示了如何为购物篮(basket)视图组件创建服务。必须首先在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子类
在上一节中,我们学习了如何创建具有双重行为的单个视图组件:它可以用作用户通知计数器或购物篮(Basket)计数器。
然而,使用编码类型通常需要大量使用条件结构,例如if和switch,这被认为是“代码气味”,换句话说,是一种糟糕的编程实践,因为它违背了面向对象编程的目的。即使代码中没有很多if / switch指令,也可以将其视为OOP子利用的情况,因为这是代码为重构和多态性而尖叫的情况。
有一种称为以子类取代类型码的已知技术,我们将在此处应用:
它包括为每组不同的行为创建不同的子类,因此我们可以消除编码类型和if / switch语句的使用。
让我们先创建一个抽象类UserCounterViewComponent。这样,我们可以避免直接实例化,迫使开发人员从继承自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)
现在我们创建一个继承自UserCounterViewComponent的新NotificationCounterViewComponent 类。您可以看到我们如何消除编码类型和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:所需更新,以便购物篮(Basket)CounterViewComponent类成为子类
现在是时候重新编译项目并用专门的视图组件标记助手替换标记助手:
<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框架提供的模拟对象。
我们已经了解了视图组件如何具有专用的C#类,它可以接收参数,从依赖注入技术中受益。由于内置的ASP.NET Core依赖注入机制,可以为组件构造函数提供服务。我们已经看到了如何嵌套视图组件,以便不同组件可以显示同一视图的不同层。
最后,我们看到了如何使用视图组件来创建独立的、与视图无关的组件,这些组件托管在应用程序布局页面中,以便在应用程序的多个视图中显示。此外,我们使用视图组件探索了多态性和继承,表明此技术可以帮助执行良好的编程实践,即使在应用程序视图层中也是如此。
原文地址:https://www.codeproject.com/Articles/4042438/ASP-NET-Core-Road-to-Microservices-Part-02-View-Co