ASP-NET-Core3-和-Angular9-全-
ASP.NET Core3 和 Angular9(全)
零、前言
ASP.NET Core 是一个由 Microsoft 开发的免费开源模块化 web 框架,它运行在完整的.NET framework(Windows)或.NET Core(跨平台)之上。它是专门为构建高效的 HTTP 服务而设计的,可供大量客户访问和使用,包括 web 浏览器、移动设备、智能电视、基于 web 的家庭自动化工具等。
AngularJS 是 AngularJS 的继承者,AngularJS 是一个世界知名的开发框架,其目的是为编码人员提供工具箱,用于构建针对桌面和移动设备进行优化的反应式和跨平台的基于 web 的应用。它的特点是基于自然、易于编写且可读的语法的结构丰富的模板方法。
从技术上讲,这两个框架几乎没有共同之处:ASP.NET Core 主要集中在 web 开发堆栈的服务器端部分,而 Angular 则致力于涵盖 web 应用的所有客户端方面,如用户界面(UI和用户体验(UX),但它们都是由于各自创作者的共同愿景而产生的:t**HTTP 协议不仅限于服务于网页;它还可以作为一个可行的平台,用于构建基于 web 的 API,以有效地发送和接收数据。这一概念在万维网生命的最初 20 年中慢慢形成,现在是一个不可否认的、被广泛认可的说法,也是几乎所有现代网络开发方法的基本支柱。
至于这种视角转换背后的原因,有很多很好的原因,其中最重要的是与 HTTP 协议的固有特性有关:它使用起来相当简单,并且足够灵活,能够满足万维网所处的不断变化的环境的大多数发展需要。更不用说它现在有多普遍了:我们能想到的几乎任何平台都有一个 HTTP 库,因此 HTTP 服务可以覆盖广泛的客户端,包括桌面和移动浏览器、物联网设备、桌面应用、视频游戏等等。
本书的主要目的是将 ASP.NET Core 和 Angular 的最新版本整合到一个开发堆栈中,以演示如何使用它们创建任何客户端都可以无缝使用的高性能 web 应用和服务。
这本书是给谁的
本书面向经验丰富的 ASP.NET 开发人员,他们已经了解 ASP.NET Core 和 Angular,希望了解更多关于它们的信息,并了解如何将它们结合起来创建一个生产就绪的单页应用(SPA)或渐进式 Web 应用(PWA ). 然而,完整文档化的代码示例(也可在 GitHub 上获得)和分步实现教程使本书即使对于初学者和刚刚起步的开发人员也很容易理解。
这本书涵盖的内容
第 1 章准备介绍了我们将在本书中使用的框架的一些基本概念,以及可以创建的各种 web 应用(SPA、PWA、原生 web 应用等)。
第 2 章环顾四周,详细概述了 Visual Studio 2019 附带的.NET Core 和 Angular 模板提供的各种后端和前端元素,并提供了一些关于它们如何在典型 HTTP 请求-响应周期中协同工作的高级解释。
第 3 章前端和后端交互提供了一个全面的教程,用于构建示例 ASP.NET Core 和 Angular 应用,该应用通过使用基于引导的 Angular 客户端查询健康检查中间件,向最终用户提供诊断信息。
第 4 章、实体框架核心数据模型构成了实体框架核心及其作为对象关系映射(ORM框架的能力的旅程,从 SQL 数据库部署(基于云和/或本地实例)到数据模型设计,包括从后端控制器读取和写入数据的各种技术。
第 5 章获取和显示数据介绍了如何使用 ASP.NET Core 后端 web API 公开实体框架核心数据,使用 Angular 消费该数据,然后使用前端 UI 向最终用户展示。
第 6 章表单和数据验证详细介绍了如何在后端 web API 中实现 HTTP PUT 和 POST 方法,以便使用 Angular 执行插入和更新操作,以及服务器端和客户端数据验证
第 7 章代码调整和数据服务探讨了一些有用的重构和改进,以增强应用的源代码,并包括对 Angular 数据服务的深入分析,以了解为什么以及如何使用它们。
第 8 章、后端和**前端调试介绍如何充分利用 Visual Studio 提供的各种调试工具,正确调试典型 web 应用的后端和前端堆栈。
第 9 章、ASP.NET Core 和 Angular 单元测试对测试驱动开发(TDD)和行为驱动开发(BDD)开发实践进行了详细的回顾,并探讨了如何定义、实施,并使用 xUnit、Jasmine 和 Karma 执行后端和前端单元测试。
第 10 章身份验证和授权从较高层次介绍了身份验证和授权的概念,并介绍了正确实施专有或第三方用户身份系统的一些技术、方法和方法。其中包括一个基于 ASP.NET Identity 和 IdentityServer 4 的 ASP.NET Core 和 Angular 身份验证机制的实际示例。
第 11 章渐进式网络应用深入探讨了如何使用服务人员、清单文件和离线缓存功能将现有 SPA 转换为 PWA。
第 12 章Windows 和 Linux 部署教您如何部署前几章创建的 ASP.NET 和 Angular 应用,并使用 Windows Server 2019 或 Linux CentOS 虚拟机在基于云的环境中发布它们。
充分利用这本书
以下是用于编写本书和测试源代码的软件包(以及相关版本号):
- Visual Studio 2019 社区版 16.4.3
- Microsoft.NET Core SDK 3.1.1
- 打字稿 3.7.5
- NuGet 软件包管理器 5.1.0
- Node.js 13.7.0(我们强烈建议使用节点版本管理器,也称为NVM进行安装)
- Angular 9.0.0 决赛
在Windows上部署:
- ASP.NET Core 3.1 Linux 运行时(YUM 软件包管理器)
- .NET Core 3.1 CLR for Linux(YUM 软件包管理器)
- Nginx HTTP 服务器(YUM 包管理器)
在Linux上部署:
- ASP.NET Core 3.1 Linux 运行时(YUM 软件包管理器)
- .NET Core 3.1 CLR for Linux(YUM 软件包管理器)
- Nginx HTTP 服务器(YUM 包管理器)
If you're on Windows, I strongly suggest installing Node.js using NVM for Windows-a neat Node.js version manager for the Windows system. You can download it from the following URL:
https://github.com/coreybutler/nvm-windows/releases.
我们强烈建议使用本书中使用的相同版本–或更新版本,但风险自负!除了笑话,如果你喜欢使用不同的版本,那很好,只要你知道,在这种情况下,你可能需要对源代码进行一些手动更改和调整。
下载示例代码文件
您可以从您的账户www.packt.com下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,将文件通过电子邮件直接发送给您。
您可以通过以下步骤下载代码文件:
- 登录或注册www.packt.com。
- 选择“支持”选项卡。
- 点击代码下载。
- 在搜索框中输入图书名称,然后按照屏幕上的说明进行操作。
下载文件后,请确保使用以下最新版本解压或解压缩文件夹:
- WinRAR/7-Zip for Windows
- 适用于 Mac 的 Zipeg/iZip/UnRarX
- 适用于 Linux 的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上的https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition 。如果代码有更新,它将在现有 GitHub 存储库中更新。
我们的丰富书籍和视频目录中还有其他代码包,请访问https://github.com/PacktPublishing/ 。看看他们!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:https://static.packt-cdn.com/downloads/9781789612165_ColorImages.pdf 。
使用的惯例
本书中使用了许多文本约定。
CodeInText:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。下面是一个示例:“导航到/ClientApp/src/app/cities文件夹。”
代码块设置如下:
<mat-form-field [hidden]="!cities">
<input matInput (keyup)="loadData($event.target.value)"
placeholder="Filter by name (or part of it)...">
</mat-form-field>
当我们希望提请您注意代码块的特定部分时,相关行或项目以粗体显示:
import { FormGroup, FormControl } from '@angular/forms';
class ModelFormComponent implements OnInit {
form: FormGroup;
ngOnInit() {
this.form = new FormGroup({
title: new FormControl()
});
}
}
任何命令行输入或输出的编写方式如下:
> dotnet new angular -o HealthCheck
粗体:表示一个新术语、一个重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词出现在文本中,如下所示。下面是一个例子:“一个简单的添加一个新城市按钮将同时解决这两个问题。”
Warnings or important notes appear like this. Tips and tricks appear like this.
联系
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中注明书名,并发送电子邮件至customercare@packtpub.com。
勘误表:尽管我们已尽一切努力确保内容的准确性,但还是会出现错误。如果您在本书中发现错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,单击 errata 提交表单链接,然后输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法复制品,请您提供我们的位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供该材料的链接。
如果您有兴趣成为一名作家:如果您对某个主题有专业知识,并且您有兴趣撰写或贡献一本书,请访问authors.packtpub.com。
评论
请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在读者可以看到并使用您的无偏见意见做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。非常感谢。
有关 Packt 的更多信息,请访问Packt.com。
一、准备
在本章中,我们将结合 ASP.NET 和 Angular Travel 的最相关功能的理论介绍,并使用更实用的方法,构建它们的基础知识。更具体地说,在本章的下一部分中,我们将简要回顾 ASP.NET Core 和 Angular 框架的最新历史,而在后一部分中,我们将学习如何配置本地开发环境,以便组装、构建和测试示例 web 应用样板。
在本章结束时,您将了解 ASP.NET Core 和 Angular 在过去几年中为改进 web 开发所采取的方法,并了解如何正确设置 ASP.NET 和 Angular web 应用。
以下是我们将要讨论的主要主题:
- ASP.NET Core 革命:ASP.NET Core 的简史和 Angular 的最新成就。
- 全堆栈方法:学习如何设计、组装和交付完整产品的重要性。
- 单页应用(SPAs)、原生 Web 应用(NWAs)、和渐进 Web 应用(PWAs):不同类型 Web 应用之间的关键特性和最重要的差异,以及 ASP.NET Core 和 Angular 与它们之间的关系。
- 水疗项目示例:本书中我们将要做的事情。
- 准备工作区:如何设置我们的工作站以实现我们的第一个目标——实现一个简单的 Hello World 样板文件,该样板文件将在以下章节中进一步扩展。
技术要求
以下是用于编写本书和测试源代码的软件包(以及相关版本号):
- Visual Studio 2019 社区版 16.4.3
- Microsoft.NET Core SDK 3.1.1
- 打字稿 3.7.5
- NuGet 软件包管理器 5.1.0
- Node.js 13.7.0(我们强烈建议使用节点版本管理器,也称为NVM进行安装)
- Angular 9.0.0 决赛
If you're on Windows, I strongly suggest installing Node.js using NVM for Windows-a neat Node.js version manager for the Windows system. You can download it from the following URL:
https://github.com/coreybutler/nvm-windows/releases.
我们强烈建议使用本书中使用的相同版本–或更新版本,但风险自负!除了笑话,如果你喜欢使用不同的版本,那很好,只要你知道,在这种情况下,你可能需要对源代码进行一些手动更改和调整。
两名球员,一个进球
从功能全面的基于 web 的应用的 Angular 来看,我们可以说 ASP.NET Core 框架提供的 web API 接口是一组服务器端处理程序的编程集,服务器使用这些处理程序向定义的请求-响应消息系统公开多个钩子和/或端点。这通常用结构化标记语言(XML)、独立于语言的数据格式(JSON)或 API 查询语言(GraphQL)表示。如前所述,这是通过公开的 web 服务器(如 IIS、Node.js、Apache、Nginx 等)通过 HTTP 和/或 HTTPS 协议公开应用编程接口(API)来实现的。
类似地,Angular 可以被描述为一个现代的、功能丰富的客户端框架,它通过将 HTML 网页的输入和/或输出部分绑定到一个灵活、可重用且易于测试的模型中,将 HTML 和 ECMAScript 的最高级功能以及现代浏览器的功能推到最大程度。
我们能否结合 ASP.NET Core 的后端优势和 Angular 的前端功能,构建一个现代化、功能丰富且高度通用的 web 应用?
简言之,答案是肯定的。在接下来的章节中,我们将通过分析一个编写良好、设计合理、基于 web 的产品的所有基本方面,以及如何使用最新版本的 ASP.NET Core 和/或 Angular 来处理每一个方面,来了解如何做到这一点。但是,在完成所有这些之前,回顾一下我们将要使用的两个框架的开发历史,花一些宝贵的时间回顾过去 3 年中发生的事情,这是非常有用的。尽管他们不断壮大的竞争对手做出了宝贵的努力,了解我们仍然给予他们充分信任的主要原因将非常有用。
ASP.NET Core 革命
总结过去 4 年 ASP.NET 世界发生的事情并不是一件容易的事情;简言之,我们可以说,自.NET Framework 面世以来,我们无疑见证了它最重要的一系列变化。这是一场革命,几乎在所有方面都改变了微软的软件开发方法。为了正确理解这些年来发生的事情,在一个缓慢但持续的过程中确定一些独特的关键框架是很有用的,这一过程使一家因其专有软件、许可证和专利而闻名(但有点讨厌)的公司成为全世界开源开发的驱动力。
第一次相关的步骤,至少在我的拙见上,是在 2014 年 4 月 3 日在旧金山莫斯康展览中心(欧美地区)举行的年度微软建筑会议上进行的。正是在那里,在一次令人难忘的主题演讲中,德尔福之父、C#的首席架构师安德斯·海尔斯伯格(Anders Hejlsberg)公开发布了.NET 编译器平台的第一个版本,称为 Roslyn,作为一个开源项目。微软云和 AI 集团的执行副总裁 Scott Guthrie 也宣布正式启动.NETFraseFund,这是一个非营利组织,旨在改善.NET 生态系统中的开源软件开发和协作工作。
从那关键的一天起,.NET 开发团队在 GitHub 平台上发布了一系列微软开源项目,包括:Entity Framework Core(2014 年 5 月)、TypeScript(2014 年 10 月)、.NET Core(2014 年 10 月)、CoreFX(2014 年 11 月)、CoreCLR 和 RyuJIT(2015 年 1 月)、MSBuild(2015 年 3 月)、.NET Core CLI(2015 年 10 月),Visual Studio 代码(2015 年 11 月)、.NET 标准(2016 年 9 月)等。
ASP.NET Core 1.x
这些努力为开源开发带来的最重要成就是 ASP.NET Core 1.0 的公开发布,该版本于 2016 年第三季度发布。这是自 2002 年 1 月以来我们所知道的 ASP.NET 框架的一次完全重新实现,其核心架构没有重大变化,已经发展到版本 4.6.2(2016 年 8 月)。全新的框架将所有以前的 web 应用技术(如 MVC、web API 和 web 页面)整合到一个单独的编程模块中,以前称为 MVC6。新框架引入了一个功能齐全的跨平台组件,也称为.NET Core,与前面提到的整套开源工具一起提供,即编译器平台(Roslyn)、跨平台运行时(CoreCLR)和改进的 x64 即时编译器(RyuJIT)。
Some of you may be wondering what happened to ASP.NET 5 and Web API 2, as these used to be quite popular names until mid-2016.
ASP.NET 5 was no less than the original name of ASP.NET Core before the developers chose to rename it to emphasize the fact that it is a complete rewrite. The reasons for that, along with the Microsoft vision about the new product, are further explained in the following Scott Hanselman blog post that anticipated the changes on Jan 16, 2016:
http://www.hanselman.com/blog/ASPNET5IsDeadIntroducingASPNETCore10AndNETCore10.aspx.
For those who don't know, Scott Hanselman is the outreach and community manager for .NET/ASP.NET/IIS/Azure and Visual Studio since 2007. Additional information regarding the perspective switch is also available in the following article by Jeffrey T. Fritz, program manager for Microsoft and a NuGet team leader:
https://blogs.msdn.microsoft.com/webdev/2016/02/01/an-update-on-asp-net-core-and-net-core/. As for Web API 2, it was a dedicated framework for building HTTP services that returned pure JSON or XML data instead of web pages. Initially born as an alternative to the MVC platform, it has been merged with the latter into the new, general-purpose web application framework known as MVC6, which is now shipped as a separate module of ASP.NET Core.
在 1.0 最终版本发布后不久,ASP.NET Core 1.1(2016 年第 4 季度)推出了一些新功能和性能增强,并解决了影响早期版本的许多错误和兼容性问题。这些新功能包括将中间件配置为过滤器(通过将其添加到 MVC 管道而不是 HTTP 请求管道)、内置的、独立于主机的 URL 重写模块(通过专用的Microsoft.AspNetCore.RewriteNuGet 软件包提供)、将组件视为标记帮助器、在运行时而不是按需查看编译、,.NET 本机压缩和缓存中间件模块等。
For a detailed list of all the new features, improvements, and bug fixes of ASP.NET Core 1.1, check out the following links:
Release notes: https://github.com/aspnet/AspNetCore/releases/1.1.0.
Commits list: https://github.com/dotnet/core/blob/master/release-notes/1.1/1.1-commits.md.
ASP.NET Core 2.x
ASP.NET Core 2.0 又迈出了重要的一步,它在 2017 年第二季度作为预览版发布,然后在 2017 年第三季度作为最终版本发布。新版本有大量重要的界面改进,主要目的是标准化.NET Framework、.NET Core、,NET 标准,使其与.NET Framework 向后兼容。由于这些努力,将现有的.NET 框架项目迁移到.NET Core 和/或.NET 标准变得比以前容易得多,这使许多传统开发人员有机会尝试并适应新的范例,而不会失去他们现有的专有技术。
同样,主版本之后不久又出现了一个经过改进和完善的版本:ASP.NET Core 2.1。它于 2018 年 5 月 30 日正式发布,并引入了一系列额外的安全性和性能改进,以及一系列新功能,包括 SignalR,一个开源库,可简化向.NET Core 应用添加实时 web 功能;Razor 类库;RazorSDK 的一项重大改进,允许开发人员将视图和页面构建到可重用类库和/或库项目中,这些项目可以作为 NuGet 软件包发布;标识 UI 库和脚手架,用于向任何应用添加标识并自定义它以满足您的需要,默认情况下启用 HTTPS 支持;内置的通用数据保护条例(GDPR)支持使用面向隐私的 API 和模板,使用户可以控制其个人数据和 cookie 许可;为 Angular 和 ReactJS 客户端框架更新 SPA 模板;还有更多。
For a detailed list of all the new features, improvements, and bug fixes of ASP.NET Core 2.1, check out the following links:
Release notes: https://docs.microsoft.com/en-US/aspnet/core/release-notes/aspnetcore-2.1.
Commits list: https://github.com/dotnet/core/blob/master/release-notes/2.1/2.1.0-commit.md.
等一下:我们刚才说的是 Angular 吗?是的,没错。事实上,自最初发布以来,ASP.NET Core 就专门设计用于与流行的客户端框架(如 ReactJS 和 Angular)无缝集成。正是因为这个原因,像这样的书确实存在。ASP.NET Core 2.1 中引入的主要区别在于,默认的 Angular 和 ReactJS 模板已经更新,以使用标准项目结构并为每个框架构建系统(Angular CLI 和 NPX 的create-react-app命令),而不是依赖于 Grunt 或 Gulp 等任务运行程序,模块构建器(如 webpack)或工具链(如 Babel)在过去被广泛使用,但安装和配置起来相当困难。
**Being able to eliminate the need for these tools was a major achievement, which played a decisive role in revamping the .NET Core usage and growth rate among the developer communities since 2017. If you take a look at the two previous installments of this book – ASP.NET Core and Angular 2, published in mid-2016, and ASP.NET Core 2 and Angular 5, out in late 2017 – and compare their first chapter with this one, you will see the huge difference between having to manually use Gulp, Grunt,or webpack and relying on the integrated framework-native tools. This is a substantial reduction in complexity that would greatly benefit any developer, especially those less accustomed to working with those tools.
在 2.1 版本发布后的 6 个月之后,.NET 基金会得到了进一步的改进:2018 年 12 月 4 日,ASP.NET Core 2.2 2.2 To0T0} ToRt1Ap 发布了几项修复和新功能,如改进的端点路由系统以更好地调度请求,更新的模板具有 Bootstrap 4 和 Angular 6 支持,这是一种新的运行状况检查服务,用于监控部署环境及其基础架构的状态,包括 Kubernetes 等容器编排系统,Kestrel 中内置的 HTTP/2 支持,一个新的 signarjava 客户端,用于简化 Android 应用中 signarjava 的使用,等等。
For a detailed list of all the new features, improvements, and bug fixes of ASP.NET Core 2.2, check out the following links:
Release notes: https://docs.microsoft.com/en-US/aspnet/core/release-notes/aspnetcore-2.2.
Commits list: https://github.com/dotnet/core/blob/master/release-notes/2.2/2.2.0/2.2.0-commits.md.
ASP.NET Core 3.x
ASP.NET Core 3 在 2019 年 9 月发布,并带来了另一组性能和安全改进和新的特性,如 AutoT44.Po.Ty5T.Windows 桌面应用支持(仅 Windows),具有先进的 Windows 窗体导入功能和 AutoT6. Windows 演示基金会 T7 应用、C#8 通过一组新的内置 API 支持依赖于.NET 平台的内在访问,这些 API 可以在某些场景中带来显著的性能改进,通过在项目配置中使用<PublishSingleFile>XML 元素的dotnet publish命令或通过/p:PublishSingleFile支持单文件可执行文件命令行参数,一种新的内置 JSON 支持,具有高性能和低分配的特点,可以说比 JSON.NET 第三方库(在大多数 ASP.NET web 项目中成为事实上的标准)快 2-3 倍,在 Linux 中支持 TLS 1.3 和OpenSSL 1.1.1,这是 Linux 中的一些重要安全改进System.Security.Cryptography名称空间,包括 AES-GCM 和 AES-CCM 密码支持等。
**为了提高框架在集装箱化环境中的性能和可靠性,已经做了大量的工作。ASP.NET Core 开发团队投入大量精力改进.NET Core Docker 在.NET Core 3.0 上的体验。更具体地说,这是第一个具有实质性运行时更改的版本,以使 CoreCLR 更高效,默认情况下更好地遵守 Docker 资源限制(如内存和 CPU),并提供更多配置调整。在各种改进中,我们可以提到默认情况下改进的内存和 GC 堆使用率,以及 PowerShell Core,这是著名的自动化和配置工具的跨平台版本,现在与.NET Core SDK Docker 容器映像一起提供。
.NETCoreFramework3 还引入了 Blazor,这是一个免费的开源 web 框架,使开发人员能够使用 C#和 HTML 创建 web 应用。
最后但并非最不重要的一点是,值得注意的是,新的.NET Core SDK 比以前的版本小得多,这主要是由于开发团队从最终版本中删除了用于组装以前的 SDK(包括 ASP.NET Core 2.2)的各种 NuGet 包中包含的大量不必要的构件,对于 Linux 和 macOS 版本来说,大小的改进是巨大的,而在 Windows 上则不那么明显,因为 SDK 还包含新的 WPF 和 Windows 窗体集,这些库都是平台特定的库。
For a detailed list of all the new features, improvements, and bug fixes of ASP.NET Core 3.0, check out the following links:
Release notes: https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0.
ASP.NET Core 3.0 releases page: https://github.com/dotnet/core/tree/master/release-notes/3.0.
ASP.NET Core 3.1 是撰写本文时最新的稳定版本,于 2019 年 12 月 3 日发布。最新版本的变化主要集中在 Windows 桌面开发上,最终删除了许多旧版 Windows 窗体控件(DataGrid、ToolBar、ContextMenu、Menu、MainMenu等)菜单项并添加了对创建 C++/CLI 组件的支持(仅在 Windows 上)。
大多数 ASP.NET Core 更新都是与 Blazor 相关的修复,例如防止事件的默认操作和停止 Blazor 应用中的事件传播,对 Razor 组件的部分类支持,附加的标记帮助器组件功能,等等;然而,与其他.1版本一样,.NET Core 3.1 的主要目标是完善和改进上一版本中已经提供的功能,修复了 150 多个性能和稳定性问题。
A detailed list of the new features, improvements, and bug fixes introduced with ASP.NET Core 3.1 is available at the following URL:
Release notes: https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-1.
这就结束了我们关于 ASP.NET Core 最新历史的旅程。在下一节中,我们将把重点转移到 Angular 生态系统,它经历了一个非常相似的事件转折。
有什么新鲜事吗?
如果遵循微软和.NET 基金会近年来的脚步并不是一件容易的事情,那么当我们把目光转向客户端的 Web 框架时,事情就不会变得更好了。为了了解那里发生了什么,我们必须追溯到 10 年前,当 jQuery 和 MooTools 等 JavaScript 库在客户端场景中占据主导地位时,Dojo、Backbone.js 和 Knockout.js 等第一个客户端框架正努力获得普及和广泛采用,像 React 和 Vue.js 这样的东西根本不存在
Truth be told, jQuery is still dominating the scene to a huge extent, at least according to Libscore (http://libscore.com/#libs) and w3Techs (https://w3techs.com/technologies/overview/javascript_library/all). However, despite being used by 74.1% of all websites, it's definitely a less chosen option for web developers than it was 10 years ago.
长角的
AngularJS 的故事始于 2009 年,当时 Miško Hevery(现在是谷歌的高级计算机科学家和敏捷教练)和 Adam Abrons(现在是 Grand Rounds 的工程总监)正在进行一项端到端的项目(E2E)web 开发工具,该工具将提供一个在线 JSON 存储服务和一个客户端库,用于根据它构建 web 应用。为了发布他们的项目,他们使用了GetAngular.com主机名。
**在此期间,已经在谷歌工作的 Hevery 与另外两名开发者一起被分配到谷歌反馈项目。他们一起在 6 个月内编写了 17000 多行代码,慢慢陷入了代码膨胀和测试问题的令人沮丧的场景。鉴于这种情况,Hevery 要求他的经理使用 GetAngular(前面提到的副项目)重写应用,并打赌他可以在两周内单独完成。他的经理接受了,不久他就输掉了赌注,因为整件事花了他三个星期而不是两个星期;然而,新的应用只有 1500 行代码,而不是 17000 行。这足以引起谷歌对新框架的兴趣,该框架不久后被命名为 AngularJS。
To listen to the full story, take a look at the following Miško Hevery keynote speech at ng-conf 2014:
https://www.youtube.com/watch?v=r1A1VR0ibIQ.
安格拉斯
AngularJS 的第一个稳定版本(版本 0.9.0,也称为 dragon Breathe)于 2010 年 10 月在 GitHub 上发布,获得麻省理工学院许可;2012 年 6 月,AngularJS 1.0.0(也称为时间支配)问世时,该框架已经在全球 web 开发社区中获得了巨大的普及。
取得如此巨大成功的原因很难用几句话来概括,但我将通过强调一些基本的关键卖点来尝试做到这一点:
- 依赖注入:AngularJS 是第一个实现它的客户端框架。不可否认,这是相对于竞争对手(包括 jQuery 等 DOM 操作库)的巨大优势。使用 AngularJS,开发人员可以编写松散耦合且易于测试的组件,而框架的任务是创建组件,解析它们的依赖关系,并在请求时将它们传递给其他组件。
- 指令:这些可以描述为特定 DOM 项上的标记,如元素、属性、样式等:一个强大的功能,可用于指定自定义和可重用的类似 HTML 的元素和属性,这些元素和属性定义数据绑定和/或表示组件的其他特定行为。
- 双向数据绑定:模型和视图组件之间的数据自动同步。当模型中的数据发生变化时,视图反映了变化;当视图中的数据更改时,模型也会更新。这会立即自动发生,从而确保模型和视图随时更新。
- 单页方法:AngularJS 是第一个完全消除页面重新加载需求的框架。这在服务器端(更少、更小的网络请求)和客户端(更平滑的过渡、更响应的体验)两个方面都提供了巨大的好处,并为单页应用模式铺平了道路,React、Vue.js 和其他亚军框架稍后也将采用这种模式。
- 缓存友好:所有的 AngularJS 魔术都是在客户端进行的,没有任何服务器端的工作来生成 UI/UX 部分。正是由于这个原因,所有 AngularJS 网站都可以缓存在任何地方和/或通过 CDN 提供。
For a detailed list of AngularJS features, improvements, and bug fixes from 0.9.0 through 1.7.8, check out the following link:
Angularjs 1.x Changelog: https://github.com/angular/angular.js/blob/master/CHANGELOG.md.
Angular 2
2016 年 9 月 14 日发布的 AngularJS 的新版本称为 Angular2,完全基于新的 ECMAScript 版本 6(正式称为 ECMAScript 2015)规范,是对前一版本的完全重写。与 ASP.NET Core 重写一样,这场革命在体系结构层面带来了如此多的突破性变化,HTTP 管道处理、应用生命周期和状态管理——将旧代码移植到新代码几乎是不可能的。尽管保留了以前的名称,新的 Angular 版本是一个全新的框架,与以前的版本几乎没有共同之处。
选择不使 Angular2 向后兼容 AngularJS 清楚地表明了作者团队采用全新方法的意图:不仅在代码语法方面,而且在思维方式和设计客户端应用方面。新的 Angular 是高度模块化的,基于组件的,附带了一个新的和改进的依赖注入模型,以及许多它的前辈从未听说过的编程模式。
以下是 Angular 2 引入的最重要改进的简要列表:
- 语义版本控制:Angular 2 是第一个使用语义版本控制的版本,也称为 SemVer:一种通用的版本控制方法,用于控制各种软件版本,以帮助开发人员跟踪正在发生的事情,而无需深入了解变更日志的详细信息。SemVer 基于三个数字——X.Y.Z–,其中X代表主要版本;Y代表次要版本;Z代表补丁版本。更具体地说,当对稳定的 API 进行不兼容的 API 更改时,代表主要版本的X号会增加;当添加向后兼容功能时,代表次要版本的Y号会增加;而Z数字代表补丁版本,在修复向后兼容的 bug 时会增加。这种改进很容易被低估,但对于连续交付(CDE的大多数现代软件开发场景来说,这是必须的最重要的是,新版本发布频率很高。
- 类型脚本:如果你是一名经验丰富的 web 开发人员,你可能已经知道什么是类型脚本。如果你不这样做,不用担心,你会在后面得到更多,因为我们将在本书的 Angular 相关章节中大量使用它。现在,让我们假设 TypeScript 是微软制造的 JavaScript 超集,它允许使用所有 ES2015 功能(如默认的 Rest 排列参数、模板文本、箭头函数、承诺、等),并在开发过程中添加强大的类型检查和面向对象的功能 TypeScript 源代码可以转换成所有浏览器都能理解的标准 JavaScript 代码。
- 服务器端渲染(SSR):Angular 2 配备 Angular Universal,这是一种开源技术,允许后端服务器运行 Angular 应用,并仅向客户端提供生成的静态 HTML 文件。简言之,服务器将第一次呈现页面,以便更快地传递到客户端,然后立即使用客户端代码刷新页面。SSR 有它的警告,例如要求主机上安装 Node.js 以执行必要的预渲染步骤,以及将整个
node_modules文件夹放在主机上,但可以大大增加应用对典型 internet 浏览器的响应时间,从而缓解已知的 AngularJS 性能问题。 - Angular Mobile Toolkit(AMT):一套专门为构建高性能移动应用而设计的工具。
- 命令行界面****CLI:Angular 2 引入的新 CLI 可供开发人员使用,通过控制台/终端命令以及简单的测试 shell 生成组件、路由、服务和管道。
*** 组件。这些是 Angular2 的主要构建块,完全取代了 AngularJS 的控制器和作用域,也提升了以前指令中涵盖的大部分任务。Angular 2 应用的应用数据、业务逻辑、模板和样式都可以使用组件制作。**
**I did my best to explore most of these features in my first book, ASP.NET Core and Angular 2, which was published in October 2016, right after the final release of the two frameworks:
https://www.packtpub.com/application-development/aspnet-core-and-angular-2.
Angular 4
2017 年 3 月 23 日,谷歌发布了 Angular 4:第 3 个版本被完全跳过,以统一在此之前单独开发的许多 Angular 组件的所有主要版本,例如 Angular Router,当时已经是 3.x 版本。从 Angular 4 开始,整个 Angular 框架被统一到同一个 MAJOR.MINOR.PATCH SemVer 模式中。
新的主要版本带来了数量有限的突破性更改,例如新的和改进的路由系统、TypeScript 2.1+支持(和需求),以及一些不推荐使用的接口和标记。还有很多改进,包括:
- 提前AOT编译:Angular 4 在构建阶段编译模板并相应生成 JavaScript 代码。与 AngularJS 和 Angular2 在运行时编译应用时使用的 JIT 模式相比,这是一个巨大的架构改进。例如,当应用启动时,不仅应用速度更快,因为客户端不必编译任何东西,而且对于大多数组件错误,它在构建时而不是在运行时抛出/中断,从而导致更安全和稳定的部署。
- 动画 npm 包:所有现有的 UI 动画和效果,以及新的 UI 动画和效果,都已移至
@angular/animations专用包,而不是@angular/core的一部分。这是一个聪明的举动,让非动画应用有机会删除这部分代码,从而变得更小、更快。
其他值得注意的改进包括:检查有效电子邮件地址的新表单验证程序、HTTP 路由模块中 URL 参数的新 paramMap 接口、更好的内部化支持,等等。
Angular 5
Angular 5 于 2017 年 11 月 1 日发布,提供了 TypeScript 2.3 支持、另一系列突破性更改、许多性能和稳定性改进,以及一些新功能,例如:
- 新的 HTTP 客户端 API:从 Angular 4.3 开始,
@angular/http模块被搁置一旁,取而代之的是一个新的@angular/common/http包,该包具有更好的 JSON 支持、拦截器和不可变的请求/响应对象等功能。该切换在 Angular 5 中完成,之前的模块已弃用,建议在所有应用中使用新模块。 - 状态转移 API:一项新功能,使开发人员能够在服务器和客户端之间转移应用的状态。
- 一组新的路由事件,用于对 HTTP 生命周期进行更精细的控制:
ActivationStart、ActivationEnd、ChildActivationStart、ChildActivationEnd、GuardsCheckStart、GuardsCheckEnd、ResolveStart和ResolveEnd。
November 2017 was also the release month of my ASP.NET Core 2 and Angular 5 book, which covers most of the aforementioned improvements:
https://www.packtpub.com/application-development/aspnet-core-2-and-angular-5.
In June 2018, that book was made available as a video course:
https://www.packtpub.com/web-development/asp-net-core-2-and-angular-5-video.
Angular 6
Angular 6 于 2018 年 4 月发布,主要是一个维护版本,其重点是改进框架及其工具链的整体一致性,而不是添加新功能。因此,没有重大突破性变化。RxJS 6 支持注册提供程序的新方法、新的providedIn可注入装饰器、改进的 Angular Material 支持(专门用于在 Angular 客户端 UI 中实现材质设计的组件)、更多 CLI 命令/更新,等等。
另一个值得一提的改进是新的 CLIng add命令,该命令使用包管理器下载新的依赖项,并调用安装脚本以使用配置更改更新项目,添加其他依赖项和/或特定于 scaffold 包的初始化代码。
最后,Angular 团队引入了 Ivy,这是下一代的 Angular 渲染引擎,旨在提高速度并减小应用的大小。
Angular 7
Angular 7 于 2018 年 10 月发布,这无疑是一次重大更新,我们可以通过阅读谷歌开发者关系负责人斯蒂芬·弗洛因和 Angular 著名发言人在 Angular development 官方博客正式发布时所写的文字轻松猜到:
"This is a major release spanning the entire platform, including the core framework, Angular Material, and the CLI with synchronized major versions. This release contains new features for our toolchain, and has enabled several major partner launches."
以下是新功能的列表:
- 轻松升级:得益于版本 6 的基础工作,Angular 团队能够减少将现有 Angular 应用从旧版本升级到最新版本所需的步骤。详细程序可访问查看 https://update.angular.io ,一本非常有用的 Angular upgrade 交互式指南,可用于快速恢复所需步骤,如 CLI 命令、软件包更新等。要将现有 Angular 应用从 Angular 的旧版本升级到最新版本,需要执行此操作。
- CLI update:一个新命令,尝试按照前面提到的过程自动升级 Angular 应用及其依赖项。
- CLI 提示:Angular 命令行界面经过修改,可以在运行
ng new或ng add @angular/material等常用命令时提示用户,帮助开发者发现路由、SCSS 支持等内置功能。 - Angular Material 和 CDK:额外的 UI 元素,如虚拟滚动,一个基于列表可见部分从 DOM 加载和卸载元素的组件,使用户可以通过非常大的可滚动列表、CDK 本机拖放支持,为用户建立非常快速的体验,改进的下拉列表元素等。
*** 合作伙伴发布:改善了与许多第三方社区项目的兼容性,如:Angular Console;一个可下载的控制台,用于在本地计算机上启动和运行 Angular 项目,AngularFire,Firebase 集成的官方 Angular 包,Angular for NativeScript;Angular 和 NativeScript 之间的集成–一个使用 JavaScript 和/或基于 JS 的客户端框架构建本机 iOS 和 Android 应用的框架,为StackBlitz 提供了一些有趣的新 Angular 特定功能;可用于创建 Angular 和 React 项目的在线 IDE,例如选项卡式编辑器和与 Angular 语言服务的集成,等等。** 更新依赖项:增加了对 TypeScript 3.1、RxJS 6.3 和 Node 10 的支持,尽管之前的版本仍然可以用于向后兼容。***
***The Angular Language Service is a way to get completions, errors, hints, and navigation inside Angular templates: think about it as a virtuous mix between a syntax highlighter, IntelliSense, and a real-time syntax error checker. Before Angular 7, which added the support for StackBlitz, such a feature was only available for Visual Studio Code and WebStorm.
For additional information about the Angular Language Service, take a look at the following URL:
https://angular.io/guide/language-service
Angular 8
Angular 7 紧随其后的是 Angular 8,于 2018 年 5 月 29 日发布。新版本主要是关于 Ivy,Angular 期待已久的新编译器/运行时:尽管从 Angular 5 开始就是一个正在进行的项目,但版本 8 是第一个正式提供运行时切换以实际选择使用 Ivy 的版本,这将成为 Angular 9 开始的默认运行时。
In order to enable Ivy on Angular 8, the developers had to add an "enableIvy": true property to the angularCompilerOptions section within the app's tsconfig.json file.
Those who want to know more about Ivy are encouraged to give an extensive look at the following post by Cédric Exbrayat, co-founder and trainer at the Ninja-Squad website and now part of the Angular developer team:
https://blog.ninja-squad.com/2019/05/07/what-is-angular-ivy/.
其他值得注意的改进和新功能包括:
- Bazel 支持:Angular 8 是支持 Bazel 的第一个版本,Bazel 是由谷歌开发并用于构建和测试软件自动化的自由软件工具。它对于希望自动化交付管道的开发人员非常有用,因为它允许增量构建和测试,甚至允许在构建场上配置远程构建(和缓存)。
- 路由:引入了一种新语法,使用 TypeScript 2.4+中的
import()语法来声明延迟加载路由,而不是依赖字符串文本。为了向后兼容,保留了旧语法,但可以说很快就会被删除。 - 服务工作者:引入了一种新的注册策略,允许开发者选择何时注册他们的工作者,而不是在应用启动生命周期结束时自动注册。还可以使用新的
ngsw-bypass头绕过特定 HTTP 请求的服务工作者。 - 工作空间 API:一种新的、更方便的方式来读取和修改 Angular 工作空间配置,而不是手动修改
angular.json文件。
In client-side development, a service worker is a script that the browser runs in the background to do any kind of stuff that doesn't require either a user interface or any user interaction.
新版本还引入了一些引人注目的突破性变化——主要是由于常春藤的缘故——并删除了一些长期不推荐使用的软件包,如@angular/http,在 Angular 4.3 中被@angular/common/http取代,然后在 5.0 中正式不推荐使用。
A comprehensive list of all the deprecated APIs can be found in the official Angular deprecations guide at the following URL:
https://angular.io/guide/deprecations.
Angular 9
最后,但并非最不重要的一点是,《Angular 9》在 2019 年第 4 季度之前的一系列候选发行之后于 2020 年 2 月发布,目前是最新版本。
新版本带来了以下新功能:
- JavaScript 捆绑包和性能:试图修复非常大的捆绑包文件,这是 Angular 早期版本中最麻烦的问题之一,极大地增加了下载时间,降低了整体性能。
- 常春藤编译器:新的 Angular 构建和渲染管道(Angular 8 随附作为可选预览)现在是默认渲染引擎。
- 无选择器绑定:以前的渲染引擎提供了一个有用的功能,但 Angular 8 Ivy 预览中缺少了该功能,现在 Ivy 也可以使用该功能。
- 国际化:另一项常春藤增强功能,利用 Angular CLI 生成为翻译人员创建文件和以多种语言发布 Angular 应用所需的大部分标准代码,这得益于新的
i18n属性。
The new i18n attribute is a numeronym, which is often used as an alias of internationalization. The number 18 stands for the number of letters between the first i and the last n in the word internationalization. The term seems to have been coined by the Digital Equipment Corporation (DEC) around the 1970s or 1980s, together with l10n for localization, due to the excessive length of the two words.
期待已久的 Ivy 编译器值得多说几句,这是 Angular 未来的一个非常重要的功能。
您很可能已经知道,渲染引擎在任何前端框架的总体性能中起着重要作用,因为它是将表示逻辑(Angular、组件和模板)执行的动作和意图转换为将更新 DOM 的指令的工具。如果渲染器效率更高,则可以说它需要更少的指令,从而提高总体性能,同时减少所需的 JavaScript 代码量。由于 Ivy 生成的 JavaScript 包比以前的渲染引擎小得多,Angular 9 的整体改进在性能和大小方面都是相关的。
这就结束了我们对 ASP.NET Core 和角生态系统最近历史的简要回顾。在下一节中,我们将总结导致我们在 2020 年实际选择它们的最重要原因。
选择.NET Core 和 Angular 的原因
正如我们所看到的,这两个框架都经历了三年激烈的变化。这导致了他们的核心的整体重建,并且在那之后,他们需要不断地努力重新回到顶端——或者至少不会在他们现在已经离开黄金时代后出现的大多数现代框架面前失利。他们渴望在开发领域占据主导地位:Python、Go 和 Rust 用于服务器端部分,React、Vue.js 和 Ember.js 用于客户端部分,更不用说 Node.js 和 Express 生态系统,以及 90 年代和 2000 年代的大多数老竞争对手,如 Java、Ruby 和 PHP,它们仍然活跃和活跃。
也就是说,下面列出了 2019 年选择 ASP.NET Core 的理由:
- 性能:新的.NET Core web 堆栈速度相当快,尤其是自 3.x 版以来。
- 集成:它支持大多数(如果不是全部的话)现代客户端框架,包括 Angular、React 和 Vue.js。
- 跨平台方法:.NET Core web 应用可以以几乎无缝的方式在 Windows、macOS 和 Linux 上运行。
- 托管:。NET Core web 应用几乎可以托管在任何地方:从使用 IIS 的 Windows 机器到使用 Apache 或 Nginx 的 Linux 设备,从 Docker 容器到 edge case,使用 Kestrel 和 WebListener HTTP 服务器的自托管场景。
*** 依赖注入:框架支持内置依赖注入设计模式,在开发过程中提供了大量优势,如减少依赖性、代码重用性、可读性和测试性。* 模块化 HTTP 管道:ASP.NET Core 中间件为开发人员提供了对 HTTP 管道的细粒度控制,可以将 HTTP 管道简化为其核心(用于超轻量任务),也可以使用强大的、高度可配置的功能(如国际化、第三方身份验证/授权、缓存、路由等)进行丰富。* 开源:整个.NET Core 栈已经作为开源发布,完全专注于强大的社区支持,因此每天都有数千名开发人员对其进行审查和改进。*** 并行执行:支持在同一台机器上同时运行一个应用或组件的多个版本。这基本上意味着可以在同一台计算机上同时拥有公共语言运行库的多个版本,以及使用运行库版本的应用和组件的多个版本。这对于大多数实际开发场景来说都很好,因为它让开发团队能够更好地控制应用绑定到哪个版本的组件,以及应用使用哪个版本的运行时。****
****至于 Angular 框架,我们之所以选择它而不是 React、Vue.JS 和 Ember.JS 等其他优秀的 JS 库,最重要的原因是它已经提供了大量现成的功能,这使它成为最合适的选择,尽管可能没有其他框架/库那么简单。如果我们将其与 TypeScript 语言带来的一致性优势结合起来,我们可以说 Angular 从 2016 年重生至今,在过去三年中,该项目经历了六个主要版本,在稳定性、性能和功能方面取得了很大的进步,但在向后兼容性、最佳实践和,和总体方法。所有这些理由都足以投资于 it,希望它能继续跟上这些引人注目的前提。
既然我们已经认识到了使用这些框架的原因,那么让我们问问自己,找到更多关于它们的最佳方法是什么:接下来的部分应该为我们提供我们需要的答案。
全堆栈方法
学习使用 ASP.NET Core 和 Angular 将意味着能够同时使用 web 应用的前端(客户端)和后端(服务器端);换句话说,它意味着能够设计、组装和交付完整的产品。
最终,为了做到这一点,我们需要深入研究以下内容:
- 后端编程
- 前端编程
- 用户界面样式和用户体验设计
- 数据库设计、建模、配置和管理
- Web 服务器配置和管理
- Web 应用部署
乍一看,这种做法似乎违背了常识;不应允许单个开发人员独自完成所有工作。每个开发人员都知道后端和前端需要完全不同的技能和经验,我们到底为什么要这样做?
在回答这个问题之前,我们应该理解我们所说的能够的真正含义。我们不必成为每一层的专家;没有人期望我们这样做。当我们选择采用全堆栈方法时,我们真正需要做的是提高我们在整个堆栈中的意识水平;这意味着我们需要知道后端是如何工作的,以及它如何能够并且将如何连接到前端。我们需要知道如何存储、检索数据,然后通过客户端提供服务。我们需要确认我们需要在 web 应用所用的各种组件之间分层的交互,并且我们需要了解安全问题、身份验证机制、优化策略、负载平衡技术等等。
这并不意味着我们必须在所有这些领域拥有强大的技能;事实上,我们几乎永远不会。尽管如此,如果我们想采用全栈方法,我们需要理解所有这些方法的含义、作用和范围。此外,我们应该能够在需要时通过这些领域中的任何一个努力。
SPA、NWA 和 PWA
为了演示 ASP.NET Core 和 Angular 如何充分协同工作,我们想不出比使用大多数(如果不是全部)构建一些小型 SPA 项目更好的方法,本机 Web 应用特性。选择这样一个选项的原因很明显:没有更好的方法来展示他们现在提供的一些最好的特性。我们将有机会使用现代接口和模式,如 HTML5 pushState API、webhooks、基于数据传输的请求、动态 web 组件、UI 数据绑定,以及能够完美涵盖所有这些内容的无状态 AJAX 驱动体系结构。我们还将充分利用一些独特的 NWA 特性,如服务人员、web 清单文件等。
如果您不知道这些定义和首字母缩略词的含义,请不要担心,我们将在接下来的几节中探讨这些概念,这些章节将专门列举以下类型的 web 应用的最相关功能:SPA、NWA 和 PWA。在这里,我们还将尝试找出最常见的产品所有者对典型的基于 web 的项目的期望。
单页应用
简而言之,SPA 是一种基于 web 的应用,它难以提供与桌面应用相同的用户体验。如果我们考虑到所有的 SPA 仍然通过 Web 服务器来服务,从而与其他标准网站一样被 Web 浏览器访问,那么我们可以很容易地理解如何通过改变 Web 开发中常用的一些默认模式(如资源加载、DOM 管理)来实现所期望的结果。和 UI 导航。在一个好的 SPA 中,内容和资源——HTML、JavaScript、CSS 等——要么在单个页面加载中检索,要么在需要时动态获取。这也意味着页面不会重新加载或刷新,它只是根据用户操作进行更改和调整,在后台执行所需的服务器端调用。
以下是当今竞争激烈的水疗中心提供的一些关键功能:
- 无服务器端往返:竞争性 SPA 可以重新绘制客户端 UI 的任何部分,而不需要完整的服务器端往返来检索完整的 HTML 页面。这主要是通过实现关注点分离(SOC)设计原则来实现的,这意味着数据源、业务逻辑和表示层将被分离。
- 高效路由:有竞争力的 SPA 能够使用有组织的、基于 JavaScript 的路由在其整个导航体验中跟踪用户的当前状态和位置。我们将在接下来的章节中介绍服务器端和客户端路由的概念时进一步讨论这一点。
- 性能和灵活性:由于选择了 JavaScript SDK(Angular、JQuery、Bootstrap 等),竞争性 SPA 通常会将其所有 UI 传输到客户端。这通常有助于提高网络性能,因为增加客户端渲染和脱机处理可以减少网络上的 UI 影响。然而,这种方法带来的真正好处是赋予 UI 的灵活性,因为开发人员可以完全重写应用前端,而对服务器几乎没有影响,除了一些静态资源文件。
这个列表很容易增长,因为这些只是一个设计合理、竞争激烈的水疗中心的主要优势之一。如今,这些方面发挥着重要作用,因为许多商业网站和服务正在从传统的多页面应用(MPA模式)转向完全承诺或混合的基于 SPA 的方法。
本机 web 应用
自 2015 年以来越来越流行的多页应用通常被称为 NWA,因为它们倾向于实现多页框架上绑定在一起的多个小规模单页模块,而不是构建单个单一 SPA。
答:更不用说还有很多企业级 SPA 和 NWA 每天完美地为成千上万的用户提供服务。想举几个例子吗?WhatsApp Web 和 Teleport Web、Flickr,以及广泛的 Google Web 服务,包括 Gmail、联系人、电子表格、地图等。这些服务,加上他们庞大的用户群,最终证明我们所说的不是一个愚蠢的趋势,它将随着时间的推移而消失;相反,我们正在见证一个整合模式的完成,这一模式肯定会持续下去。
渐进式 web 应用
2015 年,当弗朗西斯·贝里曼(英国自由设计师)和亚历克斯·拉塞尔(谷歌浏览器工程师)被曝光时,另一种网络开发模式也被曝光首次使用术语 PWAs 来指代那些可以利用现代浏览器支持的两个新的重要功能的 web 应用:服务工作者和 web 清单文件。这两项重要的改进可以成功地用于提供一些通常仅在移动应用上可用的功能——推送通知、脱机模式、基于权限的硬件访问等——使用标准的基于 web 的开发工具,如 HTML、CSS 和 JavaScript。
进步网络应用的兴起始于 2018 年 3 月 19 日,当时苹果在 Safari 11.1 中实施了对服务人员的支持。从那一天开始,PWA 由于其相对于 MPA、SPA 和 NWA 的无可否认的优势而在整个行业中得到广泛采用:更快的加载时间、更小的应用大小、更高的受众参与度等等。
以下是渐进式 Web 应用的主要技术特征(根据谷歌的说法):
- 渐进式:适用于每一位用户,无论选择何种浏览器,都采用渐进式增强原则
*** 响应性:适合任何形式因素:台式机、手机、平板电脑或尚未出现的形式。* 独立于连接:服务人员允许脱机使用,或在低质量网络上使用。* 类似的应用:对于用户来说,感觉就像一个应用,具有应用风格的交互和导航。* 新鲜:由于服务人员更新过程,始终保持最新* 安全:通过 HTTPS 提供服务,防止窥探并确保内容未被篡改* 可发现:可通过 web 清单(manifest.json文件和注册服务人员识别为应用,并可通过搜索引擎发现* 可重新参与:能够使用推送通知与用户保持互动* 可安装:提供主屏幕图标,无需使用应用商店* 可链接:可通过 URL 轻松共享,无需复杂安装**
**但是,它们的技术基线标准可限于以下子集:
- HTTPS:必须从安全来源提供服务,这意味着通过带有绿色挂锁显示的 TLS(无活动混合内容)。
- 最小离线模式:即使设备未连接到网络,也必须能够启动,功能有限或至少显示自定义离线页面。
- 服务工作者:他们必须向获取事件处理程序注册服务工作者(如前所述,这是最低限度的离线支持所必需的)。
- Web 清单文件:他们需要引用一个有效的
manifest.json文件,该文件至少有四个关键属性(name、short_name、start_url、display)和一组最少需要的图标。
For those interested in reading about this directly from the source, here's the original link from the Google Developers website:
https://developers.google.com/web/progressive-web-apps/.
In addition, here are two follow-up posts from Alex Russell's Infrequently Noted blog:
https://infrequently.org/2015/06/progressive-apps-escaping-tabs-without-losing-our-soul/.
https://infrequently.org/2016/09/what-exactly-makes-something-a-progressive-web-app/.
For those who don't know, Alex Russell has worked as a senior staff software engineer at Google since December 2008.
虽然有一些相似之处,但 PWA 和 SPA 是两个不同的概念,有不同的要求,并且在许多重要方面有所不同。正如我们所看到的,前面提到的 PWA 要求都不是指单页应用或服务器端往返。渐进式 Web 应用可以在单个 HTML 页面和基于 AJAX 的请求中工作(因此也是 SPA),但它也可以请求其他服务器呈现(或静态)页面和/或执行标准 HTTP GET 或 POST 请求,很像 MPA。相反,任何 SPA 都可以实现任何单一的 PWA 技术标准,这取决于产品所有者的需求(稍后将详细介绍)、采用的服务器端和客户端框架以及开发人员的最终目标。
由于我们将使用 Angular,这是关于开发单页应用的,并且自版本 5 以来,还附带了一个强大而稳定的 service worker 实现,因此我们完全有权利用这两个方面的优势。正是出于这个原因,我们将在需要的时候使用服务人员——以及他们提供的可靠性和性能提高的好处——同时保持坚实的 SPA 方法。此外,我们肯定会实施一些战略性的 HTTP 往返(和/或其他基于重定向的技术),只要我们能够利用微服务从我们的应用中释放一些工作负载,就像任何优秀的原生 Web 应用都应该做的那样。
所有这些功能是否都能满足现代市场需求?让我们试试看。
产品所有者期望
许多现代敏捷软件开发框架(如 Scrum)提出的最有趣但被低估的概念之一是角色的含义和定义的重要性。其中,没有什么比产品所有者(在极限编程方法中也称为客户)或其他地方的客户代表更重要。他们是那些将我们难以满足的期望带到发展表上的人。他们将告诉我们什么是最重要的交付内容,以及何时根据其明显的业务价值而不是基础架构价值对我们的工作进行优先排序。他们将有权由管理层做出决定并做出艰难的决定,这有时是好的,有时不是;这通常会对我们的开发进度产生很大影响。简而言之,他们是项目的负责人;这就是为什么,为了交付一个符合他们期望的 web 应用,我们需要理解他们的愿景,并将其视为我们自己的愿景。
即使项目的产品所有者是我们的父亲、妻子或最好的朋友,这也是事实:这就是它的工作原理。
现在我们已经明确了,让我们来看看一些最常见的产品所有者对一个典型的基于网络的 SPA 项目的期望。我们应该看看使用 ASP.NET Core 和 Angular 的选择是否足以满足其中的每一项要求,如下所示:
- 提前发布:无论我们销售什么,客户都会希望看到他在买什么。例如,如果我们计划使用一个敏捷开发框架,比如 Scrum,我们必须在每个 sprint结束时发布一个潜在的可交付产品。如果我们希望采用基于瀑布的方法,我们将有里程碑,等等。有一点是肯定的,为了有效地组织我们的开发工作,我们能做的最好的事情就是采用迭代和/或面向模块的方法。ASP.NET Core 和 Angular,以及它们底层基于 MVC 或 MVVM 的模式所赋予的强大的关注点分离,将优雅地将我们推入这样做所需的思维模式。
- 后端的 GUI:我们经常被要求处理 GUI 和前端功能,因为这将是客户唯一真正可以查看和测量的东西。这基本上意味着我们必须模拟数据模型,尽快开始前端的工作,延迟所有依赖于引擎盖下的东西,即使这意味着让它空着;我们可以说,引擎盖是我们最需要的。注意,这种方法不一定是坏的;无论如何,我们不会仅仅为了满足产品所有者的期望而这样做。相反,选择使用 ASP.NET Core 和 Angular 将使我们有机会轻松地将表示层和数据层解耦,实现第一层并模拟后者,这是一件很好的事情。在浪费宝贵的时间或被迫做出可能错误的决定之前,我们将能够看到我们将走向何方。ASP.NET Core 的 Web API 接口将提供适当的工具来实现这一点,它允许我们使用 Visual Studio 中可用的控制器模板和 Entity Framework Core 支持的内存数据上下文,在几秒钟内创建一个示例 Web 应用框架,我们将能够首先使用实体模型和代码访问该框架。一旦我们做到这一点,我们就可以使用 Angular presentation layer 工具箱切换到 GUI 设计,直到达到预期的结果。一旦我们满意了,我们只需要正确地实现 WebAPI 控制器接口并连接实际数据。
- 快速完成:除非我们也能在合理的时间内完成所有工作,否则上述任何一项都不会起作用。这是选择采用服务器端框架和客户端框架轻松协作的关键原因之一。ASP.NET Core 和 Angular 是首选工具,不仅因为它们都建立在坚实、一致的基础上,而且因为它们的目的就是要做到这一点——在各自的方面完成工作,并为其他合作伙伴提供可用的界面。
- 适应性:正如敏捷宣言所述,能够响应变更请求比遵循计划更重要。这在软件开发中尤其如此,在软件开发中,我们甚至可以声称任何不能处理更改的东西都是失败的项目。这是接受由我们选择的两个框架强制实施的关注点分离的另一个很好的理由,因为这使开发人员能够管理甚至在某种程度上欢迎在开发阶段预期的大多数布局或结构更改。
A few lines ago, we mentioned Scrum, which is one of the most popular Agile software development frameworks out there. Those who don't know it yet should definitely take a look at what it can offer to any results-driven team leader and/or project manager. Here's a good place to start:
https://en.wikipedia.org/wiki/Scrum_(software_development).
For those who are curious about the Waterfall model, here's a good place to learn more about it:
https://en.wikipedia.org/wiki/Waterfall_model.
就这样。请注意,我们在这里没有涵盖所有内容,因为如果不知道实际的任务,这是不可能的。我们只是试图对以下一般问题给出一个广泛的答案:如果我们要建立一个 SPA 和/或 PWA,ASP.NET Core 和 Angular 是否是一个合适的选择?答案无疑是肯定的,尤其是在一起使用时。
这是否意味着我们已经完成了?这不是一个机会,因为我们无意将这一假设视为理所当然。相反,是时候我们停止笼统的说话,开始行动起来,以此来证明这一点了。这正是我们在下一节中要做的:准备、构建和测试一个示例单页应用项目。
水疗项目示例
我们现在需要的是构思一个合适的测试用例场景,类似于我们最终必须处理的场景——一个样本 SPA 项目,包含我们期望从潜在可交付产品中获得的所有核心方面。
为了做到这一点,我们需要做的第一件事是成为我们自己的客户一分钟,并提出一个想法;与他人分享的愿景。然后,我们将能够重新穿上开发人员的鞋子,并将抽象计划拆分为需要实现的项目列表;这些将是我们项目的核心要求。最后,我们将通过获取所需的包、添加资源文件以及在 VisualStudioIDE 中配置 ASP.NET Core 和 Angular 框架来设置工作站。
不是你平常的你好世界!
我们将在本书中编写的代码不仅仅是对全堆栈开发概念的简单演示;我们不会在这里或那里抛出一些工作代码,并期望您将这些点连接起来。我们的目标是使用我们选择的框架创建坚实、逼真的 web 应用,包括服务器端 web API 和客户端 UI,我们还将遵循当前的开发最佳实践。
每章将专门讨论一个核心方面。如果你觉得你已经知道了去那里的路,请跳到下一个。相反,如果您愿意跟随我们完成整个循环,您将有一个伟大的旅程,了解 ASP.NET Core 和 Angular 最有用的方面,以及它们如何协同工作以交付最常见和有用的 web 开发任务,从最琐碎的任务到更复杂的任务。这是一项可以带来回报的投资,因为它将为您带来一个可维护、可扩展、结构良好的项目,以及构建您自己的项目所需的知识。在这次旅行中,我们还将学习如何处理一些重要的高级方面,如 SEO、安全性、性能问题、最佳编码实践和部署,因为如果我们的应用最终在生产环境中发布,它们将变得非常重要。
为了避免让事情变得太无聊,我们将尝试选择在现实世界中也有一些用处的有趣的主题和场景:为了更好地理解我们的意思——这里没有破坏者——你只需要继续阅读。
准备工作区
我们要做的第一件事就是设置我们的工作站;这并不困难,因为我们只需要一小部分基本工具。这些包括 VisualStudio 2019、更新的 No.js 运行时、开发 Web 服务器(如内置的 IIS Express)和一个像样的源代码控制系统,如 Git、Myuri 或 Team Foundation。我们将后者视为理所当然,因为我们很可能已经启动并运行了它。
In the unlikely case you don't, you should really make amends before moving on! Stop reading, go to www.github.com, www.bitbucket.com or whichever online SCM service you like the most, create a free account, and spend some time learning how to effectively use these tools; you won't regret it, that's for sure.
在接下来的部分中,我们将设置 web 应用项目,安装或升级包和库,构建并最终测试我们的工作结果。然而,在这样做之前,我们将花几分钟来理解一个非常重要的概念,这是正确使用这本书而不会(在情感上)受到伤害所必需的——至少在我看来是这样。
免责声明–不要在家里尝试
在继续之前,我们需要了解一些非常重要的事情。如果你是一个经验丰富的网络开发者,你很可能已经知道了;然而,由于这本书是为(几乎)每个人准备的,我觉得尽快处理这件事非常重要。
本书将广泛使用许多不同的编程工具、外部组件、第三方库等。其中大多数,如 TypeScript、NPM、NuGet、大多数.NET Core 框架/包/运行时等,都是与 Visual Studio 2019 一起提供的,而其他的,如 Angular、其所需的 JS 依赖项以及其他第三方服务器端和客户端包,则将从其官方存储库中获取。这些东西意味着以 100%兼容的方式一起工作;然而,它们都会在不可避免的时间过程中发生变化和更新。随着时间的推移,这些更新可能会影响它们之间的交互方式以及项目的运行状况的可能性将会降低。
破译的代码神话
为了尽量减少发生这种情况的可能性,本书将始终使用可使用配置文件处理的任何第三方组件的固定版本/构建。但是,其中一些更新(如 Visual Studio 和/或.NET Framework 更新)可能超出该范围,并可能给项目带来严重破坏。源代码可能停止工作,或者 VisualStudio 可能突然无法正确编译它。
当这样的事情发生时,经验不足的人总是会把责任推到书上。他们中的一些人甚至会开始这样想:
There are a lot of compile errors, hence the source code must be broken!
或者,他们可能会这样想:
The code sample doesn't work: the author must have rushed things here and there, and forgot to test what he was writing.
不言而喻,这些假设很少是正确的,特别是考虑到这些书的作者、编辑和技术评论员在构建源代码、使其在 GitHub 上可用之前在编写、测试和完善源代码上花费的时间,甚至经常将生成的应用的工作实例发布到全球公共网站。
The GitHub repository for this book can be found here:
https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition
It contains a Visual Studio solution file for each chapter (Chapter_01.sln, Chapter_02.sln and so on), as well as an additional solution file (All_Chapters.sln) containing the source code for all the chapters.
任何有经验的开发人员都会很容易理解,如果某个地方有一些坏代码,这些事情中的大多数都无法完成;这本书要想上架是不可能的,除非它有 100%的可用源代码,除了一些可能的小错误,这些错误会很快报告给出版商,并在短时间内在 GitHub 存储库中修复。在看起来不太可能的情况下,例如引发意外的编译错误,新手开发人员应该花费合理的时间试图理解根本原因。
以下是他们应该在回答其他问题之前先尝试回答的问题列表:
- 我是否使用了与本书相同的开发框架、第三方库、版本和构建?
- 如果我更新了一些东西,因为我觉得我需要,我是否意识到可能会影响源代码的更改?我看过相关的变更记录了吗?我是否花了合理的时间四处寻找可能对源代码产生影响的破坏性更改和/或已知问题?
- 该书的 GitHub 存储库是否也受到此问题的影响?我是否尝试将其与我自己的代码进行比较,可能会替换我的代码?
如果这些问题的答案是否,那么这个问题很有可能不是本书造成的。
保持饥饿,保持愚蠢,同时也要负责任
请不要误解我的意思:每当您想要使用较新版本的 VisualStudio 时,请更新您的 Typescript 编译器或升级任何第三方库,我们鼓励您这样做。这正是本书的主要内容——让您完全了解自己在做什么,以及能够做什么,远远超出给定的代码示例。
然而,如果您觉得已经准备好这样做,那么您还必须相应地修改代码;大多数时候,我们谈论的都是琐碎的事情,尤其是现在,你可以用谷歌搜索问题和/或在 StackOverflow 上获得解决方案。他们换了打字机?然后你需要加载新的打字。他们把班级搬到别的地方去了?然后您需要找到新的名称空间并相应地更改它,依此类推。
仅此而已——不多也不少。代码反映了时间的流逝;开发人员只需要跟上流程,在需要时对其执行最小的更改。如果您更新了您的环境,并且没有意识到您必须更改一堆代码行才能使其再次工作,那么您不可能迷失方向并责怪别人而不是您自己。
我是在暗示作者不对本书的源代码负责吗?恰恰相反;作者总是有责任的。他们应该尽最大努力修复所有报告的兼容性问题,同时保持 GitHub 存储库的更新。但是,你也应该有自己的责任感;更具体地说,您应该了解任何开发书籍的工作原理,以及时间流逝对任何给定源代码的不可避免影响。无论作者如何努力维护它,补丁永远不会足够快或全面,使这些代码行始终在任何给定场景下工作。这就是为什么你需要理解的最重要的事情——甚至在书的主题之前——是现代软件开发中最有价值的概念:能够有效地处理将总是发生的不可避免的变化。
谁拒绝理解,谁就注定要失败;这是没有办法的。
建立项目
假设我们已经安装了 Visual Studio 2019 和 Node.js,下面是我们需要做的:
- 下载并安装.NET Core SDK
- 检查.NET CLI 是否将使用该 SDK 版本
- 创建新的.NET Core 和 Angular 项目
- 在 VisualStudio 中签出新创建的项目
- 将所有软件包和库更新到我们选择的版本
让我们开始工作吧。
安装.NET Core SDK
我们可以从 Microsoft 官方 URL(下载最新版本 https://dotnet.microsoft.com/download/dotnet-core 或来自 GitHub 官方发布页面(https://github.com/dotnet/core/blob/master/release-notes/README.md )。
安装非常简单–只需按照向导进行操作,直到完成工作,如下所示:

整个安装过程不应超过几分钟。
检查 SDK 版本
一旦安装了.NET Core SDK,我们需要确认新的 SDK 路径已正确设置和/或.NET CLI 将实际使用它。最快的检查方法是打开命令提示符并键入以下内容:
> dotnet --help
请确保.NET CLI 执行时没有问题,并且给定的版本号与我们刚才安装的版本号相同。
If the prompt is unable to execute the command, go to Control Panel | System | Advanced System Settings | Environment Variables and check that the C:\Program Files\dotnet\ folder is present within the PATH environment variable; manually add it if needed.
创建.NET Core 和 Angular 项目
接下来我们要做的就是创建我们的第一个.NET Core plus Angular 项目——换句话说,我们的第一个应用。我们将使用.NET Core SDK 附带的 Angular 项目模板来实现这一点,因为它通过添加所有必需的文件和一个通用配置提供了一个方便的起点,我们以后可以自定义该配置以更好地满足我们的需要。
在命令行中,创建一个根文件夹,该文件夹将包含我们的所有项目并进入其中。
In this book, we're going to use \Projects\as our root folder: non-experienced developers are strongly advised to use the same folder to avoid possible path errors and/or issues related to path names being too long (Windows 10 has a 260-character limit that can create some issues with some deeply nested NPM packages). It would also be wise to use something other than the C: drive to avoid permission issues.
到达后,键入以下命令以创建 Angular 应用:
> dotnet new angular -o HealthCheck
此命令将在C:\Projects\HealthCheck\文件夹中创建我们的第一个 Angular 应用。正如我们很容易猜到的,它的名字将是HealthCheck:有一个很好的理由这样一个名字,我们将在短时间内看到(没有剧透,记得吗?)。
在 VisualStudio 中打开新项目
现在是时候启动 Visual Studio 2019 并对我们新创建的项目进行快速检查了。这可以通过双击HealthCheck.csproj文件或通过 VS2019 主菜单(文件|打开|项目/解决方案)完成
完成后,我们应该能够看到项目的源代码树,如下面的屏幕截图所示:

正如我们从前面的屏幕截图中看到的,它是一个相当紧凑的样板文件,只包含所需的.NET Core 和 Angular 配置文件、资源和依赖项:这正是我们开始编码所需要的!
但是,在此之前,让我们继续简要回顾一下。通过查看各种文件夹,我们可以看到,工作环境包含以下内容:
- 默认的 ASP.NET MVC
/Controllers/和/Pages/文件夹,都包含一些工作示例。 /ClientApp/src/文件夹,其中包含一些 TypeScript 文件,其中包含示例 Angular 应用的源代码。/ClientApp/e2e/文件夹包含一些使用量角器测试框架构建的样本 E2E 测试。/wwwroot/文件夹,VisualStudio 将使用该文件夹构建客户端代码的优化版本,无论何时我们需要在本地执行或在别处发布。该文件夹最初为空,但将在项目第一次运行时填充。
如果我们花一些时间浏览这些文件夹并查看它们的内容,我们将看到.NET Core 开发人员如何通过 Angular 项目设置过程简化.NET。如果我们将此样板与 Visual Studio 2015/2017 附带的内置 Angular 2.x/5.x 模板进行比较,我们将看到可读性和代码清洁度方面的巨大改进,以及更好的文件和文件夹结构。此外,那些在最近与 Grunt 或 Gulp 等任务运行程序和/或 webpack 等客户端构建工具进行过斗争的人很可能会意识到,此模板与此不同:所有打包、构建和编译任务都完全由 Visual Studio 通过底层的.NET Core 和 Angular CLI 来处理,具有特定的开发和生产装载策略。
Truth be told, the choice to use a pre-made template such as this one comes with its flaws. The fact that the back-end (the .NET Core APIs) and the front-end (the Angular app) are both hosted within a single project can be very useful, and will greatly ease up the learning and development phase, but it's not a recommended approach for production.
Ideally, it would be better to split the server-side and the client-side parts into two separate projects to enforce decoupling, which is paramount when building microservice-based architectures. That said, being able to work with the back-end and the front-end within the same project is a good approach for learning, thus making these templates an ideal approach for the purpose of a programming book – and that's why we're going to always use them.
在继续之前,我们一定要执行一个快速的测试运行,以确保我们的项目正常工作。这就是下一节的内容。
执行测试运行
幸运的是,在这一点上执行测试运行就像点击运行按钮或F**5键一样简单:

这是一个很好的一致性检查,以确保我们的开发系统配置正确。如果我们看到示例 Angular SPA 启动并运行,如前面的屏幕截图所示,这意味着我们可以开始了;如果我们不这样做,这可能意味着我们缺少了一些东西,或者我们有一些冲突的软件阻止 Visual Studio 和/或底层.NET Core 和 Angular CLI 正确编译项目。
为了解决这个问题,我们可以尝试执行以下操作:
- 卸载/重新安装 Node.js,因为我们可能安装了过时的版本。
- 卸载/重新安装 Visual Studio 2019,因为我们当前的安装可能已损坏。.NET Core SDK 应该已经随附;但是,我们也可以尝试重新安装它。
如果一切仍然失败,我们可以尝试在干净的环境(无论是物理系统还是虚拟机)中安装 VS2019 和前面提到的软件包,以克服与当前操作系统配置相关的任何可能问题。
If none of these work, the best thing we can do is to ask for specific support on the .NET Core community forum at https://forums.asp.net/1255.aspx/1?ASP+NET+Core.
如果我们成功地执行了测试运行,这意味着示例应用正在工作:我们准备继续
总结
到目前为止,一切顺利;我们刚刚建立了一个即将到来的工作框架。在继续之前,让我们快速回顾一下我们在本章中所做的(可以说是学到的)工作。
我们简要介绍了我们选择的平台——ASP.NET Core 和 Angular——并承认它们在构建现代 web 应用过程中的综合潜力。我们花了一些宝贵的时间回忆过去 3 年中发生的事情,并总结了两个开发团队重新启动和改进各自框架的努力。这些概述非常有助于列举和理解我们仍在使用它们而不是其不断增长的竞争对手的主要原因。
在那之后,我们尽了最大的努力来理解当今可用于创建 web 应用的各种方法之间的差异:SPA、MPA 和 PWA。我们还解释说,由于我们将使用.NET Core 和 Angular,因此我们将坚持 SPA 方法,但我们也将实现大多数 PWA 功能,如服务人员和 web 清单文件。为了再现真实的生产案例场景,我们还介绍了最常见的 SPA 功能,首先从技术 Angular 来看,然后设身处地为典型的产品所有者着想,同时列举他们的期望。
最后,但并非最不重要的是,我们学会了如何正确设置我们的开发环境;我们选择使用.NET Core SDK 附带的最新 Angular SPA 模板,从而采用标准 ASP.NET Core 方法。我们使用.NET Core CLI 创建了我们的应用,然后在 Visual Studio 上对其进行了测试,以确保其正常工作。
在下一章中,我们将深入了解刚刚创建的示例应用,以便正确理解.NET Core后端和 Angular前端如何执行各自的任务以及它们可以一起做什么。
建议的主题
敏捷开发、Scrum、极限编程、MVC 和 MVVM 架构模式、ASP.NET Core、.NET Core、Roslyn、CoreCLR、RyuJIT、单页应用(SPA)、渐进式 Web 应用(PWA)、原生 Web 应用(NWA)、多页应用(MPA)、NuGet、NPM、ECMAScript 6、JavaScript、TypeScript、网页包、SystemJS、RxJS、缓存控制、,HTTP 头、.NET 中间件、Angular Universal、服务器端呈现(SSR)、提前(AOT)编译器、服务人员、web 清单文件。
工具书类
- 原生网络应用、亨里克·乔雷泰格、2015:https://blog.andyet.com/2015/01/22/native-web-apps/
- 敏捷软件开发宣言,Kent Beck、Mike Beedle 和其他许多人,2001 年:https://agilemanifesto.org/
** ASP.NET 5 已死——引入 ASP.NET Core 1.0 和.NET Core 1.0:http://www.hanselman.com/blog/ASPNET5IsDeadIntroducingASPNETCore10AndNETCore10.aspx*
** ASP.NET Core 和.NET Core 的更新:https://blogs.msdn.microsoft.com/webdev/2016/02/01/an-update-on-asp-net-core-and-net-core/
- ASP.NET Core 1.1.0 发行说明:https://github.com/aspnet/AspNetCore/releases/1.1.0
- ASP.NET Core 1.1.0 提交列表:https://github.com/dotnet/core/blob/master/release-notes/1.1/1.1-commits.md
- ASP.NET Core 2.1.0 发行说明:https://docs.microsoft.com/en-US/aspnet/core/release-notes/aspnetcore-2.1
- ASP.NET Core 2.1.0 提交列表:https://github.com/dotnet/core/blob/master/release-notes/2.1/2.1.0-commit.md
- ASP.NET Core 2.2.0 发行说明:https://docs.microsoft.com/en-US/aspnet/core/release-notes/aspnetcore-2.2
- ASP.NET Core 2.2.0 提交列表:https://github.com/dotnet/core/blob/master/release-notes/2.2/2.2.0/2.2.0-commits.md
- ASP.NET Core 3.0.0 发行说明:https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0
- ASP.NET Core 3.0 发布页面:https://github.com/dotnet/core/tree/master/release-notes/3.0
** ASP.NET Core 3.1.0 发行说明:https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-1* Libscore:JavaScript 库使用情况统计:http://libscore.com/#libs* 网站使用 JavaScript 库:https://w3techs.com/technologies/overview/javascript_library/all* 米什科·海弗利和布拉德·格林——基调——2014 年国家地理论坛**:https://www.youtube.com/watch?v=r1A1VR0ibIQ* AngularJS 1.7.9 变更记录:https://github.com/angular/angular.js/blob/master/CHANGELOG.md* ASP.NET Core 与角 2:https://www.packtpub.com/application-development/aspnet-core-and-angular-2* ASP.NET Core 2 和 Angular 5:https://www.packtpub.com/application-development/aspnet-core-2-and-angular-5* ASP.NET Core 2 和 Angular 5-视频课程:https://www.packtpub.com/web-development/asp-net-core-2-and-angular-5-video* Angular 更新指南:https://update.angular.io* 角语言服务:https://angular.io/guide/language-service* 不推荐的 API 和特性:https://angular.io/guide/deprecations* 什么是角常春藤?:https://blog.ninja-squad.com/2019/05/07/what-is-angular-ivy/* 渐进式网络应用:https://developers.google.com/web/progressive-web-apps/* 渐进式网络应用:逃离标签而不失去灵魂:https://infrequently.org/2015/06/progressive-apps-escaping-tabs-without-losing-our-soul/* 到底是什么让某些东西成为一个进步的 Web 应用?:https://infrequently.org/2016/09/what-exactly-makes-something-a-progressive-web-app/* Scrum(软件开发):https://en.wikipedia.org/wiki/Scrum_(软件开发)* 瀑布模型:https://en.wikipedia.org/wiki/Waterfall_model* ASP.NET Core 论坛:https://forums.asp.net/1255.aspx/1?ASP+网络+核心*******************
二、环顾四周
现在我们的项目已经创建,现在是时候快速浏览一下,并尝试了解.NET Core SPA 模板为使其工作所做的一些艰苦工作。
……嘿,等一下!我们不应该跳过所有这些设置技术细节,直接开始编码吗?
事实上,是的,我们肯定会在一段时间内这样做。然而,在这样做之前,明智的做法是强调已经到位的代码的几个方面,这样我们就知道如何在我们的项目中提前有效地移动:在哪里找到服务器端和客户端代码,在哪里放置新内容,如何更改初始化参数,等等。这也是一个很好的机会来回顾我们对 VisualStudio 环境的基本知识以及我们需要的软件包。
这正是我们在本章要做的。更准确地说,以下是我们将要讨论的主要主题:
- 解决方案概述:我们将要处理的内容的高层总结
- .NET Core 后端:Razor 页面、控制器、配置文件等
- Angular 前端:工作区、
ClientApp文件夹、Angular 初始化周期等 - 开始工作:缓存概念,移除一些我们不再需要的.NET 控制器和 Angular 组件等等
*IMPORTANT! The sample code we're reviewing here is the code that comes with the default Angular SPA Visual Studio template shipped by .NET Core SDK 3.1 at the time of writing—the one created with the dotnet new angular command. In the (likely) event that this sample code is updated in future releases, ensure you get the former source code from the web using this book's official NuGet repository and use it to replace the contents of your project folder. Caution: failing to do that could result in you working with different sample code from the code featured in this book.
技术要求
在本章中,第 1 章准备就绪中列出的所有先前技术要求将适用,无需额外资源、库或包。
本章代码文件可在此处找到:https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition/tree/master/Chapter_02/ 。
解决方案概述
首先引人注目的是,正如我们已经提到的,标准 ASP.NET Core 解决方案的布局与 ASP.NET 4 和早期版本中的布局大不相同。但是,如果我们已经有了一些 ASP.NET MVC 的经验,我们应该能够区分.NET Core后端部分和 Angular前端部分,并找出这两个方面是如何相互作用的。
.NET Core后端堆栈包含在以下文件夹中:
Dependencies虚拟文件夹,基本上取代了旧的Resources文件夹,包含构建和运行项目所需的所有内部、外部和第三方引用。我们将添加到项目中的所有对 NuGet 包的引用也将放在那里。/Controllers/文件夹,自上一版本的 MVC 框架以来,它已随任何基于 MVC 的 ASP.NET 应用一起提供。/Pages/文件夹,其中包含一个 Razor 页面-Error.cshtml-用于处理运行时和/或服务器错误(稍后将详细介绍)- 根级别的文件-
Program.cs、Startup.cs,和appsettings.json-将决定我们的 web 应用的配置,包括模块和中间件、编译设置和发布规则;我们将在一段时间内解决所有问题。
角型前端包含以下文件夹:
/wwwroot/文件夹,其中将包含已编译的准备发布我们应用的内容:HTML、JS 和 CSS 文件,以及字体、图像以及我们希望用户能够访问的静态文件的所有其他内容。/ClientApp/根文件夹,它承载 Angular(和 package manager)配置文件,以及几个重要的子文件夹,我们将对这些子文件夹进行概述。/ClientApp/src/文件夹,其中包含 Angular 应用源代码文件。如果我们观察它们,我们可以看到它们都有一个.ts扩展,这意味着我们将使用类型脚本编程语言(稍后我们将对此进行详细介绍)。- 使用量角器测试框架构建的
/ClientApp/e2e/文件夹,包含一些样本端到端(E2E)测试。
让我们快速回顾一下此结构中最重要的部分。
NET Core 后端
如果您来自 ASP.NET MVC 框架,您可能想知道为什么此模板不包含/Views/文件夹:我们的 Razor 视图到哪里去了?
事实上,此模板不使用视图。如果我们仔细想想,原因很明显:单页应用(SPA)也可以去掉它们,因为它们只能在一个 HTML 页面中运行一次。在这个模板中,这样的页面就是/ClientApp/src/ folder/index.html文件,我们可以清楚地看到,它也是一个静态页面。此模板提供的唯一服务器端-呈现 HTML 页面是/Pages/Error.cshtmlRazor 页面,用于处理 Angular 引导阶段之前可能发生的运行时和/或服务器错误。
剃须刀页面
那些从未听说过剃须刀页面的人应该花 5-10 分钟看看下面的指南,其中解释了它们是什么以及它们是如何工作的:https://docs.microsoft.com/en-us/aspnet/core/razor-pages/.
简而言之,Razor 页面是在.NET Core 2.0 中引入的,代表了实现 ASP.NET Core MVC 模式的另一种方式。Razor 页面非常类似于 Razor 视图,具有相同的语法和功能,但它还包含控制器源代码,该源代码放在一个单独的文件中:这些文件与带有附加扩展名的页面共享相同的名称。
为了更好地显示 Razor 页面的**.**cshtml和.cshtml.cs文件之间的依赖关系,Visual Studio 方便地将后者嵌套在前者中,如下面的屏幕截图所示:

……嘿,等一下:我以前在哪里看过这部电影?
是的,这确实敲响了警钟:作为标准 MVC控制器+视图方法的精简版,Razor 页面与旧的.aspx+.aspx.csASP.NET Web 表单非常相似。
控制器
如果剃须刀页面包含控制器,为什么我们还有一个/Controller/文件夹?原因很简单:并不是所有的控制器都要服务于服务器呈现的HTML 页面(或视图)。例如,它们可以输出 JSON 输出(RESTAPI)、基于 XML 的响应(SOAPWeb 服务)、静态或动态创建的资源(JPG、JS 和 CSS 文件),甚至是没有内容体的简单 HTTP 响应(如 HTTP 301 重定向)。
事实上,Razor 页面最重要的优点之一是,它们允许在服务标准 HTML 内容的内容(我们通常称之为页面-和 HTTP 响应的其余部分之间解耦,HTTP 响应的其余部分可以松散地定义为服务 API。我们的.NET Core+Angular 模板完全支持这种划分,它提供了两个主要好处:
- 关注点分离:使用页面将强制分离我们如何加载服务器端页面(1%)和我们如何提供 API(99%)。所显示的百分比对于我们的特定场景是有效的:我们将遵循 SPA 方法,这是关于服务和调用 Web API 的。
- 单一责任:每个剃须刀页面都是独立的,因为它的视图和控制器是相互交织、组织在一起的。这遵循了单一责任原则,这是一种计算机编程良好实践,建议每个模块、类或函数应对软件提供的功能的单个部分负责,并且该责任也应完全由该类封装。
通过确认所有这些,我们已经可以推断,/Controllers/文件夹中包含的单个样本WeatherForecastController是用来公开一组 Web API 的,这些 API 将被 Angular前端使用。要快速检查,点击F5以调试模式启动项目,并通过键入以下 URL 执行默认路由:https://localhost:44334/WeatherForecast。
The actual port number may vary, depending on the project configuration file: to set a different port for debug sessions, change the iisSettings | iisExpress | applicationUrl and/or iisSettings | iisExpress | sslPortvalues in the Properties/launchSettings.json file.
这将执行WeatherForecastController.cs文件中定义的Get()方法。通过查看源代码我们可以看到,这种方法有一个IEnumerable<WeatherForecast>返回值,这意味着它将返回一个WeatherForecast类型的对象数组。
如果将前面的 URL 复制到浏览器中并执行它,您应该会看到随机生成的数据的 JSON 数组,如以下屏幕截图所示:

不难想象谁会要求这些价值观。
配置文件
现在让我们看看根级别的配置文件及其用途:Program.cs、Startup.cs和appsettings.json。这些文件包含 web 应用的配置,包括模块和中间件、编译设置和发布规则。
至于WeatherForecast.cs文件,它只是一个强类型类,设计用于反序列化WeatherForecastController返回的 JSON 对象,我们在上一节中已经看到了这一点:换句话说,它是一个JSON 视图模型——一个专为包含反序列化的而设计的视图模型 JSON 对象。在我们看来,模板作者应该把它放在/ViewModel/文件夹中(或者类似的东西),而不是放在根级别。不管怎样,我们暂时忽略它,因为它不是一个配置文件,我们只关注其余部分。
Program.cs
Program.cs文件很可能会引起大多数经验丰富的 ASP.NET 程序员的兴趣,因为它不是我们通常在 web 应用项目中看到的东西。首先在 ASP.NET Core 1.0 中引入,Program.cs文件的主要目的是创建一个WebHostBuilder,一个由.NET Core framework 用来设置和构建IWebHost的对象,它将承载我们的 web 应用。
Web 主机与 Web 服务器
知道这个很好,但是什么是w****eb 主机?简而言之,主机是任何 ASP.NET Core 应用的执行上下文。在基于 web 的应用中,主机必须实现IWebHost接口,该接口公开了一组与 web 相关的特性和服务,以及一个Start方法。web 主机引用将处理请求的服务器。
前面的语句可能导致 web 主机和 web 服务器是同一事物的假设;然而,很重要的一点是要理解它们不是,因为它们的用途非常不同。简单地说,主机负责应用启动和生存期管理,而服务器负责接受 HTTP 请求。主机的部分责任包括确保应用的服务和服务器可用并正确配置。
我们可以将主机视为服务器的包装器:主机被配置为使用特定的服务器,而服务器不知道其主机。
有关 web 主机、WebHostBuilder类和Setup.cs文件用途的更多信息,请参阅以下指南:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/ 。
如果我们打开Program.cs文件并查看代码,我们可以很容易地看到WebHostBuilder是以一种非常简单的方式构建的,如下所示:
public class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}
WebHost.CreateDefaultBuilder(args)是在.NET Core 2 中引入的,它是对其 1.x 版本的一个重大改进,因为它简化了建立基本用例所需的源代码量,从而更容易开始新项目。
为了更好地理解这一点,让我们来看一看样本[0]的等价物,就像它在.NET Core 1。
public class Program
{
public static void Main(string[] args)
{
var host = new WebHostBuilder()
.UseKestrel()
.UseContentRoot(Directory.GetCurrentDirectory())
.UseIISIntegration()
.UseStartup<Startup>()
.UseApplicationInsights()
.Build();
host.Run();
}
}
这用于执行以下步骤:
- 设置
Kestrelweb 服务器 - 设置内容根文件夹,即查找
appsettings.json文件和其他配置文件的位置 - 设置 IIS 集成
- 定义要使用的
Startup类(通常在Startup.cs文件中定义) - 最后,在现在配置的
IWebHost上执行构建和运行
在.NET Core 1.x 中,所有这些步骤都必须在此处显式调用,并且在Startup.cs文件中手动配置;尽管.NETCore2 和 3 我们仍然可以做到这一点,但使用WebHost.CreateDefaultBuilder()方法通常更好,因为它可以处理大部分工作,并且可以随时更改默认值。
If you're curious about this method, you can even take a peek at the source code on GitHub: https://github.com/aspnet/MetaPackages/blob/master/src/Microsoft.AspNetCore/WebHost.cs.
At the time of writing, the WebHost.CreateDefaultBuilder() method implementation starts at line #148.
如我们所见,CreateWebHostBuilder方法以对UseStartup<Startup>()的链式调用结束,以指定 web 主机将使用的启动类型。这个类型是在Startup.cs文件中定义的,这就是我们要讨论的。
Startup.cs
如果您是一名经验丰富的.NET 开发人员,您可能已经熟悉了Startup.cs文件,因为它最初是在基于 OWIN 的应用中引入的,用来取代以前由好的老Global.asax文件处理的大部分任务。
Open Web Interface for .NET (OWIN) comes as part of project Katana, a flexible set of Components released by Microsoft back in 2013 for building and hosting OWIN-based web applications. For additional info, refer to https://www.asp.net/aspnet/overview/owin-and-katana.
然而,相似之处到此为止;该类已被完全重写为尽可能的可插拔和轻量级,这意味着它将只包含和加载完成应用任务所必需的内容。
更具体地说,在.NET Core 中,Startup.cs文件是我们可以执行以下操作的地方:
- 在
ConfigureServices()方法中添加并配置服务和依赖注入 - 通过在
Configure()方法中添加所需的中间件来配置 HTTP 请求管道
为了更好地理解这一点,让我们来看看下面的代码,这些代码是从我们选择的项目模板中导出的。
// This method gets called by the runtime. Use this method to
// configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days.
// You may want to change this for production scenarios,
// see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
if (!env.IsDevelopment())
{
app.UseSpaStaticFiles();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
});
app.UseSpa(spa =>
{
// To learn more about options for serving an Angular SPA
// from ASP.NET Core,
// see https://go.microsoft.com/fwlink/?linkid=864501
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
}
这是Configure()方法实现,正如我们刚才所说,我们可以在这里设置和配置 HTTP 请求管道。
代码可读性很强,因此我们可以轻松理解此处发生的情况:
- 第一组行具有一个
if-then-else语句,该语句实现了两种不同的行为来处理开发和生产中的运行时异常,在前一种情况下抛出异常,在后一种情况下向最终用户显示不透明的错误页面;这是一种用很少几行代码处理运行时异常的简洁方法。 - 紧接着,我们可以看到第一块中间件:
HttpsRedirection,用于处理 HTTP 到 HTTPS 的重定向;StaticFiles,为/wwwroot/文件夹下的静态文件提供服务;和SpaStaticFiles,为/ClientApp/src/img/文件夹(Angular 应用的assets文件夹)中的静态文件提供服务。如果没有最后两个中间件,我们将无法为本地托管的资产(如 JS、CSS 和图像)提供服务;这就是他们在管道中的原因。另外,请注意这些方法是如何在没有参数的情况下调用的:这只意味着它们的默认设置对我们来说已经足够了,所以这里没有任何配置或覆盖 - 在三个包之后,就是
Endpoints中间件,它将添加所需的路由规则,以将某些 HTTP 请求映射到我们的 Web API 控制器。我们将在接下来的章节中详细讨论这一点,届时我们将讨论服务器端路由方面;现在,我们只需要了解一个活动映射规则,它将捕获所有类似于控制器名称(和/或可选动作名称和/或可选 IDGET参数)的 HTTP 请求,并将它们路由到该控制器。这正是我们能够从 web 浏览器调用WeatherForecastController.Get()方法并接收结果的原因。 - 最后但并非最不重要的是
UseSpa中间件,它通过两个配置设置添加到 HTTP 管道中。第一个很容易理解:它只是 Angular 应用根文件夹的源路径。在这个模板的场景中,它是/ClientApp/文件夹。第二个只在开发场景中执行,要复杂得多:简单地说,UseAngularCliServer()方法告诉.NET Core 将所有发往 Angular 应用的请求传递给 Angular CLI 服务器的内存实例:这对于开发场景非常有用,因为我们的应用将始终提供最新的 CLI 构建资源,而无需每次手动运行 Angular CLI 服务器;同时,由于额外的开销和明显的性能影响,它对于生产场景并不理想。
It's worth noting that middlewares added to the HTTP pipeline will be processed in registration order, from top to bottom; this means that the StaticFile middleware will take priority over the Endpoint middleware, which will take place before the Spa middleware, and so on. Such behavior is very important and could cause unexpected results if taken lightly, as shown in the following StackOverflow thread:
https://stackoverflow.com/questions/52768852/.
让我们进行一个快速测试,以确保我们正确了解这些中间件的工作原理:
- 从 VisualStudio 的解决方案资源管理器中,转到
/wwwroot/文件夹,并将新的test.html页面添加到我们的项目中。 - 完成后,填写以下内容:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Time for a test!</title>
</head>
<body>
Hello there!
<br /><br />
This is a test to see if the StaticFiles middleware is
working properly.
</body>
</html>
现在,让我们使用 Run 按钮或F5键盘键在调试模式下启动应用,并将地址栏指向以下 URL:https://localhost:44334/test.html。
Again, the TCP/IP port number may vary: edit the Properties/launchSettings.json file if you want to change it.
我们应该能够看到我们的test.html文件,如下面的屏幕截图所示:

根据我们刚才了解到的情况,我们知道这个文件是通过StaticFiles中间件提供的。现在让我们回到我们的Startup.cs文件,注释掉app.UseStaticFiles()调用,以防止加载StaticFiles中间件:
app.UseHttpsRedirection();
// app.UseStaticFiles();
app.UseSpaStaticFiles();
完成后,再次运行应用并尝试返回到上一个 URL,如以下屏幕截图所示:

正如所料,test.html静态文件不再提供服务:文件仍然存在,但StaticFile中间件未注册,无法处理。因此,现在未经处理的 HTTP 请求会一直通过 HTTP 管道,直到到达Spa中间件,该中间件充当“一网打尽”的角色,并将其抛给客户端应用。但是,由于没有与test.html模式匹配的客户端路由规则,因此请求最终被重定向到应用的起始页。
故事的最后一部分完全记录在浏览器的控制台日志中,如前一个屏幕截图所示:无法匹配任何路由错误消息来自 Angular,这意味着我们的请求通过了整个.NET Core后端堆栈。
现在,我们已经证明了我们的观点,我们可以通过删除注释来恢复StaticFiles中间件,然后继续。
For additional information regarding the StaticFiles middleware and static file handling in .NET Core, visit the following URL:
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/static-files.
总之,由于 Angular SPA 模板附带的Startup.cs文件已经具备了我们所需的一切,我们可以暂时保持原样。
感谢这个简短的概述,我们现在应该完全了解如何处理 web 应用接收的 HTTP 请求。让我们试着总结一下:
- 每个请求都将由.NET Core 后端接收,该后端将通过检查 HTTP 管道中注册的各种中间件(按注册顺序)尝试在服务器端级别进行处理:在我们的具体场景中,首先检查
/wwwroot/文件夹中的静态文件,然后是/ClientApp/src/img/文件夹中的静态文件,然后是映射到 Web API 控制器/端点的路由中的静态文件。 - 如果上述中间件之一能够匹配并处理请求,.NET Core 后端将处理该请求;相反,
Spa中间件将请求传递给 Angular客户端应用,该应用将使用其客户端路由规则进行处理(稍后将详细介绍)。
appsettings.json
appsettings.json文件只是旧的Web.config文件的替代品;XML 语法已被可读性更强、更不冗长的 JSON 格式所取代。此外,新的配置模型基于键/值设置,可以使用集中式接口从各种来源(包括但不限于 JSON 文件)检索这些设置。
一旦检索到它们,就可以在我们的代码中通过文字字符串使用依赖项注入(使用香草IConfiguration类)轻松访问它们:
public SampleController(IConfiguration configuration)
{
var myValue = configuration["Logging:IncludeScopes"];
}
或者,我们可以通过使用自定义的POCO类使用强类型方法来实现相同的结果(稍后我们将讨论这个问题)。
值得注意的是,主文件下面还有一个嵌套的appsettings.Development.json文件。这样的文件与旧的Web.Debug.config文件的用途相同,后者在 ASP.NET 4.x 时期被广泛使用。简而言之,这些附加文件可用于为特定环境指定附加配置键/值对(和/或覆盖现有键/值对)。
为了更好地理解这个概念,让我们来看看这两个文件的内容。
以下是appsettings.json文件:
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*"
}
这是appsettings.Development.json文件:
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
}
}
如我们所见,我们的应用的Logging.LogLevel.Default值在第一个文件中设置为Warning;但是,每当我们的应用在开发模式下运行时,第二个文件将覆盖该值,将其设置为Debug,并添加System和Microsoft日志级别,将它们都设置为Information。
Back in .NET Core 1.x, this overriding behavior had to be specified manually within the Startup.cs file; in .NET Core 2, the WebHost.CreateDefaultBuilder() method within the Program.cs file takes care of that automatically, by assuming that you can rely on this default naming pattern and don't need to add another custom .json configuration file.
假设我们理解了这里的所有内容,那么我们已经完成了对.NET Core后端部分的检查;现在是时候进入 Angular前端文件夹和文件了。
有 Angular 的前端
模板的前端部分可能会被视为更复杂的理解,因为 Angular 就像大多数客户端框架一样,以惊人的速度发展,因此在其核心架构、工具链管理、编码语法、模板和设置方面经历了许多突破性的变化。
出于这个原因,花点时间了解模板附带的各种文件的作用是非常重要的:这个简要概述将从根级配置文件开始,它还将使用我们需要使用的 Angular 软件包(及其依赖项)的最新版本进行更新。
工作空间
Angular 工作空间是包含 Angular 文件的文件系统位置:应用文件、库、资产等的集合。在我们的模板中,与大多数.NET Core 和 Angular 项目一样,工作区位于/ClientApp/文件夹中,该文件夹被定义为工作区根目录。
工作区通常由用于创建应用的 CLI 命令创建和初始化:您还记得我们在第一章、准备中使用的dotnet new命令吗?这就是我们要讨论的:模板的 Angular 部分是由该命令创建的。我们可以使用 Angular CLI,通过使用ng new命令实现相同的结果。
在应用和/或其库上运行的任何 CLI 命令(如添加或更新新软件包)都将从工作区文件夹中执行。
angular.json
工作区中最重要的角色是由 CLI 在工作区根目录中创建的angular.json文件:这是工作区配置文件,包含由 CLI 提供的所有构建和开发工具的工作区范围和特定于项目的配置默认值。
It's worth noting that all the paths defined within this file are meant to be relative to the workspace root folder: in our scenario, for example, src/main.ts will resolve to /ClientApp/src/main.ts.
文件顶部的前几个属性定义了工作空间和项目配置选项:
version:配置文件版本。newProjectRoot:相对于工作区根文件夹创建新项目的路径。我们可以看到,该值被设置为项目文件夹,该文件夹甚至不存在。这是完全正常的,因为我们的工作区将包含两个已定义文件夹中的 Angular 项目:我们的 HealthCheck Angular 应用,位于/ClientApp/src/文件夹中,以及端到端测试,位于/ClientApp/e2e/文件夹中。因此,没有必要定义一个newProjectRoot——同样重要的是,不要使用现有文件夹,以避免覆盖某些现有内容的风险。projects:一个容器项,为工作区中的每个项目承载一个子部分,包含项目特定的配置选项defaultProject:默认项目名称任何未指定项目名称的 CLI 命令都将在此项目上执行。
It's worth noting that the angular.json file follows a standard generic-to-specific cascading rule. All configuration values set at the workspace level will be the default values for any project, and can be overridden by those set at the project level. These, in turn, can be overridden by* command-line* values available when using the CLI.
It's also worth mentioning that, before Angular 8, manually modifying the angular.json file was the only way to make changes to the workspace config.
这就是我们需要知道的全部,至少目前是这样:所有的配置值对于我们的场景来说已经足够好了,因此我们现在就让它们保持原样。
在Angular 7之前,手动修改angular.json文件是对工作区配置进行更改的唯一方式:随着工作区 API的引入Angular 8的改变,现在可以更方便地读取和修改这些配置。有关此新功能的更多信息,我们建议查看以下页面:
https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/core/README.md#workspaces 。
package.json
package.json文件为Node Package Managernpm****配置文件;它基本上包含了开发者希望在项目开始前恢复的npm 包列表。那些已经知道 npm 是什么以及它是如何工作的人可以跳到下一节,而那些不知道的人应该继续阅读。
npm 最初是作为 JavaScript 运行时环境 Node.js 的默认包管理器。不过,近年来,它还被用于托管许多独立的 JavaScript 项目、库和任何类型的框架,包括Angular。最终,它成为 JavaScript 框架和工具的事实上的包管理器。那些从未使用过它的人可以将其视为 JavaScript 世界的NuGet。
尽管 npm 主要是一个命令行工具,但从 VisualStudio 使用它最简单的方法是正确配置一个package.json文件,其中包含我们希望获取、恢复和保持最新的所有 npm 包。这些包被下载到我们项目目录中的/node_modules/文件夹中,默认情况下在 VisualStudio 中是隐藏的;但是,可以从 npm 虚拟文件夹查看所有检索到的包。一旦我们添加、删除或更新了package.json文件,Visual Studio 将自动相应地更新该文件夹。
在我们使用的 Angular SPA 模板中,附带的package.json包含大量的包—所有Angular包,加上大量的依赖项、工具和第三方实用程序,如Karma(JavaScript/TypeScript 的优秀测试运行程序)。
在继续前行之前,让我们进一步看看我们的.t0t0.x 文件,并试图从中得到最大的好处。我们可以看到所有包是如何在一个标准的 JSON 对象中列出的,该对象完全由{ To.t1·key 值} T2×对对构成;软件包名称为键,而值用于指定版本号。我们可以输入精确的内部版本号,也可以使用标准的npmJS语法指定自动更新规则使用支持的前缀绑定到自定义版本范围,例如:
- 波浪线(~):值
"~1.1.4"将匹配所有 1.1.x 版本,不包括 1.2.0、1.0.x 等。 - 插入符号(^):值
"^1.1.4"将匹配 1.1.4 以上的所有内容,2.0.0 及以上除外。
这是另一个智能感知派上用场的场景,因为它还可以直观地解释这些前缀的实际含义。
For an extensive list of available npmJS commands and prefixes, it's advisable to check out the official npmJS documentation at https://docs.npmjs.com/files/package.json.
升级(或降级)Angular
正如我们所见,Angular SPA 模板对所有 Angular 相关包使用固定版本号;这绝对是一个明智的选择,因为我们无法保证新版本将与现有代码无缝集成,而不会引发一些潜在的问题和/或编译器错误。不用说,随着时间的推移,版本号自然会增加,因为模板开发人员一定会努力使他们的优秀作品保持最新。
也就是说,以下是贯穿本书的最重要的 Angular 软件包和发行版(不包括稍后可能添加的一小部分附加软件包):
"@angular/animations": "9.0.0",
"@angular/common": "9.0.0",
"@angular/compiler": "9.0.0",
"@angular/core": "9.0.0",
"@angular/forms": "9.0.0",
"@angular/platform-browser": "9.0.0",
"@angular/platform-browser-dynamic": "9.0.0",
"@angular/platform-server": "9.0.0",
"@angular/router": "9.0.0",
"@nguniversal/module-map-ngfactory-loader": "9.0.0-next.9",
"@angular-devkit/build-angular": "0.900.0",
"@angular/cli": "9.0.0",
"@angular/compiler-cli": "9.0.0",
"@angular/language-service": "9.0.0"
前者可以在dependencies部分找到,而后者是devDependencies部分的一部分。我们可以看到,所有软件包的版本号基本相同,对应于撰写本文时可用的最新最终版本。
The version of Angular 9 that we use in this book was released a few weeks before this book hit the shelves: we did our best to use the latest available (non-beta, non-rc) version to give the reader the best possible experience with the most recent technology available. That said, that freshness will eventually decrease over time and this book's code will start to become obsolete: when it happens, try to not blame us for that!
如果我们想确保我们的项目和本书的源代码之间尽可能高的兼容性,我们肯定应该采用相同的版本,在撰写本文时,它也对应于最新的稳定版本。我们可以通过更改版本号轻松执行升级或降级;一旦我们保存文件,Visual Studio应该通过npm自动获取新版本。在不太可能的情况下,手动删除旧包并发布完整重建应该足以解决问题。
一如既往,我们可以自由覆盖此类行为并获取这些软件包的更新(或更旧)版本,前提是我们正确理解后果,并根据第 1 章中的免责声明,准备。
如果您在更新您的package.json文件时遇到问题,例如包冲突或损坏的代码,请确保您从本书的官方 GitHub 存储库下载完整的源代码,其中包括用于编写、审阅和测试本书的相同package.json文件;它肯定会确保与您将在这里找到的源代码有很好的兼容性。
升级(或降级)其他软件包
正如我们所料,如果我们将 Angular 升级(或降级)到 5.0.0,我们还需要处理一系列可能需要更新(或降级)的其他 npm 包。
以下是我们将在本书的package.json文件中使用的完整软件包列表(包括 Angular 软件包),分为dependencies、devDependencies和optionalDependencies部分:重要软件包在以下代码段中突出显示,请务必对其进行三重检查!
"dependencies": {
"@angular/animations": "9.0.0",
"@angular/common": "9.0.0",
"@angular/compiler": "9.0.0",
"@angular/core": "9.0.0",
"@angular/forms": "9.0.0",
"@angular/platform-browser": "9.0.0",
"@angular/platform-browser-dynamic": "9.0.0",
"@angular/platform-server": "9.0.0",
"@angular/router": "9.0.0",
"@nguniversal/module-map-ngfactory-loader": "9.0.0-next.9",
"aspnet-prerendering": "3.0.1",
"bootstrap": "4.4.1",
"core-js": "3.6.1",
"jquery": "3.4.1",
"oidc-client": "1.9.1",
"popper.js": "1.16.0",
"rxjs": "6.5.4",
"zone.js": "0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "0.900.0",
"@angular/cli": "9.0.0",
"@angular/compiler-cli": "9.0.0",
"@angular/language-service": "9.0.0",
"@types/jasmine": "3.5.0",
"@types/jasminewd2": "2.0.8",
"@types/node": "13.1.1",
"codelyzer": "5.2.1",
"jasmine-core": "3.5.0",
"jasmine-spec-reporter": "4.2.1",
"karma": "4.4.1",
"karma-chrome-launcher": "3.1.0",
"karma-coverage-istanbul-reporter": "2.1.1",
"karma-jasmine": "2.0.1",
"karma-jasmine-html-reporter": "1.5.1",
"typescript": "3.7.5"
},
"optionalDependencies": {
"node-sass": "4.13.0",
"protractor": "5.4.2",
"ts-node": "5.0.1",
"tslint": "5.20.1"
}
It's advisable to perform a manual command-line npm update from the project's root folder right after applying these changes to the package.json file, in order to trigger a batch update of all the project's npm packages: sometimes Visual Studio doesn't update the packages automatically and doing that using the GUI can be tricky.
正是由于这个原因,一个方便的update-npm.bat批处理文件被添加到本书 GitHub 上的源代码库中(在/ClientApp/文件夹中),以处理该文件,而无需手动键入前面的命令。
那些在npm update命令后遇到npm和/或ngcc编译问题的人也可以尝试删除/node_modules/文件夹,然后从头开始执行npm install。
为了进一步参考和/或将来的更新,还请检查本书的官方 GitHub 存储库中更新的源代码,其中始终包含最新的改进、错误修复、兼容性修复等。
tsconfig.json
tsconfig.json文件是 TypeScript 配置文件。同样,那些已经知道 TypeScript 是什么的人不需要阅读所有这些,尽管那些不知道的人应该阅读。
TypeScript 是一种免费的开源编程语言,由微软开发和维护,作为 JavaScript 超集;这意味着任何 JavaScript 程序也是有效的 TypeScript 程序。TypeScript 还可以编译为 JavaScript,因此它可以无缝地在任何与 JavaScript 兼容的浏览器上工作,而无需外部组件。使用它的主要原因是为了克服 JavaScript 在开发大规模应用或复杂项目时的语法限制和总体缺点:简单地说,当开发人员被迫处理非琐碎的 JavaScript 代码时,它简化了开发人员的生活。
在这个项目中,我们肯定会使用 TypeScript,原因有很多;最重要的是:
- TypeScript 与 JavaScript 相比有许多特性,例如静态类型、类和接口。在 VisualStudio 中使用它也让我们有机会从内置的 IntelliSense 中获益,这是一个巨大的好处,通常会导致显著的生产力爆发。
- 对于一个大型的客户端项目,TypeScript 将允许我们生成更健壮的代码,它也可以完全部署在普通 JavaScript 文件运行的任何地方。
更不用说我们选择的 Angular SPA 模板已经使用了 TypeScript。因此,我们可以说,我们已经有一只脚在水里了!
撇开玩笑不谈,我们不是唯一赞扬打字稿的人;Angular 团队自己也承认这一点,考虑到Angular 源代码自 Angular 2以来一直使用 TypeScript 编写,正如微软在 2015 年 3 月的以下 MDSN 博客文章中骄傲地宣布的那样:https://devblogs.microsoft.com/typescript/angular-2-built-on-typescript/ 。
Victor Savkin(独角鲸技术公司的共同创始人和公认的 Angular 顾问)在 2016 年 10 月的个人博客中进一步强调了这一点 https://vsavkin.com/writing-angular-2-in-typescript-1fa77c78d8e8 。
回到tsconfig.json档案,没什么好说的;Angular SPA 模板使用的选项值或多或少是我们配置 Visual Studio 和TypeScript 编译器(TSC)以正确传输/ClientApp/文件夹中包含的 TypeScript 代码文件所需的值。然而,在这里,我们可以借此机会对它们进行更多的调整:
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"module": "esnext",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2015",
"typeRoots": [
"node_modules/@types"
],
"lib": [
"es2017",
"dom"
]
},
"angularCompilerOptions": {
"strictMetadataEmit": true
}
}
如突出显示的行所示,我们添加了一个新的angularCompilerOptions部分,可用于配置 Angular AoT 编译器的行为。更具体地说,我们添加的strictMetadataEmit设置将告诉编译器立即报告语法错误,而不是生成.metadata.json错误日志文件。这种行为在生产中可以很容易地关闭,但在开发过程中非常方便。
For more info regarding the new Angular AoT compiler, read the following URL: https://angular.io/guide/aot-compiler.
其他工作区级文件
在工作区根目录中还有 CLI 创建的其他值得注意的文件。由于我们不会对其进行更改,因此我们将在下面的列表中简要介绍它们:
.editorconfig:代码编辑器的工作区特定配置。.gitignore:一个文本文件,告诉 Git-A 版本控制系统您很可能很清楚在工作区中忽略哪些文件或文件夹:这些是故意未跟踪的文件,不应添加到版本控制存储库中。README.md:工作区的介绍性文档。.md扩展代表Markdown,这是一种轻量级标记语言,由John Gruber和Aaron Swartz于 2004 年创建。package-lock.json:提供npm客户端在/node_modules/文件夹中安装的所有软件包的版本信息。如果您计划用纱线替换npm,您可以安全地删除此文件(将创建yarn.lock文件)。/node_modules/:包含整个工作区的所有npm包的文件夹:此文件夹将填充位于工作区根目录上的package.json文件中定义的包,所有项目都可以看到该文件。tslint.json:工作区中所有项目的默认TSLint配置选项。这些一般规则将与项目根文件夹中包含的项目特定tslint.json文件集成和/或覆盖。
TSLint is an extensible static analysis tool that checks TypeScript code for readability, maintainability, and functionality errors: it's very similar to JSLint, which performs the same tasks for JavaScript code. The tool is widely supported across modern editors and build systems and can be customized with your own lint rules, configurations, and formatters.
/ClientApp/src/文件夹
现在是时候访问我们的示例 Angular 应用,看看它是如何工作的。放心吧,我们不会呆太久的;我们只是想看看引擎盖下面有什么。
通过展开/ClientApp/src/目录,我们可以看到有以下子文件夹:
/ClientApp/src/app/文件夹及其所有子文件夹包含与 Angular 应用相关的所有 TypeScript 文件:换句话说,整个客户端应用源代码将放在这里。/ClientApp/src/app/img/文件夹用于存储应用的所有图像和其他资产文件:无论何时构建应用,这些文件都会像/wwwroot/文件夹中的一样复制和/或更新。/ClientApp/src/app/environment/文件夹包含针对特定环境的构建配置选项:该模板与任何新项目默认模板一样,包括environment.ts文件(用于开发)和environment.prod.ts文件(用于生产)。
还有一组根级别的文件:
browserslist:配置各种前端工具之间目标浏览器和 Node.js 版本的共享。index.html:当有人访问您的网站时提供的主 HTML 页面。CLI 会在构建应用时自动添加所有 JavaScript 和 CSS 文件,因此您通常不需要在此处手动添加任何<script>或<link>标记。karma.conf.js:特定于应用的Karma配置。Karma 是一个用于运行基于Jasmine的测试的工具:我们现在可以安全地忽略整个主题,因为我们稍后将讨论它。main.ts:您申请的主要入口点。使用 JIT 编译器编译应用,并引导应用的根模块(AppModule)在浏览器中运行。通过在 CLI build 和 service 命令中添加--aot标志,还可以使用 AOT 编译器,而无需更改任何代码。polyfills.ts:提供 polyfill 脚本以支持浏览器。styles.css:为项目提供样式的 CSS 文件列表。test.ts:项目单元测试的主要切入点。tsconfig.*.json:针对我们 app 各个方面的项目特定配置选项:应用层.app.json、服务器层.server.json和测试的.specs.json。这些选项将覆盖工作区根目录中的通用tsconfig.json文件中设置的选项。tslint.json:本项目的TSLint配置。
/app/文件夹
我们模板的/ClientApp/src/app/文件夹遵循 Angular 文件夹结构最佳实践,包含我们项目的逻辑和数据,因此包括所有 Angular模块、服务和组件,以及模板和样式。它也是唯一值得研究的子文件夹,至少目前是这样。
应用模块
正如我们在第 1 章、GETINGReady*中简要预期的,Angle 应用的基本构建块是NgModules*,它为组件提供编译上下文。NgModules 的作用是将相关代码收集到功能集中:因此,整个 Angular 应用由一个或多个 NgModules 的集合定义。
Angular 应用需要一个根模块——通常称为AppModule——它告诉 Angular 如何组装应用,从而启用引导并启动初始化生命周期(见下图)。其余模块称为功能模块,用途不同。根模块还包含所有可用组件的参考列表。
以下是标准Angular 初始化循环的模式,这将帮助我们更好地了解其工作原理:

我们可以看到,main.ts文件引导app.module.ts(AppModule),然后加载app.component.ts文件(AppComponent;稍后我们将看到,后者将在应用需要时加载所有其他组件。
我们的模板创建的示例 Angular app 的根模块可以在/ClientApp/src/app/文件夹中找到,并在app.module.ts文件中定义。如果我们看一下源代码,我们可以看到它包含一堆import语句和一些引用组件、其他模块、提供者等的数组:这应该不神秘,因为我们刚才说的根模块基本上是一个参考文件。
用于 SSR 的服务器端 AppModule
我们可以看到,/ClientApp/src/app/文件夹还包含一个app.server.module.ts文件,该文件将用于启用Angular Universal****服务器端渲染(SSR)——一种在服务器上渲染 Angular 应用的技术,前提是后端框架支持它。模板生成此文件是因为.NET Core 本机支持此类方便的功能。
以下是使用 SSR 时改进的 Angular 初始化模式:

就这样,至少现在是这样。如果你觉得你仍然缺少一些东西,别担心,我们会很快回来帮助你更好地理解这一切。
To avoid losing too much time on the theoretical aspects of .NET Core and Angular, we won't enter into the details of SSR. For a more detailed look at different techniques and concepts surrounding Angular Universal and SSR, we suggest checking out the following article:
https://developers.google.com/web/updates/2019/02/rendering-on-the-web.
应用组件
如果 NgModules 是 Angular 构建块,组件可以定义为用于将应用组装在一起的砖块,我们可以说 Angular 应用基本上是一个组件树。
组件定义视图,这是一组屏幕元素,Angular 可以根据您的程序逻辑和数据进行选择和修改,并使用服务,这些服务提供与视图不直接相关的特定功能。服务提供商也可以作为依赖项注入组件,从而使应用代码模块化、可重用、高效。
这些组件的基石通常称为AppComponent,这也是根据 Angular 文件夹结构约定应放置在/app/根文件夹中的唯一组件。所有其他组件应放在一个子文件夹中,该子文件夹将充当专用的命名空间。
如我们所见,我们的样本AppComponent由两个文件组成:
app.component.ts:定义组件逻辑,即组件类源代码。app.component.html:定义与AppComponent关联的 HTML 模板。任何 Angular 组件都可以有一个包含其 UI 布局结构的可选 HTML 文件,而不是在组件文件本身中定义它。这几乎总是一个很好的实践,除非组件具有非常小的 UI。
由于AppComponent通常是轻量级的,因此它没有其他可在其他组件中找到的可选文件,例如:
<*>.component.css:定义组件的基础 CSS 样式表。与.html文件一样,该文件是可选的,除非组件不需要 UI 样式,否则应始终使用该文件。<*>.component.spec.ts:定义组件的单元测试。
其他组成部分
除了AppComponent之外,我们的模板还包含四个组件,每个组件位于一个专用文件夹中,如下所示:
CounterComponent:放置在counter子文件夹中FetchDataComponent:放置在fetch-data子文件夹中HomeComponent:放置在home子文件夹中NavMenuComponent:放置在nav-menu子文件夹中
通过查看其各自子文件夹中的源文件,我们可以看到,其中只有一个有一些已定义的测试:CounterComponent,它附带一个包含两个测试的counter.component.spec.ts文件。运行它们,看看由我们的模板设置的Karma+Jasmine测试框架是否有效,这可能会很有用。然而,在这样做之前,最好先看看这些组件,看看它们在 Angular 应用中是如何工作的。
在接下来的部分中,我们将处理这两个任务。
测试应用
让我们先看看这些组件,看看它们是如何工作的。
HomeComponent
当我们点击F5以调试模式运行应用时,我们会收到HomeComponent,如下图所示:

顾名思义,HomeComponent可以被认为是我们 app 的主页;然而,由于页面的概念在处理单页面应用时可能会产生误导,因此我们将在本书中将其称为视图,而不是页面。单词 view 基本上是指由给定导航路线对应的 Angular 组件(包括所有子组件)生成的组合 HTML 模板。
导航组件
我们已经有子组件了吗?是的,我们有。NavMenuComponent就是一个完美的例子,因为它本身没有专用的路由,而是作为其相应视图中其他组件的一部分呈现。
更准确地说,它是每个视图的顶部,我们可以从以下屏幕截图中看到:

NavMenuComponent的主要目的是让用户浏览应用的主要视图。换句话说,在这里我们实现了AppModule中定义的所有第一级导航路线,都指向给定的 Angular 分量。
F第一级导航路线是我们希望用户只需点击一下即可到达的路线,也就是说,不必先浏览其他组件。在我们现在回顾的示例应用中,有三个:
/home:指向HomeComponent/counter:指向CounterComponent/fetch-data:指向FetchDataComponent
如我们所见,这些导航路线已经在NavMenuComponent中通过使用放置在单个无序列表中的锚链接来实现:一组<a>元素放置在<ul>/<li>结构中,在组件的右侧和包含该组件的任何组件的右上角渲染。
现在让我们回顾一下设计用于处理剩余两条一级航线:CounterComponent和FetchDataComponent的。
反元件
CounterComponent显示一个递增计数器,我们可以通过按下递增按钮来增加该计数器:

FetchDataComponent是一个交互式表,由服务器端Web API 通过WeatherForecastController生成的 JSON 数组填充,我们刚才在检查项目后端部分时看到了这个 JSON 数组:

specs.ts 文件
如果我们查看上述组件子文件夹中的源文件,我们可以看到CounterComponent附带了一个counter.component.spec.ts文件。根据 Angular 命名约定,这些文件将包含counter.component.ts源文件的单元测试,并通过Karma测试运行程序使用JasmineJavaScript 测试框架运行。
**For additional info regarding Jasmine and Karma, check out the following guides:
Jasmine:https://jasmine.github.io/
Karma:
https://karma-runner.github.io/
Angular Unit Testing:
https://angular.io/guide/testing
当我们在那里的时候,让他们运行一下,看看由我们的模板建立的 Jasmine+Karma测试框架是否有效,这可能会很有用。
我们的第一个应用测试
在运行测试之前,了解更多关于茉莉花和业力的信息可能会有所帮助。如果你对他们一无所知,别担心你很快就会知道的。现在,只需知道Jasmine是一个开放源码的 JavaScript 测试框架,可用于定义测试,而Karma是一个测试运行工具,可以自动生成一个 web 服务器,该服务器将针对 Jasmine 制作的测试执行 JavaScript 源代码并在命令行上输出各自(和组合)的结果。
在这个快速测试中,我们将基本上启动Karma来针对counter.component.spec.ts文件中模板定义的Jasmine测试执行示例 Angular 应用的源代码:这实际上比看起来容易得多。
打开命令提示符,导航到<project>/ClientApp/文件夹,然后执行以下命令:
> npm run ng test
这将使用npm调用 Angular CLI。
或者,我们可以使用以下命令全局安装 Angular CLI:
> npm install -g @angular/cli
完成后,我们将能够通过以下方式直接调用它:
> ng test
In the unlikely event that the npm command returns a program not found error, check that the Node.js/npm binary folder is properly set within the PATH variable. If it's not there, be sure to add it, then close and re-open the command-line window and try again.
在我们点击进入之后,一个新的浏览器窗口将打开,其中包含 Karma 控制台和茉莉花测试结果列表,如以下屏幕截图所示:

IMPORTANT: At the time of writing, there's a critical path error in the template-generated angular.json file that will prevent any test from running. To fix it, open that file, scroll down to line #74 (Project | HealthCheck | Test | Options | Styles) and change the styles.css value with src/styles.css.
正如我们所看到的,两个测试都已成功完成,这就是我们现在需要做的一切。没有必要偷看counter.component.specs.ts源代码,因为我们将把它与所有模板组件一起丢弃,并创建新组件(使用它们自己的测试)。
For the sake of simplicity, we're going to stop here with Angular app tests for the time being; we'll discuss them in far greater depth in Chapter 9,ASP.NET Core and Angular Unit Testing.
上班
现在我们对新项目有了一个大致的了解,是时候做点什么了。让我们从两个简单的练习开始,这两个练习将来也会派上用场:第一个练习将涉及应用的服务器端方面,第二个练习将在客户端上执行。两者都将帮助我们在进入后续章节之前发现我们是否真正理解了所有需要知道的事情。
静态文件缓存
让我们从服务器端任务开始。您还记得我们在检查StaticFiles中间件如何工作时添加的/wwwroot/test.html文件吗?我们将使用它快速演示应用如何在内部缓存静态文件。
我们要做的第一件事是在调试模式下运行应用(通过点击运行按钮或按F5键),并将以下 URL 放在地址行中,这样我们可以再仔细查看一下:http://localhost:<port>/test.html
紧接着,在不停止应用的情况下,打开test.html文件并将以下行添加到现有内容中(突出显示新行):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Time for a test!</title>
</head>
<body>
Hello there!
<br /><br />
This is a test to see if the StaticFiles middleware is working
properly.
<br /><br />
What about the client-side cache? Does it work or not?
</body>
</html>
保存文件,然后返回浏览器地址栏,再次按回车向test.html文件发出另一个 HTTP 请求。确保不要使用F5或刷新按钮,因为这会强制服务器部分刷新页面,这不是我们想要的;我们将看到前面的更改不会反映在浏览器中,这意味着我们访问了该页面的客户端缓存版本。
在客户机上缓存静态文件在生产服务器中可能是一件好事,但在开发过程中肯定会让人恼火。幸运的是,Spa 中间件在开发过程中提供的内存AngularCliServer将自动修复所有 TypeScript 文件以及我们通过 Angular 自身提供的所有静态资产的此问题。但是,那些通过后端提供的服务呢?我们很可能会有一些静态 HTML 文件、favicon、图像文件、音频文件以及其他我们希望由 web 服务器直接提供服务的内容。
有没有办法微调静态文件的缓存行为?如果是这样,我们还可以为调试/开发和发布/生产场景设置不同的行为吗?
这两个问题的答案都是肯定的。让我们看看怎么做。
过去的爆炸
回到 ASP.NET 4,我们可以通过在主应用的Web.config文件中添加一些行来轻松禁用静态文件缓存,例如:
<caching enabled="false" />
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
<httpProtocol>
<customHeaders>
<add name="Cache-Control" value="no-cache, no-store" />
<add name="Pragma" value="no-cache" />
<add name="Expires" value="-1" />
</customHeaders>
</httpProtocol>
就这样,;我们甚至可以通过将这些行添加到Web.debug.config文件中,将此类行为限制在调试环境中。
我们不能在.NETCore 中使用相同的方法,因为配置系统已经从头开始重新设计,现在与以前的版本有很大不同;如前所述,Web.config和Web.debug.config文件已经被appsettings.json和appsettings.Development.json文件所取代,它们的工作方式也完全不同。现在我们已经了解了基础知识,让我们看看是否可以通过利用新的配置模型来解决缓存问题。
回到未来
首先要做的是了解如何修改静态文件的默认 HTTP 头。事实上,我们可以通过向Startup.cs文件中的app.UseStaticFiles()方法调用添加一组自定义配置选项来实现这一点,该文件将StaticFiles中间件添加到 HTTP 请求管道中。
为此,打开Startup.cs,向下滚动至Configure方法,并用以下代码替换该单行(新的/修改的行突出显示):
app.UseStaticFiles(new StaticFileOptions()
{
OnPrepareResponse = (context) =>
{
// Disable caching for all static files.
context.Context.Response.Headers["Cache-Control"] =
"no-cache, no-store";
context.Context.Response.Headers["Pragma"] =
"no-cache";
context.Context.Response.Headers["Expires"] =
"-1";
}
});
这一点也不难;我们只是在方法调用中添加了一些额外的配置值,将它们封装在一个专用的StaticFileOptions对象实例中。
然而,我们还没有完成;现在我们已经了解了如何更改默认行为,我们只需要使用指向appsettings.Development.json文件的一些方便的引用来更改这些静态值。为此,我们可以通过以下方式将以下键/值部分添加到appsettings.Development.json文件中(突出显示新行):
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
},
"StaticFiles": {
"Headers": {
"Cache-Control": "no-cache, no-store",
"Pragma": "no-cache",
"Expires": "-1"
}
}
}
然后相应更改前面的Startup.cs代码(修改后的行突出显示):
app.UseStaticFiles(new StaticFileOptions()
{
OnPrepareResponse = (context) =>
{
// Retrieve cache configuration from appsettings.json
context.Context.Response.Headers["Cache-Control"] =
Configuration["StaticFiles:Headers:Cache-Control"];
context.Context.Response.Headers["Pragma"] =
Configuration["StaticFiles:Headers:Pragma"];
context.Context.Response.Headers["Expires"] =
Configuration["StaticFiles:Headers:Expires"];
}
});
确保您也将这些值添加到appsettings.json文件的非开发版本中,否则,应用将找不到它们(在开发环境之外执行时),并将抛出错误。
由于这很可能发生在生产环境中,我们可能会稍微放宽这些缓存策略:
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*",
"StaticFiles": {
"Headers": {
"Cache-Control": "max-age=3600",
"Pragma": "cache",
"Expires": null
}
}
}
就这样。学习如何使用此模式是非常明智的,因为它是正确配置应用设置的一种非常有效的方法。
测试它
让我们看看我们的新缓存策略是否如预期的那样工作。在调试模式下运行应用,然后通过在浏览器地址栏http://localhost:/test.html中键入以下 URL 向test.html页面发出请求
我们应该能够看到更新的内容与我们写的短语之前;如果没有,则在浏览器中按F5强制从服务器检索页面:

现在,在不停止应用的情况下,编辑test.html页面并按以下方式更新其内容(更新的行突出显示):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Time for a test!</title>
</head>
<body>
Hello there!
<br /><br />
This is a test to see if the StaticFiles middleware is working
properly.
<br /><br />
What about the client-side cache? Does it work or not?
<br /><br />
It seems like we can configure it: we disabled it during
development, and enabled it in production!
</body>
</html>
紧接着,返回浏览器,选择地址栏,按回车;同样,不要按刷新按钮或F5键,否则我们必须重新开始。如果一切正常,我们将立即在屏幕上看到更新的内容:

我们做到了!我们的服务器端任务已成功完成。
强类型方法(es)
我们选择的检索appsettings.json配置值的方法使用了泛型IConfiguration对象,可以使用前面基于字符串的语法查询该对象。这种方法比较实用,;但是,如果我们想以更健壮的方式检索这些数据,例如,以强类型的方式,我们可以而且应该实现更好的方法。尽管在本书中我们不会更深入地介绍这一点,但我们建议您阅读以下优秀文章,其中展示了实现这一结果的三种不同方法:
第一个是由Rick Strahl编写的,说明了如何使用IOptions<T>提供程序接口实现这一点:
https://weblog.west-wind.com/posts/2016/may/23/strongly-typed-configuration-settings-in-aspnet-core
第二个由Filip W编写,解释了如何使用一个简单的POCO类来实现这一点,从而避免了IOptions<T>接口和上述方法所需的额外依赖性:
https://www.strathweb.com/2016/09/strongly-typed-configuration-in-asp-net-core-without-ioptionst/
第三个由Khalid Abuhakmeh编写,展示了一种使用标准POCO类并直接将其注册为ServicesCollection的Singleton类的替代方法,同时还(可选地)防止由于开发错误而对其进行不必要的修改:
https://rimdev.io/strongly-typed-configuration-settings-in-asp-net-core-part-ii/
所有这些方法最初都是为了使用.NETCore1.x;但是,它们仍然可以与.NETCore3.x 一起使用(在撰写本文时)。也就是说,如果我们做出选择,我们可能会选择最后一个选项,因为我们发现它是最干净、最聪明的。
客户端应用清理
现在我们的服务器端之旅已经结束,是时候用一个快速的客户端练习来挑战自己了。别担心,这只是一个非常简单的演示,演示如何更新/ClientApp/文件夹中的 Angular 源代码,以更好地满足我们的需要。更具体地说,我们将从所选 Angular SPA 模板附带的示例 Angular 应用中删除所有不需要的内容,并将其替换为我们自己的内容。
We can never say it enough, so it's worth repeating again: The sample source code explained in the following sections is taken from the ASP.NET Core with Angular (C#) project template originally shipped with the .NET Core 3 SDK; since it might be updated in the future, it's important to check it against the code published in this book's GitHub repo. If you find relevant differences between the book's code and yours, feel free to get the one from the repository and use that instead.
精简组件列表
我们要做的第一件事是删除我们不想使用的 Angular 组件。
转到/ClientApp/src/app/文件夹,删除counter和fetch-data文件夹及其包含的所有文件
Although they can still be used as valuable code samples, keeping these Components within our client code will eventually confuse us, hence it's better to delete them in order to prevent the Visual Studio TypeScript compiler from messing with the .ts files contained there. Don't worry—we'll still be able to check them out via the book's GitHub project.
一旦我们这样做,Visual Studio错误列表视图将立即提出两个基于阻塞类型脚本的问题:
Error TS2307 (TS) Cannot find module './counter/counter.component'.
Error TS2307 (TS) Cannot find module './fetch-data/fetch-data.component'.
所有这些错误都指向app.module.ts文件,我们已经知道,该文件包含 Angular 应用使用的所有 TypeScript 文件的引用。如果打开该文件,我们将立即看到问题:

为了修复它们,我们需要删除两个违规的import引用(第 10-11 行);紧接着,还会出现两个错误:
Error TS2304 (TS) Cannot find name 'CounterComponent'.
Error TS2304 (TS) Cannot find name 'FetchDataComponent'.
这可以通过从declarations数组(第 18-19 行,上次删除后变为第 16-17 行)和RouterModule配置(删除后第 27-28 行或第 25-26 行)中删除两个有问题的组件名称来解决。
完成后,我们更新的app.module.ts文件应如下所示:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' }
])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
既然我们在这里,那些不知道 Angular 如何工作的人应该花几分钟来理解AppModule类实际上是如何工作的。
AppModule 源代码
Angular模块,也称为NgModules,在 Angular 2 RC5 中引入,是组织和引导任何 Angular 应用的强大方式;它们帮助开发人员将自己的组件、指令和管道整合到可重用的块中。如前所述,自 v2 RC5 以来的每个 Angular 应用必须至少有一个模块,通常称为根模块,因此被命名为AppModule类名。
AppModule通常分为两个主要代码块:
- 导入语句列表,指向应用所需的所有引用(以 TypeScript 文件的形式)。
- 根
NgModule块,正如我们所看到的,基本上是一个命名数组的数组,每个数组包含一组具有共同用途的 Angular 对象:指令、组件、管道、模块、提供者等等。最后一个包含我们想要引导的组件,在包括我们在内的大多数场景中,它是主要的应用组件,AppComponent。
更新导航菜单
如果我们在调试模式下运行我们的项目,我们可以看到我们最近的代码更改了对这两个组件的删除—不会阻止客户端应用正常启动。这次我们没有弄坏它耶!但是,如果我们尝试使用导航菜单通过单击主视图中的链接转到Counter和/或Fetch data,则不会发生任何事情。这并不奇怪,因为我们刚刚将这些组件移到一边。为了避免混淆,我们也从菜单中删除这些链接。
打开/ClientApp/app/components/nav-menu/nav-menu.component.html文件,该文件是NavMenuComponent的 UI 模板。正如我们所见,它确实包含一个标准的 HTML 结构,包含我们应用主页的标题部分,包括主菜单。
找到我们需要删除的 HTML 部分以删除指向CounterComponent和FetchDataComponent的链接应该不难——它们都包含在一个专用的 HTML<li>元素中:
[...]
<li class="nav-item" [routerLinkActive]="['link-active']">
<a class="nav-link text-dark" [routerLink]="['/counter']"
>Counter</a
>
</li>
<li class="nav-item" [routerLinkActive]="['link-active']">
<a class="nav-link text-dark" [routerLink]="['/fetch-data']"
>Fetch data</a
>
</li>
[...]
删除两个<li>元素并保存文件。
完成后,NavMenuComponent代码的更新 HTML 结构应如下所示:
<header>
<nav
class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light
bg-white border-bottom box-shadow mb-3"
>
<div class="container">
<a class="navbar-brand" [routerLink]="['/']">HealthCheck</a>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target=".navbar-collapse"
aria-label="Toggle navigation"
[attr.aria-expanded]="isExpanded"
(click)="toggle()"
>
<span class="navbar-toggler-icon"></span>
</button>
<div
class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-
reverse"
[ngClass]="{ show: isExpanded }"
>
<ul class="navbar-nav flex-grow">
<li
class="nav-item"
[routerLinkActive]="['link-active']"
[routerLinkActiveOptions]="{ exact: true }"
>
<a class="nav-link text-dark"
[routerLink]="['/']">Home</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
既然我们在这里,就让我们抓住这个机会把别的东西处理掉吧。你还记得我们第一次运行项目时浏览器显示的Hello, World!介绍性文本吗?让我们用我们自己的内容来代替它。
打开/ClientApp/src/app/components/home/home.component.html文件,将其全部内容替换为以下内容:
<h1>Greetings, stranger!</h1>
<p>This is what you get for messing up with .NET Core and Angular.</p>
保存,在调试模式下运行项目,并准备好查看以下内容:

Counter和Fetch data菜单链接不见了,我们的Home View欢迎语也变得更加流畅。
现在我们已经从前端中删除了任何引用,我们可以对以下后端文件执行相同的操作,我们不再需要这些文件:
WeatherForecastController.csControllers/WeatherForecastController.cs
使用 Visual Studio 的解决方案资源管理器找到这两个文件并将其删除。
It's worth noting that, once we do that, we won't have any .NET controllers available in our web application anymore; that's perfectly fine since we don't have Angular Components that need to fetch data either. Don't worry, though—we're going to add them back in upcoming chapters!
现在就到此为止。请放心,我们可以轻松地对其他组件执行相同的操作,并完全重写它们的文本,包括导航菜单;我们将在下面的章节中完成这项工作,我们还将更新 UI 布局,添加新组件,等等。就目前而言,了解更改内容有多容易,以及 Visual Studio、ASP.NET Core 和 Angular 对我们的修改做出反应的速度有多快就足够了。
总结
在第二章中,我们花了一些宝贵的时间探索和理解示例项目的核心组件,它们是如何协同工作的,以及它们的独特作用。为了简单起见,我们将分析分为两个部分:.NET Core后端生态系统和 Angular前端架构,每个部分都有自己的配置文件、文件夹结构、命名约定和总体范围。
最后,我们可以肯定地说,我们达到了本章的最终目标,学到了很多有用的东西:我们知道服务器端和客户端源代码文件的位置和用途;我们能够删除现有内容并插入新内容;我们知道缓存系统和其他设置参数;等等
最后但并非最不重要的一点是,我们还花时间进行了一些快速测试,看看我们是否准备好在接下来的章节中坚守立场:建立一个改进的请求-响应周期,构建我们自己的控制器,定义额外的路由策略,等等。
建议的主题
Razor 页面、关注点分离、单一责任原则、JSON、web 主机、Kestrel、OWIN、中间件、依赖注入、Angular workspace、Jasmine、Karma、单元测试、服务器端渲染(SSR)、类型脚本、Angular 架构、Angular 初始化周期、浏览器缓存、,和客户端缓存。
工具书类
- ASP.NET Core 介绍:https://docs.microsoft.com/en-us/aspnet/core/
- Angular:设置本地环境和工作空间:https://angular.io/guide/setup-local
** 角结构概述:https://angular.io/guide/architecture** 类型脚本-模块:https://www.typescriptlang.org/docs/handbook/modules.html** 类型脚本-模块分辨率:https://www.typescriptlang.org/docs/handbook/module-resolution.html** 业力:https://karma-runner.github.io/*** 茉莉花:https://jasmine.github.io/** Angular 测试:https://angular.io/guide/testing** ASP.NET Core 中的强类型配置设置:https://weblog.west-wind.com/posts/2016/may/23/strongly-typed-configuration-settings-in-aspnet-core* 无 IOPS 的 ASP.NET Core 中的强类型配置https://www.strathweb.com/2016/09/strongly-typed-configuration-in-asp-net-core-without-ioptionst/* ASP.NET Core 第二部分中的强类型配置设置 https://rimdev.io/strongly-typed-configuration-settings-in-asp-net-core-part-ii/*************
三、前端和后端交互
现在我们已经有了一个极简但完全可以运行的.NETCore3 和 Angular8Web 应用,我们可以开始构建一些东西了。在本章中,我们将学习客户端和服务器端交互的基础知识:换句话说,前端(Angular)如何从后端(.NET Core)获取一些相关数据,并以可读的方式显示在屏幕上。
等等……事实上,我们应该已经知道它的工作原理了,对吧?我们在第 2 章中看到了这一点,环顾了,然后摆脱了 Angular 的FetchDataComponent和.NET Core 的WeatherForecastController.cs类和文件。Angular 组件(前端)从.NET 控制器(后端)中提取数据,然后将其放在浏览器屏幕(UI)上显示。
这种说法是绝对正确的。然而,控制器并不是我们的.NET Core后端向前端提供数据的唯一方式:它还可以提供静态文件、Razor 页面和任何其他中间件,用于处理请求和输出某种响应流或内容,只要我们将其添加到应用管道中。这种高度模块化的方法是.NET Core 最相关的概念之一。在本章中,我们将通过引入(并使用)一个与.NET 控制器几乎没有或根本没有关系的内置中间件来利用它,尽管它能够像处理请求和响应一样处理请求和响应:HealthChecksMiddleware。
下面是我们将要实现的目标的快速细分:
- 介绍.NET Core 运行状况检查:它们是什么,以及我们如何使用它们来学习有关.NET Core 和 Angular 交互的一些有用概念。
- HealthCheckMiddleware:如何在我们的.NET Core后端中正确实现它,在我们的 web 应用的管道中配置它,并输出一条 JSON 结构化消息,供我们的 Angular 应用使用。
- HealthCheckComponent:如何构建一个 Angular 组件,从.NET Core后端获取
HealthCheck结构化数据,并以可读的方式将其全部带到前端。
你准备好了吗?让我们这样做!
技术要求
在本章中,我们需要前面章节中列出的所有技术要求,不需要额外的资源、库或包。
本章代码文件可在此处找到:https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition/tree/master/Chapter_03/ 。
介绍.NET Core 运行状况检查
我们之所以称我们的第一个项目为HealthCheck是因为:我们将要构建的 web 应用将充当监控和报告服务,它将检查目标服务器和/或其基础设施的运行状况状态,并实时显示在屏幕上。
为了做到这一点,我们将充分利用Microsoft.AspNetCore.Diagnostics.HealthChecks包,这是.NET Core 框架的一个内置功能,最初在 2.2 中引入,然后在 3.0 版本中进行了改进。这个包旨在允许监控服务检查另一个正在运行的服务的状态,例如,另一个 web 服务器,这正是我们要做的。
For additional information about .NET Core health checks, we strongly suggest reading the official MS documentation at the following URL:
https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-3.0.
添加 HealthChecks 中间件
我们需要做的第一件事是将HealthChecks中间件添加到我们的 web 应用中。这可以通过以下方式完成:
- 打开
Startup.cs文件。 - 在
ConfigureServices方法中添加以下行:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
// In production, the Angular files will be served
// from this directory
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/dist";
});
services.AddHealthChecks();
}
Configure方法的以下行:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...existing code...
app.UseRouting();
app.UseHealthChecks("/hc");
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
});
// ...existing code...
}
The // ...existing code... comment is just a way to tell us to leave the already existing code as it is, without altering it. We're going to use that keyword whenever we need to add a few lines of code to an existing block instead of rewriting the unmodified lines—thus saving some valuable space!
我们传递给UseHealthChecks中间件的/hc参数将创建用于健康检查的服务器端路由。还值得注意的是,我们在UseEndpoints之前添加了中间件,这样我们的新路由就不会被此处指定的通用控制器路由模式覆盖。
我们可以通过执行以下操作立即查看新路线:
- 按F5使我们的 web 应用在调试模式下运行。
- 手动在起始 URL 的末尾键入
/hc并点击输入。
一旦我们做到这一点,我们应该能够看到这样的情况:

正如我们所看到的,我们的系统是Healthy:这很明显,因为我们还没有定义检查。
加一个怎么样?这就是我们在下一节要做的。
添加 Internet 控制消息协议(ICMP)检查
我们要实现的第一个检查是最流行的检查之一:一个互联网控制消息协议(ICMP)请求检查外部主机,也称为PING。
我们很可能已经知道,PING 请求是检查服务器存在性的一种非常基本的方法,因此我们知道我们应该能够在局域网(LAN或广域网(WAN连接中访问服务器。简而言之,它的工作方式如下:执行 PING 的机器向目标主机发送一个或多个 ICMP echo 请求数据包,并等待回复;如果收到,则报告整个任务的往返时间;否则,它会超时并报告一个host not reachable错误。
host not reachable错误可能是由于以下几种可能的情况造成的:
- 目标主机不可用。
- 目标主机可用,但主动拒绝任何类型的 TCP/IP 连接。
- 目标主机可用并接受 TCP/IP 传入连接,但已将其配置为显式拒绝 ICMP 请求和/或不发送 ICMP 回显回复。
- 目标主机可用并正确配置以接受 ICMP 请求并发送回显回复,但连接速度非常慢或****由于未知原因(性能、负载等)阻碍,因此往返时间过长甚至超时。
正如我们所看到的,这是进行健康检查的理想场景:如果我们正确地配置目标主机以接受 PING 并始终应答它,那么我们肯定可以使用它来确定主机何时处于健康状态。
可能的结果
现在我们知道了 PING 测试请求背后的常见场景,我们可以列出一个可能的结果列表,如下所示:
- 【谚】:无论何时 ping 成功,都没有错误和超时,我们可以考虑主机 To1 T1。
- 【方法】T2、T2、T3、Ping 在 TIP 成功时均可考虑,但往返时间太长。
- 【方法】To 2 t2。
既然我们已经确定了这三种状态,我们只需要在健康检查中正确地实施它们。
创建 ICMPHealthCheck 类
我们要做的第一件事是在项目的根文件夹中创建一个新的ICMPHealthCheck.cs类。
完成后,填写以下内容:
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Tasks;
namespace HealthCheck
{
public class ICMPHealthCheck : IHealthCheck
{
private string Host = "www.does-not-exist.com";
private int Timeout = 300;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
using (var ping = new Ping())
{
var reply = await ping.SendPingAsync(Host);
switch (reply.Status)
{
case IPStatus.Success:
return (reply.RoundtripTime > Timeout)
? HealthCheckResult.Degraded()
: HealthCheckResult.Healthy();
default:
return HealthCheckResult.Unhealthy();
}
}
}
catch (Exception e)
{
return HealthCheckResult.Unhealthy();
}
}
}
}
正如我们所看到的,我们实现了IHealthCheck接口,因为它是.NET 处理健康检查的官方方式:这样的接口需要一个async方法CheckHealthAsync,我们用来确定 ICMP 请求是否成功
该代码非常容易理解,可以处理我们在上一节中定义的三种可能的场景。让我们看一下主机可以被认为是什么:
Healthy,如果 PING 请求得到成功的回复,且往返时间不超过 300 毫秒Degraded,如果 PING 请求得到成功回复,且往返时间大于 300msUnhealthy,如果 PING 请求失败或抛出Exception
Notice that the host is hardcoded to a non-existing name, which is rather awkward! Don't worry! We did that for demonstration purposes so that we'll be able to simulate an unhealthy scenario: we're going to change it later on.
差不多就是这样。我们的健康检查已经准备好进行测试,我们只需要找到一种方法将其加载到我们的 web 应用的管道中。
将 ICMPHealthCheck 添加到管道
为了将 ICMP 健康检查加载到 web 应用管道中,我们需要将其添加到HealthChecks中间件中。为此,请再次打开Startup.cs类,并按以下方式更改之前添加到ConfigureServices方法的行:
public void ConfigureServices(IServiceCollection services)
{
/// ...existing code...
services.AddHealthChecks()
.AddCheck<ICMPHealthCheck>("ICMP");
}
就是这样:现在,我们可以点击F5并尝试一下。以下是我们应该能够看到的:

太好了,对吗?
事实上,没那么好。我们的健康检查确实有效,但存在以下三个主要缺陷:
- 硬编码值:
Host和Timeout变量应作为参数传递,以便我们可以通过编程进行设置。 - 非信息性响应:
Healthy和Unhealthy都不是很好,我们应该找到一种方法来定制(更好)输出消息。 - 非类型化输出:当前响应是以纯文本形式发送的,如果我们想使用 Angular 获取它,JSON 内容类型肯定会更好(而且更有用)。
让我们一次解决一个问题。
改进 ICMPHealthCheck 类
在本节中,我们将通过添加host和timeout参数、每个可能状态的自定义结果消息以及 JSON 结构化输出来改进ICMPHealthCheck类。
添加参数和响应消息
打开ICMPHealthCheck.cs类文件并执行以下更改(添加/修改的行突出显示):
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Tasks;
namespace HealthCheck
{
public class ICMPHealthCheck : IHealthCheck
{
private string Host { get; set; }
private int Timeout { get; set; }
public ICMPHealthCheck(string host, int timeout)
{
Host = host;
Timeout = timeout;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
using (var ping = new Ping())
{
var reply = await ping.SendPingAsync(Host);
switch (reply.Status)
{
case IPStatus.Success:
var msg = String.Format(
"IMCP to {0} took {1} ms.",
Host,
reply.RoundtripTime);
return (reply.RoundtripTime > Timeout)
? HealthCheckResult.Degraded(msg)
: HealthCheckResult.Healthy(msg);
default:
var err = String.Format(
"IMCP to {0} failed: {1}",
Host,
reply.Status);
return HealthCheckResult.Unhealthy(err);
}
}
}
catch (Exception e)
{
var err = String.Format(
"IMCP to {0} failed: {1}",
Host,
e.Message);
return HealthCheckResult.Unhealthy(err);
}
}
}
}
正如我们所见,我们改变了一些事情,如下所示:
- 我们添加了一个构造函数,它接受我们希望通过编程设置的两个参数:
host和timeout。旧的硬编码变量现在是属性,因此我们可以在初始化时使用它们来存储参数,然后在类中使用它们(例如在 main 方法中)。 - 我们创建了各种不同的结果消息,其中包含目标主机、ping 结果和往返持续时间(或运行时错误),并将它们作为参数添加到
HealthCheckResult返回对象中。
差不多就是这样。现在,我们只需要通过编程方式设置host名称和timeout,因为旧的硬编码默认值已经不存在了。为此,我们必须更新Startup.cs文件中的中间件设置。
更新中间件设置
再次打开Startup.cs文件,按以下方式更改ConfigureServices方法中的中间件初始化代码:
public void ConfigureServices(IServiceCollection services)
{
/// ...existing code...
services.AddHealthChecks()
.AddCheck("ICMP_01", new ICMPHealthCheck("www.ryadel.com",
100))
.AddCheck("ICMP_02", new ICMPHealthCheck("www.google.com",
100))
.AddCheck("ICMP_03", new ICMPHealthCheck("www.does-not-
exist.com", 100));
}
我们来看看:正如我们所看到的,能够以编程方式配置主机的另一个优点是,我们可以为每个要实际检查的主机添加多次 ICMP 运行状况检查。在前面的示例中,我们利用这个机会测试了三个不同的主机:www.ryadel.com、www.google.com和我们之前使用的同一个不存在的主机,这允许我们模拟Unhealthy状态以及Healthy状态。
现在,我们可以试着点击F5并尝试一下。。。然而,如果我们这样做,我们将面临一个相当令人失望的结果,如以下屏幕截图所示:

原因很明显:即使我们运行多个检查,我们仍然依赖默认的结果消息。。。这只不过是所有已检查主机返回的状态的布尔和。正是由于这个原因,如果其中至少有一个是Unhealthy,那么整个检查也将标记为Unhealthy。
幸运的是,通过处理ICMPHealthCheck的第三个缺陷,我们可以避免这个总和,并获得更细粒度的输出:实现定制的 JSON 结构化输出消息。
实现自定义输出消息
要实现自定义输出消息,我们需要重写HealthCheckOptions类。为此,向项目的根文件夹中添加一个新的CustomHealthCheckOptions.cs文件,并用以下内容填充它:
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Http;
using System.Linq;
using System.Net.Mime;
using System.Text.Json;
namespace HealthCheck
{
public class CustomHealthCheckOptions : HealthCheckOptions
{
public CustomHealthCheckOptions() : base()
{
var jsonSerializerOptions = new JsonSerializerOptions()
{
WriteIndented = true
};
ResponseWriter = async (c, r) =>
{
c.Response.ContentType =
MediaTypeNames.Application.Json;
c.Response.StatusCode = StatusCodes.Status200OK;
var result = JsonSerializer.Serialize(new
{
checks = r.Entries.Select(e => new
{
name = e.Key,
responseTime =
e.Value.Duration.TotalMilliseconds,
status = e.Value.Status.ToString(),
description = e.Value.Description
}),
totalStatus = r.Status,
totalResponseTime =
r.TotalDuration.TotalMilliseconds,
}, jsonSerializerOptions);
await c.Response.WriteAsync(result);
};
}
}
}
代码是自解释的:我们用自己的自定义类重写标准类,该类输出我们想要更改的一个单词的输出,这样我们就可以更改它的ResponseWriter属性,以便让它输出我们想要的任何东西
更具体地说,我们希望输出一条自定义的 JSON 结构化消息,其中包含来自每个检查的大量有用内容,如下所示:
name:我们在Startup.cs文件"ICMP_01"、"ICMP_02"等的ConfigureServices方法中将其添加到管道中时提供的标识字符串responseTime:单次检查的整个持续时间status:不要与整个HealthCheck的状态混淆,即所有内部检查状态的布尔和description:我们在前面优化ICMPHealthCheck类时配置的自定义信息消息
所有这些值都将是 JSON 输出中包含的数组项的属性:每个检查一个。值得注意的是,JSON 文件除了该数组之外,还将包含以下两个附加属性:
totalStatus:所有内部检查状态的布尔和-Unhealthy如果至少有Unhealthy主机,Degraded如果至少有Degraded主机,Healthy否则。totalResponseTime:所有检查的整个持续时间。
这是很多有用的信息,对吗?我们只需要将中间件配置为输出它们,而不是我们以前看到的一个单词的响应。
关于运行状况检查响应和 HTTP 状态代码
在继续之前,值得注意的是,在前面的CustomHealthCheckOptions类中,我们确实将ResponseWriter的 HTTP 状态代码设置为固定的StatusCodes.Status200OK。这背后有什么原因吗?
事实上,有,而且这也是一个相当重要的问题。HealthChecks中间件的默认行为返回 HTTP 状态代码 200(如果所有检查都正常)(Healthy),或者返回 HTTP 状态代码 503(如果一个或多个检查为 KO)(Unhealthy)。由于我们已经切换到 JSON 结构的输出,我们不再需要 503,因为如果处理不当,它很可能会破坏我们的前端客户端 UI 逻辑。因此,为了简单起见,我们只是强制执行 HTTP200 响应,而不管最终结果如何。我们将找到一种在即将到来的 Angular UI 中适当强调错误的方法。
配置输出消息
打开Startup.cs文件,向下滚动至Configure方法,并相应更改以下行(更新的代码突出显示):
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
/// ...existing code...
app.UseHealthChecks("/hc", new CustomHealthCheckOptions());
/// ...existing code...
}
一旦完成,我们最终可以点击F5并正确测试它。这一次,我们不会对结果感到失望,如以下屏幕截图所示:

这是一个很好的回答,不是吗?
现在,每一次检查以及总计结果数据都正确地记录在结构化 JSON 对象中。这正是我们需要提供的一些 Angular 组件,我们可以在屏幕上以一种人类可读(和时尚)的方式显示,我们正准备这样做,从下一节开始。
医院的健康检查
现在是时候构建一个Angular 组件,它能够获取和显示我们在前面几节中成功获取的结构化 JSON 数据。
从第 2 章环顾可知,一个 Angular 分量通常由三个单独的文件组成,如下所示:
- 组件(
.ts文件,以 TypeScript 编写,包含组件类,以及所有模块引用、函数、变量等。 - 模板(
.html文件,用 HTML 编写,扩展了角模板语法,定义了 UI 布局架构。 - 样式(
.css****文件,用 CSS 编写,包含用于绘制 UI 的级联样式表规则和定义。
**Although the aforementioned three-files approach is arguably the most practical one, the only required file is the Component one, as both the Template and the Style files could also be embedded as inline elements within the Component file. The choice between using separate files or going inline is a matter of taste; however, we strongly suggest adopting the three-files approach because it enforces the separation of concerns embodied within the Component/template duality featured by Angular.
If we're used to the model-view-controller (MVC) and model-view-viewmodel (MVVM) patterns, we can say that, in Angular, the Component is the Controller/viewmodel and the template represents the view.
在下一节中,我们将全部实现它们。
创建 Angular 组件
在解决方案资源管理器中,浏览/ClientApp/src/app文件夹并创建一个新的health-check文件夹。
进入后,创建以下.ts、.html和.css文件:
health-check.component.tshealth-check.component.htmlhealth-check.component.css
完成后,用以下内容填充它们。
health-check.component.ts
以下是 /ClientApp/src/app/health-check/health-check.component.ts源代码:
import { Component, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-health-check',
templateUrl: './health-check.component.html',
styleUrls: ['./health-check.component.css']
})
export class HealthCheckComponent {
public result: Result;
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.http.get<Result>(this.baseUrl + 'hc').subscribe(result => {
this.result = result;
}, error => console.error(error));
}
}
interface Result {
checks: Check[];
totalStatus: string;
totalResponseTime: number;
}
interface Check {
name: string;
status: string;
responseTime: number;
}
如果你对我们在那里做的事情感到好奇,下面是最相关的东西的分类:
- 在文件开始时,我们确保
import所有 Angular指令、管道、服务和组件一言以蔽之,模块是我们在整个课程中需要的。 - 在组件的构造函数中,我们使用依赖注入(DI实例化了
HttpClient服务和baseUrl变量;baseUrl值是通过使用/ClientApp/src/main.ts文件中定义的BASE_URL提供程序来设置的,我们在第 2 章中简要介绍了环顾,通过查看该文件的源代码可以看出,它将解析为我们应用的根 URL:HttpClient需要这样一个值服务,以构建 URL,该 URL 将用于通过 HTTP GET 请求从服务器获取数据,该请求发送到我们前面设置的.NETHealthChecks中间件(请参见'hc'字符串)。 - 最后但并非最不重要的一点是,我们定义了两个接口来处理我们期望从
HealthChecks中间件接收的 JSON 请求:Result和Check,我们设计这两个接口分别承载整个 JSON 结果对象和内部数组的每个元素。
在进一步讨论之前,花一些宝贵的时间通过实现前面的代码来扩展我们刚刚遇到的一些非常重要的主题可能会很有用,如下所示:
- 导入和模块
- 迪
- ngOnInit(和其他生命周期挂钩)
- 施工单位
- HttpClient
- 可观测值
- 接口
因为我们将在本书中看到它们,所以现在回顾它们绝对是明智的。
导入和模块
我们在前面的HealthCheckComponent中多次使用的静态import语句用于导入由其他 JavaScript 模块导出的绑定。
使用模块的概念始于 ECMAScript 2015,并已被 TypeScript 完全采用,因此也被 Angular 采用。模块基本上是一个变量、函数、类等的集合,分组在一个类中:每个模块在自己的范围内执行,而不是在全局范围内执行,这意味着其中声明的所有元素从外部看不到,除非它们显式地使用export语句导出。相反,要使用包含在模块中的变量、函数、类、接口等(以及导出的,必须使用import语句导入该模块。这与我们在大多数编程语言中对名称空间所做的操作非常相似(例如,C#有using语句)。
事实上,所有 Angular 的指令、pi**pes、服务、C**组件也都打包到了JavaScript 模块集合中,我们必须导入任何类型的脚本类,只要我们想使用它们。这些集合基本上是模块库:我们可以很容易地识别它们,因为它们的名称以@angular前缀开头。我们的/ClientApp/packages.json文件(NPM 包文件),我们在前面的章节中看到过,包含了其中的大部分。
To know more about ECMAScript modules and better understand the module resolution strategy in TypeScript, check out the following URLs:
TypeScript modules:
https://www.typescriptlang.org/docs/handbook/modules.html.
Module resolution:
https://www.typescriptlang.org/docs/handbook/module-resolution.html.
重要:JavaScript 模块不应与 Angular 自己的模块化系统混淆,后者基于@NgModule装饰器。我们已经从第一章、准备、第二章、环顾了解到,Angular 的NgModules是构建块-即专用于应用领域的内聚代码块的容器,工作流或公共功能集。从前面的章节中我们知道,每个 Angular 应用至少有一个NgModule类,称为根模块,通常称为AppModule,位于应用根目录中的app.module.ts文件中;在接下来的章节中,将添加其他模块。
不幸的是,JavaScript 模块系统和模块系统使用了非常相似的词汇表(导入与导入、导出与导出),这可能会导致混淆,尤其是考虑到Angular 应用要求开发人员同时使用这两种应用(通常在同一类文件中)。幸运的是,虽然一开始被迫将这两个系统交织在一起可能有点棘手,但最终,我们将熟悉它们使用的不同上下文。
下面是一个示例屏幕截图,取自我们的HealthCheck应用的AppModule类文件,它将帮助您区分两个不同的系统:

For additional information regarding the Angular module system and the NgModule decorator, check out the following URLs:
NgModule: **https://angular.io/guide/ngmodules.
Angular architecture: NgModules and JavaScript modules**:https://angular.io/guide/architecture-modules#ngmodules-and-javascript-modules.
DI
我们已经多次讨论过 DI,这是有充分理由的,因为它是.NET Core 或 Angular 的重要应用设计模式,两个框架都广泛使用它来提高效率和模块化。
为了解释 DI 实际上是什么,我们必须首先讨论类中的依赖项是什么:它们可以定义为服务或对象,类需要将实例化为变量或属性以执行一项或多项任务。
在经典编码模式中,这些依赖项在类本身中动态实例化,例如在初始化阶段,例如在构造函数方法中。这是一个典型的例子:
public MyClass() {
var myElement = new Element();
myElement.doStuff();
}
在前面的示例中,myElement变量是Element类型的对象实例,也是MyClass的(本地)依赖项:正如我们所看到的,它在构造函数中被实例化,因为我们很可能需要在那里使用它。从那里,我们可以将其用作局部变量(并让它在构造函数的生命周期结束时死亡,或者将其分配给类属性以进一步延长其生命周期和范围。
DI 是另一种软件设计模式,在这种模式中,类从外部源请求依赖项,而不是自己创建依赖项。为了更好地理解这样一个概念,让我们尝试使用DI方法重写与之前相同的代码,如下所示:
public MyClass(Element myElement) {
myElement.doStuff();
}
正如我们所看到的,没有必要实例化myElement变量,因为依赖项注入器已经处理了这样的任务,这是一个外部代码,负责创建可注入对象并将对象注入类中。
整个 DI 编码模式基于控制反转(IoC的概念,以解决依赖关系。这样一个概念围绕着这样一个基本思想,即在形式上,如果ObjectA依赖于ObjectB,那么ObjectA就不能直接创建或导入ObjectB,而是提供了一种注入ObjectB的方式。在前面的代码块示例中,ObjectA显然是MyClass,而ObjectB是myElement实例。
For additional information about the DI software design pattern, check out the following links:
DI in .NET Core:
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection.
DI in Angular:
https://angular.io/guide/dependency-injection.
在 Angular 中,DI 框架在实例化类时向该类提供声明的依赖关系。
在前面的HealthCheckComponent类中,我们在组件的构造函数方法中使用 DI 注入HttpClient服务实例和baseUrl实例;如我们所见,我们还借此机会将private访问修饰符分配给了这两个修饰符。由于该修改器,这些变量将通过整个组件类进行访问。
As per Angular conventions, a parameter injected without an access modifier can only be accessed within the constructor; conversely, if it gets an access modifier such as private or public, it will be defined as a class member, hence changing its scope to the class. Such a technique is called variable scoping, and we're going to use it a lot in our Angular Components from now on.
ngOnInit(和其他生命周期挂钩)
我们在HealthCheckComponent类中使用的ngOnInit方法是组件的生命周期钩子方法之一:在本节中,我们将尝试对它们进行一些说明,因为在本书中我们将大量使用它们。
每个 Angular 组件都有一个生命周期,由 Angular 组件管理。每次用户访问我们应用中的视图时,Angular 框架都会创建并呈现所需的组件(和指令)以及他们的孩子,在用户与他们交互时对他们的更改做出反应,并最终将其销毁并从文档对象模型中删除(DOM)当用户在别处导航时。所有这些“关键时刻”都会触发 Angular 向开发人员公开的一些生命周期钩子方法,以便他们能够在每一个方法实际发生时执行某些操作:事实上,它们与 C#事件处理程序非常相似。
以下是可用钩子的列表,按执行顺序排列(如果可能,因为其中一些钩子在组件的生命周期中被多次调用):
ngOnChanges():当 Angular(重新)设置数据绑定输入属性时响应。该方法接收当前和以前属性值的SimpleChanges对象。在ngOnInit()之前调用,并且每当一个或多个数据绑定输入属性更改时调用。ngOnInit():首先显示数据绑定属性并设置指令/组件的输入属性后,初始化指令/组件。在第一个ngOnChanges()方法之后调用一次。ngDoCheck():检测 Angular 自身无法或不会检测到的变化并对其采取行动。在每次变更检测运行期间,在ngOnChanges()和ngOnInit()之后立即调用。ngAfterContentInit():Angular 将外部内容投射到组件视图/指令所在视图后响应。在第一个ngDoCheck()方法之后调用一次。ngAfterContentChecked():检查投射到指令/组件中的内容后响应。在ngAfterContentInit()方法和随后的每一个ngDoCheck()方法之后调用。ngAfterViewInit():Angular 初始化组件视图和子视图/指令所在视图后响应。在第一个ngAfterContentChecked()方法之后调用一次。ngAfterViewChecked():Angular 检查组件视图和子视图/指令所在视图后响应。在ngAfterViewInit()方法和随后的每一个ngAfterContentChecked()方法之后调用。ngOnDestroy():在 Angular 销毁指令/组件之前进行清理。取消订阅Observables并取消活动。- 避免内存泄漏的处理程序:在 Angular 销毁指令/组件之前调用。
上述生命周期挂钩方法适用于所有 Angular组件和指令。要调用它们,我们只需将它们添加到我们的组件类中,这正是我们在前面HealthCheckComponent中所做的。
现在我们已经理解了ngOnInit()的作用,我们应该花点时间来解释为什么我们将HttpClient源代码放在ngOnInit()生命周期钩子方法中,而不是使用组件的constructor()方法:我们不应该使用它吗?
下一节将极大地帮助我们理解这样一个选择的原因。
建造师
我们很可能已经知道,所有 TypeScript 类都有一个constructor()方法,每当我们创建该类的实例时都会调用该方法:因为 TypeScript 无论如何都是 JavaScript 的超集,任何 TypeScriptconstructor()方法都会被转换成 JavaScriptconstructor()函数。
以下代码块显示了 TypeScript 类的示例:
class MyClass() {
constructor() {
console.log("MyClass has been instantiated");
}
}
这将被传输到以下 JavaScript 函数中:
function MyClass() {
console.log("MyClass has been instantiated");
}
如果在 TypeScript 中省略构造函数,JavaScript Transpile 函数将为空;但是,无论框架是否具有构造函数,只要框架需要实例化它,它仍将以以下方式调用它:
var myClassInstance = new MyClass();
理解这一点非常重要,因为它极大地帮助我们理解组件的constructor()方法与其ngOnInit()生命周期挂钩之间的差异,这是一个巨大的差异,至少从组件初始化阶段的 Angular 来看是如此。
整个 Angular 引导过程可分为两个主要(及后续)阶段:
- 实例化组件
- 执行变更检测
我们很容易猜到,constructor()方法在前一阶段被调用,而包括ngOnInit()方法在内的所有生命周期挂钩在后一阶段都被调用。
如果我们从这个 Angular 来看这些方法,很容易理解以下关键概念:
- 如果我们需要在一个 Angular 分量中创建或注入一些依赖项,我们应该使用
constructor()方法;事实上,这也是我们能够做到这一点的唯一方法,因为构造函数是在 Angular 注入器上下文中被调用的唯一方法。 - 相反,每当我们需要执行任何组件初始化和/或更新任务时,比如执行 HTTP 请求、更新 DOM 等等,我们都应该使用生命周期挂钩中的一个来完成。
顾名思义,ngOnInit()方法通常是组件初始化任务的最佳选择,因为它发生在指令和/或组件输入属性设置之后。这就是我们使用 Angular 内置的HttpClient服务来实现 HTTP 请求的原因。
HttpClient
能够高效地从.NET Core 控制器发送和接收 JSON 数据可能是我们的单页应用(SPA的最重要要求。我们选择使用 AngularHttpClient服务来实现这一点,AngularHttpClient服务首先在 Angular 4.3.0-RC.0 中引入,这是框架能够提供的完成工作的最佳答案之一。正是因为这个原因,我们将在本书中大量使用它;但是,在这样做之前,最好正确地理解它是什么,为什么它比以前的实现更好,以及如何正确地实现它。
新的HttpClient服务于 2017 年 7 月推出,作为前 Angular HTTP 客户端 API(也称为@angular/http或简称HTTP的改进版本。Angular 开发团队没有替换@angular/http包中的旧版本,而是将新类放在一个单独的包@angular/common/http中。他们选择这样做是为了保持与现有代码库的向后兼容性,同时也为了确保缓慢而稳定地迁移到新 API。
那些至少使用过一次旧 Angular HTTP 服务类的人很可能会记住它的主要限制,如下所示:
- 默认情况下未启用JSON,这迫使开发人员在使用 RESTful API 时,在请求头-和
JSON.parse/JSON.stringify数据中显式设置它。 - 没有简单的方法访问 HTTP 请求/响应管道,从而阻止开发人员在请求和/或响应调用发出或接收后,使用一些丑陋且破坏模式的
黑客拦截或更改这些调用。事实上,扩展和包装类基本上是定制服务的唯一方法,至少在全局范围内是这样。 - 请求和响应对象没有本机的强类型,尽管这可以通过将 JSON 转换为接口作为解决方法来解决。
好消息是新的HttpClient做到了所有这些,而且做得更多;其他特性包括可测试性支持和通过完全基于Observables的 API 更好地处理错误。
It's worth noting that putting the HttpClient service within the Component itself is not good practice because it will often lead to unnecessary code repetitions among the various Components that need to perform HTTP calls and handle their results. This is a known issue that greatly affects production-level apps, which will likely require post-processing of the received data, handling errors, adding retry logic to deal with intermittent connectivity, and so on.
To better deal with those scenarios, it's strongly advisable to separate the data access logic and the data presentation role by encapsulating the former in a separate service, which can be then injected into all the Components that require it, in a standardized and centralized way. We'll talk more about that in Chapter 7, Code Tweaks and Data Services, where we'll eventually replace multiple HttpClient implementations and centralize their source code within a couple of D**ata Services.
可观测
观测值是管理异步数据的强大功能;它们是ReactiveX****JavaScript(RxJS)库的主干,该库是必需的依赖项之一,计划包含在 ECMAScript 7 的最终版本中。如果您熟悉 ES6承诺,您可以将其视为该方法的改进版本。
可观测可配置为同步或异步发送文字值、结构化值、消息和事件:可通过使用subscribe方法钩子订阅可观测本身来接收值,这意味着整个数据流都在其中处理,直到我们以编程方式选择取消订阅。这种方法的好处在于,无论选择何种方法(同步或异步)、流式传输频率和数据类型,用于监听值和停止监听的编程接口都是相同的。
可观测物的巨大优势是 Angular 在处理数据时广泛使用它们的原因。如果我们仔细看一下我们的HealthCheckComponent源代码,我们可以看到当我们的HttpClient服务从服务器获取数据并将结果存储在this.result局部变量中时,我们如何使用它们。这样的任务是通过调用两个连续的方法来执行的:get<Result>()和subscribe()。
让我们试着总结一下他们的工作,如下所示:
get<Result>():顾名思义,该方法向我们的.NET CoreHealthChecks中间件发出标准 HTTP 请求,获取结果JSON 响应对象。这个方法需要一个 URL 参数,我们通过向 Angular 应用的BASE_URL添加hc文本字符串(与我们之前在Startup.cs文件的Configure方法中设置的字符串相同)来动态创建该参数。subscribe():此方法实例化一个可观察的对象,该对象将在结果之后和/或出现错误时执行两个非常不同的操作。不用说,所有这些都将以异步方式完成,这意味着它将在单独的线程中运行(或计划稍后执行),而其余代码将继续执行。
Those who want to get additional information can take a look at the following URLs, taken from the RxJS official documentation:
ReactiveX Library—Observables guide:
http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html.
Angular.io—Observables guide:
https://angular.io/guide/observables.
理解这一点非常重要,因为我们只是触及了可观测的作用的表面。然而,这就是我们现在所需要的:稍后我们将有机会更多地讨论它们。
接口
现在我们知道了 AngularHttpClient服务是如何工作的,我们有权问自己几个问题:我们为什么还要使用那些接口?我们不能只使用我们早期定义的.NET CoreHealthChecks中间件发送的原始 JSON 数据,将其作为匿名 JavaScript 对象使用吗?
*从理论上讲,我们可以,就像我们可以从控制器输出原始 JSON 一样,而不是像我们那样创建所有 ViewModel 类。但是,在一个编写良好的应用中,我们应该始终抵制处理原始 JSON 数据和/或使用匿名对象的诱惑,原因有很多:
- 我们之所以选择 TypeScript 而不是 JavaScript,是因为我们希望使用类型定义:匿名对象和属性正好相反;它们导致了 JavaScript 的工作方式,这是我们首先要避免的。
- 匿名对象(及其属性)不容易验证:我们不希望数据项容易出错或被迫处理丢失的属性。
- 匿名对象几乎不可重用,并且不会从许多 Angular 方便的功能中受益,例如对象映射,这些功能要求我们的对象是接口和/或类型的实际实例。
前两个参数非常重要,特别是如果我们的目标是生产就绪的应用;无论我们的开发任务一开始看起来有多么简单,我们都不应该认为我们可以承受失去对应用源代码的控制。
第三个原因也是至关重要的,只要我们想充分利用 Angular。如果是这样,使用未定义的属性数组(如原始 JSON 数据)基本上是不可能的;相反,使用结构化 TypeScript 接口可以说是以强类型方式处理结构化 JSON 数据的最轻量级的方式。
It's worth noting that we've not added the export statement to our interface: we did that on purpose since we're only going to use this within the HealthCheckComponent class. Should we need to change such behavior in the future—for example, to create an external Data Service—we'll have to add such a statement (and, arguably, move each one of them into a separate file) to enable us to import them into other classes.
health-check.component.html
以下是/ClientApp/src/app/health-check/health-check.component.html源代码:
<h1>Health Check</h1>
<p>Here are the results of our health check:</p>
<p *ngIf="!result"><em>Loading...</em></p>
<table class='table table-striped' aria-labelledby="tableLabel" *ngIf="result">
<thead>
<tr>
<th>Name</th>
<th>Response Time</th>
<th>Status</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let check of result.checks">
<td>{{ check.name }}</td>
<td>{{ check.responseTime }}</td>
<td class="status {{ check.status }}">{{ check.status }}</td>
<td>{{ check.description }}</td>
</tr>
</tbody>
</table>
我们可以看到,Angular 组件的模板部分基本上是一个 HTML 页面,包含一个带有 Angular 指令的表。在继续之前,让我们仔细看看,如下所示:
ngIf:这是一个结构指令,根据等号(=)后指定的布尔表达式值,有条件地包含 container HTML 元素:当该表达式的计算结果为true时,Angular 渲染该元素;否则,它不会。它可以链接到一个else块,当表达式的计算结果为false或null时,如果存在该块,将显示该块。在前面的代码块中,我们在<table>元素中使用它,因此它仅在result内部变量(我们在组件类中定义)停止为null时出现,这将在从服务器获取数据后发生。ngFor:另一个结构指令,为给定集合中包含的每个项目提供模板。该指令放置在元素上,该元素将成为克隆模板的父元素。在前面的代码块中,我们在主<table>元素中使用它为result.checks数组中的每个check项创建并显示一个<tr>元素(一行)。{{ check.name }}、{{ check.responseTime }}等:这些被称为插值,可用于将计算字符串合并到 HTML 元素标记之间和/或属性分配内的文本中。换句话说,我们可以将它们用作类变量属性值的占位符。正如我们所看到的,插值默认分隔符是双大括号,{{和}}。
To understand more about ngIf, ngFor, interpolations, and other Angular UI fundamentals, we strongly suggest taking a look at the official documentation:
Displaying data:
https://angular.io/guide/displaying-data.
Template syntax:
https://angular.io/guide/template-syntax.
Structural directives:
https://angular.io/guide/structural-directives.
health-check.component.css
以下是/ClientApp/src/app/health-check/health-check.component.css源代码:
.status {
font-weight: bold;
}
.Healthy {
color: green;
}
.Degraded {
color: orange;
}
.Unhealthy {
color: red;
}
这里没什么好说的;只是一些普通的 CSS 来设计组件模板的样式。
Due to space, we won't be able to talk much about CSS styling in this book: we will just take it for granted that the average web programmer knows how to handle the simple definitions, selectors, and styling rules we will use in our examples.
Those who want (or need) to understand more about CSS and CSS3 are encouraged to take a look at this great online tutorial:
https://developer.mozilla.org/en-US/docs/Web/CSS.
注意我们如何处理表格单元格的样式,它将包含各种检查的状态。强烈建议尽可能突出显示它们,因此我们制作了它们bold,并使用了与status类型匹配的color:green表示Healthy,orange表示Degraded,红色表示Unhealthy。
将组件添加到 Angular 应用
现在我们的组件已经准备好了,我们需要将其正确地添加到 Angular 应用中。为此,我们需要对以下文件进行一些最小的更改:
app.module.tsnav-menu.component.tsnav-menu.component.html
让我们把这件事做完。
应用模块
我们从第 2 章中了解到环顾,每一个新的组件都必须在AppModule中引用,以便在我们的应用中注册。除此之外,我们需要在RoutingModule配置中创建相关条目,以便用户能够导航到该页面。
打开/ClientApp/src/app/app.module.ts文件并添加以下突出显示的行:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
import { HealthCheckComponent } from './health-check/health-check.component';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
HealthCheckComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'health-check', component: HealthCheckComponent }
])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
任务完成:让我们继续前进。
导航组件
将我们新的组件导航路径添加到RoutingModule是让我们的用户能够到达的必要步骤;但是,我们还需要添加一个链接,供用户点击。由于NavMenuComponent是处理导航用户界面的组件,我们也需要在那里执行一些操作。
打开ClientApp/src/app/nav-menu/nav-menu.component.ts文件,添加以下高亮显示的行:
// ... existing code...
<ul class="navbar-nav flex-grow">
<li
class="nav-item"
[routerLinkActive]="['link-active']"
[routerLinkActiveOptions]="{ exact: true }"
>
<a class="nav-link text-dark" [routerLink]="['/']">Home</a>
</li>
<li class="nav-item" [routerLinkActive]="['link-active']">
<a class="nav-link text-dark" [routerLink]="['/health-check']"
>Health Check</a
>
</li>
</ul>
// ... existing code...
现在我们新的组件已经添加到我们的 Angular 应用中,我们只需要对其进行测试。
测试它
要看到我们新的HealthCheckComponent的辉煌,我们只需要点击F5并查看浏览器的结果,如果我们做的一切都正确,应该与下面的屏幕截图非常相似:

看起来肯定是我们干的!
我们的健康检查已经启动并运行,自豪地向我们展示我们在.NET Core 的HealthChecksMiddleware中设置的三个 ICMP 请求的结果。
总结
让我们花一分钟时间简要回顾一下我们在本章学到的内容。首先,我们承认,.NET 控制器并不是 shed 中唯一的工具:事实上,任何中间件都几乎能够处理 HTTP请求和响应周期,只要它在我们的应用管道中。
为了演示这样一个概念,我们引入了HealthChecksMiddleware,这是一个整洁的.NET Core 内置功能,可用于实现状态监视服务。。。。这就是我们在本章中所做的。我们从.NET Core后端开始,不断完善我们的工作,直到能够创建 JSON 结构的输出;然后,我们切换到 Angular,在那里我们学习了如何使用组件正确地获取它,并通过浏览器基于 HTML 的 UI 在屏幕上显示它。最终,最终的结果是好的,足以奖励我们的辛勤工作。
这对于健康检查来说已经足够了,至少目前是这样:从下一章开始,我们将带回标准的.NET控制器模式,看看如何利用它来学习新的东西。
建议的主题
运行状况监视、运行状况检查、运行状况检查软件、运行状况检查选项、HTTP 请求、HTTP 响应、ICMP、PING、ResponseWriter、JSON、JSON 序列化配置。组件、路由、模块、AppModule、HttpClient、ngIf、ngFor、指令、结构指令、插值、NgModule、Angular 模块系统、JavaScript 模块系统(导入/导出)。
工具书类
- ASP.NET Core 中的健康检查:https://docs.microsoft.com/en-US/aspnet/core/host-and-deploy/health-checks
- ASP.NET Core 中的请求和响应操作:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/request-response
- ASP.NET Core 健康监控:https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/monitor-app-health
- 类型脚本模块:https://www.typescriptlang.org/docs/handbook/modules.html
*** 模块分辨率:https://www.typescriptlang.org/docs/handbook/module-resolution.html** ASP.NET Core 中的依赖项注入:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection* Angular io 依赖注入:https://angular.io/guide/dependency-injection* 弯钩:https://angular.io/guide/lifecycle-hooks* 反应性 X 库可见物:http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html* Angular io 可观测引导:https://angular.io/guide/observables** JavaScript 导入语句:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import* JavaScript 导出语句:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export* Angular HttpClient:https://angular.io/guide/http#httpclient* 角模件https://angular.io/guide/ngmodules* Angular 模块和 JavaScript 模块:https://angular.io/guide/architecture-modules#ngmodules-和 javascript 模块* Angular 显示数据:https://angular.io/guide/displaying-data* Angular 模板语法:https://angular.io/guide/template-syntax* 角结构指令:https://angular.io/guide/structural-directives*** CSS 级联样式表:https://developer.mozilla.org/en-US/docs/Web/CSS***********
四、实体框架核心的数据模型
我们从第 1 章、准备开始使用的HealthCheck示例应用运行良好,但缺少一些我们可能在典型 web 应用中使用的重要功能;其中最重要的是能够从数据库管理系统(DBMS中读取和写入数据,因为这是几乎所有与 web 相关的任务的基本要求:内容管理、知识共享、即时通信、数据存储和/或挖掘、跟踪和统计,用户身份验证、系统日志记录等。
**说实话,即使是我们的HealthCheck应用也肯定可以使用其中的一些任务:随着时间的推移跟踪主机状态可能是一个不错的功能;用户认证应该是必须具备的,特别是如果我们计划将其公开发布到 web 上;系统日志记录总是很棒的;等等然而,由于我们更喜欢保持项目尽可能简单,我们将创建一个新项目,并向其授予一些 DBMS 功能。
这是我们在本章要做的:
- 创建一个全新的.NET Core 3 和 Angular web 应用项目,名为WorldCities:世界各地城市的数据库
- 选择合适的数据源获取合理数量的真实数据进行播放
- 使用实体框架核心定义并实现数据模型
- 配置并部署我们项目将使用的 DBMS 引擎
- 使用实体框架核心的数据迁移功能创建数据库
- 执行数据播种策略将数据源加载到数据库
- 使用实体框架核心提供的对象关系映射(ORM技术,通过与.NET Core 进行数据读写
你准备好开始了吗?
技术要求
在本章中,我们需要前面章节中列出的所有先前的技术要求,以及以下外部库:
Microsoft.EntityFrameworkCoreNuGet 套餐Microsoft.EntityFrameworkCore.ToolsNuGet 套餐Microsoft.EntityFrameworkCore.SqlServerNuGet 套餐- SQL Server 2019(如果我们选择本地 SQL 实例路由)
- MS Azure 订阅(如果我们选择云数据库托管路由)
和往常一样,建议不要直接安装它们。我们将在本章中介绍它们,以便我们可以在项目中对它们的用途进行上下文分析。
世界城市网络应用
我们要做的第一件事是创建一个新的.NET Core 和 Angular web 应用项目。还记得我们在第 1 章、准备的第二部分所做的事情吗?我们可以做同样的事情(并对我们在第 2 章、环视中所做的示例项目进行所有相关更改),或者将我们现有的HealthCheck项目复制到另一个文件夹,将所有引用重命名为HealthCheck(源代码并文件系统),并撤销我们在第 2 章、环视、第 3 章、前端和后端交互中所做的一切。******
******尽管两种方法都很好,但前一种方法肯定更实用,更不用说这是一个很好的机会,可以将我们迄今为止学到的知识付诸实践,并确保我们理解了每个相关步骤。
让我们简要回顾一下我们需要做什么:
- 使用
dotnet new angular -o WorldCities命令创建一个新项目。 - 编辑或删除以下.NET Core后端文件:
Startup.cs(编辑)WeatherForecast.cs(删除)/Controllers/WeatherForecastController.cs(删除)
- 编辑或删除以下 Angular前端文件:
/ClientApp/package.json(编辑)/ClientApp/src/app/app.module.ts(编辑)/ClientApp/src/app/nav-menu/nav-menu.component.html(编辑)/ClientApp/src/app/counter/(删除整个文件夹)/ClientApp/src/app/fetch-data/(删除整个文件夹)
In the unlikely case that we choose to copy and paste the HealthCheck project—which we don't recommend, we would need to remove the HealthChecks middleware references from the Startup.cs file and the Angular Components references within the various Angular configuration files. We would also have to delete the related .NET and Angular class files (ICMPHealthCheck, CustomHealthCheckOptions, the /ClientApp/src/app/health-check/ folder, and so on).
As we can see, cloning a project would mean that we would have to perform a lot of undo and/or rename activities: this is precisely why starting from scratch is generally a better approach.
在完成所有这些更改后,我们可以通过按F5并检查结果来检查一切是否正常。如果一切正常,我们应该能够看到以下屏幕:

就是这样:现在,我们有一个全新的.NET Core+Angular web 应用可供使用。我们只需要一个数据源和一个数据模型,可以通过后端 web API访问,从以下位置检索一些数据:换句话说,数据服务器。
使用数据服务器的原因
在我们继续之前,花几分钟回答以下问题是明智的:我们真的需要真正的数据服务器吗?我们就不能模仿一个吗?毕竟,我们只运行代码示例。
事实上,我们完全可以避免这样做,跳过整个章节:Angular 提供了一个内存 Web API包,它取代了HttpClient模块的HttpBackend并在 RESTful API 上模拟CRUD操作;仿真是通过截获 Angular HTTP 请求并将它们重定向到我们控制下的内存中数据存储来执行的。
此包非常好,适用于大多数测试用例场景,例如:
- 模拟针对尚未在开发/测试服务器上实现的数据收集的操作
- 编写读写数据的单元测试应用,而无需拦截多个 HTTP 调用和生成响应序列
- 在不干扰真实数据库的情况下执行端到端测试,这对于C****持续集成(CI构建非常有用
内存中的 Web API 服务工作得非常好,整个 Angular 文档都位于https://angular.io/ 依赖它。然而,我们现在不打算使用它,原因很简单(也很明显):本书的重点不是 Angular,而是 Angular 和.NET Core 之间的客户端/服务器互操作性;正是出于这个原因,开发一个真实的 Web API 并通过真实的数据模型将其连接到真实的数据源是游戏的一部分。
我们不想模拟 RESTful后端的行为,因为我们需要了解那里发生了什么以及如何正确地实现它:我们想要实现它,以及承载和提供数据的 DBMS。
这正是我们要做的,从下一节开始。
Those who want to get additional information about the Angular In-memory Web API service can visit the in-memory-web-api GitHub project page at https://github.com/angular/in-memory-web-api/.
数据源
我们的WorldCitiesweb 应用将提供什么样的数据?我们已经知道答案:一个来自世界各地的城市数据库。这样的存储库还存在吗?
事实上,我们可以使用几种替代方法来填充数据库,然后将其提供给最终用户。
以下是 DSpace CRIS 的自由世界城市数据库:
- URL:https://dspace-cris.4science.it/handle/123456789/31
- 格式:CSV
- 许可证:免费使用
以下是 GeoDataSource 的世界城市数据库(免费版):
- URL:http://www.geodatasource.com/world-cities-database/free
- 格式:CSV
- 许可证:免费使用(需注册)
以下是 simplemaps.com 提供的世界城市数据库:
- URL:https://simplemaps.com/data/world-cities
- 格式:CSV,XLSX
- 许可证:免费使用(4.0 抄送,https://creativecommons.org/licenses/by/4.0/
所有这些替代方案都足以满足我们的需要:我们将使用后者,因为它不需要注册,并且提供了一种可读的电子表格格式。
打开您喜爱的浏览器,键入或复制上述 URL,然后查找“世界城市数据库”部分的“基本”列:

单击下载按钮检索包含.csv和.xlsx文件的(巨大)ZIP 文件,并将其保存在某处。现在就这样;我们稍后会处理这些问题。
从下一节开始,我们将开始数据模型的构建过程:这将是一个漫长但非常有益的旅程。
数据模型
现在我们有了原始数据源,我们需要找到一种方法使其可用于我们的 web 应用,以便我们的用户能够检索(并可能更改)实际数据。
为了简单起见,我们不会浪费宝贵的时间来介绍整个数据模型的概念,以及这两个词的各种含义。你们这些有经验的人,以及经验丰富的开发人员,可能会知道所有相关的东西。我们只想说,当我们谈论数据模型时,我们所指的并不是更多或更少的一组轻量级、明确类型化的实体类,这些实体类表示持久的、代码驱动的数据结构,我们可以将其用作 Web API 代码中的资源。
使用 persistent 这个词是有原因的;我们希望数据结构存储在数据库中。这对于任何基于数据的应用来说都是显而易见的。我们将要创建的全新 web 应用不会例外,因为我们希望它充当记录的集合或存储库,以便我们可以根据需要读取、创建、删除和/或修改。
我们很容易猜到,所有这些任务都将由一些由前端UI(Angular 组件)触发的后端业务逻辑(.NET 控制器)执行。
引入实体框架核心
我们将借助微软为ADO.NET开发的实体框架核心(也称EF 核心)和著名的开源对象关系映射器(ORM)来创建我们的数据库。作出这种选择的原因如下:
- 与 VisualStudioIDE 的无缝集成
- 基于实体类(实体数据模型(EDM)的概念模型,允许我们使用特定于域的对象处理数据,而无需编写数据访问代码,这正是我们所寻找的
- 易于在开发和生产阶段部署、使用和维护
- 兼容所有主流开源商业 SQL 和 NoSQL 引擎,包括MSSQL、SQLite、Azure Cosmos DB、PostgreSQL、MySQL/MariaDB、MyCAT、Firebird、Db2/Informix、Oracle DB、MongoDB等,感谢通过 NuGet 提供的官方和/或第三方提供商和/或连接器
值得一提的是,实体框架核心之前被称为实体框架 7,直到其最新的 RC 版本。名称更改遵循了我们已经讨论过的 ASP.NET 5/ASP.NET Core 透视图切换,因为如果我们将其与前几期进行比较,它还强调了实体框架核心的主要重写/重新设计。
您可能想知道为什么我们选择采用基于 SQL 的方法,而不是使用 NoSQL 替代方案;有很多好的 NoSQL 产品,比如 MongoDB、RavenDB 和 CouchDB,它们碰巧有一个 C#连接器库。用其中一个来代替怎么样?
答案很简单:尽管可以作为第三方提供商使用,但它们尚未被纳入官方的实体框架核心数据库提供商列表(请参见以下信息框中的链接)。正是出于这个原因,我们将坚持使用关系数据库,这对于我们将在本书中设计的简单数据库模式来说可能是一种更方便的方法。
对于那些希望更多了解即将发布的版本和/或大胆使用它的人,我们强烈建议您查看以下链接和文档:
项目路线图:https://github.com/aspnet/EntityFramework/wiki/Roadmap GitHub 上的源代码:https://github.com/aspnet/EntityFramework
正式文件:https://docs.efproject.net/en/latest/ 官方实体框架核心数据库提供商列表:**https://docs.microsoft.com/en-us/ef/core/providers/?tabs=dotnet-核心 cli
****# 安装实体框架核心
要安装 EntityFrameworkCore,我们需要将相关包添加到项目文件的 dependencies 部分。我们可以通过以下方式使用可视化 GUI 轻松完成此操作:
- 右键点击
WorldCities项目。 - 选择 Manage NuGet Packages。
- 确保“包源”下拉列表设置为“全部”。
- 转到“浏览”选项卡,搜索包含
Microsoft.EntityFrameworkCore关键字的软件包:

到达后,选择并安装以下软件包(最新版本,在撰写本文时):
Microsoft.EntityFrameworkCore版本 3.1.1Microsoft.EntityFrameworkCore.Tools版本 3.1.1Microsoft.EntityFrameworkCore.SqlServer版本 3.1.1
所有这些软件包还将带来一些必需的依赖项,我们还需要安装这些依赖项:

如果我们希望使用 NuGet package manager 命令行执行此操作,则可以输入以下内容:
PM> Install-Package Microsoft.EntityFrameworkCore -Version 3.1.1
PM> Install-Package Microsoft.EntityFrameworkCore.Tools -Version 3.1.1 PM> Install-Package Microsoft.EntityFrameworkCore.SqlServer -Version 3.1.1
值得注意的是,版本号(在撰写本文时是最新的版本号)可能会发生更改:请务必在本书的 GitHub 存储库中三次检查它!
SQL Server 数据提供程序
在已安装的名称空间中,值得注意的是存在Microsoft.EntityFrameworkCore.SqlServer,它是实体框架核心的Microsoft SQL 数据库提供程序。此高度通用的连接器为整个 Microsoft SQL Server 数据库系列(包括最新的 SQL Server 2019)提供了一个接口。
DBMS 许可模型
尽管有一个相当昂贵(至少可以说)的许可模式,但至少有三个 Microsoft SQL 版本可以免费使用,只要满足某些要求:
- 评估版是免费的,但没有生产使用权,这意味着我们只能在开发服务器上使用。此外,它只能使用 180 天。之后,我们必须购买许可证或卸载它(并迁移到其他版本)。
- 开发者版也是免费的,没有生产使用权。但是,如果我们只在开发和/或测试场景中使用它,则可以不受限制地使用它。
- Express Edition是免费的,可以在任何环境中使用,这意味着我们可以在开发和生产服务器上使用它。但是,它有一些主要的性能和大小限制,可能会阻碍复杂和/或高流量 web 应用的性能。
For additional information regarding the various SQL Server editions, including the commercial ones that do require a paid licensing model, check out the following links:
https://www.microsoft.com/en-us/sql-server/sql-server-2019
https://www.microsoft.com/en-us/sql-server/sql-server-2019-comparison
正如我们很容易看到的,开发者和Express版本对于像我们在本书中所玩的那些小型 web 应用来说都是非常有用的。
Linux 呢?
SQL Server 2019 也可用于 Linux,并正式支持以下发行版:
- 红帽企业(RHEL)
- SUSE 企业服务器
- Ubuntu
除此之外,它还可以设置为在 Docker 上运行,甚至在 Azure 上配置为虚拟机,如果我们不想安装本地 DMBS 实例并节省宝贵的硬件资源,这通常是一个很好的选择。
至于许可模式,对于所有这些环境,所有 SQL Server 产品的许可方式都是相同的:这基本上意味着我们可以在我们选择的平台上使用我们的许可证(包括免费许可证)。
SQL Server 替代方案
如果您不想使用 Microsoft SQL Server,您可以 100%自由选择其他 DBMS 引擎,如 MySQL、PostgreSQL 或任何其他产品,只要它得到某种实体框架官方(或第三方)支持。
我们应该现在作出决定吗?这完全取决于我们想要采用的数据建模方法;目前,为了简单起见,我们将坚持使用 Microsoft SQL Server 系列,它允许我们在本地计算机(开发和/或生产)或 Azure(由于其 200 欧元的成本和 12 个月的免费试用期)上免费安装一个像样的 DBMS:现在不用担心,我们稍后会实现的。
数据建模方法
现在我们已经安装了实体框架,并且我们或多或少地知道我们将使用哪种 DBMS,我们必须在三种可用方法中选择一种来建模数据结构:模型优先、数据库优先或代码优先。每一种方法都有其相当多的优点和缺点,有经验的人和经验丰富的.NET 开发人员几乎肯定都知道这一点。尽管我们不会深入研究这些问题,但在做出选择之前对每一个问题进行简要总结可能是有用的。
模型优先
如果您不熟悉 Visual Studio IDE 设计工具,例如基于 XML 的数据集模式(XSD)和实体设计器模型 XML 可视化界面(EDMX),那么模型优先的方法可能会相当混乱。理解它的关键是要承认这样一个事实,即这里的单词 Model 是用来定义用设计工具构建的可视化图表。然后,框架将使用该图自动生成 SQL 脚本和数据模型源代码文件。
总之,我们可以说,先建立模型意味着处理可视化的 EDMX 图,并让实体框架相应地创建/更新其余的:

下面几节将解释其利弊。
赞成的意见
这种方法有以下好处:
- 我们将能够使用可视化设计工具创建数据库模式和类图作为一个整体,这在数据结构非常大的情况下非常有用。
- 每当数据库发生更改时,可以相应地更新模型,而不会丢失数据。
欺骗
然而,也有一些不利因素,如下所示:
- 图表驱动、自动生成的 SQL 脚本在更新时可能导致数据丢失。一个简单的解决方法是在磁盘上生成脚本并手动修改它们,这将需要良好的 SQL 知识。
- 处理图表可能很棘手,特别是如果我们想精确控制我们的模型类;我们并不总是能够得到我们想要的,因为实际的源代码将由工具自动生成。
数据库优先
考虑到 Model First 的缺点,我们可以认为数据库优先可能是一种方式。如果我们已经有了一个数据库,或者不介意事先建立它,这可能是真的。在这种情况下,数据库优先的方法与模型优先的方法相似,只是它的方向相反;我们没有手动设计 EDMX 并生成 SQL 脚本来创建数据库,而是构建后者,然后使用 Entity Framework designer 工具生成前者。
我们可以这样概括,首先进入数据库意味着构建数据库,并让实体框架相应地创建/更新其余的:

下面几节将解释其利弊。
赞成的意见
以下是数据库优先方法的主要优点:
- 如果我们有一个已经存在的数据库,这可能是一个可行的方法,因为它将使我们无需重新创建它。
- 数据丢失的风险将保持在最低限度,因为任何结构更改或数据库模型更新都将始终在数据库本身上执行。
欺骗
以下是缺点:
- 如果我们要处理集群、多个实例或许多开发/测试/生产环境,手动更新数据库可能会很棘手,因为我们必须手动保持它们的同步,而不是依赖代码驱动的更新/迁移或自动生成的 SQL 脚本。
- 与使用模型优先方法相比,我们对自动生成的模型类(及其源代码)的控制更少。这需要对环境足迹公约和标准有广泛的了解;否则,我们将常常难以得到我们想要的东西。
代码优先
最后但并非最不重要的是 Entity Framework 自版本 4 以来的旗舰方法,它支持优雅、高效的数据模型开发工作流。这种方法的吸引力很容易在其前提中找到;代码优先方法允许开发人员仅使用标准类定义模型对象,而不需要任何设计工具、XML 映射文件或繁琐的自动生成代码。
总之,我们可以说,先编写代码意味着编写我们将在项目中使用的数据模型实体类,并让实体框架相应地生成数据库:

下面几节将解释其利弊。
赞成的意见
以下是这种方法的优点:
- 不需要任何图表和可视化工具,这对于中小型项目非常有用,因为这样可以节省大量时间。
- 它有一个流畅的代码 API,允许开发人员遵循约定而不是配置的方法,以便它可以处理最常见的场景,同时也让他们有机会切换到自定义的、基于属性的实现,以覆盖自定义数据库映射的需要。
欺骗
以下是这种方法的缺点:
- 需要对 C#和更新的 EF 公约有良好的了解。
- 维护数据库通常很棘手,处理更新时也不会丢失数据。迁移支持是在 4.3 中添加的,用于克服此问题,并从那时起不断更新,极大地缓解了此问题,尽管它也以负面方式影响了学习曲线。
作出选择
考虑到这三个选项的优缺点,没有一个整体的更好的或最好的方法;相反,我们可以说每个项目场景都可能有一个最适合的方法。
关于我们的项目,考虑到我们还没有数据库,并且我们的目标是一个灵活、可变的小规模数据结构,采用代码优先的方法可能是一个不错的选择。
然而,要做到这一点,我们需要创建一些实体,并找到一个合适的 DBMS 来存储数据:这正是我们在下面几节要做的。
创建实体
现在我们有了一个数据源,我们可以利用前面提到的代码优先方法的一个主要优点,尽早开始编写实体类,而不用太担心最终将使用什么数据库引擎。
说实话,我们已经知道一些我们最终会用到的东西。我们不会采用 NoSQL 解决方案,因为它们还没有得到实体框架核心的正式支持;我们也不想承诺购买昂贵的许可计划,因此 Oracle 和 SQL Server 的商业版可能也不在考虑之列。
这就给我们留下了相对较少的选择:SQLServerDeveloper(或 Express)版、MySQL/MariaDB 或其他不太知名的解决方案,如 PostgreSQL。此外,我们仍然不能 100%确定是否在开发机器(和/或生产服务器)上安装本地 DBMS 实例,或者是否依赖于云托管解决方案(如 Azure)。
也就是说,首先采用代码将使我们有机会推迟调用,直到数据模型就绪。
然而,要创建实体类,我们需要知道它们将包含什么类型的数据以及如何构造它:这在很大程度上取决于我们最终希望首先使用代码创建的数据源和数据库表。
在以下部分中,我们将学习如何处理这些任务。
定义实体
在实体框架中,以及在大多数 ORM 框架中,实体是映射到给定数据库表的类。实体的主要目的是使我们能够以面向对象的方式处理数据,同时使用强类型属性访问每行的表列(和数据关系)。我们将使用实体从数据库中获取数据,并将它们序列化为用于前端的 JSON。我们也会做相反的事情,也就是说,每当前端发出我们需要持久化数据库的状态时,从 POST 数据反序列化它们。
如果我们试着扩大我们的关注点,看看总体情况,我们将能够看到实体如何在 DBMS、web 应用的后端和前端部分之间的整个双向数据流中发挥核心作用。
为了理解这样一个概念,我们来看看下面的图表:

我们可以清楚地看到,实体框架核心的主要目的是将数据库表映射到实体类:这正是我们现在需要做的。
解压我们刚才下载的 world cities 压缩文件,打开worldcities.xlsx文件:如果您没有 MS Excel,可以使用 Google Sheets 在 Google Drive 上导入,如下 URL 所示:http://bit.ly/worldcities-xlsx 。
Right after importing it, I also took the chance to make some small readability improvements to that file: bold column names, resizing the columns, changing the background color and freezing on the first row, and so on.
如果打开前面的 URL,我们将看到导入的电子表格的外观:

通过查看电子表格标题,我们可以推断出至少需要两个数据库表:
- 城市:对于A、B、C和D(如果我们想保留这些唯一 ID,可以说是K)
- 国家:E、F和G列
从常识来看,这似乎是最方便的选择。或者,我们可以将所有内容都放在一个Cities表中,但我们会有大量冗余内容,这是我们可能希望避免的。
如果我们要处理两个数据库表,这意味着我们首先需要两个实体来映射它们并创建它们,因为我们计划采用代码优先的方法。
城市实体
让我们从City实体开始。
从项目的解决方案资源管理器中,执行以下操作:
- 在
WorldCities项目的根级新建/Data/文件夹;这将是我们所有实体框架相关类将驻留的地方。 - 创建一个
/Data/Models/文件夹。 - 添加一个新的 ASP.NET Core|代码|类文件,将其命名为
City.cs,并用以下代码替换示例代码:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
namespace WorldCities.Data.Models
{
public class City
{
#region Constructor
public City()
{
}
#endregion
#region Properties
/// <summary>
/// The unique id and primary key for this City
/// </summary>
[Key]
[Required]
public int Id { get; set; }
/// <summary>
/// City name (in UTF8 format)
/// </summary>
public string Name { get; set; }
/// <summary>
/// City name (in ASCII format)
/// </summary>
public string Name_ASCII { get; set; }
/// <summary>
/// City latitude
/// </summary>
public decimal Lat { get; set; }
/// <summary>
/// City longitude
/// </summary>
public decimal Lon { get; set; }
#endregion
/// <summary>
/// Country Id (foreign key)
/// </summary>
public int CountryId { get; set; }
}
}
正如我们所见,我们为我们早期识别的每个电子表格列添加了一个专用属性;我们还包括一个CountryId属性,我们将使用该属性映射与城市相关的Country的外键(稍后将对此进行详细介绍)。我们还试图通过为每个属性提供一些有用的注释来提高实体类源代码的整体可读性,这些注释肯定会帮助我们记住它们的用途。
最后但并非最不重要的一点是,值得注意的是,我们利用一些数据注释属性来修饰实体类,因为它们是覆盖默认代码优先约定的最方便的方式。更具体地说,我们使用了以下注释:
[Required]:将属性定义为必填(不可空)字段。[Key]:表示该属性承载数据库表的主键。[ForeignKey]:表示该属性承载一个外部表的主**键。
那些对实体框架(和关系数据库)有一定经验的人很可能会理解这些数据注释的用途:它们是一种方便的方法,可以指导实体框架在使用代码优先方法时如何正确构建数据库。这里没有什么复杂的东西;我们只是告诉 Entity Framework,为承载这些属性而创建的数据库列应该根据需要进行设置,主键应该以一对多的关系绑定到不同表中的其他外部列。
The binding that's declared using the [ForeignKey] Data Annotation will be formally enforced by creating a constraint, as long as the DB engine supports such a feature.
为了使用数据注释,我们必须在类的开头添加对System.ComponentModel.DataAnnotations和System.ComponentModel.DataAnnotations.Schema名称空间的引用。如果我们看一看前面的代码,就会发现这两个名称空间都被 using 语句引用了。
If you want to find out more about Data Annotations in Entity Framework Core, we strongly suggest reading the official documentation, which can be found at the following URL: https://docs.efproject.net/en/latest/modeling/index.html.
国
下一个实体将是识别国家的实体,该实体将与Cities建立一对多关系。
This is hardly a surprise: we're definitely going to expect a single country for each City and multiple Cities for each given Country: this is what one-to-many relationships are for.
右键点击/Data/Models/文件夹,添加Country.cs类文件,并填入以下代码:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
namespace WorldCities.Data.Models
{
public class Country
{
#region Constructor
public Country()
{
}
#endregion
#region Properties
/// <summary>
/// The unique id and primary key for this Country
/// </summary>
[Key]
[Required]
public int Id { get; set; }
/// <summary>
/// Country name (in UTF8 format)
/// </summary>
public string Name { get; set; }
/// <summary>
/// Country code (in ISO 3166-1 ALPHA-2 format)
/// </summary>
public string ISO2 { get; set; }
/// <summary>
/// Country code (in ISO 3166-1 ALPHA-3 format)
/// </summary>
public string ISO3 { get; set; }
#endregion
}
}
同样,每个电子表格列都有一个属性,带有相关的数据注释和注释。
ISO 3166 is a standard that was published by the International Organization for Standardization (ISO) that's used to define unique codes for the names of countries, dependent territories, provinces, and states. For additional information, check out the following URLs:
https://en.wikipedia.org/wiki/ISO_3166
https://www.iso.org/iso-3166-country-codes.html
The part that describes the country codes is the first one (ISO 3166-1), which defines three possible formats: ISO 3166-1 alpha-2 (two-letter country codes), ISO 3166-1 alpha-3 (three-letter country codes), and ISO 3166-1 numeric (three-digit country codes). For additional information about the ISO 3166-1 ALPHA-2 and *ISO 3166-1 ALPHA-3 *formats, which are the ones that are used in our data source and therefore in this book, check out the following URLs:
https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3
定义关系
现在,我们已经构建了主要的City和Country实体框架,我们需要加强我们知道它们之间存在的关系。我们希望能够做一些事情,比如检索一个Country,然后浏览到所有相关的Cities,可能是以强类型的方式。
为此,我们必须添加两个新的实体相关属性,每个实体类一个。更具体地说,我们将添加以下内容:
- 我们的
City实体类别中的Country财产,将包含一个与该城市相关的国家(即母公司) - 我们的
Country实体类中的Cities属性,将包含与该国相关的城市集合(即儿童)
如果我们深入观察并尝试可视化这些实体之间的关系,我们将能够看到前一个属性如何识别父(从每个子视图),而后一个属性将包含子(从父视图)当前位置这种模式正是我们所期望的一对多关系,就像我们正在处理的关系。
在以下部分中,我们将学习如何实现这两个导航属性。
将 Country 属性添加到城市实体类
在文件末尾附近添加以下代码行,靠近属性区域的末尾(新行高亮显示):
using System.ComponentModel.DataAnnotations.Schema;
// ...existing code...
/// <summary>
/// Country Id (foreign key)
/// </summary>
[ForeignKey("Country")]
public int CountryId { get; set; }
#endregion
#region Navigation Properties
/// <summary>
/// The country related to this city.
/// </summary>
public virtual Country Country { get; set; }
#endregion
// ...existing code...
如我们所见,除了添加新的Country属性外,我们还使用新的[ForeignKey("Country")]数据注释装饰了现有的CountryId属性。由于该注释,Entity Framework 将知道这样一个属性将托管一个外部表的主键,Country导航属性将用于托管父实体。
It's worth noting that the binding that's declared using that [ForeignKey] data annotation will be also formally enforced by creating a constraint, as long as the DB engine supports such a feature.
通过查看前面源代码的第一行可以看出,要使用[ForeignKey]数据注释,我们必须在类的开头添加对System.ComponentModel.DataAnnotations.Schema名称空间的引用。
将 Cities 属性添加到 Country 实体类
同样,在属性区域的末尾添加以下内容(新行高亮显示):
// ...existing code...
#region Navigation Properties
/// <summary>
/// A list containing all the cities related to this country.
/// </summary>
public virtual List<City> Cities { get; set; }
#endregion
// ...existing code...
就这样。如我们所见,由于一对多关系不需要来自父端的外键属性,因此没有必要为该实体定义外键属性:因此,不需要添加[ForeignKey]数据注释和/或其所需的命名空间。
实体框架核心加载模式
现在我们在Country实体中有了Cities属性,在City实体中有了相应的[ForeignKey]数据注释,您可能想知道我们如何使用这些导航属性来加载相关实体。换句话说:我们将如何在需要时在国家实体内填充城市财产?
这样的问题让我们有机会花几分钟列举 Entity Framework Core 支持的三种 ORM 模式,以加载此类相关数据:
- 急加载:作为初始查询的一部分,从数据库加载相关数据。
- 显式加载:以后从数据库显式加载相关数据。
- 延迟加载:第一次访问实体导航属性时,从数据库中透明加载相关数据。这是三种模式中最复杂的模式,如果没有正确实现,可能会受到一些严重的性能影响。
理解这一点很重要,无论何时我们想要加载实体的相关数据,我们都需要激活(或实现)其中一种模式。这意味着,在我们的特定场景中,Country实体的Cities属性将在我们从数据库获取一个或多个国家时设置为 NULL,除非我们明确告知实体框架核心也加载城市这是在处理 Web API 时要考虑的一个非常重要的方面,因为它肯定会影响我们的.NETCyrasyTo.T6.后端 AutoT7A.将如何服务于我们的 JSON 结构化数据响应到我们的 To8T8 前端前端 T9 角客户机。
为了理解我们的意思,让我们来看看几个例子。
下面是一个标准的实体框架核心查询,用于从给定的Id中检索Country:
var country = await _context.Countries
.FindAsync(id);
return country; // country.Cities is still set to NULL
正如我们所看到的,country变量返回给调用者,Cities属性设置为 NULL,这仅仅是因为我们没有要求它:正是因为这个原因,如果我们将该变量转换为 JSON 对象并返回给客户端,JSON 对象也将不包含任何城市。
下面是一个实体框架核心查询,使用急加载从给定id中检索country:
var country = await _context.Countries
.Include(c => C.Cities) .FindAsync(id);
return country; // country.Cities is (eagerly) loaded
让我们试着了解一下这里发生了什么:
- 在查询开始时指定的
Include()方法告诉实体框架核心激活急切加载数据检索模式。 - 对于新模式,EF 查询将在单个查询中获取
country以及所有相应的城市。 - 由于所有这些原因,返回的
country变量的Cities属性将填充与country相关的所有cities(即CountryId值将等于国家的id值)。
**For additional information regarding lazy loading, eager loading, and explicit loading, we strongly suggest that you take a look at the following URL: https://docs.microsoft.com/en-US/ef/core/querying/related-data.
这样,我们就完成了实体的处理,至少目前是这样。现在,我们只需要为自己建立一个 DBMS,这样我们就可以实际创建数据库了。
获取 SQL Server
让我们一劳永逸地缩小这一差距,并为自己提供一个 SQL Server 实例。正如我们已经提到的,我们可以采取两条主要路线:
- 在我们的开发机器上安装本地 SQL Server 实例(Express 或 Developer Edition)。
- 使用 Azure平台上提供的几种选项之一,在 Azure 上设置 SQL 数据库(和/或服务器)。
前一个选项体现了软件和 web 开发人员从一开始就一直使用的经典的无云方法:本地实例很容易实现,并将提供我们在开发和生产环境中需要的一切……只要我们不关心数据冗余,由于我们的服务器是一个单一的物理实体,因此基础架构负载过大,可能会影响性能(对于高流量网站)、扩展和其他瓶颈。
在 Azure 中,事情以不同的方式运行:将我们的 DBMS 放在那里让我们有机会将 SQL Server 工作负载作为托管基础设施(基础设施即服务(IaaS)或托管服务(PaaS运行):如果我们想自己处理数据库维护任务,例如应用补丁和进行备份,那么第一个选项非常好;如果我们希望将此类操作委托给 Azure,则第二个选项更可取。然而,无论我们选择何种路径,我们都将拥有一个可扩展的数据库服务,它具有完全冗余和无单点故障保证,以及许多其他性能和数据安全优势。正如我们很容易猜测的那样,其负面影响如下:额外的成本以及我们将把数据放在别处这一事实,在某些情况下,这可能是隐私和数据保护方面的一个主要问题。
在下一节中,我们将快速总结如何实现这两种方法,以便做出最方便的选择。
安装 SQL Server 2019
如果我们想避开云,坚持“老派”的做法,我们可以选择在我们的开发(以及以后的生产)机器上安装一个SQL Server Express(或开发者)本地实例。
为此,请执行以下步骤:
- 从以下 URL 下载 SQL Server 2019 内部部署安装包(可以说是 Windows 版本,但也可以使用 Linux 安装程序):https://www.microsoft.com/en-us/sql-server/sql-server-downloads 。
- 双击可执行文件开始安装过程。当提示输入安装类型时,选择默认选项(除非我们需要配置一些高级选项以满足特定需要,前提是我们知道自己在做什么)。
然后,安装包将开始下载所需的文件。完成后,我们只需单击 New SQL Server 单机安装(从顶部开始的第一个可用选项,如以下屏幕截图所示)即可开始实际的安装过程:

接受许可条款并继续,保留所有默认选项,并在要求时执行所需操作(例如打开Windows 防火墙。
If we want to keep our disk space consumption to a minimum amount, we can safely remove the SQL Replication and Machine Learning services from the Feature Selection section and save roughly 500 GB.
将实例名称设置为SQLExpress,实例 ID 设置为SQLEXPRESS。记住这个选择:当我们必须写下连接字符串时,我们将需要它。
当要求我们选择身份验证模式时(如下面的屏幕截图所示),请选择以下选项之一:
- Windows 身份验证模式,如果我们希望能够仅从本地计算机(使用 Windows 凭据)无限制地访问数据库引擎
- 混合模式,启用 SQL Server 系统管理员(即
sa用户)并为其设置密码
在以下屏幕截图中可以看到这两个选项:

前一个选项对于安全性来说是非常好的,而后一个选项则更加通用,特别是如果我们要使用 SQL server 内置的管理界面远程管理 SQL server,这是我们将用来创建数据库的工具。
Those who need a more comprehensive guide to perform the SQL Server local instance installation can take a look at the following tutorials:
Installing SQL Server on Windows: https://docs.microsoft.com/en-US/sql/database-engine/install-windows/installation-for-sql-server.
Installing SQL Server on Linux: https://docs.microsoft.com/en-US/sql/linux/sql-server-linux-setup.
SQL Server 安装完成后,我们还应该安装SQL Server 管理工具——这是一组有用的工具,可以用来管理本地和/或远程可用的任何 SQL 实例,只要服务器可以访问并且已配置为允许远程访问。更具体地说,我们需要的工具是SQL Server Management Studio(SSMS),它基本上是一个 GUI 界面,可用于创建数据库、表、存储过程等,以及操作数据。
Although being available from the SQL Server installation and setup tool, SSMS is a separate product and is available (free of charge) at the following URL: https://docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms.
然而,在使用它之前,我们将花一些宝贵的时间讨论 Azure 路径。
在 Azure 上创建 SQL 数据库
如果您想摆脱 DBMS 本地实例,采用云Azure路线,我们的待办事项列表完全取决于我们将从 Azure 平台提供的主要方法中选择哪一种。以下是终端用户可以选择的三个主要选项,从最低到最昂贵。详情如下:
- SQL 数据库:这是一个基于 SQL Server 企业版的全管理 SQL 数据库引擎。此选项允许我们使用平台即服务(PaaS)使用和计费模型来设置和管理托管在 Azure 云中的一个或多个单一关系数据库:更具体地说,我们可以将其定义为数据库即服务(DBaaS方法。此选项提供了内置的高可用性、智能性和管理功能,这意味着它非常适合那些需要多功能解决方案的人,而无需配置、管理和支付整个服务器主机的费用。
- SQL 托管实例:这是 Azure 上的一个专用 SQL 托管实例,这是一个可扩展的数据库服务,与标准 SQL Server 实例几乎 100%兼容,并具有 IaaS 使用和计费模型。此选项提供了前一个实例(SQL 数据库)的所有 PaaS 好处但添加了一些与基础设施相关的附加功能,如本机虚拟网络(VNet)、自定义专用 IP 地址、具有共享资源的多个数据库等。
- SQL 虚拟机:这是一个完全管理的 SQL Server,由 Windows 或 Linux 虚拟机组成,上面安装了 SQL Server 实例。这种方法还采用 IaaS 使用和计费模式,对整个 SQL Server 实例和底层操作系统提供完全的管理控制,因此是最复杂和可定制的一个。与其他两个选项(SQL 数据库和 SQL 托管实例)最显著的区别在于,SQL Server VM 还允许完全控制数据库引擎:我们可以选择何时开始维护/修补、更改恢复模型、暂停/启动服务,等等。
For more information regarding the pros and cons of the Azure options described here, we strongly suggest that you read the following guide: https://docs.microsoft.com/en-US/azure/sql-database/sql-database-paas-vs-sql-server-iaas.
所有这些选项都很好,虽然在总体成本上有很大不同,但可以免费激活:SQLDatabase可以说是最便宜的一个,因为它可以免费使用 12 个月,这要感谢 Azure 提供的试用订阅计划,只要我们将其大小保持在 250GB 以下;无论是SQL 托管实例还是SQL 虚拟机都相当昂贵,因为它们都提供了虚拟化的 IaaS,但它们可以免费激活(至少在几周内),由同一 Azure 试用订阅计划提供 200 欧元。
在以下几节中,我们将学习如何设置 SQL 数据库,因为从长远来看,这是一种成本较低的方法:唯一的缺点是我们必须将其大小保持在 250GB 以下。。。考虑到我们的世界城市数据源文件的大小小于 1GB,这绝对不是问题。
In case we want to opt for an Azure SQL managed instance (option #2), here's a great guide explaining how to do that: https://docs.microsoft.com/en-us/azure/sql-database/sql-database-managed-instance-get-started.
If you wish to set up a SQL Server installed on a virtual machine (option #3), here's a tutorial covering that topic: https://docs.microsoft.com/en-US/azure/virtual-machines/windows/sql/quickstart-sql-vm-create-portal.
设置 SQL 数据库
让我们从访问以下 URL 开始:https://azure.microsoft.com/en-us/free/services/sql-database/ 。
这将使我们进入以下网页,该网页允许我们创建 Azure SQL 托管实例:

单击开始自由按钮并创建一个新帐户。
If you already have a valid MS account, you can definitely use it; however, you should only do that if you're sure that you want to use the free Azure trial on it: if that's not the case, consider creating a new one.
在简短的注册表单(和/或登录阶段)之后,我们将被重定向到 Azure 门户。
不言而喻,如果我们登录的帐户已经过了免费期,或者有一个有效的付费订阅计划,我们将优雅地恢复:

最终,在我们整理好所有事情之后,我们应该能够访问 Azure 门户(https://portal.azure.com )在它所有的荣耀中:

到达后,请执行以下操作:
- 单击“创建资源”按钮访问 Azure Marketplace。
- 搜索名为 Azure SQL 的条目。
- 单击“创建”以访问选择页面,如以下屏幕截图所示:
IMPORTANT: Be careful that you don't pick the SQL managed instance entry instead, which is the one for creating the SQL Server Virtual Machine—this is option #2 that we talked about earlier.

在前面的选择屏幕中,执行以下操作:
- 选择第一个选项(SQL 数据库)。
- 将“资源类型”下拉列表设置为“单个数据库”。
- 单击“创建”按钮以启动主安装向导。
在此过程中,我们还将被要求创建我们的第一个A****祖尔租户(除非我们已经有了一个)。这是一个虚拟组织,拥有并管理一组特定的 Microsoft 云服务。租户由以下格式的唯一 URL 标识:<TenantName>.onmicrosoft.com。给它一个合适的名字,然后继续。
配置实例
单击“创建”按钮后,系统将要求我们使用类似向导的界面配置 SQL 数据库,该界面分为以下选项卡:
- 基本信息:订阅类型、实例名称、管理员用户名和密码等
- 网络:网络连接方法和防火墙规则
- 附加设置:排序和时区
*** 标记:一组名称/值对,可用于将 Azure 资源逻辑组织为共享公共范围的功能类别或组(如生产和测试)。* 查看+创建:查看并确认前面的所有内容**
在“基本”选项卡中,我们必须插入数据库详细信息,例如数据库名称和要使用的服务器。如果这是我们第一次来这里,我们将没有任何可用的服务器。因此,我们必须通过点击创建新链接并填写弹出表单来创建第一个,该表单将滑入屏幕最右侧。请务必设置一个非平凡的服务器管理员登录和一个复杂的密码**,因为我们将需要这些凭据来创建即将到来的连接字符串。
以下屏幕截图显示了如何配置向导此部分的示例:

“基本”选项卡中的最后一个选项将要求我们提供计算+存储类型:对于这个特定项目,我们完全可以选择最小可能的 tier a 基本存储类型,最大空间为 2 GB。
但是,如果我们想大胆一点,我们可以选择存储容量为 250 GB 的标准类型,因为它在 12 个月内仍然是免费的(请参见下面的屏幕截图):

在“网络”选项卡中,确保选择公共端点以启用来自 internet 的外部访问,以便我们能够从所有环境连接到数据库。我们还应该将防火墙规则设置为“是”,以允许 Azure 服务和资源访问服务器,并将我们当前的 IP 地址添加到允许的 IP 白名单中。
**Wait a minute: isn't that a major security issue? What if our databases contain personal or sensitive data?
As a matter of fact, it actually is: allowing public access from the internet is something we should always avoid unless we're playing with open data for testing, demonstrative, or tutorial purposes... which is precisely what we're doing right now.
附加设置和标记选项卡与默认设置一致:只有在需要更改某些选项(如最适合我们的语言和国家的排序规则和时区)或激活特定内容(如高级数据安全时,我们才应更改它们-这对于我们当前的需求来说是完全不必要的。
在“查看+创建”选项卡中,我们将有最后一次机会查看和更改设置(如以下屏幕截图所示):如果我们不确定这些设置,我们将有机会返回并更改它们。当我们 100%确定后,我们可以点击“创建”按钮,在几秒钟内部署 SQL 数据库:

It's worth noticing that we can also Download a template for automation, in case we want to save these settings to create additional SQL Databases in the future.
就是这样:现在,我们可以集中精力配置数据库。
配置数据库
不管路径如何,我们都会选择本地实例或 Azure,我们应该准备好管理新创建的 Azure SQL 数据库。
最实用的方法是使用 SSMS,这是一种免费的 SQL Server 管理 GUI,我们可以按照前面解释的说明免费下载(请参见安装 SQL Server 2019部分)。如果我们还没有安装它,我们可以在下载后立即安装。
完成后,我们只需选择SQL Server Authentication,然后输入我们在 Azure 上创建 SQL 数据库时选择的服务器名称、登录名、和密码。这可以在以下屏幕截图中看到:
**
通过单击“连接”按钮,我们应该能够登录到我们的数据库服务器。一旦 SSMS 连接到 SQL 数据库服务器,就会出现一个服务器资源管理器窗口,其中包含一个表示 SQL server 实例结构的树视图。这是我们用来创建数据库的界面,也是我们的应用用来访问数据库的用户/密码。
创建世界城市数据库
如果我们采用 Azure SQL 数据库路由,我们应该已经能够在左侧对象浏览器树的Databases文件夹中看到WorldCities数据库:
*
或者,如果我们安装了本地的SQL Server Express或开发实例,我们必须通过执行以下操作手动创建它:
- 右键点击
Databases文件夹。
*** 从上下文菜单中选择“添加数据库”。* 键入 WorldCities 名称,然后单击 OK 创建它。**
一旦创建了数据库,我们将有机会通过点击左侧的加号(+符号来扩展其树节点,并通过 SSMS GUI 与所有子对象表、存储过程、用户等进行可视化交互。不用说,如果我们现在这样做,我们将找不到表,因为我们还没有创建它们:这是实体框架稍后将为我们做的事情。然而,在此之前,我们将添加一个登录**帐户,使我们的 web 应用能够连接。
添加 WorldCities 登录
返回根目录Databases文件夹,展开位于其下方的Security文件夹。到达后,请执行以下操作:****
***** 右键点击Logins子文件夹,选择新建登录。
- 在出现的模式窗口中,将登录名设置为
WorldCities。 - 从登录名下方的单选按钮列表中,选择 SQL Server Authentication,并设置一个强度合适的密码(例如 MyVeryOwn$721,从现在起,我们将在代码示例和屏幕截图中使用此密码)。
- 确保禁用用户下次登录时必须更改密码选项(默认为勾选;否则,Entity Framework Core 稍后将无法执行登录。
- 将用户的默认数据库设置为
WorldCities。 - 查看所有选项,然后单击“确定”创建
WorldCities帐户。
If we want a simpler password, such as WorldCities or Password, we might have to disable the enforce password policy option. However, we strongly advise against doing that: choosing a weak password is never a wise choice, especially if we do that in a production-ready environment. We suggest that you always use a strong password, even in testing and development environments. Just be sure not to forget it, as we're going to need it later on.
将登录名映射到数据库
我们需要做的下一件事是将这个登录正确地映射到我们前面添加的WorldCities数据库。以下是如何做到这一点:
- 双击 Databases | Security 文件夹中的
WorldCities登录名,打开我们几秒钟前使用的相同模式。 - 从左侧的导航菜单切换到用户映射选项卡。
- 点击
WorldCities数据库右侧的复选框:用户单元格应自动填入WorldCities值。如果没有,我们需要手动输入WorldCities。 - 在右下面板的数据库角色成员身份框中,分配
db_owner成员身份角色。
**以下屏幕截图描述了上述所有步骤:

就这样!现在,我们可以回到 web 应用项目,添加连接字符串,并使用实体框架代码优先的方法创建表(和数据)。
首先使用代码创建数据库
在继续之前,让我们做一个快速检查表:
- 我们的实体结束了吗?是
- 我们有 DBMS 和
WorldCities数据库吗?是 - 我们是否已经完成了所有需要完成的步骤,以便首先使用代码实际创建和填写上述数据库?否
事实上,我们还需要注意两件事:
- 设置适当的数据库上下文。
- 在我们的项目中启用代码优先数据迁移支持。
在以下部分中,我们将填补所有这些空白,并最终填补我们的WorldCities数据库。
设置 DbContext
为了将数据作为对象/实体类进行交互,实体框架核心使用Microsoft.EntityFrameworkCore.DbContext类,也称为DbContext或简称上下文。此类负责运行时期间的所有实体对象,包括使用数据库中的数据填充它们、跟踪更改以及在 CRUD 操作期间将它们持久化到数据库。
我们可以很容易地为我们的项目创建我们自己的DbContext类,我们称之为ApplicationDbContext——通过执行以下操作:
- 在解决方案资源管理器中,右键单击我们刚才创建的
/Data/文件夹,并添加一个新的ApplicationDbContext.cs类文件。 - 用以下代码填充它:
using Microsoft.EntityFrameworkCore;
using WorldCities.Data.Models;
namespace WorldCities.Data
{
public class ApplicationDbContext : DbContext
{
#region Constructor
public ApplicationDbContext() : base()
{
}
public ApplicationDbContext(DbContextOptions options)
: base(options)
{
}
#endregion Constructor
#region Methods
protected override void OnModelCreating(ModelBuilder
modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Map Entity names to DB Table names
modelBuilder.Entity<City>().ToTable("Cities");
modelBuilder.Entity<Country>().ToTable("Countries");
}
#endregion Methods
#region Properties
public DbSet<City> Cities { get; set; }
public DbSet<Country> Countries { get; set; }
#endregion Properties
}
}
我们在这里做了几件重要的事情:
- 我们重写了
OnModelCreating方法来手动定义实体类的数据模型关系。注意,我们使用modelBuilder.Entity<TEntityType>().ToTable方法手动配置了每个实体的表名;我们这样做的唯一目的是向您展示定制首先生成的数据库代码是多么容易。 - 我们为每个实体添加了一个
DbSet<T>属性,以便以后可以轻松访问它们。
数据库初始化策略
第一次创建数据库并不是我们唯一需要担心的事情;例如,我们如何跟踪数据模型肯定会发生的更改?
在以前的 EF 非核心版本(高达 6.x)中,我们可以选择代码优先方法提供的一种数据库管理模式(称为数据库初始化器或数据库初始化器,也就是说,根据我们的具体需求选择适当的数据库初始化策略:CreateDatabaseIfNotExists、DropCreateDatabaseIfModelChanges、DropCreateDatabaseAlways或MigrateDatabaseToLatestVersion。此外,如果我们需要满足特定的要求,我们还可以通过扩展前面的一种方法并覆盖其核心方法来设置我们自己的自定义初始值设定项。
DbInitializer 的主要缺陷是,对于普通开发人员来说,它们没有足够的即时性和流线型。它们是可行的,但如果没有对实体框架逻辑的广泛了解,就很难处理它们。
在实体框架内核中,该模式被大大简化;没有 DBInitializer,自动数据迁移也已删除。数据库初始化方面现在完全通过 PowerShell 命令处理,唯一的例外是可以直接放置在DbContext实现构造函数上的一小部分命令,以部分自动化过程;详情如下:
Database.EnsureCreated()Database.EnsureDeleted()Database.Migrate()
目前无法通过编程方式创建数据迁移;它们必须通过 PowerShell 添加,我们很快就会看到。
更新 appsettings.json 文件
在解决方案资源管理器中,打开appsettings.json文件,并在"Logging"文件的正下方添加以下"ConnectionStrings"JSON 属性部分(新行高亮显示):
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost\\SQLEXPRESS; Database=WorldCities;
User Id=WorldCities;Password=MyVeryOwn$721;
Integrated Security=False;MultipleActiveResultSets=True" },
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*"
}
Unfortunately, JSON doesn't support LF/CR, so we'll need to put the DefaultConnection value on a single line. If you copy and paste the preceding text, ensure that Visual Studio doesn't automatically add additional double quotes and/or escape characters to these lines; otherwise, your connection string won't work.
这是我们稍后将在项目的Startup.cs文件中引用的连接字符串。
创建数据库
现在我们已经建立了自己的DbContext并定义了一个指向WorldCities数据库的有效连接字符串,我们可以轻松地添加初始迁移并创建数据库。
更新 Startup.cs
我们要做的第一件事是将EntityFramework支持和ApplicationDbContext实现添加到我们的应用启动类中。打开Startup.cs文件,按以下方式更新ConfigureServices方法(新行高亮显示):
// ...existing code...
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
// In production, the Angular files will be served
// from this directory
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/dist";
});
// Add EntityFramework support for SqlServer.
services.AddEntityFrameworkSqlServer();
// Add ApplicationDbContext.
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")
)
);
}
// ...existing code...
新代码还需要以下命名空间引用:
using Microsoft.EntityFrameworkCore;
using WorldCities.Data;
添加初始迁移
打开 PowerShell 命令提示符并浏览项目的根文件夹,在我们的示例中如下所示:
C:\ThisBook\Chapter_04\WorldCities\
到达后,键入以下命令以全局安装dotnet-ef命令行工具:
dotnet tool install --global dotnet-ef
等待安装完成。当我们收到绿色消息输出时,输入以下命令添加第一次迁移:
dotnet ef migrations add "Initial" -o "Data\Migrations"
可选-o参数可用于更改迁移代码生成文件的创建位置;如果我们没有指定,默认情况下会创建并使用根级别的/Migrations/文件夹。因为我们将所有的EntityFrameworkCore类都放在/Data/文件夹中,所以建议也将迁移存储在那里。
上述命令将产生以下输出:

嘿,等一下:那些黄色警告信息是什么
让我们花几秒钟仔细阅读它们,并承认它们所指的问题。City实体中的 Lat/Lon 属性(都是十进制类型)显然都缺少一个明确的精度值:如果我们不提供这样的信息,entity Framework 将不知道为这些属性创建的数据库表列设置哪个精度,并将返回其默认值。如果我们的实际数据有更多的小数,那么这种回退可能会导致精度损失。
即使在我们的特定场景中,我们不能不关心这些 Lat/Lon 坐标的精度,因为我们只是在玩数据游戏,一旦我们看到这些问题,就立即修复它们绝对是明智的。幸运的是,这可以通过向这些属性添加一些数据注释轻松完成。
打开/Data/Models/City.cs文件并相应更改以下代码(修改的行突出显示):
// ...existing code...
/// <summary>
/// City latitude
/// </summary>
[Column(TypeName = "decimal(7,4)")]
public decimal Lat { get; set; }
/// <summary>
/// City longitude
/// </summary>
[Column(TypeName = "decimal(7,4)")]
public decimal Lon { get; set; }
// ...existing code...
完成后,删除/Data/Models/Migration文件夹(以及其中的所有文件),并再次启动dotnet-ef命令:
dotnet ef migrations add "Initial" -o "Data\Migrations"
这一次,迁移应该在没有黄色警告问题的情况下创建,如下面的屏幕截图所示:

这意味着我们终于有了绿灯来应用它。
If we go back to Visual Studio and take a look at our project's Solution Explorer, we will see that there's a new /Data/Migrations/ folder containing a bunch of code-generated files. Those files contain the actual low-level SQL commands that will be used by Entity Framework Core to create and/or update the database schema.
更新数据库
应用数据迁移基本上意味着创建(或更新)数据库,以便将其内容(表结构、约束等)与DbContext中的总体模式和定义以及各种实体类中的数据注释定义的规则同步。更具体地说,第一次数据迁移从头开始创建整个数据库,而随后的迁移将更新它(创建表、添加/修改/删除表字段等)。
在我们的特定场景中,我们将执行第一次迁移。下面是我们需要从命令行(在项目根文件夹中,就像以前一样)键入的一行代码:
dotnet ef database update
一旦我们点击回车,我们的命令行终端窗口的输出中将填充一堆 SQL 语句。完成后,如果一切正常,我们可以返回 SSMS 工具,刷新 Server Object Explorer 树视图,并验证WorldCities数据库以及所有相关表是否已创建:

Those of you who have used migrations before might be asking why we didn't use Visual Studio's Package Manager Console to execute these commands. The reason is simple—unfortunately, doing this won't work because the commands need to be executed within the project root folder, which is not where the Package Manager Console commands are executed. It is unknown whether that behavior will change in the near future. Until it does, we'll have to use the command line.
“找不到与命令 dotnet ef 匹配的可执行文件”错误
在撰写本文时,有一个棘手的问题影响了大多数基于.NET Core 的 Visual Studio 项目,它可能会阻止dotnet ef命令正常工作。更具体地说,在尝试执行任何基于dotnet ef的命令时,我们可能会收到以下错误消息:
No executable found matching command "dotnet-ef"
如果我们碰巧遇到此问题,我们可以尝试检查以下内容:
- 再次检查我们是否正确添加了
Microsoft.EntityFrameworkCore.Tools和Microsoft.EntityFrameworkCore.Tools.DotNet包库(如前所述),因为它们是命令工作所必需的。 - 确保我们在项目的根文件夹中发出的
dotnet ef命令与包含<ProjectName>.csproj文件的根文件夹相同;它在其他任何地方都不起作用。
如果这两个检查都符合要求,我们可以尝试以下解决方法:右键单击项目的根文件夹,选择Edit <ProjectName>.csproj打开该文件,以便在 Visual Studio 中对其进行编辑,并查找以下元素:
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools"
/>
<DotNetCliToolReference
Include="Microsoft.EntityFrameworkCore.Tools.DotNet" />
</ItemGroup>
Alternatively, we can also edit the <ProjectName>.csproj file with a text editor such as Notepad++; just ensure that you reload the project when you're done.
<ItemGroup>元素在这里只是一个容器;我们需要寻找突出显示的行(它们可能有版本属性,也可能没有,这取决于我们使用的实体框架核心版本)。
如果这些行不存在,这就是dotnet ef命令不起作用的原因。我们需要通过卸载/重新安装相关的 NuGet 软件包或手动将其添加到项目配置文件来修复这种不必要的行为。如果我们选择手动执行,我们需要确保将它们包装在新的或现有的<ItemGroup>块中。
在修复项目配置文件之后,我们可以重新启动 Visual Studio(或重新加载项目),并尝试从项目的根文件夹再次执行dotnet ef命令。在不太可能的情况下,我们最终会遇到一些 NuGet 包冲突,我们可以尝试发出dotnet update命令来修复它们,再次重新加载我们的项目,然后再次尝试执行dotnet ef命令。
A lot more can be said regarding this issue, but doing is outside the scope of this book. Those of you who want to know more can take a look at this article I wrote about it while working on my ASP.NET Core 2 and Angular 5 book at https://goo.gl/Ki6mdb.
了解迁移
在我们继续之前,先说几句话来解释什么是代码优先迁移,以及我们通过使用它们所获得的优势是很有用的。
每当我们开发一个应用并定义一个数据模型时,我们都可以确信它会因为许多好的原因发生多次更改:来自产品所有者的新需求、优化过程、整合阶段等等。将添加、删除一组属性,或更改其类型。很可能,我们迟早也会根据不断变化的需求添加新的实体和/或改变它们的关系模式。
每次我们这样做时,我们也会使数据模型与其底层的、代码优先生成的数据库不同步。当我们在开发环境中调试应用时,这不会是一个问题,因为该场景通常允许我们在项目更改时从头开始重新创建数据库。
在将应用部署到生产环境中时,我们将面临一个完全不同的情况:只要我们处理真实数据,删除和重新创建数据库就不再是一种选择。这就是代码优先迁移特性要解决的问题:让开发人员有机会更改数据库模式,而不必删除/重新创建整个数据库。
我们不会深入探讨这个话题;实体框架核心是它自己的世界,详细描述它超出了本书的范围。如果您想了解更多信息,我们建议您从的官方实体框架核心 MS 文档开始 https://docs.microsoft.com/en-us/ef/core/ 。
是否需要数据迁移?
数据迁移可能非常有用,但它不是必需的功能,如果我们不想,我们肯定不会被迫使用它。事实上,对于许多开发人员来说,这是一个很难理解的概念,尤其是对于那些对 DBMS 设计和/或脚本编写不太了解的开发人员。在大多数情况下,管理 DBA 也可能非常复杂,例如,在公司中,DBA 角色由 It 开发团队下面的人员(如外部 It 顾问或专家)担任。
无论何时我们从一开始就不想使用它们,或者到了不想再使用它们的地步,我们都可以切换到数据库优先的方法,并开始手动设计、创建和/或修改我们的表:Entity Framework core 将非常有效,只要实体中定义的属性类型 100%匹配相应的 DB 表字段。我们完全可以做到这一点,即使是将本书中介绍的项目样本付诸实践(包括WorldCities项目,因此从现在开始,字面上就是这样),只要我们觉得我们在生活中不需要这样的技术。
或者,我们可以尝试一下,看看情况如何。一如既往,选择权在你。
填充数据库
现在我们有了一个可用的 SQL 数据库和一个DbContext可以用来读写它,我们终于准备好用我们的世界城市数据填充这些表了。
为此,我们需要实施数据播种策略。我们可以使用各种实体框架核心支持的方法之一来实现这一点:
- 模型数据种子
- 手工迁移定制
- 自定义初始化逻辑
这三种方法在下面的文章中有很好的解释,以及它们各自的优缺点:https://docs.microsoft.com/en-us/ef/core/modeling/data-seeding 。
由于我们必须处理一个相对较大的 Excel 文件,我们将采用我们可以使用的最可定制的模式:一些自定义初始化逻辑,它将依赖于一个专用的.NET Core 控制器,我们可以在需要为数据库种子时手动甚至自动执行。
实现种子控制器
我们的自定义初始化逻辑实现将依赖于一个全新的专用控制器,称为SeedController。
从我们项目的解决方案资源管理器中,执行以下操作:
- 右键点击
/Controllers/文件夹。 - 单击添加|控制器。
- 选择
API Controller - Empty选项(写作时顶部第三个选项)。 - 为控制器指定
SeedController名称,然后单击“添加”以创建它。
完成后,打开新创建的/Controllers/SeedController.cs文件并查看源代码:您将看到只有一个空类:正如预期的空控制器一样!这很好,因为我们需要理解一些关键概念,最重要的是学习如何在源代码中正确地翻译它们。
还记得我们在Startup.cs文件中添加ApplicationDbContext类的时候吗?正如我们已经从第 2 章中了解到的,环顾,这意味着我们将实体框架核心中间件添加到了应用的管道中。这意味着我们现在可以利用.NET Core 体系结构提供的依赖项注入加载功能,在控制器中注入该DbContext类的实例。
下面是我们如何将这样一个概念转化为源代码(新行突出显示):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using WorldCities.Data;
namespace WorldCities.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class SeedController : ControllerBase
{
private readonly ApplicationDbContext _context;
public SeedController(ApplicationDbContext context)
{
_context = context;
}
}
}
如我们所见,我们添加了一个_context私有变量,并使用它在构造函数中存储ApplicationDbContext类的对象实例。这样的实例将由框架通过其依赖注入特性在SeedController的构造函数方法中提供。
在充分利用DbContext实例将一组实体插入我们的数据库之前,我们需要找到一种从 Excel 文件中读取这些世界城市值的方法。我们怎么能做到呢?
导入 Excel 文件
幸运的是,有一个很棒的第三方库,它正是我们所需要的:使用 Office Open XML 格式(xlsx)读取(甚至写入!)Excel 文件,从而使其内容在任何基于.NET 的应用中都可用。
这个伟大工具的名字是EPPlus。它的作者 Jan Källman 在 GitHub 和 NuGet 上免费提供了它,网址如下:
- GitHub(源代码):https://github.com/JanKallman/EPPlus
- NuGet(.NET 包):https://www.nuget.org/packages/EPPlus/4.5.3.2
正如我们所看到的,该项目是根据 GNU图书馆通用公共许可证(LGPL)v3.0 进行许可的,这意味着我们可以无限制地将其集成到我们的软件中,只要我们不修改它。
在我们的WorldCities项目中安装EPPlus的最佳方式是使用 NuGet package manager GUI 添加 NuGet 软件包:
- 在项目的解决方案资源管理器中,右键单击
WorldCities项目。 - 选择管理 NuGet 软件包。。。
- 使用浏览选项卡搜索
EPPlus包,点击右上角的安装按钮进行安装:
**
完成后,我们可以返回到SeedController.cs文件,使用EPPlus的强大功能读取worldcities.xlsxExcel 文件。
然而,在这样做之前,明智的做法是将该文件移动到示例项目的/Data/文件夹中,这样我们就可以使用System.IO命名空间提供的.NET Core 文件系统功能来读取它。在这里,让我们创建一个/Data/Source/子文件夹,并将其与其他实体框架核心文件分开:

下面是我们需要添加到SeedController.cs文件中的源代码,以读取worldcities.xlsx文件并将所有行存储在City实体列表中:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using WorldCities.Data;
using OfficeOpenXml;
using System.IO;
using Microsoft.AspNetCore.Hosting;
using WorldCities.Data.Models;
using System.Text.Json;
namespace WorldCities.Controllers
{
[Route("api/[controller]/[action]")]
[ApiController]
public class SeedController : ControllerBase
{
private readonly ApplicationDbContext _context;
private readonly IWebHostEnvironment _env;
public SeedController(
ApplicationDbContext context,
IWebHostEnvironment env)
{
_context = context;
_env = env;
}
[HttpGet]
public async Task<ActionResult> Import()
{
var path = Path.Combine(
_env.ContentRootPath,
String.Format("Data/Source/worldcities.xlsx"));
using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read))
{
using (var ep = new ExcelPackage(stream))
{
// get the first worksheet
var ws = ep.Workbook.Worksheets[0];
// initialize the record counters
var nCountries = 0;
var nCities = 0;
#region Import all Countries
// create a list containing all the countries
// already existing into the Database (it
// will be empty on first run).
var lstCountries = _context.Countries.ToList();
// iterates through all rows, skipping the
// first one
for (int nRow = 2;
nRow <= ws.Dimension.End.Row;
nRow++)
{
var row = ws.Cells[nRow, 1, nRow,
ws.Dimension.End.Column];
var name = row[nRow, 5].GetValue<string>();
// Did we already created a country with
// that name?
if (lstCountries.Where(c => c.Name ==
name).Count() == 0)
{
// create the Country entity and fill it
// with xlsx data
var country = new Country();
country.Name = name;
country.ISO2 = row[nRow,
6].GetValue<string>();
country.ISO3 = row[nRow,
7].GetValue<string>();
// save it into the Database
_context.Countries.Add(country);
await _context.SaveChangesAsync();
// store the country to retrieve
// its Id later on
lstCountries.Add(country);
// increment the counter
nCountries++;
}
}
#endregion
#region Import all Cities
// iterates through all rows, skipping the
// first one
for (int nRow = 2;
nRow <= ws.Dimension.End.Row;
nRow++)
{
var row = ws.Cells[nRow, 1, nRow,
ws.Dimension.End.Column];
// create the City entity and fill it
// with xlsx data
var city = new City();
city.Name = row[nRow, 1].GetValue<string>();
city.Name_ASCII = row[nRow,
2].GetValue<string>();
city.Lat = row[nRow, 3].GetValue<decimal>();
city.Lon = row[nRow, 4].GetValue<decimal>();
// retrieve CountryId
var countryName = row[nRow,
5].GetValue<string>();
var country = lstCountries.Where(c => c.Name
== countryName)
.FirstOrDefault();
city.CountryId = country.Id;
// save the city into the Database
_context.Cities.Add(city);
await _context.SaveChangesAsync();
// increment the counter
nCities++;
}
#endregion
return new JsonResult(new {
Cities = nCities,
Countries = nCountries
});
}
}
}
}
}
正如你所看到的,我们在那里做了很多有趣的事情。前面的代码有很多注释,应该非常可读;然而,简要列举最相关的部分可能是有用的:
- 我们通过依赖注入注入了一个
IWebHostEnvironment实例,就像我们对ApplicationDbContext所做的一样,这样我们就可以检索 web 应用路径并能够读取 Excel 文件。 - 我们增加了一个
Import()动作方法,使用ApplicationDbContext和EPPlus包读取 Excel 文件,增加Countries和Cities;为了方便起见,这两项任务分为两部分。 - 首先导入
Countries,因为City实体需要CountryId外键值,当对应的Country作为新记录在数据库中创建时会返回。 - 我们定义了一个
List<Country>容器对象来存储创建后的每个Country,这样我们就可以使用 LINQ 查询该列表来检索CountryId,而不是执行大量的SELECT查询。 - 最后但并非最不重要的一点是,我们创建了一个 JSON 对象以在屏幕上显示总体结果。
请注意,Import方法设计用于导入 230 多个国家和 12000 多个城市,因此在一台平均开发机器上,此任务可能需要 10 到 20 分钟的时间。这绝对是一个重要的数据种子!我们在强调这个框架。
In case we don't want to wait for that long, we can always give the nEndRow internal variable a fixed value, such as 1,000, to limit the total number of cities (and countries) that will be read and therefore loaded into the database.
如果我们想更仔细地了解整个导入过程是如何工作的,我们可以在if循环中放置一些断点,在它运行时检查它。
最终,我们应该能够在浏览器窗口中看到以下响应:

前面的输出表示导入已成功执行:我们做到了!我们的数据库现在充满了供我们玩的12959城市和235国家。在下一节中,我们将学习如何读取这些数据,以便能够将 Angular 引入循环。
实体控制器
现在,我们的数据库中有数千个城市和数千个国家,我们需要找到一种方法将这些数据带到 Angular,反之亦然。从第 2 章环顾我们已经知道,这个角色是由.NET 控制器扮演的,所以我们将创建两个:
*** CityController,服务(接收)城市数据
CountryController对国家也是如此
让我们开始吧。
花旗控制器
让我们从城市开始。还记得我们创建SeedController时做了什么吗?我们现在要做的事情非常类似,但这次我们将充分利用 VisualStudio 的代码生成功能。
从我们项目的解决方案浏览器中,执行以下步骤:
- 右键点击
/Controllers/文件夹。 - 单击添加|控制器。
- 选择 AddAPI 控制器和动作,使用实体框架选项(在撰写本文时,从顶部选择最后一个选项)。
- 在出现的模态窗口中,选择
City模型类和ApplicationDbContext数据上下文类,如下图所示。将控制器命名为CityController并单击“添加”创建:

Visual Studio 将使用我们在此阶段指定的设置来分析我们的实体(以及我们的DbContext),并自动生成一个包含有用方法的完整 API 控制器。
以下是我们免费获得的源代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using WorldCities.Data;
using WorldCities.Data.Models;
namespace WorldCities.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class CitiesController : ControllerBase
{
private readonly ApplicationDbContext _context;
public CitiesController(ApplicationDbContext context)
{
_context = context;
}
// GET: api/Cities
[HttpGet]
public async Task<ActionResult<IEnumerable<City>>> GetCities()
{
return await _context.Cities.ToListAsync();
}
// GET: api/Cities/5
[HttpGet("{id}")]
public async Task<ActionResult<City>> GetCity(int id)
{
var city = await _context.Cities.FindAsync(id);
if (city == null)
{
return NotFound();
}
return city;
}
// PUT: api/Cities/5
// To protect from overposting attacks, please enable the
// specific properties you want to bind to, for
// more details see https://aka.ms/RazorPagesCRUD.
[HttpPut("{id}")]
public async Task<IActionResult> PutCity(int id, City city)
{
if (id != city.Id)
{
return BadRequest();
}
_context.Entry(city).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!CityExists(id))
{
return NotFound();
}
else
{
throw;
}
}
return NoContent();
}
// POST: api/Cities
// To protect from overposting attacks, please enable the
// specific properties you want to bind to, for
// more details see https://aka.ms/RazorPagesCRUD.
[HttpPost]
public async Task<ActionResult<City>> PostCity(City city)
{
_context.Cities.Add(city);
await _context.SaveChangesAsync();
return CreatedAtAction("GetCity", new { id = city.Id },
city);
}
// DELETE: api/Cities/5
[HttpDelete("{id}")]
public async Task<ActionResult<City>> DeleteCity(int id)
{
var city = await _context.Cities.FindAsync(id);
if (city == null)
{
return NotFound();
}
_context.Cities.Remove(city);
await _context.SaveChangesAsync();
return city;
}
private bool CityExists(int id)
{
return _context.Cities.Any(e => e.Id == id);
}
}
}
正如我们所看到的,代码生成器在遵循与我们的SeedController类类似的模式的同时做了很多有用的工作。以下是相关方法的分类,按外观顺序排列:
GetCities()返回包含数据库中所有城市的 JSON 数组。GetCity(id)返回包含单个City的 JSON 对象。PutCity(id, city)允许我们修改现有City。PostCity(city)允许我们添加新的City。DeleteCity(id)允许我们删除现有City。
显然,我们的前端已经具备了所需的一切。在继续讨论 Angular 之前,让我们对Countries做同样的操作。
国家控制员
在Solution Explorer中,右键点击/Controllers/文件夹,执行与我们添加CitiesController相同的任务集–除了名称之外,名称显然是CountriesController。
为了简单起见,我们不会因为重复自动生成的代码而浪费额外的页面:毕竟,我们有一个专门的 GitHub 存储库来查找这些代码。但是,我们将获得与前面提到的处理国家/地区相同的方法集。
我们的实体框架之旅到此结束。现在,我们需要用我们最喜欢的前端框架将这些点连接起来,种植我们已经播种的东西。
总结
本章开始时,我们列举了一些没有合适的数据提供者就无法完成的事情。为了克服这些限制,我们决定为自己提供一个 DBMS 引擎和一个用于读取和/或写入数据的持久数据库。为了避免弄乱我们在前几章中所做的事情,我们创建了一个全新的 web 应用项目来处理这个问题,我们称之为WorldCities。
然后,我们为我们的新项目选择了一个合适的数据源:一个世界城市和国家的列表,我们可以在一个方便的 MS Excel 文件中免费下载。
紧接着,我们转到了数据模型:实体框架核心似乎是获得我们想要的东西的一个明显选择,所以我们将其相关包添加到我们的项目中。我们简要列举了可用的数据建模方法,并由于其灵活性而采用了先使用代码的方法。完成后,我们创建了两个实体City和Country,这两个实体都基于我们必须存储在数据库中的数据源值,以及一组数据注释和关系,利用了著名的实体框架核心的约定优于配置的方法。然后,我们建立了相应的ApplicationDbContext类。
创建数据模型后,我们评估了配置和部署 DBMS 引擎的各种选项:我们回顾了 DMBS 本地实例和基于云的解决方案,如 MS Azure,并解释了如何实现这两种解决方案。
最后但并非最不重要的一点是,我们创建了.NET 控制器类来处理数据:SeedController用于读取 Excel 文件并为数据库种子,CitiesController用于处理城市,CountriesController用于处理国家。
完成所有这些任务后,我们在调试模式下运行应用,以验证一切仍按预期工作。现在,我们准备搞乱我们应用的前端部分。在下一章中,我们将学习如何正确地从服务器获取这些数据,并以一种流行的方式将其提供给用户
好了,我们来了!
建议的主题
Web API、内存中 Web API、数据源、数据服务器、数据模型、数据提供程序、ADO.NET、ORM、实体框架核心、代码优先、数据库优先、模型优先、实体类、数据注释、DbContext、CRUD 操作、数据迁移、依赖项注入、ORM 映射、JSON、ApiController。
工具书类
- 角内存 Web API:https://github.com/angular/in-memory-web-api/
- 维基百科:ISO 3166:https://en.wikipedia.org/wiki/ISO_3166
- 维基百科:ISO 3166 alpha-2:https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
- 维基百科:ISO 3166 alpha-3:https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3
- ISO 3166 国家代码:https://www.iso.org/iso-3166-country-codes.html
** SQL Server 2019 官方页面:https://www.microsoft.com/en-us/sql-server/sql-server-2019* SQL Server 2019-比较 SQL Server 版本:https://www.microsoft.com/en-us/sql-server/sql-server-2019-comparison** Linux 上的 SQL Server 2019:https://docs.microsoft.com/en-US/sql/linux/sql-server-linux-overview* 在 Windows 上安装 SQL Server:https://docs.microsoft.com/en-US/sql/database-engine/install-windows/installation-for-sql-server* 在 Linux 上安装 SQL Server:https://docs.microsoft.com/en-US/sql/linux/sql-server-linux-setup* 下载 SQL Server Management Studio(SSMS):https://docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms* 在 Azure上创建 SQL Server 数据库 https://azure.microsoft.com/en-us/resources/videos/create-sql-database-on-azure/* Azure 免费账户常见问题解答:https://azure.microsoft.com/en-in/free/free-account-faq/* Azure SQL Server 托管实例:https://azure.microsoft.com/en-us/services/sql-database/** 使用标签组织您的 Azure 资源:https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/tag-resources* 在 Azure SQL中选择正确的部署选项 https://docs.microsoft.com/en-US/azure/sql-database/sql-database-paas-vs-sql-server-iaas* 创建 Azure SQL 数据库托管实例:https://docs.microsoft.com/en-us/azure/sql-database/sql-database-managed-instance-get-started* 实体框架核心:加载相关数据:https://docs.microsoft.com/en-US/ef/core/querying/related-data* 实体框架核心:数据播种:https://docs.microsoft.com/en-us/ef/core/modeling/data-seeding* 实体框架核心:DbContext:https://www.entityframeworktutorial.net/efcore/entity-framework-core-dbcontext.aspx**************************************
五、获取和显示数据
在上一章中,我们创建了一个新的WorldCitiesweb 应用项目,并通过基于 DBMS 的数据提供程序对其进行了大量的授权,该数据提供程序使用代码优先的方法构建在实体框架核心之上。现在我们有了数据持久性,我们已经准备好委托我们的用户与我们的应用交互;这意味着我们可以实现一些急需的东西,例如:
- 取数:使用 HTTP 请求从客户端查询数据提供者,从服务器端返回结构化结果
- 显示数据:填充典型的客户端组件,如表、列表等,确保终端用户有良好的用户体验
在本章中,我们将通过添加一些由标准 HTTP 请求/响应链处理的客户机-服务器交互来讨论这两个主题;毋庸置疑,Angular 将在这里发挥重要作用,还有几个有用的软件包将帮助我们实现我们的目标。
技术要求
在本章中,我们需要前面章节中列出的所有技术要求,以及以下外部库:
@angular/material(Angular npm 包)System.Linq.Dynamic.Core(.净核心 NuGet 包)
一如既往,建议避免直接安装它们;在本章中,我们将引入它们,以便更好地将它们的目的置于项目中。
获取数据
从第一章、准备中我们已经知道,从数据库中读取数据主要是让 Angular前端向.NET Core后端发送 HTTP 请求,并相应获取相应的 HTTP 响应;这些数据传输将主要使用JavaScript 对象表示法(JSON)来实现,这是一种轻量级的数据交换格式,两个框架本机都支持。
在本节中,我们将主要讨论 HTTP 请求和响应,了解如何从.NET Core后端获取数据,并展示一些使用 Angular 组件的原始 UI 示例,这些组件将在下一节中进一步细化。
我们准备好了吗?让我们开始吧!
请求和答复
让我们先看看我们将要处理的 HTTP 请求和响应:点击F5以调试模式启动WorldCities项目,并在浏览器的地址栏中键入以下 URL:https://localhost:44334/api/Cities/9793 。
以下是我们应该看到的:

The city might or might not be New York, depending on various factors: the world cities file version/progress, the starting auto-incrementing id of the [Cities] database table we used to store the data source, and so on. Don't mind that – it could be any city, as long as we get a valid JSON depicting it.
JSON 约定和默认值
如我们所见,JSON 基本上是我们City实体的序列化,具有一些内置约定,如:
- CamelCase 而不是 PascalCase:我们用
name而不是Name,countryId而不是CountryId等等,这意味着我们所有的 PascalCase.NET 类名和属性在序列化为 JSON 时都会自动转换成 CamelCase。 - 无缩进和无换行/回车(LF/CR):所有内容都堆叠在一行文本中。
这些约定是.NETCore 在处理 JSON 输出时设置的默认选项。通过向 MVC 中间件添加一些定制选项,可以更改其中的大多数。但是,我们不需要这样做,因为它们完全由 Angular 支撑,这就是我们要用来处理这些字符串的方法;我们只需要确保我们将创建的用于镜像实体类的 Angular 接口的名称和属性设置为 camelCase。
Whoever wants to know why they chose camelCase instead of PascalCase as the default serialization option should check out the following GitHub thread:
https://github.com/aspnet/Mvc/issues/4283.
无论如何,为了可读性,让我们添加一些缩进,以便能够理解更多这些输出。
打开Startup.cs文件,找到ConfigureServices方法,并添加以下代码(突出显示新的/更新的行):
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews()
.AddJsonOptions(options => {
// set this option to TRUE to indent the JSON output
options.JsonSerializerOptions.WriteIndented = true;
// set this option to NULL to use PascalCase instead of
// camelCase (default)
// options.JsonSerializerOptions.PropertyNamingPolicy =
// null;
}); );
As we can see, we also added the required configuration option to force PascalCase instead of camelCase; however, for the sake of these sample projects, we do prefer to enforce the camelCase convention on JSON and Angular, so we have chosen to comment that line.
Those who want to uncomment it should be aware of the fact that they'll have to use camelCase for their Angular interfaces as well, changing our sample code accordingly.
保存文件,点击F5,再次键入上一个 URL,查看以下更改:

现在,JSON 是完全可读的,Angular 仍然能够正确地获取它。
(非常)长的名单
现在让我们转到 Angular 应用,创建一个示例组件来显示Cities列表。我们已经在第 3 章、前端和后端交互中创建了一个组件,所以我们知道该怎么做。
在解决方案资源管理器中,执行以下操作:
- 导航到
/ClientApp/src/app/文件夹。 - 创建一个新的
/cities/文件夹。 - 在该文件夹中,创建以下新文件:
city.tscities.component.tscities.component.htmlcities.component.css
完成后,用以下内容填充它们。
city.ts
打开/ClientApp/src/app/cities/citiy.ts文件并添加以下内容:
export interface City {
id: number;
name: string;
lat: string;
lon: string;
}
这个小文件包含我们的城市接口,我们将在CitiesComponent类文件中使用它。因为我们最终也会在其他组件中使用它,所以最好在一个单独的文件中创建它,并用export语句修饰它,以便到时候我们也能在那里使用它。
城市
打开/ClientApp/src/app/cities/cities.component.ts文件并添加以下内容:
import { Component, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { City } from './city';
@Component({
selector: 'app-cities',
templateUrl: './cities.component.html',
styleUrls: ['./cities.component.css']
})
export class CitiesComponent {
public cities: City[];
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
http.get<City[]>(baseUrl + 'api/Cities')
.subscribe(result => {
this.cities = result;
}, error => console.error(error));
}
}
正如我们所看到的,我们在不久前创建的City接口上添加了一个import引用。我们还使用了ngOnInit()生命周期钩子方法来执行 HTTP 请求来检索城市,就像我们在第 3 章、前端和后端交互中为我们之前的HealthCheck应用所做的那样。
cities.component.html
打开/ClientApp/src/app/cities/cities.component.html文件并添加以下内容:
<h1>Cities</h1>
<p>Here's a list of cities: feel free to play with it.</p>
<p *ngIf="!cities"><em>Loading...</em></p>
<table class='table table-striped' aria-labelledby="tableLabel" [hidden]="!cities">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Lat</th>
<th>Lon</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let city of cities">
<td>{{ city.id }}</td>
<td>{{ city.name }}</td>
<td>{{ city.lat }}</td>
<td>{{ city.lon }}</td>
</tr>
</tbody>
</table>
[隐藏]属性
如果我们仔细查看前面的 HTML 代码,我们将看到,<table>元素具有一个奇怪的[hidden]属性。为什么它在那里,为什么它在方括号中?
事实上,hidden属性是 HTML5 的有效内容属性,可以在任何 HTML 元素上合法设置。它应该扮演的角色与 CSSdisplay: none设置非常相似:它向浏览器指示元素及其所有子元素不应该对任何用户可见或感知。换句话说,这只是向用户隐藏某些内容的另一种方式。
For additional information regarding the hidden attribute, check out the following URL:
HTML Living Standard (last updated on November 26, 2019):
https://html.spec.whatwg.org/multipage/interaction.html#the-hidden-attribute.
至于方括号,这只是定义属性绑定的 Angular 语法,即组件模板(我们的.html文件)中的 HTML 属性或属性,其值来自组件类(我们的.ts文件)中定义的变量、属性或表达式。值得注意的是,这样的绑定朝一个方向流动:从组件类(源)到组件模板(目标)中的 HTML 元素。
作为我们刚才所说的直接结果,每次源值计算为true,方括号之间的 HTML 属性(或属性)也将设置为true(反之亦然);这是处理许多使用布尔值的 HTML 属性的好方法,因为我们可以在整个组件的生命周期中动态设置它们。这正是我们对前面代码块中的<table>元素所做的:它的hidden属性将求值为false,直到cities组件变量被从服务器获取的实际城市填充,这只有在HttpClient模块完成其请求/响应任务时才会发生。不错吧?
等等:*ngIf结构指令的行为与我们在第 3 章前端和后端交互中已经知道的*ngIf结构指令的行为是否相同?为什么我们要使用这个[hidden]属性呢?
这是一个非常好的问题,让我们有机会澄清这两种相似但不完全相同的方法之间的区别:
*ngIfstructural 指令根据元素对应的条件或表达式在 DOM 中添加或删除元素;这意味着每当元素的状态发生变化时,该元素将被初始化和/或处置(连同其所有子元素、事件等)。hidden属性与display: noneCSS 设置非常相似,只会指示浏览器向用户显示或隐藏元素;这意味着元素仍然存在,因此是完全可用和可访问的(例如,通过 JavaScript 或其他 DOM 操作)。
通过查看前面的 HTML 代码可以看出,我们同时使用了这两种代码:*ngIf结构指令添加或删除加载<p>元素,而[hidden]属性绑定显示或隐藏主<table>元素。我们选择这样做的原因是:<p>元素不会有依赖于它的子元素或事件,而<table>将很快成为一个复杂的对象,在 DOM 中有许多需要初始化和保存的特性。
cities.component.css
以下是/ClientApp/src/app/cities/cities.component.ts文件的代码:
table {
width: 100%;
}
就是这样,至少现在是这样:因为我们使用的是引导客户机框架,所以我们组件的 CSS 文件通常非常小。
app.module.ts
正如我们已经知道的,如果我们以以下方式将该组件添加到app.module.ts文件中(新行高亮显示),则只能加载该组件,并且只能通过客户端路由访问该组件:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from
'@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
import { CitiesComponent } from './cities/cities.component';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
CitiesComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'cities', component: CitiesComponent }
])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
我们开始吧。
nav-component.html
最后但并非最不重要的一点是,我们需要在 appnavigator 组件中添加对新组件路由的引用;否则,浏览器客户端将无法使用 UI 查看(从而访问)它。
为此,打开nav-component.html文件并添加以下(突出显示的)行:
// ...existing code...
<ul class="navbar-nav flex-grow">
<li
class="nav-item"
[routerLinkActive]="['link-active']"
[routerLinkActiveOptions]="{ exact: true }"
>
<a class="nav-link text-dark" [routerLink]="['/']">Home</a>
</li>
<li class="nav-item" [routerLinkActive]="['link-active']">
<a class="nav-link text-dark" [routerLink]="['/cities']"
>Cities</a
>
</li>
</ul>
// ...existing code...
就这样。现在,我们可以点击F5启动我们的应用,点击屏幕右上角出现的城市链接,体验如下结果:

正如我们通过查看右侧的垂直滚动条所看到的,我们将被一个由 12959(大约)行组成的巨大 HTML 表所淹没!
对于.NET Core 和 Angular 来说,这是另一个巨大的性能压力——在任何一台普通的开发机器上都应该能够顺利通过,因为这两个框架都可以很好地处理各自的任务。
然而,就用户体验而言,这样的 UI 结果绝对是不可能的:如果我们强迫最终用户使用浏览器浏览 13k 行的 HTML 表格,我们就不能合理地期望他们会高兴。如果他们想找到他们要找的城市,一定会发疯的!
为了解决这些主要的可用性问题,我们需要实现两个常用于处理胖 HTML 表的重要特性:分页、排序、和过滤。
**# 用角材料服务数据
为了实现一个具有分页、排序和过滤功能的表,我们将使用Angular Material,这是一个 UI 组件库,在 Angular 中实现材质设计。我们很可能已经知道,Material Design 是谷歌在 2014 年开发的一种 UI 设计语言,它专注于使用基于网格的布局、响应动画、过渡、填充和深度效果,如照明和阴影。
Material Design was introduced by the Google designer Matías Duarte on June 25, 2014, at the 2014 Google I/O conference. To make UI designers familiarize themselves with its core concepts, he explained that: "unlike real paper, our digital material can expand and reform intelligently. Material has physical surfaces and edges. Seams and shadows provide meaning about what you can touch."
The main purpose of Material Design is to create a new UI language combining principles of good design with technical and scientific innovation in order to provide a consistent user experience not only across all Google platforms and applications, but also any other web applications seeking to adopt such concepts. The language was revamped in 2018, providing more flexibility and advanced customization features based on themes.
截至 2019 年,几乎所有 Google web 应用和工具都使用了材质设计,包括 Gmail、YouTube、Google Drive、Google 文档、表单、幻灯片、Google 地图和所有 Google Play 品牌应用,以及大多数 Android 和 Google OS UI 元素。这种广泛采用还包括 Angular,该公司提供了一个专用的 npm 包,可添加到任何 Angular 项目中,以在任何 Angular 应用中实现材料设计;这个包被称为@angular/material,包括本机 UI 元素、组件开发工具包(CDK)、一组动画和其他有用的东西。
要安装角材料,请执行以下操作:
- 打开命令提示符。
- 导航到我们项目的
/ClientApp/文件夹。 - 键入以下命令:
> ng add @angular/material
执行此操作将触发 Angular Material 命令行设置向导,该向导将安装以下 npm 软件包:
@angular/material@angular/cdk(先决条件)
Important: Be sure to install the same @angular/material version specified in the package.json of the GitHub project released with this book - 9.0.0 at the time of writing. Those who want to change or update the Angular version should pay special attention to updating the @angular/material package as well and/or manually fixing the potential breaking changes between the various versions.
For additional information about Angular Material, check out the following URLs:
https://material.angular.io/
https://github.com/angular/components
在安装过程中,前面的命令将询问我们要安装的预构建主题,如以下屏幕截图所示:

在本章中,我们将选择Indigo/Pink,但我们可以自由选择我们喜欢的任何其他主题。如果我们想在做出选择之前查看它们,我们可以访问前面屏幕截图中列出的预览 URI。
安装向导还将询问我们是否要为手势识别设置HammerJS——如果我们计划发布移动设备应用,这可能非常有用——并添加动画支持:在本书中,我们将为这两个功能选择Y。
完成后,安装过程将更新以下文件:
package.json/src/main.ts/src/app/app.module.tsangular.jsonsrc/index.htmlsrc/styles.css
现在,我们可以继续修改我们的城市表。
MatTableModule
我们将要使用的 Angular 组件是MatTableModule,它提供了一个材质设计样式的 HTML 表格,可用于显示数据行。让我们看看如何在现有的 Angular 应用中实现它。
从解决方案资源管理器中,导航到/ClientApp/src/app/文件夹,创建一个新的angular-material.module.ts文件,并用以下内容填充该文件:
import { NgModule } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
@NgModule({
imports: [
MatTableModule
],
exports: [
MatTableModule
]
})
export class AngularMaterialModule { }
这是一个全新的模块,我们将用于我们希望在应用中实现的所有 Angular 材料模块;将它们放在这里而不是使用app.module.ts文件将使该文件更小,这对项目的可管理性非常好。
不用说,为了使这个模块容器能够正常工作,我们需要将其添加到现有的app.module.ts文件中。打开它并添加以下(突出显示的)行:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from
'@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
import { CitiesComponent } from './cities/cities.component';
import { BrowserAnimationsModule } from '@angular/platform-
browser/animations';
import { AngularMaterialModule } from './angular-material.module';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
CitiesComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'cities', component: CitiesComponent }
]),
BrowserAnimationsModule,
AngularMaterialModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
现在,我们将在angular-material.module.ts文件中输入的所有内容也将在我们的应用中引用。
完成后,我们最终可以打开/ClientApp/src/app/cities/cities.component.ts文件并添加以下(突出显示的)行:
// ...existing code...
export class CitiesComponent {
public displayedColumns: string[] = ['id', 'name', 'lat', 'lon'];
public cities: City[];
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
}
// ...existing code...
紧接着,打开/ClientApp/src/app/cities/cities.component.html文件,用新的MatTableModule替换我们以前的表实现,方法如下(更新的代码突出显示):
<h1>Cities</h1>
<p>Here's a list of cities: feel free to play with it.</p>
<p *ngIf="!cities"><em>Loading...</em></p>
<table mat-table [dataSource]="cities" class="mat-elevation-z8"
[hidden]="!cities">
<!-- Id Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th>
<td mat-cell *matCellDef="let city">{{city.id}}</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let city">{{city.name}}</td>
</ng-container>
<!-- Lat Column -->
<ng-container matColumnDef="lat">
<th mat-header-cell *matHeaderCellDef>Latitude</th>
<td mat-cell *matCellDef="let city">{{city.lat}}</td>
</ng-container>
<!-- Lon Column -->
<ng-container matColumnDef="lon">
<th mat-header-cell *matHeaderCellDef>Longitude</th>
<td mat-cell *matCellDef="let city">{{city.lon}}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
正如我们所看到的,MatTableModule有点模仿标准 HTML 表的行为,但对每一列都采用基于模板的方法;模板具有一系列辅助结构指令(使用*<directiveName>语法应用),可用于标记某些模板节并定义其模板节的实际角色。如我们所见,所有这些指令都以Def后缀结尾。
以下是上述代码中最相关的代码:
[hidden]属性绑定并不奇怪,因为它已经出现在前一个表中,用于完全相同的目的:在城市加载之前隐藏该表matColumnDef指令用唯一键标识给定列。matHeaderCellDef指令定义了如何显示每列的标题。matCellDef指令定义了如何显示每列的数据单元格。matHeaderRowDef指令可在前面代码末尾附近找到,它标识了表格标题行的配置元素和标题列的显示顺序。我们可以看到,这个指令表达式指向一个名为displayedColumns的组件变量,我们在cities.component.ts文件中很早就定义了它;此变量承载一个数组,其中包含我们要显示的所有列键,这些列键需要与通过各种matColumnDef指令指定的名称相同。
让我们点击F5并导航到城市视图,看看我们全新的表格是什么样子。这可以在以下屏幕截图中看到:

好的,材料设计确实存在,但是表与以前有相同的 UI/UX 问题!首先,它仍然很长;让我们通过实现分页特性来解决这个问题。
matpaginatromodule
现在我们使用的是 Angular 材质,实现分页是一项相当简单的任务。我们需要做的第一件事是将MatPaginatorModule服务导入我们不久前创建的angular-material.module.ts文件中。
客户端分页
我们可以这样做(突出显示新行):
import { NgModule } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
@NgModule({
imports: [
MatTableModule,
MatPaginatorModule
],
exports: [
MatTableModule,
MatPaginatorModule
]
})
export class AngularMaterialModule { }
紧接着,我们需要打开cities.component.ts文件,导入MatPaginator、MatTableDataSource和ViewChild服务:
import { Component, Inject, ViewChild } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator'; import { City } from './city';
@Component({
selector: 'app-cities',
templateUrl: './cities.component.html',
styleUrls: ['./cities.component.css']
})
export class CitiesComponent {
public cities: City[];
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
http.get<City[]>(baseUrl + 'api/Cities')
.subscribe(result => {
this.cities = result;
}, error => console.error(error));
}
}
最后但并非最不重要的一点是,我们需要在cities.component.html文件中的</table>结束标记之后添加以下分页指令:
// ...existing code...
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<!-- Pagination directive -->
<mat-paginator [hidden]="!cities"
[pageSize]="10"
[pageSizeOptions]="[10, 20, 50]"
showFirstLastButtons></mat-paginator>
我们可以看到,我们再次使用了[hidden]属性绑定来隐藏分页器,直到城市被加载。我们可以在<mat-paginator>元素上看到的其他属性配置了一些MatPaginatorModuleUI 选项,例如默认页面大小和我们希望向用户提供的所有页面大小选项的数组。
现在,我们可以点击F5,看看我们的努力。请看以下屏幕截图:

现在,我们的表格只显示了前 10 个城市。它在右下角还有一个整洁的分页器,可以使用箭头在各个页面中导航。我们的最终用户甚至可以使用一个简洁的下拉列表(每页 10 个、20 个或 50 个城市)来选择每页显示多少项目。看起来我们确实做到了!
然而,如果我们想一想,我们可以很容易地承认我们还没有完全做到这一点。当然,现在我们的用户可以很好地浏览表格,而不必上下滚动很长时间,但不需要天才就能理解所有这些行仍然存在于页面上:我们从未告诉服务器实际支持分页请求,因此我们仍然从数据提供器获取所有城市(并通过.NET Core API 控制器)就像以前一样:事实上,它们只是被前端隐藏了起来。
这基本上意味着,在服务器端(巨大的 SQL 查询结果、巨大的 JSON)和客户端(在每个分页器操作上显示/隐藏大量 HTML 行,导致页面更改),我们仍然具有与以前相同的性能影响。
为了缓解上述问题,我们需要从客户端分页转移到服务器端分页——这正是我们将在下一节中要做的。
服务器端分页
实现服务器端分页比客户端分页要复杂一些。我们需要做的是:
- 更改我们的
CitiesController.NET Core 类,使其支持分页 HTTP GET 请求。 - 创建一个新的
ApiResult.NET 类,我们可以使用它来改进我们的.NET Core控制器的 JSON 响应 - 更改我们的
cities.controller.tsAngular 组件以及当前的MatPaginatorModule配置,使其能够发出新的 GET 请求并处理新的 JSON 响应。
让我们这样做!
花旗控制器
我们的CitiesController的GetCities方法默认返回数据库中所有~13000 个城市的 JSON 数组;就服务器端性能而言,这绝对是不可能的,所以我们需要改变它。理想情况下,我们只希望返回少量的Cities,通过在方法签名中添加一些(必需的)变量,例如pageIndex和pageSize,我们可以轻松实现这一点。
下面是我们如何改变这种情况以强制实施这种行为(突出显示更新的行):
// ...existing code...
[HttpGet]
public async Task<ActionResult<IEnumerable<City>>> GetCities(
int pageIndex = 0,
int pageSize = 10)
{
return await _context.Cities
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToListAsync();
}
// ...existing code...
就这样,;我们还为这些变量指定了一些合理的默认值,以避免在默认情况下出现大量 JSON 响应。
让我们快速测试一下刚才的操作:点击F5*并在浏览器的地址栏中键入以下 URL:https://localhost:44334/api/Cities/?pageIndex=0 &页面大小=10。
以下是我们应该得到的:

看来我们的计划确实奏效了!
然而,我们必须处理一个主要问题:如果我们只返回一个包含 10 个城市的 JSON 数组,Angular 应用将无法实际知道数据库中有多少城市。如果没有这些信息,分页器很难像我们早期实现客户端分页时那样正常工作。
长话短说,我们需要找到一种方法来告诉我们的 Angular 应用一些附加信息,例如:
- 可用的总页数(和/或记录)
- 当前页面
- 每页上的记录数
说实话,唯一需要的信息是第一个,因为有棱角的客户可以跟踪其他两个;然而,既然我们需要实现那个,我们不妨将它们全部归还,这样我们的前端生活就容易多了。
为了做到这一点,我们能做的最好的事情就是创建一个专用的响应类型类——从现在起我们将大量使用这个类。
芹菜
在解决方案资源管理器中,右键单击Data文件夹并添加新的ApiResult.csC#类文件。然后,填写以下内容:
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace WorldCities.Data
{
public class ApiResult<T>
{
/// <summary>
/// Private constructor called by the CreateAsync method.
/// </summary>
private ApiResult(
List<T> data,
int count,
int pageIndex,
int pageSize)
{
Data = data;
PageIndex = pageIndex;
PageSize = pageSize;
TotalCount = count;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
}
#region Methods
/// <summary>
/// Pages a IQueryable source.
/// </summary>
/// <param name="source">An IQueryable source of generic
/// type</param>
/// <param name="pageIndex">Zero-based current page index
/// (0 = first page)</param>
/// <param name="pageSize">The actual size of each
/// page</param>
/// <returns>
/// A object containing the paged result
/// and all the relevant paging navigation info.
/// </returns>
public static async Task<ApiResult<T>> CreateAsync(
IQueryable<T> source,
int pageIndex,
int pageSize)
{
var count = await source.CountAsync();
source = await source
.Skip(pageIndex * pageSize)
.Take(pageSize);
var data = await source.ToListAsync();
return new ApiResult<T>(
data,
count,
pageIndex,
pageSize);
}
#endregion
#region Properties
/// <summary>
/// The data result.
/// </summary>
public List<T> Data { get; private set; }
/// <summary>
/// Zero-based index of current page.
/// </summary>
public int PageIndex { get; private set; }
/// <summary>
/// Number of items contained in each page.
/// </summary>
public int PageSize { get; private set; }
/// <summary>
/// Total items count
/// </summary>
public int TotalCount { get; private set; }
/// <summary>
/// Total pages count
/// </summary>
public int TotalPages { get; private set; }
/// <summary>
/// TRUE if the current page has a previous page,
/// FALSE otherwise.
/// </summary>
public bool HasPreviousPage
{
get
{
return (PageIndex > 0);
}
}
/// <summary>
/// TRUE if the current page has a next page, FALSE otherwise.
/// </summary>
public bool HasNextPage
{
get
{
return ((PageIndex +1) < TotalPages);
}
}
#endregion
}
}
这个async类包含一些非常有趣的内容。让我们试着总结一下最相关的事情:
Data:一个List<T>类型的属性,将用于包含分页数据(它将被转换为 JSON 数组)PageIndex:返回当前页面从零开始的索引(第一页 0,第二页 1,依此类推)PageSize:返回总页面大小(TotalCount/PageSize)TotalCount:返回Item计数总数TotalPages:返回考虑总Items计数的总页数(TotalCount/PageSize)HasPreviousPage:如果当前页面有上一页,则返回True,否则返回FalseHasNextPage:如果当前页面有下一页,则返回True,否则返回False
这些财产正是我们所寻找的;通过查看前面的代码,计算其值的底层逻辑应该很容易理解。
除此之外,该类基本上围绕静态方法CreateAsync<T>(IQueryable<T> source, int pageIndex, int pageSize)展开,该方法可用于对实体框架IQueryable对象进行分页。
It's worth noting that the ApiResult class cannot be instantiated from the outside since its contructor has been marked as private; the only way to create it is by using the static CreateAsync factory method. There are good reasons to do that: since it is not possible to define an async constructor, we have resorted to using a static async method that returns a class instance; the constructor has been set to private to prevent developers from directly using it instead of the factory method since it's the only reasonable way to instantiate this class.
下面是我们如何利用我们CitiesController的GetCities方法中全新的ApiResult类:
// ...existing code...
// GET: api/Cities
// GET: api/Cities/?pageIndex=0&pageSize=10
[HttpGet]
public async Task<ActionResult<ApiResult<City>>> GetCities(
int pageIndex = 0,
int pageSize = 10)
{
return await ApiResult<City>.CreateAsync(
_context.Cities,
pageIndex,
pageSize
);
}
// ...existing code...
我们走!现在,我们应该有我们的 10 个城市和所有我们正在寻找的信息。
让我们点击F5并导航到与之前相同的 URL,查看发生了什么变化:https://localhost:44334/api/Cities/?pageIndex=0 &页面大小=10。
以下是更新后的 JSON 响应:

如果我们向下滚动到页面底部,我们将看到我们急需的属性都在那里
这个实现的唯一缺点是,我们需要调用的 URL 来获得这样的结果是相当丑陋的;在转向 Angular 之前,花些时间看看是否有办法使其更光滑可能会很有用。
从理论上讲,通过以下方式在CitiesController.cs文件中实现专用路由,我们可以做得更好(更新的行高亮显示,但不要对您的代码执行这样的更改—请看一看):
// ...existing code...
// GET: api/Cities
// GET: api/Cities/?pageIndex=0&pageSize=10
// GET: api/Cities/0/10
[HttpGet]
[Route("{pageIndex?}/{pageSize?}")]
public async Task<ActionResult<ApiResult<City>>> GetCities(
int pageIndex = 0,
int pageSize = 10)
{
return await ApiResult<City>.CreateAsync(
_context.Cities,
pageIndex,
pageSize
);
}
// ...existing code...
通过实现该路由,我们可以使用以下新 URL 调用GetCities操作方法:https://localhost:44334/api/Cities/0/10 。
这可以说比下面的 URL 要好:https://localhost:44334/api/Cities/?pageIndex=0 &页面大小=10。
然而,至少现在不要这样做:执行此更改将意味着无法添加其他参数,这可能是定制选项方面的巨大损失——我们将在短时间内看到这一点。
现在让我们转到 Angular 的CitiesComponent并对其进行更新,以使用这种新的优化方式从服务器获取我们的城市。
CitiesComponent
我们只需要更改以下 Angular 文件:
CitiesComponentTypeScript 文件,这是我们现在需要更新的所有数据检索逻辑所在的位置CitiesComponentHTML 文件,用于将特定事件绑定到MatPaginator元素
让我们这样做。
打开cities.component.ts文件并执行以下更改(突出显示新的/更新的行):
import { Component, Inject, ViewChild } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { City } from './city';
@Component({
selector: 'app-cities',
templateUrl: './cities.component.html',
styleUrls: ['./cities.component.css']
})
export class CitiesComponent {
public displayedColumns: string[] = ['id', 'name', 'lat', 'lon'];
public cities: MatTableDataSource<City>;
@ViewChild(MatPaginator) paginator: MatPaginator;
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
} ngOnInit() {
var pageEvent = new PageEvent();
pageEvent.pageIndex = 0;
pageEvent.pageSize = 10;
this.getData(pageEvent);
}
getData(event: PageEvent) {
var url = this.baseUrl + 'api/Cities;
var url = this.baseUrl + 'api/Cities';
var params = new HttpParams()
.set("pageIndex", event.pageIndex.toString())
.set("pageSize", event.pageSize.toString());
this.http.get<any>(url, { params }) .subscribe(result => {
this.paginator.length = result.totalCount;
this.paginator.pageIndex = result.pageIndex;
this.paginator.pageSize = result.pageSize;
this.cities = new MatTableDataSource<City>(result.data);
}, error => console.error(error));
}
}
让我们来总结一下我们在这里所做的工作:
- 我们使用
@ViewChild装饰器设置一个静态视图查询,并将其结果存储到paginator变量中;这允许我们从 Component 类中访问和操作之前在组件模板中设置的MatPaginator实例。 - 我们从
ngOnInit()生命周期钩子方法中删除了HttpClient,并将整个数据检索登录放在一个单独的getData()方法中。为了做到这一点,我们必须为host``HttpClient和baseUrl定义几个内部类变量来持久化它们,以便我们能够多次使用它们(即,在多次getData()调用中)。 - 我们更改了数据检索逻辑以匹配新的 JSON 响应对象。
- 我们修改了
paginator配置策略,手动设置从服务器端获得的值,而不是让服务器端自动计算出来;这样做是必须的,否则它只会考虑(并且对进行分页)我们在每次 HTTP 请求时检索的一小部分城市,而不是整个批次。
在我们用前面代码实现的各种新功能中,@ViewChilddecorator 值得多说几句:简而言之,它可以用于从 Angular 组件中获取 DOM 模板元素的引用,因此无论何时我们需要操作元素的属性,它都是非常有用的功能。
从前面的代码可以看出,@ViewChilddecorator 是使用访问 DOM 元素所需的选择器参数定义的:选择器可以是类名(如果类有@Component或@Directivedecorator)、模板引用变量,在子组件树中定义的提供程序,依此类推。在我们的特定场景中,我们使用了MatPaginator类名,因为它确实有@Component装饰器。
While we're at it, it can be useful to know that the @ViewChild decorator also accepts a second parameter, which was required until Angular 8 and became optional in Angular 9: a static flag, which can be either true or false (in Angular 9, it defaults to false). If this flag is explicitly set to true, the @ViewChild is retrieved from the template before the Change Detection phase runs (that is, even before the ngOnInit() life cycle); conversely, the Component/element retrieval task is resolved either after the Change Detection phase if the element is inside a nested view (for example, a view with a *ngIf conditional display directive), or before Change Detection if it isn't.
Since we've used the [hidden] attribute binding in the template instead of the *ngIf directive, our MatPaginator won't run into initialization issues, even without having to set that flag to true.
For additional information about the @ViewChild decorator, we suggest you take a look at the Angular docs:
https://angular.io/api/core/ViewChild.
对于cities.component.html文件,我们只需要在<mat-paginator>指令中添加一行,在每个分页事件上绑定getData()事件。以下是如何做到这一点(新行突出显示):
// ...existing code
<!-- Pagination directive -->
<mat-paginator [hidden]="!cities"
(page)="pageEvent = getData($event)"
[pageSize]="10"
[pageSizeOptions]="[10, 20, 50]"
showFirstLastButtons></mat-paginator>
这个简单的绑定起着非常重要的作用:它确保每次用户与paginator元素交互以执行页面更改、请求上一页/下一页、第一页/最后一页、更改要显示的项目数等时调用getData()事件。我们很容易理解,服务器端分页需要这样一个调用,因为每次需要显示不同的行时,我们都需要从服务器获取更新的数据。
完成后,让我们通过点击F5来尝试新的魔法,然后导航到城市视图。如果我们一切都做得好,我们应该得到我们以前可以看到的相同 UI:

但是,这一次,我们应该体验更好的总体性能和更快的响应时间。这是因为我们没有在后台处理成千上万的 JSON 项和 HTML 表行;我们使用改进的服务器端逻辑一次只获取其中的几个(也就是我们看到的那些)。
既然我们已经完成了分页,我们最终可以处理排序。
Matsort 模块
为了实现排序,我们将使用MatSortModule,它可以像我们使用paginator模块一样实现。
这一次,我们不会像早期分页那样进行客户端排序实验;我们从一开始就采用服务器端模式。
扩展 ApiResult
让我们从.NET Core后端部分开始。
你还记得我们之前创建的ApiResult类吗?是时候改进它的源代码来添加排序支持了。
从解决方案资源管理器中,打开/Data/ApiResult.cs文件并相应更新其内容(突出显示新的/更新的行):
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Linq.Dynamic.Core;
using System.Reflection;
namespace WorldCities.Data
{
public class ApiResult<T>
{
/// <summary>
/// Private constructor called by the CreateAsync method.
/// </summary>
private ApiResult(
List<T> data,
int count,
int pageIndex,
int pageSize,
string sortColumn,
string sortOrder)
{
Data = data;
PageIndex = pageIndex;
PageSize = pageSize;
TotalCount = count;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
SortColumn = sortColumn;
SortOrder = sortOrder;
}
#region Methods
/// <summary>
/// Pages and/or sorts a IQueryable source.
/// </summary>
/// <param name="source">An IQueryable source of generic
/// type</param>
/// <param name="pageIndex">Zero-based current page index
/// (0 = first page)</param>
/// <param name="pageSize">The actual size of each
/// page</param>
/// <param name="sortColumn">The sorting column name</param>
/// <param name="sortOrder">The sorting order ("ASC" or
/// "DESC")</param>
/// <returns>
/// A object containing the IQueryable paged/sorted result
/// and all the relevant paging/sorting navigation info.
/// </returns>
public static async Task<ApiResult<T>> CreateAsync(
IQueryable<T> source,
int pageIndex,
int pageSize,
string sortColumn = null,
string sortOrder = null)
{
var count = await source.CountAsync();
if (!String.IsNullOrEmpty(sortColumn)
&& IsValidProperty(sortColumn))
{
sortOrder = !String.IsNullOrEmpty(sortOrder)
&& sortOrder.ToUpper() == "ASC" ? "ASC"
: "DESC";
source = source.OrderBy(
String.Format(
"{0} {1}",
sortColumn,
sortOrder)
);
}
source = source
.Skip(pageIndex * pageSize)
.Take(pageSize);
var data = await source.ToListAsync();
return new ApiResult<T>(
data,
count,
pageIndex,
pageSize,
sortColumn,
sortOrder);
}
#endregion
#region Methods
/// <summary>
/// Checks if the given property name exists
/// to protect against SQL injection attacks
/// </summary>
public static bool IsValidProperty(
string propertyName,
bool throwExceptionIfNotFound = true)
{
var prop = typeof(T).GetProperty(
propertyName,
BindingFlags.IgnoreCase |
BindingFlags.Public |
BindingFlags.Instance);
if (prop == null && throwExceptionIfNotFound)
throw new NotSupportedException(
String.Format(
"ERROR: Property '{0}' does not exist.",
propertyName)
);
return prop != null;
}
#endregion
#region Properties
/// <summary>
/// The data result.
/// </summary>
public List<T> Data { get; private set; }
/// <summary>
/// Zero-based index of current page.
/// </summary>
public int PageIndex { get; private set; }
/// <summary>
/// Number of items contained in each page.
/// </summary>
public int PageSize { get; private set; }
/// <summary>
/// Total items count
/// </summary>
public int TotalCount { get; private set; }
/// <summary>
/// Total pages count
/// </summary>
public int TotalPages { get; private set; }
/// <summary>
/// TRUE if the current page has a previous page,
/// FALSE otherwise.
/// </summary>
public bool HasPreviousPage
{
get
{
return (PageIndex > 0);
}
}
/// <summary>
/// TRUE if the current page has a next page, FALSE otherwise.
/// </summary>
public bool HasNextPage
{
get
{
return ((PageIndex +1) < TotalPages);
}
}
/// <summary>
/// Sorting Column name (or null if none set)
/// </summary>
public string SortColumn { get; set; }
/// <summary>
/// Sorting Order ("ASC", "DESC" or null if none set)
/// </summary>
public string SortOrder { get; set; }
#endregion
}
}
我们所做的基本上是在主类静态方法中添加两个新的sortColumn和sortOrder属性,并通过代码实现它们;在那里,我们还利用这个机会定义了两个同名的新属性(大写),这样排序细节将成为 JSON 响应的一部分,就像分页响应一样。
值得注意的是,由于我们现在正在将我们的语言集成 Query(LINQ)组装到具有来自客户端的文本数据的 SQL 查询中,因此我们还添加了一个新的IsValidProperty()方法,该方法将检查指定的sortColumn是否确实作为泛型的类型属性存在<T>我们正在处理的实体;正如方法注释明确指出的,这实际上是一种针对 SQL 注入尝试的安全对策。这是一个非常重要的安全问题,我们稍后将讨论。
如果我们尝试在这些更改之后立即构建项目,我们很可能会遇到一些编译器错误,例如以下错误:
Error CS0246: The type or namespace name System.Linq.Dynamic could not be found (are you missing a using directive or an assembly reference?).
别担心,这很正常:我们只需要在项目中添加一个新的 NuGet 包。
安装 System.Linq.Dynamic.Core
我们在改进的ApiResult源代码中使用的IQueryable<T>.OrderBy()扩展方法以编程方式应用列排序是System.Linq.Dynamic.Core名称空间的一部分;多亏了这个库,才有可能在IQueryable上编写动态 LINQ 查询(基于字符串),就像我们在前面的代码中所做的那样。
不幸的是,System.Linq.Dynamic.Core不是.NET Core 股票二进制文件的一部分;因此,为了使用这些特性,我们需要通过 NuGet 添加它
最快的方法是打开 Visual Studio 的包管理器控制台并发出以下命令:
> Install-Package System.Linq.Dynamic.Core
重要:请确保安装System.Linq.Dynamic.Core而不是System.Linq.Dynamic,后者是其.NET Framework 4.0 的对应版本;后者不适用于我们的.NET Core web 应用项目。
At the time of writing, the most recent version of the System.Linq.Dynamic.Core package is 1.0.19, which works absolutely fine for our purposes.
For those who want to retrieve additional information regarding this great package, we suggest you take a look at the following resources:
NuGet website:
https://www.nuget.org/packages/System.Linq.Dynamic.Core/.
GitHub project:
https://github.com/StefH/System.Linq.Dynamic.Core.
林克是什么?
在继续之前,让我们花几分钟讨论一下 LINQ,如果你从未听说过它的话。
也被称为语言集成查询y,LINQ 是微软.NET 框架技术集的代号,该技术将数据查询功能添加到.NET 语言,如 C#和 VB.NET。LINQ 于 2007 年首次发布,是.NETFramework3.5 的主要新特性之一。
LINQ 的主要目的是使开发人员能够使用一流的语言结构表达针对数据的结构化查询,而不必学习每种类型的数据源(收集类型、SQL、XML、CSV 等)的不同查询语言。对于这些主要数据源类型,有一个 LINQ 实现,它为对象(LINQ to objects)、实体框架实体(LINQ to entities)、关系数据库(LINQ to SQL)、XML(LINQ to XML等提供相同的查询体验。
LINQ 结构化查询可以使用两种可选的(但也是互补的)方法表示:
- Lambda 表达式,如:
var city = _context.Cities.Where(c => c.Name == "New York").First();
- 查询表达式,如:
var city = (from c in _context.Cities where c.Name == "New York" select c).First();
由于查询表达式在编译之前会被转换为等价的 lambda 表达式,因此这两种方法都会产生相同的结果和相同的性能。
For additional information about LINQ, lambda expressions, and query expressions, check out the following links:
LINQ:
https://docs.microsoft.com/en-us/dotnet/csharp/linq/.
LINQ lambda expressions (C# Programming Guide):
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/statements-expressions-operators/lambda-expressions.
LINQ query expression basics:
https://docs.microsoft.com/en-us/dotnet/csharp/linq/query-expression-basics.
Linq.Dynamic.Core 的优点和缺点
现在,既然 LINQ 从 v3.5 开始就内置了.NET Framework,而且它还附带了.NET Core,System.Linq.Dynamic.Core包实际上做什么?我们为什么要使用它?
从前面的两个示例中可以看出,lambda 表达式和查询表达式都使用强类型方法:每当我们使用 LINQ查询任何类型的对象时,编译器必须知道源类型以及我们希望查询检查的所有属性。这意味着我们将无法将这些技术用于泛型对象(object或类型(<T>)。这就是Linq.Dynamic的解救之道,允许开发人员使用文字字符串编写 lambda 表达式和查询表达式,并使用反射将其转换为强类型等价物 .
以下是与前面使用System.Linq.Dynamic.Core编写的查询相同的查询:
var city = _context.Cities.Where("Name = @1", "New York").First();
我们可以立即看到差异——以及使用这种方法可以获得的巨大优势:我们将能够动态构建查询,而不管我们是处理强类型对象还是泛型类型,就像我们不久前在ApiResult的源代码中所做的那样。
然而,这种方法也有一个主要的缺点:我们的代码不太容易测试,而且太容易出错,这至少有两个重要原因:
- 我们只需一个文本字符串,就可以查询错误,而这些错误几乎总是会导致严重的崩溃
- 不需要的查询(包括 SQL 注入攻击)的风险可能会成倍增加,这取决于我们如何构建这些查询和/或从何处获取我们的动态字符串
Those who don't know what SQL injections are and/or why they are dangerous should definitely take a look at the following guide, written by Tim Sammutand Mike Schiffman from the Cisco Security Intelligence team:
Understanding SQL Injections:https://tools.cisco.com/security/center/resources/sql_injection.
前一个问题很糟糕,但后一个问题更糟糕:对 SQL 注入攻击持开放态度可能是毁灭性的,因此我们应该不惜任何代价避免这一点——包括扔掉System.Linq.Dynamic.Core包。
防止 SQL 注入
幸运的是,我们不需要这样做:虽然我们从客户端得到了两个可能有害的变量字符串—sortColumn和sortOrder,但我们已经在前面的ApiResult源代码中为这两个字符串制定了有效的对策。
以下是我们为sortOrder所做的:
//... existing code...
sortOrder = !String.IsNullOrEmpty(sortOrder)
&& sortOrder.ToUpper() == "ASC"
? "ASC"
: "DESC";
//... existing code...
正如我们所看到的,在任何地方使用它之前,我们将把它转换成"ASC"或"DESC",这样就不会给 SQL 注入留下任何机会。
sortColumn参数处理起来要复杂得多,因为理论上它可以包含映射到我们任何实体的任何可能的列名:id、name、lat、lon、iso2、iso3。。。如果我们要全部检查它们,我们将需要一个很长的条件块!更不用说,每当我们向项目中添加新的实体和/或属性时,它也很难维护。
正是出于这个原因,我们选择了一种完全不同的——也可以说是更好的——方法,它依赖于以下IsValidProperty方法:
// ...existing code...
public static bool IsValidProperty(
string propertyName,
bool throwExceptionIfNotFound = true)
{
var prop = typeof(T).GetProperty(
propertyName,
BindingFlags.IgnoreCase |
BindingFlags.Public |
BindingFlags.Instance);
if (prop == null && throwExceptionIfNotFound)
throw new NotSupportedException(
String.Format(
"ERROR: Property '{0}' does not exist.",
propertyName)
);
return prop != null;
}
// ...existing code...
如我们所见,该方法检查给定的propertyName是否对应于我们<T>泛型实体类中的现有类型Property:如果对应,则返回True;否则,它会抛出一个NotSupportedException(或返回False,具体取决于我们如何称呼它)。这是保护代码不受 SQL 注入影响的一个很好的方法,因为有害字符串绝对不可能与实体的某个属性匹配。
The property name check has been implemented through System.Reflection, a technique that's used to inspect and/or retrieve metadata on types at runtime. To work with reflection, we need to include the System.Reflection namespace in our class – which is precisely what we did at the beginning of the source code of our improved ApiResult.
For additional information about System.Reflection, check out the following guide:
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/reflection.
通过回顾ApiResult源代码我们可以看到,这样一个方法是通过以下方式调用的:
if (!String.IsNullOrEmpty(sortColumn)
&& IsValidProperty(sortColumn))
{
/// if we are here, sortColumn is safe to use
}
这些花括号定义了我们的 SQL 注入安全区:只要我们在其中处理sortColumn,我们就没有什么可担心的。
Truth be told, even after implementing this defensive approach, there's still a minor threat we could be exposed to: if we have some reserved columns/properties that we don't want the client to interact with (system columns, for example), the preceding countermeasure won't block it from doing that, although being unable to acknowledge their existence or to read their data, an experienced user could still be able to "order" the table results by them – providing it knows their precise name somehow.
If we want to prevent this remote – yet theoretically possible – leak, we can set these properties to private (since we told our IsValidProperty method to the only check for public properties) and/or rethink the whole method logic so that it better suits our security needs.
更新 CitiesController
现在我们已经改进了我们的ApiResult类,我们可以在CitiesController中实现它。
打开/Controllers/CitiesController.cs文件并相应更改其内容(更新的行突出显示):
// ..existing code...
// GET: api/Cities
// GET: api/Cities/?pageIndex=0&pageSize=10
// GET: api/Cities/?pageIndex=0&pageSize=10&sortColumn=name&
// sortOrder=asc
[HttpGet]
public async Task<ActionResult<ApiResult<City>>> GetCities(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null)
{
return await ApiResult<City>.CreateAsync(
_context.Cities,
pageIndex,
pageSize,
sortColumn,
sortOrder);
}
// ..existing code...
我们已经完成了后端部分;让我们转到前端。
更新 Angular 应用
一如既往,我们需要更改三个文件:
angular-material.module.ts文件,我们需要在其中添加新的@angular/material模块cities.component.ts文件,实现分拣业务逻辑cities.component.html文件,用于绑定 UI 模板中.ts文件中定义的新变量、方法和引用
角材料模块
打开/ClientApp/src/app/angular-material.module.ts文件并按以下方式进行更改(更新的行突出显示):
import { NgModule } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
@NgModule({
imports: [
MatTableModule,
MatPaginatorModule,
MatSortModule
],
exports: [
MatTableModule,
MatPaginatorModule,
MatSortModule
]
})
export class AngularMaterialModule { }
从现在起,我们将能够import任何 Angular 组件中的 MatSortModule 相关类。
城市
完成后,打开cities.component.ts文件并进行以下修改(更新的行突出显示):
import { Component, Inject, ViewChild } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { City } from './city';
@Component({
selector: 'app-cities',
templateUrl: './cities.component.html',
styleUrls: ['./cities.component.css']
})
export class CitiesComponent {
public displayedColumns: string[] = ['id', 'name', 'lat', 'lon'];
public cities: MatTableDataSource<City>;
defaultPageIndex: number = 0;
defaultPageSize: number = 10;
public defaultSortColumn: string = "name";
public defaultSortOrder: string = "asc";
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.loadData();
}
loadData() { var pageEvent = new PageEvent();
pageEvent.pageIndex = this.defaultPageIndex;
pageEvent.pageSize = this.defaultPageSize; this.getData(pageEvent);
}
getData(event: PageEvent) {
var url = this.baseUrl + 'api/Cities';
var params = new HttpParams()
.set("pageIndex", event.pageIndex.toString())
.set("pageSize", event.pageSize.toString())
.set("sortColumn", (this.sort)
? this.sort.active
: this.defaultSortColumn)
.set("sortOrder", (this.sort)
? this.sort.direction
: this.defaultSortOrder);
this.http.get<any>(url, { params })
.subscribe(result => {
console.log(result);
this.paginator.length = result.totalCount;
this.paginator.pageIndex = result.pageIndex;
this.paginator.pageSize = result.pageSize;
this.cities = new MatTableDataSource<City>(result.data);
}, error => console.error(error));
}
}
以下是最相关变更的明细:
- 我们从
@angular/material包中导入了MatSort参考。 - 我们添加了四个新的类变量来设置分页和排序默认值:
defaultPageIndex、defaultPageSize、defaultSortColumn和defaultSortOrder。其中两个被定义为public,因为我们需要通过双向数据绑定从 HTML 模板使用它们。 - 我们将初始的
getData()调用从类构造函数移动到一个新的集中式loadData ()函数,这样我们就可以将它绑定到表中(稍后我们将看到)。 - 我们将
sortColumn和sortOrderHTTP GET 参数添加到我们的HttpParams对象中,以便我们可以将排序信息发送到服务器端。
cities.component.html
紧接着,打开cities.component.html文件并进行以下修改(更新的行突出显示):
// ...existing code
<table mat-table [dataSource]="cities" class="mat-elevation-z8"
[hidden]="!cities" matSort (matSortChange)="loadData()"
matSortActive="{{defaultSortColumn}}"
matSortDirection="{{defaultSortOrder}}">
<!-- Id Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let city"> {{city.id}} </td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let city"> {{city.name}} </td>
</ng-container>
<!-- Lat Column -->
<ng-container matColumnDef="lat">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Latitude
</th>
<td mat-cell *matCellDef="let city"> {{city.lat}} </td>
</ng-container>
<!-- Lon Column -->
<ng-container matColumnDef="lon">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Longitude
</th>
<td mat-cell *matCellDef="let city"> {{city.lon}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
// ...existing code...
简而言之,我们所做的如下:
-
我们在
<table mat-table>元素中添加了以下属性:matSort:对我们早期添加到cities.component.ts文件中的matSort局部变量的引用(matSortChange):一个事件绑定,用户每次尝试排序时都会执行sortData()方法(前面的.ts文件中也有定义)matSortActive和matSortDirection:两个数据绑定到.ts文件中早期定义的defaultSortColumn和defaultSortOrder变量
-
我们为每个
<th mat-header-cell>元素添加了mat-sort-header属性(每个表列一个)。
Now we can see why we didn't use the sleek URL we defined early on in our .NET Core CitiesController and opted for the standard GET parameters instead: this approach allows us to programmatically add an indefinite amount of HTTP GET parameters to our request thanks to the HttpParams class from the @angular/common/http package.
让我们通过点击F5并导航到城市视图来快速测试它。下面是我们应该能够看到的:

正如我们所看到的,城市现在是按字母顺序升序排列的。如果我们单击各个列标题,我们可以随意更改它们的顺序:第一次单击将按升序对内容进行排序,而第二次单击则相反。
It's worth noting how the paging and sorting features are able to coexist without issues; needless to say, whenever we try to change the table sorting, the paging will just roll back to the first page.
现在已经实现了排序,只剩下一个缺少的特性:过滤。
添加过滤
如果我们认为我们能够摆脱另一个组件,这一次,我们会感到失望:角材质没有提供用于过滤目的的特定模块。这意味着我们不能依靠标准方法将筛选添加到表中;我们必须自己想出一个合理的办法。
一般来说,当我们需要自己编写一个功能时,最好的办法就是开始可视化我们想要它的样子:例如,我们可以想象一个位于表格顶部的搜索输入字段会触发我们的CitiesComponent从服务器重新加载城市数据——通过它的getData()方法–每当我们在其中键入内容时。听起来怎么样?
让我们试着制定一个行动计划:
- 和往常一样,我们需要扩展
ApiResult类,以编程方式处理服务器端的过滤任务。 - 我们还需要更改我们的.NET
CitiesController的GetCities()操作方法的签名,以便我们可以从客户端获取附加信息。 - 在那之后,我们必须在我们的
CitiesComponent中实现过滤逻辑。 - 最后但并非最不重要的一点是,我们需要在
CitiesComponentHTML 模板文件中添加输入文本框,并将一个事件绑定到该文本框,以便在键入某个内容时触发数据检索过程。
既然我们已经做到了,让我们尽最大努力把这个计划付诸实施。
扩展结果(再次)
似乎我们需要对我们钟爱的ApiResult类进行另一次升级,以向已经存在的分页和排序逻辑添加过滤支持。
说实话,我们不必在ApiResult类中做任何事情:我们可以完全跳过该部分,只需在现有CitiesController中添加以下内容:
// ...existing code...
[HttpGet]
public async Task<ActionResult<ApiResult<City>>> GetCities(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
// first we perform the filtering...
var cities = _context.Cities;
if (!String.IsNullOrEmpty(filterColumn)
&& !String.IsNullOrEmpty(filterQuery))
{
cities= cities.Where(c => c.Name.Contains(filterQuery));
}
// ... and then we call the ApiResult
return await ApiResult<City>.CreateAsync(
cities,
pageIndex,
pageSize,
sortColumn,
sortOrder);
}
// ...existing code...
这绝对是一个可行的方法。事实上,如果我们不使用System.Linq.Dynamic.Core包库,这很可能是唯一可能的方法:我们将无法使用与泛型IQueryable<T>对象一起工作的外部类以编程方式设置列过滤器,因此不知道实体类型和属性名称。
幸运的是,我们确实有这个包,因此我们可以避免执行前面的更改(或者回滚,如果我们已经这样做了),而是用以下方式修改我们的/Data/ApiResult.cs类文件:
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Linq.Dynamic.Core;
namespace WorldCities.Data
{
public class ApiResult<T>
{
/// <summary>
/// Private constructor called by the CreateAsync method.
/// </summary>
public ApiResult(
List<T> data,
int count,
int pageIndex,
int pageSize,
string sortColumn,
string sortOrder,
string filterColumn,
string filterQuery)
{
Data = data;
PageIndex = pageIndex;
PageSize = pageSize;
TotalCount = count;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
SortColumn = sortColumn;
SortOrder = sortOrder;
FilterColumn = filterColumn;
FilterQuery = filterQuery;
}
#region Methods
/// <summary>
/// Pages, sorts and/or filters a IQueryable source.
/// </summary>
/// <param name="source">An IQueryable source of generic
/// type</param>
/// <param name="pageIndex">Zero-based current page index
/// (0 = first page)</param>
/// <param name="pageSize">The actual size of
/// each page</param>
/// <param name="sortColumn">The sorting colum name</param>
/// <param name="sortOrder">The sorting order ("ASC" or
/// "DESC")</param>
/// <param name="filterColumn">The filtering column name</param>
/// <param name="filterQuery">The filtering query (value to
/// lookup)</param>
/// <returns>
/// A object containing the IQueryable paged/sorted/filtered
/// result
/// and all the relevant paging/sorting/filtering navigation
/// info.
/// </returns>
public static async Task<ApiResult<T>> CreateAsync(
IQueryable<T> source,
int pageIndex,
int pageSize,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
if (!String.IsNullOrEmpty(filterColumn)
&& !String.IsNullOrEmpty(filterQuery)
&& IsValidProperty(filterColumn))
{
source = source.Where(
String.Format("{0}.Contains(@0)",
filterColumn),
filterQuery);
}
var count = await source.CountAsync();
if (!String.IsNullOrEmpty(sortColumn)
&& IsValidProperty(sortColumn))
{
sortOrder = !String.IsNullOrEmpty(sortOrder)
&& sortOrder.ToUpper() == "ASC"
? "ASC"
: "DESC";
source = source.OrderBy(
String.Format(
"{0} {1}",
sortColumn,
sortOrder)
);
}
source = source
.Skip(pageIndex * pageSize)
.Take(pageSize);
var data = await source.ToListAsync();
return new ApiResult<T>(
data,
count,
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
#endregion
#region Methods
/// <summary>
/// Checks if the given property name exists
/// to protect against SQL injection attacks
/// </summary>
public static bool IsValidProperty(
string propertyName,
bool throwExceptionIfNotFound = true)
{
var prop = typeof(T).GetProperty(
propertyName,
BindingFlags.IgnoreCase |
BindingFlags.Public |
BindingFlags.Instance);
if (prop == null && throwExceptionIfNotFound)
throw new NotSupportedException(
String.Format(
"ERROR: Property '{0}' does not exist.",
propertyName)
);
return prop != null;
}
#endregion
#region Properties
/// <summary>
/// The data result.
/// </summary>
public List<T> Data { get; private set; }
/// <summary>
/// Zero-based index of current page.
/// </summary>
public int PageIndex { get; private set; }
/// <summary>
/// Number of items contained in each page.
/// </summary>
public int PageSize { get; private set; }
/// <summary>
/// Total items count
/// </summary>
public int TotalCount { get; private set; }
/// <summary>
/// Total pages count
/// </summary>
public int TotalPages { get; private set; }
/// <summary>
/// TRUE if the current page has a previous page,
/// FALSE otherwise.
/// </summary>
public bool HasPreviousPage
{
get
{
return (PageIndex > 0);
}
}
/// <summary>
/// TRUE if the current page has a next page, FALSE otherwise.
/// </summary>
public bool HasNextPage
{
get
{
return ((PageIndex +1) < TotalPages);
}
}
/// <summary>
/// Sorting Column name (or null if none set)
/// </summary>
public string SortColumn { get; set; }
/// <summary>
/// Sorting Order ("ASC", "DESC" or null if none set)
/// </summary>
public string SortOrder { get; set; }
/// <summary>
/// Filter Column name (or null if none set)
/// </summary>
public string FilterColumn { get; set; }
/// <summary>
/// Filter Query string
/// (to be used within the given FilterColumn)
/// </summary>
public string FilterQuery { get; set; }
#endregion
}
}
就这样。正如我们所见,由于System.Linq.Dynamic.Core包提供了另一种有用的扩展方法,我们能够以编程方式实现IQueryable<T>.Where()方法——它实际上执行过滤任务。
不用说,我们再次利用我们的IsValidProperty方法来屏蔽我们的代码,防止可能的 SQL 注入尝试:过滤相关逻辑(和动态 LINQ 查询)只有在返回True时才会执行,也就是说,如果filterColumn参数值与现有实体公共属性匹配。
在这里,我们还添加了两个附加属性(FilterColumn和FilterQuery,以便在 JSON 响应对象上使用它们,并相应地修改了构造函数方法签名。
花旗控制器
现在,我们可以打开我们的/Controllers/CitiesController.cs文件并进行以下更改:
[HttpGet]
public async Task<ActionResult<ApiResult<City>>> GetCities(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
return await ApiResult<City>.CreateAsync(
_context.Cities,
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
前面的代码与我们在上一节中假设的替代实现非常相似:正如我们前面提到的,这两种方法都是可行的,取决于我们的喜好。然而,由于我们将在短时间内为国家使用相同的实现,因此充分利用System.Linq.Dynamic.Core并集中所有IQueryable逻辑可以说是一种更好的方法,因为它使我们的源代码尽可能干燥。
Don't Repeat Yourself (DRY) is a widely achieved principle of software development. Whenever we violate it, we fall into a WET approach, which could mean Write Everything Twice, We Enjoy Typing, or Waste Everyone's Time, depending on what we like the most.
NET 部分完成;让我们继续讨论 Angular 问题。
CitiesComponent
打开/ClientApp/src/app/cities/cities.component.ts文件并按以下方式更新其内容(修改的行突出显示):
import { Component, Inject, ViewChild } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { City } from './city';
@Component({
selector: 'app-cities',
templateUrl: './cities.component.html',
styleUrls: ['./cities.component.css']
})
export class CitiesComponent {
public displayedColumns: string[] = ['id', 'name', 'lat', 'lon'];
public cities: MatTableDataSource<City>;
defaultPageIndex: number = 0;
defaultPageSize: number = 10;
public defaultSortColumn: string = "name";
public defaultSortOrder: string = "asc";
defaultFilterColumn: string = "name";
filterQuery:string = null;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.loadData(null);
}
loadData(query: string = null) {
var pageEvent = new PageEvent();
pageEvent.pageIndex = this.defaultPageIndex;
pageEvent.pageSize = this.defaultPageSize;
if (query) {
this.filterQuery = query;
}
this.getData(pageEvent);
}
getData(event: PageEvent) {
var url = this.baseUrl + 'api/Cities';
var params = new HttpParams()
.set("pageIndex", event.pageIndex.toString())
.set("pageSize", event.pageSize.toString())
.set("sortColumn", (this.sort)
? this.sort.active
: this.defaultSortColumn)
.set("sortOrder", (this.sort)
? this.sort.direction
: this.defaultSortOrder);
if (this.filterQuery) {
params = params
.set("filterColumn", this.defaultFilterColumn)
.set("filterQuery", this.filterQuery);
}
this.http.get<any>(url, { params })
.subscribe(result => {
this.paginator.length = result.totalCount;
this.paginator.pageIndex = result.pageIndex;
this.paginator.pageSize = result.pageSize;
this.cities = new MatTableDataSource<City>(result.data);
}, error => console.error(error));
}
}
这一次,新代码只包含几个额外的行;我们刚刚更改了loadData()方法的签名(使用null默认值,这样我们就不会破坏任何东西),并有条件地向 HTTP 请求添加了几个参数——就是这样。
CitiesComponent 模板(HTML)文件
让我们看看我们需要在/ClientApp/src/app/cities/cities.component.html模板文件中添加什么:
<h1>Cities</h1>
<p>Here's a list of cities: feel free to play with it.</p>
<p *ngIf="!cities"><em>Loading...</em></p>
<mat-form-field [hidden]="!cities">
<input matInput (keyup)="loadData($event.target.value)"
placeholder="Filter by name (or part of it)...">
</mat-form-field>
<table mat-table [dataSource]="cities" class="mat-elevation-z8" [hidden]="!cities"
matSort (matSortChange)="loadData()"
matSortActive="{{defaultSortColumn}}" matSortDirection="{{defaultSortOrder}}">
// ...existing code...
正如我们所看到的,我们刚刚添加了一个<mat-form-field>元素,该元素具有通常的[hidden]属性绑定(使其仅在我们的城市加载后出现)和一个(keyup)事件绑定,该绑定将在每次按键时触发loadData()方法;这个调用还将包含输入值,它将由我们的Component类通过我们刚才在那里实现的方法来处理。
CitiesComponent 样式(CSS)文件
在测试之前,我们还需要对/ClientApp/src/app/cities/cities.component.css文件做一个小改动:
table {
width: 100%;
}
.mat-form-field {
font-size: 14px;
width: 100%;
}
这是使我们的新MatInputModule跨越整个可用空间所必需的(默认限制为180px。
角材料模块
等等:我们刚才不是说了MatInputModule吗?这是正确的:事实上,看起来我们实际上毕竟使用了一个 Angular 材质模块——这是有充分理由的,因为它看起来比普通的 HTML 输入文本框要好得多!
然而,既然我们这样做了,我们需要在AngularMaterialModule容器中引用它,否则我们将得到一个编译器错误。为此,打开/ClientApp/src/app/angular-material.module.ts文件并添加以下行:
import { NgModule } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
@NgModule({
imports: [
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatInputModule
],
exports: [
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatInputModule
]
})
export class AngularMaterialModule { }
就是这样:现在,我们可以点击F5并导航到Cities视图来测试新的过滤功能。如果我们做得很好,我们应该能够看到类似于以下屏幕截图的内容:

看起来不错,对吧
如果我们试图在 filter 文本框中键入一些内容,我们应该会看到表和分页器会相应地实时更新。看看如果我们输入New York会发生什么:

这绝对是一个很好的实时过滤功能。
将国家添加到循环中
在继续之前,让各国跟上进度如何?是的,这意味着我们要重做我们第二次做的一切;然而,现在我们知道如何做到这一点,我们可以说是能够做到这一点在一瞬间
... 也许不是。
尽管如此,我们现在确实应该花一些合理的时间来做这件事,因为这将是一个很好的方法,可以将我们迄今为止学到的一切都植入我们的肌肉记忆中。
让我们这样做,以便我们可以继续尝试其他内容。为了避免浪费页面,我们将只关注此处最相关的步骤,而将其他所有内容留给我们刚刚对城市所做的事情,以及我们的 GitHub 存储库,它承载了我们需要做的全部源代码。
.NET Core
让我们从.NET Core 部分开始。
国家控制员
我们应该已经准备好了第 4 章中的CountriesController,具有实体框架核心的数据模型,对吗?打开该文件并用以下代码替换GetCountries()默认操作方法:
// ...existing code...
[HttpGet]
public async Task<ActionResult<ApiResult<Country>>> GetCountries(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
return await ApiResult<Country>.CreateAsync(
_context.Countries,
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
// ...existing code...
幸运的是,我们的ApiResult类是类型不可知的;因此,我们可以在那里毫无问题地使用它。此外,由于我们已经将所有的艰苦工作集中在那里,.NET 服务器端部分已经完成。
一个奇怪的 JSON 命名问题
在继续之前,让我们快速测试组件:点击F5并在浏览器的地址栏中键入以下 URL:https://localhost:44334/api/Countries/?pageIndex=0 &页面大小=2。
点击进入后,应该可以看到如下界面:

好像都是 g。。。嘿,等一下:isO2和isO3房产名称是怎么回事?他们不应该这样资本化!
为了理解那里发生了什么,我们需要退一步,承认到目前为止我们可能低估的东西:全新的System.Text.JsonAPI(与.NET Core 3 一起引入)在将所有.NET 类序列化为 JSON 时自动进行的 camelCase 转换。在本章的早期,我们已经讨论过这个问题,当我们第一次看到.NETCitiesControllerJSON 输出时,我们说这没什么大不了的,因为 Angular 也是面向 camelCase 的–我们只需要使用 camelCase 定义各种接口即可
不幸的是,当处理所有大写属性(如这两个)时,这种自动的 camelCase 转换可能会导致不必要的副作用;无论何时发生这种情况,我们都需要调整源代码以正确处理:
- 最明显的事情是,用完全相同的方式在我们的 Angular 界面中定义它们,也就是说,使用精确的外壳;然而,这意味着在整个 Angular 代码中处理这些
isO2和isO3变量名,这相当难看,也可能会产生误导。
** 如果我们不想采用这些可怕的属性名称,那么我们可以使用另一种——也可以说是更好的——解决方法:我们可以使用[JsonPropertyName]数据注释来装饰我们的违规属性,这允许我们强制使用 JSON 属性名称,而不管默认的大小写约定(camelCase 或 PascalCase)在Startup类中指定。*
*[JsonPropertyName]解决方案似乎是我们可以应用于特定场景的最合理的解决方案;让我们随它去,永远摆脱这样的问题吧!
打开/Data/Country.cs文件,将以下行添加到现有代码中(突出显示新行):
// ...existing code...
/// <summary>
/// Country code (in ISO 3166-1 ALPHA-2 format)
/// </summary>
[JsonPropertyName("iso2")]
public string ISO2 { get; set; }
/// <summary>
/// Country code (in ISO 3166-1 ALPHA-3 format)
/// </summary>
[JsonPropertyName("iso3")]
public string ISO3 { get; set; }
// ...existing code...
现在,我们可以通过点击F5并在浏览器的地址栏中键入与之前相同的 URL:来查看这些属性是否会尊重此行为 https://localhost:44334/api/Countries/?pageIndex=0 &页面大小=2:

看起来确实如此;由于这个意想不到的问题,我们有机会为我们的.NET Core 武库添加一个新的强大武器。
现在,我们只需要创建和配置 Angular 分量。
有棱角的
Angular 实现将比.NET Core 实现更简单,因为我们必须处理多个方面:
- 添加
CountriesComponentTS、HTML 和 CSS 文件,实现Countries表,以及分页、排序和过滤功能,就像我们对城市所做的那样 - 配置
AppModule以正确引用它并添加相应的路由 - 更新
NavComponent添加导航链接
让我们这样做!在解决方案资源管理器中,执行以下操作:
- 导航到
/ClientApp/src/app/文件夹。 - 创建一个新的
/countries/文件夹。 - 在该文件夹中,创建以下新文件:
country.tscountries.component.tscountries.component.htmlcountries.component.css
完成后,用以下内容填充它们。
country.ts
以下是/ClientApp/src/app/countries/country.ts接口文件的源代码:
export interface Country {
id: number;
name: string;
iso2: string;
iso3: string;
}
这里没有什么新东西——代码与我们创建city.ts接口文件时的代码非常相似。
countries.component.ts
以下是/ClientApp/src/app/countries/countries.component.ts文件的源代码:
import { Component, Inject, ViewChild } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { Country } from './country';
@Component({
selector: 'app-countries',
templateUrl: './countries.component.html',
styleUrls: ['./countries.component.css']
})
export class CountriesComponent {
public displayedColumns: string[] = ['id', 'name', 'iso2', 'iso3'];
public countries: MatTableDataSource<Country>;
defaultPageIndex: number = 0;
defaultPageSize: number = 10;
public defaultSortColumn: string = "name";
public defaultSortOrder: string = "asc";
defaultFilterColumn: string = "name";
filterQuery: string = null;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.loadData(null);
}
loadData(query: string = null) {
var pageEvent = new PageEvent();
pageEvent.pageIndex = this.defaultPageIndex;
pageEvent.pageSize = this.defaultPageSize;
if (query) {
this.filterQuery = query;
}
this.getData(pageEvent);
}
getData(event: PageEvent) {
var url = this.baseUrl + 'api/Countries';
var params = new HttpParams()
.set("pageIndex", event.pageIndex.toString())
.set("pageSize", event.pageSize.toString())
.set("sortColumn", (this.sort)
? this.sort.active
: this.defaultSortColumn)
.set("sortOrder", (this.sort)
? this.sort.direction
: this.defaultSortOrder);
if (this.filterQuery) {
params = params
.set("filterColumn", this.defaultFilterColumn)
.set("filterQuery", this.filterQuery);
}
this.http.get<any>(url, { params })
.subscribe(result => {
this.paginator.length = result.totalCount;
this.paginator.pageIndex = result.pageIndex;
this.paginator.pageSize = result.pageSize;
this.countries = new MatTableDataSource<Country>(result.data);
}, error => console.error(error));
}
}
同样,这基本上是cities.component.ts文件的镜像。
countries.component.html
以下是/ClientApp/src/app/countries/countries.component.html文件的源代码:
<h1>Countries</h1>
<p>Here's a list of countries: feel free to play with it.</p>
<p *ngIf="!countries"><em>Loading...</em></p>
<mat-form-field [hidden]="!countries">
<input matInput (keyup)="loadData($event.target.value)"
placeholder="Filter by name (or part of it)...">
</mat-form-field>
<table mat-table [dataSource]="countries" class="mat-elevation-z8"
[hidden]="!countries"
matSort (matSortChange)="loadData()"
matSortActive="{{defaultSortColumn}}"
matSortDirection="{{defaultSortOrder}}">
<!-- Id Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let country"> {{country.id}} </td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let country"> {{country.name}} </td>
</ng-container>
<!-- Lat Column -->
<ng-container matColumnDef="iso2">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ISO 2</th>
<td mat-cell *matCellDef="let country"> {{country.iso2}} </td>
</ng-container>
<!-- Lon Column -->
<ng-container matColumnDef="iso3">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ISO 3</th>
<td mat-cell *matCellDef="let country"> {{country.iso3}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<!-- Pagination directive -->
<mat-paginator [hidden]="!countries"
(page)="pageEvent = getData($event)"
[pageSize]="10"
[pageSizeOptions]="[10, 20, 50]"
showFirstLastButtons></mat-paginator>
正如预期的那样,该模板与cities.component.html模板文件几乎相同。
countries.component.css
以下是/ClientApp/src/app/countries/countries.component.css文件的源代码:
table {
width: 100%;
}
.mat-form-field {
font-size: 14px;
width: 100%;
}
前面的文件与cities.components.css文件非常相似,我们甚至可以引用它,而不是创建一个新的文件;但是,考虑到以后可能需要对Cities和Countries表应用不同的更改,处理单独的文件几乎总是一个更好的选择。
应用模块
现在,让我们将新组件注册到AppModule配置文件中。
打开/ClientApp/src/app/app.module.ts文件并添加以下突出显示的行:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
import { CitiesComponent } from './cities/cities.component';
import { CountriesComponent } from './countries/countries.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AngularMaterialModule } from './angular-material.module';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
CitiesComponent,
CountriesComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'cities', component: CitiesComponent },
{ path: 'countries', component: CountriesComponent }
]),
BrowserAnimationsModule,
AngularMaterialModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
前面的RouterModule配置将使我们的新CountriesComponent在客户端浏览器指向/countries专用路径时得到 Angular 的服务。然而,如果我们不在NavComponent菜单中添加可见链接,我们的用户就不会知道这样一条路线的存在;这正是我们要添加它的原因。
导航组件
打开/ClientApp/src/app/nav-menu/nav-menu.component.html文件,将以下突出显示的行添加到现有代码中:
// ...existing code...
<ul class="navbar-nav flex-grow">
<li
class="nav-item"
[routerLinkActive]="['link-active']"
[routerLinkActiveOptions]="{ exact: true }"
>
<a class="nav-link text-dark" [routerLink]="['/']">Home</a>
</li>
<li class="nav-item" [routerLinkActive]="['link-active']">
<a class="nav-link text-dark" [routerLink]="['/cities']"
>Cities</a
>
</li>
<li class="nav-item" [routerLinkActive]="['link-active']">
<a class="nav-link text-dark" [routerLink]="['/countries']"
>Countries</a
>
</li>
</ul>
// ...existing code...
... 就这样!
我们的CountriesComponent已经完成了,而且——如果我们没有犯错误的话——它应该以与我们所爱的CitiesComponent差不多的方式工作,而我们所爱的CitiesComponent花了这么多时间才完成。
测试国家/地区组件
是时候看看我们努力工作的结果了:点击F5,导航到国家视图,期待看到以下内容:

如果我们能在第一次尝试时得到同样的结果,那肯定意味着我们已经吸取了教训;如果我们没有,不要担心:我们只需要检查一下我们做错了什么,然后修复它。熟能生巧。
The browser's console log can be a very useful tool for debugging the server-side and client-side errors: most Angular errors come with well-documented exception text and a contextual link to the corresponding file and source code line, thus making it quite easy for the developer to understand what happens under the hood.
总结
本章是关于从.NET Core后端读取数据,并找到一种方法,通过 Angular前端将数据正确显示给浏览器。
我们开始使用我们现有的CitiesController获取大量具有 Angular 分量的城市;尽管这两个框架都能够完美地处理海量数据,但我们很快就明白,我们需要改进整个数据请求、响应和渲染流过程,以便为用户提供良好的用户体验。
**正是出于这个原因,我们选择采用System.Linq.Dynamic.Core.NET 包来改进s服务器端业务逻辑和 Angular Material npm 包,以极大地改进我们的客户端 UI:通过结合这两个包的强大功能,我们成功地实现了一系列有趣的功能:分页、排序、,在我们的开发过程中,我们还借此机会发现、解决和缓解了一些重要的安全问题,例如有害的 SQL 注入风险。
在完成Cities的工作后,我们立即转到Countries,借此机会回顾我们的步骤,并将我们刚刚学到的东西巩固到我们的肌肉记忆中。
经过我们所有的努力,我们可以肯定地说,我们做了一项伟大的工作,实现了我们的目标:能够从.NET Core后端读取我们的数据,并通过前端以 Angular 优雅地呈现出来,从而使最终用户完全能够看到并与之交互。
我们现在准备为我们的应用增加另一层复杂性:让我们的用户有机会修改现有数据和/或使用 HTML 表单添加新数据;这些功能是大多数交互式 web 应用(如 CMS、论坛、社交网络、聊天室等)的必备功能。在下一章中,我们将看到如何使用反应式表单处理此类任务,反应式表单是一个关键的 Angular 模块,它提供了一种模型驱动的方法来处理值随时间变化的表单输入。
建议的主题
JSON、RESTful 约定、HTTP 谓词、HTTP 状态、生命周期挂钩、客户端分页、服务器端分页、排序、筛选、依赖项注入、SQL 注入
.NET Core
System.Linq、System.Linq.Dynamic.Core、IQueryable、实体框架核心
有棱角的
组件、路由、模块、AppModule、HttpClient、ngIf、隐藏、数据绑定、属性绑定、属性绑定、ngFor、指令、结构指令、插值、模板
工具书类
- 添加排序、过滤和分页–ASP.NET MVC 与 EF 核心:https://docs.microsoft.com/en-us/aspnet/core/data/ef-mvc/sort-filter-page
- 角材官网:https://material.angular.io/
- 角材库:https://github.com/angular/components
- 角材:表概图:https://material.angular.io/components/table/overview
- Angular-视图儿童:https://angular.io/api/core/ViewChild
- GitHub 上的System.Linq.Dynamic.Core 项目页面:https://github.com/StefH/System.Linq.Dynamic.Core
- LINQ 概述:https://docs.microsoft.com/en-us/dotnet/csharp/linq/
- LINQ(语言综合查询):https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/
** LINQ Lambda 表达式(C#编程指南):https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/statements-expressions-operators/lambda-expressions* LINQ 查询表达式基础:https://docs.microsoft.com/en-us/dotnet/csharp/linq/query-expression-basics* 反射(C#):https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/reflection* .NET Core 和实体框架:使用动态 LINQ:编程设置 IQueryable列名 https://www.ryadel.com/en/asp-net-core-set-column-name-programmatically-dynamic-linq-where-iqueryable/ * 理解 SQL 注入:https://tools.cisco.com/security/center/resources/sql_injection*************
六、表单和数据验证
在本章中,我们将主要讨论表单、数据输入和验证技术。我们已经知道,HTML 表单是任何业务应用中最重要和最微妙的方面之一。如今,表单几乎用于完成任何涉及用户提交数据的任务,例如注册或登录网站、支付款项、预订酒店房间、订购产品、执行和检索搜索结果等等。
如果要求我们从开发人员的 Angular 定义表单,我们会得出这样的结论:“表单是一个基于 UI 的界面,允许授权用户输入数据,这些数据将发送到服务器进行处理。当我们接受这一定义时,应考虑两个额外的因素:
- 每个表单应提供足够好的数据输入体验,以有效地指导用户完成预期的工作流程;否则,他们将无法正确使用它。
- 每个表单,只要它给服务器带来潜在的不安全数据,就可能在数据完整性、数据安全性和系统安全性方面产生重大的安全影响,除非开发人员具备所需的专有技术来采取和实施适当的对策。
这两个短语很好地概括了本章的内容;我们将尽最大努力引导用户以最合适的方式提交数据,我们还将学习如何正确检查这些输入值,以防止、避免和/或最小化各种完整性和安全威胁。理解这两个主题经常相互交织也很重要;因此,我们通常会同时处理这些问题。
在本章中,我们将介绍以下主题:
- Angular 表单,我们将处理模板驱动表单以及反应表单,同时了解这两种方法的优缺点,并查看哪种方法最适合在各种常见场景中使用
- 数据验证,我们将学习如何在前端和后端双重检查用户的输入数据,以及在用户发送错误或无效值时提供视觉反馈的各种技术
- 表单生成器,我们将使用一些工厂方法实现另一个反应式表单,而不是手动实例化各种表单模型元素
在每个任务结束时,我们还将花一些时间使用 web 浏览器验证我们的工作结果。
技术要求
在本章中,我们需要前面章节中提到的所有技术要求,以及以下外部库:
- System.Linq.Dynamics.Core(可选)
和往常一样,建议不要直接安装它们。我们将在本章中介绍它们,以便我们可以在项目中对它们的用途进行上下文分析。
探索角形
如果我们看看我们目前的.NET Core 和 Angular 项目,我们会发现它们都不允许我们的用户与数据交互:
- 对于
HealthCheck应用,这是意料之中的,因为根本没有数据可处理:这是一个不存储任何内容且不需要用户输入的监控应用。 - 然而,
WorldCities应用讲述了一个完全不同的故事:我们确实有一个数据库,可以用来将结果返回给用户,至少在理论上,用户可以被允许进行更改。
不用说,WorldCities应用将是我们实现表单的最佳候选程序。在下面的部分中,我们将这样做,从 Angular 的前端开始,然后移动到.NET Core后端。
角形
让我们花一分钟简单地回顾一下我们在 Po.T7 结尾的状态下的我们的应用。如果我们看一下CitiesComponent和CountriesComponent模板,我们会发现我们实际上已经有了某种类型的数据输入元素:我们显然在谈论<mat-form-field>,它是角材料MatInputModule的选择器,我们在第 5 章中添加到循环中获取并显示数据,让我们的用户按名称过滤cities和countries。
以下是相关的代码片段:
<mat-form-field [hidden]="!cities">
<input matInput (keyup)="loadData($event.target.value)"
placeholder="Filter by name (or part of it)...">
</mat-form-field>
这意味着我们已经在接受某种类型的用户操作——由单个输入字符串组成——并对其做出相应的反应:这样的操作+反应链是用户和应用之间交互的基础,这基本上就是绝大多数表单的全部内容。
但是,如果我们查看生成的 HTML 代码,我们可以清楚地看到,我们没有任何实际的<form>元素。我们可以通过在浏览器窗口中右键单击该视图的输入元素并选择 Inspect element 来测试它,如以下屏幕截图所示:

正如我们所看到的,没有主表单,只有一个input字段可以完美地处理我们分配给它的任务。由于我们没有使用表单数据提交任何内容,因此没有忽略表单的存在:我们使用 AngularHttpClient模块执行数据获取,从技术上讲,该模块通过 JavaScript 使用异步XMLHttpRequest(XHR)来实现这一点——简言之,就是 AJAX。
这种方法不需要<form>容器元素来使用以下支持的方法处理数据编码和传输任务:
application/x-www-form-urlencodedmultipart/form-datatext/plain
它只需要实际的输入元素就可以获得用户的输入。
For further details regarding the encoding method supported by the HTML <form> element, take a look at the following specifications:
URL Living Standard, – URL-encoded Form Data:https://url.spec.whatwg.org/#concept-urlencoded.
HTML Living Standard, section 4.10.21.7 – Multipart Form Data:https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data. HTML Living Standard, section 4.10.21.8 – Plain Text Form Data:https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#plain-text-form-data.
尽管不是必需的,form元素——或用于输入元素的任何 HTML 容器——对于许多不属于数据编码和传输主题的重要任务可能非常有用。让我们看看它们是什么,为什么我们可能需要它们。
使用表单的理由
让我们试着总结一下我们当前无形方法最明显的不足:
- 我们无法跟踪全局表单状态,因为我们无法判断输入文本是否有效。
- 我们没有简单的方法向用户显示错误消息,让他们知道如何使表单有效。
- 我们不以任何方式验证输入数据;我们只需收集并将其扔到服务器上,无需三思而后行。
在我们的特定场景中,这是非常好的,因为我们只处理单个文本字符串,而不太关心它的长度、输入文本等等。然而,如果我们必须处理多个输入元素和多个值类型,这些限制可能会严重阻碍我们的工作——无论是在数据流控制、数据验证还是用户体验方面。
当然,通过在基于表单的组件中实现一些自定义方法,我们可以轻松解决上述大多数问题;我们可以到处抛出一些错误,比如isValid()、isNumber()等等,然后将它们连接到我们的模板语法,并借助于*ngIf、*ngFor等结构指令来显示/隐藏验证消息。但是,这肯定是解决我们问题的一种可怕的方式;我们没有选择像 Angular 这样功能丰富的客户端框架来实现这一点。
幸运的是,我们没有理由这么做,因为 Angular 为我们提供了两种替代策略来处理这些常见的表单相关场景:
- 模板驱动表单
- 模型驱动表单,又称反应表单
两者都与框架高度耦合,因此非常可行;它们都属于@angular/forms库,并且共享一组通用的表单控制类。然而,它们也有自己的特定功能集,以及它们的优点和缺点,这最终会导致我们选择其中之一。
让我们尝试快速总结这些差异。
模板驱动表单
如果您来自 AngularJS,那么模板驱动的方法很有可能会敲响一两声警钟。顾名思义,模板驱动表单承载了模板代码中的大部分逻辑;使用模板驱动表单意味着在.html模板文件中构建表单,使用ngModel实例将数据绑定到各个输入字段,并使用与整个表单相关的专用ngForm对象,包含所有输入,每个输入都可以通过其名称访问,以执行所需的有效性检查。
为了理解这一点,以下是模板驱动表单的外观:
<form novalidate autocomplete="off" #form="ngForm"
(ngSubmit)="onSubmit(form)">
<input type="text" name="name" value="" required
placeholder="Insert the city name..."
[(ngModel)]="city.Name" #title="ngModel"
/>
<span *ngIf="(name.touched || name.dirty) &&
name.errors?.required">
Name is a required field: please enter a valid city name.
</span>
<button type="submit" name="btnSubmit"
[disabled]="form.invalid">
Submit
</button>
</form>
如我们所见,我们可以使用一些方便的别名访问任何元素,包括表单本身——带有#符号的属性——并检查它们的当前状态,以创建我们自己的验证工作流。这些状态由框架提供,并将根据各种情况实时更改:touched例如,当控件至少被访问一次时变为True;dirty与pristine相反,表示控制值已经改变,依此类推。我们在前面的示例中使用了这两种方法,因为我们希望只有当用户将焦点移到<input name="name">然后离开时,才会显示验证消息,并通过删除其值或不设置其值将其保留为空。
简言之,这些是模板驱动的表单;现在,我们已经全面了解了它们,让我们尝试总结一下这种方法的优缺点。
职业选手
以下是模板驱动表单的主要优点:
- 模板驱动表单非常容易编写。我们可以回收大部分 HTML 知识(假设我们有)。最重要的是,如果我们来自 AngularJS,我们已经知道一旦我们掌握了这项技术,我们可以让它们发挥多大的作用。
- 它们非常容易阅读和理解,至少从 HTML 的 Angular 来看是如此;我们有一个简单易懂的 HTML 结构,其中包含一个接一个的所有输入字段和验证器。每个元素都有一个名称,一个与底层
ngModel的双向绑定,以及(可能)基于别名构建的模板驱动逻辑,这些别名已连接到我们也可以看到的其他元素或表单本身。
犯人
以下是他们的弱点:
- 模板驱动的表单需要大量 HTML 代码,这些代码很难维护,通常比纯 TypeScript 更容易出错。
- 出于同样的原因,这些表单无法进行单元测试。除了使用浏览器运行端到端测试之外,我们没有办法测试它们的验证器,也没有办法确保我们实现的逻辑能够正常工作,这对于复杂的表单来说并不理想。
- 随着我们添加越来越多的验证器和输入标签,它们的可读性将迅速下降。将它们的所有逻辑保留在模板中对于小表单来说可能很好,但是在处理复杂数据项时,它不能很好地扩展。
最终,我们可以说,当我们需要使用简单的数据验证规则构建小型表单时,模板驱动表单可能是一种可行的方法,在这种情况下,我们可以从它们的简单性中获得更多好处。除此之外,它们与我们已经习惯的典型 HTML 代码非常相似(假设我们有一个简单的 HTML 开发背景):我们只需要学习如何用别名修饰标准<form>和<input>元素,并加入一些由结构指令处理的验证器,比如我们已经看到的那些指令,我们马上就要出发了。
For additional information on Template-Driven Forms, we highly recommend that you read the official Angular documentation at https://angular.io/guide/forms.
这就是说,缺乏单元测试、最终产生的 HTML 代码膨胀以及伸缩困难最终将引导我们为任何非平凡的表单找到一种替代方法。
模型驱动/反应式表单
Angular 2+中专门添加了模型驱动方法,以解决模板驱动表单的已知限制。使用这种替代方法实现的表单称为模型驱动表单或反应式表单,它们完全相同。
这里的主要区别是(几乎)模板中什么都没有发生,它只是对更复杂的 TypeScript 对象的引用,该对象在组件类中以编程方式定义、实例化和配置:表单模型。
为了理解整体概念,让我们尝试以模型驱动/反应式的方式重写前面的表单(突出显示了相关部分)。这样做的结果如下:
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" required />
<span *ngIf="(form.get('name').touched || form.get('name').dirty)
&& form.get('name').errors?.required">
Name is a required field: please enter a valid city name.
</span>
<button type="submit" name="btnSubmit"
[disabled]="form.invalid">
Submit
</button>
</form>
正如我们所看到的,所需的代码量要少得多。
下面是我们将在组件类文件中定义的基础表单模型(以下代码中突出显示了相关部分):
import { FormGroup, FormControl } from '@angular/forms';
class ModelFormComponent implements OnInit {
form: FormGroup;
ngOnInit() {
this.form = new FormGroup({
title: new FormControl()
});
}
}
让我们试着了解这里发生了什么:
form属性是FormGroup的一个实例,表示表单本身。- 顾名思义,
FormGroup是一个具有相同用途的表单控件容器。正如我们所看到的,form本身充当FormGroup,这意味着我们可以将FormGroup对象嵌套在其他FormGroups中(尽管我们在样本中没有这样做)。 - 表单模板中的每个数据输入元素——在前面的代码
name中——由FormControl的一个实例表示。 - 每个
FormControl实例封装了相关控件的当前状态,如valid、invalid、touched、dirty,包括其实际值。 - 每个
FormGroup实例封装了每个子控件的状态,这意味着它只有在其所有子控件都有效时才有效。
另外,请注意,我们无法直接访问FormControls,就像我们在模板驱动表单中所做的那样;我们必须使用主FormGroup的.get()方法来检索它们,这就是表单本身。
乍一看,模型驱动的模板似乎与模板驱动的模板没有太大区别;我们还有一个<form>元素,一个<input>元素连接到<span>验证器,还有一个submit按钮;最重要的是,检查输入元素的状态需要更多的源代码,因为它们没有我们可以使用的别名。那么,真正的交易是什么?
为了帮助我们直观地看到差异,让我们看下面的图表;下面是一个模式,描述了模板驱动的表单是如何工作的:

通过看箭头,我们可以很容易地看到,在模板驱动****表单中,所有事情都发生在模板中;HTML 表单元素直接绑定到D****ataModel组件,该组件由一个属性表示,该属性填充了对Web 服务器的异步 HTML 请求,这与我们对城市和国家表所做的非常类似。即数据模型将在用户更改某些内容时立即更新,也就是说,除非验证器阻止他们这样做。如果我们仔细想想,我们很容易理解为什么整个工作流程中没有一个部分恰好在我们的控制之下;Angular 使用模板中定义的数据绑定中的信息自行处理一切。这就是模板驱动的实际含义:模板在发号施令。
现在,让我们来看看 Apple T0.模型驱动的窗体:To1 T1(或反应式)方法:

正如我们所见,描绘模型驱动****表单工作流的箭头讲述了一个完全不同的故事。它们显示了数据如何在数据模型组件(我们从Web 服务器获取)和面向 UI 的表单模型之间流动,该表单模型保留呈现给用户的 HTML 表单(及其子输入元素)的状态和值。这意味着我们将能够进入数据和表单控件对象之间,并直接执行许多任务:推拉数据、检测和响应用户更改、实现我们自己的验证逻辑、执行单元测试等等。
我们可以通过编程方式跟踪和影响工作流,而不是被不受我们控制的模板取代,因为调用快照的表单模型也是一个TypeScript类;这就是模型驱动表单的意义所在。这也解释了为什么它们也被称为反应式表单——这是对反应式编程风格的明确引用,它支持在整个工作流中进行明确的数据处理和更改管理。
For additional information on Model-Driven/Reactive Forms, we highly recommend reading the official Angular documentation at https://angular.io/guide/reactive-forms.
理论已经足够了;是时候用一些反应形式来增强我们的组件了。
构建我们的第一个反应式表单
在本节中,我们将创建第一个反应式表单。更具体地说,我们将建立一个CityEditComponent,让我们的用户有机会编辑现有城市记录。
为此,我们将执行以下操作:
- 在我们的
AppModule类中添加对ReactiveFormsModule的引用。 - 创建
CityEditComponent类型脚本和模板文件。
反应窗体模块
我们首先要做的是在AppModule类中添加对ReactiveFormsModule的引用,以开始使用被动形式。
在解决方案资源管理器中,打开/ClientApp/src/app/app.module.ts文件并添加以下代码(突出显示更新的源代码):
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
import { CitiesComponent } from './cities/cities.component';
import { CountriesComponent } from './countries/countries.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AngularMaterialModule } from './angular-material.module';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
CitiesComponent,
CountriesComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'cities', component: CitiesComponent },
{ path: 'countries', component: CountriesComponent }
]),
BrowserAnimationsModule,
AngularMaterialModule,
ReactiveFormsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
现在我们已经在应用的AppModule文件中添加了对ReactiveFormsModule的引用,我们可以实现承载实际表单的 Angular 组件。
城市编辑组件
由于我们的CityEditComponent旨在允许我们的用户修改一个城市,我们需要让它知道它必须从服务器获取(并发送到)哪个城市。最好的方法是使用GET参数,例如城市id。
因此,我们将实现一个标准的主控/细节UI 模式,非常类似于下面的模式:

听起来像个计划:让我们做吧!
在WorldCities项目的解决方案浏览器中,执行以下操作:
- 导航到
/ClientApp/src/app/cities文件夹。 - 右键单击文件夹名称并选择“添加|新项目”三次以创建以下文件:
city-edit.component.tscity-edit.component.htmlcity-edit.component.css
根据第 3 章、前端和后端交互中的内容,我们知道我们在这里做什么:我们正在创建一个新的 Angular 组件。
**# city-edit.component.ts
完成后,打开三个新(空)文件并用以下代码填充它们。
以下是/ClientApp/src/app/cities/city-edit.component.ts文件的源代码:
import { Component, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl } from '@angular/forms';
import { City } from './City';
@Component({
selector: 'app-city-edit',
templateUrl: './city-edit.component.html',
styleUrls: ['./city-edit.component.css']
})
export class CityEditComponent {
// the view title
title: string;
// the form model
form: FormGroup;
// the city object to edit
city: City;
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.form = new FormGroup({
name: new FormControl(''),
lat: new FormControl(''),
lon: new FormControl('')
});
}
loadData() {
// retrieve the ID from the 'id' parameter
var id = +this.activatedRoute.snapshot.paramMap.get('id');
// fetch the city from the server
var url = this.baseUrl + "api/cities/" + id;
this.http.get<City>(url).subscribe(result => {
this.city = result;
this.title = "Edit - " + this.city.name;
// update the form with the city value
this.form.patchValue(this.city);
}, error => console.error(error));
}
onSubmit() {
var city = this.city;
city.name = this.form.get("name").value;
city.lat = +this.form.get("lat").value;
city.lon = +this.form.get("lon").value;
var url = this.baseUrl + "api/cities/" + this.city.id;
this.http
.put<City>(url, city)
.subscribe(result => {
console.log("City " + city.id + " has been updated.");
// go back to cities view
this.router.navigate(['/cities']);
}, error => console.log(error));
}
}
}
这是相当多的源代码:幸运的是,有很多注释可以帮助我们理解每个相关步骤的目的。
让我们来总结一下我们在这里所做的工作:
- 我们添加了一些
import对我们将要在这个类中使用的模块的引用:在这些模块中,我们可以看到一些新的模块:@angular/router和@angular/form。前者需要定义一些内部路由模式,而后者包含构建表单所需的FormGroup和FormControl类。 - 在类定义的正下方,我们在一个
form变量中创建了一个FormGroup实例:这是我们的表单模型。 form变量实例包含三个FormControl对象,它们将存储我们希望允许用户更改的城市值:name、lat和lon。我们不想让他们改变Id或CountryId——至少现在不想。- 在
form变量的正下方,我们定义了一个city变量,当我们从数据库检索时,它将承载实际的城市。 - 城市检索任务是通过
loadData()方法处理的,这与我们在cities.component.ts文件中实现的方法非常相似:由constructor()注入的HttpClient模块处理的标准数据获取任务。这里最相关的区别是,该方法在 HTTP 请求/响应周期之后,在表单模型中主动加载检索到的城市数据(使用表单的patchValue()方法)不要依赖 Angular 数据绑定特性:这并不奇怪,因为我们使用的是模型驱动/反应式方法,而不是模板驱动的方法。 onSubmit()方法是发生更新魔法的地方:HttpClient在这里也起着主要作用,它向服务器发出 PUT 请求,正确地发送city变量。一旦处理了可观察的订阅,我们使用router实例将用户重定向回CitiesComponent(主视图)。
The patchValue() method that we used previously is one of a few more words. The @angular/forms package gives us two ways to update a Reactive Form's model: the setValue() method, which sets a new value for each individual control, and the patchValue() method, which will replace any properties that have been defined in the object that have changed in the form model. The main difference between them is that setValue() performs a strict check of the source object and will throw errors if it doesn't fully adhere to the model structure (including all nested FormControl elements), while patchValue() will silently fail on those errors. Therefore, we can say that the former method might be a better choice for complex forms and/or whenever we need to catch nesting errors, while the latter is the way to go when things are simple enough – like in our current samples.
@angular/router包值得特别提及,因为这是我们第一次在组件 TypeScript 文件中看到它:我们以前只使用过两次:
- 在
app.module.ts文件中,定义我们的客户端路由规则 - 在
nav.component.html文件中,实现上述路由规则,并使其显示为 web 应用主菜单中的导航链接
这一次,我们必须import它,因为我们需要一种从 URL 检索城市参数的方法。为此,我们使用了ActivatedRoute接口,它允许我们检索关于当前活动路由的信息,以及我们正在寻找的 GET 参数。
city-edit.component.html
以下是/ClientApp/src/app/cities/city-edit.component.html模板文件的内容:
<div class="city-edit">
<h1>{{title}}</h1>
<p *ngIf="!city"><em>Loading...</em></p>
<div class="form" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<!--
<div class="form-group" [ngClass]="{ 'has-error has-
feedback' : hasError('name') }">
-->
<label for="name">City name:</label>
<br />
<input type="text" id="name"
formControlName="name" required
placeholder="City name..."
class="form-control" />
<!--
<span *ngIf="hasError('name')"
class="glyphicon glyphicon-remove form-control-
feedback"
aria-hidden="true"></span>
<div *ngIf="hasError('name')"
class="help-block">
Name is a required field: please insert a valid name.
</div>
-->
</div>
<div class="form-group">
<!--
<div class="form-group" [ngClass]="{ 'has-error has-
feedback' : hasError('name') }">
-->
<label for="lat">City latitude:</label>
<br />
<input type="text" id="lat"
formControlName="lat" required
placeholder="Latitude..."
class="form-control" />
<!--
<span *ngIf="hasError('lat')"
class="glyphicon glyphicon-remove form-control-
feedback"
aria-hidden="true"></span>
<div *ngIf="hasError('lat')"
class="help-block">
Latitude is a required field: please insert a valid
latitude value.
</div>
-->
</div>
<div class="form-group">
<!--
<div class="form-group" [ngClass]="{ 'has-error has-
feedback' : hasError('name') }">
-->
<label for="lon">City longitude:</label>
<br />
<input type="text" id="lon"
formControlName="lon" required
placeholder="Latitude..."
class="form-control" />
<!--
<span *ngIf="hasError('lon')"
class="glyphicon glyphicon-remove form-control-
feedback"
aria-hidden="true"></span>
<div *ngIf="hasError('lon')"
class="help-block">
Longitude is a required field: please insert a valid
longitude value.
</div>
-->
</div>
<div class="form-group commands">
<button type="submit"
(click)="onSubmit()"
class="btn btn-success">
Create City
</button>
<button type="submit"
[routerLink]="['/countries']"
class="btn btn-default">
Cancel
</button>
</div>
</div>
</div>
等等:我们的<form>HTML 元素在哪里?我们不是说过我们使用基于表单的方法是因为它们比在这里和那里放置一堆单独的<input>字段要好得多吗?
事实上,我们确实有一种形式:我们只是使用了<div>而不是经典的<form>元素。正如您可能已经猜到的,Angular 中的表单不一定必须使用<form>HTML 元素创建,因为我们不会使用其独特的功能:正是出于这个原因,我们可以使用<div>、<p>或任何可能合理包含<input>字段的 HTML 块级元素来自由定义它们。
city-edit.component.css
最后但并非最不重要的是,以下是我们的/ClientApp/src/app/cities/city-edit.component.css内容:
/* empty */
是的,就是这样:我们现在不需要特定的样式,所以我们就让它空着吧。
添加导航链接
现在我们的CityEditComponent已经准备好了,我们需要通过添加一个导航链接来加强我们的主/详细模式,该链接允许我们的用户从我们的城市列表(主)导航到城市编辑表单(详细)。
为此,我们需要执行两项任务:
- 在
app.module.ts文件中创建新路由。 - 在
CitiesComponent的模板代码中执行前面的路由。
让我们这样做!
app.module.ts
打开/ClientApp/src/app/app.module.ts文件,用以下源代码定义新路由(新行高亮显示):
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
import { CitiesComponent } from './cities/cities.component';
import { CityEditComponent } from './cities/city-edit.component';
import { CountriesComponent } from './countries/countries.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AngularMaterialModule } from './angular-material.module';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
CitiesComponent,
CityEditComponent,
CountriesComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'cities', component: CitiesComponent },
{ path: 'city/:id', component: CityEditComponent },
{ path: 'countries', component: CountriesComponent },
]),
BrowserAnimationsModule,
AngularMaterialModule,
ReactiveFormsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
如我们所见,我们导入了CityEditComponent,将其添加到@NgModule声明列表中,最后但并非最不重要的是,定义了一个与路由对应的新city/:id。我们使用的语法将路由由city和一个将注册为id名称的参数组成的任何 URL。
cities.component.html
现在我们已经有了导航路线,我们需要在主视图中实现它,以便可以到达细节视图。
打开/ClientApp/src/app/cities/cities.component.html文件,按以下方式更改城市Name栏的 HTML 模板代码:
<!-- ...existing code... -->
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let city">
<a [routerLink]="['/city', city.id]">{{city.name}}</a>
</td>
</ng-container>
<!-- ...existing code... -->
完成后,点击F5并导航到城市视图进行测试。如以下屏幕截图所示,城市名称现在是可单击的链接:

从那里,过滤表单中的Paris,点击第一个结果访问CityEditComponent,我们最终可以看到它(如下面的屏幕截图所示):

正如我们所看到的,一切都和我们预期的一样。我们有三个文本框,一个保存按钮和一个取消按钮,它们都准备好执行分配给它们的任务。前者将修改后的文本发送到服务器进行更新,然后将用户重定向到主视图,而后者将在不进行任何更改的情况下重定向用户。
这绝对是一个好的开始!然而,我们还远远没有完成:我们仍然需要添加验证器,实现错误处理,并为客户端和服务器端编写几个单元测试。让我们开始吧。
添加一个新城市
在继续之前,让我们再花几分钟为我们的CityEditComponent添加一个非常有用的功能:添加一个全新City的机会。这是具有编辑功能的细节视图的一个非常经典的要求,可以用同一个组件来处理,只要我们执行一些小的修改来处理两种可能的场景。
为此,我们必须执行以下步骤:
- 扩展我们的 CityEdit 组件的功能,使其能够添加新城市,以及编辑现有城市。
- 在我们组件的模板文件中添加新的添加城市按钮,并将其绑定到新的客户端路由。
- 实现所需的功能为新添加的城市选择国家,这在编辑模式下也很有用(它将允许用户更改现有城市的国家)。
让我们开始工作吧!
扩展 cityedit 组件
打开/ClientApp/src/app/cities/city-edit.component.ts文件并添加以下代码(新的/更新的行高亮显示):
import { Component, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl } from '@angular/forms';
import { City } from './City';
@Component({
selector: 'app-city-edit',
templateUrl: './city-edit.component.html',
styleUrls: ['./city-edit.component.css']
})
export class CityEditComponent {
// the view title
title: string;
// the form model
form: FormGroup;
// the city object to edit or create
city: City;
// the city object id, as fetched from the active route:
// It's NULL when we're adding a new city,
// and not NULL when we're editing an existing one.
id?: number;
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.form = new FormGroup({
name: new FormControl(''),
lat: new FormControl(''),
lon: new FormControl('')
});
this.loadData();
}
loadData() {
// retrieve the ID from the 'id'
this.id = +this.activatedRoute.snapshot.paramMap.get('id');
if (this.id) {
// EDIT MODE
// fetch the city from the server
var url = this.baseUrl + "api/cities/" + this.id;
this.http.get<City>(url).subscribe(result => {
this.city = result;
this.title = "Edit - " + this.city.name;
// update the form with the city value
this.form.patchValue(this.city);
}, error => console.error(error));
}
else {
// ADD NEW MODE
this.title = "Create a new City";
}
}
onSubmit() {
var city = (this.id) ? this.city : <City>{};
city.name = this.form.get("name").value;
city.lat = +this.form.get("lat").value;
city.lon = +this.form.get("lon").value;
if (this.id) {
// EDIT mode
var url = this.baseUrl + "api/cities/" + this.city.id;
this.http
.put<City>(url, city)
.subscribe(result => {
console.log("City " + city.id + " has been updated.");
// go back to cities view
this.router.navigate(['/cities']);
}, error => console.log(error));
}
else {
// ADD NEW mode
var url = this.baseUrl + "api/cities";
this.http
.post<City>(url, city)
.subscribe(result => {
console.log("City " + result.id + " has been created.");
// go back to cities view
this.router.navigate(['/cities']);
}, error => console.log(error));
}
}
}
HTML 模板文件还可以执行小更新,以通知用户新特性。
打开/ClientApp/src/app/cities/cities.component.html文件,按以下方式修改(新的/更新的行高亮显示)。
在文件开头附近添加以下突出显示的代码:
<!-- ... existing code ... -->
<p *ngIf="this.id && !city"><em>Loading...</em></p>
<!-- ... existing code ... -->
通过这样的改进,我们将确保在添加新城市时不会出现"Loading..."消息,因为city变量将为空。
另外,在文件末尾添加以下突出显示的代码:
<!-- ... existing code ... -->
<div class="form-group commands">
<button *ngIf="id" type="submit"
(click)="onSubmit()"
class="btn btn-success">
Save
</button>
<button *ngIf="!id" type="submit"
(click)="onSubmit()"
class="btn btn-success">
Create
</button>
<button type="submit"
[routerLink]="['/cities']"
class="btn btn-default">
Cancel
</button>
</div>
<!-- ... existing code ... -->
这个小而有用的添加将让我们知道表单是否按预期工作:每当我们添加一个新城市时,我们将看到一个更合适的创建按钮,而不是保存按钮,该按钮在编辑模式下仍然可见。
现在,我们需要做两件事:
- 找到一个好方法,让我们的用户知道他们可以添加新城市,也可以修改现有城市。
- 使他们能够访问此新功能。
一个简单的“添加新城市”按钮将同时解决这两个问题:让我们将其添加到我们的CitiesComponent中。
添加“添加新城市”按钮
打开/ClientApp/src/app/cities/cities.component.html文件,添加以下代码:
<!-- ... existing code ... -->
<h1>Cities</h1>
<p>Here's a list of cities: feel free to play with it.</p>
<p *ngIf="!cities"><em>Loading...</em></p>
<div class="commands text-right" *ngIf="cities">
<button type="button"
[routerLink]="['/city']"
class="btn btn-success">
Add a new City
</button>
</div>
<!-- ... existing code ... -->
我们开始吧。这里没有什么新鲜事;我们在容器中添加了通常基于路线的按钮和*ngIf结构指令,使其在城市阵列可用后出现。
添加新路线
现在,我们需要定义“添加新城市”按钮所引用的新路线。
为此,打开/ClientApp/src/app/app.module.ts文件并更新代码,如下所示:
// ...existing code...
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'cities', component: CitiesComponent },
{ path: 'city/:id', component: CityEditComponent },
{ path: 'city', component: CityEditComponent },
{ path: 'countries', component: CountriesComponent },
]),
// ...existing code...
我们可以看到,添加新城市的(新)路线和编辑现有城市的(现有)路线非常相似,因为它们都将用户重定向到同一组件:唯一的区别是后者没有id参数,这是我们用来让组件知道它被调用的任务的技术。如果id存在,则表示用户正在编辑现有城市;否则,他们会添加一个新的。
我们做得很好。。。但我们还没有完全做到。如果我们要通过点击F5并尝试添加一个新城市来测试我们迄今为止所做的工作,我们的HttpClient模块将收到来自服务器的 HTTP 500-内部服务器错误,类似于以下屏幕截图中所示的错误:

以下是完整的错误文本(突出显示相关部分):
---> Microsoft.Data.SqlClient.SqlException (0x80131904): The INSERT statement conflicted with the FOREIGN KEY constraint "FK_Cities_Countries_CountryId". The conflict occurred in database "WorldCities", table "dbo.Countries", column 'Id'.The statement has been terminated.
显然,我们似乎忘记了City实体的CountryId属性:当我们必须定义 Angular city 接口时,我们是故意这么做的,因为当时我们不需要它。当我们实现 city edit 模式时,我们没有遇到缺少该属性的问题,因为该属性是从服务器以静默方式获取的,然后存储在我们的 Angular 局部变量中,当 HTTPPUT请求执行更新时,我们将该变量发送回服务器。然而,现在我们确实想从头开始创建一个新的城市,这样一个缺失的财产最终将付出代价。
为了解决这个问题,我们需要通过以下方式将CountryId属性添加到/ClientApp/src/app/cities/city.ts文件中(新行高亮显示):
export interface City {
id: number;
name: string;
lat: number;
lon: number;
countryId: number;
}
然而,这还不够:我们还需要给我们的用户分配一个特定的Country给新城市的机会;否则,countryId属性将永远看不到实际值——除非我们用固定值以编程方式定义它,这将是一个相当难看的解决方法(至少可以说)。
让我们通过在CityEditComponent中添加一个国家列表来解决这个问题,这样用户可以在点击Create按钮之前选择一个国家。这样一个新功能将非常有用——即使组件在编辑模式下运行——因为它将允许我们的用户更改现有城市的国家。
HTML 选择
允许我们的用户从国家列表中选择一个国家的最简单方法是使用<select>元素,并通过 CountriesController 的GetCountries()方法从.NET后端获取我们的数据来填充它。我们现在就开始吧。
打开/ClientApp/src/app/cities/city-edit.component.ts文件并添加以下代码(新行高亮显示):
import { Component, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl } from '@angular/forms';
import { City } from './City';
import { Country } from './../countries/Country';
@Component({
selector: 'app-city-edit',
templateUrl: './city-edit.component.html',
styleUrls: ['./city-edit.component.css']
})
export class CityEditComponent {
// the view title
title: string;
// the form model
form: FormGroup;
// the city object to edit or create
city: City;
// the city object id, as fetched from the active route:
// It's NULL when we're adding a new city,
// and not NULL when we're editing an existing one.
id?: number;
// the countries array for the select
countries: Country[];
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.form = new FormGroup({
name: new FormControl(''),
lat: new FormControl(''),
lon: new FormControl(''),
countryId: new FormControl('')
});
this.loadData();
}
loadData() {
// load countries
this.loadCountries();
// retrieve the ID from the 'id'
this.id = +this.activatedRoute.snapshot.paramMap.get('id');
if (this.id) {
// EDIT MODE
// fetch the city from the server
var url = this.baseUrl + "api/cities/" + this.id;
this.http.get<City>(url).subscribe(result => {
this.city = result;
this.title = "Edit - " + this.city.name;
// update the form with the city value
this.form.patchValue(this.city);
}, error => console.error(error));
}
else {
// ADD NEW MODE
this.title = "Create a new City";
}
}
loadCountries() {
// fetch all the countries from the server
var url = this.baseUrl + "api/countries";
var params = new HttpParams()
.set("pageSize", "9999")
.set("sortColumn", "name");
this.http.get<any>(url, { params }).subscribe(result => {
this.countries = result.data;
}, error => console.error(error));
}
onSubmit() {
var city = (this.id) ? this.city : <City>{};
city.name = this.form.get("name").value;
city.lat = +this.form.get("lat").value;
city.lon = +this.form.get("lon").value;
city.countryId = +this.form.get("countryId").value;
if (this.id) {
// EDIT mode
var url = this.baseUrl + "api/cities/" + this.city.id;
this.http
.put<City>(url, city)
.subscribe(result => {
console.log("City " + city.id + " has been updated.");
// go back to cities view
this.router.navigate(['/cities']);
}, error => console.log(error));
}
else {
// ADD NEW mode
var url = this.baseUrl + "api/cities";
this.http
.post<City>(url, city)
.subscribe(result => {
console.log("City " + result.id + " has been created.");
// go back to cities view
this.router.navigate(['/cities']);
}, error => console.log(error));
}
}
}
我们在这里做了什么?
- 我们将
HttpParams模块添加到@angular/common/http的import列表中。 - 我们在
Country接口中添加了一个引用,因为我们还需要处理国家/地区。 - 我们添加了一个
countries变量来存储我们的国家。 - 我们在表单中添加了一个
countryId表单控件(带有必需的验证器,因为它是必需的值)。 - 我们添加了一个
loadCountries()方法来从服务器获取国家。 - 我们从
loadData()方法中添加了对loadCountries()方法的调用,以便在执行loadData()其余工作(例如加载城市和/或设置表单)时异步获取国家。 - 我们更新了城市的
countryId,使其与onSubmit()方法中表单中选择的内容相匹配,以便将其发送到服务器以执行插入或更新任务。
It's worth noting how, in the loadCountries() method, we had to set up some GET parameters for the /api/countries URL to comply with the strict default values that we set in Chapter 5, Fetching and Displaying Data: we don't need paging here since we need to fetch the entire countries list to populate our select list. More specifically, we set a pageSize of 9999 to ensure that we get all our countries, as well as an appropriate sortColumn to have them ordered by their name.
现在,我们可以在 HTML 模板上使用全新的countries变量。
打开/ClientApp/src/app/cities/city-edit.component.html文件并添加以下代码(新行高亮显示):
<!-- ...existing code... -->
<div class="form-group">
<label for="lon">City longitude:</label>
<br />
<input type="text" id="lon"
formControlName="lon" required
placeholder="Latitude..."
class="form-control" />
</div>
<div class="form-group" *ngIf="countries">
<label for="lon">Country:</label>
<br />
<select id="countryId" class="form-control"
formControlName="countryId">
<option value="">--- Select a country ---</option>
<option *ngFor="let country of countries" [value]="country.id">
{{country.name}}
</option>
</select>
</div>
<!-- ...existing code... -->
如果我们按F5来测试代码并导航到添加新城市或编辑城市视图,我们将看到以下输出:

现在,通过点击---选择一个国家---选择列表,我们的用户将能够从可用的国家中选择一个国家。这还不错,对吧?
然而,我们可以做得更好:我们可以通过使用Angle Material包库MatSelectModule中更强大的组件替换标准 HTMLselect来改善视图的用户体验。
Angular 材质选择(模块)
因为我们以前从未使用过MatSelectModule,所以我们需要将其添加到/ClientApp/src/app/angular-material.component.ts文件中,就像我们在第 5 章获取和显示数据中对MatPaginatorModule、MatSortModule和MatInputModule所做的一样。
以下是如何做到这一点(新行突出显示):
import { NgModule } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
@NgModule({
imports: [
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatInputModule,
MatSelectModule
],
exports: [
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatInputModule,
MatSelectModule
]
})
export class AngularMaterialModule { }
紧接着,我们可以用以下方式替换刚才添加到/ClientApp/src/app/cities/city-edit.component.ts文件中的<select>HTML 元素(更新的行高亮显示):
<!-- ...existing code... -->
<div class="form-group">
<label for="lon">Country:</label>
<br />
<mat-form-field *ngIf="countries">
<mat-label>Select a Country...</mat-label>
<mat-select id="countryId" formControlName="countryId">
<mat-option *ngFor="let country of countries"
[value]="country.id">
{{country.name}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- ...existing code... -->
就这样!点击F5可以看到更新结果(输出见以下截图):

MatSelectModule肯定比 stock<select>HTML 元素更漂亮,同时保留了相同的特性:我们甚至不需要更改底层Component类文件,因为它使用相同的绑定接口。
现在,我们可以将我们的全新城市添加到我们的数据库中。让我们使用以下数据执行此操作:
- 名称:
New Tokyo - 纬度:
35.685 - 经度:
139.7514 - 国家:
Japan
用这些值填写我们的Create a new City表单,然后单击“创建”按钮。如果一切顺利,我们应该回到城市视图,在那里我们可以使用过滤器找到我们的New Tokyo城市(见以下截图):

我们来了:我们成功地添加了我们的第一个城市!
现在,我们的反应式表单工作正常,我们对它的工作方式有了相当好的了解,我们准备花一些时间对它进行调整,添加一些在生产场景中可能非常有用的功能:一些错误处理功能。我们将通过添加一些数据验证器来获得这些数据。
理解数据验证
向表单中添加数据验证几乎不是一个选项:它是检查用户输入的准确性和完整性的必需功能,通过验证我们想要或需要收集的数据来提高总体数据质量。它在用户体验方面也非常有用,因为它附带的错误处理功能将使我们的用户能够理解为什么表单不工作,以及他们可以做些什么来解决阻止他们提交数据的问题。
为了理解这样一个概念,让我们以我们当前的CityEditComponent反应式表单为例:如果我们的用户填写了所有必需的字段,那么它工作得很好;然而,他们无法理解所需的值实际上是什么,或者如果他们忘记填写所有值会发生什么。。。除了一条console.log错误消息,这是当我们的 PUT 和 POST 请求最终出现任何类型的后端错误时,我们的源代码当前正在执行的操作。
在本节中,我们将学习如何从前端UI 验证用户输入,并使用当前的反应式表单显示有用的验证消息。在这里,我们还将借此机会创建一个Edit Country/Add new Country表单,并在此过程中学习一些新的东西。
模板驱动验证
为了简单起见,我们选择不使用模板驱动的表单,而是将重点放在模型驱动/反应式表单上。然而,花几分钟时间了解如何将验证添加到模板驱动的表单中可能是明智的。
好消息是,我们可以使用通常用于验证原生 HTML 表单的相同标准验证属性:Angular 框架使用指令在内部以完全透明的方式将它们与验证器函数匹配。更具体地说,每当表单控件的值发生更改时,Angular 将运行这些函数并生成验证错误列表,从而导致无效状态,或 null,这意味着表单是有效的。
可以通过将ngModel导出到本地模板变量来检查/检查表单的状态以及每个表单控件的状态。以下是一个有助于澄清这一点的示例:
<input id="name" name="name" class="form-control" required minlength="4"
[(ngModel)]="city.name" #name="ngModel">
<div *ngIf="name.invalid && (name.dirty || name.touched)" class="alert alert-danger">
<div *ngIf="name.errors?.required">Name is required.</div>
<div *ngIf="name.errors?.minlength">Name must be at least 4
characters long.</div>
</div>
数据验证指令以粗体突出显示。如我们所见,只要城市的name不存在或其字符数小于 4,因为这是名称输入允许的最小长度,前面的表单就会引发错误,并向用户显示带有警报样式的<div>元素。
值得注意的是,我们正在检查两个听起来很奇怪的属性:name.dirty和name.touched。下面简要解释一下他们的意思以及为什么检查他们的状态是明智的:
dirty属性开始时为 false,当用户更改其起始值时变为 true。touched属性开始时为 false,当用户模糊表单控件元素时变为 true,也就是说,它在聚焦后离开表单控件元素。
现在我们知道了这些属性是如何工作的,我们应该能够理解为什么要检查它们:我们希望只有当用户离开控件时,才能看到数据验证器错误,留下无效值,或者根本没有值。
That's it for Template-Driven validation, at least for the purpose of this book. Those who need additional information should check out the following guide at https://angular.io/guide/forms#template-driven-forms.
安全导航操作员
在继续之前,花几分钟时间解释我们在需要检查是否存在表单错误时一直使用的?问号的含义可能会很有用,例如在下面的示例中,该示例取自前面的代码:
name.errors?.required
这样的问号是 Angular 的安全导航操作符,也称为Elvis 操作符,对于防止属性路径中出现空值和未定义的值非常有用。当安全导航操作符存在时,Angular 在遇到第一个null值时停止计算表达式。在前面的代码中,如果name.errors是null,则整个表达式将返回 false 而不检查required属性,从而避免了以下空引用异常:
TypeError: Cannot read property 'required' of null.
事实上,安全导航操作符通过返回对象路径的值(如果存在)或 null,使我们能够导航对象路径,即使我们不知道该路径是否存在。如果我们希望检查 Angular 形式中是否存在有条件的错误,则这种行为是完美的,null返回值与false(=无错误)具有相同的含义。正是因为这个原因,从现在起,我们将大量使用它。
有关安全导航操作员的更多信息,请查看以下网址https://angular.io/guide/template-syntax#safe-导航操作员。
模型驱动验证
在处理反应式表单时,整个验证方法是完全不同的。简而言之,我们可以说这项工作的大部分都必须在组件类中完成:我们不需要在模板中使用 HTML 属性添加验证器,而是必须将验证器函数直接添加到组件类中的表单控件模型中,以便 Angular 能够在控件值发生更改时调用它们。
由于我们主要处理函数,我们还可以选择使它们同步或异步,从而有机会添加同步和/或异步验证器:
- 同步验证程序立即返回一组验证错误或
null。当我们实例化需要检查的FormControl时,可以使用第二个参数设置它们(第一个参数是默认值)。 - 异步验证器返回一个承诺或可观察的,该承诺已配置为发出一组验证错误或
null。当我们实例化需要检查的FormControl时,可以使用第三个参数设置它们
It's important to know that async validators will only be executed/checked after the sync validators, and only if all of them successfully pass. Such an architectural choice has been made for performance reasons.
在接下来的部分中,我们将创建它们并将它们添加到表单中。
我们的第一批验证器
理论已经足够了:让我们以 CityEdit 组件的形式添加第一组验证器。
打开/ClientApp/src/app/cities/city-edit.component.ts文件,添加以下代码:
import { Component, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { City } from './City';
import { Country } from './../countries/Country';
@Component({
selector: 'app-city-edit',
templateUrl: './city-edit.component.html',
styleUrls: ['./city-edit.component.css']
})
export class CityEditComponent {
// the view title
title: string;
// the form model
form: FormGroup;
// the city object to edit or create
city: City;
// the city object id, as fetched from the active route:
// It's NULL when we're adding a new city,
// and not NULL when we're editing an existing one.
id?: number;
// the countries array for the select
countries: Country[];
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
}
ngOnInit() {
this.form = new FormGroup({
name: new FormControl('', Validators.required),
lat: new FormControl('', Validators.required),
lon: new FormControl('', Validators.required),
countryId: new FormControl('', Validators.required)
}, null, this.isDupeCity());
this.loadData();
}
// ...existing code...
如我们所见,我们添加了以下内容:
- 从
@angular/forms包导入对Validators类的引用。 - A
Validators.required到我们的FormControl元素。顾名思义,这样的验证器期望这些字段的值为非空;否则返回invalid状态。
Validators.required is a built-in sync validator among those available from the Validators class. Other built-in validators provided by this class include min, max, requiredTrue, email, minLength, maxLength, pattern, nullValidator, compose, and composeAsync.
For more information regarding Angular's built-in validators, take a look at the following URL at https://angular.io/api/forms/Validators.
完成后,打开/ClientApp/src/app/cities/city-edit.component.html文件并添加以下代码:
<div class="city-edit">
<h1>{{title}}</h1>
<p *ngIf="this.id && !city"><em>Loading...</em></p>
<div class="form" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">City name:</label>
<br />
<input type="text" id="name"
formControlName="name" required
placeholder="City name..."
class="form-control"
/>
<div *ngIf="form.get('name').invalid &&
(form.get('name').dirty || form.get('name').touched)"
class="invalid-feedback">
<div *ngIf="form.get('name').errors?.required">
Name is required.
</div>
</div>
</div>
<div class="form-group">
<label for="lat">City latitude:</label>
<br />
<input type="text" id="lat"
formControlName="lat" required
placeholder="Latitude..."
class="form-control" />
<div *ngIf="form.get('lat').invalid &&
(form.get('lat').dirty || form.get('lat').touched)"
class="invalid-feedback">
<div *ngIf="form.get('lat').errors?.required">
Latitude is required.
</div>
</div>
</div>
<div class="form-group">
<label for="lon">City longitude:</label>
<br />
<input type="text" id="lon"
formControlName="lon" required
placeholder="Latitude..."
class="form-control" />
<div *ngIf="form.get('lon').invalid &&
(form.get('lon').dirty || form.get('lon').touched)"
class="invalid-feedback">
<div *ngIf="form.get('lon').errors?.required">
Longitude is required.
</div>
</div>
</div>
<div class="form-group">
<label for="lon">Country:</label>
<br />
<mat-form-field *ngIf="countries">
<mat-label>Select a Country...</mat-label>
<mat-select id="countryId" formControlName="countryId">
<mat-option *ngFor="let country of countries"
[value]="country.id">
{{country.name}}
</mat-option>
</mat-select>
</mat-form-field>
<div *ngIf="form.get('countryId').invalid &&
(form.get('countryId').dirty ||
form.get('countryId').touched)"
class="invalid-feedback">
<div *ngIf="form.get('countryId').errors?.required">
Please select a Country.
</div>
</div>
</div>
<div class="form-group commands">
<button *ngIf="id" type="submit"
(click)="onSubmit()"
[disabled]="form.invalid"
class="btn btn-success">
Save
</button>
<button *ngIf="!id" type="submit"
(click)="onSubmit()"
[disabled]="form.invalid"
class="btn btn-success">
Create
</button>
<button type="submit"
[routerLink]="['/cities']"
class="btn btn-default">
Cancel
</button>
</div>
</div>
</div>
在这里,我们添加了四个<div>元素(每个输入一个)来检查输入值并有条件地返回错误。正如我们所看到的,这些验证器都以相同的方式工作:
- 第一个
<div>(父项)检查FormControl是否有效。只有当它无效、脏或被触摸时才会显示,直到用户有机会设置它时才会显示。 - 第二个
<div>(子项)检查所需的验证器。
我们使用这种方法是因为我们可以为每个FormControl使用多个验证器。因此,为它们中的每一个都有一个单独的子元素和一个包含它们的单个父元素是很有用的(invalid被设置为true,只要任何配置的验证器没有通过)。
在这里,我们添加了一个绑定到Create和Save按钮的[disabled]属性,以便在窗体具有无效状态时有条件地禁用它们。这是防止用户提交错误或无效值的好方法。
然后打开/ClientApp/src/app/cities/city-edit.component.css文件,添加以下代码:
input.ng-valid {
border-left: 5px solid green;
}
input.ng-invalid.ng-dirty,
input.ng-invalid.ng-touched {
border-left: 5px solid red;
}
input.ng-valid ~ .valid-feedback,
input.ng-invalid ~ .invalid-feedback {
display: block;
}
这些简单而强大的样式利用现有的 Angular 和 Bootstrap CSS 类,以便在输入字段处于有效或无效状态时对其进行修饰。
让我们快速检查到目前为止所做的一切:点击F5,导航到城市视图,点击Add a new City按钮,在尝试触发验证程序的同时玩表单。
下面是我们在不键入任何内容的情况下循环各种输入值时发生的情况:

不错吧?输入错误再明显不过了,创建按钮将一直处于禁用状态,直到它们全部修复,从而防止意外提交。所有这些彩色警告都应该帮助我们的用户了解他们做错了什么,并解决这些问题。
在结束数据验证之旅之前,我们还需要讨论一个主题:服务器端验证,这通常是防止某些复杂错误的唯一合理方法。
服务器端验证
服务器端验证是在服务器端检查错误(并相应地处理错误)的过程,即在数据发送到后端之后。这是客户端验证的一种完全不同的方法,其中前端正在检查数据,也就是说,在数据发送到服务器之前。
在客户端上处理错误在速度和性能上都有很多优势,因为用户无需查询服务器即可立即知道输入数据是否有效。然而,服务器端验证是任何体面的 web 应用所必需的功能,因为它可以防止许多潜在的有害场景,例如:
- 客户端验证过程的实现错误,可能无法阻止格式错误的数据
- 由经验丰富的用户、浏览器扩展或插件执行的客户端黑客可能希望允许用户向后端发送不受支持的输入值
- 请求伪造,即包含错误或恶意数据的虚假 HTTP 请求
所有这些技术都是基于绕过客户端验证程序的基础上的,这总是可能的,因为我们无法阻止我们的用户(或黑客)跳过、修改或删除它们;相反,服务器端验证程序无法避免,因为它们将由处理输入数据的相同后端执行。
因此,简而言之,我们可以合理地说,客户端验证是一个可选且方便的功能,而服务器端验证验证是任何关心输入数据质量的体面 web 应用的一个要求。
To avoid confusion, it is important to understand that server-side validation, although being implemented on the back-end, also requires a front-end implementation, such as calling the back-end and then showing the validation results to the user. The main difference between client-side validation and server-side validation* is that the former only exists on the client-side and never calls the back-end, while the latter relies upon a front-end + back-end coordinated effort, thus being more complex to implement and test.
此外,在某些情况下,服务器端验证是检查某些条件或要求的唯一可能方法,而这些条件或要求不能仅通过客户端验证进行验证。为了解释这个概念,让我们看一个简单的例子。
点击F5在d和ebug模式下启动我们的WorldCities应用,进入我们的城市视图,在过滤文本框中输入paris。
您应该看到以下输出:

前面的屏幕截图告诉我们以下内容:
- 全世界至少有五座被称为
Paris的城市。(!) - 多个城市可以有相同的名称。
这并不奇怪:当我们首先使用带有代码的实体框架创建数据库时,我们没有使name字段唯一,因为我们知道同名城市的可能性很高。幸运的是,这不是一个问题,因为我们仍然可以通过查看lat、lon和country值来区分它们。
For example, if we check the first three on Google Maps, we will see that the first one is in France, the second is in Texas (US), and the third is in Tennessee (US). Same name, different cities.
现在,添加一个验证器怎么样?它可以检查我们试图添加的城市是否与我们数据库中已经存在的城市具有相同的name、lat和lon值?这样的功能将阻止我们的用户多次插入相同的城市,从而避免真正的重复,而不会阻止具有不同坐标的同名词。
不幸的是,仅在客户端上无法做到这一点。为了完成这个任务,我们需要创建一个 Angular自定义**验证器,它可以异步检查服务器上的这些值,然后返回OK(有效或 KO(无效)结果:换句话说,是一个服务器端验证任务。
让我们现在就试着去做吧。
双重验证器
在本节中,我们将创建一个自定义验证器,该验证器将对我们的.NET Core后端执行异步调用,以确保我们尝试添加的城市与现有城市name、lat、lon和country不相同。
city-edit.component.ts
我们要做的第一件事是创建验证器本身并将其绑定到我们的反应式表单。为此,打开/ClientApp/src/app/cities/city-edit.component.ts文件并相应更改其内容(新的/更新的行突出显示):
import { Component, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl, Validators, AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { City } from './City';
import { Country } from './../countries/Country';
// ...existing code...
ngOnInit() {
this.form = new FormGroup({
name: new FormControl('', Validators.required),
lat: new FormControl('', Validators.required),
lon: new FormControl('', Validators.required),
countryId: new FormControl('', Validators.required)
}, null, this.isDupeCity());
this.loadData();
}
// ...existing code...
isDupeCity(): AsyncValidatorFn {
return (control: AbstractControl): Observable<{ [key: string]: any } | null> => {
var city = <City>{};
city.id = (this.id) ? this.id : 0;
city.name = this.form.get("name").value;
city.lat = +this.form.get("lat").value;
city.lon = +this.form.get("lon").value;
city.countryId = +this.form.get("countryId").value;
var url = this.baseUrl + "api/cities/IsDupeCity";
return this.http.post<boolean>(url, city).pipe(map(result => {
return (result ? { isDupeCity: true } : null);
}));
}
}
}
正如我们所看到的,我们在前面的代码中做了一些重要的更改:
- 我们添加了一些用于实现新的异步自定义验证器的导入引用(
AbstractControl、AsyncValidatorFn、Observable和map。如果你没有得到我们所需要的,不要担心:我们稍后会讨论这个话题。 - 我们创建了一个新的
isDupeCity()方法,其中包含我们的异步自定义验证器的整个实现。 - 我们将新的验证器配置为供主
FormGroup(与整个表单相关的验证器)使用。
至于我们的自定义验证器,它似乎比实际复杂得多。让我们试着总结一下它的作用:
- 首先值得一提的是,该函数被定义为返回一个
Observable的AsyncValidatorFn:这意味着我们不返回一个值,而是返回一个最终将返回一个值的订户函数实例——它将是一个键/值对象或null。只有在执行可观察的时,才会发出。 - 内部函数创建一个临时
city对象,用实时表单数据填充它,调用一个我们还不知道的IsDupeCity后端URL(但我们很快就会知道),最终根据结果返回true或null。值得注意的是,我们这次不是像过去那样订阅HttpClient了:我们使用pipe和mapReactJS(RxJS操作符来操作它,我们将在稍后讨论。
For more information regarding custom async validators, read the following guide at https://angular.io/guide/form-validation#implementing-custom-async-validator.
由于我们的自定义验证器依赖于发送到.NET Core后端的 HTTP 请求,我们也需要实现该方法。
花旗控制器
打开/Controllers/CitiesController.cs文件,在文件底部添加以下方法:
// ...existing code...
private bool CityExists(int id)
{
return _context.Cities.Any(e => e.Id == id);
}
[HttpPost]
[Route("IsDupeCity")]
public bool IsDupeCity(City city)
{
return _context.Cities.Any(
e => e.Name == city.Name
&& e.Lat == city.Lat
&& e.Lon == city.Lon
&& e.CountryId == city.CountryId
&& e.Id != city.Id
);
} // ...existing code...
NET 方法非常简单:它检查与前端提供的Name、Lat、Lon和CountryId相同的City数据模型(以及不同的 Id),并分别返回true或false作为结果。添加了Id检查,当用户编辑现有城市时,有条件地禁用重复检查。如果是这样的话,使用相同的Name、Lat、Lon和CountryId将是允许的,因为我们基本上覆盖了同一个城市,而不是创建一个新的城市。当用户添加新城市时,Id值将始终设置为零,防止重复检查被禁用。
city-edit.component.html
既然后端代码已经准备好,我们需要从 UI 创建一条合适的错误消息。打开/ClientApp/src/app/cities/city-edit.component.html文件并按以下方式更新其内容(新行高亮显示):
<div class="city-edit">
<h1>{{title}}</h1>
<p *ngIf="this.id && !city"><em>Loading...</em></p>
<div class="form" [formGroup]="form" (ngSubmit)="onSubmit()">
<div *ngIf="form.invalid && form.errors?.isDupeCity"
class="alert alert-danger">
<strong>ERROR</strong>:
A city with the same <i>name</i>, <i>lat</i>,
<i>lon</i> and <i>country</i> already exists.
</div>
<!-- ...existing code... -->
如前代码所示,我们添加的警报<div>只有在表单无效时才会显示。存在与表单本身严格相关的错误,isDupeCity错误返回true;所有这些条件都需要满足,否则我们就有可能显示出这样的警报,即使不需要这样做。
测试它
现在组件 HTML 模板已经设置好,我们可以测试我们努力工作的结果:按F5,导航到城市视图,单击添加新城市按钮,插入以下值:
- 名称:
New Tokyo - 纬度:
35.685 - 经度:
139.7514 - 国家:
Japan
如果一切正常,您将收到以下错误消息:

太好了!我们定制的异步验证器工作正常,同时触发前端和后端验证逻辑。
可观测与 RxJS 算子
用于执行调用的异步逻辑广泛使用了可观察的/RxJS模式:不过,这次我们选择了pipe+map方法,而不是依赖于我们已经使用过多次的subscribe()方法。这是两个非常重要的RxJS操作符,允许我们执行数据操作任务,同时保留返回值的可观察状态,而订阅将执行可观察并返回实际数据。
这样的概念可能很难理解。换言之,我们试着说:
- 当我们想要执行可观测并得到其实际结果时,我们应该使用
subscribe()方法;例如,JSON 结构化响应。这种方法返回一个可以取消的订阅,但不能再订阅。 - 当我们想要在不执行的情况下转换/操作可观测的数据事件时,我们应该使用
map()操作符,以便它可以传递给其他异步参与者,这些参与者也将操作(并最终执行)它。这样的方法返回一个可观察到的可以订阅。
至于pipe(),它只是一个RxJS操作符,组成/链接其他操作符(如map、filter等)。
可观察的方法与 RxJS 操作符之间最重要的区别在于后者总是返回可观察的,而前者返回不同的(且大部分是最终的)对象类型。它响了吗?
**如果我们回想一下我们在章er 5获取和显示数据中学到的东西,在处理.NET实体框架时,它听起来肯定很熟悉。还记得我们玩IQueryable<T>界面的时候吗?我们在构建ApiResult类时使用的各种Where、OrderBy和CountAsync可量化方法与我们通过pipe操作符链接多个map函数在 Angular 中所做的非常相似。相反,subscribe()方法与我们在.NET 中用于执行IQueryable并在可用对象中检索其结果的各种ToListAsync()/ToArrayAsync()方法非常相似。
性能问题
在继续之前,让我们尝试回答以下问题:何时检查此验证器?换句话说,考虑到它在每次检查时执行服务器端API 调用,我们是否可以合理地预期性能问题?
如果我们回想前面所说的,异步验证器只有在所有同步验证器返回true时才会被检查。因为isDupeCity是异步,所以我们之前在所有FormControl元素中设置的Validators.required返回true后才会调用。这确实是一个好消息,因为检查一个name、lat、lon和/或countryId为空的现有城市是没有意义的。
根据我们刚才所说的,我们可以合理地预期每次表单提交都会调用isDupeCity验证器一次或两次,这在性能影响方面是非常好的。那么一切都好了。让我们继续。
介绍 FormBuilder
现在我们的CityEditComponent已经建立,我们可能会尝试重用相同的技术来创建CountryEditComponent并完成工作,就像我们在第章er 5中使用CitiesComponent和CountryComponent文件获取和显示数据。然而,我们不会这样做。相反,我们将借此机会向 shed 介绍一种在处理多种表单时非常有用的新工具:FormBuilder服务。
在以下章节中,我们将执行以下操作:
- 使用所有必需的 TypeScript、HTML 和 CSS 文件创建我们的
CountryEditComponent。 - 了解如何使用
FormBuilder服务以更好的方式生成表单控件。 - 向新表单实现中添加一组新的
Validators(包括一个全新的isDupeCountry自定义验证器)。 - 测试我们新的基于 FormBuilder 的实现,以检查一切是否正常。
在本节结束时,我们将有一个功能完整的CountryEditComponent,它将以CityEditComponent相同的方式工作,只是它将基于一个稍微不同的方法。
创建 CountryEditComponent
让我们先放好需要的文件。在WorldCities项目的解决方案资源管理器中,执行以下操作:
- 导航到
/ClientApp/src/app/countries文件夹。
*** 右键点击文件夹名称,选择Add|New Item三次,创建以下文件:**
*** country-edit.component.ts
country-edit.component.htmlcountry-edit.component.css
完成后,用以下内容填充它们。
country-edit.component.ts
打开/ClientApp/src/app/countries/country-edit.component.ts文件,填写以下代码。注意突出显示的部分,与前面的CityEditComponent有很大不同;其他细微差异,如country而非city,未突出显示,因为它们超出预期:
import { Component, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormBuilder, Validators, AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { Country } from './../countries/Country';
@Component({
selector: 'app-country-edit',
templateUrl: './country-edit.component.html',
styleUrls: ['./country-edit.component.css']
})
export class CountryEditComponent {
// the view title
title: string;
// the form model
form: FormGroup;
// the city object to edit or create
country: Country;
// the city object id, as fetched from the active route:
// It's NULL when we're adding a new country,
// and not NULL when we're editing an existing one.
id?: number;
constructor(
private fb: FormBuilder,
private activatedRoute: ActivatedRoute,
private router: Router,
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
this.loadData();
}
ngOnInit() {
this.form = this.fb.group({
name: ['',
Validators.required,
this.isDupeField("name")
],
iso2: ['',
[
Validators.required,
Validators.pattern('[a-zA-Z]{2}')
],
this.isDupeField("iso2")
],
iso3: ['',
[
Validators.required,
Validators.pattern('[a-zA-Z]{3}')
],
this.isDupeField("iso3")
]
});
this.loadData();
}
loadData() {
// retrieve the ID from the 'id'
this.id = +this.activatedRoute.snapshot.paramMap.get('id');
if (this.id) {
// EDIT MODE
// fetch the country from the server
var url = this.baseUrl + "api/countries/" + this.id;
this.http.get<Country>(url).subscribe(result => {
this.country = result;
this.title = "Edit - " + this.country.name;
// update the form with the country value
this.form.patchValue(this.country);
}, error => console.error(error));
}
else {
// ADD NEW MODE
this.title = "Create a new Country";
}
}
onSubmit() {
var country = (this.id) ? this.country : <Country>{};
country.name = this.form.get("name").value;
country.iso2 = this.form.get("iso2").value;
country.iso3 = this.form.get("iso3").value;
if (this.id) {
// EDIT mode
var url = this.baseUrl + "api/countries/" + this.country.id;
this.http
.put<Country>(url, country)
.subscribe(result => {
console.log("Country " + country.id + " has been updated.");
// go back to cities view
this.router.navigate(['/countries']);
}, error => console.log(error));
}
else {
// ADD NEW mode
var url = this.baseUrl + "api/countries";
this.http
.post<Country>(url, country)
.subscribe(result => {
console.log("Country " + result.id + " has been created.");
// go back to cities view
this.router.navigate(['/countries']);
}, error => console.log(error));
}
}
isDupeField(fieldName: string): AsyncValidatorFn {
return (control: AbstractControl): Observable<{ [key: string]: any
} | null> => {
var params = new HttpParams()
.set("countryId", (this.id) ? this.id.toString() : "0")
.set("fieldName", fieldName)
.set("fieldValue", control.value);
var url = this.baseUrl + "api/countries/IsDupeField";
return this.http.post<boolean>(url, null, { params })
.pipe(map(result => {
return (result ? { isDupeField: true } : null);
}));
}
}
}
正如我们所见,组件的源代码与CityEditComponent非常相似,除了一些有限但重要的差异,我们将在这里总结:
FormBuilder服务已添加到@angular/forms导入列表中,取代了我们不再需要的FormControl引用。事实上,我们仍然在创建表单控件,但我们将通过FormBuilder来实现,而不是手动实例化它们,这意味着我们不需要显式引用它们。form变量现在使用另一种方法实例化,这种方法强烈依赖于新的FormBuilder服务。- 在
form中实例化的各种FormControl元素具有一些我们以前从未见过的验证器。
FormBuilder服务为我们提供了三种工厂方法,以便我们可以创建我们的表单结构:control()、group()和array()。每个生成对应的FormControl、FormGroup和FormArray类的实例。在我们的示例中,我们创建了一个包含三个控件的组,每个控件都有自己的一组验证器。
至于验证器,我们可以看到两个新条目:
Validator.pattern:一个内置的验证器,需要控件的值匹配给定的正则表达式(正则表达式模式。由于我们的ISO2和ISO3国家字段是使用严格的格式定义的,因此我们将使用它们来确保用户输入正确的值。isDupeField:这是我们第一次在这里实现的自定义异步验证器。它类似于我们为CityEditComponent创建的isDupeCity验证器,但有一些关键区别,我们将在下一节中总结。
Those who don't know much about regular expressions (or regex for short) and want to use the Validator.pattern to its full extent should definitely visit the following website, which contains a good amount of resources regarding regex and a great online builder and tester with full JavaScript and PHP/PCRE regex support: https://regexr.com/.
isDupeField 验证器
通过查看前面组件的源代码可以看出,isDupeField自定义验证器没有像isDupeCity那样分配给主FormGroup;相反,它设置了三次:需要检查的每FormControl一次。原因很简单:与isDupeCity相比isDupeCity意味着使用四字段复制键检查重复的城市,isDupeField需要单独检查分配给它的每个字段。我们需要这样做,因为我们不希望超过一个国家拥有相同的name、或相同的iso2、或相同的iso3。
这也解释了为什么我们需要指定一个fieldName和一个相应的fieldValue而不是传递一个Country接口:isDupeField服务器端API 必须对我们要传递的每个fieldName执行不同的检查,而不是像isDupeCityAPI 那样依赖单一的通用检查。
对于countryId参数,需要防止重复检查在编辑现有国家时引发验证错误。isDupeCity验证器中的作为city类的属性传递。现在,我们需要显式地将其添加到POST参数中。
IsDupeField 服务器端 API
现在,我们需要实现定制验证器的后端API。
打开/Controllers/CountriesController.cs文件,在文件底部添加以下方法:
// ...existing code...
private bool CountryExists(int id)
{
return _context.Countries.Any(e => e.Id == id);
}
[HttpPost]
[Route("IsDupeField")]
public bool IsDupeField(
int countryId,
string fieldName,
string fieldValue)
{
case "name":
return _context.Countries.Any(
c => c.Name == fieldValue && c.Id != countryId);
case "iso2":
return _context.Countries.Any(
c => c.ISO2 == fieldValue && c.Id != countryId);
case "iso3":
return _context.Countries.Any(
c => c.ISO3 == fieldValue && c.Id != countryId);
default:
return false;
}
虽然代码类似于IsDupeCity服务器端API,但我们正在切换fieldName参数,并根据其值执行不同的重复检查;这种逻辑是通过一个标准的switch/case条件块来实现的,对于我们可以合理预期的每个字段,都有强类型LINQ lambda 表达式。同样,我们也在检查countryId是否不同,以便我们的用户可以编辑现有国家。
如果从客户端收到的fieldName与支持的三个值不同,我们的 API 将以false响应。
一种使用 Linq.Dynamic 的替代方法
在继续之前,我们可能想问问自己,为什么我们在switch...case块中使用强类型LAMBDA 表达式来实现IsDupeFieldAPI,而不是依赖System.Linq.Dynamic.Core库。
事实上,我们这样做是为了简单,因为动态方法需要我们编写额外的代码来保护我们的方法免受SQL 注入攻击。然而,既然我们已经在ApiResult类的IsValidProperty()方法中实现了这样一个任务,也许我们可以使用它并缩减前面的代码:毕竟,我们已经将它公开和静态,以便我们可以在任何地方使用它。
下面是使用上述工具的替代实现(注释旧代码,突出显示新代码):
using System.Linq.Dynamic.Core;
// ...existing code...
[HttpPost]
[Route("IsDupeField")]
public bool IsDupeField(
int countryId,
string fieldName,
string fieldValue)
{
// Default approach (using strongly-typed LAMBA expressions)
//switch (fieldName)
//{
// case "name":
// return _context.Countries.Any(c => c.Name == fieldValue);
// case "iso2":
// return _context.Countries.Any(c => c.ISO2 == fieldValue);
// case "iso3":
// return _context.Countries.Any(c => c.ISO3 == fieldValue);
// default:
// return false;
//}
// Alternative approach (using System.Linq.Dynamic.Core)
return (ApiResult<Country>.IsValidProperty(fieldName, true))
? _context.Countries.Any(
String.Format("{0} == @0 && Id != @1", fieldName),
fieldValue,
countryId)
: false;
}
不错吧
备选**动态方法显然比默认方法更干练、更通用,同时对SQL 注入攻击保持相同的安全级别。唯一的缺点可能是由于System.Linq.Dynamics.Core库带来的额外开销,这可能会对性能产生一些较小的影响。虽然在大多数情况下这不应该是一个问题,但只要我们希望 API 尽快响应 HTTP 请求,我们就应该支持默认方法。
country-edit.component.html
是时候实现我们的CountryEditComponent模板了。
打开/ClientApp/src/app/countries/country-edit.component.html文件,填写以下代码。再次注意高亮部分,与CityEditComponent模板有较大差异;其他细微差异,如country而非city,由于超出预期,因此未突出显示:
<div class="country-edit">
<h1>{{title}}</h1>
<p *ngIf="this.id && !country"><em>Loading...</em></p>
<div class="form" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Country name:</label>
<br />
<input type="text" id="name"
formControlName="name" required
placeholder="Country name..."
class="form-control"
/>
<div *ngIf="form.get('name').invalid &&
(form.get('name').dirty || form.get('name').touched)"
class="invalid-feedback">
<div *ngIf="form.get('name').errors?.required">
Name is required.
</div>
<div *ngIf="form.get('name').errors?.isDupeField">
Name already exists: please choose another.
</div>
</div>
</div>
<div class="form-group">
<label for="iso2">ISO 3166-1 ALPHA-2 Country Code (2
letters)</label>
<br />
<input type="text" id="iso2"
formControlName="iso2" required
placeholder="2 letters country code..."
class="form-control" />
<div *ngIf="form.get('iso2').invalid &&
(form.get('iso2').dirty || form.get('iso2').touched)"
class="invalid-feedback">
<div *ngIf="form.get('iso2').errors?.required">
ISO 3166-1 ALPHA-2 country code is required.
</div>
<div *ngIf="form.get('iso2').errors?.pattern">
ISO 3166-1 ALPHA-2 country code requires 2 letters.
</div>
<div *ngIf="form.get('iso2').errors?.isDupeField">
This ISO 3166-1 ALPHA-2 country code already exist:
please choose another.
</div>
</div>
</div>
<div class="form-group">
<label for="iso3">ISO 3166-1 ALPHA-3 Country Code (3
letters)</label>
<br />
<input type="text" id="iso3"
formControlName="iso3" required
placeholder="3 letters country code..."
class="form-control" />
<div *ngIf="form.get('iso3').invalid &&
(form.get('iso3').dirty || form.get('iso3').touched)"
class="invalid-feedback">
<div *ngIf="form.get('iso3').errors?.required">
ISO 3166-1 ALPHA-3 country code is required.
</div>
<div *ngIf="form.get('iso3').errors?.pattern">
ISO 3166-1 ALPHA-3 country code requires 3 letters.
</div>
<div *ngIf="form.get('iso3').errors?.isDupeField">
This ISO 3166-1 ALPHA-3 country code already exist:
please choose another.
</div>
</div>
</div>
<div class="form-group commands">
<button *ngIf="id" type="submit"
(click)="onSubmit()"
[disabled]="form.invalid"
class="btn btn-success">
Save
</button>
<button *ngIf="!id" type="submit"
(click)="onSubmit()"
[disabled]="form.invalid"
class="btn btn-success">
Create
</button>
<button type="submit"
[routerLink]="['/countries']"
class="btn btn-default">
Cancel
</button>
</div>
</div>
</div>
我们可以看到,最相关的差异都与显示新的模式和isDupeField验证器所需的 HTML 代码有关。现在,我们的字段有多达三个不同的验证器,这非常棒:我们的用户不会有机会输入错误的值!
country-edit.component.css
最后但并非最不重要的一点,让我们应用 UI 样式。
打开/ClientApp/src/app/countries/country-edit.component.css文件,填写以下代码:
input.ng-valid {
border-left: 5px solid green;
}
input.ng-invalid.ng-dirty,
input.ng-invalid.ng-touched {
border-left: 5px solid red;
}
input.ng-valid ~ .valid-feedback,
input.ng-invalid ~ .invalid-feedback {
display: block;
}
在这里没有意外;前面的样式表代码与我们用于CityEditComponent的样式表代码相同。
我们的组件终于完成了!现在,我们需要在AppModule文件中引用它,并在CountriesComponent中实现导航路线。
应用模块
打开/ClientApp/src/app/app.module.ts文件并添加以下代码(新行高亮显示):
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';
import { CitiesComponent } from './cities/cities.component';
import { CityEditComponent } from './cities/city-edit.component';
import { CountriesComponent } from './countries/countries.component';
import { CountryEditComponent } from './countries/country-edit.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AngularMaterialModule } from './angular-material.module';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
CitiesComponent,
CityEditComponent,
CountriesComponent,
CountryEditComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'cities', component: CitiesComponent },
{ path: 'city/:id', component: CityEditComponent },
{ path: 'city', component: CityEditComponent },
{ path: 'countries', component: CountriesComponent },
{ path: 'country/:id', component: CountryEditComponent },
{ path: 'country', component: CountryEditComponent }
]),
BrowserAnimationsModule,
AngularMaterialModule,
ReactiveFormsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
现在我们已经制定了两条路线,以便编辑和添加国家,我们只需要在 countries 组件的模板文件中实现它们。
countries.component.ts
打开/ClientApp/src/app/countries/countries.component.html文件并添加以下代码(新行高亮显示):
<h1>Countries</h1>
<p>Here's a list of countries: feel free to play with it.</p>
<p *ngIf="!countries"><em>Loading...</em></p>
<div class="commands text-right" *ngIf="countries">
<button type="submit"
[routerLink]="['/country']"
class="btn btn-success">
Add a new Country
</button>
</div>
<mat-form-field [hidden]="!countries">
<input matInput (keyup)="loadData($event.target.value)"
placeholder="Filter by name (or part of it)...">
</mat-form-field>
<table mat-table [dataSource]="countries" class="mat-elevation-z8" [hidden]="!countries"
matSort (matSortChange)="loadData()"
matSortActive="{{defaultSortColumn}}" matSortDirection="{{defaultSortOrder}}">
<!-- Id Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let country"> {{country.id}} </td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let country">
<a [routerLink]="['/country', country.id]">{{country.name}}</a>
</td>
</ng-container>
<!-- ...existing code... -->
... 就这样!现在,我们准备好测试一切。
测试组件
现在,是时候按下F5并欣赏我们努力工作的成果了。
在调试模式下启动应用后,导航至国家视图,查看添加新国家按钮和各个国家名称上的编辑链接,如以下屏幕截图所示:

现在,让我们使用我们的过滤器搜索Denmark并点击名称,在编辑模式中输入CountryEditComponent。如果一切正常,name、iso2和iso3字段都应该是绿色的,这意味着我们的isDupeField自定义验证器没有引发错误:

现在,让我们尝试将国家名称更改为Japan,将 ISO 3166-1 ALPHA-2 国家代码更改为IT,看看会发生什么:

这是一个很好的结果:这意味着我们的自定义验证器正在做他们的工作,积极地提高了一些重复错误,因为这些值已保留给其他现有国家(分别为Japan和Italy)。
现在,让我们点击取消按钮并返回国家视图。从那里,单击添加新国家/地区按钮,尝试插入具有以下值的国家/地区:
- 国家名称:
New Japan - ISO 3166-1 阿尔法-2 国家代码:
JP - ISO 3166-1 阿尔法-2 国家代码:
NJ2
如果一切正常,我们应该再提出两个验证错误,如以下屏幕截图所示:

前一个错误是由我们的isDupeField海关验证器引起的,原因是 ALPHA-2 国家代码已经属于现有国家(Japan);后者由内置的Validators.pattern引发,我们配置了正则表达式、'[a-zA-Z]{3}',不允许数字。
让我们通过键入以下值来修复这些错误:
- 国家名称:
New Japan - ISO 3166-1 阿尔法-2 国家代码:
NJ - ISO 3166-1 阿尔法-2 国家代码:
NJP
完成后,单击“创建”以创建新国家。如果一切都按预期进行,那么这个观点应该把我们引向主要国家的观点。
从那里,我们可以在文本过滤器中键入New Japan,以确保我们的全新国家确实存在:

... 给你!这意味着我们最终完成了CountryEditComponent并准备好继续新的令人兴奋的任务。
总结
本章完全致力于角形。我们首先阐明了表单的实际含义,列举了表单履行职责所需的功能,并将其分为两个主要要求:提供良好的用户体验和正确处理提交的数据。
然后,我们将重点转向 AngularJS 框架和它提供的两种形式设计模型:模板驱动方法,主要继承自 AngularJS,以及模型驱动或反应式替代方案。我们花了一些宝贵的时间分析了两者的利弊,然后对底层逻辑和工作流进行了详细的比较。最后,我们选择了反应式方式,因为它给了开发者更多的控制权,并在数据模型和表单模型之间实施了更一致的职责分离。
紧接着,我们从理论到实践,创建了一个CityEditComponent,并用它实现了一个功能齐全的反应形式;我们还通过充分利用Angular 模板语法以及 Angular 的ReactiveFormsModule授予的类和指令,添加了客户端和服务器端数据验证逻辑。完成后,我们对CountryEditComponent也做了同样的操作,我们借此机会尝试使用FormBuilder而不是之前使用的FormGroup/FormControl实例。
完成后,我们使用浏览器进行了一次表面测试,以检查所有的内置和自定义验证器,确保它们在前端以及后端API 上正常工作。
在下一章中,我们将通过以更好的方式重构 Angular 组件的一些粗略方面来完善我们迄今为止所做的工作。通过这样做,我们将学习如何对数据进行后处理,添加适当的错误处理,实现一些重试逻辑来处理连接问题,使用 Visual Studio客户端调试器调试表单,以及最重要的是执行一些单元测试。
建议的主题
模板驱动表单、模型驱动表单、反应式表单、JSON、RFC 7578、RFC 1341、URL 生活标准、HTML 生活标准、数据验证、Angular 验证器、自定义验证器、异步验证器、正则表达式(RegEx)、Angular 管道、FormBuilder、RxJS、可观察对象、安全导航操作符(Elvis 操作符)。
工具书类
- 申请表/www-form-urlencoded format-draft-hoehrmann-urlencoded-01:https://tools.ietf.org/html/draft-hoehrmann-urlencoded-01
- RFC 7578-表单返回值:多部分/表单数据:https://tools.ietf.org/html/rfc7578
- RFC 1341,第 7.2 节-多部分内容类型:https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
- URL 生活标准,-URL 编码表单数据:https://url.spec.whatwg.org/#concept-URL 编码
** HTML 生活标准,第 4.10.21.7 节-多部分表单数据:https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-表单数据*** HTML 生活标准,第 4.10.21.8 节-纯文本表单数据:https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#plain-文本表单数据* Angular:模板驱动表单:https://angular.io/guide/forms#template-驱动形式* Angular:反应形式:https://angular.io/guide/reactive-forms* Angular:表单验证:https://angular.io/guide/form-validation* Angular:验证器:https://angular.io/api/forms/Validators** Angular:自定义异步验证程序:https://angular.io/guide/form-validation#implementing-自定义异步验证程序* 正则表达式:学习、构建和测试正则表达式:https://regexr.com/** 安全导航操作员:https://angular.io/guide/template-syntax#safe-导航操作员******************
七、代码调整和数据服务
我们的WorldCitiesweb 应用现在是一个成熟的项目,提供了许多有趣的功能:我们可以检索我们 DBMS 中所有城市和国家的列表,并通过分页表浏览它们,我们可以订购和过滤 ;由于我们的主/细节UI 模式,我们还可以访问每个城市和国家的详细视图,在那里我们可以阅读****和/或编辑这两个城市最相关的字段;最后但并非最不重要的是,由于上述细节视图中的添加了新的功能,我们可以创建新的城市和国家。
**现在,在继续之前,花一些时间巩固我们迄今所学的知识并改进我们遵循的基本模式可能是明智的:毕竟,改进我们的前端和后端以及他们目前所依赖的整体逻辑将肯定使他们更通用和故障预防为即将到来的事情。
本章完全致力于这些任务。下面是我们将要面对的各个部分的内容:
- 优化和调整,我们将实现一些高级源代码和 UI 改进。
- 漏洞修复和改进,我们将利用前面的调整来增强应用的一致性,并添加一些新功能。
- 数据服务,我们将学习如何从当前简化的实现(我们直接在组件内部使用原始
HttpClient服务)迁移到更通用的方法,这将允许我们添加后处理、错误处理、重试逻辑等功能。
所有这些更改都是值得的,因为它们将增强我们应用的源代码,并为下一章将出现的调试和测试阶段做好准备。
好吧,那么。。。让我们开始工作吧。
技术要求
在本章中,我们将需要前面所有章节中列出的所有技术要求,而不需要额外的资源、库或包。
优化和调整
在计算机编程中,术语代码膨胀通常用于描述不必要的长、慢或浪费的源代码。这样的代码是不可取的,因为它不可避免地使我们的应用更容易受到人为错误、回归错误、逻辑不一致、资源浪费等的影响。这也使得调试和测试更加困难和紧张;出于上述所有原因,我们应该尽可能地防止这种情况发生。
对付代码膨胀最有效的方法是采用并坚持干式原则,这是任何开发人员都应该尽可能遵循的原则。正如第 5 章中所述,获取和显示数据,不要重复自己(干)是一个广泛实现的软件开发原则:每当我们违反它,我们就会陷入湿的方法,这可能意味着每件事都写两遍、我们喜欢打字或浪费每个人的时间,这取决于我们最喜欢的内容。
在本节中,我们将尝试解决当前代码中一些比较潮湿的部分,看看如何使它们更干燥:这样做将极大地帮助我们以后调试和测试会话。
模板改进
如果我们再看看我们的CityEditComponent和CountryEditComponent模板文件,我们肯定会看到一定数量的代码膨胀。每个表单调用form.get()方法的次数不少于10次,这对模板的可读性构成了严重威胁。我们谈论的是非常小和简单的形式。与大公司打交道时会发生什么?有办法解决这个问题吗?
事实上,存在这样的问题:每当我们觉得自己编写了太多的代码或重复了太多的复杂任务时,我们都可以在组件类中创建一个或多个 helper 方法来集中底层逻辑。这些助手方法将充当我们可以调用的快捷方式,而不是重复整个验证逻辑。让我们尝试将它们添加到与表单相关的 Angular 分量中。
表单验证快捷方式
让我们看看如何在CityEditComponent课上做到这一点。
打开/ClientApp/src/app/cities/city-edit.component.ts文件,添加以下方法(新行高亮显示):
// retrieve a FormControl
getControl(name: string) {
return this.form.get(name);
}
// returns TRUE if the FormControl is valid
isValid(name: string) {
var e = this.getControl(name);
return e && e.valid;
}
// returns TRUE if the FormControl has been changed
isChanged(name: string) {
var e = this.getControl(name);
return e && (e.dirty || e.touched);
}
// returns TRUE if the FormControl is raising an error,
// i.e. an invalid state after user changes
hasError(name: string) {
var e = this.getControl(name);
return e && (e.dirty || e.touched) && e.invalid;
}
这些评论是不言自明的,所以没有什么要说的了。这些助手方法使我们有机会缩减以前的验证代码,如下所示:
<!-- ...existing code... --->
<div *ngIf="hasError('name')"
class="invalid-feedback">
<div *ngIf="form.get('name').errors?.required">
Name is required.
</div>
</div> <!-- ...existing code... --->
<div *ngIf="hasError('lat')"
class="invalid-feedback">
<div *ngIf="form.get('lat').errors?.required">
Latitude is required.
</div>
</div>
<!-- ...existing code... --->
... 等等好多了,对吧?
让我们对CityEditComponent的所有表单控件执行相同的操作,然后切换到CountryEditComponent并在那里执行相同的操作。。。
... 或者不是。
等一下:我们不是说过我们会尽可能地坚持干燥模式吗?如果我们要在不同的类中复制并粘贴相同的方法,我们怎么能合理地期望这样做呢?如果我们有 10 个基于表单的组件要修补,而不是只有 2 个,那会怎么样?听起来除了湿之外,什么都没有。既然我们已经找到了一种收缩模板代码的好方法,那么我们还需要找到一种体面的方法来实现那些与表单相关的方法,而不必到处产生克隆。
幸运的是,TypeScript 提供了一种很好的方法来处理这类场景:类继承。让我们来看看如何利用这些特性。
类继承
面向对象编程(OOP通常由两个核心概念定义:多态性和继承性。虽然这两个概念是相关的,但它们并不相同。简而言之,他们的意思是:
- 多态性允许我们在同一实体上分配多个接口(例如变量、函数、对象或类型),和/或在不同实体上分配相同的接口:换句话说,它允许实体有多个表单。
- 继承允许我们通过从另一个对象(基于原型的继承或类(基于类的继承派生来扩展一个对象或类,同时保留类似的实现;扩展类通常称为子类或子类,而继承类的名称为超类或基类。
现在让我们关注继承:在 TypeScript 中,与大多数基于类的面向对象语言一样,通过继承(一个子类)创建的类型获得父类型的所有属性和行为,除了构造函数、析构函数、重载运算符和私有类型属于基类的成员。
如果我们仔细想想,这正是我们在场景中需要的:如果我们创建一个基类并在那里实现所有与表单相关的方法,我们只需要扩展我们当前的组件类,而不必多次编写它。
让我们看看如何才能成功。
实现 BaseFormComponent
在解决方案资源管理器中,右键单击/ClientApp/src/app/文件夹并创建一个新的base.form.component.ts文件。打开它,并用以下内容填充它:
import { Component } from '@angular/core';
import { FormGroup } from '@angular/forms';
@Component({
template: ''
})
export class BaseFormComponent {
// the form model
form: FormGroup;
constructor() { }
// retrieve a FormControl
getControl(name: string) {
return this.form.get(name);
}
// returns TRUE if the FormControl is valid
isValid(name: string) {
var e = this.getControl(name);
return e && e.valid;
}
// returns TRUE if the FormControl has been changed
isChanged(name: string) {
var e = this.getControl(name);
return e && (e.dirty || e.touched);
}
// returns TRUE if the FormControl is raising an error,
// i.e. an invalid state after user changes
hasError(name: string) {
var e = this.getControl(name);
return e && (e.dirty || e.touched) && e.invalid;
}
}
现在,我们确实有一个BaseFormComponent超类,我们可以使用它扩展我们的子类;正如我们所看到的,这里没有什么,只有与表单相关的方法和form变量本身,因为这些方法使用(因此是必需的)。
一如既往,在使用新的超类之前,我们需要以以下方式在/ClientApp/src/app/app.module.ts文件中引用它:
// ... existing code...
import { AppComponent } from './app.component';
import { BaseFormComponent } from './base.form.component';
// ... existing code...
@NgModule({
declarations: [
AppComponent,
BaseFormComponent,
NavMenuComponent,
HomeComponent,
CitiesComponent,
CityEditComponent,
CountriesComponent,
CountryEditComponent
],
// ... existing code...
From now on, we'll take for granted that we've got the logic behind our code samples; consequently, we're going to present them in a more succinct way to avoid wasting more pages by saying the obvious: please bear with it! After all, whenever we need to see the full file, we can always find it on the book's online source code repository on GitHub.
之后,我们可以更新当前的CityEditComponent类型脚本文件,以便相应地扩展其类。
扩展 cityedit 组件
打开/ClientApp/src/app/cities/city-edit.component.ts文件,然后在文件开头的import列表末尾添加BaseFormComponent超类:
import { Component, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl, Validators, AbstractControl,
AsyncValidatorFn } from '@angular/forms';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { City } from './city';
import { Country } from '../countries/country';
import { BaseFormComponent } from '../base.form.component';
// ...existing code...
现在,我们需要使用类声明后面的extends修饰符来实现类继承:
// ...existing code...
export class CityEditComponent
extends BaseFormComponent {
// ...existing code...
就是这样:CityEditComponent现在正式成为BaseFormComponent超类的子类。
最后,我们需要在子类构造函数的实现中调用super()来调用超类构造函数:
// ...existing code...
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private http: HttpClient,
@Inject('BASE_URL') private baseUrl: string) {
super();
}
// ...existing code...
就是这样:现在我们可以自由地删除我们早期添加到CityEditComponent类文件中的所有与表单相关的方法——getControl、isValid、isChanged和hasError——因为我们的子类现在将透明地从其超类继承它们。
As for the form variable, it's worth noting that we're actually overriding it in the child class source code: in TypeScript, this can be done without any fancy modifier. We just need to re-define it: since we have already got it, we don't need to do anything.
现在,让我们通过点击F5并在编辑和添加新模式中导航CityEditComponent来测试我们所做的工作。如果我们做的每件事都是正确的,我们就不会看到任何问题:每件事都应该像以前一样工作——源代码的数量相当少。
Don't forget to test our validators, since the form-related methods that we have implemented mostly impact them: if the form validators are still working and show their errors when triggered, it means that the child class is able to inherit and use its base class methods – thus proving that our brand-new superclass/subclass implementation is working fine.
扩展 CountryEditComponent
一旦我们确定一切正常,我们就可以扩展CountryEditComponent类,并使其成为BaseFormComponent的子类:让我们快点这样做,这样我们就可以继续前进。
我们不打算在这里显示源代码的更改,因为所需的步骤与我们刚才看到的几乎相同;如果我们有任何疑问,可以参考 GitHub 存储库中本章的源代码。
Bug 修复和改进
老实说:尽管我们在构建master/细节UI 模式方面做得不错,并且我们使用最相关的city和country字段组合了这两个视图,但我们的应用仍然缺少我们的用户可能希望看到的东西。更具体地说,缺少以下详细信息:
- 我们的城市细节视图没有正确验证 lat 和 lon输入值:例如,我们被允许输入字母而不是数字,这完全破坏了表单。
** **我们的国家视图没有显示每个国家实际包含的城市数量。* 我们的城市视图没有为每个列出的城市显示国家名称***。
**让我们尽最大努力永久解决所有这些问题。
验证 lat 和 lon
让我们从唯一真正的bug开始:可以从前端中断的表单是我们应该始终避免的事情——即使这些输入类型在后端被我们的.NET Core API 隐式检查。
幸运的是,我们已经知道如何修复这些类型的错误:我们需要为CityEditComponent的lat和lon表单控件添加一些基于模式的验证器,就像我们对CountryEditComponent文件中的iso2和iso3控件所做的一样。我们已经知道,我们需要更新两个文件:
CityEditComponent类文件,用于实现验证器并基于正则表达式定义验证模式。CityEditComponent模板文件,用于实现验证器的错误消息及其显示/隐藏逻辑。
让我们这样做!
city-edit.component.ts
打开/ClientApp/src/app/cities/city-edit.component.ts文件,并相应更新其内容(突出显示新的/更新的行):
// ...existing code...
ngOnInit() {
this.form = new FormGroup({
name: new FormControl('', Validators.required),
lat: new FormControl('', [
Validators.required,
Validators.pattern('^[-]?[0-9]+(\.[0-9]{1,4})?$')
]),
lon: new FormControl('', [
Validators.required,
Validators.pattern('^[-]?[0-9]+(\.[0-9]{1,4})?$')
]),
countryId: new FormControl('', Validators.required)
}, null, this.isDupeCity());
this.loadData();
}
// ...existing code...
我们开始吧。从第 6 章、表单和数据验证中我们已经知道,该表单的实现仍然是基于手动实例化的FormGroup和FormControl对象,而不是使用FormBuilder:但是,现在没有理由更改它,因为我们仍然能够实现Validators.pattern没有任何问题。
让我们花几分钟解释一下我们在这里使用的正则表达式:
^定义我们需要检查的用户输入字符串的开始。[-]?允许存在可选减号,处理负坐标时需要此减号。[0-9]+要求输入一个或多个介于 0 和 9 之间的数字。(\.[0-9]{1,4})?定义了一个可选组(感谢末尾的?,如果存在,需要遵守以下规则:\.:必须以单点(小数点)开头。点转义是因为它是保留的正则表达式字符,未转义时表示任何字符。[0-9]{1,4}要求在 0 和 9 之间提供一到四个数字(因为我们确实希望在点后的 1 到 4 个十进制值。
$定义用户输入字符串的结尾。
We could've used \d (any digit) as an alternative of [0-9], which is a slightly more succinct syntax: however, we have chosen to stick with [0-9] for better readability: feel free to replace it with \d at any time.
既然验证器已经设置到位,我们需要将错误消息添加到CityEditComponent模板文件中。
city-edit.component.html
打开/ClientApp/src/app/cities/city-edit.component.html文件并相应更新其内容(突出显示新的/更新的行):
<!-- ...existing code -->
<div *ngIf="form.get('lat').errors?.required">
Latitude is required.
</div>
<div *ngIf="form.get('lat').errors?.pattern">
Latitude requires a positive or negative number with 0-4
decimal values.
</div>
<!-- ...existing code -->
<div *ngIf="form.get('lon').errors?.required">
Longitude is required.
</div> <div *ngIf="form.get('lon').errors?.pattern">
Longitude requires a positive or negative number with 0-4
decimal values.
</div>
<!-- ...existing code -->
我们开始吧。
让我们快速测试一下:
- 点击F5以调试模式启动应用。
- 在城市视图中导航。
- 筛选列表以查找马德里。
- 在城市纬度和城市经度输入字段中键入一些无效字符。
如果验证器已正确实现,我们将看到错误消息显示在所有页面中,并且“保存”按钮已禁用,如以下屏幕截图所示:

就这样。现在我们已经修复了第一个 UI 错误,让我们继续下一个任务。
增加城市数量
我们现在需要做的是找到一种方法,在 Countries(国家)视图中显示一个额外的列,允许用户立即看到每个列出的国家的城市数量。为了做到这一点,我们肯定需要改进我们的后端Web API,因为我们知道,目前无法从服务器检索此类信息。
好的,从技术上讲,有一种方法:我们可以使用 CitiesController 的GetCities()方法,使用一个巨大的pageSize参数(99999 左右)和一个合适的过滤器来检索每个给定国家的全部城市数量,然后计算该集合并输出数字
然而,这样做确实会对绩效产生巨大影响:我们不仅需要检索所有列出的国家的所有城市,但是我们必须通过为每个表行发出一个单独的 HTTP 请求来实现这一点。如果我们想以一种智能和高效的方式完成任务,这绝对不是我们想要的。
以下是我们将要做的:
- 从后端找到一种智能高效的方法来统计每个上市国家的城市数量。
- 将一个
totCities属性添加到我们的countryAngular接口中,以在客户端上存储相同的号码。
让我们这样做。
国家控制员
让我们从后端部分开始。找到一种智能高效的方式计算每个国家的城市数量可能比看起来要困难。
如果我们想在一次快照中检索这样的值,也就是说,不需要对 Angular 执行额外的 API 请求,毫无疑问,我们需要改进我们当前的 CountriesController 的GetCountries()方法——这就是我们目前用来获取国家数据的方法。
让我们打开我们的/Controllers/CountriesController.cs文件,看看.NET Core 和实体框架核心(EF 核心如何帮助我们实现我们想要的。
下面是我们需要更新的GetCountries()方法:
public async Task<ActionResult<ApiResult<Country>>> GetCountries(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
return await ApiResult<Country>.CreateAsync(
_context.Countries,
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
正如我们所看到的,没有城市的痕迹。虽然我们知道我们的Country实体包含一个Cities属性,用于存储城市列表,但我们也记得(从第 4 章、数据模型和实体框架核心中)该属性设置为null,因为我们从未告诉 EF Core 加载实体的相关数据。
如果我们现在做呢?我们可能会试图通过激活急切加载模式来解决问题,并用实际值填充Cities属性,以满足我们的客户需求。我们可以这样做:
return await ApiResult<Country>.CreateAsync(
_context.Countries
.Include(c => c.Cities),
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
然而,不需要一个天才就能理解这样一个变通方法很难聪明和高效:一个国家实体可能有很多城市,有时数百个。我们真的认为我们的后端可以从 DBMS 中检索它们吗?我们真的要用那些巨大的 JSON 数组来淹没我们的 Angular前端吗?
那绝对不行:我们可以做得更好。特别是考虑到,毕竟我们不需要检索每个国家的整个城市的数据来实现我们的目标:我们只需要知道它们的编号。
我们可以这样做:
[HttpGet]
public async Task<ActionResult<ApiResult<CountryDTO>>> GetCountries(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
return await ApiResult<CountryDTO>.CreateAsync(
_context.Countries
.Select(c => new CountryDTO()
{
Id = c.Id,
Name = c.Name,
ISO2 = c.ISO2,
ISO3 = c.ISO3,
TotCities = c.Cities.Count
}),
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
正如我们所看到的,我们采取了一种完全不同的方法:Include()方法已经过时了;现在,我们不是急切地加载城市,而是使用Select()方法将我们生成的国家投影到一个全新的CountryDTO对象中,该对象包含与其源完全相同的属性,再加上一个新的TotCities变量:这样我们就永远不会得到城市,我们只获取它们的编号
It's also worth noting that, since we switched out our Country entity class for a new CountryDTOclass, we had to change the ApiResult generic type (from ApiResult<Country> to ApiResult<CountryDTO>) in the method's return type.
虽然这个方法要复杂一点,但它绝对是一种处理我们任务的聪明而高效的方法;唯一的缺点是我们需要创建CountryDTO类,它还不存在
创建 CountryDTO 类
在解决方案资源管理器中,右键单击/Data/文件夹,然后添加一个新的CountryDTO.cs文件,打开它,并用以下内容填充它:
using System.Text.Json.Serialization;
namespace WorldCities.Data
{
public class CountryDTO
{
public CountryDTO() { }
#region Properties
public int Id { get; set; }
public string Name { get; set; }
[JsonPropertyName("iso2")]
public string ISO2 { get; set; }
[JsonPropertyName("iso3")]
public string ISO3 { get; set; }
public int TotCities { get; set; }
#endregion
}
}
如我们所见,前面的CountryDTO类包含Country实体类已经提供的大部分属性,没有Cities属性(我们知道这里不需要该属性)和一个额外的TotCities属性:它是数据传输对象(DTO类这仅用于向客户提供(仅)我们需要发送的数据。
As the name implies, a DTO is an object that carries data between processes. That's a widely used concept when developing web services and micro-services, where each HTTP call is an expensive operation that should always be cut to the bare minimum amount of required data.
The difference between DTOs and business objects and/or data access objects (such as DataSets, DataTables, DataRows, IQueryables, Entities, and so on) is that a DTO should only store, serialize, and deserialize their own data.
值得注意的是,我们在这里也必须使用[JsonPropertyName]属性,因为这个类将被转换为 JSON,ISO2和ISO3属性不会以我们期望的方式进行转换(正如我们已经在第 5 章、获取和显示数据中看到的那样)。
Angular 前端更新
现在是切换到 Angular 并相应更新前端的时候了,新的更改应用于后端。
遵循以下步骤:
- 打开
/ClientApp/src/app/countries/country.ts文件,将TotCities属性按如下方式添加到Country界面:
export interface Country {
id: number;
name: string;
iso2: string;
iso3: string;
totCities: number;
}
- 紧接着,打开
/ClientApp/src/app/countries/countries.component.ts文件,按以下方式更新displayedColumns内部变量:
// ...existing code...
public displayedColumns: string[] = ['id', 'name', 'iso2',
'iso3', 'totCities'];
// ...existing code...
- 完成后,打开
/ClientApp/src/app/countries/countries.component.html文件,按以下方式将TotCities列添加到角材料的MatTable模板中(更新的行高亮显示):
<!-- ...existing code... -->
<!-- Lon Column -->
<ng-container matColumnDef="iso3">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
ISO 3
</th>
<td mat-cell *matCellDef="let country"> {{country.iso3}} </td>
</ng-container>
<!-- TotCities Column -->
<ng-container matColumnDef="totCities">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
Tot. Cities
</th>
<td mat-cell *matCellDef="let country"> {{country.totCities}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<!-- ...existing code... -->
- 现在,我们终于可以点击F5,看到我们努力工作的成果。如果我们一切都做对了,我们应该能够看到新的托特。城市栏,如以下屏幕截图所示:

一点也不坏:最重要的是,新列也将是可排序,这意味着我们可以通过升序或降序的上市城市数量,点击一两下,对国家进行排序。通过这一新功能,我们可以了解到美国是拥有最多上市城市的国家(4864 个),而新日本,我们在第 6 章表单和数据验证中创建的虚拟国家,仍然拥有零。
在这里,让我们通过转到城市视图,使用它编辑新东京,并通过以下方式更改其国家,快速解决此问题:

如果我们将新东京的国家设置为新日本,点击保存按钮应用更改,然后返回国家视图,我们应该看到新日本现在只有一个城市(如以下屏幕截图所示):

现在,我们已经成功地在我们的国家视图中显示了每个国家的城市数量——并在此过程中将新日本与新东京结合在一起——我们准备进行第三项改进。
然而,在这样做之前,花一些时间思考一下我们必须创建的DTO 类来完成我们的最新任务可能是有用的。
DTO 类–我们真的应该使用它们吗?
现在我们已经看到Country实体类和CountryDTO类实际上是多么的相似,我们应该问问自己是否可以做得更好。例如,我们可以继承CountryDTO类中的Country实体类,从而避免重复四个属性;或者我们可以完全避免使用CountryDTO类,只需将TotCities属性添加到Country实体中即可。
好的,答案是是:我们肯定可以使用这些变通方法,从而避免创建额外的属性(或类)并使代码更干燥。我们为什么不这么做?
答案很简单:因为之前的两种解决方案都有一些相关的设计和安全缺陷。让我们尽最大努力解决这些问题,并理解为什么应该尽可能避免这些问题。
关注点分离
作为一般的经验法则,实体类不应该被只为满足客户端需求而存在的属性所拖累:每当我们需要创建它们时,明智的做法是创建一个中间类,然后分离实体从我们通过 Web API 发送给客户机的输出对象。
If we've worked with the ASP.NET MVC Framework, we can relate this separation of concerns with the one that distinguishes the Model from the ViewModel in the Model-View-ViewModel (MVVM) presentation pattern. The scenario is basically the same: both are simple classes with attributes, but they do have different audiences – the controller and the view. In our scenario, the view is nothing less than our Angular client.
现在,不用说,将TotCities属性放入实体类将打破关注点分离。我们的Countries数据库表中没有TotCities列;该属性仅用于向前端发送一些附加数据。
**除此之外,TotCities属性和已经存在的Cities属性之间没有关系:如果我们激活 EF 核心急切加载模式并填充Cities属性,TotCities属性仍将设置为零(反之亦然);这种误导性的行为将是一个糟糕的设计选择,甚至可能导致那些合理预期我们的实体类是数据源的 C#版本的人的实现错误。
安全考虑
将实体类与客户端API 输出类分开通常是一个不错的选择,即使出于安全目的:现在我们处理的是城市和国家,我们并没有真正受到影响,但如果我们要处理用户呢包含个人和/或登录数据的表?如果我们仔细想想,有很多可能的情况下,从数据库中提取整个字段并以 JSON 格式发送给客户端是不明智的。当我们从 Visual Studio 界面添加它们时,.NET Core Web API 控制器创建的默认方法-这是我们在第 4 章中所做的,具有实体框架核心的数据模型——不必在意,这对于代码示例甚至简单的基于 API 的项目来说都是完美的。但是,当事情变得更加复杂时,建议以可控的方式向客户端提供有限的数据。
也就是说,在.NET 中实现这一点的最有效方法是创建和提供更薄、更安全的DTO 类,而不是主要的实体:这正是我们在前面几节中对CountryDTO类所做的。
DTO 类与匿名类型
上述DTO 类唯一可接受的替代方案是使用Select()方法将主要实体类投影为非特定类型并为其提供服务。
这里是先前 CountriesController 的GetCountries()方法的另一个版本,使用匿名类型代替CountryDTO类(以下代码中突出显示了相关更改):
[HttpGet]
public async Task<ActionResult<ApiResult<dynamic>>> GetCountries(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
return await ApiResult<dynamic>.CreateAsync(
_context.Countries
.Select(c => new
{
id = c.Id,
name = c.Name,
iso2 = c.ISO2,
iso3 = c.ISO3,
totCities = c.Cities.Count
}),
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
正如预期的那样,我们必须在代码中将我们的ApiResult泛型类型更改为dynamic,在方法的返回值中也要更改;除此之外,前面的方法似乎很好,它肯定会像前面的方法一样工作。
那么我们应该用什么呢?DTO 类还是a类类类?
说实话,这两种方法都很好:匿名类型通常是一个很好的选择,特别是当我们需要快速定义 JSON 返回类型时;但是,有一些特定的场景(例如单元测试,我们将在后面看到),我们更愿意处理命名类型。一如既往,选择取决于形势。在我们当前的场景中,我们将继续使用CountryDTO类,但在不久的将来我们也将使用匿名类型。
For additional info on the anonymous types in C#, read the following document:
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/anonymous-types
保护实体
如果我们不想使用DTO 类,并且匿名类型不是我们喜欢的,那么我们可以考虑第三种可行的选择:保护我们的实体,以防止它们向 EF Core 发出错误的指令(例如创建错误的列),或者通过 RESTful API 发送太多数据:如果我们能够做到这一点,我们可以继续使用它们并保持 Web API 代码干燥。
我们可以通过使用一些特定的D**ata 注释属性来装饰实体的属性来实现这一结果,例如:
-
[NotMapped]:防止 EF Core 为该属性创建数据库列。 -
[JsonIgnore]:防止对属性进行序列化或反序列化。 -
[JsonPropertyName("name")]:允许我们在 JSON 类序列化和反序列化时覆盖属性名,覆盖Startup.cs文件中JsonNamingPolicy设置指定的属性名和任何命名策略。
前一个属性需要Microsoft.EntityFrameworkCore名称空间,而其他属性是System.Text.Json.Serialization名称空间的一部分。
我们已经在第 5 章中使用了[JsonPropertyName]属性**获取和显示数据,其中我们必须为Country实体的ISO2和ISO3属性指定 JSON 属性名称:让我们也实现另外两个。
[NotMapped]和[JsonIgnore]属性
打开/Data/Models/Country.cs文件并更新文件末尾的现有代码,如下所示(突出显示新的/更新的行):
#region Client-side properties
/// <summary>
/// The number of cities related to this country.
/// </summary>
[NotMapped]
public int TotCities
{
get
{
return (Cities != null)
? Cities.Count
: _TotCities;
}
set { _TotCities = value; }
}
private int _TotCities = 0;
#endregion
#region Navigation Properties
/// <summary>
/// A list containing all the cities related to this country.
/// </summary>
[JsonIgnore]
public virtual List<City> Cities { get; set; }
#endregion
简而言之,我们所做的就是:
- 我们已经在实体代码中实现了
TotCities属性,并用[NotMapped]属性对其进行了修饰,使得 EF Core 不会在任何迁移和/或更新任务时创建其对应的数据库列。 - 当我们在那里的时候,我们抓住机会写了一些额外的逻辑将这个属性链接到
Cities属性值(只有当它不是null时):这样我们的实体就不会给出误导性的信息,比如Cities列表属性中有 20 多个城市和TotCities同时为零的值。 - 最后但并非最不重要的一点是,我们将
[JsonIgnore]属性添加到Cities属性中,从而防止此类信息被发送到客户端(无论其值如何–即使在null时)。
The [NotMapped] attribute, which we've never used before, helps mitigate the fact that we're using an Entity to store the properties that are required by the front-end, and are therefore completely unrelated to the Data Model: in a nutshell, such an attribute will tell EF Core that we do not want to create a database column for that property in the database. Since we've created our database using EF Core's Code-First approach (see Chapter 4, Data Model with Entity Framework Core), and we're using migrations to keep the database structure updated, we need to use that attribute each and every time we want to create an extra property on our Entity classes. Whenever we forget to do that, we would definitely end with unwanted database fields.
使用[JsonIgnore]来防止服务器发送Cities属性似乎有些过分:既然它当前是null,我们为什么还要跳过这样一个值呢?
事实上,我们做出这个决定是为了预防:因为我们直接使用实体,而不是依赖DTO 类或匿名类型,所以我们希望对数据实施限制性方法。当我们不需要它时,明智的做法是应用[JsonIgnore],以确保我们不会披露任何超出需要的信息;默认情况下,我们可以称之为数据保护方法,这将有助于我们控制我们的 Web API,防止其共享过多。毕竟,我们可以随时删除该属性。
不言而喻,如果我们想要采用担保实体替代方法,我们将不再需要CountryDTO.cs类;因此,我们可以还原我们刚才更改的/Controllers/CountriesController.cs文件的GetCountries()方法,并将Country引用放回原处:
return await ApiResult<Country>.CreateAsync(
_context.Countries
.Select(c => new Country()
{
Id = c.Id,
Name = c.Name,
ISO2 = c.ISO2,
ISO3 = c.ISO3,
TotCities = c.Cities.Count
}),
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
All three alternative implementations of the GetCountries() method that have been discussed in this section – CountryDTO, dynamic, and Country – are available in the /Controllers/CountriesController.cs file in the GitHub source code for Chapter07; the former is what we'll be using for this book's samples, while the other two have been commented out and put there for reference only: feel free to switch them at will!
就这样:现在我们终于可以开始我们的第三个也是最后一个任务了。
添加国家名称
现在,我们需要找到一种方法,在 Cities 视图中添加一个Country列,这样我们的用户就可以看到每个列出的城市的国家名称;考虑到我们刚刚对国家所做的,这应该是一项相当简单的任务。
花旗控制器
一如既往,让我们从 Web API 开始。遵循以下步骤:
- 打开
/Controllers/CitiesController.cs文件,按以下方式更改GetCities()方法:
// ...existing code...
[HttpGet]
public async Task<ActionResult<ApiResult<CityDTO>>> GetCities(
int pageIndex = 0,
int pageSize = 10,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
return await ApiResult<CityDTO>.CreateAsync(
_context.Cities
.Select(c => new CityDTO()
{
Id = c.Id,
Name = c.Name,
Lat = c.Lat,
Lon = c.Lon,
CountryId = c.Country.Id,
CountryName = c.Country.Name
}),
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
// ...existing code...
正如我们所看到的,我们坚持基于 DTO 的模式——这意味着我们必须创建一个额外的CountryDTO类。
- 使用 Visual Studio 的解决方案资源管理器添加新的
/Data/CityDTO.cs文件,并用以下内容填充该文件:
namespace WorldCities.Data
{
public class CityDTO
{
public CityDTO() { }
public int Id { get; set; }
public string Name { get; set; }
public string Name_ASCII { get; set; }
public decimal Lat { get; set; }
public decimal Lon { get; set; }
public int CountryId { get; set; }
public string CountryName { get; set; }
}
}
就这样:我们的 Web API 已经准备好了,所以让我们转到 Angular。
As we've seen when working with the CountriesController's GetCountries() method early on, we could've implemented the Web API by using anonymous types, or with a secured City entity, thus avoiding having to write the CityDTO class.
Angular 前端更新
让我们从/ClientApi/src/app/cities/city.ts接口开始,在这里我们需要添加countryName属性;打开该文件,并按以下方式更新其内容:
interface City {
id: number;
name: string;
lat: number;
lon: number;
countryId: number;
countryName: string;
}
完成后,打开/ClientApi/src/app/cities/cities.component.ts类,需要添加countryName列定义:
// ...existing code...
public displayedColumns: string[] = ['id', 'name', 'lat', 'lon', 'countryName'];
// ...existing code...
然后,打开/ClientApi/src/app/cities/cities.component.html类并相应地添加一个新的<ng-container>:
<!-- ...existing code... -->
<!-- Lon Column -->
<ng-container matColumnDef="lon">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Longitude</th>
<td mat-cell *matCellDef="let city"> {{city.lon}} </td>
</ng-container>
<!-- CountryName Column -->
<ng-container matColumnDef="countryName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Country</th>
<td mat-cell *matCellDef="let city">
<a [routerLink]="['/country',
city.countryId]">{{city.countryName}}</a>
</td>
</ng-container>
<!-- ...existing code... -->
如我们所见,我们将countryName包装在routerLink中,指向编辑国家视图,以便我们的用户能够将其用作导航元素。
让我们测试一下我们做了什么:点击F5以调试模式启动应用,然后进入城市视图。如果我们做的一切都正确,我们应该受到以下结果的欢迎:

不错吧
从那里,如果我们点击国家名称——比如约旦——我们应该进入编辑国家视图:

太棒了!
这就把我们带到了次要代码改进和 UI 调整的结尾:在下一节中,我们将面临一项更为艰巨的任务,这将需要代码重构我们迄今为止创建的所有 Angular 组件。
In software development, code refactoring is the process of restructuring existing source code without changing its external behavior: there could be multiple reasons to perform refactoring activities, such as improving the code's readability, extensibility or performance, making it more secure, reducing its complexity, and so on.
For additional information regarding the code refactoring high-level concept, check out the following URL:
https://docs.microsoft.com/en-us/visualstudio/ide/refactoring-in-visual-studio
数据服务
到目前为止,我们创建的两个 web 应用——第 1 章至3 章中的HealthCheck和第 4 章至7中的WorldCities都具有通过 HTTP(S)协议进行的前端至后端通信,为了建立这样的通信,我们充分利用了HttpClient类,这是@angular/common/http包附带的内置 Angular HTTP API 客户端,它位于XMLHttpRequest接口上。
Angular 的HttpClient类有很多好处,包括可测试性特性、请求和响应类型的对象、请求和响应拦截、可观察的API,以及简化的错误处理。它甚至可以在没有数据服务器的情况下使用,这要归功于内存中的 Web API 包,它在 RESTful API 上模拟 CRUD 操作:我们在第 4 章的开头简要介绍了数据模型和实体框架核心,当我们问自己是否真的需要数据服务器时(答案是肯定的,因此我们没有使用它)。
出于上述所有原因,对于任何想要使用 Angular 框架开发前端web 应用的人来说,充分利用HttpClient类无疑是最合理的选择;这就是说,有多种方法可以实现它,这取决于我们希望利用其宝贵功能的程度。
在本节中,在简要介绍了其他可用的替代方案之后,我们将了解如何重构我们的应用,以便将我们当前的HttpClient实现替换为一种基于专用HTTP 数据服务的更通用的方法。
XMLHttpRequest 与 Fetch(与 HttpClient)
正如我们刚才所说,Angular 的HttpClient类是基于XMLHttpRequest(XHR)的 API,该 API 由浏览器通过其 JavaScript 引擎提供的对象组成,可用于以异步方式在 web 浏览器和 web 服务器之间传输数据,而且不必重新加载整个页面。这项技术最近庆祝了它的 20 周年,在 2017 年FetchAPI最终问世之前,它基本上是唯一可用的替代方法。
Fetch API 是另一个获取资源的接口,旨在成为XMLHttpRequestAPI 的现代替代品,提供更强大、更灵活的功能集;在下一节中,我们将快速回顾这两种方法,并讨论它们的优缺点。
XMLHttpRequest
它背后的概念在 1999 年首次出现,当时微软发布了适用于 MS Exchange Server 2000 的第一版Outlook Web Access(OWA)。
以下是 Alex Hopmann 撰写的一篇非常古老的帖子的摘录,Alex Hopmann 是该帖子的开发者之一:
"XMLHTTP actually began its life out of the Exchange 2000 team. I had joined Microsoft in November 1996 and moved to Redmond in the spring of 1997 working initially on some Internet Standards stuff as related to the future of Outlook. I was specifically doing some work on meta-data for web sites including an early proposal called "Web Collections". During this time period Thomas Reardon one day dragged me down the hall to introduce me to this guy named Jean Paoli that had just joined the company. Jean was working on this new thing called XML that some people suspected would be very big some day (for some unclear reason at the time)."
* – Alex Hopmann, The Story of XMLHTTP, http://www.alexhopmann.com/xmlhttp.htm*
Alex 是对的:几个月后,他的团队发布了一个名为IXMLHTTPRequest的接口,该接口被实现到第二版本的Microsoft XML 核心服务(MSXML)库中:该版本于 1999 年 3 月随 Internet Explorer 5.0 一起发布,这可能是第一个能够访问该界面(通过 ActiveX)的浏览器。
不久之后,Mozilla 项目开发了一个名为nsIXMLHttpRequest的接口,并将其应用到 Gecko 布局引擎中;这与 Microsoft 界面非常相似,但它还附带了一个包装器,允许通过 JavaScript 使用它,这要感谢浏览器返回的对象。该物体于 2000 年 12 月 6 日在 Gecko v0.6 上被称为XMLHttpRequest
在随后的几年中,XMLHttpRequest对象成为所有主要浏览器中的事实上的标准,在Safari 1.2(2004 年 2 月)、Opera 8.0(2005 年 4 月)、iCab 3.0b352(2005 年 9 月)和Internet Explorer 7(2006 年 10 月)中实施。这些早期采用使得谷歌工程师能够开发和发布两个领先的 web 应用G**mail(2004)和谷歌地图(2005),这些应用完全基于 XMLHttpRequestAPI。只要看一下这些应用就足以理解 web 开发已经进入了一个新时代。
这项激动人心的技术唯一缺少的是一个名字,这个名字是在 2005 年 2 月 18 日发现的,当时Jesse James Garrett写了一篇名为AJAX:Web 应用的新方法的标志性文章。
这是已知的术语 AJAX 的首次出现,异步 JavaScript+XML的首字母缩略词:XMLHttpRequest对象在客户端中起着关键作用,这是一组可用于创建异步 web 应用的 web 开发技术。
2006 年 4 月 5 日,万维网联盟(W3C)发布了XMLHttpRequest对象的第一份规范草案,试图创建一个官方的 Web 标准。
The latest draft of the XMLHttpRequest object was published on 6 October, 2016, and is available at the following URL:
https://www.w3.org/TR/2016/NOTE-XMLHttpRequest-20161006/
W3C 草案为 AJAX 开发的广泛采用铺平了道路。然而,对于大多数 web 开发人员来说,最初的实现是相当困难的,因为不同浏览器对所涉及 API 的实现存在一些相关的差异。幸运的是,多亏了许多跨浏览器 JavaScript 库——比如j**Query、Axios和MooTools——它们足够聪明,可以将其添加到可用的工具集中:这允许开发人员使用底层的XMLHttpRequest通过一组标准化的高级方法间接实现对象功能。
随着时间的推移,XHR 数据格式迅速从 XML 转换为JSON、HTML和纯文本,更适合使用 DOM 页面,而不改变整体方式;另外,当 JavaScript(RxJS库的反应式扩展出现时,XMLHttpRequest对象可以很容易地放在Observable后面,从而获得了很多优势(比如能够与其他观察对象混合匹配,订阅/取消订阅、管道/地图,等等)。
这就是 Angular 的HttpClient类背后的主要思想,它可以被描述为Angular 处理**XMLHttpRequest的方式:一个非常方便的包装器,允许开发人员通过Observable模式有效地使用它。
取来
在早期,使用原始的XMLHttpRequest对象对大多数 web 开发人员来说相当困难,并且很容易产生大量 JavaScript 源代码,这些代码通常很难阅读和理解:这些问题最终由等库带来的上层结构解决 jQuery等,但代价是不可避免的代码(和资源)开销。
发布 Fetch API 是为了以更简洁的方式解决此类问题,使用了一种基于内置、承诺的方法,该方法可以轻松地执行相同的异步服务器请求,而无需第三方库。
下面是一个使用XHR的 HTTP 请求示例:
var oReq = new XMLHttpRequest();
oReq.onload = function() {
// success
var jsonData = JSON.parse(this.responseText);
};
oReq.onerror = function() {
// error
console.error(err);
};
oReq.open('get', './api/myCmd', true);
oReq.send();
下面是使用fetch执行的相同请求:
fetch('./api/myCmd')
.then((response) => {
response.json().then((jsonData) => {
// success
});
})
.catch((err) => {
// error
console.error(err);
});
正如我们所看到的,基于 fetch 的代码显然更具可读性。它的通用接口提供了更好的一致性,原生 JSON 功能使代码更加枯燥,承诺它返回的允许更容易的链接和异步/等待任务,而无需定义回调。
长话短说,如果我们将原始 XHR 实现与全新的fetch()API 进行比较,不需要天才就能看出,后者显然获胜。
HttpClient
然而,由于 Angular 的HttpClient类,使用原始 XHR 是不可能的;我们将使用客户端提供的内置抽象,它允许我们以以下方式编写前面的代码:
this.http.get('./api/myCmd')
.subscribe(jsonData => {
// success
},
error => {
// error
console.error(error));
};
如我们所见,前面代码中基于的代码HttpClient与基于的获取的代码提供了类似的好处:我们获得了一致的接口、本机 JSON 功能、链接和异步/等待任务。
除此之外,可观察到的也可以转换为承诺,这意味着我们甚至可以做到以下几点:
this.http.get('./api/myCmd')
.toPromise()
.then((response) => {
response.json().then((jsonData) => {
// success
});
})
.catch((err) => {
// error
console.error(err);
});
At the same time, it's true that Promises can also be converted to Observables using the RxJS library.
总而言之,JavaScript 原生Fetch API 和Angular 原生HttpClient类都是完全可行的,它们中的任何一个都可以在 Angular 应用中有效地使用。
以下是使用获取的主要优点:
- 它是最新的行业标准,可用于处理 HTTP请求和响应。
- 它是JavaScript 原生,因此,它不仅可以在Angular上使用,还可以在任何其他基于 JavaScript 的前端框架上使用(如React、Vue等)。
- 它简化了与服务人员的工作,因为请求和响应对象与我们在正常代码中使用的对象相同。
- 它是围绕 HTTP 请求具有单个返回值的规范构建的,因此返回的是承诺,而不是像观察者那样的流类型(这在大多数情况下可能是一个优势,但也可能成为一个缺点)。
以下是使用HttpClient最相关的优点:
- 它是Angular native,因此受到框架的广泛支持和不断更新(很可能在将来也会如此)。
- 它可以方便地混合和匹配多个观测值。
- 它的抽象级别允许我们轻松实现一些HTTP 魔力(例如定义自动重试尝试,以防请求失败)。
- 观察者可以说比承诺的更通用,功能更丰富,在一些复杂场景中可能有用,例如执行顺序调用,能够在发送 HTTP请求后取消 HTTP请求,等等。
- 它可以注入,因此用于编写各种场景的单元测试。
出于所有这些原因,在仔细考虑之后,我们真诚地认为在 Angular 中采用HttpClient可能是一个更好的选择,因此我们将在本书的其余部分坚持使用它。这就是说,由于 fetchapi 在大多数场景中几乎都是可行的,因此读者可以肯定地尝试这两种方法,看看哪种方法最适合任何给定的任务。
For the sake of simplicity, we're not going any further with these topics. Those who wants to know more about XMLHttpRequest, Fetch API, Observables, and Promises are encouraged to check out the following URIs:
XMLHttpRequest Living Standard (September 24, 2019):
https://xhr.spec.whatwg.org/
Fetch API - Concepts and usage:
https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
RxJS - Observable:
http://w3sdesign.com/?gr=b07&ugr=proble
MDN - Promise:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
构建数据服务
既然我们选择了 Angular 的HttpClient类,我们已经在所有地方使用过了,这意味着我们很好,对吗?
事实上,没有。虽然使用HttpClient绝对是一个不错的选择,但我们使用了一种过于简单的方法来实现它。如果我们从 Angular 来看我们的源代码,我们可以看到实际的 HTTP 调用是如何放置在组件中的,这对于小规模的示例应用来说是可以接受的,但在现实场景中,这绝对不是最好的方法。如果我们想以更复杂的方式处理 HTTP 错误(例如,为了统计目的将它们全部发送到远程服务器),该怎么办?如果我们需要缓存和/或后处理我们通过后端API 获取的数据,该怎么办?更不用说我们肯定会实施重试逻辑来处理潜在的连接问题——这是任何渐进式 Web 应用的典型要求。
我们应该在每个组件的方法集中实现前面的所有内容吗?如果我们想坚持干模式,那绝对不是一个选择;也许我们可以定义一个超类,为其提供 HTTP 功能,并通过调用super方法和一系列高度定制的参数,调整我们的子类源代码以执行所有操作。这样的解决方案可以用于小任务,但一旦事情变得更加复杂,它很容易变得一团糟。
一般来说,我们应该尽最大努力防止我们的TypeScript类——无论是标准、超级还是子——被大量的数据访问代码弄乱;一旦我们陷入这种困境,我们的组件将变得更加难以理解,无论何时我们想要升级、标准化和/或测试它们,我们都会遇到困难。为了避免这种结果,最好将数据访问层与数据表示逻辑分离,这可以通过将前者封装在单独的服务中,然后将服务注入组件本身来实现
这正是我们要做的。
创建 BaseService
由于我们处理的是多个组件类,它们根据上下文(即它们需要访问的数据源)处理不同的任务,因此非常建议创建多个服务:每个上下文对应一个服务。
更具体地说,我们需要以下内容:
CityService,处理城市相关角组件和.NET Core Web API。CountryService,处理国家相关角组件和.NET Core Web API。
另外,假设它们很可能有一些相关的共同点,那么为它们提供一个超类将作为公共接口,这可能会很有用。我们开始吧。
Using an abstract superclass as a common interface might seem a bit counter intuitive: why don't we just create an interface, then? We already have two of them, for cities (/citirs/city.ts) and countries (/countries/country.ts).
As a matter of fact, we did that for a good reason: Angular does not allow us to provide interfaces as providers, because interfaces aren't compiled into the JavaScript output of TypeScript; therefore, to create an interface for a service to an interface, the most effective way to do that is to use an abstract class.
在解决方案资源管理器中,浏览到/ClientApp/src/app/文件夹,右键点击创建一个新的base.service.ts文件,并用以下代码填充其内容:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable()
export abstract class BaseService {
constructor(
protected http: HttpClient,
protected baseUrl: string
) {
}
}
之前的源代码(减去abstract和protected突出显示的修饰符)也是典型 HTTP 数据服务的核心:我们将其用作基类,用于extend我们的服务类;更准确地说,我们将有一个超类(BaseService,其中包含两个不同超类(CityService和CountryService的公共接口,将注入我们的组件。
我们在类声明之前使用的@Injectable修饰符将告诉 Angular,这个类将提供一个可注入的服务,通过依赖注入其他类和组件可以使用该服务。
对于两个高亮显示的修改器,让我们尝试对它们进行一些说明:
abstract:在 TypeScript 中,抽象类是可能有一些未实现的方法的类:这些方法称为抽象方法。抽象类不能作为实例创建,但其他类可以扩展抽象类,从而重用其构造函数和成员。protected:所有服务子类都需要HttpClient类,因此,它是我们将提供给它们的第一个成员(也是唯一的一个,至少目前是这样)。为了做到这一点,我们需要使用允许子类使用它的访问修饰符。在我们的样本中,我们使用了保护,但我们也可以使用公共。
在进一步讨论之前,简要回顾一下 TypeScript 支持了多少访问修饰符,以及它们实际上是如何工作的可能是有用的;如果我们已经从 C#或其他 OO 编程语言中了解了它们,那么在大多数情况下,这将是一个熟悉的故事。
类型脚本访问修饰符
访问修饰符是一种类型脚本概念,允许开发人员将方法和属性声明为public、private、protected和只读。如果未提供修改器,则假定该方法或属性为public,这意味着它可以在内部和外部访问而不会出现问题。相反,如果标记为private,则该方法或属性将仅在类中可访问,不包括其子类(如果有)。Protected表示该方法或属性只能在类及其所有子类内部访问,即任何扩展它的类,但不能在外部访问。最后,只读将导致 TypeScript 编译器在类构造函数中初始赋值后,如果属性值发生更改,则抛出错误。
但是,重要的是要记住,这些访问修饰符将仅在编译时强制执行。TypeScript transpiler 将警告我们所有不适当的使用,但它无法在运行时停止不适当的使用。
添加公共接口方法
现在,让我们用一些高级方法来扩展我们的BaseService公共接口,这些方法对应于我们在子类中需要做的事情。由于我们正在重构的组件已经存在,定义这些通用接口方法的最佳方式是查看它们的源代码,并相应地采取行动。
这是一个好的开始:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable()
export abstract class BaseService {
constructor(
protected http: HttpClient,
protected baseUrl: string
) {
}
abstract getData<ApiResult>(
pageIndex: number,
pageSize: number,
sortColumn: string,
sortOrder: string,
filterColumn: string,
filterQuery: string): Observable<ApiResult>;
abstract get<T>(id: number): Observable<T>;
abstract put<T>(item: T): Observable<T>;
abstract post<T>(item: T): Observable<T>;
}
interface ApiResult<T> {
data: T[];
pageIndex: number;
pageSize: number;
totalCount: number;
totalPages: number;
sortColumn: string;
sortOrder: string;
filterColumn: string;
filterQuery: string;
}
让我们简要回顾一下前面的每个抽象方法:
getData<ApiResult>():这是为了取代我们在CitiesComponent和CountriesComponent类型脚本文件中的getData()方法的当前实现,分别检索城市和国家列表。正如我们所见,我们借此机会指定了一个新的强类型接口–ApiResult–将填充结构化 JSON 输出,我们已经从GetCities和GetCountries.NET Core Web API 接收到这些输出。get<T>():这将取代我们的CityEditComponent和CountryEditComponent类型脚本文件的loadData()方法的当前实现。put<T>()和post<T>():这将取代我们当前对CityEditComponent和CountryEditComponent类型脚本文件的submit()方法的实现。
因为我们使用了大量的泛型类型变量,所以简要回顾一下它们是什么以及它们如何帮助我们定义公共接口是非常有用的。
类型变量和泛型类型–和
值得注意的是,对于get、put和post方法,我们没有使用强类型接口,而是使用了类型变量;我们被迫这样做,因为这些方法将返回一个City或Country接口,具体取决于将实现它们的派生的类。
考虑到这一点,我们将选择使用<T>,而不是<any>,这样我们就不会在函数返回时丢失关于该类型的信息。<T>泛型类型允许我们推迟对返回变量类型的指定,直到类或方法被客户端代码声明和实例化,这意味着无论何时我们在派生类中实现该方法,我们都能够捕获给定参数的类型(也就是说,当我们知道返回的内容时)。
The type <T> variable is a great way to deal with unknown types in an interface, to the point that we've also used it in the preceding ApiResult Angular interface – just like we did in the /Data/ApiResult.cs C# file in the .NET Core back-end.
这些概念并不是什么新鲜事,因为我们已经在后端代码中使用了它们:感谢 TypeScript 编程语言,我们还可以在 Angular前端上使用它们,这真是太棒了。
为什么返回可观察对象而不是 JSON?
在继续之前,最好简单地解释一下为什么我们选择返回Observable类型,而不是我们已有的基于JSON 的接口,例如City、Country和ApiResult:这不是一个更实际的选择吗?
事实上,情况恰恰相反:如果我们将接口类型与前面提到的功能丰富的Observable集合进行比较,我们的接口类型的选项确实非常有限。为什么我们要限制自己——以及调用这些方法的组件?即使我们希望(或需要)实际执行 HTTP 调用并从中检索数据,我们也可以重新创建Observable,并在完成此任务后返回它:我们将在下一章中详细讨论这一点。
创建城市服务
现在让我们创建我们的第一个派生服务,即我们的第一个 BaseService 的派生类(或子类。
在解决方案资源管理器中,浏览到/ClientApp/src/app/cities/文件夹,右键点击创建一个新的city.service.ts文件,并用以下代码填充:
import { Injectable, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { BaseService, ApiResult } from '../base.service';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class CityService
extends BaseService {
constructor(
http: HttpClient,
@Inject('BASE_URL') baseUrl: string) {
super(http, baseUrl);
}
getData<ApiResult>(
pageIndex: number,
pageSize: number,
sortColumn: string,
sortOrder: string,
filterColumn: string,
filterQuery: string
): Observable<ApiResult> {
var url = this.baseUrl + 'api/Cities';
var params = new HttpParams()
.set("pageIndex", pageIndex.toString())
.set("pageSize", pageSize.toString())
.set("sortColumn", sortColumn)
.set("sortOrder", sortOrder);
if (filterQuery) {
params = params
.set("filterColumn", filterColumn)
.set("filterQuery", filterQuery);
}
return this.http.get<ApiResult>(url, { params });
}
get<City>(id): Observable<City> {
var url = this.baseUrl + "api/Cities/" + id;
return this.http.get<City>(url);
}
put<City>(item): Observable<City> {
var url = this.baseUrl + "api/Cities/" + item.id;
return this.http.put<City>(url, item);
}
post<City>(item): Observable<City> {
var url = this.baseUrl + "api/Cities/" + item.id;
return this.http.post<City>(url, item);
}
}
前面的源代码最相关的方面是服务的@Injectable()decorator 中的providedIn属性,我们已将其设置为root:这将告诉 Angular 在应用根中提供此可注入,从而使其成为单例服务。
A singleton service is a service for which only one instance exists in an app: in other words, Angular will create only one instance of that service, which will be shared to all the Components that will use it (through dependency injection) in our application. Although Angular services are not required to be singleton, such a technique provides an efficient use of memory and good performance, thus being the most used implementation approach. For additional info about singleton services, check out the following URL:
https://angular.io/guide/singleton-services
除此之外,前面的代码中没有什么新内容:我们只是复制了CitiesComponent和CityEditComponentTypeScript 文件中已经存在的实现(并稍加修改)。主要的区别是我们现在在那里使用了HttpClient,这意味着我们可以将其从组件类中删除,并将abstract与CityService一起使用
实施城市服务
现在让我们重构我们的 Angular 组件,使用全新的CityService而不是原始的HttpClient。我们将在短时间内看到,我们之前使用(并讨论)的新单例服务模式将使事情比以前稍微容易一些。
应用模块
在 6.0 之前的 Angular 版本中,使单例服务在整个应用中可用的唯一方法是在AppModule文件中以以下方式引用它:
// ...existing code...
import { CityService } from './cities/city.service';
// ...existing code...
providers: [ CityService ],
// ...existing code...
如我们所见,我们应该在AppModule文件的开头添加新服务的import语句,并在现有(但仍然是空的)providers: []部分中注册服务本身。
幸运的是,由于我们使用了 Angular 6.0 引入的providedIn: root方法,因此不再需要以前的技术——尽管它仍然作为可行的替代方案受到支持
As a matter of fact, the providedIn: root approach is preferable, because it makes our service tree-shakable. Tree shaking is a method of optimizing the JavaScript-compiled code bundles by eliminating any code from the final file that isn't actually being used.
For additional info about tree shaking in JavaScript, take a look at the following URL:
https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking
长话短说,由于新方法,我们不再需要更新AppModule文件:我们只需要重构将使用该服务的组件。
CitiesComponent
在解决方案资源管理器中,打开/ClientApp/src/app/cities/cities.component.ts文件并相应更新其内容:
import { Component, Inject, ViewChild } from '@angular/core';
// import { HttpClient, HttpParams } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { City } from './city';
import { CityService } from './city.service';
import { ApiResult } from '../base.service';
@Component({
selector: 'app-cities',
templateUrl: './cities.component.html',
styleUrls: ['./cities.component.css']
})
// ...existing code...
constructor(
private cityService: CityService) {
}
// ...existing code...
getData(event: PageEvent) {
var sortColumn = (this.sort)
? this.sort.active
: this.defaultSortColumn;
var sortOrder = (this.sort)
? this.sort.direction
: this.defaultSortOrder;
var filterColumn = (this.filterQuery)
? this.defaultFilterColumn
: null;
var filterQuery = (this.filterQuery)
? this.filterQuery
: null;
this.cityService.getData<ApiResult<City>>(
event.pageIndex,
event.pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery)
.subscribe(result => {
this.paginator.length = result.totalCount;
this.paginator.pageIndex = result.pageIndex;
this.paginator.pageSize = result.pageSize;
this.cities = new MatTableDataSource<City>(result.data);
}, error => console.error(error));
}
}
正如我们所看到的,我们只需要执行一些小的更新:
- 在
import部分,我们添加了一些对新文件的引用。 - 在构造函数中,我们将现有的
HttpClient类型的http变量切换为一个全新的CityService类型的cityService变量:我们也可以保留旧的变量名,只需更改类型,但为了避免混淆,我们更愿意更改它。 - 因为我们不再直接处理
HttpClient,所以我们不需要在这个组件类中注入BASE_URL;因此,我们从构造函数的参数中删除了 DI 条目。 - 最后但并非最不重要的一点是,我们在
HttpClient的基础上更改了getData()方法的现有实现——使用了一个依赖于新CityService的新实现。
值得注意的是,我们已经注释掉了@angular/common/http包中的所有import引用,这仅仅是因为我们不再需要它们,因为我们不再直接在这个类中使用这些东西。
城市编辑组件
在CityEditComponent中实现CityService将和CitiesComponents一样简单。
在解决方案资源管理器中,打开/ClientApp/src/app/cities/city-edit.component.ts文件并相应更新其内容:
import { Component, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormControl, Validators, AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BaseFormComponent } from '../base.form.component';
import { City } from './city';
import { Country } from '../countries/country';
import { CityService } from './city.service';
import { ApiResult } from '../base.service'; // ...existing code...
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private http: HttpClient,
private cityService: CityService,
@Inject('BASE_URL') private baseUrl: string) {
super();
}
// ...existing code...
onSubmit() {
var city = (this.id) ? this.city : <City>{};
city.name = this.form.get("name").value;
city.lat = +this.form.get("lat").value;
city.lon = +this.form.get("lon").value;
city.countryId = +this.form.get("countryId").value;
if (this.id) {
// EDIT mode
this.cityService
.put<City>(city)
.subscribe(result => {
console.log("City " + city.id + " has been updated.");
// go back to cities view
this.router.navigate(['/cities']);
}, error => console.log(error));
}
else {
// ADD NEW mode
this.cityService
.post<City>(city)
.subscribe(result => {
console.log("City " + result.id + " has been created.");
// go back to cities view
this.router.navigate(['/cities']);
}, error => console.log(error));
}
}
// ...existing code...
正如我们所看到的,这一次我们无法摆脱@angular/common/http包引用,因为我们仍然需要HttpClient来执行一些特定的任务—loadCountries()和isDupeCity(),而我们目前的服务无法处理这些任务。为了解决这些问题,我们显然需要在CityService中实现另外两种方法。
让我们这样做!
在 CityService 中实现 loadCountries()和 IsDupeCity()
在解决方案资源管理器中,打开/ClientApp/src/app/cities/city.service.ts文件,并在文件末尾,即最后一个花括号之前添加以下方法:
// ...existing code...
getCountries<ApiResult>(
pageIndex: number,
pageSize: number,
sortColumn: string,
sortOrder: string,
filterColumn: string,
filterQuery: string
): Observable<ApiResult> {
var url = this.baseUrl + 'api/Countries';
var params = new HttpParams()
.set("pageIndex", pageIndex.toString())
.set("pageSize", pageSize.toString())
.set("sortColumn", sortColumn)
.set("sortOrder", sortOrder);
if (filterQuery) {
params = params
.set("filterColumn", filterColumn)
.set("filterQuery", filterQuery);
}
return this.http.get<ApiResult>(url, { params });
}
isDupeCity(item): Observable<boolean> {
var url = this.baseUrl + "api/Cities/IsDupeCity";
return this.http.post<boolean>(url, item);
}
通过这种新的服务方式,我们可以通过以下方式修补我们的CityEditComponent类文件:
import { Component, Inject } from '@angular/core';
// import { HttpClient, HttpParams } from '@angular/common/http';
// ...existing code...
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private cityService: CityService) {
super();
}
// ...existing code...
loadCountries() {
// fetch all the countries from the server
this.cityService.getCountries<ApiResult<Country>>(
0,
9999,
"name",
null,
null,
null,
).subscribe(result => {
this.countries = result.data;
}, error => console.error(error));
}
// ...existing code...
isDupeCity(): AsyncValidatorFn {
return (control: AbstractControl): Observable<{ [key: string]:
any } | null> => {
var city = <City>{};
city.id = (this.id) ? this.id : 0;
city.name = this.form.get("name").value;
city.lat = +this.form.get("lat").value;
city.lon = +this.form.get("lon").value;
city.countryId = +this.form.get("countryId").value;
return this.cityService.isDupeCity(city)
.pipe(map(result => {
return (result ? { isDupeCity: true } : null);
}));
}
}
}
就这样!现在,我们可以从我们的CityEditComponent代码中删除@angular/common/http引用、HttpClient用法和BASE_URL注入参数。
在下一节中,我们将对与国家相关的组件执行相同的操作。
Before going further, it could be wise to check what we have done so far by hitting F5, and ensuring that everything is still working like before. If we did everything correctly, we should see no differences: our new CityService should be able to transparently perform all the tasks that were previously handled by HttpClient; that's expected since we're still using it under the hood!
创建 CountryService
现在是创建CountryService的时候了,它将是我们的第二个也是最后一个 BaseService 的派生类(或子类。
在解决方案资源管理器中,浏览到/ClientApp/src/app/countries/文件夹,右键点击创建一个新的country.service.ts文件,并用以下代码填充:
import { Injectable, Inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { BaseService, ApiResult } from '../base.service';
import { Observable } from 'rxjs';
import { Country } from './country';
@Injectable({
providedIn: 'root',
})
export class CountryService
extends BaseService {
constructor(
http: HttpClient,
@Inject('BASE_URL') baseUrl: string) {
super(http, baseUrl);
}
getData<ApiResult>(
pageIndex: number,
pageSize: number,
sortColumn: string,
sortOrder: string,
filterColumn: string,
filterQuery: string
): Observable<ApiResult> {
var url = this.baseUrl + 'api/Countries';
var params = new HttpParams()
.set("pageIndex", pageIndex.toString())
.set("pageSize", pageSize.toString())
.set("sortColumn", sortColumn)
.set("sortOrder", sortOrder);
if (filterQuery) {
params = params
.set("filterColumn", filterColumn)
.set("filterQuery", filterQuery);
}
return this.http.get<ApiResult>(url, { params });
}
get<Country>(id): Observable<Country> {
var url = this.baseUrl + "api/Countries/" + id;
return this.http.get<Country>(url);
}
put<Country>(item): Observable<Country> {
var url = this.baseUrl + "api/Countries/" + item.id;
return this.http.put<Country>(url, item);
}
post<Country>(item): Observable<Country> {
var url = this.baseUrl + "api/countries/" + item.id;
return this.http.post<Country>(url, item);
}
isDupeField(countryId, fieldName, fieldValue): Observable<boolean> {
var params = new HttpParams()
.set("countryId", countryId)
.set("fieldName", fieldName)
.set("fieldValue", fieldValue);
var url = this.baseUrl + "api/Countries/IsDupeField";
return this.http.post<boolean>(url, null, { params });
}
}
正如我们所看到的,这次我们先走了一步,抓住机会直接添加了isDupeField()方法,因为我们肯定需要它在短时间内重构CountryEditComponent的验证器。
一如往常,既然我们已经创建了服务,我们需要在我们的应用中实现它。幸运的是,正如我们前面所解释的,我们不必在我们的AppModule文件中引用它,我们只需要在我们国家相关的组件中正确地实现它。
Countries 组件
在解决方案资源管理器中,打开/ClientApp/src/app/countries/countries.component.ts文件并相应更新其内容:
import { Component, Inject, ViewChild } from '@angular/core';
// import { HttpClient, HttpParams } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { Country } from './country';
import { CountryService } from './country.service';
import { ApiResult } from '../base.service';
@Component({
selector: 'app-countries',
templateUrl: './countries.component.html',
styleUrls: ['./countries.component.css']
})
export class CountriesComponent {
public displayedColumns: string[] = ['id', 'name', 'iso2', 'iso3', 'totCities'];
public countries: MatTableDataSource<Country>;
defaultPageIndex: number = 0;
defaultPageSize: number = 10;
public defaultSortColumn: string = "name";
public defaultSortOrder: string = "asc";
defaultFilterColumn: string = "name";
filterQuery: string = null;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(
private countryService: CountryService) {
}
ngOnInit() {
this.loadData(null);
}
loadData(query: string = null) {
var pageEvent = new PageEvent();
pageEvent.pageIndex = this.defaultPageIndex;
pageEvent.pageSize = this.defaultPageSize;
if (query) {
this.filterQuery = query;
}
this.getData(pageEvent);
}
getData(event: PageEvent) {
var sortColumn = (this.sort)
? this.sort.active
: this.defaultSortColumn;
var sortOrder = (this.sort)
? this.sort.direction
: this.defaultSortOrder;
var filterColumn = (this.filterQuery)
? this.defaultFilterColumn
: null;
var filterQuery = (this.filterQuery)
? this.filterQuery
: null;
this.countryService.getData<ApiResult<Country>>(
event.pageIndex,
event.pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery)
.subscribe(result => {
this.paginator.length = result.totalCount;
this.paginator.pageIndex = result.pageIndex;
this.paginator.pageSize = result.pageSize;
this.countries = new MatTableDataSource<Country>(result.data);
}, error => console.error(error));
}
}
这里没什么新鲜事,我们只是重复了刚才在CitiesComponent上做的事情。
CountryEditComponent
在解决方案资源管理器中,打开/ClientApp/src/app/countries/country-edit.component.ts文件并按以下方式更改其内容:
import { Component, Inject } from '@angular/core';
// import { HttpClient, HttpParams } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, Validators, AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { BaseFormComponent } from '../base.form.component';
import { Country } from '../countries/country';
import { CountryService } from './country.service';
@Component({
selector: 'app-country-edit',
templateUrl: './country-edit.component.html',
styleUrls: ['./country-edit.component.css']
})
export class CountryEditComponent
extends BaseFormComponent {
// the view title
title: string;
// the form model
form = this.fb.group({
name: ['',
Validators.required,
this.isDupeField("name")
],
iso2: ['',
[
Validators.required,
Validators.pattern('[a-zA-Z]{2}')
],
this.isDupeField("iso2")
],
iso3: ['',
[
Validators.required,
Validators.pattern('[a-zA-Z]{3}')
],
this.isDupeField("iso3")
]
});
// the city object to edit or create
country: Country;
// the city object id, as fetched from the active route:
// It's NULL when we're adding a new country,
// and not NULL when we're editing an existing one.
id?: number;
constructor(
private fb: FormBuilder,
private activatedRoute: ActivatedRoute,
private router: Router,
private countryService: CountryService) {
super();
}
ngOnInit() {
this.loadData();
}
loadData() {
// retrieve the ID from the 'id'
this.id = +this.activatedRoute.snapshot.paramMap.get('id');
if (this.id) {
// EDIT MODE
// fetch the country from the server
this.countryService.get<Country>(this.id)
.subscribe(result => {
this.country = result;
this.title = "Edit - " + this.country.name;
// update the form with the country value
this.form.patchValue(this.country);
}, error => console.error(error));
}
else {
// ADD NEW MODE
this.title = "Create a new Country";
}
}
onSubmit() {
var country = (this.id) ? this.country : <Country>{};
country.name = this.form.get("name").value;
country.iso2 = this.form.get("iso2").value;
country.iso3 = this.form.get("iso3").value;
if (this.id) {
// EDIT mode
this.countryService
.put<Country>(country)
.subscribe(result => {
console.log("Country " + country.id + " has been updated.");
// go back to cities view
this.router.navigate(['/countries']);
}, error => console.log(error));
}
else {
// ADD NEW mode
this.countryService
.post<Country>(country)
.subscribe(result => {
console.log("Country " + result.id + " has been created.");
// go back to cities view
this.router.navigate(['/countries']);
}, error => console.log(error));
}
}
isDupeField(fieldName: string): AsyncValidatorFn {
return (control: AbstractControl): Observable<{ [key: string]: any } | null> => {
var countryId = (this.id) ? this.id.toString() : "0";
return this.countryService.isDupeField(
countryId,
fieldName,
control.value)
.pipe(map(result => {
return (result ? { isDupeField: true } : null);
}));
}
}
}
正如我们所看到的,我们在这里应用的代码更改与我们在CityEditComponent中所做的非常相似:因为我们抓住机会在CountryService类中预防性地添加了isDupeField()方法,所以这次我们能够一次性摆脱@angular/common/http包。
就这样,至少现在是这样。在下一章中,我们将充分利用这些新服务。但是,在继续之前,强烈建议您执行一些调试运行(通过点击F5】,以确保一切仍在运行。
总结
在本章中,我们花了一些宝贵的时间整合我们的 WorldCities Angular 应用的现有源代码:我们通过充分利用 TypeScript 类继承特性成功地实现了一些优化和调整:我们学习了如何创建基类(超类和派生类(子类,从而使我们的源代码更易于维护和干燥。同时,我们还借此机会进行了一些 bug 修复,并在应用的 UI 中添加了一些新功能。
紧接着,我们改进了 Angular 应用的数据获取功能,从组件中直接使用 Angular 的HttpClient类转变为更通用的基于服务的方法:最终,我们创建了CityService和CountryService——都扩展了BaseService抽象类–处理所有 HTTP 请求,从而为后处理、错误处理、重试逻辑和下一章将介绍的更有趣的内容铺平道路。
建议的主题
面向对象编程、多态性、继承、AJAX、XMLHttpRequest、Fetch API、Angular HttpClient、Angular services、RxJS、Observables、Promises、树抖动、singleton 服务、TypeScript 访问修饰符、TypeScript 泛型、基类和派生类、超类和子类、访问修饰符。
工具书类
- Jesse James Garrett–AJAX:Web 应用的新方法:https://web.archive.org/web/20061107032631/http://www.adaptivepath.com/publications/essays/archives/000385.php
- XMLHttpRequest 对象——W3C 第一份工作草案(2006 年 4 月5 日):https://www.w3.org/TR/2006/WD-XMLHttpRequest-20060405/
- XMLHttpRequest 一级–W3C 最新草案(2016 年 10 月62016):https://www.w3.org/TR/2016/NOTE-XMLHttpRequest-20161006/
- xmlHttp 要求的生活水平(2019 年 9 月24 日):https://xhr.spec.whatwg.org/
- 获取 API–概念和用法:https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- RxJS-可观察:http://w3sdesign.com/?gr=b07 &ugr=问题
** MDN–承诺:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise* Angular-单件服务:https://angular.io/guide/singleton-services* JavaScript 中的树抖动:https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking* 类型脚本:访问修饰符:http://www.typescriptlang.org/docs/handbook/classes.html#public-专用和受保护的修改器* 类型脚本:泛型类型:https://www.typescriptlang.org/docs/handbook/generics.html* C#中的匿名类型 https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/anonymous-types* 创建数据传输对象(DTO):https://docs.microsoft.com/en-us/aspnet/web-api/overview/data/using-web-api-with-entity-framework/part-5* 数据传输对象的利弊:https://docs.microsoft.com/en-us/archive/msdn-magazine/2009/brownfield/pros-and-cons-of-data-transfer-objects* Microsoft.EntityFrameworkCore 命名空间:https://docs.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore* System.Text.Json.Serialization 命名空间:https://docs.microsoft.com/en-us/dotnet/api/system.text.json.serialization* 重构代码:https://docs.microsoft.com/en-us/visualstudio/ide/refactoring-in-visual-studio*******
八、后端和前端调试
所有编程语言(如 C#)和大多数脚本语言(如 JavaScript)最相关的功能之一是它们为开发人员提供的调试功能
"If debugging is the process of removing software bugs, then programming must be the process of putting them in."
— E. W. Dijkstra
术语“调试”普遍指的是发现和解决阻止程序或应用按预期工作的问题和/或问题(通常称为 bug)的过程,我们可以说,调试过程使开发人员能够更好地理解源代码是如何在后台执行的,以及为什么它会产生这样的结果。
调试对于任何开发人员来说都是一项非常重要的技能,可以说与编程本身一样重要;这是一项所有开发人员都必须通过理论、实践和经验学习的技能,就像编码一样。
完成这些任务的最佳方法是使用调试器——一种允许在受控条件下运行目标程序的工具。这使开发人员能够实时跟踪其操作,使用断点停止操作、逐步执行操作、查看基础类型的值等。高级调试器功能还允许开发人员访问内存内容、CPU 寄存器、存储设备活动等,查看或更改其值以再现可能导致解决问题的特定条件。
幸运的是,Visual Studio提供了一组调试器,可用于跟踪任何.NET Core 应用。尽管它的大部分功能都是为了调试我们应用的托管代码部分(例如,我们的 C#文件),但其中一些功能——如果配置正确——对于跟踪客户端代码也非常有用。在本章中,我们将学习如何使用它们,以及 Chrome、Firefox、和 Edge 等一些 web 浏览器内置的各种调试工具,以不断监控我们WorldCities应用的整个 HTTP 工作流。
出于实际原因,调试过程分为两个单独的部分:
- 后端,调试任务主要使用 Visual Studio 和.NET Core 工具处理
- 前端,其中 VisualStudio 和 web 浏览器都起主要作用
在本章结束时,我们将学习如何充分使用 VisualStudio 提供的各种调试工具正确调试 web 应用的 web API 以及 Angle 组件。
技术要求
在本章中,我们将需要前面章节中列出的所有以前的技术要求,而不需要额外的资源、库或包。
本章代码文件可在此处找到:https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition/tree/master/Chapter_08/
后端调试
在本节中,我们将学习如何利用 VisualStudio 环境提供的调试功能来查看 web 应用的服务器端生命周期,并了解如何正确地排除某些潜在缺陷。
然而,在做这件事之前,让我们花几分钟来看看它在各种可用的操作系统中是如何工作的。
Windows 还是 Linux?
为了简单起见,我们将理所当然地使用 Visual Studio 社区版、专业版或企业版 Windows 操作系统。但是,由于.NET Core 的设计是跨平台的,因此对于希望调试到其他环境(如 Linux 或 macOS)的用户,至少有两种选择:
- 使用 Visual Studio 代码,这是 Visual Studio 的一种轻量级开源替代方案,可用于 Windows、Linux 和 macOS,并提供完全的调试支持
- 使用 Visual Studio,得益于自 Visual Studio 2017 起提供的 Docker 容器工具,以及自版本 16.3 起内置到 Visual Studio 2019 中的工具
**Visual Studio Code can be downloaded for free (under MIT license) from the following URL:
https://code.visualstudio.com/download
Visual Studio Docker container tools require Docker for Windows, which can be installed from the following URL:
https://docs.docker.com/docker-for-windows/install/
The container tools usage information is available here:
https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/docker/visual-studio-tools-for-docker
For additional information about the .NET Core debugging features under Linux and macOS, check out the following URL:
在本书中,为了简单起见,我们将坚持使用 Windows 环境,从而使用适用于 Windows 的 Visual Studio 调试器集。
基础
我们想当然地认为,购买本书的每个人都已经知道 Visual Studio 提供的所有基本调试功能,例如:
- 调试与发布构建配置模式
- 断点以及如何设置和使用断点
- 将和中的步出程序****
******* 观看、呼叫栈、本地人、和即时窗口******
******For those who don't know (or remember) them well enough, here's a great tutorial that can be useful if you want a quick recap:
https://docs.microsoft.com/en-US/dotnet/core/tutorials/debugging-with-visual-studio?tabs=csharp
在下一节中,我们将简要介绍一些在特定场景中有用的高级调试选项。
条件断点
条件断点是一个有用的调试特性,大多数开发人员通常不知道(或未充分利用);它的行为就像一个普通的断点,但它只有在满足某些条件时才会触发。
要设置条件断点,只需单击创建标准断点时出现的设置上下文图标(带齿轮的图标),如以下屏幕截图所示:

一旦我们这样做,一个模式面板将出现在窗口底部,显示我们可以为该断点配置的一些可能的条件设置:

如我们所见,有许多可能的设置(条件、操作等)。让我们看看如何使用它们。
条件
如果选中条件复选框,我们将能够定义触发断点的代码条件。
为了更好地解释其工作原理,让我们执行一个快速调试测试:
- 在解决方案资源管理器中,打开
/Controllers/CitiesController.cs文件。 - 在
GetCity()方法的最后一行设置一个断点(找到城市后将其返回给客户端的断点–有关详细信息,请参见下面的屏幕截图)。 - 单击设置图标以访问断点设置面板。
- 激活条件复选框。
- 选择条件表达式,并在两个下拉列表中为 true。
- 在右侧的文本框中键入以下条件:
city.Name == "Moscow"。
完成后,断点设置面板应如以下屏幕截图所示:

正如我们所见,我们的条件已经创造出来;该界面允许我们添加其他条件,以及通过激活其下方的另一个复选框来执行某些操作。
行动
Actions 功能可用于在输出窗口中显示自定义消息(例如,嘿,我们正在从 Angular 编辑 Moscow!)和/或选择是否继续执行代码。如果未指定操作,断点将正常运行,不会发出消息并停止代码执行。
在这里,让我们借此机会测试一下 Actions 特性。激活复选框,然后在最右边的文本框中键入上一段中的消息。完成后,断点设置面板应如以下屏幕截图所示:

我们刚刚创建了第一个条件断点;让我们快速测试它,看看它是如何工作的。
测试条件断点
要测试断点被命中时发生的情况,请在调试模式下运行WorldCities应用(通过点击F5),导航到 Cities 视图,过滤表格以定位莫斯科市,然后单击其名称进入编辑模式。
如果一切正常,我们的条件断点应按以下方式触发和运行:

正如我们所看到的,输出窗口中也填充了我们的自定义消息。如果我们现在用不同的名字(例如,罗马、布拉格或纽约)对任何其他城市重复相同的测试,则根本不会触发相同的断点;什么也不会发生。
It's worth mentioning that there are two cities called Moscow in our WorldCities database: the Russian capital city and a city in Idaho, USA. It goes without saying that our conditional breakpoint will trigger on both of them because it only checks for the Name property. If we wanted to limit its scope to the Russian city only, we should refine the Conditional Expression to also match the CityId, the CountryId, or any other suitable property.
到目前为止一切都很好;让我们继续。
输出窗口
在上一节中,我们讨论了 VisualStudio 输出窗口,每当遇到条件断点时,我们都会使用该窗口编写自定义消息
如果您有使用 VisualStudio 调试器的经验,您需要了解该窗口的极端重要性,以了解幕后发生的事情。“输出”窗口显示 IDE 中各种功能的状态消息,这意味着大多数.NET 中间件、库和包都将其相关信息写入其中,就像我们使用条件断点所做的那样。
To open the Output window, either choose View | Output from the main menu bar or press Ctrl + Alt + O.
如果我们在刚刚执行的测试期间查看输出窗口中发生的情况,我们可以看到很多有趣的东西:

正如我们所看到的,有许多不同来源的信息,包括以下内容:
-
Microsoft.AspNetCore.Hosting.Diagnostics:专用于异常处理、异常显示页面和诊断信息的.NET Core 中间件。它处理开发人员异常页面中间件、异常处理程序中间件、运行时信息中间件、状态代码页面中间件和欢迎页面中间件。简而言之,它是调试 web 应用时输出窗口的王者。 -
Microsoft.AspNetCore.Mvc.Infrastructure:处理(和跟踪)控制器操作并响应.NET Core MVC 中间件的名称空间。 -
Microsoft.AspNetCore.Routing:处理静态和动态路由的.NET Core 中间件,例如我们的所有 web 应用的 URI 端点。 -
Microsoft.EntityFrameworkCore:处理数据源连接的.NET Core 中间件;例如,我们的 SQL Server,我们在第 4 章中详细讨论过,数据模型,具有实体框架核心。
所有这些信息基本上都是 web 应用执行期间发生的所有事情的顺序日志。通过执行用户驱动的操作并阅读它,我们可以从.NET Core 生命周期中学到很多东西。
配置输出窗口
不用说,VisualStudio 界面允许我们过滤输出和/或选择捕获信息的详细程度。
要配置要显示和隐藏的内容,请从主菜单中选择工具|选项,然后从右侧的树菜单项导航到调试|输出窗口。从该面板中,我们可以选择(或取消选择)许多输出消息:异常消息、模块加载消息/模块卸载消息、进程退出消息、步骤筛选消息,等等:

现在我们已经了解了后端调试输出的要点,让我们将注意力转移到一个可能需要特别注意的中间件上:实体框架(EF)核心。
调试 EF 核心
如果我们在调试模式下运行一个 web 应用后立即查看输出窗口,我们应该能够看到大量以纯文本编写的 SQL 查询。这些是底层的LINQ to SQL 提供者使用的查询,它负责将我们所有的 lambda 表达式、查询表达式、IQueryable 对象、和表达式树转换为有效的 T-SQL 查询。
以下是Microsoft.EntityFrameworkCore中间件发出的输出信息行,其中包含用于检索莫斯科市的 SQL 查询(实际 SQL 查询突出显示):
Microsoft.EntityFrameworkCore.Database.Command: Information: Executing DbCommand [Parameters=[@__p_0='?' (DbType = Int32), @__p_1='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT [c].[Id], [c].[Name], [c].[Lat], [c].[Lon], [c0].[Id] AS [CountryId], [c0].[Name] AS [CountryName]
FROM [Cities] AS [c]
INNER JOIN [Countries] AS [c0] ON [c].[CountryId] = [c0].[Id]
WHERE CHARINDEX(N'moscow', [c].[Name]) > 0
ORDER BY [c].[Name]
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
不错吧?在转换 lambda 或 LINQ 查询表达式s的性能时,这些明文形式的 SQL 查询对于确定 LINQ-to-SQL 提供程序是否工作良好非常有用。
GetCountries()SQL 查询
让我们尝试使用相同的技术来检索与 CountriesController 的GetCountries()方法实现相对应的 SQL 查询,我们在第 7 章、代码调整和数据服务中对其进行了细化,以包括城市计数。
**以下是源代码片段:
return await ApiResult<CountryDTO>.CreateAsync(
_context.Countries
.Select(c => new CountryDTO()
{
Id = c.Id,
Name = c.Name,
ISO2 = c.ISO2,
ISO3 = c.ISO3,
TotCities = c.Cities.Count
}),
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
要查看它是如何转换为 T-SQL 的,请执行以下操作:
- 点击F5以调试模式运行 web 应用。
- 导航到“国家/地区”视图。
- 查看结果输出窗口(搜索
TotCities会有帮助)。
下面是我们应该在那里找到的 SQL 查询:
SELECT [c0].[Id], [c0].[Name], [c0].[ISO2], [c0].[ISO3], (
SELECT COUNT(*)
FROM [Cities] AS [c]
WHERE [c0].[Id] = [c].[CountryId]) AS [TotCities]
FROM [Countries] AS [c0]
ORDER BY [c0].[Name]
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
那还不错;LINQ-to-SQL 提供程序使用子查询对其进行转换,这在性能方面是一个不错的选择。SQL 查询的OFFSET部分与前面代码段中提到的DBCommand Parameters一起处理分页,并确保我们只获得我们一直要求的行。
然而,VisualStudio 输出窗口并不是查看这些 SQL 查询的唯一方法——我们可以通过实现一个简单但有效的扩展方法为自己提供一个更好的选择,我们将在以下部分中看到。
以编程方式获取 SQL 代码
输出窗口对于大多数场景来说已经足够好了,但是如果我们想通过编程方式从IQueryable<T>中检索 SQL 代码呢?这样的选项对于调试(或有条件地调试)应用的某些部分可能非常有用,特别是如果我们希望在输出窗口之外自动保存这些 SQL 查询(例如,日志文件或日志聚合器服务)。
为了实现这样的结果,我们需要创建一个专用的助手函数,该函数将能够使用System.Reflection实现这一点。让我们快速地做这件事,并测试它是如何工作的。
在解决方案资源管理器中,右键单击/Data/文件夹,创建一个新的IQueryableExtensions.cs文件,并用以下源代码填充其内容:
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
namespace WorldCities.Data
{
public static class IQueryableExtension
{
public static string ToSql<T>(this IQueryable<T> query)
{
var enumerator = query.Provider
.Execute<IEnumerable<T>>
(query.Expression).GetEnumerator();
var relationalCommandCache = enumerator
.Private("_relationalCommandCache");
var selectExpression = relationalCommandCache
.Private<SelectExpression>("_selectExpression");
var factory = relationalCommandCache
.Private<IQuerySqlGeneratorFactory>
("_querySqlGeneratorFactory");
var sqlGenerator = factory.Create();
var command = sqlGenerator.GetCommand(selectExpression);
string sql = command.CommandText;
return sql;
}
private static object Private(this object obj, string
privateField) =>
obj?.GetType()
.GetField(privateField, BindingFlags.Instance |
BindingFlags.NonPublic)?
.GetValue(obj);
private static T Private<T>(this object obj, string
privateField) =>
(T)obj?
.GetType()
.GetField(privateField, BindingFlags.Instance |
BindingFlags.NonPublic)?
.GetValue(obj);
}
}
正如我们所看到的,我们抓住机会创建了 helper 类作为一个IQueryable<T>扩展方法。这允许我们扩展IQueryable<T>类型的功能,而无需创建新的派生类型、修改原始类型或创建明确要求它作为引用参数的静态函数。
For those who have never heard of them, C# extension methods are static methods that can be called as if they were instance methods on the extended type. For further information, take a look at the following URL from the Microsoft C# programming guide:
现在我们已经创建了IQueryableExtension静态类,我们可以在任何其他类中使用ToSql()扩展方法,只要它包含对WorldCities.Data名称空间的引用。
让我们看看如何在ApiResult.cs类中实现前面的扩展,这是大多数IQueryable<T>对象执行的地方。
实现 ToSql()方法
在解决方案资源管理器中,选择/Data/ApiResult.cs文件,打开该文件进行编辑,并将以下行添加到现有CreateAsync方法实现中(新行高亮显示):
// ...existing code...
public static async Task<ApiResult<T>> CreateAsync(
IQueryable<T> source,
int pageIndex,
int pageSize,
string sortColumn = null,
string sortOrder = null,
string filterColumn = null,
string filterQuery = null)
{
if (!String.IsNullOrEmpty(filterColumn)
&& !String.IsNullOrEmpty(filterQuery)
&& IsValidProperty(filterColumn))
{
source = source.Where(
String.Format("{0}.Contains(@0)",
filterColumn),
filterQuery);
}
var count = await source.CountAsync();
if (!String.IsNullOrEmpty(sortColumn)
&& IsValidProperty(sortColumn))
{
sortOrder = !String.IsNullOrEmpty(sortOrder)
&& sortOrder.ToUpper() == "ASC"
? "ASC"
: "DESC";
source = source.OrderBy(
String.Format(
"{0} {1}",
sortColumn,
sortOrder)
);
}
source = source
.Skip(pageIndex * pageSize)
.Take(pageSize);
// retrieve the SQL query (for debug purposes)
var sql = source.ToSql();
var data = await source.ToListAsync();
return new ApiResult<T>(
data,
count,
pageIndex,
pageSize,
sortColumn,
sortOrder,
filterColumn,
filterQuery);
}
// ...existing code...
如我们所见,我们添加了一个变量来存储新扩展方法的结果。让我们快速测试一下,看看它是如何工作的。
It's worth noting that, since the ApiResult.cs class is part of the WorldCities.Data namespace, we didn't have to add the using reference at the top.
在ApiResult.cs类的行上,在前面添加的新行的正下方放置一个断点(如下面的屏幕截图所示)。完成后,点击F5以调试模式运行 web 应用,然后导航到国家/地区视图。
应该命中断点,如以下屏幕截图所示:

如果我们将鼠标光标移到sql变量上并单击放大镜图标,我们将能够在文本可视化工具窗口中看到 SQL 查询。现在,我们知道如何从IQueryable<T>对象快速查看 EF Core 生成的 SQL 查询。
使用#if 预处理器
如果我们担心ToSql()方法任务的性能受到影响,我们肯定可以通过以下方式使用#if预处理器调整前面的代码:
// retrieve the SQL query (for debug purposes)
#if DEBUG
{
var sql = source.ToSql();
// do something with the sql string
}
#endif
如我们所见,我们已经将ToSql()方法调用包装在#if预处理器指令块中:当 C#编译器遇到这些指令时,它将仅在定义了指定符号的情况下编译它们之间的代码。更具体地说,我们在前面的代码中使用的DEBUG符号将防止编译包装好的代码,除非 web 应用正在调试模式下运行,从而避免在发布/生产版本中出现任何性能损失。
For additional information regarding the C# preprocessor directives, take a look at the following URLs:C# preprocessor directives:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/
if preprocessor directives:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-if
关于 VisualStudio 和.NETCore 提供的后端调试功能,还有很多话要说;然而,出于空间的原因,最好暂时停在这里,继续前进到前端。
前端调试
在本节中,我们将简要回顾现有的各种前端调试选项(VisualStudio 或浏览器的开发人员工具)。之后,我们将研究一些 Angular 特性,可以利用这些特性提高对客户端应用在后台执行的各种任务的认识,并对它们进行调试。
VisualStudioJavaScript 调试
前端调试工作s与后端调试一样,得益于 Visual Studio 的JavaScript 调试功能。默认情况下不会启用 JS 调试器,但当我们第一次在 JavaScript(或 TypeScript)文件上设置断点并在调试模式下运行应用时,VisualStudioIDE 将自动询问是否激活它。
*到目前为止,仅为 Chrome 和 Microsoft Edge 提供客户端调试支持。除此之外,由于我们直接使用的是 TypeScript 而不是 JavaScript,如果我们想在 TypeScript 文件(我们的 Angular 组件类文件)而不是 JavaScript 传输文件中设置和命中断点,则需要使用源映射。
幸运的是,我们正在使用的 Angular 模板(参见第 1 章、准备和第 2 章、环视)已经提供了源地图支持,我们可以通过查看/ClientApp/tsconfig.json文件中的sourceMap参数值看到:
[...]
"sourceMap": true
[...]
这意味着我们可以做到以下几点:
- 打开
/ClientApp/src/app/countries/countries.component.ts文件。 - 在
countryService返回的Observable订阅中放置一个调试器(详见下面的屏幕截图)。 - 点击F5以调试模式启动 web 应用。
如果一切都正确,Visual Studio IDE 应该询问我们是否要启用 JavaScript 调试,如以下屏幕截图所示:

一旦启用,运行时环境将在我们导航到 Countries 视图时立即停止程序执行。不用说,我们将能够检查 Angular 组件类的各种成员:

那很酷,对吧?我们甚至可以定义条件断点,使用无明显缺陷的监视、调用堆栈、局部变量、和立即窗口。
For additional information about debugging a TypeScript or JavaScript app in Visual Studio, take a look at the following URL:
https://docs.microsoft.com/en-US/visualstudio/javascript/debug-nodejs.
在下一节中,我们将介绍另一个重要的前端调试资源:JavaScript 源映射。
JavaScript 源代码映射
对于那些不知道源地图实际上是什么的人,让我们尝试简要总结一下这个概念。
*从技术上讲,源映射是将压缩、组合、缩小和/或传输文件中的代码映射回其在源文件中的原始位置的文件。由于这些映射,我们甚至可以在资产优化后调试应用。
正如我们刚才看到的,源映射被 Visual Studio JavaScript 调试器广泛使用,使我们能够在 TypeScript 源代码中设置断点,Google Chrome、Mozilla Firefox 和 Microsoft Edge developer 工具也支持它们,这样,即使在处理压缩和缩小的文件时,这些浏览器的内置调试器也可以向开发人员显示未统一和未组合的源代码。
*有关 JavaScript 源映射的其他信息,请查看以下 URL:
JavaScript 源代码映射简介,Ryan Seddon:
https://www.html5rocks.com/en/tutorials/developertools/sourcemaps/ 。
源地图介绍,马特·韦斯特:
https://blog.teamtreehouse.com/introduction-source-maps 。
然而,考虑到我们的特定场景,上述浏览器的调试功能可能并不理想;在下一节中,我们将尽力解释原因。
浏览器开发工具
我们很容易猜到,VisualStudioJavaScript 调试功能不是调试客户端脚本的唯一方法。然而,由于我们正在处理一个 TypeScript 应用,它可以说是最好的选择,因为它允许通过自动生成的源映射调试.ts文件。
尽管浏览器的内置调试工具肯定可以使用源映射让我们处理未统一和未组合的文件,但它们无法将这些已传输的文件恢复到以前的 TypeScript 类中,因为它们从未见过这些文件。
正是出于这个原因,如果我们尝试激活 Chrome 开发者工具来调试我们的CountriesComponentAngular 类,我们会遇到如下情况:

我们可以看到,TypeScript 文件不在那里。浏览器正在处理一个巨大的main.js传输文件,该文件基本上包含所有 Angular 组件。在该文件中,CountriesComponent类的上述行(第 69 行左右)对应于第 888 行(参见前面的屏幕截图;实际行号可能有所不同)。
但是,只要我们单击该行在那里设置断点,相应的 TypeScript 文件也将变得可访问,就像在 Visual Studio 中一样:

这怎么可能呢?我们刚才不是说浏览器对 TypeScript 类一无所知吗?
事实上,事实并非如此;但是,由于我们在开发环境中运行应用,我们的.NET Core 应用正在使用AngularCliMiddleware为我们的 Angular 应用提供服务,从而转发那里的所有 HTTP 请求。我们已经在Startup.cs文件中看到了此设置:
// [...]
app.UseSpa(spa =>
{
// To learn more about options for serving an Angular SPA from
// ASP.NET Core,
// see https://go.microsoft.com/fwlink/?linkid=864501
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
// [...]
UseAngularCliServer()方法将在内部调用AngularCliMiddleware,它将执行以下操作:
- 启动 npm 实例(使用动态端口)
- 使用该动态端口执行 ng 发球(发球 Angular 应用)
- 创建一个透明代理,将所有 HTTP 请求转发到 Angular dev 服务器
多亏了这一切,浏览器虽然只接收到main.jsJavaScript 传输文件,但仍然能够按照源映射到达底层的 TypeScript 文件
*但是,即使我们在 TypeScript 页面上设置了断点,一旦触发,我们将返回到main.js文件,如下面的屏幕截图所示:

这种行为并不令人惊讶;浏览器的内置调试器可以使用源映射从代理检索 TypeScript 类,但显然无法直接处理/调试它们。
正是出于这个原因,至少在我们的特定场景中,VisualStudio 前端调试功能(使用内置 JavaScript 调试器)可以说是当今调试 Angular 应用最有效的方法。
角形调试
在本节中,我们将花费一些宝贵的时间来理解与表单调试相关的一些关键概念。
正如我们在第 6 章、表单和数据验证中提到的,模型驱动方法的优势之一是它允许我们对表单元素进行粒度控制。我们如何利用这些特性并将其转化为编写更健壮的代码?
在下面的部分中,我们将通过展示一些有用的技术来解决这个问题,这些技术可以用来更好地控制表单。
查看表单模型
在第 6 章、表单和数据验证中,我们已经讨论了很多表单模型,但我们从未仔细看过。在开发表单模板时将其显示在屏幕上会有很大帮助,特别是当我们使用表单输入和控件时,它可以实时更新。
下面是一个方便的 HTML 代码段,其中包含实现此操作所需的模板语法:
<!-- Form debug info panel -->
<div class="card bg-light mb-3">
<div class="card-header">Form Debug Info</div>
<div class="card-body">
<div class="card-text">
<div><strong>Form value:</strong></div>
<div class="help-block">
{{ form.value | json }}
</div>
<div class="mt-2"><strong>Form status:</strong></div>
<div class="help-block">
{{ form.status | json }}
</div>
</div>
</div>
</div>
我们可以将此代码片段放在任何基于表单的组件上,例如,CityEditComponent,以获得以下结果:

很有用吧?如果我们稍微玩一下表单,我们可以看到表单调试信息面板中包含的值将如何随着输入控件的更改而更改;在处理复杂表单时,这样的东西肯定会派上用场。
管道操作员
通过查看前面源代码中突出显示的行,我们可以看到如何使用管道操作符(|),这是来自 Angular 模板语法的另一个有用工具。
为了快速总结它的作用,我们可以这样说:pipe运算符允许使用一些转换函数,这些函数可用于执行各种任务,例如格式化字符串、将数组元素合并为字符串、将文本大写/小写以及对列表排序。
以下是内置有 Angular 传感器的管道:
DatePipeUppercasePipeLowerCasePipeCurrencyPipePercentPipeJsonPipe
这些都可以在任何模板中使用。不用说,我们在前面的脚本中使用了后者来将form.value和form.status对象转换为可读的 JSON 字符串。
It's worth noting that we can also chain multiple pipes and define custom pipes; however, we don't need to do that for the time being, and talking about such a topic will take us far away from the scope of this chapter. Those who want to know more about pipes should take a look at the official Angular documentation at https://angular.io/guide/pipes.
对变化作出反应
我们选择反应式方法的原因之一是能够对用户发布的更改做出反应。我们可以通过订阅FormGroup和FormControl类公开的valueChanges属性来实现这一点,该属性返回一个发出最新值的RxJS Observable。
自第三章前端和后端交互开始,我们就一直在使用 Observable,当我们第一次订阅HttpClient的get()方法来处理 web 服务器接收到的HTTP响应时。我们在第 6 章、表单和数据验证中再次使用了它们,当时我们还必须实现对put()和post()方法的支持,最后但并非最不重要的是,我们在第 7 章、中广泛讨论了它们代码调整和数据服务,当我们解释它们相对于承诺的优缺点时,了解了它们最相关的一些特性,并将它们集成到我们的CityService和CountryService中。事实上,无论何时何地,只要我们需要获取为数据模型接口和表单模型对象提供数据的 JSON 数据,我们都会继续使用它们。**
***在下一节中,我们将使用它们演示如何在用户更改表单中的某些内容时执行一些任意操作。更准确地说,我们将通过实现一个定制的活动日志来观察可观察到的情况。
活动日志
再一次,CityEditComponent将成为我们的实验鼠。
打开/ClientApp/src/app/cities/city-edit.component.ts类文件并用以下突出显示的行更新其代码:
// ...existing code...
// Activity Log (for debugging purposes)
activityLog: string = '';
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private cityService: CityService,
@Inject('BASE_URL') private baseUrl: string) {
super();
}
ngOnInit() {
this.form = new FormGroup({
name: new FormControl('', Validators.required),
lat: new FormControl('', [
Validators.required,
Validators.pattern('^[-]?[0-9]+(\.[0-9]{1,4})?$')
]),
lon: new FormControl('', [
Validators.required,
Validators.pattern('^[-]?[0-9]+(\.[0-9]{1,4})?$')
]),
countryId: new FormControl('', Validators.required)
}, null, this.isDupeCity());
// react to form changes
this.form.valueChanges
.subscribe(val => {
if (!this.form.dirty) {
this.log("Form Model has been loaded.");
}
else {
this.log("Form was updated by the user.");
}
});
this.loadData();
}
log(str: string) {
this.activityLog += "["
+ new Date().toLocaleString()
+ "] " + str + "<br />";
}
// ...existing code
在前面的代码中,我们为表单模型提供了一个简单但有效的日志功能,该功能将注册框架和/或用户执行的任何更改活动。
正如我们所看到的,所有逻辑都放在了constructor中,因为这是组件类初始化的地方,以及我们需要监视的可观察对象。log()函数只是一个快捷方式,可以将基本时间戳附加到日志活动字符串中,并以集中的方式将其添加到activityLog局部变量中。
为了充分享受我们新的日志功能,我们必须找到一种方法将activityLog显示在屏幕上。
为此,请打开/ClientApp/src/app/cities/city-edit.component.html模板文件,并在文件末尾添加以下 HTML 代码片段,位于上一个表单调试信息面板的正下方:
<!-- Form activity log panel -->
<div class="card bg-light mb-3">
<div class="card-header">Form Activity Log</div>
<div class="card-body">
<div class="card-text">
<div class="help-block">
<span *ngIf="activityLog"
[innerHTML]="activityLog"></span>
</div>
</div>
</div>
</div>
就这样,;现在,活动日志将实时显示,这意味着以真正的反应方式显示。
It's worth noting that we didn't use the double curly braces of interpolation here—we went straight for the [innerHTML] directive instead. The reason for that is very simple. The interpolation strips the HTML tags from the source string; hence, we would've lost the <br /> tag that we used in the log() function to separate all log lines with a line feed. If not for that, we would have used the {{ activityLog }} syntax instead.
测试活动日志
我们现在需要做的就是测试我们的新活动日志。
为此,在调试模式下运行项目,通过编辑已存在的城市(例如布拉格)直接进入CityEditComponent,使用表单字段,然后查看表单活动日志面板中发生的情况:

当HttpClient从后端 Web API 检索到 city JSON 并且表单模型得到更新时,第一个日志行应该会自动触发。然后,表单将记录用户执行的任何更新;我们所能做的就是改变各种输入字段,但这足以让我们谦逊的反应性测试成功完成。
扩展活动日志
对表单模型更改作出反应不是我们唯一能做的事情;我们还可以扩展订阅以观察任何表单控件。让我们对当前的活动日志实现进行进一步升级,以演示这一点
打开/ClientApp/src/app/cities/city-edit.component.ts类文件,用以下突出显示的行更新constructor方法中的代码:
// ...existing code...
// react to form changes
this.form.valueChanges
.subscribe(val => {
if (!this.form.dirty) {
this.log("Form Model has been loaded.");
}
else {
this.log("Form was updated by the user.");
}
});
// react to changes in the form.name control
this.form.get("name")!.valueChanges
.subscribe(val => {
if (!this.form.dirty) {
this.log("Name has been loaded with initial values.");
}
else {
this.log("Name was updated by the user.");
}
});
// ...existing code...
前面的代码将在表单活动日志中添加更多的日志行,所有这些都与包含城市名称的name表单控件中发生的更改有关,如下所示:

我们在这里所做的足以证明valueChanges可观测属性的奇迹;让我们转到下一个话题。
We can definitely keep the Form Debug Info and Form Activity Log panels in the CityEditComponent template for further reference, yet there's no need to copy/paste it within the other form-based Components' templates or anywhere else.
客户端调试
observables 的另一个巨大优势是,我们可以通过在订阅源代码中放置断点来使用它们调试整个反应式工作流的大部分。要快速演示这一点,只需在最新订阅中添加 Visual Studio 断点,如下所示:

完成后,以调试模式运行项目并导航到CityEditComponent;一旦加载表单模型,断点就会被命中,因为name控件也会被更新,而且每次我们对该控件进行更改时都会被更新。无论何时,我们都可以使用客户端调试中可用的所有 VisualStudioJavaScript 调试工具和功能,例如Watch、Locals、Autos、Immediate、调用堆栈等等。
For additional information about client-side debugging with Google Chrome, we strongly suggest reading the following post on the official MSDN blog:
总结
在本章中,我们讨论了一些在开发过程中非常有用的调试特性和技术。让我们试着快速总结一下到目前为止所学的内容。
我们从 VisualStudio服务器端调试功能开始了我们的旅程。这些是一组运行时调试功能,可用于防止 Web API 上的大多数编译器错误,并允许我们跟踪整个后端应用生命周期:从中间件初始化到整个 HTTP 请求/响应管道,再到控制器、实体和IQueryable对象。
*紧接着,我们转到了 VisualStudio 客户端调试功能。这是一个整洁的 JavaScript 调试器,由于 TypeScript transpiler 创建的源映射,它允许我们直接调试 TypeScript 类,并以真正高效的方式访问变量、订阅和初始值设定项。
最后,我们设计并实现了一个实时活动日志。这是一种快速有效的方法,可以利用 Angular 模块暴露的各种可见光的反应特性来跟踪我们的组件发生了什么;更不用说 VisualStudioTypeScript transpiler(和 Intellisense)有望保护我们免受大多数语法、语义和逻辑编程错误的影响,使我们免受基于脚本编程的有害影响,至少在大部分情况下是如此。
然而,如果我们想针对一些特定的用例测试我们的表单呢?有没有一种方法可以模拟后端.NET Core 控制器的行为,以及前端 Angular 组件的行为,并执行单元测试?
答案是肯定的。事实上,我们选择的两个框架提供了各种开源测试工具来执行单元测试。在下一章中,我们将学习如何使用它们来提高代码质量,并在重构、回归和新的实现过程中防止 bug。
建议的主题
Visual Studio 代码、调试器、服务器端调试、客户端调试、扩展方法、C#预处理器指令、JavaScript 源代码映射和 Angular 管道。
工具书类
- Visual Studio 代码:https://code.visualstudio.com/
- 具有 ASP.NET Core 的 Visual Studio 容器工具:https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/docker/visual-studio-tools-for-docker Visual Studio 在 Linux OSX 上对.NET Core 进行越野调试: https://github.com/Microsoft/MIEngine/wiki/Offroad-Debugging-of-.NET-Core-on-Linux---OSX-from-Visual-Studio*
*** 使用 Visual Studio:调试应用 https://docs.microsoft.com/en-US/dotnet/core/tutorials/debugging-with-visual-studio?tabs=csharp 扩展方式:https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods* Microsoft.EntityFrameworkCore 命名空间:https://docs.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore* C#预处理器指令:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/* C#中的#IF 预处理器指令:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-if* 在 Visual Studio 中调试 JavaScript 或 TypeScript 应用:https://docs.microsoft.com/en-US/visualstudio/javascript/debug-nodejs* JavaScript 源代码映射介绍:https://www.html5rocks.com/en/tutorials/developertools/sourcemaps/** 源图介绍:https://blog.teamtreehouse.com/introduction-source-maps** 角管:https://angular.io/guide/pipes* Google Chrome 中 ASP.NET 项目的客户端调试:https://blogs.msdn.microsoft.com/webdev/2016/11/21/client-side-debugging-of-asp-net-projects-in-google-chrome/* Angular 调试:https://blog.angular-university.io/angular-debugging/************************
九、ASP.NET Core 和 Angular 单元测试
单元测试是一种软件测试方法的名称,它有助于确定程序(单元)的独立模块是否正常工作。在对各个单元进行验证后,可以将它们合并在一起并作为一个整体进行测试(集成测试和系统测试)和/或在生产中发布。
根据这个定义,很容易理解正确定义和隔离各个单元的重要性。这些是我们软件中最小的可测试部分,具有少量输入和单个输出。在O面向对象编程OOP中,程序的源代码被划分为类,单元通常是超级、抽象或派生类的方法,但也可以是辅助类的静态函数。
尽管单元测试已经成为高质量项目的一个事实上的标准,但大多数开发人员和项目经理往往低估了单元测试,他们渴望加快整个开发过程,从而降低总体成本。这种方法对于利润率低的小规模项目可能是不可接受的,因为创建单元测试无疑需要一些额外的工作。然而,了解它们对中大型项目和企业解决方案的巨大好处是非常重要的,特别是当它们需要大量开发人员的协调工作时。
本章完全致力于单元测试。更准确地说,我们将学习如何定义、实现和执行以下内容:
- 后端单元在.NET Core中测试,使用xUnit.NET测试工具
- 前端单元 Angular 测试,使用Jasmine测试框架和第 2 章环视**和中简要介绍的Karma测试运行器
我们还将有机会简要介绍一些广泛使用的测试实践,它们可以帮助我们从测试中获得最大收益,例如测试驱动开发(TDD)和行为驱动开发(BDD*)。到本章结束时,我们将学习如何按照这些实践正确设计和实现后端和 f前端单元测试。*
*为了简单起见,我们将在现有的WorldCitiesAngular 应用中执行单元测试。然而,为了做到这一点,我们将向我们的项目中添加一些新的包。
技术要求
在本章中,我们需要前面章节中列出的所有先前的技术要求,以及以下附加包:
- Microsoft.NET.Test.Sdk
- 迅特
- xunit.runner.visualstudio
- 最低起订量
- Microsoft.EntityFrameworkCore.InMemory
一如既往,建议避免直接安装它们。在本章中,我们将引入它们,以便更好地将它们的目的置于项目中。
本章代码文件可在此处找到:https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition/tree/master/Chapter_09/ 。
.NET Core 单元测试
在本节中,我们将学习如何使用 xUnit.NET 构建.NET Core 单元测试项目:一个免费、开源、以社区为中心的.NET 框架单元测试工具,由 Brad Wilson 创建,他也是 NUnit v2 的开发者。我们之所以选择这个工具,是因为它可以说是当今最强大、最易于使用的单元测试工具之一。它是.NET 基金会的一部分,因此在其行为准则下运行,并在 Apache 许可证(2 版)下进行授权。
在继续之前,我们还将借此机会在以下部分讨论 TDD 和 BDD。这是两种广泛使用的测试方法,它们有许多相似之处和不同之处,值得探讨。
创建世界城市测试项目
要创建测试项目,请执行以下步骤:
- 打开命令行终端。
- 导航到
WorldCities解决方案的根文件夹(注意:解决方案根文件夹,而不是项目根文件夹!)。 - 键入以下命令并点击回车键:
> dotnet new xunit -o WorldCities.Tests
NET CLI 应该为我们创建一个新项目,并处理一些创建后操作。完成后,一条文本消息将通知我们恢复任务已完成(恢复已完成)。如果我们做的一切都正确,那么新的WorldCities.Test项目应该与现有的WorldCities项目处于相同的文件夹级别。
在此之后,我们可以通过以下方式将新的WorldCities.Tests项目添加到我们的主解决方案中:
- 在解决方案资源管理器中,右键单击根解决方案的节点,然后选择“添加现有项目”。
- 在
/WorldCities.Tests/文件夹内导航并选择WorldCities.Tests.proj文件。
新的WorldCities.Tests项目将加载到WorldCities现有项目正下方的现有解决方案中,如下图所示:
**
让我们删除现有的UnitTest1.cs文件,因为我们不需要它。我们将在短时间内创建自己的单元测试类。
新的WorldCities.Test项目应该已经有以下 NuGet 包参考:
- Microsoft.NET.Test.Sdk(版本 1.6.4 或更高版本)
- xunit(版本 2.4.1 或更高版本)
- xunit.runner.visualstudio(版本 2.4.1 或更高版本)
The preceding packages' version numbers are the latest at the time of writing and the ones that we're going to use in this book.
但是,我们还需要另外两个 NuGet 软件包:Moq和Microsoft.EntityFrameworkCore.InMemory,下面我们来看看如何添加它们。
最小起订量
Moq可以说是.NET 最流行、最友好的模拟框架。为了更好地理解我们为什么需要它,我们需要引入模仿的概念。
模拟是一种方便的功能,我们可以在单元测试中使用,只要我们要测试的单元具有外部依赖项,而这些依赖项不能在测试项目中轻松创建。模拟框架的主要目的是创建模拟真实对象行为的替换对象。Moq是一个最低限度的框架,可以做到这一点。
要安装它,请执行以下操作:
- 在解决方案资源管理器中,右键单击
WorldCities.Test项目并选择管理 NuGet 软件包。 - 搜索
Moq关键字。 - 查找并安装 Moq NuGet 软件包。
或者,只需在 Visual Studio 的包管理器控制台中键入以下命令:
> Install-Package Moq
At the time of writing, we're using Moq 4.13.1, this being the latest stable version. To be sure that you are using this version as well, just add -version 4.13.1 to the preceding command.
The latest Moq NuGet package, as well as all of the previous versions, are available here:
https://www.nuget.org/packages/moq/
就这样!我们现在需要安装另一个 NuGet 软件包。
Microsoft.EntityFramework.InMemory
Microsoft.EntityFrameworkCore.InMemory是实体框架核心的内存数据库提供程序,可用于测试目的。这与我们在第 4 章、数据模型和实体框架核心中提到的 Angular 内存 Web API 的概念基本相同。简而言之,我们可以把它看作是一个方便的数据库模拟。
**要安装它,请执行以下操作:
- 在解决方案资源管理器中,右键单击
WorldCities.Test项目并选择管理 NuGet 软件包。 - 搜索
Microsoft.EntityFrameworkCore.InMemory关键字。 - 查找并安装 Microsoft.EntityFrameworkCore.InMemoryNuGet 软件包。
**或者,只需在 Visual Studio 的包管理器控制台中键入以下命令:
> Install-Package Microsoft.EntityFrameworkCore.InMemory
At the time of writing, we're using Microsoft.EntityFrameworkCore.InMemory 3.1.1, this being the latest stable version. To be sure that you are using this version as well, just add -version 3.1.1 to the preceding command.
The latest Microsoft.EntityFrameworkCore.InMemory NuGet package, as well as all of the previous versions, are available here:
https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.InMemory/
有了这个,我们都准备好了。
添加 WorldCities 依赖项引用
我们需要做的下一件事是在新的WorldCities.Test项目的依赖项中添加对主项目的引用,以便我们能够导入所需的类和类型。
为此,右键单击新项目的 Dependencies 节点以添加对WorldCities项目的引用,如以下屏幕截图所示,然后按 OK:

通过这样做,我们的测试项目将能够访问(从而测试)整个WorldCities代码。
我们现在准备学习xUnit的实际工作原理。和往常一样,最好的方法是创建我们的第一个单元测试。
我们的第一次测试
在标准测试开发实践中,我们将从现在起称之为 STD,单元测试通常用于确保现有代码正常工作。一旦准备就绪,这些单元将受到保护,防止回归错误和破坏性更改。
由于我们的后端代码是一个 Web API,我们可以在单元测试中首先介绍的应该是单个控制器的方法。然而,在 web 应用生命周期之外实例化控制器并不是那么简单,因为它们至少有两个重要的依赖项:HttpContext和ApplicationDbContext。在我们的WorldCities.Test项目中,有没有一种方法可以实例化它们?
多亏了Microsoft.EntityFrameworkCore.InMemory,这可能是一项相当简单的任务。。。只要我们知道如何使用它。
从解决方案资源管理器中,打开WorldCities.Test项目。在项目的根目录中创建一个新的CitiesController_Test.cs文件,并用以下内容填充:
using Microsoft.EntityFrameworkCore;
using WorldCities.Controllers;
using WorldCities.Data;
using WorldCities.Data.Models;
using Xunit;
namespace WorldCities.Tests
{
public class CitiesController_Tests
{
/// <summary>
/// Test the GetCity() method
/// </summary>
[Fact]
public async void GetCity()
{
#region Arrange
// todo: define the required assets
#endregion
#region Act
// todo: invoke the test
#endregion
#region Assert
// todo: verify that conditions are met.
#endregion
}
}
}
通过查看突出显示的区域,我们可以看到,我们已将单元测试分为三个代码块或阶段:
- 安排:定义运行测试所需的资产
- 行为:调用测试对象的行为
- 断言:通过评估行为的返回值或根据一些用户定义的规则对其进行度量来验证是否满足预期条件
这种方法被称为排列、动作、断言模式。这是描述 TDD 中软件测试各个阶段的典型方式。然而,也有其他名称用于描述这些相同的测试阶段;例如,BDD 框架通常将它们称为给定、时、和然后。
TDD and BDD are two development practices that enforce a different coding approach when compared to Standard Testing Development (STD). We'll talk more about these soon enough.
无论名称如何,重要的是理解以下关键概念:
- 将三个阶段分开可以提高测试的可读性。
- 以适当的顺序执行这三个阶段可以使测试更容易理解。
现在让我们看看我们是如何实施这三个阶段的。
安排
Arrange阶段是我们定义运行测试所需的资产的地方。在我们的场景中,由于我们要测试CitiesController的GetCity()方法的功能,我们需要为控制器提供合适的ApplicationDbContext。
然而,因为我们不是在测试ApplicationDbContext本身,所以实例化真实的东西是不可取的,至少目前是这样。我们不希望因为数据库不可用或数据库连接不正确而导致测试失败,因为这些是不同的单元,因此应通过不同的单元测试进行检查。此外,我们绝对不能允许我们的单元测试对实际数据源进行操作:如果我们想测试更新或删除任务,该怎么办
我们可以做的最好的事情是测试我们的 Web API 控制器,找到一种方法为它们提供一个替换对象,它可以像我们真实的ApplicationDbContext一样工作;换句话说,这是一个嘲弄。这就是我们早期安装的Microsoft.EntityFrameworkCore.InMemoryNuGet 包可能派上用场的地方。
下面是我们如何使用它来正确实施Arrange阶段:
// ...existing code...
#region Arrange
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: "WorldCities")
.Options;
using (var context = new ApplicationDbContext(options))
{
context.Add(new City() {
Id = 1,
CountryId = 1,
Lat = 1,
Lon = 1,
Name = "TestCity1"
});
context.SaveChanges();
}
City city_existing = null;
City city_notExisting = null;
#endregion
// ...existing code...
如我们所见,我们使用了Microsoft.EntityFrameworkCore.InMemory包提供的UseInMemoryDatabase扩展方法来创建合适的DbContextOptionsBuilder。一旦我们有了它,我们就可以用它来实例化一个带有内存数据库的ApplicationDbContext会话,而不是WorldCities项目使用的 SQL Server。
一旦创建了上下文就可以通过创建新的城市来填充,这就是我们在前面的代码中所做的,用一些随机数据创建TestCity1。这将允许我们的GetCity()方法CitiesController实际检索某些内容,前提是我们将传递该城市 ID。
除此之外,我们还定义了两个City对象,其中包含本测试的两个样本。
行为
Act阶段是进行测试的阶段。它通常由一条与我们要检查的单元行为相对应的指令组成。
以下是Act阶段的实施:
// ...existing code...
#region Act
using (var context = new ApplicationDbContext(options))
{
var controller = new CitiesController(context);
city_existing = (await controller.GetCity(1)).Value;
city_notExisting = (await controller.GetCity(2)).Value;
}
#endregion
// ...existing code...
正如我们所看到的,整个实现都包含在一个using指令中,该指令确保我们的内存ApplicationDbContext实例将在阶段结束时被正确处理。
代码的其余部分完全是不言自明的。我们已经使用内存上下文创建了一个CitiesController实例,并执行了两次GetCity()方法:
- 第一种情况是检索现有的城市(使用我们用来填充内存数据库的相同的
Id)。 - 第二种情况是检索不存在的城市(使用不同的
Id。
这两个返回值随后存储在city_existing和city_notExisting变量中。理想情况下,第一个应该包含我们在排列阶段创建的TestCity1,而后一个应该是null。
明确肯定
Assert阶段的目的是验证Act阶段检索到的值是否正确满足我们预期的条件。为此,我们将使用xUnit提供的Assert类,该类包含各种静态方法,可用于验证这些条件是否满足。
以下是Assert阶段的实施:
#region Assert
Assert.True(
city_existing != null
&& city_notExisting == null
);
#endregion
如我们所见,我们只是检查两个变量的值,这两个变量包含在Act阶段进行的CitiesController的两个GetCity()方法调用的返回值。我们有理由预计city_existing不会是null,而city_notExisting肯定应该是null。
我们的测试现在已经准备好了,让我们看看如何执行它。
执行测试
每个单元测试可通过两种方式执行:
- 从命令行,使用.NET Core CLI
- 来自 Visual Studio GUI,使用 Visual Studio 的内置测试运行程序(测试浏览器)
让我们快速尝试这两种方法。
使用 CLI
要使用.NET CLI 执行我们的测试单元,请执行以下步骤:
- 打开命令提示符。
- 导航到
WorldCities.Tests项目根文件夹。 - 执行以下命令:
> dotnet test
如果我们做的每件事都是正确的,我们应该看到这样的情况:

就这样。我们的测试正在运行,并且它确实通过了,这意味着CitiesController的GetCity()方法的行为符合预期。
使用 VisualStudio 测试资源管理器
如果我们想自动化此类任务,那么能够从命令行运行测试是一个很好的特性。然而,在大多数情况下,我们更希望能够直接从 VisualStudioGUI 中运行这些测试。
幸运的是,这完全可以通过 Test Explorer 窗口实现,该窗口可以通过按Ctrl+E、T或从菜单视图激活,如以下屏幕截图所示:

一旦激活,测试资源管理器窗口应该可以在 Visual Studio GUI 最右侧的解决方案资源管理器窗口下方访问。从那里,我们可以运行所有测试,或者只运行当前测试,方法是按面板左上角的前两个绿色播放图标,称为“全部运行”和“运行”,分别(参考以下屏幕截图):

因为我们现在只有一个测试,所以这两个命令都会做同样的事情:运行我们的单元测试,并使用绿色复选(成功)或红色十字(失败)显示结果。
As we can see in the preceding screenshot, those green and/or red icons will be used to determine the combined results of the testing class, the namespace, and the whole assembly.
在继续之前,我们应该再花几分钟学习如何调试这些单元测试。
调试测试
如果单击测试资源管理器窗口左上角第二次运行图标旁边的箭头手柄,我们可以看到可以为测试提供许多其他可能的命令,包括 Debug、Debug All tests 和 Debug Last Run(请参阅以下屏幕截图):

或者,我们可以使用 Debug Tests 命令,该命令在解决方案资源管理器窗口中右键单击WorldCities.Tests项目节点时显示:

这两个命令都将在调试模式下执行测试,这意味着我们可以设置断点(或条件断点)并评估结果。
要快速测试,请在Assert区域的第一行设置断点,然后执行前面的调试测试命令,等待命中:

好了。现在,我们知道了如何调试单元测试。在采用阶段,当我们仍然不知道如何正确使用它们和/或我们仍在学习各种 xUnit.net 命令时,这可能非常有用。
Those readers who want to know more about xUnit.net for .NET Core and the unique unit test classes and methods provided by this package are strongly encouraged to check out the following URL:
https://xunit.net/docs/getting-started/netcore/cmdline
在切换到前端之前,花几分钟熟悉 TDD 和 BDD 的概念可能是值得的,因为这可以极大地帮助我们创建有用的相关测试。
测试驱动开发
TDD 与其说是一种测试方法,不如说是一种编程实践,至少对于某些场景来说,它是一种非常好的实践。
简言之,采用 TDD 方法的软件开发人员会将所有软件需求转换为特定的测试用例,然后编写新代码(或改进现有代码),以便通过测试。
让我们试着借助一个小图表来可视化这些编程实践的实际生命周期:

正如我们所看到的,TDD 主要是一种设计代码的方法,它要求开发人员在编写任何实际代码之前开始编写测试用例,以表达他们希望代码做什么(红色)。一旦完成,它要求他们只编写使测试用例通过所需的代码(绿色)。最终,当所有测试用例都通过时,可以对现有代码进行改进(重构),直到出现更多的测试用例。这种短的开发周期通常被称为红-绿-重构,是 TDD 实践的支柱。值得注意的是,红色始终是任何循环的初始步骤,因为测试在开始时总是会失败,因为可能允许他们通过的代码尚未编写。
这样的实践与 STD 实践非常不同,在 STD 实践中,我们首先生成代码,然后(可能)生成测试。换句话说,我们的源代码可以在测试用例之前(甚至没有测试用例的情况下)编写(因此通常会编写)。这两种方法之间的主要区别在于,在 TDD 中,测试是我们在 STD 中需要满足的需求条件,正如我们刚才所说的,它们主要是我们现有代码工作的证明。
在下一章中,在处理身份验证和授权时,我们将尝试使用 TDD 方法创建两个后端单元测试;毕竟,由于 TDD 实践仅在我们必须实现附加需求时才需要创建测试用例,因此使用它的最佳方式是在我们需要添加一些新特性时。
行为驱动开发
BDD 是一个敏捷的软件开发过程,与 TDD 的测试优先方法相同,但强调最终用户视角的结果,而不是专注于实现。
为了更好地理解 TDD 和 BDD 之间的关键区别,我们可以问自己以下问题:
What are we testing for?
当我们准备编写一些单元测试时,这是一个很好的问题。
如果我们想测试方法/单元的实际实现,TDD 可能是正确的方法。然而,如果我们的目标是找出我们的应用在特定情况下的最终用户行为,TDD 可能会给我们带来误报,特别是在系统发展的情况下(如敏捷驱动的项目通常所做的那样)。更具体地说,我们可能会遇到这样一种情况:一个或多个单元通过了测试,尽管未能交付预期的最终用户结果。
更笼统地说,我们可以说:
- TDD 旨在强制开发人员控制他们编写的源代码。
- BDD 旨在满足开发商和最终用户(或客户)。
因此,我们可以很容易地看到 BDD 如何取代 TDD 而不是取代它
让我们试着将这些概念总结成一个图表:

正如我们所看到的,BDD 就像 TDD 的一个扩展。我们不是编写测试用例,而是从编写行为开始。一旦我们这样做,我们将开发应用能够执行它所需的代码(可以使用 TDD),然后我们继续定义其他行为或重构现有行为。
由于这些行为是针对最终用户的,因此也必须使用可理解的术语编写。正是由于这个原因,BDD 测试通常使用一种半正式的格式来定义,这种格式借鉴了敏捷的用户故事,具有强烈的叙述性和明确的上下文。这些用户情景通常要符合以下结构:
- 标题:明确的标题,如编辑现有城市
- 叙述:一个描述性部分,使用角色/功能/从敏捷用户故事中受益模式,例如作为用户,我想编辑一个现有城市,以便我可以更改其值
- 验收标准:对三个测试阶段的描述,使用/给出的当/然后模型,这基本上是 TDD 中使用的排列/动作/断言循环的一个更容易理解的版本,例如:给定一个包含一个或多个城市的世界城市数据库;当用户选择一个城市时;然后应用必须从数据库中检索并在前端上显示
正如我们所看到的,我们只是试图用一种典型的 BDD 方法来描述我们刚才创建的单元测试。尽管它基本上是有效的,但很明显,单个行为可能需要多个后端和前端单元测试。这让我们了解 BDD 实践的另一个显著特征。前端测试阶段最重要的是测试用户行为的最佳方式,而不是实现规范。
总之,如果我们能够正确设计所需的前端和后端测试,BDD 可以是扩展标准 TDD 方法来设计测试的一个很好的方式,从而使测试结果能够面向更广泛的受众。
在下一节中,我们将学习如何做到这一点。
角单元测试
我们在本章前面几节中所说的关于.NET Core 测试目的、意义和方法的所有内容也适用于 Angular。
幸运的是,这次我们不需要安装任何东西,因为我们用来创建WorldCities项目的.NET Core 和 Angular Visual Studio 模板已经包含了我们为 Angular 应用编写应用测试所需的一切。
更具体地说,我们已经可以依赖以下软件包,我们在第 2 章环顾中简要介绍了这些软件包:
- Jasmine:一个 JavaScript 测试框架,完全支持我们前面提到的 BDD 方法
- Karma:一个让我们从命令行生成浏览器并在其中运行 Jasmine 测试(并显示结果)的工具
- 量角器:一种端到端测试框架,在真实的浏览器中对 Angular 应用运行测试,并像真实用户一样与之交互
For additional information regarding Jasmine and Karma, check out the following guides:
Karma:
https://karma-runner.github.io/
Jasmine: https://jasmine.github.io/
Protractor:
https://www.protractortest.org/
Angular Unit Test:
https://angular.io/guide/testing
在以下部分中,我们将执行以下操作:
- 查看我们
WorldCitiesAngular app 中仍然存在的测试配置文件 - 介绍 Angular 测试最重要的概念之一的测试台接口
- 探索茉莉花和因果报应以了解它们实际上是如何工作的
- 创建一些.spec.ts 文件来测试我们现有的组件
- 为我们的 Angular app 设置并配置一些测试
让我们开始吧!
一般概念
相反,从我们在.NET Core 中所做的,我们在单独的WorldCities.Tests项目中创建了单元测试,所有前端测试都将在托管 Angular 应用的同一项目中编写。
事实上,当我们第一次探索 Angular 文件夹时,我们已经在第 2 章、环视**和中看到了其中一个测试。测试写在counter.component.spec.ts文件中,我们在该章末尾删除了该文件,因为我们不再需要CounterComponent。
幸运的是,我们没有从/ClientApp/src/app/文件夹中删除以下文件:
karma.conf.js:特定于应用的 Karma 配置文件,包含有关报告者、要使用的浏览器、TCP 端口等的信息test.ts:项目单元测试的 Angular 入口点;这是 Angular 初始化测试环境的地方,配置.spec.ts扩展以识别测试文件,并从@angular/core/testing和@angular/platform-browser-dynamic/testing包加载所需的模块
这是一件好事,因为现在我们只需要为新组件编写测试。然而,在这样做之前,明智的做法是花更长的时间解释 Angular 测试实际上是如何工作的。
介绍测试平台接口
试验台接口是 Angular 测试方法中最重要的概念之一。简而言之,测试台是一个动态构建的 Angular 测试模块,模拟 Angular@NgModule的行为。
首先,Angular 2 引入了测试台概念,作为测试具有真实 DOM 的组件的便捷方法。由于它支持将服务(真实或模拟)注入到我们的组件中,以及自动绑定组件和模板,所以 TestBed 接口在这方面有很大的帮助。
为了更好地理解测试平台是如何工作的以及我们如何使用它,让我们来看看在 Tyt T0x 文件中所提供的测试平台的实现,我们在后面的第 2 章中删除了它。
TestBed.configureTestingModule({
declarations: [ CounterComponent ]
})
.compileComponents();
在前面的代码中,我们可以看到测试床如何再现一个简约的AppModule文件的行为——Angular 应用的引导@NgModule,其唯一目的是编译我们需要测试的组件。它使用 Angular 模块系统(我们在第 3 章、前端和后端交互中讨论过)来声明和编译CounterComponent,以便我们可以在测试中使用其源代码。
茉莉花试验
Jasmine 测试通常使用以下三个主要 API 构建:
describe():用于创建一组测试的包装上下文(也称为测试套件)it():单次试验的声明expect():测试的预期结果
由于与 Jasmine 测试框架的内置 Angular 集成,这些 API 将以静态方法的形式出现在我们的*.spec.ts文件中。
记住这一点,让我们为 Angular 应用创建第一个测试类文件。
我们的第一个 Angular 测试套件
现在,让我们尝试为现有的一个 Angular 组件创建我们自己的测试套件和相应的测试台。我们将使用CitiesComponent,因为我们非常了解它。
Unfortunately, the Angular CLI doesn't (yet) provide a way to automatically generate spec.ts files for existing Component classes. However, there are a number of third-party libraries that generate the specs based on Angular CLI spec presets.
The most popular (and widely used) package that does that is called ngx-spec and is available on GitHub at the following URL:
https://github.com/smnbbrv/ngx-spec
However, we're not going to use it in our specific scenario; we'll create and implement our spec.ts files manually so that we can better understand how they work.
从解决方案资源管理器中,创建一个新的/ClientApp/src/app/cities/cities.component.spec.ts文件并打开它。因为我们将要编写大量的源代码,所以明智的做法是将其分成多个块。
进口部
让我们从定义所需的import语句开始:
import { async, ComponentFixture, TestBed } from
'@angular/core/testing';
import { BrowserAnimationsModule } from
'@angular/platform-browser/animations';
import { AngularMaterialModule } from '../angular-material.module';
import { of } from 'rxjs';
import { CitiesComponent } from './cities.component';
import { City } from './city';
import { CityService } from './city.service';
import { ApiResult } from '../base.service';
// ...to be continued...
如我们所见,我们添加了一系列模块,这些模块已经在AppModule和CitiesComponent类中使用。这当然是意料之中的,因为我们的测试平台需要复制一个合适的@NgModule来运行我们的测试。
在每一节之前描述和说明
现在,我们已经获得了所有必需的参考资料,让我们看看如何使用describe()API 来布局我们的测试套件:
// ...existing code...
describe('CitiesComponent', () => {
let fixture: ComponentFixture<CitiesComponent>;
let component: CitiesComponent;
// async beforeEach(): testBed initialization
beforeEach(async(() => {
// todo: initialize the required providers
TestBed.configureTestingModule({
declarations: [CitiesComponent],
imports: [
BrowserAnimationsModule,
AngularMaterialModule
],
providers: [
// todo: reference the required providers
]
})
.compileComponents();
}));
// synchronous beforeEach(): fixtures and components setup
beforeEach(() => {
fixture = TestBed.createComponent(CitiesComponent);
component = fixture.componentInstance;
// todo: configure fixture/component/children/etc.
});
// todo: implement some tests
});
正如我们通过查看前面的代码所看到的,一切都发生在一个describe()包装上下文中,它代表了我们的CitiesComponent测试套件。与我们的CitiesComponent类相关的所有测试都将在此套件中实现。
我们在测试套件中做的第一件事是定义两个重要变量,这两个变量将在我们的测试中广泛使用:
fixture:该属性为运行测试承载CitiesComponent的固定状态;我们可以使用该装置与实例化的组件及其子元素进行交互。component:此属性将包含从前面的 fixture 创建的CitiesComponent实例。
紧接着,我们定义了两个连续的beforeEach()方法调用。为了更好地区分它们,我们使用内联注释为它们提供了不同的名称:
async beforeEach(),其中创建并初始化了测试床synchronous beforeEach(),其中夹具和组件被实例化和配置
在async beforeEach()内部,我们通过声明我们的CitiesComponent并导入其所需的两个模块BrowserAnimationModule和AngularMaterialModule定义了一个测试平台。从我们留下的todo注释中可以看出,我们还需要定义和配置我们的providers(例如CityService,否则CitiesComponent将无法注入它们;我们马上就去。在synchronous beforeEach()中,我们实例化了fixture和component变量。由于我们可能需要正确地设置它们和/或配置组件的一些子元素,因此我们在这里也留下了另一条todo注释。
在文件末尾,还有另一条todo*注释。这就是我们将使用 Jasmine 框架提供的it()和expect()API 实现测试的地方。
添加模拟城市服务
现在,我们将通过实现一个模拟CityService来替换我们的第一个todo,这样我们就可以在我们的测试床中引用它。
As we already know from the back-end testing using .NET Core and xUnit, a mock is a replacement object that simulates the behavior of the real ones.
就像.NETCore 和 xUnit 一样,Jasmine 提供了多种方法来设置模拟对象。在以下段落中,我们将简要回顾一些最常用的方法。
假服务班
我们可以创建一个假的CityService,它只返回我们测试所需的任何内容。一旦完成,我们可以在.spec.ts类中import它,并将它添加到测试床的providers列表中,这样我们的组件就可以像真正的组件一样调用它。
扩展和覆盖
我们不需要创建一个完整的双类,只需扩展真正的服务,然后重写执行测试所需的方法。完成后,我们可以使用@NgModule 的useValue特性在测试床中设置扩展类的实例。
接口实例
我们可以实例化服务的接口,实现测试所需的方法,而不是创建新的双类或扩展类。完成后,我们可以使用@NgModule 的useValue特性在测试床上设置该实例。
间谍
这种方法依赖于一种称为spy的 Jasmine 特定功能,它允许我们获取现有的类、函数或对象并对其进行模拟,以便控制其返回值。由于不会执行真正的方法,因此 spy 方法将像重写一样工作,而无需创建扩展类。
我们可以使用这样的特性来创建我们服务的真实实例,监视我们想要覆盖的方法,然后使用@NgModule 的useValue特性在我们的测试床上设置该特定实例。或者,我们可以使用jasmine.createSpyObj()静态函数创建一个具有多种间谍方法的模拟对象,然后以各种方式进行配置。
实现模拟城市服务
我们应该走哪条路线?不幸的是,对于所有场景都没有最佳答案,因为最佳方法通常取决于我们想要测试的功能的复杂性和/或我们想要如何构建测试套件。
从理论上讲,创建一个完整的假服务类可以说是最安全和通用的选择,因为我们可以完全定制我们的模拟服务返回值。然而,只要我们处理的是简单的服务和/或小规模的测试,它也可能非常耗时,而且通常是不必要的。相反,扩展和覆盖、接口、和间谍方法通常是解决大多数测试基本需求的好方法,但在复杂的测试场景中,它们可能会产生意想不到的结果,除非我们密切注意覆盖/监视所有必需的方法。
**考虑到所有因素,由于我们的CityService非常小,并且具有一个简单的实现和少量的方法,我们将使用 spy 方法,这似乎是最适合我们给定场景的方法。
让我们回到/ClientApp/src/cities/cities.components.spec.ts文件。到达后,需要更换以下代码行:
// todo: initialize the required providers
前一行代码必须替换为以下代码:
// Create a mock cityService object with a mock 'getData' method
let cityService = jasmine.createSpyObj<CityService>('CityService', ['getData']);
// Configure the 'getData' spy method
cityService.getData.and.returnValue(
// return an Observable with some test data
of<ApiResult<City>>(<ApiResult<City>>{
data: [
<City>{
name: 'TestCity1',
id: 1, lat: 1, lon: 1,
countryId: 1, countryName: 'TestCountry1'
},
<City>{
name: 'TestCity2',
id: 2, lat: 1, lon: 1,
countryId: 1, countryName: 'TestCountry1'
},
<City>{
name: 'TestCity3',
id: 3, lat: 1, lon: 1,
countryId: 1, countryName: 'TestCountry1'
}
],
totalCount: 3,
pageIndex: 0,
pageSize: 10
}));
就这样。现在,我们可以将新的模拟CityService添加到测试床配置中,替换第二个todo:
// todo: reference the required providers
这将替换为以下代码中突出显示的行:
// ...existing code...
TestBed.configureTestingModule({
declarations: [CitiesComponent],
imports: [
BrowserAnimationsModule,
AngularMaterialModule
],
providers: [
{
provide: CityService,
useValue: cityService
}
]
})
.compileComponents();
// ...existing code...
该模拟CityService现在将被注入CitiesComponent,从而使我们能够控制每次测试返回的数据。
使用接口方法的替代实现
下面是我们如何使用接口方法实现模拟CityService:
// Create a mock cityService object with a mock 'getData' method
let cityService = <CityService>{
get: () => { return null; },
put: () => { return null; },
post: () => { return null; },
getCountries: () => { return null; },
isDupeCity: () => { return null; },
http: null,
baseUrl: null,
getData: () => {
// return an Observable with some test data
return of<ApiResult<City>>(<ApiResult<City>>{
data: [
<City>{
name: 'TestCity1',
id: 1, lat: 1, lon: 1,
countryId: 1, countryName: 'TestCountry1'
},
<City>{
name: 'TestCity2',
id: 2, lat: 1, lon: 1,
countryId: 1, countryName: 'TestCountry1'
},
<City>{
name: 'TestCity3',
id: 3, lat: 1, lon: 1,
countryId: 1, countryName: 'TestCountry1'
}
],
totalCount: 3,
pageIndex: 0,
pageSize: 10
});
}
};
正如我们所看到的,代码非常相似,但是如果我们想要维护<CityService>类型断言,实现接口需要额外的代码。这就是我们使用间谍方法的原因。
配置夹具和组件
现在是移除我们/ClientApp/src/cities/cities.components.spec.ts课程中第三个todo的时候了:
// todo: configure fixture/component/children/etc.
这需要替换为以下突出显示的行:
// ...existing code...
// synchronous beforeEach(): fixtures and components setup
beforeEach(() => {
fixture = TestBed.createComponent(CitiesComponent);
component = fixture.componentInstance;
component.paginator = jasmine.createSpyObj(
"MatPaginator", ["length", "pageIndex", "pageSize"]
);
fixture.detectChanges();
});
// ...existing code...
上述代码将在每次测试前直接执行以下步骤:
- 创建一个模拟
MatPaginator对象实例。 - 触发组件上的更改检测运行。
As we might easily surmise, change detection isn't done automatically there, so we have to manually trigger it by calling the detectChanges method on our fixture. This will make our ngOnInit() method fire and populate the table with the cities. Since we're testing the Component behavior, that's definitely something to do before running our tests.
创建标题测试
我们终于准备好创建第一个测试了。
我们/ClientApp/src/cities/cities.components.spec.ts班上剩下的最后一行todo需要更换:
// todo: implement some tests
需要按如下方式替换前一行代码:
it('should display a "Cities" title', async(() => {
let title = fixture.nativeElement
.querySelector('h1');
expect(title.textContent).toEqual('Cities');
}));
正如我们所看到的,我们最终使用了it()和expect()茉莉花方法。第一个声明了我们测试的意义,而后一个根据预期评估组件的行为并确定测试结果。
在第一个测试中,我们要检查组件是否向用户显示Cities标题。因为我们知道我们的组件的模板在<H1>HTML 元素中保存标题,所以我们可以通过对fixture.nativeElement执行 DOM 查询来检查它,该根组件元素包含所有呈现的 HTML 内容。
一旦我们得到了title元素,我们就检查它的textContent属性,看看它是否是我们所期望的(Cities。这就是测试通过或失败的原因。
创建城市测试
在运行测试套件之前,让我们添加另一个测试。
再次打开/ClientApp/src/cities/cities.components.spec.ts文件,在上一次测试的正下方添加以下行:
// ...existing code...
it('should contain a table with a list of one or more cities', async(() => {
let table = fixture.nativeElement
.querySelector('table.mat-table');
let tableRows = table
.querySelectorAll('tr.mat-row');
expect(tableRows.length).toBeGreaterThan(0);
}));
// ...existing code...
这一次,我们正在检查包含城市列表的表。更准确地说,我们正在计算表体行数,以确保生成的数字大于零,这意味着该表已填充至少一个城市。要执行此计算,我们使用 Angular Material 默认分配给其MatTable组件的 CSS 类。
为了更好地理解这一点,请查看以下屏幕截图:

我们可以看到,mat-rowCSS 类只应用于表体行,而表头行有mat-header-row类。因此,如果测试通过,这肯定意味着组件在表中至少创建了一行。
IMPORTANT: It goes without saying that relying upon CSS classes applied by a third-party package to define our tests is not a good practice. We're doing that just to demonstrate what we can do with our current implementation. A safer approach for such DOM-based tests would arguably require defining custom CSS classes and checking for their presence instead.
运行测试套件
现在是时候运行我们的测试套件了,看看我们得到了什么。
为此,请执行以下步骤:
- 打开命令提示符。
- 导航到
WorldCities应用的/ClientApp/文件夹。 - 执行以下命令:
> ng test
这将启动 Karma test runner,它将打开一个专用浏览器来运行测试。如果我们已经正确地完成了所有操作,我们应该能够看到以下结果:

就这样。两项测试都通过了。为了 100%确定我们做的每件事都是正确的,现在让我们试着让他们失败。
再次打开/ClientApp/src/cities/cities.components.spec.ts文件,按以下方式修改测试的源代码(更新的行高亮显示):
it('should display a "Cities" title', async(() => {
let title = fixture.nativeElement
.querySelector('h1');
expect(title.textContent).toEqual('Cities!!!');
}));
it('should contain a table with a list of one or more cities', async(() => {
let table = fixture.nativeElement
.querySelector('table.mat-table');
let tableRows = table
.querySelectorAll('tr.mat-row');
expect(tableRows.length).toBeGreaterThan(3);
}));
现在,我们的第一个测试将期望一个不正确的标题值,第二个测试将查找三行以上的内容,但情况并非如此,因为我们的模拟CityService已配置为服务其中三行
保存文件后,Karma 测试运行程序应自动重新加载测试页面并显示更新的结果(请参阅以下屏幕截图):

好了。现在,我们正经历两次失败,正如预期的那样。Jasmine 框架还告诉我们出了什么问题,以便我们能够及时解决这些问题。
让我们这样做。打开/ClientApp/src/cities/cities.components.spec.ts文件,将测试的源代码恢复到以前的状态:
it('should display a "Cities" title', async(() => {
let title = fixture.nativeElement
.querySelector('h1');
expect(title.textContent).toEqual('Cities');
}));
it('should contain a table with a list of one or more cities', async(() => {
let table = fixture.nativeElement
.querySelector('table.mat-table');
let tableRows = table
.querySelectorAll('tr.mat-row');
expect(tableRows.length).toBeGreaterThan(0);
}));
就这样。现在我们已经测试了我们的测试套件,我们可以通过在ng test终端窗口上按Ctrl+C关闭测试运行程序,然后选择Y(并点击Enter**终止批处理作业。
至此,我们通过前端*测试结束了我们的学习之旅。
总结
本章完全致力于测试和单元测试的概念。在简要介绍了这些概念的含义和各种可用的测试实践之后,我们花了一些宝贵的时间学习如何正确地实现它们。
在 xUnit.net 测试工具的帮助下,我们开始关注后端测试。这种方法要求我们创建一个新的测试项目,在那里我们实现了第一个后端单元测试。在学习过程中,我们了解了一些与测试相关的概念的重要性,例如 mocking,我们用来模拟ApplicationDbContext类的行为,以提供一些内存中的数据,而不是使用 SQL Server 数据源。
后端测试方法极大地帮助我们理解 TDD 的含义及其与 BDD 方法的异同,BDD 方法是一种独特的前端测试实践。
这样的比较将我们引向 Angular,我们使用Jasmine测试框架和Karma测试运行器开发了一些前端测试。在这里,我们有机会学习一些良好的测试实践,以及与 Jasmine 框架严格相关的其他重要概念,如测试床、测试套件和 spies。最终,我们在WorldCities应用中成功地看到了我们的测试。
在下一章中,我们将尝试在处理授权和身份验证主题时设计更多的测试。我们在这里学到的概念在必须实现注册和登录工作流时肯定非常有用。
建议的主题
单元测试、xUnit、Moq、TDD、BDD、模拟、存根、夹具、Jasmine、Karma、量角器、Spy、测试套件、测试床。
工具书类
- 迅网入门:https://xunit.net/docs/getting-started/netcore/cmdline
- 在.NET Core 和.NET 标准中进行单元测试:https://docs.microsoft.com/en-US/dotnet/core/testing/
- ASP.NET Core 中的单元测试控制器逻辑:https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/testing
- 使用说明(C#):https://docs.microsoft.com/en-US/dotnet/csharp/language-reference/keywords/using-statement
- xUnit.net–使用.NET Core 和.net SDK 命令行:https://xunit.net/docs/getting-started/netcore/cmdline
- Angular 测试:https://angular.io/guide/testing
- 量角器:端对端测角:https://www.protractortest.org/
- Jasmine:行为驱动 JavaScript:https://jasmine.github.io/
- Karma:JavaScript 的壮观测试运行者:https://karma-runner.github.io/latest/index.html
- Angular 测试:组件夹具:https://angular.io/api/core/testing/ComponentFixture
- Angular 参考:ngAfterViewInit:https://ngrefs.com/latest/core/ng-after-view-init*************
十、认证和授权
一般来说,术语认证指的是任何验证某人(无论是人还是自动系统)是其声称的人(或什么)的过程。在万维网(WWW的上下文中也是如此,该词主要用于表示网站或服务使用的任何技术,以从用户代理(通常是 Web 浏览器)收集一组登录信息,并使用成员资格和/或身份服务对其进行身份验证。
不要将认证与授权混为一谈,因为这是一个不同的过程,负责一项非常不同的任务。为了给出一个快速定义,我们可以说授权的目的是确认允许请求用户访问他们想要执行的操作。换句话说,身份验证是关于他们是谁,授权是关于他们可以做什么。
为了更好地理解这两个显然相似的概念之间的差异,我们可以考虑两种现实场景:
- 一个免费但注册的账户,试图获得付费或仅限高级的服务或功能;这是已验证但未授权访问的常见示例;我们知道他们是谁,但他们不被允许去那里。
- 试图访问公开可用页面或文件的匿名用户;这是一个未经认证但经授权的访问示例;我们不知道他们是谁,但他们可以像其他人一样获得公共资源。
认证和授权将是本章的主要主题,我们将尝试从理论和实践的 Angular 来解决。更准确地说,我们将做以下工作:
- 讨论一些典型场景,其中可能需要或不需要身份验证和授权。
- 引入 ASP.NET Core Identity,这是一个现代会员制系统,允许开发人员向其应用添加登录功能,以及
IdentityServer,中间件设计用于向任何 ASP.NET Core 应用添加 OIDC 和 OAuth 2.0 端点。 - 实现 ASP.NET Core 身份和****身份服务器为我们现有的
WorldCities应用添加登录和注册功能。 - 探索.NET Core 和 Angular Visual Studio 项目模板提供的 Angular 授权 API,该 API 实现了oidc 客户端npm 包,与 ASP.NET Core 身份系统提供的 URI 端点交互,以及一些关键 Angular 特性,如路由防护以及HTTP 拦截器,处理整个授权流程。
- 将上述后端和前端 API集成到我们的
WorldCities项目中,为我们的用户提供满意的认证和认证体验。
让我们尽力而为!
技术要求
在本章中,我们需要前面章节中列出的所有技术要求,以及以下附加包:
Microsoft.AspNetCore.Identity.EntityFrameworkCoreMicrosoft.AspNetCore.ApiAuthorization.IdentityServerMicrosoft.AspNetCore.Identity.UI
和往常一样,避免直接安装它们是明智的:我们将在本章中引入它们,以便更好地将它们的用途与我们的项目联系起来。
授权,还是不授权
事实上,对于大多数基于 web 的应用或服务,实现身份验证和/或授权逻辑并不是强制性的;有许多网站仍然没有做到这一点,主要是因为它们提供的内容可以在任何时候被任何人访问。直到几年前,这在大多数公司、营销和信息网站中都很常见;那是在他们的所有者了解到建立注册用户网络的重要性以及这些“忠诚”联系人如今的价值之前。
我们不需要成为有经验的开发者,就可以认识到 WWW 在过去几年中发生了多大的变化;如今,每一个网站,无论其目的如何,都有越来越多或或多或少的合法兴趣跟踪其用户,让他们有机会定制导航体验、与社交网络互动、收集电子邮件地址等等。如果没有某种身份验证机制,上述任何操作都无法完成。
有几十亿个网站和服务需要身份验证才能正常工作,因为它们的大部分内容和/或意图取决于注册用户的行为:论坛、博客、购物车、基于订阅的服务,甚至维基等协作工具。
长话短说,答案是肯定的;只要我们想让用户在我们的客户端应用中执行创建、读取、更新和删除(CRUD操作,毫无疑问我们应该实施某种身份验证和授权程序。如果我们的目标是生产就绪的单页应用(SPA),具有任何类型的用户交互,我们肯定想知道我们的用户的姓名和电子邮件地址。这是确定谁能够查看、添加、更新或删除我们的记录的唯一方法,更不用说执行管理级别的任务、跟踪我们的用户等等。
认证
自 WWW 诞生以来,绝大多数认证技术都依赖于HTTP/HTTPS 实现标准,它们的工作方式大致如下:
-
未经身份验证的用户代理请求未经某种许可无法访问的内容。
-
web 应用返回身份验证请求,通常以 HTML 页面的形式返回,其中包含要完成的空 web 表单。
-
用户代理使用他们的凭证(通常是用户名和密码)填写 web 表单,然后使用
POST命令发送回表单,该命令很可能是通过单击提交按钮发出的。 -
web 应用接收
POST数据并调用前面提到的服务器端实现,该实现将尝试使用给定的输入对用户进行身份验证,并返回适当的结果。 -
如果结果成功,web 应用将对用户进行身份验证并将相关数据存储在某处,具体取决于所选的身份验证方法:会话/cookie、令牌、签名等等(我们将在后面讨论)。相反,结果将在错误页面中以可读结果的形式呈现给用户,可能会要求用户重试、联系管理员或其他。
这仍然是当今最常见的方法。我们所能想到的几乎所有网站都在使用它,尽管在安全层、状态管理、JSON Web 令牌(JWT)或其他 RESTful 令牌、基本或摘要访问、单点登录属性等方面存在许多大小差异。
第三方认证
除了要求用户开发可能导致安全风险的自定义密码存储技术外,每次访问网站时被迫使用可能不同的用户名和密码可能会令人沮丧。为了克服这个问题,大量 IT 开发人员开始寻找一种替代方法来验证用户,这种方法可以用基于可信第三方提供商的验证协议取代基于用户名和密码的标准验证技术。
OpenID 的兴衰
在实现第三方认证机制的第一次成功尝试中,第一次发布了“OpenTID”OpenID AuthT1,这是一个由非营利 OpenID 基金会推动的开放和分散认证协议。自 2005 年推出以来,谷歌和 Stack Overflow 等一些大公司迅速而热情地采用了该技术,他们最初是基于该技术的身份验证提供商。
以下是它的工作原理:
- 每当我们的应用收到 OpenID 身份验证请求时,它就会通过请求用户和可信的第三方身份验证提供商(例如,谷歌身份验证提供商)打开一个透明的连接界面;该接口可以是弹出窗口、AJAX、填充模式窗口或 API 调用,具体取决于实现。
- 用户将其用户名和密码发送给上述第三方提供商,第三方提供商相应地执行身份验证,并通过将用户重定向到其来源地,以及可用于检索身份验证结果的安全令牌,将结果传递给我们的应用。
- 我们的应用使用令牌来检查身份验证结果,在成功的情况下对用户进行身份验证,或者在失败的情况下发送错误响应。
尽管 2005 至 2009 年间的热情高涨,但有许多相关公司公开宣布支持 OpenID,甚至加入了包括贝宝和脸谱网在内的基金会,但最初的协议没有达到其最大的期望:法律争议、安全问题,最重要的是,2009-2012 年期间,社交网络的大规模流行,以及基于OAuth 的社交登录,基本上扼杀了它。
Those who don't know what OAuth is, have some patience; we'll get there soon enough.
OpenID 连接
2014 年 2 月,OpenID 基金会发布了 OpenID 技术的第三代,在一次绝望的尝试中,在接管了 OAuth/OAuth2 To1 的社会登录之后,保持旗飞行;这被称为OpenID 连接(OIDC)。
尽管有这个名字,新的部分与它的祖先几乎没有关系;它只是一个基于 OAuth2 授权协议的身份验证层。换句话说,它只不过是一个标准化的接口,帮助开发人员以一种不那么不恰当的方式使用 OAuth2 作为身份验证框架,考虑到 OAuth2 在最初推出 OpenID2.0 时起到了主要作用,这有点可笑。
2014 年,放弃 OpenID 而选择 OIDC 受到了强烈批评;然而,经过 3 年多的时间,我们可以肯定地说,OIDC 仍然可以提供一种有用的、标准化的获取用户身份的方法。它允许开发人员使用一个方便的、基于 RESTful 的 JSON 接口请求和接收有关经过身份验证的用户和会话的信息;它具有一个可扩展的规范,还支持一些有前途的可选功能,如身份数据加密、OpenID 提供程序的自动发现,甚至会话管理。简而言之,它仍然足够有用,可以用来代替纯 OAuth2。
For additional information about OpenID, we strongly suggest reading the following specifications from the OpenID Foundation official website:
OpenID 连接:
http://openid.net/specs/openid-connect-core-1_0.html 。
OpenID2.0 到 OIDC 迁移指南:
http://openid.net/specs/openid-connect-migration-1_0.html 。
批准
在大多数标准实现中,包括 ASP.NET 所特有的实现,授权阶段在身份验证之后立即开始,并且主要基于权限或角色;任何经过身份验证的用户都可能拥有自己的权限集和/或属于一个或多个角色,因此被授予对特定资源集的访问权限。这些基于角色的检查通常由开发人员在应用源代码和/或配置文件中以声明方式设置。
正如我们所说的,授权不应该与身份验证混淆,尽管它也可以很容易地被利用来执行隐式身份验证,特别是当它被委托给第三方参与者时。
第三方授权
目前最著名的第三方授权协议是 OAuth 的 2.0 版本,也称为 OAuth2,它取代了 Blaine Cook 和 Chris Messina 于 2006 年最初开发的前一版本(OAuth 1 或简称 OAuth)。
我们已经谈论了很多关于它的好理由:OAuth 2 已经很快成为行业标准的授权协议,目前被大量基于社区的网站和社交网络使用,包括谷歌、Facebook 和 Twitter。它基本上是这样工作的:
- 每当现有用户通过 OAuth 向我们的应用请求一组权限时,我们都会在他们和我们的应用信任的第三方授权提供商(例如,Facebook)之间打开一个透明的连接接口。
- 提供商承认用户,如果用户拥有适当的权限,则通过向其委托一个临时的、特定的访问密钥进行响应。
- 用户向我们的应用提供访问密钥,并将被授予访问权限。
我们可以清楚地看到,利用这种授权逻辑进行身份验证是多么容易;毕竟,如果 Facebook 说我能做点什么,难道这不意味着我就是我所声称的那个人吗?这还不够吗?
简而言之,答案是否定的。Facebook 可能就是这样,因为其 OAuth 2 实现意味着接收授权的订阅者必须先通过 Facebook 的身份验证;然而,本保证书并未在任何地方书写。考虑到有多少网站使用它进行身份验证,我们可以假设 Facebook 不太可能改变他们的实际行为,但我们对此没有任何保证。
从理论上讲,这些网站可以随时将其授权系统从其身份验证协议中分离出来,从而导致我们的应用的身份验证逻辑处于不可恢复的不一致状态。更一般地说,我们可以说,假设某事物来自其他事物几乎总是一种糟糕的做法,除非该假设建立在非常坚实、有充分证据证明且(最重要的)有高度保证的基础上。
专有与第三方
从理论上讲,可以将身份验证和/或授权任务完全委托给现有的外部第三方提供商,如我们前面提到的提供商;现在有很多 web 和移动应用都自豪地遵循这条路线。使用这种方法有许多不可否认的优点,包括:
- 没有特定于用户的 DB 表/数据模型,只有一些基于提供者的标识符,可以在此处和那里用作参考键。
- 立即注册,因为不需要填写注册表单,也不需要等待确认电子邮件,没有用户名,没有密码。这将得到大多数用户的认可,并可能提高我们的转换率。
- 很少或没有隐私问题因为应用服务器上没有个人或敏感数据。
- 无需处理用户名和密码并执行自动恢复流程。
- 更少的安全相关问题例如基于表单的黑客攻击尝试或暴力登录尝试。
当然,也有一些不利因素:
- 不会有实际的用户基础,因此很难了解活跃用户的概况、获取他们的电子邮件地址、分析统计数据等。
- 登录阶段可能是资源密集型,因为它始终需要与第三方服务器进行外部、来回安全连接。
- 所有用户都需要在所选的第三方提供商处拥有(或开立)一个帐户才能登录。
- 所有用户都需要信任我们的应用,因为第三方提供商会要求他们授权它访问他们的数据。
- 我们必须向提供商注册我们的应用,以便能够执行许多必需或可选的任务,例如接收我们的公钥和密钥,授权一个或多个 URI 启动器,以及选择我们想要收集的信息。
考虑到所有这些利弊,我们可以说,对于包括我们在内的小规模应用来说,依赖第三方提供商可能是一个非常省时的选择;然而,构建我们自己的账户管理系统似乎是克服上述基于治理和控制的缺陷的唯一途径,这些缺陷不可否认是由该方法带来的。
在这本书中,我们将探索这两条路线,试图充分利用这两个世界。在本章中,我们将创建一个内部成员资格提供者,它将处理身份验证并提供自己的授权规则集;在下一章中,我们将进一步利用相同的实现来演示如何让用户有机会使用示例第三方身份验证提供商(Facebook)登录,并使用其 SDK 和 API 获取创建相应内部用户所需的数据,感谢 ASP.NET Core 标识包提供的内置功能。
具有.NET Core 的专有身份验证
ASP.NET Core 提供的身份验证模式基本上与以前版本的 ASP.NET 支持的模式相同:
- 没有身份验证如果我们不想实现任何东西,或者如果我们想使用(或开发)一个自制的身份验证接口而不依赖 ASP.NET Core 身份系统
- 个人用户账户,当我们使用标准 ASP.NET Core 身份界面建立内部数据库存储用户数据时
- Azure Active Directory,这意味着使用由Azure AD 验证库(ADAL处理的基于令牌的 API 调用集)
- Windows 身份验证,仅适用于 Windows 域或 Active Directory 树中的本地范围应用
然而,ASP.NET Core 团队在过去几年中引入的实现模式正在不断发展,以匹配可用的最新安全实践。
除第一种方法外,上述所有方法均由ASP.NET Core 身份系统处理,这是一种会员制系统,允许我们向应用添加身份验证和授权功能。
For additional info about the ASP.NET Core Identity APIs, check out the following URL:
https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity
从.Net Cype 3 开始,ASP.NET Core 标识已经集成了一个新的 API 授权机制来处理 SPAS 中的身份验证:这个新的特征是基于 GoeT0T,一个开源的 OIDC 和 OAuth2 中间件,它是.NETCype 3 的.NET 基金会的一部分。
Further information about IdentityServer can be retrieved from the official documentation website, which is available at the following URLs:
https://identityserver.io/ http://docs.identityserver.io/en/latest/
有了 ASP.NET Core 标识,我们可以轻松实现登录机制,允许用户创建帐户并使用用户名和密码登录。除此之外,我们还可以让他们有机会使用外部登录提供者,只要框架支持;截至今天,可用的提供商名单包括 Facebook、Google、Microsoft 帐户、Twitter 等。
在本节中,我们将执行以下操作:
- 介绍 ASP.NET Core 身份模型,ASP.NET Core 提供的管理和存储用户账户的框架。
- 通过将所需的 NuGet 软件包安装到我们现有的
WorldCities应用,设置 ASP.NET Core 身份实现。 - 使用个人用户账户认证类型扩展 ApplicationDbContext。
- 在我们应用的
Startup类中配置身份服务。 - 通过添加使用.NET Identity API 提供程序创建默认用户的方法来更新现有的 SeedController。
在这之后,我们还将考虑几个关于 ASP.NET Core(Oracle T0)任务异步编程 To1 T1 ^(Po.T2 TAP OutT3)模型的词。
ASP.NET Core 身份模型
ASP.NET Core 提供了一个统一的框架来管理和存储用户帐户,这些帐户可以在任何.NET Core 应用(甚至非 web 应用)中轻松使用;此框架称为ASP.NET Core 标识,提供了一组 API,允许开发人员处理以下任务:
- 设计、设置并实现用户注册和登录功能。
** 管理用户、密码、配置文件数据、角色、声明、令牌、电子邮件确认等。* 支持外部(第三方)登录提供商,如 Facebook、Google、Microsoft 帐户、Twitter 等。*
*ASP.NET Core 标识源代码是开源的,可在 GitHub 的上获得 https://github.com/aspnet/AspNetCore/tree/master/src/Identity 。
不用说,ASP.NET Core 标识需要一个持久数据源来存储(和检索)标识数据(用户名、密码和配置文件数据),例如 SQL Server 数据库:正是出于这个原因,它具有与实体框架核心的内置集成机制。
这意味着,为了实现我们自己的身份系统,我们将基本上扩展我们在第 4 章、数据模型和实体框架核心中所做的工作;更具体地说,我们将更新现有的ApplicationDbContext,以支持处理用户、角色等所需的其他实体类。
实体类型
ASP.NET Core Identity platform 强烈依赖于以下实体类型,每种实体类型代表一组特定的记录:
User:我们应用的用户Role:我们可以分配给每个用户的角色UserClaim:用户拥有UserToken:用户可能用于执行基于身份验证的任务(如登录)的身份验证令牌UserLogin:与每个用户关联的登录账号RoleClaim:授予给定角色内所有用户的声明UserRole:用于存储用户与其分配角色之间关系的查找表
这些实体类型通过以下方式相互关联:
- 每个
User可以有多个UserClaim、UserLogin和UserToken实体(一对多)。
** 每个Role可以有多个相关的RoleClaim实体(一对多。* 每个User可以关联多个Role实体,每个Role可以关联多个User实体(多对多。*
*多对多关系需要数据库中的联接表,该联接表由UserRole实体表示。
幸运的是,我们不必从头开始手动实现所有这些实体,因为 ASP.NET Core Identity 为每个实体提供了一些默认的公共语言运行时(CLR)类型:
IdentityUserIdentityRoleIdentityUserClaimIdentityUserTokenIdentityUserLoginIdentityRoleClaimIdentityUserRole
当我们需要显式定义身份相关的实体模型时,这些类型可以用作我们自己实现的基类;此外,它们中的大多数不必在最常见的身份验证场景中实现,因为它们的功能可以在更高的级别上处理,这要归功于 ASP.NET Core API 标识集,这些 API 可以从以下类访问:
RoleManager<TRole>:提供管理角色的 APISignInManager<TUser>:提供用户登录和注销(登录和注销)的 APIUserManager<TUser>:提供管理用户的 API
一旦正确配置和设置了 ASP.NET Core 标识服务,就可以使用依赖注入(DI)将这些提供者注入到我们的.NET 控制器中,就像我们使用ApplicationDbContext一样;在下一节中,我们将了解如何做到这一点。
设置 ASP.NET Core 标识
在第一章、准备和第三章、前端和后端交互中,当我们创建我们的HealthCheck和WorldCitiesNET Core 项目时,我们总是选择使用一个没有身份验证的空项目。这是因为我们不希望 Visual Studio 从一开始就在应用的启动文件中安装ASP.NET Core 标识。但是,现在我们将使用它,我们需要手动执行所需的设置步骤。
添加 NuGet 包
理论讲够了,让我们把计划付诸行动吧。
在解决方案资源管理器中,右键单击WorldCities树节点,然后选择管理 NuGet 软件包,查找以下两个软件包,并安装它们:
Microsoft.AspNetCore.Identity.EntityFrameworkCoreMicrosoft.AspNetCore.ApiAuthorization.IdentityServer
或者,打开Package Manager Console并使用以下命令进行安装:
> Install-Package Microsoft.AspNetCore.Identity.EntityFrameworkCore
> Install-Package Microsoft.AspNetCore.ApiAuthorization.IdentityServer
在撰写本文时,两者的最新版本均为3.1.1;和往常一样,只要我们知道如何相应地调整代码以修复潜在的兼容性问题,我们就可以免费安装新版本。
创建应用用户
现在我们已经安装了所需的标识库,我们需要创建一个新的ApplicationUser实体类,该类具有 ASP.NET Core 标识服务所需的所有功能,以便将其用于身份验证目的。幸运的是,这个包附带了一个内置的IdentityUser基类,可以用来扩展我们自己的实现,从而提供我们所需要的一切。
在解决方案资源管理器中,导航到/Data/Models/文件夹,然后创建一个新的ApplicationUser.cs类,并用以下代码填充其内容:
using Microsoft.AspNetCore.Identity;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace WorldCities.Data.Models
{
public class ApplicationUser : IdentityUser
{
}
}
正如我们所看到的,我们不需要在那里实现任何东西,至少目前是这样:我们只需要扩展IdentityUser基类,它已经包含了我们现在所需要的一切。
扩展 ApplicationDbContext
为了支持.NET Core 身份验证机制,我们现有的ApplicationDbContext需要从支持 ASP.NET Core 身份和IdentityServer的不同数据库抽象基类进行扩展。
打开/Data/ApplicationDbContext.cs文件并相应更新其内容(更新的行突出显示):
using IdentityServer4.EntityFramework.Options;
using Microsoft.AspNetCore.ApiAuthorization.IdentityServer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using WorldCities.Data.Models;
namespace WorldCities.Data
{
public class ApplicationDbContext : ApiAuthorizationDbContext<ApplicationUser>
{
#region Constructor
public ApplicationDbContext(
DbContextOptions options,
IOptions<OperationalStoreOptions> operationalStoreOptions)
: base(options, operationalStoreOptions)
{
}
#endregion Constructor
#region Methods
protected override void OnModelCreating(ModelBuilder
modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Map Entity names to DB Table names
modelBuilder.Entity<City>().ToTable("Cities");
modelBuilder.Entity<Country>().ToTable("Countries");
}
#endregion Methods
#region Properties
public DbSet<City> Cities { get; set; }
public DbSet<Country> Countries { get; set; }
#endregion Properties
}
}
从前面的代码可以看出,我们用新的ApiAuthorizationDbContext基类更改了当前的DbContext基类;新类强烈依赖于IdentityServer中间件,这也需要更改构造函数签名以接受正确配置操作上下文所需的一些选项。
For additional information about the .NET authentication and authorization system for SPA, ASP.NET Core Identity API, and the .NET Core IdentityServer, check out the following URL:
https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-api-authorization
调整单元测试
一旦我们保存了新的ApplicationDbContext文件,WorldCities.Tests项目中现有的CitiesController_Tests.cs类很可能会抛出一个编译器错误,如下截图所示:

原因在错误列表面板中得到了很好的解释:ApplicationDbContext的构造函数签名已更改,需要一个额外的参数,我们在此不传递。
It's worth noting that this issue doesn't affect our main application's Controllers since ApplicationDbContext is injected through DI there.
要快速修复此问题,请按以下方式更新CitiesController_Tests.cs现有源代码(突出显示新的和更新的行):
using IdentityServer4.EntityFramework.Options;
// ...existing code...
var storeOptions = Options.Create(new OperationalStoreOptions());
using (var context = new ApplicationDbContext(options, storeOptions))
// ...existing code...
现在错误应该消失了(测试应该仍然通过)。
配置 ASP.NET Core 标识中间件
现在我们已经完成了所有的先决条件,我们可以打开Startup.cs文件并在ConfigureServices方法中添加以下突出显示的行,以设置 ASP.NET Core 标识系统所需的中间件:
// ...existing code...
// This method gets called by the runtime. Use this method to add
// services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews()
.AddJsonOptions(options => {
// set this option to TRUE to indent the JSON output
options.JsonSerializerOptions.WriteIndented = true;
// set this option to NULL to use PascalCase instead of
// CamelCase (default)
// options.JsonSerializerOptions.PropertyNamingPolicy = null;
});
// In production, the Angular files will be served from
// this directory
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/dist";
});
// Add EntityFramework support for SqlServer.
services.AddEntityFrameworkSqlServer();
// Add ApplicationDbContext.
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")
)
);
// Add ASP.NET Core Identity support
services.AddDefaultIdentity<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
services.AddAuthentication()
.AddIdentityServerJwt();
}
// ...existing code...
然后,在Configure方法中,添加以下高亮显示的行:
// ...existing code...
app.UseRouting();
app.UseAuthentication();
app.UseIdentityServer();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
// ...existing code...
前面的代码与 SPA 项目的默认.NET Core 标识实现非常相似。如果我们使用 Visual Studio 向导创建了一个新的ASP.NET Core web 应用,选择个人用户帐户身份验证方法(请参见下面的屏幕截图),我们最终会得到相同的代码,但有一些细微的差异:

与默认实现相反,在我们的代码中,我们抓住机会覆盖了一些默认密码策略设置,以演示如何配置标识服务以更好地满足我们的需要。
让我们再看一看前面的代码,强调更改(突出显示的行):
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireDigit = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
正如我们所看到的,我们没有更改RequireConfirmedAccount默认设置,这需要一个确认的用户帐户(通过电子邮件验证)才能登录。我们所做的是明确设置密码强度要求,以便所有用户的密码都需要具有以下内容:
- 至少一个小写字母
- 至少有一个大写字母
- 至少一个数字字符
- 至少一个非字母数字字符
- 最小长度为八个字符
这将赋予我们的应用一个相当高的身份验证安全级别,如果我们想让它在 web 上公开访问的话。不用说,我们可以根据具体需要更改这些设置;只要我们不向公众开放,开发示例可能会使用更宽松的设置。
值得注意的是,前面的代码需要引用我们刚才安装的与身份相关的新软件包:
// ...existing code...
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; // ...existing code...
此外,我们还需要引用用于数据模型的名称空间,因为我们现在引用的是ApplicationUser类:
// ...existing code...
using WorldCities.Data.Models;
// ...existing code...
既然我们已经正确地配置了Setup类,那么我们需要对IdentityServer进行同样的配置。
配置 IdentityServer
为了正确设置IdentityServer中间件,我们需要在现有appSettings.json配置文件中添加以下行(突出显示新行):
{
"ConnectionStrings": {
"DefaultConnection": "(your connnection string)"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"IdentityServer": {
"Clients": {
"WorldCities": {
"Profile": "IdentityServerSPA"
}
},
"Key": {
"Type": "Development"
}
},
"AllowedHosts": "*"
}
如我们所见,我们为IdentityServer添加了一个客户端,这将是我们的 Angular 应用。"IdentityServerSPA"配置文件表示应用类型,它在内部用于生成该类型的服务器默认值。在我们的场景中,IdentityServer作为一个单元与 SPA 一起托管。
以下是IdentityServer将为我们的应用类型加载的默认值:
redirect_uri默认为/authentication/login-callback。post_logout_redirect_uri默认为/authentication/logout-callback。- 范围集包括
openID、Profile以及为应用中的 API 定义的每个范围。 - 允许的 OIDC 响应类型集为
id_token token或它们各自(id_token、token)。 - 允许的响应模式为片段。
其他可用配置文件包括以下内容:
- SPA:不与
IdentityServer托管的 SPA - IdentityServerJwt:与
IdentityServer一起托管的 API - API:非托管
IdentityServer的 API
在继续之前,我们需要对我们的appSettings.Development.json文件执行另一个IdentityServer相关更新。
*# 更新 appSettings.Development.json 文件
从Chapter 2环顾可知,appSettings.Development.json文件用于为开发环境指定额外的配置键/值对(和/或覆盖现有的键/值对)。这正是我们现在需要做的,因为IdentityServer需要一些不应该投入生产的特定于开发的设置。
打开appSettings.Development.json文件,添加以下内容(新行突出显示):
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
},
"IdentityServer": {
"Key": {
"Type": "Development"
}
}
}
}
我们在前面代码中添加的"Key"元素描述了将用于签名令牌的密钥;目前,由于我们仍处于开发阶段,该键类型将正常工作。然而,当我们想要将应用部署到生产环境中时,我们需要在应用旁边提供并部署一个真正的密钥。当我们达到这个目标时,我们必须向appSettings.json生产文件中添加一个"Key"元素,并对其进行相应的配置;我们将在第 12 章、Windows 和 Linux 部署中详细介绍这一点。
**在此之前,最好避免将其添加到生产设置中,以防止我们的 web 应用在不安全模式下运行
For additional information about the IdentityServer and its configuration parameters, check out the following URL:
https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-api-authorization
现在我们已经准备好创建我们的用户。
修正种子控制器
从零开始创建新用户的最佳方式是从SeedController开始,它实现了我们在第 4 章中建立的播种机制,数据模型和实体框架核心;然而,为了与实现此目的所需的.NET Core 标识 API 进行交互,我们需要使用 DI 注入它们,就像我们已经对ApplicationDbContext所做的那样。
通过 DI 添加 RoleManager 和 UserManager
在解决方案资源管理器中,打开WorldCities项目的/Controllers/SeedController.cs文件,并使用以下代码相应地更新其内容(突出显示新的/更新的行):
using Microsoft.AspNetCore.Identity;
// ...existing code...
public class SeedController : ControllerBase
{
private readonly ApplicationDbContext _context;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IWebHostEnvironment _env;
public SeedController(
ApplicationDbContext context,
RoleManager<IdentityRole> roleManager,
UserManager<ApplicationUser> userManager,
IWebHostEnvironment env)
{
_context = context;
_roleManager = roleManager;
_userManager = userManager;
_env = env;
}
// ...existing code...
正如我们所看到的,我们添加了我们之前提到的RoleManager<TRole>和UserManager<TUser>提供者;我们使用 DI 实现了这一点,就像我们在第 4 章中对ApplicationDbContext和IWebHostEnvironment所做的一样,数据模型与实体框架核心一样。我们将看看如何使用这些新的提供商尽快创建我们的用户和角色。
现在,让我们在/Controllers/SeedController.cs文件的末尾定义以下方法,就在现有Import()方法的正下方:
// ...existing code...
[HttpGet]
public async Task<ActionResult> CreateDefaultUsers()
{
throw new NotImplementedException();
}
// ...existing code...
与我们通常所做的相反,我们不会马上实现这个方法;我们将借此机会拥抱测试驱动开发(TDD)方法,这意味着我们将从创建(失败的)单元测试开始。
定义 CreateDefaultUser()单元测试
从解决方案资源管理器中,在WorldCities.Tests项目中新建一个/SeedController_Tests.cs文件;完成后,用以下代码填充其内容:
using IdentityServer4.EntityFramework.Options;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using System;
using WorldCities.Controllers;
using WorldCities.Data;
using WorldCities.Data.Models;
using Xunit;
namespace WorldCities.Tests
{
public class SeedController_Tests
{
/// <summary>
/// Test the CreateDefaultUsers() method
/// </summary>
[Fact]
public async void CreateDefaultUsers()
{
#region Arrange
// create the option instances required by the
// ApplicationDbContext
var options = new
DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: "WorldCities")
.Options;
var storeOptions = Options.Create(new
OperationalStoreOptions());
// create a IWebHost environment mock instance
var mockEnv = new Mock<IWebHostEnvironment>().Object;
// define the variables for the users we want to test
ApplicationUser user_Admin = null;
ApplicationUser user_User = null;
ApplicationUser user_NotExisting = null;
#endregion
#region Act
// create a ApplicationDbContext instance using the
// in-memory DB
using (var context = new ApplicationDbContext(options,
storeOptions))
{
// create a RoleManager instance
var roleStore = new RoleStore<IdentityRole>(context);
var roleManager = new RoleManager<TIdentityRole>(
roleStore,
new IRoleValidator<TIdentityRole>[0],
new UpperInvariantLookupNormalizer(),
new Mock<IdentityErrorDescriber>().Object,
new Mock<ILogger<RoleManager<TIdentityRole>>>(
).Object);
// create a UserManager instance
var userStore = new
UserStore<ApplicationUser>(context);
var userManager = new UserManager<TIDentityUser>(
userStore,
new Mock<IOptions<IdentityOptions>>().Object,
new Mock<IPasswordHasher<TIDentityUser>>().Object,
new IUserValidator<TIDentityUser>[0],
new IPasswordValidator<TIDentityUser>[0],
new UpperInvariantLookupNormalizer(),
new Mock<IdentityErrorDescriber>().Object,
new Mock<IServiceProvider>().Object,
new Mock<ILogger<UserManager<TIDentityUser>>>(
).Object);
// create a SeedController instance
var controller = new SeedController(
context,
roleManager,
userManager,
mockEnv
);
// execute the SeedController's CreateDefaultUsers()
// method to create the default users (and roles)
await controller.CreateDefaultUsers();
// retrieve the users
user_Admin = await userManager.FindByEmailAsync(
"admin@email.com");
user_User = await userManager.FindByEmailAsync(
"user@email.com");
user_NotExisting = await userManager.FindByEmailAsync(
"notexisting@email.com");
}
#endregion
#region Assert
Assert.True(
user_Admin != null
&& user_User != null
&& user_NotExisting == null
);
#endregion
}
}
}
如我们所见,我们正在创建RoleManager和UserManager提供者的真实实例(而不是模拟,因为我们需要它们对ApplicationDbContext选项中定义的内存数据库执行一些读/写操作;这基本上意味着这些提供程序将真正执行其工作,但所有工作都将在内存中的数据库上完成,而不是在 SQL Server 数据源上完成。这是我们测试的理想场景。
同时,我们还很好地利用了Moq包库创建了大量mock来模拟实例化RoleManager和UserManager所需的大量参数。幸运的是,它们中的大多数都是内部对象,不需要执行我们当前的测试;对于那些需要的,我们必须创建一个真实的实例。
For example, for both providers, we were forced to create a real instance of UpperInvariantLookupNormalizer—which implements the ILookupNormalizer interface—because it's being used internally by RoleManager (to lookup for existing roles) as well as the UserManager (to lookup for existing usernames); if we had mocked it instead, we would've hit some nasty runtime errors while trying to make these tests pass.
在这里,将RoleManager和UserManager生成逻辑移动到一个单独的 helper 类可能很有用,这样我们就可以在其他测试中使用它,而无需每次重复。
从解决方案资源管理器中,在WorldCities.Tests项目中新建一个IdentityHelper.cs文件;完成后,用以下代码填充其内容:
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using System;
using System.Collections.Generic;
using System.Text;
namespace WorldCities.Tests
{
public static class IdentityHelper
{
public static RoleManager<TIdentityRole>
GetRoleManager<TIdentityRole>(
IRoleStore<TIdentityRole> roleStore) where TIdentityRole :
IdentityRole
{
return new RoleManager<TIdentityRole>(
roleStore,
new IRoleValidator<TIdentityRole>[0],
new UpperInvariantLookupNormalizer(),
new Mock<IdentityErrorDescriber>().Object,
new Mock<ILogger<RoleManager<TIdentityRole>>>(
).Object);
}
public static UserManager<TIDentityUser>
GetUserManager<TIDentityUser>(
IUserStore<TIDentityUser> userStore) where TIDentityUser :
IdentityUser
{
return new UserManager<TIDentityUser>(
userStore,
new Mock<IOptions<IdentityOptions>>().Object,
new Mock<IPasswordHasher<TIDentityUser>>().Object,
new IUserValidator<TIDentityUser>[0],
new IPasswordValidator<TIDentityUser>[0],
new UpperInvariantLookupNormalizer(),
new Mock<IdentityErrorDescriber>().Object,
new Mock<IServiceProvider>().Object,
new Mock<ILogger<UserManager<TIDentityUser>>>(
).Object);
}
}
}
如我们所见,我们创建了两个方法-GetRoleManager和GetUserManager,我们可以使用它们为其他测试创建这些提供者。
现在我们可以从SeedController调用这两个方法,通过以下方式更新其代码(更新的行突出显示):
// ...existing code...
// create a RoleManager instance
var roleManager = IdentityHelper.GetRoleManager(
new RoleStore<IdentityRole>(context));
// create a UserManager instance
var userManager = IdentityHelper.GetUserManager(
new UserStore<ApplicationUser>(context));
// ...existing code...
这样,我们的单元测试就准备好了;我们只需要执行它就可以看到它失败。
为此,右键单击解决方案资源管理器中的WorldCities.Test节点,然后选择运行测试。
Alternatively, just switch to the Test Explorer window and use the topmost buttons to run the tests from there.
如果我们做的一切都正确,我们应该能够看到我们的CreateDefaultUsers()测试失败,就像下面的屏幕截图:

就这样,;我们现在要做的就是实现SeedController的CreateDefaultUsers()方法,使前面的测试通过。
实现 CreateDefaultUsers()方法
在/Controllers/SeedController.cs文件末尾,在现有Import()方法的正下方添加以下方法:
// ...existing code...
[HttpGet]
public async Task<ActionResult> CreateDefaultUsers()
{
// setup the default role names
string role_RegisteredUser = "RegisteredUser";
string role_Administrator = "Administrator";
// create the default roles (if they doesn't exist yet)
if (await _roleManager.FindByNameAsync(role_RegisteredUser) ==
null)
await _roleManager.CreateAsync(new
IdentityRole(role_RegisteredUser));
if (await _roleManager.FindByNameAsync(role_Administrator) ==
null)
await _roleManager.CreateAsync(new
IdentityRole(role_Administrator));
// create a list to track the newly added users
var addedUserList = new List<ApplicationUser>();
// check if the admin user already exist
var email_Admin = "admin@email.com";
if (await _userManager.FindByNameAsync(email_Admin) == null)
{
// create a new admin ApplicationUser account
var user_Admin = new ApplicationUser()
{
SecurityStamp = Guid.NewGuid().ToString(),
UserName = email_Admin,
Email = email_Admin,
};
// insert the admin user into the DB
await _userManager.CreateAsync(user_Admin, "MySecr3t$");
// assign the "RegisteredUser" and "Administrator" roles
await _userManager.AddToRoleAsync(user_Admin,
role_RegisteredUser);
await _userManager.AddToRoleAsync(user_Admin,
role_Administrator);
// confirm the e-mail and remove lockout
user_Admin.EmailConfirmed = true;
user_Admin.LockoutEnabled = false;
// add the admin user to the added users list
addedUserList.Add(user_Admin);
}
// check if the standard user already exist
var email_User = "user@email.com";
if (await _userManager.FindByNameAsync(email_User) == null)
{
// create a new standard ApplicationUser account
var user_User = new ApplicationUser()
{
SecurityStamp = Guid.NewGuid().ToString(),
UserName = email_User,
Email = email_User
};
// insert the standard user into the DB
await _userManager.CreateAsync(user_User, "MySecr3t$");
// assign the "RegisteredUser" role
await _userManager.AddToRoleAsync(user_User,
role_RegisteredUser);
// confirm the e-mail and remove lockout
user_User.EmailConfirmed = true;
user_User.LockoutEnabled = false;
// add the standard user to the added users list
addedUserList.Add(user_User);
}
// if we added at least one user, persist the changes into the DB
if (addedUserList.Count > 0)
await _context.SaveChangesAsync();
return new JsonResult(new
{
Count = addedUserList.Count,
Users = addedUserList
});
}
// ...existing code...
该代码是非常自解释的,它有很多注释解释了各个步骤;然而,这里是我们刚刚做的一个方便的总结:
- 我们首先定义了一些默认的角色名(
RegisteredUsers用于标准注册用户,Administrator用于管理级用户)。 - 我们创建了一个逻辑来检查这些角色是否已经存在。如果它们不存在,我们就创造它们。正如所料,这两项任务都是使用
RoleManager执行的。 - 我们定义了一个用户列表局部变量来跟踪新添加的用户,这样我们就可以将它输出到 JSON 对象中的用户,我们将在 action 方法的末尾返回该对象。
- 我们创建了一个逻辑来检查
admin@email.com用户名的用户是否已经存在;如果没有,我们将创建它并为其分配RegisteredUser和Administrator角色,因为它将是标准用户和我们应用的管理帐户。 - 我们创建了一个逻辑来检查
user@email.com用户名的用户是否已经存在;如果没有,我们将创建它并将其分配给RegisteredUser角色。 - 在 action 方法的末尾,我们配置了将返回给调用方的 JSON 对象;此对象包含已添加用户的计数和包含这些用户的列表,这些用户将被序列化为一个 JSON 对象,该对象将显示其实体值。
The Administrator and RegisteredUser roles we just implemented here will be the core of our authorization mechanism; all of our users will be assigned to at least one of them. Note how we assigned both of them to the Admin user to make them able to do everything a standard user can do, plus more: all the other users only have the latter, so they'll be unable to perform any administrative-level task—as long as they're not provided with the Administrator role.
在继续之前,值得注意的是,Email和UserName字段都使用了用户的电子邮件地址。我们这样做是有意的,因为 ASP.NET Core 标识系统中的这两个字段在默认情况下可以互换使用:每当我们使用默认 API 添加用户时,提供的Email也会保存在UserName字段中,即使它们是AspNetUsers数据库表中的两个独立字段。虽然这种行为可以更改,但我们将坚持默认设置,这样我们就可以在整个 ASP.NET 标识系统中使用默认设置,而无需更改它们。
重新运行单元测试
现在我们已经实现了测试,我们可以重新运行CreateDefaultUsers()测试并查看它是否通过。通常,我们可以通过右键单击解决方案资源管理器中的WorldCities.Test根节点并选择运行测试,或者从测试资源管理器面板中选择运行测试。
如果我们做的每件事都是正确的,我们会看到这样的情况:

就这样,;现在我们终于完成了项目类的更新。
关于异步任务、等待和死锁的说明
通过前面的CreateDefaultUsers()方法可以看出,ASP.NET Core 身份识别系统 API 的所有相关方法都是异步,这意味着它们返回的是异步任务,而不是给定的返回值;正是因为这个原因,因为我们需要一个接一个地执行这些不同的任务,所以我们必须在它们前面加上await关键字。
下面是从前面代码中提取的await用法示例:
await _userManager.AddToRoleAsync(user_Admin, role_RegisteredUser);
顾名思义,await关键字等待异步任务完成后再继续。值得注意的是,这样的表达式不会阻塞它正在执行的线程;相反,它会导致编译器注册async方法的其余部分作为等待任务的延续,从而将线程控制返回给调用方。最后,当任务完成时,它会调用其 continuation,从而在停止的地方继续执行async方法。
That's the reason why the await keyword can only be used within async methods; as a matter of fact, the preceding logic requires the caller to be async as well, otherwise, it wouldn't work.
或者,我们可以使用Wait()方法,方法如下:
_userManager.AddToRoleAsync(user_Admin, role_RegisteredUser).Wait();
然而,我们没有这样做是有充分理由的。与await关键字相反,它告诉编译器异步等待异步任务完成,无参数的Wait()方法将阻塞调用线程,直到异步任务完成执行;因此,调用线程将无条件地等待任务完成。
为了更好地解释这些技术如何影响我们的.NET Core 应用,我们应该花一点时间更好地理解异步任务的概念,因为它们是 ASP.NET Core TAP 模型的关键部分。
在 ASP.NET 中使用调用异步任务的同步方法时,我们应该了解的第一件事是,当顶级方法等待任务时,其当前执行上下文将被阻止,直到任务完成。除非上下文一次只允许运行一个线程,否则这不会成为问题,这正是AspNetSynchronizationContext的情况。如果我们结合这两件事,我们很容易看到阻塞一个async方法(即返回异步任务的方法)将使我们的应用面临死锁的高风险。
从软件开发的 Angular 来看,d**eadlock是一种可怕的情况,每当进程或线程无限期进入等待状态时就会发生,通常是因为它等待的资源被另一个等待的进程占用。在任何旧的 ASP.NET web 应用中,每次阻止任务时,我们都会面临死锁,这仅仅是因为为了完成任务,该任务将需要调用方法的相同执行上下文,在任务完成之前,调用方法会一直阻止该上下文!
幸运的是,我们这里没有使用传统的 ASP.NET;我们使用的是.NET Core,其中基于SynchronizationContext的传统 ASP.NET 模式已被一种基于多功能、死锁弹性线程池的无上下文方法所取代。
这基本上意味着使用Wait()方法阻塞调用线程不再有问题;因此,如果我们用它切换await关键字,我们的方法仍然可以正常运行并完成。然而,通过这样做,我们基本上会使用同步代码来执行异步操作,这通常被认为是一种不好的做法;此外,我们将失去异步编程带来的所有好处,如性能和可伸缩性。
出于所有这些原因,等待方法无疑是实现这一目标的途径。
For additional information regarding threads, async tasks awaits, and asynchronous programming in ASP.NET, we highly recommend checking out the outstanding articles written by Stephen Cleary on the topic, which will greatly help in understanding some of the most tricky and complex scenarios that we could face when developing with these technologies. Some of them were written a while ago, yet they never really age:
https://blog.stephencleary.com/2012/02/async-and-await.html
https://blogs.msdn.microsoft.com/pfxteam/2012/04/12/asyncawait-faq/
http://blog.stephencleary.com/2012/07/dont-block-on-async-code.html
https://msdn.microsoft.com/en-us/magazine/jj991977.aspx
https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html
Also, we strongly suggest checking out this excellent article about asynchronous programming with async and await at
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/index
更新数据库
现在是时候创建一个新的迁移,并利用我们在第 4 章、数据模型和实体框架核心中选择的代码优先方法,将代码更改反映到数据库中。
下面列出了我们在本节中要做的事情:
- 使用
dotnet-ef命令添加身份迁移,就像我们在第 4 章中所做的一样,具有实体框架核心的数据模型。 - 将迁移应用到数据库,在不改变现有数据或执行删除和重新创建的情况下更新数据库
- 使用我们早期实施的
SeedController的CreateDefaultUsers()方法对数据进行种子设定。
让我们开始工作吧。
添加身份迁移
我们需要做的第一件事是向我们的数据模型添加一个新的迁移,以反映我们通过扩展ApplicationDbContext类实现的更改。
为此,请打开命令行或 PowerShell 提示符并转到WorldCities项目的根文件夹,然后编写以下内容:
dotnet ef migrations add "Identity" -o "Data\Migrations"
然后应将新的迁移添加到项目中,如以下屏幕截图所示:

新的迁移文件将在\Data\Migrations\文件夹中自动生成。
Those who experience issues while creating migrations can try to clear the \Data\Migrations\ folder before running the preceding dotnet-ef command.
For additional information regarding EF Core migrations, and how to troubleshoot them, check out the following guide:
https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/
应用迁移
接下来要做的是将新的迁移应用到我们的数据库中。我们可以在两个选项中进行选择:
- 更新现有数据模型模式,同时保持其所有数据不变
- 从头开始删除并重新创建数据库
事实上,EF Core 迁移功能的全部目的是提供一种方法,在保留数据库中现有数据的同时,增量更新数据库模式;正是出于这个原因,我们将遵循前一条道路
Before applying migrations, it's always advisable to perform a full database backup; this advice is particularly important when dealing with production environments. For small databases such as the one currently used by our WorldCities web app, it would take a few seconds.
For additional information about how to perform a full backup of a SQL Server database, read the following guide:
https://docs.microsoft.com/en-us/sql/relational-databases/backup-restore/create-a-full-database-backup-sql-server
更新现有数据模型
要将迁移应用于现有数据库架构而不丢失现有数据,请从WorldCities项目的根文件夹运行以下命令:
dotnet ef database update
然后,dotnet ef工具将对我们的 SQL 数据库模式应用必要的更新,并在控制台缓冲区中输出相关信息以及实际的 SQL 查询,如以下屏幕截图所示:

任务完成后,我们应该使用第 4 章中安装的SQL Server Management Studio工具、数据模型和实体框架核心连接到我们的数据库,并检查是否存在新的身份相关表。
如果一切顺利,我们应该能够看到新的身份表以及现有的Cities和Countries表:

我们很容易猜到,这些桌子还是空的;为了填充它们,我们必须运行SeedController的CreateDefaultUsers()方法,这是我们将在短时间内完成的事情。
从头开始删除和重新创建数据模型
为了完整起见,让我们花点时间看看如何从头开始重新创建数据模型和数据库模式(db 模式。不用说,如果我们选择这条路线,我们将丢失所有现有数据。然而,我们总是可以使用SeedController的Import()方法重新加载所有内容,因此不会有太大的损失;事实上,我们在第 4 章数据模型和实体框架核心中基于 CRUD 的测试中只会失去我们所做的。
虽然执行数据库删除和重新创建不是建议的方法,特别是考虑到我们采用了迁移模式,以避免出现这种情况,但只要我们在迁移之前完全备份数据,并且,最重要的是,知道如何在事后恢复一切。
Although it might seem a horrible way to fix things, that's definitely not the case; we're still in the development phase, hence we can definitely allow a full database refresh.
如果我们选择这条路线,下面是要使用的dotnet ef控制台命令:
> dotnet ef database drop
> dotnet ef database update
drop命令应在继续之前请求确认是/否;当它发生时,点击Y键,让它发生。当删除和更新任务都完成后,我们可以在调试模式下运行我们的项目,并访问SeedController的Import()方法;完成后,我们应该有一个支持 ASP.NET Core 标识的更新数据库。
播种数据
不管我们选择哪个选项来更新数据库,我们现在都必须重新填充它。
点击F5以调试模式运行项目,然后在浏览器地址栏中手动输入以下 URL:https://localhost:44334/api/Seed/CreateDefaultUsers。
让SeedController的CreateDefaultUsers()方法发挥它的魔力。
完成后,我们应该能够看到以下 JSON 响应:

这个输出已经告诉我们,前两个用户已经创建并存储在我们的数据模型中。但是,我们可以通过使用 SQL Server Management Studio 工具连接到我们的数据库并查看dbo.AspNetUsers表来确认这一点(请参见以下屏幕截图):

如我们所见,我们使用以下 T-SQL 查询来检查现有用户和角色:
SELECT *
FROM [WorldCities].[dbo].[AspNetUsers];
SELECT *
FROM [WorldCities].[dbo].[AspNetRoles];
答对 了我们的 ASP.NET Core 标识系统实现已启动、运行,并与我们的数据模型完全集成;现在我们只需要在控制器中实现它,并将其与 Angular 客户端应用连接起来。
认证方法
现在,我们已经更新了数据库以支持 ASP.NET Core 身份验证工作流和模式,我们应该花一些宝贵的时间选择采用哪种身份验证方法;更准确地说,由于我们已经实现了.NET CoreIdentityServer,为了正确理解它为 SPA-JWT 令牌提供的默认身份验证方法是否足够安全,或者我们是否应该将其更改为更安全的机制。
众所周知,HTTP 协议是无状态,这意味着我们在请求/响应周期中所做的任何事情都将在后续请求之前丢失,包括身份验证结果。我们必须克服这一问题的唯一方法是将结果及其所有相关数据(如用户 ID、登录日期/时间和上次请求时间)存储在某处。
会议
从几年前开始,最常见和传统的方法就是使用基于内存、基于磁盘或外部会话管理器将数据存储在服务器上。可以使用客户端通过身份验证响应(通常在会话 cookie 中)接收的唯一 ID 检索每个会话,该 ID 将在每次后续请求时传输到服务器。
下面是一个简要的图表,显示了基于会话的身份验证流程:

这仍然是大多数 web 应用使用的一种非常常见的技术。采用这种方法没有什么错,只要我们对其广泛承认的缺点感到满意,例如:
- 内存问题:只要有很多经过身份验证的用户,web 服务器就会消耗越来越多的内存。即使我们使用基于文件或外部会话提供程序,仍然会有大量的 I/O、TCP 或套接字开销。
- 可伸缩性问题:在可伸缩体系结构(IIS web farm、负载平衡集群等)中复制会话提供程序可能不是一项容易的任务,通常会导致瓶颈或资源浪费。
- 跨域问题:会话 cookie 的行为与标准 cookie 类似,因此无法在不同来源/域之间轻松共享。这些类型的问题通常可以通过一些变通方法来解决,但它们通常会导致不安全的场景,以使事情顺利进行。
- 安全问题:有大量关于安全相关问题的详细文献,涉及会话和会话 cookie:XSS 攻击、跨站点请求伪造,以及许多其他威胁,为了简单起见,此处不作介绍。大多数问题可以通过一些对策加以缓解,但对于初级或新手开发人员来说,这些问题可能很难处理。
随着这些问题多年来的出现,毫无疑问,大多数分析师和开发人员已经投入了大量精力来找出不同的方法。
代币
在过去几年中,基于令牌的身份验证越来越多地被单页应用(SPA)和移动应用所采用,原因无可否认,我们将在这里简要总结。
基于会话的身份验证和基于令牌的身份验证之间最重要的区别在于后者是无状态,这意味着我们不会在服务器内存、数据库、会话提供程序或任何类型的其他数据容器上存储任何用户特定的信息。
这一方面解决了我们前面指出的基于会话的身份验证的大多数缺点。我们没有会话,因此不会增加开销;我们不需要会话提供程序,因此扩展会容易得多。此外,对于支持LocalStorage的浏览器,我们甚至不会使用 cookies,因此我们不会受到跨源限制性策略的阻碍,希望我们能够绕过大多数安全问题。
下面是一个典型的基于令牌的身份验证流程:

在客户机-服务器交互方面,这些步骤似乎与基于会话的身份验证流程图没有太大区别;显然,唯一的区别是我们将发布和检查令牌,而不是创建和检索会话。真正的交易正在服务器端发生(或没有发生)。我们可以立即看到,基于令牌的身份验证流不依赖于有状态会话状态服务器、服务或管理器。这将很容易转化为性能和可伸缩性方面的显著提升。
**# 签名
这是大多数现代基于 API 的云计算和存储服务使用的方法,包括亚马逊 Web 服务(AWS。与基于会话和基于令牌的方法(它们依赖于理论上可以被第三方攻击者访问或暴露给第三方攻击者的传输层)不同,基于签名的身份验证使用先前共享的私钥对整个请求执行散列。这确保了没有入侵者或中间人可以充当请求用户,因为他们将无法签署请求。
双因素
这是大多数银行和金融账户使用的标准身份验证方法,可以说是最安全的方法。
实施可能会有所不同,但它始终依赖于以下基本工作流:
- 用户使用用户名和密码执行标准登录。
- 服务器识别用户,并向他们提示一个额外的、特定于用户的请求,该请求只能由通过不同渠道获得的东西来满足:通过 SMS 发送的 OTP 密码、带有多个应答码的唯一身份验证卡、由专有设备或移动应用生成的动态 PIN 等等。
- 如果用户给出正确答案,则使用基于会话或基于令牌的标准方法对其进行身份验证。
双因素认证(2FA)自 1.0 发布以来一直受到 ASP.NET Core 的支持,通过短信验证(SMS 2FA)实现;然而,从 ASP.NET Core 2 开始,SMS 2FA 方法被弃用,取而代之的是基于时间的一次性密码算法(TOTP),该算法成为业界推荐的在 web 应用中实现 2FA 的方法。
For additional information about SMS 2FA, check out the following URL:
https://docs.microsoft.com/en-us/aspnet/core/security/authentication/2fa
For additional information about TOTP 2FA, take a look at the following URL:
https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-enable-qrcodes
结论
在回顾了所有这些身份验证方法之后,我们可以肯定地说,IdentityServer提供的基于令牌的身份验证方法对于我们的特定场景来说似乎是一个不错的选择。
我们当前的实现基于JSON Web 令牌(JWT),这是一个基于 JSON 的开放标准,专门为本地 Web 应用设计,可以使用多种语言,如.NET、Python、Java、PHP、Ruby、JavaScript/NodeJS 和 PERL。我们选择它是因为它正在成为令牌身份验证的事实标准,因为大多数技术都支持它。
For additional information about JSON Web Tokens, check out the following URL:
在 Angular 中实现身份验证
为了处理基于 JWT 的令牌身份验证,我们需要设置我们的 ASP.NET后端和 Angular前端来处理所有需要的任务。
在前面的部分中,我们花了大量时间配置.NET Core 身份服务以及IdentityServer,这意味着我们已经完成了一半;事实上,我们几乎完成了服务器端任务。同时,我们在前端级别没有做任何事情:我们在上一节中创建的两个示例用户admin@email.com和user@email.com无法登录,并且没有创建新用户的注册表。
现在,这里有一些(非常)好消息:我们用来设置应用的 Visual Studio Angular 模板附带了对 auth API 的内置支持,我们刚刚将 auth API 添加到后端,最棒的是它实际上工作得非常好!
然而,我们也得到了一些坏消息:由于我们在创建项目时选择了而不是在项目中添加任何身份验证方法,所有本来可以处理此任务的 Angular 模块、组件、服务、拦截器和测试都被排除在 Angular 应用之外;作为最初选择的结果,当我们在第 2 章中开始探索我们预先制作的 Angular 应用时,环顾,我们只有counter和fetch-data组件可供使用。
事实上,我们之所以选择排除授权组件是有原因的:因为我们使用该模板作为示例应用来了解更多关于 Angular 结构的信息,所以我们不想在早期引入所有身份验证和授权内容,从而使我们的生活复杂化。
幸运的是,所有缺少的类都可以在我们当前的WorldCities项目中轻松检索和实现。。。这正是我们在本节要做的。
更具体地说,以下是我们即将完成的任务列表:
- 创建全新的.NET Core 和 Angular 项目,我们将其用作代码库从中复制与 auth 相关的 Angular 类;新项目名称将为
AuthSample。 - 探索 Angular authorization API以了解其工作原理。
- 测试
AuthSample项目中上述 API 提供的登录注册表单。
在本节结束时,我们应该能够注册新用户,并使用AuthSample应用附带的前端授权 API 登录现有用户。
创建 AuthSample 项目
我们要做的第一件事是创建一个新的.NET Core 和 Angular web 应用项目。事实上,这已经是我们第三次这么做了:我们在第一章、准备中创建了HealthCheck项目,然后在第三章、前端和后端交互中创建了WorldCities项目;因此,我们已经知道我们必须做的大部分事情。
我们第三个项目的名称为AuthSample;但是,创建它所需的任务将与上次略有不同(而且肯定更容易):
- 使用
dotnet new angular -o AuthSample -au Individual命令创建一个新项目。 - 编辑
/ClientApp/package.json文件,将现有 npm 软件包版本更新为我们目前在现有HealthCheck和WorldCitiesAngular 应用中使用的相同版本(请参见第 2 章,环顾,了解如何进行此操作的详细信息)。
就是这样。正如我们所看到的,这次我们添加了-au开关(一个--auth的快捷方式),它将包括我们在创建HealthCheck和WorldCities项目时故意错过的所有与 auth 相关的类;此外,除了 npm 软件包版本之外,我们不需要删除或更新任何东西:内置的 Angular 组件以及后端类和库,足以探索我们迄今为止一直缺少的与身份验证相关的代码。
对 AuthSample 项目进行疑难解答
更新 npm 包后,我们应该做的第一件事是以调试模式启动项目,并确保主页正常工作(请参见以下屏幕截图):

如果我们遇到包冲突、JavaScript 错误或其他与 npm 相关的问题,我们可以尝试从/ClientApp/文件夹中执行以下 npm 命令来更新它们并验证包缓存:
> npm install
> npm cache verify
这显示在以下屏幕截图中:

尽管 Visual Studio 应该在我们更新磁盘上的package.json文件后立即自动更新 npm 包,但有时自动更新过程无法正常工作:当发生这种情况时,从命令行手动执行前面的 npm 命令是解决此类问题的方便方法。
如果我们遇到一些后端运行时错误,明智的做法是对照我们在前几章和本章中所做的工作简要回顾.NET 代码,以解决与模板源代码、第三方引用、NuGet 包版本等相关的任何问题。一如既往,本书提供的 GitHub 存储库将极大地帮助我们解决自己代码的故障;一定要去看看!
探索 Angular 授权 API
在本节中,我们将详细介绍.NET Core 和 Angular Visual Studio 项目模板提供的授权 API:一组依赖于oidc 客户端库的功能,允许 Angular 应用与 ASP.NET Core 标识系统提供的 URI 端点交互。
The **oidc-client **library is an open source solution that provides OIDC and OAuth2 protocol support for client-side, browser-based JavaScript client applications, including user session support and access token management; its npm package reference is already present in the package.json file of our WorldCities app, therefore we won't have to manually add it.
For additional info about the oidc-client library, check out the following URL:
https://github.com/IdentityModel/oidc-client-js
正如我们可以看到的,这些 API 利用一些重要的 Angular 特性,如路由保护和 HTTP 拦截器,来处理 HTTP 请求/响应周期中的授权流。
让我们从新的AuthSample项目附带的 Angular 应用的快速概述开始。如果我们观察/ClientApp/目录中的各种文件和文件夹,我们可以立即看到我们正在处理的示例应用与我们在第 2 章中已经探讨过的示例应用相同,环顾,然后对其进行精简,以更好地满足我们的需求。
然而,还有一个当时不存在的额外文件夹:我们谈论的是/ClientApp/src/app/authorization-api/文件夹,它基本上包含了我们当时错过的所有内容。前端实现了.NET Core 身份 API 和IdentityServer钩子点。
该文件夹中有许多有趣的文件和子文件夹,如以下屏幕截图所示:

得益于我们对 Angular architecture 的了解,我们可以轻松理解其中每一个的主要作用:
- 前三个子文件夹
/login/、/login-menu/和/logout/包含三个组件,每个组件都有其 TypeScript 类、HTML 模板、CSS 文件和测试套件。 api-authorization.constants.ts文件包含一组公共接口和常量,这些接口被其他类引用和使用。api-authorization.module.ts文件是一个NgModule,即授权 API 公共功能集的容器,就像我们在第 5 章获取和显示数据中在WorldCities应用中创建的AngularMaterialModule一样。如果我们打开它,我们可以看到它包含一些特定于身份验证的路由规则。authorize.guard.ts文件引入了路线守卫的概念,这是我们尚未了解的内容;我们将在短时间内对此进行更多讨论。authorize.interceptor.ts文件实现了一个HTTP 拦截器类—另一种我们还不知道的机制;再一次,我们将很快对此进行更多讨论。authorize.service.ts文件包含处理所有 HTTP 请求和响应的数据服务;我们从第 7 章、代码调整和数据服务了解他们的角色和工作方式,在那里我们为WorldCities应用实现了CityService和CountryService。
我们还没有提到各种.spec.ts文件;正如我们在第 9 章、ASP.NET Core 和 Angular Unit Testing中所了解到的,对于它们共享名称的类文件,它们是相应的测试单元。
路障
正如我们在第 2 章、环顾中了解到的,Angular Router 是允许我们的用户在我们应用的各种视图中导航的服务;每个视图更新前端并(可能)调用后端检索内容。
仔细想想,我们可以看到 Angular 路由是 ASP.NET Core 路由接口的前端对应物,负责将请求 URI 映射到后端端点,并将传入请求发送到这些端点。由于这两个模块具有相同的行为,因此在我们为应用实现身份验证和授权机制时,它们也有类似的要求。
在前面的章节中,我们在后端和前端上定义了许多路由,以允许用户访问我们实现的各种 ASP.NET Core 操作方法和 Angular 视图。如果我们仔细想想,我们会发现所有这些路线都有一个共同的特点:任何人都可以访问它们。换句话说,任何用户都可以在我们的网络应用中自由移动:他们可以编辑城市和国家,他们可以与我们的SeedController交互以执行其数据库种子任务,等等。
不用说,这种行为虽然在开发中可以接受,但在任何生产场景中都是非常不受欢迎的:当应用上线时,我们肯定希望通过将其限制为授权用户来保护其中一些路由;换言之,是为了保护他们。
路线守卫是适当执行此类要求的机制;它们可以添加到我们的路由配置中,以返回可以通过以下方式控制路由行为的值:
- 如果路线守卫返回
true,导航过程继续。 - 如果返回
false,则导航过程停止。 - 如果返回一个
UrlTree,则导航过程被取消并替换为对给定UrlTree的新导航。
可用警卫
以下路线防护装置目前在 Angular 中可用:
CanActivate:调解到给定路线的导航CanActivateChild:调解到给定子路径的导航CanDeactivate:调离当前航线的导航Resolve:在激活路由之前,执行一些任意操作(如自定义数据检索任务)CanLoad:调解到给定异步模块的导航
它们中的每一个都可以通过一个超类来使用,该超类充当公共接口:每当我们想要创建自己的防护时,我们只需扩展相应的超类并实现相关方法即可。
任何一条路由都可以配置多个防护:CanDeactivate和CanActivateChild首先检查防护,从最深的子路由到最上面;紧接着,路由将检查CanActivate守卫从上到下到最深的子路由;完成后,将检查异步模块的CanLoad路由。如果其中任何一个防护返回false,导航将停止,所有未决防护将被取消。
现在让我们看看/ClientApp/src/api-authorization/authorize.guard.ts文件,看看AuthSampleAngular 应用附带的前端授权 API 实现了哪些路由防护(相关行突出显示):
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot,
Router } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthorizeService } from './authorize.service';
import { tap } from 'rxjs/operators';
import { ApplicationPaths, QueryParameterNames } from
'./api-authorization.constants';
@Injectable({
providedIn: 'root'
})
export class AuthorizeGuard implements CanActivate {
constructor(private authorize: AuthorizeService, private router:
Router) {
}
canActivate(
_next: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> |
Promise<boolean> | boolean {
return this.authorize.isAuthenticated()
.pipe(tap(isAuthenticated =>
this.handleAuthorization(isAuthenticated, state)));
}
private handleAuthorization(isAuthenticated: boolean, state:
RouterStateSnapshot) {
if (!isAuthenticated) {
this.router.navigate(ApplicationPaths.LoginPathComponents, {
queryParams: {
[QueryParameterNames.ReturnUrl]: state.url
}
});
}
}
}
正如我们所看到的,我们正在处理一个扩展CanActivate接口的保护。正如我们从授权 API 中可以合理预期的那样,guard 正在检查AuthorizeService的isAuthenticated()方法(通过 DI 在构造函数中注入的方法),并根据该方法有条件地允许或阻止导航;难怪它的名字叫AuthorizeGuard。
一旦创建了防护,防护就可以从路由配置本身绑定到各种路由,路由配置本身为每种防护类型提供一个属性;如果我们查看AuthSample应用的/ClientApp/src/app/app.module.ts文件,其中配置了主路由,我们可以很容易地识别防护的路由:
// ...
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'counter', component: CounterComponent },
{ path: 'fetch-data', component: FetchDataComponent, canActivate:
[AuthorizeGuard] },
])
// ...
这意味着将用户带到FetchDataComponent的fetch-data视图只能由经过身份验证的用户激活:让我们快速尝试一下,看看它是否按预期工作。
按F5在调试模式下运行AuthSample应用,然后通过点击右上菜单的相应链接尝试导航到“获取数据”视图。由于我们不是经过身份验证的用户,因此应该重定向到登录视图,如以下屏幕截图所示:

看起来路由保护正在工作:如果我们现在手动编辑/ClientApp/src/app/app.module.ts文件,从fetch-data路由中删除canActivate属性,然后重试,我们将看到我们能够访问该视图而不会出现问题:
*
... 也许不是。
从控制台日志中可以看出,即使前端允许我们通过,发送给后端的 HTTP 请求似乎遇到了 401 未经授权的错误。怎么搞的?答案很简单:通过手动移除路由防护,我们能够破解我们的路径,通过 Angular前端路由系统,但通过.NET Core后端路由还具有类似的防止客户端无法避免的未授权访问的保护功能
通过打开/Controllers/WeatherForecastController.cs文件并查看现有的类属性(突出显示的相关行),可以很容易地看到这种保护:
// ...
[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
// ...
在.NET Core 控制器中,路由授权通过AuthorizeAttribute属性进行控制。更具体地说,[Authorize]属性应用于的控制器或操作方法需要其参数指定的授权级别:如果没有给出参数,则将AuthorizeAttribute属性应用于控制器或操作将限制对认证的用户的访问。
现在我们知道为什么我们无法从后端获取该数据;如果我们删除(或注释掉)该属性,我们最终将能够,如以下屏幕截图所示:

在继续之前,让我们先将前端路障和后端AuthorizeAttribute放回原位;在执行实际登录并获得访问这些资源的授权后,我们需要他们在那里正确地测试我们的导航。
然而,在这样做之前,我们必须完成我们的探索之旅;在下一节中,我们将介绍另一个重要的 Angular 概念,我们还没有讨论过。
For further information about Route Guards and their role in the Angular Routing workflow, check out the following URLs:
https://angular.io/guide/router#guards
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing
http 拦截器
AngularHttpInterceptor接口提供了一种标准化的机制来拦截和/或转换传出 HTTP 请求或传入 HTTP 响应;拦截器与我们在Chapter 2环视中介绍的 ASP.NET中间件非常相似,然后在第三章前端和后端交互中进行播放,除了在前端级别工作。
拦截器是 Angular 的一个主要功能,因为它们可以用于许多不同的任务:它们可以检查和/或记录我们应用的 HTTP 流量,修改请求,缓存响应,等等;它们是集中所有这些任务的方便方法,因此我们不必在数据服务和/或各种基于 HttpClient 的方法调用中显式地实现它们。此外,它们还可以链接,这意味着我们可以让多个拦截器在请求/响应处理程序的前向和后向链中一起工作。
AuthorizeInterceptor类随 Angular authentication API 一起提供,我们正在探索的特性是许多内联注释,这些注释极大地帮助我们理解它实际上是如何工作的。
要查看其源代码,请打开/ClientApp/src/api-authorization/authorize.interceptor.ts文件(突出显示相关行):
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent }
from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthorizeService } from './authorize.service';
import { mergeMap } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class AuthorizeInterceptor implements HttpInterceptor {
constructor(private authorize: AuthorizeService) { }
intercept(req: HttpRequest<any>, next: HttpHandler):
Observable<HttpEvent<any>> {
return this.authorize.getAccessToken()
.pipe(mergeMap(token => this.processRequestWithToken(token, req,
next)));
}
// Checks if there is an access_token available in the authorize
// service and adds it to the request in case it's targeted at
// the same origin as the single page application.
private processRequestWithToken(token: string, req:
HttpRequest<any>,
next: HttpHandler) {
if (!!token && this.isSameOriginUrl(req)) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next.handle(req);
}
private isSameOriginUrl(req: any) {
// It's an absolute url with the same origin.
if (req.url.startsWith(`${window.location.origin}/`)) {
return true;
}
// It's a protocol relative url with the same origin.
// For example: //www.example.com/api/Products
if (req.url.startsWith(`//${window.location.host}/`)) {
return true;
}
// It's a relative url like /api/Products
if (/^\/[^\/].*/.test(req.url)) {
return true;
}
// It's an absolute or protocol relative url that
// doesn't have the same origin.
return false;
}
}
我们可以看到,AuthorizeInterceptor通过定义intercept方法来实现HttpInterceptor接口。该方法的任务是拦截所有发出的 HTTP 请求,并有条件地将 JWT 承载令牌添加到它们的 HTTP 头中;此条件由isSameOriginUrl()内部方法确定,只有当请求被发送到与 Angular app 的来源相同的 URL 时,该方法才会返回true。
与任何其他 Angular 类一样,AuthorizeInterceptor需要在NgModule中正确配置才能工作;由于它需要检查任何HTTP 请求,包括那些不属于授权 API 的请求,因此它已在AppModule、根级别NgModule或AuthSample应用中配置。
要查看实际实现,请打开/ClientApp/src/app/app.module.ts文件并查看providers部分:
// ...
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthorizeInterceptor, multi: true }
],
// ...
我们在前面的代码中看到的multi: true属性是必需的设置,因为HTTP_INTERCEPTORS是一个多提供者令牌,它希望注入一个多值数组,而不是单个值。
**For additional information about HTTP interceptors, take a look at the following URLs:
https://angular.io/api/common/http/HttpInterceptor
https://angular.io/api/common/http/HTTP_INTERCEPTORS
授权组件
现在让我们看看/api-authorization/文件夹中包含的各种 Angular 组件。
LoginMenuComponent
位于/ClientApp/src/api-authorization/login-menu/文件夹中的LoginMenuComponent角色将包含在NavMenuComponent(我们已经很清楚)中,以将Login和Logout动作添加到现有导航选项中。
我们可以通过打开/ClientApp/src/app/nav-menu/nav-menu.component.html文件并检查是否存在以下行来检查它:
<app-login-menu></app-login-menu>
这是LoginMenuComponent的根元素;因此,LoginMenuComponent被实现为NavMenuComponent的子组件。但是,如果我们查看它的 TypeScript 文件源代码,我们可以看到它有一些与任务严格相关的独特特性(相关行突出显示):
import { Component, OnInit } from '@angular/core';
import { AuthorizeService } from '../authorize.service';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
@Component({
selector: 'app-login-menu',
templateUrl: './login-menu.component.html',
styleUrls: ['./login-menu.component.css']
})
export class LoginMenuComponent implements OnInit {
public isAuthenticated: Observable<boolean>;
public userName: Observable<string>;
constructor(private authorizeService: AuthorizeService) { }
ngOnInit() {
this.isAuthenticated = this.authorizeService.isAuthenticated();
this.userName = this.authorizeService.getUser().pipe(map(u => u &&
u.name));
}
}
我们可以看到,组件使用authorizeService(通过 DI 注入构造函数)来检索访问用户的以下信息:
- 该用户是否经过身份验证
- 该用户的用户名
这两个值存储在isAuthenticated和userName局部变量中,然后模板文件使用这些变量来确定组件的行为。
为了更好地理解这一点,让我们来看看{ To.T0}模板文件(相关的行突出显示):
<ul class="navbar-nav" *ngIf="isAuthenticated | async">
<li class="nav-item">
<a class="nav-link text-dark"
[routerLink]='["/authentication/profile"]'
title="Manage">Hello {{ userName | async }}</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark"
[routerLink]='["/authentication/logout"]'
[state]='{ local: true }' title="Logout">Logout</a>
</li>
</ul>
<ul class="navbar-nav" *ngIf="!(isAuthenticated | async)">
<li class="nav-item">
<a class="nav-link text-dark"
[routerLink]='["/authentication/register"]'>Register</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark"
[routerLink]='["/authentication/login"]'>Login</a>
</li>
</ul>
我们可以通过以下方式立即看到表示层是如何由两个ngIf结构指令确定的:
- 如果用户通过认证,则会显示
Hello <username>欢迎信息和Logout链接。 - 如果用户未通过认证,则会显示
Register和Login链接。
这是一种广泛使用的实现登录/注销菜单的方法;如我们所见,所有链接都指向将透明地处理每个任务的IdentityServer端点 URI。
登录组件
LoginComponent执行正确处理用户登录过程所需的各种任务;因此,任何想要限制已验证用户访问的 Angular 组件和/或.NET Core 控制器都应该执行 HTTP 重定向到此组件的路由;通过查看组件定义方法的源代码可以看出,如果传入请求提供了returnUrl查询参数,组件在执行登录后会将用户重定向回该组件:
//...
private async login(returnUrl: string): Promise<void> {
const state: INavigationState = { returnUrl };
const result = await this.authorizeService.signIn(state);
this.message.next(undefined);
switch (result.status) {
case AuthenticationResultStatus.Redirect:
break;
case AuthenticationResultStatus.Success:
await this.navigateToReturnUrl(returnUrl);
break;
case AuthenticationResultStatus.Fail:
await this.router.navigate(
ApplicationPaths.LoginFailedPathComponents, {
queryParams: { [QueryParameterNames.Message]: result.message }
});
break;
default:
throw new Error(`Invalid status result ${(result as
any).status}.`);
}
}
// ...
LoginComponentTypeScript 源代码相当长,但只要我们记住它的主要工作,就可以理解:使用默认端点 URI 将用户的身份验证信息传递给.NET CoreIdentityServer,并将结果返回给客户端;它基本上就像一个前端到后端身份验证代理。
如果我们看一下它的模板文件,这个角色将变得更加明显:
<p>{{ message | async }}</p>
就这样。事实上,这个组件确实有非常小的模板,只是因为它会将用户重定向到一些后端页面,这些页面松散地模仿了我们的 Angular 组件的视觉样式(!)。
为了快速确认,点击F5以调试模式运行AuthSample项目并访问登录视图,然后仔细查看以下屏幕截图中突出显示的 UI 元素:

我们使用红色方块突出显示的两个元素与 Angular 应用 GUI 的其余部分不匹配:右上角菜单缺少计数器和获取数据选项,并且页脚甚至不存在;它们都是从后端生成的,就像登录视图的其他 HTML 内容一样。
事实上,.NET Core 和 Angular 模板附带的身份验证 API 实现的工作方式如下:后端处理登录和注册表单,LoginComponent扮演混合角色—半个请求处理程序,半个 UI 代理
It's worth noting that these built-in Login and Registration pages provided by the ASP.NET Core back-end can be fully customized in their UI and/or behavior to make them compatible with the Angular app's look and feel: see the Installing the ASP.NET Core Identity UI package and Customizing the default Identity UI sections within this chapter for further details on how to do this.
这种技术可能看起来像是一种黑客——事实上,至少在某种程度上是如此,但它是一种非常聪明的技术,因为它透明(好吧,不太多)围绕着大多数以纯**为特征的登录机制的许多安全性、性能和兼容性问题工作前端实现,同时节省开发人员大量时间。
In one of my previous books (ASP.NET Core 2 and Angular 5), I chose to purposely avoid the .NET Core IdentityServer and manually implement the registration and login workflows from the front-end: however, the .NET Core mixed approach has greatly improved in the last 2 years and now offers a great alternative to the standard, Angular-based implementation, thanks to a solid and highly configurable interface.
Those who prefer to use the former method can take a look at the GitHub repository of the ASP.NET Core 2 and Angular 5 book, (Chapter_08 onward), which is still fully compatible with the latest Angular versions:
https://github.com/PacktPublishing/ASP.NET-Core-2-and-Angular-5/
如果我们不喜欢重定向到后端的方法,内置的授权 API 提供了一个替代实现,可以用弹出窗口替换整个页面的 HTTP 重定向
要激活它,打开/ClientApp/src/api-authorization/authorize.service.ts文件,将popupDisabled内部变量值从true更改为false,如下代码所示:
// ...
export class AuthorizeService {
// By default pop ups are disabled because they don't work properly
// on Edge. If you want to enable pop up authentication simply set
// this flag to false.
private popUpDisabled = false;
private userManager: UserManager;
private userSubject: BehaviorSubject<IUser | null> = new
BehaviorSubject(null);
// ...
如果我们希望通过弹出窗口实现 auth 特性,我们可以将前面的布尔值更改为false,然后以调试模式启动AuthSample项目来测试结果。
以下是弹出式登录页面的外观:

然而,正如内联评论所说,弹出窗口在 MicrosoftEdge 上不能正常工作(甚至其他浏览器也不喜欢它们);出于这个原因,后端生成的页面可以说是一个更好的选择,特别是如果我们可以定制它们,我们将在后面看到。
注销组件
LogoutComponent是LoginComponent的对应项,因为它处理断开用户连接并将他们带回主页的任务。
这里没什么好说的,因为它的工作方式与它的兄弟类似,将用户重定向到.NET Core Identity system 端点 URI,然后使用returnUrl参数将其带回 Angular 应用。主要区别在于这次没有后端页面,因为注销工作流不需要用户界面。
测试注册和登录
现在我们准备测试AuthSampleAngular 应用的注册和登录工作流;让我们从注册阶段开始,因为我们这里还没有任何注册用户。
点击F5以调试模式运行项目,然后导航至注册视图:插入有效电子邮件,密码与所需密码强度设置相匹配,然后点击注册按钮。
*一旦我们这样做,我们就会看到以下信息:

单击确认链接创建帐户,然后等待重新加载整个页面。
As a matter of fact, all these redirects and reloads performed by this implementation definitely break the SPA pattern that we talked about in Chapter 1, Getting Ready.
However, when we compared the pros and cons of the Native Web Application, SPA and Progressive Web Application approaches, we told ourselves that we would have definitely adopted some strategic HTTP round-trips and/or other redirect techniques whenever we could use a microservice to lift off some workload from our app; that's precisely what we are doing right now.
当我们返回到登录视图时,我们最终可以输入刚才选择的凭据并执行登录。
一旦完成,我们将受到以下屏幕的欢迎:

我们走吧;我们可以看到,我们之所以登录,是因为LoginMenuComponent的 UI 行为发生了变化,这意味着它的isAuthenticated内部变量现在计算为true。
至此,我们的AuthSample应用就完成了:现在我们已经了解了与.NET Core 和 Angular Visual Studio 模板一起提供的前端授权 API 是如何工作的,我们将把它带到WorldCities应用中。
在 WorldCities 应用中实现 Auth API
在本节中,我们将在WorldCities应用中实现AuthSample应用提供的授权 API。下面是我们将要详细执行的操作:
- 将前端授权 API从
AuthSampleapp 导入WorldCitiesapp,并集成到我们现有的 Angular 代码中。 - 调整现有后端源代码正确实现认证功能。
- 测试
WorldCities项目的登录登记表。
在本节结束时,我们应该能够使用WorldCities应用登录现有用户,并创建新用户。
导入前端授权 API
要将前端授权 API 导入我们的WorldCitiesAngular 应用,我们需要做的第一件事是从AuthSample应用复制整个/ClientApp/src/api-authorization/文件夹。这样做没有缺点,所以我们可以使用 VisualStudio 解决方案资源管理器使用复制和粘贴 GUI 命令(或者Ctrl+C/Ctrl+V,如果您喜欢使用键盘快捷键)。
完成后,我们需要将新的前端功能与现有代码集成。
应用模块
我们要修改的第一个文件是/ClientApp/src/api-authorization/api-authorization.constants.ts文件,它在其内容的第一行包含对应用名称的文字引用:
export const ApplicationName = 'AuthSample';
// ...
将'AuthSample'更改为'WorldCities',保持文件的其余部分不变。
应用模块
紧接着,我们需要更新/ClientApp/src/app/app.module.ts文件,其中我们需要向授权 API 的类添加所需的引用:
// ...
import { ApiAuthorizationModule } from 'src/api-authorization/api-authorization.module';
import { AuthorizeGuard } from 'src/api-authorization/authorize.guard';
import { AuthorizeInterceptor } from 'src/api-authorization/authorize.interceptor';
// ...
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
ApiAuthorizationModule,
RouterModule.forRoot([
{
path: '',
component: HomeComponent,
pathMatch: 'full'
},
{
path: 'cities',
component: CitiesComponent
},
{
path: 'city/:id',
component: CityEditComponent,
canActivate: [AuthorizeGuard]
},
{
path: 'city',
component: CityEditComponent,
canActivate: [AuthorizeGuard]
},
{
path: 'countries',
component: CountriesComponent
},
{
path: 'country/:id',
component: CountryEditComponent,
canActivate: [AuthorizeGuard]
},
{
path: 'country',
component: CountryEditComponent,
canActivate: [AuthorizeGuard]
}
]),
BrowserAnimationsModule,
AngularMaterialModule,
ReactiveFormsModule
],
providers: [
CityService,
CountryService,
{ provide: HTTP_INTERCEPTORS, useClass: AuthorizeInterceptor,
multi: true }
],
bootstrap: [AppComponent]
})
export class AppModule { }
正如我们所看到的,除了添加所需的引用之外,我们还利用机会使用AuthorizeGuard来保护我们的编辑组件,以便只有注册的用户才能访问这些组件。
导航组件
现在我们需要将LoginMenuComponent集成到我们现有的NavMenuComponent,就像在AuthSample应用中一样。
打开/ClientApp/src/app/nav-menu/nav-menu.component.html模板文件,并在其内容中添加对菜单的引用:
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm
navbar-light bg-white border-bottom box-shadow mb-3"
>
<div class="container">
<a class="navbar-brand" [routerLink]="['/']">WorldCities</a>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target=".navbar-collapse"
aria-label="Toggle navigation"
[attr.aria-expanded]="isExpanded"
(click)="toggle()"
>
<span class="navbar-toggler-icon"></span>
</button>
<div
class="navbar-collapse collapse d-sm-inline-flex
flex-sm-row-reverse"
[ngClass]="{ show: isExpanded }"
>
<app-login-menu></app-login-menu>
<ul class="navbar-nav flex-grow">
<li
class="nav-item"
[routerLinkActive]="['link-active']"
[routerLinkActiveOptions]="{ exact: true }"
>
<a class="nav-link text-dark"
[routerLink]="['/']">Home</a>
<!-- ...existing code... --->
现在我们可以切换到后端代码。
调整后端代码
让我们从导入OidcConfigurationController开始。AuthSample项目附带一个专用的.NET Core API 控制器,以提供 URI 端点,该端点将为客户端需要使用的 OIDC 配置参数提供服务。
将AuthSample项目的/Controllers/OidcConfigurationController.cs文件复制到WorldCities项目的/Controllers/文件夹中,然后打开复制的文件并相应更改其名称空间:
// ...
namespace AuthSample.Controllers
// ...
将AuthSample.Controllers更改为WorldCities.Controllers并继续。
安装 ASP.NET Core 标识 UI 包
还记得刚才我们讨论过从后端生成的登录和注册页面吗?这些由Microsoft.AspNetCore.Identity.UI包提供,其中包含的默认剃须刀页面内置 UI。网络核心身份框架。由于默认情况下没有安装,我们需要使用 NuGet 手动将其添加到我们的WorldCities项目中。
在解决方案资源管理器中,右键单击WorldCities树节点,然后选择MManage NuGet packages,查找以下软件包并安装:
Microsoft.AspNetCore.Identity.UI
或者,打开 Package Manager 控制台并使用以下命令进行安装:
> Install-Package Microsoft.AspNetCore.Identity.UI
在撰写本文时,该软件包的最新可用版本为3.1.1;和往常一样,只要我们知道如何相应地调整代码以修复潜在的兼容性问题,我们就可以免费安装新版本。
自定义默认标识 UI
值得注意的是,我们可以通过Identity scaffolder工具替换Microsoft.AspNetCore.Identity.UI包提供的默认登录和注册视图,该工具可用于选择性地添加Identity中包含的源代码Razor 类库(RCL)添加到我们的项目中;一旦生成,可以修改和/或定制源代码,以更改其外观(和/或行为),以更好地满足我们的需要。
Generated (and modified) code will automatically take precedence over the default code in the Identity RCL.
要完全控制 UI 而不使用默认 RCL,请参阅以下指南:
- https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity
- https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity#full
- https://docs.microsoft.com/en-us/aspnet/core/razor-pages/ui-class
为了简单起见,我们不会使用此技术来改变内置登录和注册页面的 UI 和/或行为:我们将保持它们的原样。
将 Razor 页面映射到端点中间件
现在我们(内部)正在使用一些 Razor 页面,我们需要将它们映射到后端路由系统,否则,.NET Core 应用不会将 HTTP 请求转发给它们。
为此,打开WorldCities项目的Startup.cs文件,并在EndpointMiddleware配置块中添加以下高亮显示的行:
// ...
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
endpoints.MapRazorPages();
});
// ...
就这样,;现在我们终于可以登录了。
保护后端操作方法
在测试我们的身份验证和授权实现之前,我们应该多花两分钟来保护我们的后端路由,就像我们对前端路由所做的一样。我们已经知道,我们可以使用AuthorizeAttribute来实现这一点,它可以将控制器和/或操作方法的访问权限限制为仅注册用户。
为了有效保护我们的.NET Core Web API 免受未经授权的访问尝试,明智的做法是在我们所有控制器的PUT、POST和DELETE方法上以以下方式使用它:
- 打开
/Controllers/CitiesController.cs文件,将[Authorize]属性添加到PutCity、PostCity和DeleteCity方法中:
using Microsoft.AspNetCore.Authorization;
// ...
[Authorize]
[HttpPut("{id}")]
public async Task<IActionResult> PutCity(int id, City city)
// ...
[Authorize]
[HttpPost]
public async Task<ActionResult<City>> PostCity(City city)
[Authorize]
[HttpDelete("{id}")]
public async Task<ActionResult<City>> DeleteCity(int id)
// ...
- 打开
/Controllers/CountriesController.cs文件,将[Authorize]属性添加到PutCountry、PostCountry和DeleteCountry方法中:
using Microsoft.AspNetCore.Authorization;
// ...
[Authorize]
[HttpPut("{id}")]
public async Task<IActionResult> PutCountry(int id, Country country)
// ...
[Authorize]
[HttpPost]
public async Task<ActionResult<Country>> PostCountry(Country country)
// ...
[Authorize]
[HttpDelete("{id}")]
public async Task<ActionResult<Country>> DeleteCountry(int id)
// ...
Don't forget to add a reference to the using Microsoft.AspNetCore.Authorization namespace at the top of both files.
现在,所有这些操作方法都受到保护,防止未经授权的访问,因为它们只接受来自已注册和已登录用户的请求;那些没有它的人将收到 401–未经授权的 HTTP 错误响应。
测试登录和注册
在本章中,我们将重复刚才为AuthSample应用所做的登录和注册阶段。不过,这次我们先登录,因为我们已经有了一些现有的用户,这要归功于SeedController的CreateDefaultUsers()方法。
点击F5以调试模式启动WorldCitiesapp。完成后,导航到登录屏幕并插入现有用户的电子邮件和密码。
If we didn't change them at the time, the sample values that we used in SeedController should be the following: email: user@email.com and password: MySecr3t$.
如果我们做的每件事都正确,我们将看到如下屏幕截图所示的屏幕:

之后,我们可以尝试注册工作流来注册新用户,如test@email.com;如果我们的登录路径工作得很好,那么这个操作就没有理由不成功。
我们做到了!现在我们的WorldCities应用包含功能齐全的授权和认证 API。
事实上,我们仍然缺少一些关键功能,例如:
- 电子邮件验证步骤正好在注册阶段之后,这需要电子邮件发送者
- 密码更改和密码恢复功能,也需要上述邮件发送者
- 一些外部认证服务如 Facebook、Twitter 等(即社交登录)
Now that we have implemented the ASP.NET Core Identity services, implementing an email sender to take care of the preceding features would be a rather easy task, especially if we use an external service such as SendGrid.
For additional information, check out the following guide:
https://docs.microsoft.com/en-us/aspnet/core/security/authentication/accconfirm
然而,对于我们的示例应用来说,这绝对足够了;我们准备好进入下一个话题。
总结
在本章的开头,我们介绍了身份验证和授权的概念,承认大多数应用(包括我们的应用)确实需要一种机制来正确处理经过身份验证和未经身份验证的客户端以及经过授权和未经授权的请求。
我们花了一些时间来正确理解身份验证和授权之间的异同,以及使用我们自己的内部提供商处理这些任务或将其委托给第三方提供商(如谷歌、Facebook 和 Twitter)的利弊。我们还发现,幸运的是,ASP.NET Core 身份服务以及IdentityServerAPI 支持提供了一组方便的功能,使我们能够实现两个世界的最佳效果。
为了能够使用它,我们将所需的包添加到我们的项目中,并进行适当配置所需的操作,例如在我们的Startup和ApplicationDbContext类中执行一些更新,并创建一个新的ApplicationUser实体;在实现了所有必需的更改之后,我们添加了一个新的 EF 核心迁移来相应地更新我们的数据库。
我们简要列举了目前可用的各种基于 web 的身份验证方法:会话、令牌、签名和各种双因素策略。经过仔细考虑,我们选择了使用 JWT 的基于令牌的方法,JWT 是IdentityServer默认为 SPA 客户端提供的,它是任何前端框架的可靠且众所周知的标准。
由于 Visual Studio 提供的默认.NET Core 和 Angular 项目模板具有内置的 ASP.NET Core 标识系统和对 Angular 的IdentityServer支持,因此我们创建了一个全新的项目,我们称之为AuthSample,以对其进行测试。我们花了一些时间回顾了它的主要功能,如路由保护、HTTP 拦截器、到后端的 HTTP 往返等等;在此过程中,我们花时间实现了所需的前端和后端授权规则,以保护我们的一些应用视图、路由和 API 不被未经授权的访问。最终,我们将这些 API 导入我们的WorldCitiesAngular 应用,改变了我们现有的前端以及相应的后端代码。
我们已经准备好切换到下一个主题渐进式 web 应用,这将使我们在下一章中忙个不停。
建议的主题
身份验证、授权、HTTP 协议、安全套接字层、会话状态管理、间接寻址、单点登录、Azure AD 身份验证库(ADAL)、ASP.NET Core 标识、IdentityServer、OpenID、OpenID 连接(OIDC)、OAuth、OAuth2、双因素身份验证(2FA)、SMS 2FA、基于时间的一次性密码算法(TOTP)、TOTP 2FA、,IdentityUser、无状态、跨站点脚本(XSS)、跨站点请求伪造(CSRF)、Angular HttpClient、Route Guard、Http 拦截器、LocalStorage、Web 存储 API、服务器端预呈现、Angular Universal、浏览器类型、泛型类型、JWT 令牌、声明、授权属性。
工具书类
-
ASP.NET Core 身份介绍:https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity
-
SPA 认证授权:https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-api-authorization
-
IdentityServer 官方文档:http://docs.identityserver.io/en/latest/
角色经理等级 :https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.identity.rolemanager-1 -
ASP.NET Core 中的身份模型定制:https://docs.microsoft.com/en-US/aspnet/core/security/authentication/customize-identity-model
-
ASP.NET Core 安全概述:https://docs.microsoft.com/en-us/aspnet/core/security/
-
异步并等待:https://blog.stephencleary.com/2012/02/async-and-await.html
-
异步/等待常见问题解答:https://blogs.msdn.microsoft.com/pfxteam/2012/04/12/asyncawait-faq/
-
不阻塞异步代码:http://blog.stephencleary.com/2012/07/dont-block-on-async-code.html
-
异步/等待–异步编程的最佳实践:https://msdn.microsoft.com/en-us/magazine/jj991977.aspx
-
ASP.NET Core 同步上下文:https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html
-
带异步等待的异步编程:https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/index
-
EF 岩芯迁移:https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/
-
SQL Server:创建完整数据库备份:https://docs.microsoft.com/en-us/sql/relational-databases/backup-restore/create-a-full-database-backup-sql-server
-
ASP.NET Core 短信双因素认证:https://docs.microsoft.com/en-us/aspnet/core/security/authentication/2fa
-
在 ASP.NET Core:中为 TOTP 认证器应用启用二维码生成 https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-enable-qrcodes
-
Angular:路由防护装置:https://angular.io/guide/router#guards
-
ASP.NET Core 中的路由:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing
-
ASP.NET Core 授权介绍:https://docs.microsoft.com/en-us/aspnet/core/security/authorization/introduction
-
ASP.NET Core 中的简单授权:https://docs.microsoft.com/en-us/aspnet/core/security/authorization/simple
-
在 ASP.NET Core:中有具体方案授权 https://docs.microsoft.com/en-us/aspnet/core/security/authorization/limitingidentitybyscheme
-
ASP.NET Core 项目中的脚手架标识:https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity
-
ASP.NET Core 标识:创建完整标识 UI 源:https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity#full
-
使用 ASP.NET Core:中的 Razor 类库项目创建可重用 UIhttps://docs.microsoft.com/en-us/aspnet/core/razor-pages/ui-class
-
Angular:HTTP 拦截器:https://angular.io/api/common/http/HttpInterceptor
-
ASP.NET Core 中基于角色的授权:https://docs.microsoft.com/en-us/aspnet/core/security/authorization/roles
-
ASP.NET Core 中的账号确认和密码恢复:https://docs.microsoft.com/en-us/aspnet/core/security/authentication/accconfirm*************
十一、渐进式 Web 应用
在本章中,我们将关注一个话题,我们刚刚在第一章、准备就绪中提到过,当我们第一次谈论当今可用的 web 应用的不同开发模式时:渐进式 web 应用(PWAs.
事实上,我们的HealthCheck和WorldCities应用目前都坚持单页应用(SPA**)模式,至少在大部分情况下是这样的:在以下部分中,我们将看到如何通过实现这种开发方法所需的几个成熟功能,将它们转化为 PWA。
正如我们在第 1 章、准备就绪中了解到的,PWA 是一种 web 应用,它利用现代 web 浏览器的功能向用户提供类似应用的体验。为了实现这一点,PWA 需要满足一些技术要求,包括(但不限于)一个Web 应用清单文件和一个服务人员,以允许他们在离线模式下工作,并像移动应用一样工作。**
**更准确地说,我们要做的是:
- 按照 PWA 的已知规范确定 PWA 所需的技术要求。
- 在我们现有的
HealthCheck和WorldCities应用上实现这些要求,将它们转化为 PWA。更准确地说,我们将使用两种不同的方法来实现这一点:手动执行HealthCheck应用所需的所有步骤,然后使用 Angular CLI 为WorldCities应用提供的PWA 自动设置。 - 测试这两个应用的新 PWA 功能。
本章结束时,我们将学习如何成功地将现有 SPA 转换为 PWA。
技术要求
在本章中,我们需要第 1-10 章中列出的所有以前的技术要求,以及以下附加包:
@angular/service-worker(npm 包)ng-connection-service(npm 包)Microsoft.AspNetCore.Cors(NuGet 套餐)WebEssentials.AspNetCore.ServiceWorker(NuGet 套装,可选http-server(npm 包,可选
和往常一样,避免直接安装它们是明智的:我们将在本章中引入它们,以便更好地将它们的用途与我们的项目联系起来。
PWA–独特特征
让我们从总结 PWA 的主要区别特征开始:
-
渐进式:无论使用何种平台和/或浏览器,PWA 都应适用于每个用户。
-
响应性:他们必须很好地适应任何形式因素:台式机、手机、平板电脑等。
-
独立于连接的:它们必须至少在一定程度上能够脱机工作,例如通知用户某些功能可能无法在脱机模式-或低质量网络上工作。
-
类似的应用:它们需要提供与移动应用相同的导航和交互机制。这包括点击支持、基于手势的滚动等。
-
安全:他们必须提供 HTTPS 支持以提高安全性,例如防止窥探和确保其内容未被篡改。
-
可发现:由于 W3C 清单文件和服务人员注册范围,它们必须被识别为web 应用,以便搜索引擎能够找到、识别和分类它们。
-
可重新订婚:他们应该通过推送通知等功能使重新订婚变得容易。
-
可安装:他们应该允许用户在桌面和/或移动主屏幕上安装和保存它们,就像任何标准的移动应用一样,但不必从应用商店下载和安装它们。
-
可链接:可以通过 URL 轻松共享,无需复杂的安装。
The preceding characteristics can be inferred from the following articles written by the Google developers and engineers who spent their efforts to introduce the PWA concept and define its core specs:
https://developers.google.com/web/progressive-web-apps
https://developers.google.com/web/fundamentals
https://infrequently.org/2015/06/progressive-apps-escaping-tabs-without-losing-our-soul/
这些高级需求可以转化为我们必须执行的特定技术任务。最好的方法是从谷歌 Chrome 工程师亚历克斯·拉塞尔(Alex Russell)描述的技术基线标准开始,他在 2015 年与设计师弗朗西斯·贝里曼(Frances Berriman)共同创造了 PWA 一词:
- 来源于安全来源:换句话说,完全支持 HTTPS,没有混合内容(绿色挂锁显示)。
- 离线加载,即使只是离线信息页面:这显然意味着我们需要实现一个服务工作者。
- 引用至少具有四个关键属性的 Web 应用清单:
name、short_name、stat_url和display(具有独立或全屏值)。 - PNG 格式的 144×144 图标:支持其他尺寸,但最低要求为 144×144。
- 使用矢量图形,因为它们可以无限扩展,并且需要更小的文件大小。
这些技术要求可以转化为我们必须执行的特定技术任务。在下面的部分中,我们将全部实现它们。
安全来源
实现安全源功能基本上意味着通过 HTTPS 证书为我们的应用提供服务。如今,这样的要求很容易实现:由于有很多经销商,TLS 证书非常便宜。由 Comodo Inc.发行的正面 SSL 可在线购买,价格为 10 美元/年左右,可立即下载。
如果我们不想花钱,还有一个由Let's Encrypt提供的免费替代方案:一个免费、自动化、开放的证书颁发机构,可以用来免费获得 TLS 证书。但是,他们用于发布证书的方法需要对部署 web 主机进行 shell 访问(也称为 SSH 访问)。
For additional information about Let's Encrypt and how to obtain an HTTPS certificate for free, check out the official site:
为了简单起见,我们将不讨论 HTTPS 证书发布和安装部分:我们将理所当然地认为读者能够正确安装它,这要感谢各个经销商网站提供的许多操作指南(包括Let's Encrypt)。
脱机加载和 Web 应用清单
连接独立性是 PWAs 最重要的功能之一:为了正确地实现它,我们需要引入并实现一个我们到目前为止很少提到的概念:服务人员。它们是什么?它们如何帮助我们的应用在脱机时工作?
了解服务工作者是什么的最好方法是将其视为在 web 浏览器内运行的 s脚本,并为注册它的应用处理特定任务:此类任务可以包括缓存支持和推送通知。
在正确实施和注册后,服务人员将通过提供与本地移动应用类似的用户体验来增强标准网站提供的用户体验;从技术上讲,他们的角色是拦截用户发出的任何正在进行的 HTTP 请求,并且无论何时该请求被定向到 web 应用,他们都会注册以检查 web 应用的可用性并相应地采取行动。换句话说,我们可以说,当应用无法处理请求时,它们充当具有回退功能的 HTTP 代理。
这种回退可以由开发人员配置为以多种方式进行操作,例如:
- 缓存服务(也称为 offline 模式:服务人员通过查询之前从应用构建的内部(本地)缓存(在线时)来传递缓存响应。
- 离线警告:当没有缓存内容可用时(或者如果我们没有实现缓存机制),服务人员可以提供离线状态信息文本,警告用户应用无法工作。
Those who are familiar with forward cache services might prefer to imagine service workers as reverse proxies (or CDN edges) installed in the end user's web browser instead.
缓存服务功能非常适合提供静态内容的 web 应用,例如基于 HTML5 的游戏应用和不需要任何后端交互的 Angular 应用。不幸的是,它并不适合我们的两个应用:HealthCheck和WorldCities都强烈依赖 ASP.NET Core 提供的后端web API。相反,这些应用肯定可以从离线模式中获益,这样它们的用户将被告知需要互联网连接,而不会收到连接错误、404-未找到消息或任何其他消息。
服务人员与 HttpInterceptor
如果我们还记得我们在第 10 章认证和授权中介绍的各种 Angular 特征,我们可以看到上述行为如何提醒我们HttpInterceptors所扮演的角色。
然而,由于拦截器是 Angular 应用脚本包的一部分,因此每当用户关闭包含 web 应用的浏览器选项卡时,拦截器总是停止工作。相反,在用户关闭选项卡后需要保留服务人员,以便他们可以在连接到应用之前拦截浏览器请求。
理论已经足够了:现在让我们看看如何在现有应用中实现离线模式、Web 应用清单和PNG 图标。
介绍@angular/服务人员
从 5.0.0 版开始,Angular 提供了一个功能齐全的 service worker 实现,可以轻松集成到任何应用中,而无需针对低级 API 编写代码;此类实现由@angular/service-workernpm 包处理,并依赖于从服务器加载的清单文件,该文件描述要缓存的资源,并将由服务工作者用作索引,其行为如下:
- 当 app 上线时,会对每个索引资源进行检查,以检测变化;如果源已更改,服务工作者将更新或重建缓存。
- 当应用离线时,将提供缓存版本。
前面提到的清单文件是从名为ngsw-config.json的 CLI 生成的配置文件生成的,我们必须相应地创建和设置该文件。
It's worth mentioning that web browsers will always ignore service workers if the website that tries to register them is served over an unsecured (non-HTTPS) connection. The reason for that is quite simple to understand: since service workers' defining role is to proxy their source web application and potentially serve alternative content, malicious parties could be interested in tampering them; therefore, allowing their registration to secure websites only will provide an additional security layer to the whole mechanism.
.NET Core PWA 中间件替代方案
值得注意的是,@angular/service-worker并不是我们实现 service worker 和Web App Manifest文件 PWA 功能的唯一可用方法。事实上,.NETCore 提供了自己的方法,通过一组中间件来处理这些需求,这些中间件可以轻松安装并集成到我们项目的 HTTP 堆栈中。
在提供的各种解决方案中,至少在我们看来,最有趣的是由 Mads Kristensen 开发的WebEssentials.AspNetCore.ServiceWorkerNuGet 包,Mads Kristensen 是 Visual Studio extensions 和.NET Core 库的多产作者;该软件包提供功能齐全的 ASP.NET Core PWA 中间件,该中间件附带完整的W**eb 应用清单支持和预构建的服务人员,是@angular/service-workerNPM 软件包提供的纯前端解决方案的有效后端和前端替代方案。
To get additional information about the WebEssentials.AspNetCore.ServiceWorker NuGet package, check out the following URLs:
https://github.com/madskristensen/WebEssentials.AspNetCore.ServiceWorker
https://www.nuget.org/packages/WebEssentials.AspNetCore.ServiceWorker/
总而言之,似乎我们确实有两种方便的方式来完成与 PWA 相关的任务:我们应该选择哪一种?
理想情况下,我们很乐意实现这两种方法:然而,出于空间的原因,我们将只使用@angular/service-workernpm 包,将.NET Core PWA 中间件的替代方案留到下一次使用。
在下一节中,我们将学习如何在现有 Angular 应用中实现@angular/service-worker包,方法有两种非常不同但同样有益的方法。
实施 PWA 要求
要执行我们在上一节中重点介绍的所需实施步骤,我们有两个选择:
- 手动更新我们应用的源代码。
- 使用 Angular CLI 提供的自动安装功能。
要想从这段经历中获得最大的收获,这两条路至少应该走一次。幸运的是,我们有两个现有的 Angular 应用可供实验。因此,我们将首先为HealthCheck应用选择手动路径,然后为WorldCities应用体验自动 CLI 设置。
手动安装
在本节中,我们将了解如何手动实施我们仍然缺少的必要技术步骤,以使我们的HealthCheck应用完全符合 PWA 要求
让我们简要回顾一下:
- 添加
@angular/service-workernpm 包(package.json) - 在 Angular CLI 配置文件(
angular.json中启用 service worker 支持 - 在
AppModule类(app.module.ts中导入并注册ServiceWorkerModule - 更新主应用的 HTML 模板文件(
index.html) - 添加合适的图标文件(
icon.ico) - 添加清单文件(
manifest.webmanifest) - 添加服务工作者配置文件(
ngsw-config.json)
对于每个步骤,我们都提到了必须在括号之间更新的相关文件。
添加@angular/service worker npm 包
首先要做的是将@angular/service-workernpm 包添加到我们的package.json文件中。正如我们很容易猜到的,这样一个包包含 Angular 的 service worker 实现,我们刚才讨论过。
打开/ClientApp/package.json文件,将以下包引用添加到@angular/router包正下方的"dependencies"部分:
// ...
"@angular/router": "9.0.0",
"@angular/service-worker": "9.0.0",
"@nguniversal/module-map-ngfactory-loader": "9.0.0-next.9",
// ...
保存文件后,VisualStudio 将自动下载并安装 npm 包。
更新 angular.json 文件
打开/ClientApp/angular.json配置文件,将"serviceWorker"和"ngswConfigPath"键添加到项目|健康检查|架构师|构建|选项|配置|生产部分的末尾:
// ...
"vendorChunk": false,
"buildOptimizer": true,
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
// ...
As always, whenever we have issues while applying these changes, we can check out the source code available from this book's GitHub repository.
我们刚刚设置的"serviceWorker"标志将导致生产构建在输出文件夹中包含两个额外文件:
ngsw-worker.js:主要服务人员档案ngsw.json:Angular 服务人员的运行时配置
这两个文件都是我们的服务人员执行其工作所必需的。
导入 ServiceWorkerModule
由@angular/service-workernpm 包库提供的ServiceWorkerModule将负责注册服务人员,并提供一些我们可以用来与之交互的服务。
要在我们的HealthCheck应用上安装它,请打开/ClientApp/src/app/app.module.ts文件并添加以下行(新行高亮显示):
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
// ...
imports: [
BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
HttpClientModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'health-check', component: HealthCheckComponent }
]),
ServiceWorkerModule.register(
'ngsw-worker.js',
{ registrationStrategy: 'registerImmediately' })
],
// ...
正如我们前面提到的,前面代码中引用的ngsw-worker.js文件是主服务工作者文件,它将在构建应用时由 Angular CLI 自动生成:registrationStrategy属性将确保在应用启动后立即注册。
For additional information regarding the service worker registration options and the various registrationStrategy available settings, read the following URL:
https://angular.io/api/service-worker/SwRegistrationOptions
更新 index.html 文件
/ClientApp/index.html文件是我们 Angular 应用的主要入口点。它包含<app-root>元素,该元素将在引导阶段结束时被我们的应用 GUI 所取代,以及一些描述我们应用行为和配置设置的资源引用和元标记。
打开该文件并在<head>元素末尾添加以下代码(更新的行高亮显示):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>HealthCheck</title>
<base href="/" />
<meta name="viewport" content="width=device-width,
initial-scale=1" />
<link rel="icon" type="img/x-icon" href="favicon.ico" />
<!-- PWA required files -->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,
500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#1976d2">
</head>
<body>
<app-root>Loading...</app-root>
</body>
</html>
突出显示的行配置了应用的font、theme-color以及最重要的manifest.webmanifest文件链接,正如其名称所明确暗示的,这是应用的清单文件,这是任何 PWA 的关键要求之一。
听到这个消息真是太好了,不过我们的应用中还没有这个消息:让我们现在来弥补这个差距。
添加 Web 应用清单文件
这个方便的工具还将为我们生成所有必需的 PNG 图标文件,从而节省我们很多时间。但是,我们需要一个 512 x 512 的图像源。如果我们没有,我们可以使用DummyImage网站轻松创建一个,这是另一个有用的免费工具,可用于生成任意大小的占位符图像,可在上找到 https://dummyimage.com/ 。
下面是一个生成的 PNG 文件,我们可以使用它为前面的 FirebaseWeb 应用清单生成器工具提供信息:

我们很容易猜到,HC代表健康检查;我们不太可能用这张图片赢得一场图形竞赛,但它对我们当前的任务来说效果很好。
The preceding PNG file can be downloaded from https://dummyimage.com/512x512/361f47/fff.png&text=HC.
读者可以自由地使用它,使用相同的工具创建另一个文件,或者提供另一个图像。
完成后,返回 Web App Manifest Generator online 工具,并使用以下参数对其进行配置:
- 应用名称:
HealthCheck - 简称:
HealthCheck - 主题色:
#2196f3 - 背景色:
#2196f3 - 显示方式:
Standalone - 方向:
Any - 适用范围:
/ - 起始 URL:
/
然后,单击图标按钮并选择我们刚才生成的 HC 图像,如以下屏幕截图所示:

通过单击 Generate.ZIP 按钮生成归档文件,将其解压缩,然后按以下方式复制包含的文件:
/ClientApp/src/文件夹中的manifest.json文件/icons/文件夹及其所有内容位于/ClientApp/src/img/文件夹中,因此实际的 PNG 文件将放置在/ClientApp/src/img/icons/文件夹中
完成后,我们需要对manifest.json文件执行以下更改:
- 将所有图标起始路径从
images/icons/更改为img/icons/。 - 将其从
manifest.json重命名为manifest.webmanifest,因为这是 Web 应用清单 W3C 规范定义的名称。
Those who want to take a look at the Web App Manifest W3C Working Draft 09 December 2019 can visit the following URL:
https://www.w3.org/TR/appmanifest/
As a matter of fact, the .json and .webmanifest extensions will both work, as long as we remember to set the application/manifest+json MIME type; however, since most web servers set the MIME type based upon the file extension, opting for the .webmanifest choice will arguably make things easier.
Those who want to know more about this .json versus .webmanifest extension debate should take a look at this interesting discussion in the Web App Manifest GitHub repository:
https://github.com/w3c/manifest/issues/689
更新 Startup.cs 文件
如果我们选择遵循 Web 应用清单 W3C 规范并使用.webmanifest扩展名,我们需要对.NET CoreStartup类进行一个小的修改,使 Kestrel Web 服务器能够为这些文件提供服务。
在HealthCheck和WorldCities项目中,打开Startup.cs文件并更新其Configure()方法的内容,如下所示(新的/更新的行突出显示):
using Microsoft.AspNetCore.StaticFiles;
// ...
app.UseHttpsRedirection();
// add .webmanifest MIME-type support
FileExtensionContentTypeProvider provider = new FileExtensionContentTypeProvider();
provider.Mappings[".webmanifest"] = "application/manifest+json";
app.UseStaticFiles(new StaticFileOptions()
{
ContentTypeProvider = provider,
// ...
if (!env.IsDevelopment())
{
app.UseSpaStaticFiles(new StaticFileOptions()
{
ContentTypeProvider = provider
});
}
// ...
就是这样:现在所有扩展名为.webmanifest的文件都将被正确地用作具有application/manifest+jsonMIME 类型的静态文件。
It's worth noting that we configured either the app.UseStaticFiles() or app.useSpaStaticFiles() middleware; the first one controls the static files in the /www/ folder, while the latter handles those located within the /ClientApp/dist/ app.
发布 Web 应用清单文件
要将我们的/ClientApp/src/manifest.webmanifest文件与 HealthCheck 的 Angular 应用文件一起发布,我们需要将其添加到/ClientApp/angular.jsonCLI 配置文件中。
打开该文件并替换以下所有条目:
"assets": ["src/assets"]
将其替换为以下更新值:
"assets": [
"src/assets",
"src/manifest.webmanifest"
],
angular.json文件中应该有两个"asset"键条目:
projects > health_check > architect > build > optionsprojects > health_check > architect > test > options
如前所述,它们都需要修改。
通过此更新,manifest.webmanifest文件将在我们构建 Angular 应用时发布到输出文件夹。
添加 favicon
favicon(也称为收藏夹图标、快捷方式图标、网站图标、选项卡图标、URL 图标或书签图标)是包含一个或多个小图标的文件,可用于识别特定网站;每当我们在浏览器的地址栏、历史记录和/或包含给定网站的选项卡中看到一个小图标时,我们就会看到该网站的favicon。
favicon 可以手动生成,但如果我们不是平面设计师,我们可能希望使用在线可用的各种favicon 在线生成器之一,尤其是考虑到其中大多数都是完全免费使用的;我们唯一需要的是一个合适的图像,它需要手动提供(并上传到服务)。
Here's a couple of recommended favicon online generator available nowadays:
favicon.io (https://favicon.io/)
Real Favicon Generator(https://realfavicongenerator.net/)
或者,我们可以在线下载众多免费favicon 套装中的一套:
Here are some free websites that offer free favicons to download:
**Icons8 **(https://icons8.com/icons/set/favicon)
**FreeFavicon **(https://www.freefavicon.com/freefavicons/icons/)
事实上,我们用来创建HealthCheck项目的.NET Core 和 Angular Visual Studio 模板已经为我们提供了一个favicon:我们可以在项目的/www/根文件夹中找到它。
老实说,这样一个favicon是相当丑陋的,我们可以从下面的截图中看到:

虽然不是很好,但这样的 favicon 不会阻止我们的应用成为 PWA:我们可以保留它,也可以使用前面提到的网站之一更改它。
添加 ngsw-config.json 文件
在解决方案资源管理器中,在HealthCheck项目的/ClientApp/文件夹中创建一个新的ngsw-config.json文件,并用以下源代码填充其内容:
{
"$schema": "./node_modules/@angular/service-worker/config/
schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/img/**",
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
]
}
}
]
}
通过查看assetGroup > app部分,我们可以看到,前面的文件告诉 Angular 缓存favicon.ico文件和manifest.webmanifest文件(我们不久前创建的),以及主index.html文件和所有 CSS 和 JS 包,换句话说,我们应用的静态资产文件。紧接着,还有一个assetGroup > assets部分,它定义了图像文件。
这两部分之间的主要区别在于installMode参数值,它决定了这些资源最初是如何缓存的:
- 预取告知服务工作者在缓存当前版本的应用时获取这些资源;换句话说,一旦这些内容可用,即浏览器第一次访问在线应用时,它会将所有这些内容放在缓存中。我们可以称之为预先缓存策略。
- lazy告诉服务工作者,当浏览器第一次显式请求这些资源时,只缓存这些资源。这可以称为按需缓存策略。
上述设置适用于仅依赖于前端(无后端所需调用)的通用 Angular 应用,因为这些文件基本上包含整个应用;更具体地说,一个托管 HTML5 游戏的 Angular 应用可能会考虑将其部分图像文件(甚至全部)从资产部分移动到应用部分,这样整个应用(包括图标、精灵、,所有图像资源都将被提前缓存,即使在应用离线时也完全可用。
然而,对于我们的HealthCheck和WorldCities应用来说,这样的缓存策略是不够的:即使我们告诉我们的服务人员缓存整个应用文件,我们的所有应用的 HTTP 调用仍然会在浏览器离线时失败,而不会让用户知道任何相关信息。事实上,我们的后端可用性要求迫使我们为这两个应用做一些额外的工作。
然而,在这样做之前,让我们让我们的WorldCities应用加速。
自动安装
我们在上一节中手动执行的所有步骤都可以通过使用以下 CLI 命令自动完成,以便为我们的HealthCheck应用启用Service Worker支持:
> ng add @angular/pwa
让我们在WorldCities应用中采用这种替代技术。
打开命令提示符并导航到 WorldCities 应用的/ClientApp/文件夹,然后执行前面的命令:Angular CLI 将通过添加@angular/service-worker包并执行其他所需步骤自动配置我们的应用。
整个操作的最相关信息将写入控制台输出,如以下屏幕截图所示:

从日志中我们可以看到,自动过程执行的步骤与我们刚刚应用于HealthCheck应用的步骤相同。
At the time of writing, the latest available version of the @angular/pwa package is 0.900.0: however, the ng add command will likely install an older version, such as ^0.803.21. We can either keep that version or manually upgrade it to the latest available one: both of them will work just fine.
Angular PNG 图标集
PWA 自动设置功能还将在/ClientApp/src/img/icons/文件夹中提供一些不同大小的 PNG 图标。如果我们用图形应用打开它们,我们可以看到它们都复制了有 Angular 的徽标,如下所示:

无论何时,只要我们想让我们的应用向公众开放,我们都可能想要更改这些图标。然而,它们已经足够了,至少目前是这样:让我们保持这些文件的原样,继续完成剩下的最后一项任务,将 SPA 转换为 PWA。
处理脱机状态
现在,我们在两个应用中都配置了一个服务工作者,我们可以想出一种方法来处理脱机状态消息,这样当应用脱机时,我们的每个组件都可以以不同的方式运行,例如限制其功能并显示脱机状态向我们的用户发送信息性消息。
要实现这些条件行为,我们需要找到一种方法来正确确定浏览器连接状态,即浏览器是否在线;在下面的部分中,我们将简要回顾几种不同的方法,我们可以使用它们来做出(可以说)最好的选择。
选项 1–窗口的 isonline/ISOFLINE 事件
如果我们愿意接受一种纯 JavaScript 的方式来处理这个问题,那么使用window.ononline和window.onofflineJavaScript 事件就可以很容易地完成这样的任务,这些事件可以直接从任何 Angular 类访问。
以下是我们如何使用它们:
window.addEventListener("online", function(e) {
alert("online");
}, false);
window.addEventListener("offline", function(e) {
alert("offline");
}, false);
然而,如果我们愿意采用纯 JavaScript 方法,还有更好的方法来实现它。
选项 2–Navigator.onLine 属性
由于我们不想跟踪网络状态的变化,只想寻找一种简单的方法来确定浏览器是否在线,我们只需检查window.navigator.onLine属性就可以让事情变得更简单:
if (navigator.onLine) {
alert("online");
}
else {
alert("offline");
}
正如我们可以很容易地从其名称猜到的那样,这样的属性返回浏览器的联机状态。该属性返回一个布尔值,true表示在线,false表示离线,并在浏览器连接到网络的能力发生变化时更新。
由于这一特性,我们的 Angular 实现可以简化为:
ngOnInit() {
this.isOnline = navigator.onLine;
}
然后,我们可以在组件的模板文件中使用isOnline局部变量,这样我们就可以使用ngIf结构指令向用户显示不同的内容。这将非常简单,对吗?
不幸的是,事情从来没有这么简单:让我们试着去理解为什么。
JavaScript 方法的缺点
我们提到的两种基于 JS 的方法都有一个严重的缺陷,这是因为现代浏览器以不同的方式实现了navigator.online属性(以及window.isononline和window.isonoffline事件)。
更具体地说,当浏览器可以连接到局域网或路由时,Chrome 和 Safari 会将该属性设置为true:这很容易产生误报,因为大多数家庭和商业连接都是通过本地局域网连接到互联网的,即使实际的互联网接入中断,本地局域网也会保持正常。
For additional information regarding the Navigator.onLine property and its drawbacks, check out the following URL:
https://developer.mozilla.org/en-US/docs/Web/API/NavigatorOnLine/onLine
综上所述,这基本上意味着我们不能使用前面描述的方便方法来检查浏览器的在线状态;只要我们想认真处理这件事,我们就需要找到更好的办法。
选项 3–ng 连接服务 npm 包
幸运的是,有一个整洁的 npm 软件包正是我们所需要的:它的名字是ng-connection-service,它基本上是一个互联网连接监控服务,可以检测浏览器是否有活动的互联网连接。
在线检测任务正在使用(可配置的)心跳系统执行,该系统将定期向(可配置的)URL 发出头请求,以确定互联网连接状态。
以下是软件包的默认值:
enableHeartbeat:trueheartbeatUrl://internethealthtest.orgheartbeatInterval:1000(毫秒)heartbeatRetryInterval:1000requestMethod:head
For additional information about the ng-connection-service npm package, check out the following URL:
https://github.com/ultrasonicsoft/ng-connection-service
除了heartbeatUrl之外,大多数都是好的——原因很多,我们将在后面解释。
不用说,它是一个 Angular 服务,我们将能够以集中的方式配置它,然后在需要时注入它,而无需每次手动配置:这似乎太好了,不可能是真的!
让我们看看如何实现它。
安装 ng 连接服务
不幸的是,引入心跳的ng-connection-service最新版本到今天为止还没有在 npm 上提供:最新的更新版本是1.0.4,它是在一年多前(在撰写本文时)为 Angular 6 开发的,仍然基于我们早期讨论的window.isononline和window.isonoffline事件。
在撰写本文时,我不知道作者(Balram Chavan)为什么还没有更新 npm 软件包:但是,由于他在 GitHub 上发布了最新版本的源代码,并获得了MIT许可证,我们完全可以手动将其安装到HealthCheck和WorldCities应用中。
为此,我们需要在HealthCheck和WorldCities项目中执行以下步骤:
-
访问位于的项目 GitHub 存储库 https://github.com/ultrasonicsoft/ng-connection-service
-
使用 GIT 克隆项目或在本地下载 ZIP 文件并将其解压缩到某个地方
-
创建一个新的
/ClientApp/src/ng-connection-service/**文件夹,并在其中复制以下文件:connection-service.module.tsconnection-service.service.spec.tsconnection-service.service.ts**
**这些文件可在ng-connection-servicenpm 包包的以下子文件夹中找到:
ng-connection-service-master\projects\connection-service\src\lib
就这样!现在,我们可以在我们的应用中实现该服务。
正在更新 app.component.ts 文件
离线状态提示信息应显示给我们的用户:
- 尽快,以便他们在导航到某个地方之前了解应用的连接状态
- 无处不在,这样即使他们访问一些内部视图,也会收到警告
因此,实现它的一个好方法是AppComponent类,它包含我们所有的应用,而不管用户选择的前端路径。
打开/ClientApp/src/app/app.component.ts文件并相应修改其类文件(更新的行高亮显示):
import { Component } from '@angular/core';
import { ConnectionService } from '../ng-connection-service/connection-service.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent {
title = 'app';
hasNetworkConnection: boolean;
hasInternetAccess: boolean;
isConnected = true;
status: string;
constructor(private connectionService: ConnectionService) {
this.connectionService.updateOptions({
heartbeatUrl: "/isOnline.txt"
}); this.connectionService.monitor().subscribe(currentState => {
this.hasNetworkConnection = currentState.hasNetworkConnection;
this.hasInternetAccess = currentState.hasInternetAccess;
if (this.hasNetworkConnection && this.hasInternetAccess) {
this.isConnected = true;
this.status = 'ONLINE';
} else {
this.isConnected = false;
this.status = 'OFFLINE';
}
});
}
}
正如我们所见,我们借此机会修改了heartbeatUrl值:我们将检查一个专用的sOnline.txt文件,我们将在应用中创建并正确配置该文件,而不是查询第三方网站。我们选择这一选择有几个很好的理由,其中最重要的是:
- 避免对第三方主机造成麻烦
- 避免针对第三方资源的跨来源资源共享(CORS)问题
We'll talk more about CORS in a dedicated mini-section in a short while.
由于前面提到的isOnline.txt文件还不存在,我们现在就创建它。
在解决方案资源管理器中,右键单击HealthCheck项目的/www/文件夹,然后在那里创建一个新的isOnline.txt文件,并用以下行填充其内容:
.
事实上,内容并不相关;因为我们只需要对它执行一些 HEAD 请求来检查我们的应用的在线状态,一个点就足够了。
IMPORTANT: Remember to perform the preceding changes (and to create the isOnline.txt file) for both our projects (HealthCheck and WorldCities).
从缓存中删除 isOnline.txt 静态文件
isOnline.txt文件当然是静态文件;因此,它受我们在第 2 章环顾中为HealthCheck应用设置的静态文件缓存规则的约束。然而,由于该文件将用于定期检查我们的应用的在线状态,因此将其缓存在后端几乎不是一个好主意。
要将其从我们设置的全局静态文件缓存规则中删除,请打开HealthCheck的Startup.cs文件,并按以下方式更新其内容(新的/更新的行高亮显示):
// ...
app.UseStaticFiles(new StaticFileOptions()
{
ContentTypeProvider = provider,
OnPrepareResponse = (context) =>
{
if (context.File.Name == "isOnline.txt")
{
// disable caching for these files
context.Context.Response.Headers.Add("Cache-Control",
"no-cache, no-store");
context.Context.Response.Headers.Add("Expires", "-1");
}
else
{
// Retrieve cache configuration from appsettings.json
context.Context.Response.Headers["Cache-Control"] =
Configuration["StaticFiles:Headers:Cache-Control"];
context.Context.Response.Headers["Pragma"] =
Configuration["StaticFiles:Headers:Pragma"];
context.Context.Response.Headers["Expires"] =
Configuration["StaticFiles:Headers:Expires"];
}
}
});
// ...
现在我们已经从后端缓存中排除了isOnline.txt文件,我们可以进入下一步。
IMPORTANT: Remember to configure the isOnline.txt file no-cache features in the WorldCities project as well. Even if no caching rules have been defined there, it's definitely a good idea to explicitly keep it outside the cache by adding the above headers.
通过 NPM 安装 ng 连接服务(备用路线)
如果我们不想手动安装ng-connection-service,我们仍然可以使用 1.0.4 版本,方法是在"dependencies"部分末尾为前面两个项目的/ClientApp/src/package.json文件添加以下高亮显示的行:
// ...
"zone.js": "0.10.2",
"ng-connection-service": "1.0.4"
// ...
并以以下方式在 AppComponent 的文件中实现它:
import { Component } from '@angular/core';
import { ConnectionService } from 'connection-service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent {
status = 'ONLINE';
isConnected = true;
constructor(private connectionService: ConnectionService) {
this.connectionService.monitor().subscribe(isConnected => {
this.isConnected = isConnected;
if (this.isConnected) {
this.status = "ONLINE";
}
else {
this.status = "OFFLINE";
}
})
}
}
正如我们所看到的,ConnectionService接口在这个版本中略有不同:因此,我们将无法依赖hasNetworkConnection和hasInternetAccess变量,以及它们在新版本中提供的有用信息。
However, if we choose that (simplified and less robust) approach, we won't have to configure the internethealthtest.org website to our app's CORS policy: we'll talk about it later on.
正在更新 app.component.html 文件
最后但并非最不重要的一点是,我们需要修改AppComponent的模板文件,以便在isConnected局部变量计算为false时向用户显示“脱机状态”信息消息。
打开/ClientApp/src/app/app.component.html文件并相应更新其内容:
<body>
<div class="alert alert-warning" *ngIf="!isConnected">
<strong>WARNING</strong>: the app is currently <i>offline</i>:
some features that rely upon the back-end might not work as
expected. This message will automatically disappear as soon
as the internet connection becomes available again.
</div>
<app-nav-menu></app-nav-menu>
<div class="container">
<router-outlet></router-outlet>
</div>
</body>
就是这样:因为我们的应用的主视图不直接需要后端HTTP 请求,所以我们选择只显示一条警告消息,通知用户我们的应用的某些功能在脱机时可能无法工作。相反,我们可以通过向其他元素添加一个额外的ngIf="isConnected"结构指令来完全关闭应用,这样离线状态消息将是唯一可见的输出。
跨请求资源共享
正如我们前面所说的,ng-connection-service的最新版本允许我们在定义的时间(“心跳”)内执行 HEAD 请求,以确定我们是否在线。但是,我们已经选择使用我们为此目的创建的本地文件(isOnline.txt)更改在服务默认值(internethealthtest.org中设置的第三方网站。
我们为什么这么做?定期向第三方网站发出 HEAD 请求有什么不对?
第一个原因很容易理解:我们不想成为这些网站的麻烦,因为它们绝对不是让我们检查它们的在线状态的。如果他们的系统管理员在他们的日志中看到我们的请求,他们可能会禁止我们,或者采取一些措施阻止我们的心跳检查工作,或者更糟糕地损害它的可靠性状态。
然而,避免这种做法还有另一个重要原因。
允许我们的应用向外部网站发出 HTTP 请求可能违反这些网站的默认 CORS 策略设置;在这里,花几句话来更好地理解这个概念可能会很有用。
我们可能已经知道,现代浏览器具有内置的安全设置,可防止网页向服务于该网页的域以外的域发出 JavaScript 请求:这种限制称为同源策略它的引入是为了防止恶意第三方网站从其他网站读取数据。
然而,大多数网站可能希望(或需要)向其他网站发出一些外部请求:例如,ng-connection-service中配置的默认heartbeatUrl会告诉我们的应用向internethealthtest.org外部网站发出 HEAD 请求,以检查其在线状态。
这些要求在大多数应用中非常常见,称为 CORS:为了允许这些要求,浏览器希望从接收服务器接收托管所需资源的要求——一个合适的CORS 策略这将允许他们通过:如果此策略不出现或不包括请求源,HTTP 请求将被阻止。
For additional information about CORS and its settings, visit the following URL:
https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
如果我们是远程服务器,我们可以通过配置Microsoft.AspNetCore.CorsNuGet 包中的.NET CORS 中间件来配置这样的策略:ng-connection-servicenpm 包使用的心跳机制使我们的应用及其主机名成为源服务器,这意味着只有当远程服务器具有与我们兼容的 CORS 策略时,这种方法才会起作用,而不会改变它。
由于这种基于心跳的机制现在是我们应用的一个关键部分,我们不能承担被切断的风险:因此,我们用一个更安全的 URL 取代了不安全的第三方引用,该 URL 指向我们控制下的内部资源,并且没有 CORS 策略要求,因为它被托管在为 Angular 应用服务的同一台服务器上。
To know more about the Microsoft.AspNetCore.Cors NuGet package and how to configure CORS in .NET Core apps, check out the following URL:
https://docs.microsoft.com/en-us/aspnet/core/security/cors
有了这些,我们成功地实现了所有必需的 PWA 特性。现在,让我们找到一种方法来正确地测试我们所做的事情:由于 PWAs 的独特功能,在 Visual Studio 中这样做并不容易,但是我们可以使用一些变通方法来实现它。
测试 PWA 能力
在本节中,我们将尝试测试HealthCheck应用的服务人员注册。不幸的是,在 Visual Studio 开发环境中执行此操作是一项相当复杂的任务,原因有以下几点:
ng serveAngular CLI 命令不支持服务人员,该命令在我们以调试模式运行应用时预安装软件包并启动应用- 刚才我们放在
AppModulea 类中的服务人员注册任务只在应用在生产环境中运行时注册 - Angular CLI 使用我们早期修改的
angular.json配置文件生成的所需静态文件将仅在生产环境中可用
幸运的是,我们可以通过一些小调整来克服这些限制,这将允许我们从 Visual Studio 和 IIS Express 中正确测试我们的Web 应用清单文件和服务人员。
使用 VisualStudio 和 IIS Express
简而言之,我们需要做以下几点:
- 为我们的
HealthCheck和WorldCities项目创建发布配置文件,并使用生产环境将我们的项目发布到临时文件夹中,这是发布我们的应用时的默认配置 - 将 CLI 生成的文件从发布文件夹复制到我们项目的
/www/文件夹 - 在调试模式下运行这两个应用,并正确检查它们的 PWA 功能
The tweak is to copy the CLI-generated files to the /www/ folder so that they will be available to the web browser even if the app is being built and launched from a development environment.
让我们开始工作吧。
创建发布配置文件
我们可能已经知道,发布****概要文件是 Visual Studio 为生产环境部署 web 应用项目提供的一种便捷方式。这样的功能允许我们在文件系统上发布应用,通过 FTP/FTPS 服务器,在 Windows 或 Linux 上的 Azure 应用服务上发布应用,等等。
在我们的特定场景中,我们需要将我们的.NET Core 和 Angular web 应用发布到一个文件系统文件夹中,这样我们就可以通过命令行的http-server为其提供服务;为此,我们需要执行以下步骤:
- 在解决方案资源管理器中的项目上单击鼠标右键,然后选择“发布”。
- 从右侧可用的各种选项中选择文件夹。
- 选择合适的文件夹路径,如
C:\Temp\HealthCheck\-并单击高级链接(参见以下屏幕截图):

- 在高级设置中,选择以下参数:
- 配置:发布
- 目标框架:netcoreapp3.1
- 部署模式:依赖于框架
- 目标运行时:可移植
您可以在以下屏幕截图中看到这一点:

- 完成后,单击“保存”保存高级设置,然后创建配置文件以完成任务。因此,新的
FolderProfile.pubxml文件将添加到项目的Properties/PublishProfiles文件夹中。
现在我们可以按发布按钮发布所选文件夹中的HealthCheck应用文件。完成后,我们可以对WorldCities应用重复相同的任务,相应地更改目标文件夹。
复制 CLI 生成的文件
现在我们已经有了一个生产构建,我们可以将以下 CLI 生成的文件从示例中的文件系统发布文件夹C:\Temp\HealthCheck\和C:\Temp\WorldCities\复制到项目的/www/文件夹中。
The /www/ folder, as we already know since Chapter 2, Looking Around, can be used to host the web app's static files, that is, those that we want to make available to the public; that's just what we need to make those CLI-generated files available to the browser to fetch the Web Manifest File and register the service worker.
以下是我们需要复制的文件:
manifest.webmanifestngsw.jsonngsw-worker.jssafety-worker.jsworker-basic.min.js
完成后,我们可以按F5以调试模式启动应用,就像我们一直做的那样。
测试我们的 PWA
最后,我们能够正确地测试我们应用的 PWA 功能:为了简单起见,下面的屏幕截图将全部与HealthCheck相关,但同样的检查也可以应用于WorldCities应用,因为我们使用相同的实现模式对其进行了配置。
It's strongly advisable to perform the following tests with Google Chrome since it comes with some neat built-in tools to check for Web App Manifest and service workers presence. Also, be sure to use the incognito mode to ensure that the service worker will always start from scratch, without reading previously built caches or states.
在应用的主页vie**w正确加载后,按Shift+Ctrl+J打开 Chrome 开发者工具,如下图所示:

如我们所见,如果我们导航到开发者工具的应用选项卡,我们可以看到我们的Web 应用清单文件已正确加载:如果我们向下滚动应用清单面板,我们将能够看到我们的 PNG 图标。
我们可以检查的下一件事是 Application | Service Workers 面板,该面板应与以下屏幕截图中显示的面板非常相似:

服务人员JavaScript 文件应清晰可见,并带有注册日期和当前启动和运行状态。
现在让我们尝试将 web 浏览器脱机。要做到这一点,请激活 Chrome Developer Tools 应用选项卡左上方的 Offline 复选框,然后查看发生了什么:

由于我们的ng-connected-service实施,我们的离线警告信息消息应该立即生效。如果我们移动到网络选项卡,我们可以看到isOnline.txt文件不再可访问,这意味着AppComponents的isConnected变量现在计算为false。
现在,我们可以恢复连接(通过取消选中 Offline 复选框)并检查另外两个功能:可链接的和可安装的PWA 功能。它们都清楚地显示在浏览器地址栏的最右侧,如下面的屏幕截图所示:

如果我们使用鼠标指针,我们应该能够看到上下文消息,要求我们分别将应用的 URL 发送到其他设备并安装到桌面。
安装 PWA
现在让我们点击安装按钮(圆圈中刻有加符号的按钮),确认我们要本地安装HealthCheckPWA
一个新的弹出窗口应该能够在桌面应用(如窗口)中显示我们新安装的应用的主视图,如以下屏幕截图所示:

然后,执行以下操作:
- 点击Shift+Ctrl+J再次打开谷歌 Chrome 开发者工具
- 导航到应用|服务人员面板
- 单击脱机复选框以再次选中/激活它
应用应再次显示离线警告信息消息;完成后,单击应用导航菜单右上角附近的最右侧链接,尝试导航到“健康检查”视图。
我们应该可以看到这样的情况:

正如我们所看到的,我们的应用即使在离线状态下也能工作:离线消息显示给用户。
The "912" attempts shown in the top-right section of the Google Chrome Developer tools containing window also shows that our heartbeat is doing its job, periodically trying to find to the isOnline.txt file back up.
不用说,我们将看不到我们的健康检查结果表:然而,离线警告信息消息足以让我们的用户意识到这样的行为在应用离线时是完全可以接受的。
就是这样:我们已经成功地将水疗变成了 PWA。事实上,我们刚刚触及了这种有前途的部署方法所提供的许多可能性的表面:然而,我们已经成功地证明了我们的前端和后端框架完全能够正确且一致地处理其主要需求。
替代测试方法
如果我们不想使用前面的调整,可以使用多种可能的选项,例如:
- 在类似产品的环境中发布我们的应用
- 使用支持服务工作者的 HTTP 服务器本地提供用于发布应用的文件系统文件夹的内容
第一个选项将在第 12 章、Windows 和 Linux 部署中广泛介绍;为了实现第二个选项,我们可以使用http-server,一个简单、轻量级的命令行 HTTP 服务器,可以在几秒钟内安装和启动。
**# 使用 http 服务器为我们的 PWA 提供服务
http-server可以使用npm安装,也可以使用npx直接启动,这是 Node.js 附带的一个工具,可用于执行npm包二进制文件,而无需安装它们。
如果我们想在启动之前对其进行全局安装,可以使用以下命令:
> npm install http-server -g
> http-server -p 8080 -c-1 C:\Temp\HealthCheck\ClientApp\dist\
如果我们只想测试我们的服务人员,我们可以使用以下命令:
> npx http-server -p 8080 -c-1 C:\Temp\HealthCheck\ClientApp\dist\
这两个命令都将启动http-server并将我们的HealthCheck应用服务于本地8080TCP 端口,如下图所示:

一旦我们这样做,我们就可以通过打开浏览器并在地址栏中键入以下 URL 连接到它:http://localhost:8080 。
我们可以查看应用的 PWA 功能,就像我们早期使用 Visual Studio 和 IIS Express 时一样;但是,我们无法测试后端HTTP 请求,因为http-server本机不支持.NET Core。幸运的是,我们不需要后端来运行这些测试。
总结
这一章是关于 PWA 的:我们花了一些宝贵的时间来更好地理解这种现代 web 开发模式的高级独特功能,以及如何将它们转化为技术规范。紧接着,我们开始实施它们,考虑到我们的前端和后端框架提供的各种可用选项。
由于 PWA 概念与我们应用的前端方面密切相关,我们选择采用 Angular 的方式来实现其所需的功能:考虑到这一点,我们选择首先为我们的HealthCheck应用采用手动路径,然后体验由 Angular CLI 为WorldCities应用提供的自动安装功能。在这两种情况下,我们都很好地利用了@angular/service-workernpm 包,这是自 Angular 5 开始提供的一个模块,它提供了一个功能齐全的 service worker 实现,可以轻松地集成到我们的应用中。
一旦我们做到了这一点,我们就手动运行了一些一致性测试,使用谷歌 Chrome 及其开发工具检查我们应用的全新 PWA 功能。为此,我们借此机会学习如何使用 VisualStudio 的发布配置文件功能发布我们的应用。
在本章结束时,我们终于看到我们的服务人员、以及Web 应用清单文件能够为 PNG 图标提供服务,并为我们的应用提供安装和链接功能。
我们在本章中学习的各种概念也帮助我们关注了一些非常重要的问题,这些问题与开发和生产环境之间的差异有关,从而使我们准备好正确面对旅程的最后一部分:Windows 和 Linux 部署,这将是下一章的主要主题。
建议的主题
渐进式 Web 应用(PWA)、@angular/service worker、安全源、HTTPS、TLS、Let's Encrypt、service workers、HTTP 拦截器、favicon、Web 应用清单文件、Microsoft.AspNetCore.Cors、跨源资源共享(Cors)、脱机状态、window.navigator、ng 连接服务、IIS Express、HTTP 服务器。
工具书类
- 渐进式网络应用:https://developers.google.com/web/progressive-web-apps
- 网络基础:https://developers.google.com/web/fundamentals
- 渐进式网络应用:逃离标签而不失去灵魂:https://infrequently.org/2015/06/progressive-apps-escaping-tabs-without-losing-our-soul/
- 让我们加密:https://letsencrypt.org/
- 网络应用清单:https://developers.google.com/web/fundamentals/web-app-manifest
- 角服工:https://angular.io/guide/service-worker-getting-started
- 服务人员配置:https://angular.io/guide/service-worker-config
- 服务人员-实践指导介绍(多个示例):https://blog.angular-university.io/service-workers/
- 安格尔大学:服务人员分步指导、https://blog.angular-university.io/angular-service-worker/
- favicon.io:https://favicon.io/
- 真法维康发生器:https://realfavicongenerator.net/
- Icons8:https://icons8.com/icons/set/favicon
- 免费电视:https://www.freefavicon.com/freefavicons/icons/
- Firebase Web App 清单生成器:https://app-manifest.firebaseapp.com
- DummyImage-占位符图像生成器:https://dummyimage.com/
- 网络应用清单-W3C 工作草案 2019 年 12 月 09 日:https://www.w3.org/TR/appmanifest/
- 在 ASP.NET Core:中启用跨源请求(COR)https://docs.microsoft.com/en-us/aspnet/core/security/cors
- http 服务器:https://www.npmjs.com/package/http-server
- npx-执行 npm 包二进制文件:https://www.npmjs.com/package/npx
- ng 发球:https://angular.io/cli/serve
- 用于 ASP.NET Core 应用部署的 Visual Studio 发布配置文件(.pubxml):https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/visual-studio-publish-profiles
- Angular-服务人员注册选项:https://angular.io/api/service-worker/SwRegistrationOptions********
十二、Windows 和 Linux 部署
我们通过 ASP.NET Core 和 Angular 开发的宝贵旅程即将结束。自第 1 章、准备就绪-HealthCheck和WorldCities-以来,我们一直在开发的 web 应用现在都是潜在的可交付产品,并且大部分都准备在合适的环境中发布,以便进行评估。
*在本章中,我们将讨论以下主题:
- 准备我们的应用投入生产,我们将学习一些有用的优化策略,将我们的应用移动到生产文件夹中
- Windows 部署,我们将在这里了解如何将我们的
HealthCheckweb 应用部署到 Windows Server 2019 环境,并使用新的进程内托管模式的 IIS 在 web 上发布
** Linux 部署,我们将在 Linux CentOS 服务器上部署WorldCitiesweb 应用,并通过基于 Nginx 的代理使用 Kestrel web 服务器在 web 上发布*
*这一漫长而雄心勃勃的章节的最终目标是学习在生产 Windows 和/或 Linux 托管服务器上部署.NET Core 和 Angular 应用所需的工具和技术,因此让我们开始这最后的工作。
技术要求
在本章中,我们需要第 1 章-11中列出的所有先前的技术要求,以及以下附加包:
对于 Windows 部署:
- 互联网信息服务****IIS(Windows 服务器)
- ASP.NET Core 3.1 运行时和 Windows 托管捆绑包 Win64 安装程序(ASP.NET Core 官方网站)
对于 Linux 部署:
- ASP.NET Core 3.1 Linux 运行时(百胜软件包管理器)
- .NET Core 3.1 CLR for Linux(百胜软件包管理器)
- Nginx HTTP 服务器(百胜软件包管理器)
和往常一样,避免直接安装它们是明智的:我们将在本章的过程中引入它们,以便在我们的项目中更好地了解它们的用途。
本章代码文件可在此处找到:https://github.com/PacktPublishing/ASP.NET-Core-3-and-Angular-9-Third-Edition/tree/master/Chapter_12/ 。
准备生产
在本节中,我们将了解如何进一步完善应用的源代码,以便为生产使用做好准备。我们将主要处理服务器端和客户端缓存、环境配置等问题。在这里,我们将借此机会学习我们的前端和后端框架提供的一些有用的生产优化技巧。
更具体地说,我们将介绍以下内容:
- .NET Core 部署提示,在这里我们将了解我们的后端是如何针对生产使用进行优化的
- Angular 部署提示,这里我们将回顾 Visual Studio 模板用于优化前端制作构建阶段的一些策略
让我们开始工作吧!
.NET Core 部署提示
我们很可能已经知道,ASP.NET Core 允许开发人员跨多个环境调整应用的行为:其中最常见的是开发、登台和生产环境。通过检查可从项目配置文件中配置和修改的环境变量,在运行时识别当前活动环境。
这个变量称为ASPNETCORE_ENVIRONMENT,当我们在 Visual Studio 上运行项目时,可以使用/Properties/launchSettings.json文件来设置它,该文件控制各种设置,这些设置将在 web 应用启动时应用于本地开发机器。
launchSettings.json 文件
如果我们看一下launchSettings.json文件,我们可以看到它包含我们应用的每个执行配置文件的一些特定设置。下面是HealthCheck项目/Properties/launchSettings.json文件的内容:
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:40082",
"sslPort": 44334
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"HealthCheck": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl":
"https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
如我们所见,当前设置了两个执行配置文件:
- IIS Express 配置文件,与 IIS Express HTTP 服务器相关。每当我们在调试模式下启动项目时,都会使用此配置文件,我们可以通过按F5来执行此操作(除非我们更改了默认调试行为)。
- HealthCheck 配置文件,与应用本身相关。每当我们使用.NET Core CLI(换句话说,
dotnet run控制台命令)启动应用时,都将使用此配置文件。
对于这两个变量,ASPNETCORE_ENVIRONMENT变量当前设置为开发值,这意味着我们将始终在 Visual Studio 的开发模式下运行应用,除非我们更改这些值。
开发、暂存和生产环境
不同的环境如何影响我们的 web 应用的行为?
web 应用启动后,ASP.NET Core 立即读取ASPNETCORE_ENVIRONMENT环境变量,并将其值存储在应用的IWebHostEnvironment实例的EnvironmentName属性中,顾名思义,该实例提供有关应用运行的 web 托管环境的信息。一旦设置,该变量可以通过编程方式直接使用,也可以与一些助手方法一起使用,以确定我们的应用在后端生命周期的任何时刻的行为。
我们已经在我们的.NET Core 应用的Startup类中看到了这些方法的作用。例如,我们可以在HealthCheck``Startup.cs源代码中找到以下内容:
// ...
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this
// for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
// ...
在前面的几行中,Startup类的Configure()方法的一部分,我们告诉我们的应用有条件地使用以下内容:
- 在开发环境中运行时的开发人员异常页面
- 舞台和生产环境中的定制
ExceptionHandler中间件
这基本上意味着,每当我们的.NET Core 应用崩溃时,它都会有条件地显示以下内容:
- 给开发人员的低级/详细错误消息(如异常信息和堆栈跟踪)
- 向最终用户发送的高级/通用不可用消息
The developer exception page includes a detailed series of useful information about the exception and the request, such as exception and inner exception(s), stack trace, query string parameters, cookies, and HTTP headers.
For additional information about this, and error handling in ASP.NET Core in general, visit the following URL:
https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling
从中,我们还可以看到我们的应用在生产环境中执行时,将如何设置 30 天的HTTP 严格传输安全性(HSTS)最大年龄头值。这种行为符合一些良好的 HTTP 安全实践,因此在应用公开面向 web 时非常可取,在调试过程中通常是无用的(并且可能是一种障碍),这就是为什么没有设置它的原因。
此外,在下面几行,我们可以找到以下代码:
// ...
if (!env.IsDevelopment())
{
app.UseSpaStaticFiles(new StaticFileOptions()
{
ContentTypeProvider = provider
});
}
// ...
这是我们应用的另一个关键结构点,我们在第 2 章环顾中简要提到过,当时我们第一次看到.NET Core 的Startup类。
UseAngularCliServer()方法将把所有发往 Angular 应用的请求传递给 Angular CLI 服务器的内存实例(ng serve。对于大多数开发场景来说,这种方法无疑是可行的,因为它将确保我们的应用能够提供最新的 CLI 构建资源;但是,对于生产场景来说,它并不是那么好,在生产场景中,这些文件不会发生更改,可以使用 Angular CLI(ng build生成的静态文件提供服务,而不会浪费服务器的 CPU 和内存资源。
经验法则
既然我们已经了解了如何以编程方式确定 web 应用的执行环境并使 HTTP 管道相应地工作,那么我们应该学习如何正确地采用和调整这些有条件的实践,以最适合这些环境。
由于开发环境只对开发人员可用,因此它应该始终支持调试功能而不是性能。因此,它应该避免缓存,使用内存加载策略快速响应更改,并发出尽可能多的诊断信息(日志、异常等),以帮助开发人员及时了解发生了什么。
If we remember what we said in Chapter 9, ASP.NET Core and Angular Unit Testing, regarding Test-Driven Development (TDD), we should easily understand how the development environment is where the TDD practice mostly shines.
相反,在处理生产环境时,做出这些决策的一个好方法是应用以下经验法则:
- 尽可能打开缓存以节省资源和提高性能
- 确保所有客户端资源(JavaScript、CSS 文件等)都已绑定、缩小,并可能由内容交付网络(CDN提供服务
- 关闭诊断错误页面和/或替换为友好的、人类可读的错误页面
- 使用应用性能管理工具或其他实时监控、审核和看门狗策略启用生产日志记录和监控
- 实现框架提供的最佳安全实践,如采用开放式 Web
- 实施开放式 Web 应用安全项目(OWASP)的软件开发方法,以及网络、防火墙和服务器配置
这些是我们在改进 web 应用的后端部分以供生产使用时应始终认真考虑的一般准则(或良好实践)。
那么暂存环境呢?事实上,它主要用作预生产环境,我们可以在批准生产部署之前执行(或让一些测试人员执行)我们的前端测试。理想情况下,其物理特性应反映生产的物理特性,以便生产中可能出现的任何问题都首先出现在登台环境中,在登台环境中可以解决这些问题,而不会影响用户。
Again, if we think back to our behavior-driven development analysis back in Chapter 9,* ASP.NET Core and Angular Unit Testing*, we can definitely acknowledge that the staging environment would be the perfect place to test for the expected behavior of any newly added feature of our apps before releasing them into production.
在生产中设置环境
当我们发布用于生产部署的 web 应用时,ASPNETCORE_ENVIRONMENT变量会发生什么变化,就像我们在第 11 章、渐进式 web 应用、中为HealthCheck和WorldCities应用配置基于文件夹的发布配置文件时所做的那样?
通过查看这些文件夹,我们可以看到,launchSettings.json文件在那里找不到,因为它没有被发布。这当然是意料之中的,因为它只适用于 VisualStudio 和其他本地开发工具。
每当我们在生产服务器上托管应用时,我们都必须使用以下方法之一手动设置该值:
- 一个同名的专用环境变量
- 具体平台设置
- 一个命令行开关
这些方法强烈依赖于服务器的操作系统。在接下来的部分中,我们将了解如何在 Windows 和 Linux 服务器上执行这些操作。
It's important to remember that the environment, once set, can't be changed while the web app is running.
如果未找到与环境相关的设置,web 应用将始终使用生产值作为默认值,这是性能和安全性方面最保守的选择,因为大多数调试功能和诊断消息将被禁用。
相反,如果多次设置环境(例如通过环境变量和命令行开关),应用将使用上次读取的环境设置,从而遵循级联规则。
ASP.NET Core 部署模式
在第 11 章渐进式网络应用中,当我们创建第一个发布配置文件以将我们的应用部署到本地文件夹时,我们没有更改部署模式设置,保持原样。说实话,我们这样做是因为它不会有任何区别,因为我们使用该构建只是为了窃取一些与 PWA 相关的生成文件,并使用它们从标准 Visual Studio 调试运行中注册我们的服务人员。
然而,.NET Core 部署模式是一个非常重要的配置功能,我们必须了解它,以便在需要部署应用以供生产使用时做出正确的选择。
现在,让我们尝试了解 Visual Studio for.NET Core 应用提供的三种不同类型的部署:
- 框架依赖部署(FDD):顾名思义,这种部署模式需要存在.NET Core 框架,必须在目标系统上安装并可用;换句话说,只要宿主服务器支持.NET Core,我们就将构建一个可移植的.NET 应用。
- 自包含部署****SCD:此部署模式不依赖目标系统上是否存在.NET 组件。所有组件,包括.NET Core 库和运行时,都将包含在生产构建中。如果托管服务器支持.NET Core,则该应用将以隔离模式运行,将自身与其他.NET Core 应用分离。SCD 部署版本将包括一个可执行文件(Windows 平台上的.exe 文件)以及一个包含应用运行时的.dll 文件。
- 依赖于框架的可执行文件(FDE):此部署模式将生成一个可执行文件,该文件将在托管服务器上运行,托管服务器必须安装.NET Core 运行时。因此,这种模式与 FDD 非常相似,因为它们都依赖于框架。
现在让我们试着了解每种部署模式的优缺点。
依赖于框架的部署利弊
使用 FDD 模式为开发人员提供了许多优势,包括:
- 平台独立性:无需定义目标操作系统,因为安装在托管服务器上的.NET Core 运行时将无缝处理应用的执行,无论其平台如何。
- 小软件包大小:部署包将很小,因为它只包含应用的运行时和第三方依赖项。NET Core 本身不会出现,因为我们希望它在设计时已经出现在目标机器上。
- 最新版本:根据其默认设置,FDD 将始终使用目标系统上安装的最新服务运行时,以及所有最新的安全补丁。
- 在多主机场景下性能更好:如果主机服务器安装了多个.NET Core 应用,共享资源将使我们能够节省一些存储空间,最重要的是,减少内存使用。
然而,这种部署模式也有一些弱点,包括:
- 兼容性降低:我们的应用将需要一个.NET Core 运行时,其版本与我们的应用(或更高版本)使用的版本兼容。如果托管服务器停留在以前的版本,我们的应用将无法运行。
- 稳定性问题:如果.NET Core 运行时和/或库改变了它们的行为(换句话说,如果它们由于安全或许可原因发生了破坏性的更改或兼容性降低),我们的应用可能也会受到这些改变的影响。
自包含部署的优点和缺点
使用 SCD 模式有两大优势,这两大优势很容易超过某些特定场景的劣势:
- 完全控制已发布的.NET Core 版本,无论托管服务器上安装了什么(或将来会发生什么)
- 没有兼容性问题,因为捆绑包中提供了所有必需的库
不幸的是,还有一些相关的缺点:
- 平台依赖性:能够为.NET Core 运行时提供生产包需要开发人员提前选择目标构建平台。
- 增加的捆绑包大小:增加.NET Core 肯定会在磁盘空间需求方面付出代价。如果我们计划将多个 SCD.NET Core 应用部署到一台托管服务器上,这将是一个巨大的打击,因为每一个应用都需要大量的磁盘空间。
依赖于框架的可执行文件的优点和缺点
.NET Core 2.2 中引入了 FDE 部署模式,从 3.0 版开始,它是基本dotnet publish命令的默认模式(如果未指定选项)。这种新方法具有以下优点:
- 封装尺寸小、最新版本、多主机场景下性能更好,与 FDD 模式类似。
- 易运行:部署的可执行文件可以直接启动执行,无需调用dotnetCLI。
这种方法也有一些缺点:
- 兼容性降低:与 FDD 一样,该应用需要一个.NET Core 运行时,其版本与我们的应用(或更高版本)使用的版本兼容。
- 稳定性问题:同样,如果.NET Core 运行时和/或库更改其行为,这些更改可能会破坏应用或改变其行为。
- 平台依赖性:由于 app 是一个可执行文件,必须针对不同的目标平台发布。
正如我们很容易猜测的那样,这三种部署模式可能是好的,也可能是坏的,这取决于许多因素,例如我们对部署服务器的控制程度、我们计划发布多少.NET Core 应用以及目标系统的硬件和软件功能。
一般来说,只要我们有权在部署服务器上安装和更新系统包,FDD 模式就应该可以正常工作;相反,如果我们在没有我们想要的.NET Core 运行时的云托管提供商上托管我们的应用,SCD 可以说是最合乎逻辑的选择。可用磁盘空间和内存大小也将发挥重要作用,特别是如果我们计划发布多个应用。
也就是说,我们将使用 FDD(默认)部署模式,因为我们当前的场景需要在同一服务器上发布两个不同的应用,它们共享相同的.NET Core 版本。
Angular 展开尖端
现在,让我们将目光转向前端,以正确理解用于构建两个应用的 Visual Studio 模板如何处理 Angular 的生产部署任务。
不言而喻,我们为后端确定的相同良好实践在前端也保留了它们的价值,我们将在短时间内看到这一点。换言之,业绩和安全仍然是这方面的主要目标。
现在,让我们尝试了解 Angular CLI 是如何处理应用的发布和部署任务的,它由新的编译和渲染管道 Ivy 提供支持。
ng serve、ng build 和 package.json 文件
我们应该已经知道,每当我们在 VisualStudio 中点击F5时,Angular 应用都会使用 Angular CLI 服务器的内存实例提供服务。这样的服务器由 VisualStudio 使用ng serve命令启动。
如果我们在 web 浏览器启动之前的初始调试阶段查看 Visual Studio 输出窗口,我们可以在以下屏幕截图中清楚地看到它:

相反,当我们使用发布配置文件部署应用进行生产时,VisualStudio 使用带有--prod标志的ng build命令:

这两个命令都可以在/ClientApp/package.json文件中找到,在那里我们可以修改或配置它们以满足我们的需要,即使默认设置已经适合开发和生产部署。
Visual Studio 添加到ng build命令的--prod标志激活了许多有用的优化功能,包括以下功能:
- 提前AOT编译:将 HTML 和 TypeScript 代码转换成高效的 JavaScript 代码,以便在浏览器中提供更快的渲染;默认模式(用于
ng serve且--prod标志未启用时)称为即时(JIT编译),在运行时在浏览器中编译应用,因此是一种速度较慢、优化程度较低的替代方案 - 生产模式:通过禁用某些特定于开发的检查,例如双更改检测周期,可以加快应用的运行速度
- 捆绑:将各种应用和第三方文件(NPM 包)连接成几个捆绑包
- 缩小:这将删除 HTML、JS 和 CSS 文件中的空白、注释、可选标记以及任何不必要的字符和工件
- Uglification:这会在内部重写 JavaScript 代码,以缩短变量和函数名,使其无法读取,同时也会使恶意的反向工程尝试更加困难
- 死代码清除:删除任何未引用的模块和/或未使用的代码文件、片段或节
正如我们所看到的,前面的所有功能都旨在提高生产构建的性能和安全能力。
差动加载
另一个值得一提的特性是差异加载,它是在 Angular 8 中引入的。我们没有将它添加到前面的--prod交换机优化好处列表中,因为它默认存在,因此不限于该交换机的使用。
差异加载是 Angular 解决各种浏览器之间兼容性问题的方法,尤其是旧浏览器;换句话说,那些仍然基于旧版本的 JavaScript。
通过查看/ClientApp/tsconfig.json文件,我们可以看到,我们的 TypeScript 代码将被传输并绑定到ES2015,也称为ECMAScript 2015、ECMAScript version 6、或ES6、JavaScript 语法中,该语法与绝大多数现代浏览器兼容。但是,仍然有一些用户使用旧客户端,例如旧台式机、笔记本电脑和/或移动设备,这些用户仍然绑定到**ES5和早期版本。
为了解决这个问题,Angular 的早期版本以及大多数其他前端*框架提供了大量支持库(称为 polyfills),这些库会有条件地为那些本机不支持它们的浏览器实现缺失的功能。不幸的是,这样的解决方案极大地增加了产品包,从而导致所有用户的性能下降,包括那些使用现代浏览器的用户,这些浏览器一开始就不需要这些多边形填充。
差异加载通过在构建阶段生成两个单独的捆绑包集来解决此问题:
- 第一个包包含应用的代码,该代码已使用现代 ES2015 语法进行了传输、缩小和丑陋化。这样的束装运的多边形填充更少,因此尺寸更小。
- 第二个包包含以旧 ES5 语法传输的相同代码,以及所有必要的多边形填充。不用说,这个捆绑包的特点是捆绑包的大小要大得多,但它正确地支持较旧的浏览器。
可以通过更改两个文件来配置差异加载功能:
/ClientApp/browserlist文件,其中列出了我们的应用支持的最低浏览器/ClientApp/tsconfig.json文件,它确定代码编译到的 ECMAScript 目标版本
通过考虑这两种设置,Angular CLI 将自动确定是否启用差异加载功能。
在我们的特定场景中,这样一个功能被启用,我们可以通过查看我们应用的 production deploy 文件夹中生成的index.html文件的<body>部分看到(相关部分突出显示):
<!-- ... -->
<body>
<app-root>Loading...</app-root>
<script src="runtime-es2015.e59a6cd8f1b6ab0c3f29.js"
type="module"></script>
<script src="runtime-es5.e59a6cd8f1b6ab0c3f29.js" nomodule
defer></script>
<script src="polyfills-es5.079443d8bcab7d711023.js" nomodule
defer></script>
<script src="polyfills-es2015.58725a5910daef768ca8.js"
type="module"></script>
<script src="main-es2015.fc7dc31b264662448f17.js"
type="module"></script>
<script src="main-es5.fc7dc31b264662448f17.js" nomodule
defer></script>
</body>
<!-- ... -->
这样的策略非常有效,因为它将允许我们的 Angular 应用支持多个浏览器,而不会迫使我们的现代用户检索所有不必要的捆绑包。
angular.json 配置文件
npm serve和npm build之间最重要的区别在于后者是将生成的构建工件实际写入输出文件夹的唯一命令:这些文件是使用 webpack 构建工具构建的,可以使用/ClientApp/src/angular.json配置文件进行配置
输出文件夹也设置在该文件中,更准确地说,设置在项目|[projectName]|架构师|构建|选项|输出路径部分。在我们的示例应用中,它是dist文件夹,这意味着它们将部署在/ClientApp/dist/文件夹中。
自动部署
Angular 8.3.0 引入了新的ng deploy命令,该命令可用于将 Angular 应用部署到一个可用的生产平台上,这要感谢一些第三方建设者,他们可以使用ng add安装 Angular 应用。
以下是撰写本文时支持的构建器列表:
@angular/fire(火基)@azure/ng-deploy(微软 Azure)@zeit/ng-deploy(ZEIT Now)@netlify-builder/deploy(Netlify)angular-cli-ghpages(GitHub 页面)ngx-deploy-npm(NPM)
尽管 Visual Studio 尚不支持ng deployCLI 选项,但使用可在angular.json文件的部署部分中配置的一些预设立即部署我们的应用可能非常有用。此类节在我们项目的angular.json文件中不可用,但在使用ng addCLI 命令(及其相应的默认设置)安装之前的一个项目后,它将自动添加。
CORS 政策
我们已经在第 11 章、渐进式 Web 应用中讨论了交叉请求资源共享(CORS,当我们通过 ping 由我们托管的本地.txt文件而不是第三方 URL 来更改ng-connection-service``heartbeatUrl默认设置时。由于这一修改,我们的两个应用都不会面临 CORS 错误,因为它们只会向我们的.NET Core后端发出 HTTP 请求,该后端可能托管在同一台服务器中,因此具有相同的 IP 地址/主机名。
但是,我们可能希望将来移动后端,或者向其他远程服务(多个后端系统和/或存储)添加一些额外的 HTTP 调用。如果我们计划这样做,那么在客户端级别我们就无能为力。我们需要对目标服务器实施合适的 CORS 策略。
For additional information regarding CORS and instructions on how to enable it for specific servers, go to:
https://enable-cors.org.
幸运的是,我们不需要为即将介绍的 Windows 和 Linux 部署场景实施任何特定于 CORS 的策略。
Windows 部署
在本节中,我们将学习如何在 Microsoft Azure 上托管的 Windows 2019 数据中心版服务器上部署我们的HealthCheckweb 应用。
**下面是我们要做的:
- 使用 Windows 2019 数据中心版模板在 MS Azure上创建新 VM,并将其配置为接受到 TCP 端口 3389(用于远程桌面)和 443(用于 HTTPS)的入站呼叫
- 通过下载和/或安装所有必要的服务和运行时来配置 VM以托管
HealthCheck应用 - 将 HealthCheck 应用发布到我们刚刚设置的 web 服务器上
- 从远程客户端测试 HealthCheck 应用
让我们开始工作吧!
In this deployment example, we're going to set up a brand new VM on the MS Azure platform, which requires some additional work; those users who already have a production-ready Windows server should likely skip the paragraphs related to the VM setup and go directly to the publishing topics.
在 MS Azure 上创建 Windows Server 虚拟机
如果我们还记得我们在第 4章【实体框架核心数据模型】中通过 MS Azure 的旅程,当我们在那里部署 SQL 数据库时,我们应该已经为我们将要做的事情做好了准备:
- 访问微软 Azure 门户
- 添加并配置新 VM
- 设置入站安全规则从互联网访问云主机。
让我们这样做。
访问 MS Azure 门户
像往常一样,让我们从访问以下 URL 开始,它将把我们带到 MS Azure 网站:https://azure.microsoft.com/
同样,我们可以使用已经存在的 MS Azure 帐户登录,也可以创建一个新帐户(如果我们还没有使用免费的 30 天试用版,可能会利用这个机会)。
Refer to Chapter 4, Data Model with Entity Framework Core, for additional information on creating a free MS Azure account.
一旦我们创建了账户,我们就可以转到https://portal.azure.com/ 访问 MS Azure 管理门户,在那里我们可以创建新的虚拟机。
添加和配置新 VM
登录后,单击虚拟机图标(请参阅以下屏幕截图):

在下一页中,单击添加(位于页面左上角附近)以访问创建虚拟机面板。
“创建虚拟机”面板基本上是一个详细的向导,允许我们从头开始配置新的 VM。各种配置设置分为多个面板,每个面板专用于一组特定的功能,如以下屏幕截图所示:

以下是各种设置面板的简要摘要:
- 基础:订阅类型、VM 名称、部署区域、映像、登录凭据等
*** 磁盘:为虚拟机提供的 HDD/SDD 的数量和容量* 网络:与网络相关的配置设置* 管理:监控功能、自动关机功能、备份等* 高级:附加配置、代理、脚本、扩展等* 标记:这些允许一些名称-值对,这些名称-值对可用于对要设置的各种 MS Azure 资源进行分类**
**在我们当前的场景中,我们只需稍微修改前四个选项卡,将其余选项卡保留为默认设置。
- 在“基本”选项卡中:
- 资源组:使用与 SQL 数据库相同的资源组(或创建一个新的资源组)。
- 虚拟机名称:使用
HealthCheck(或任何其他合适的名称)。 - 地区:选择离我们地理位置最近的地区。
- 可用性选项:无需基础架构冗余。
- 图像:在我们的示例中,我们将使用 WindowsServer2019 数据中心默认图像;也可以随意使用它或选择另一个。
- Azure Spot 实例:否。
- 大小:标准 B1ms(1 个 VCPU,2 个 GiB 内存)。如果我们愿意花更多的钱,请随意选择不同的尺寸:B1ms 是一款入门级机器,具有非常有限的资源集,足以满足此部署示例,但在生产中性能不佳。
- 管理员账号:选择密码认证类型,然后创建合适的用户名和密码集。请记住在一个安全的地方写下这些,因为我们肯定需要这些凭证来访问我们的机器。
- 公共入站端口:无(目前,我们稍后将以更安全的方式进行设置)。
- 在“磁盘”选项卡中:
- 操作系统磁盘类型:选择标准硬盘;这是最便宜的选择。
- 数据盘:为操作系统创建一个新的标准 HDD(或者高级 SSD,如果我们愿意支付额外费用的话)磁盘,不需要额外的数据盘。
- 在“网络”选项卡中:
- 虚拟网络:选择用于 SQL 数据库的同一个 VNET(或创建一个新的 VNET)。
- 在“管理”选项卡中:
- 监控|引导诊断:关闭。
完成后,单击 Review+create 按钮查看配置设置并启动 VM 部署过程。
在流程结束时,我们将看到一个屏幕截图,大致如下所示:

在这里,我们可以点击G****o 到资源按钮,进入虚拟机概览面板
设置入站安全规则
进入设置|网络选项卡,记录机器的公共 IP 地址;我们很快就会需要这个。完成后,添加以下入站安全规则:
- TCP 和 UDP 端口 3389,以便我们能够使用远程桌面访问机器
- TCP 端口 443,用于从互联网访问 HTTP 服务器(以及我们的
HealthCheckweb 应用)
对于此部署测试,强烈建议将对这些入站规则的访问限制为安全的源 IP 地址(或地址范围),该地址可以设置为静态 IP 地址或 ISP 的 IP 掩码。这样的设置将确保没有第三方能够尝试远程桌面访问或访问我们的 web 应用。
以下屏幕截图描述了 Azure VM 门户上的添加入站安全规则面板,单击相应按钮时将打开该面板:

现在,我们应该能够通过开发系统的标准远程桌面连接访问我们的新 VM。
配置虚拟机
TCP 端口 3389 打开后,我们可以从本地基于 Windows 的开发机器启动远程桌面连接内置工具。键入 Azure VM 的公共 IP 地址,然后单击连接以启动与远程主机的 RDC 会话:

如果入站安全规则已正确配置,我们应该能够连接到新 VM 的桌面,并设置 VM 以服务于 ASP.NET Core 和 AngularHealthCheckweb 应用。执行此操作需要一系列配置任务,这些任务将在下一节中描述。
我们将在下一节中讨论的第一步是安装互联网信息服务(IIS),这是一个灵活、安全且可管理的 HTTP 服务器,我们将使用它在 web 上托管我们的 ASP.NET Core 和 Angular 应用。
For obvious reasons of space, we're not going to talk about IIS or explore its functionalities. For additional information regarding this, check out the following URL:
https://www.iis.net/overview.
添加 IIS web 服务器
通过远程桌面连接后,我们可以访问控制面板|程序和功能|打开和关闭 Windows 功能(或服务器管理器仪表板中的添加角色和功能向导),将 IIS 安装到 VM,如以下屏幕截图所示:

从各种可用角色中,选择 Web 服务器(IIS),如以下屏幕截图所示。确保选中“包括管理工具”复选框,然后单击“添加功能”开始安装:

在安装阶段结束之前,无需更改任何内容,默认设置对于我们的部署场景来说可以正常工作。
安装 ASP.NET Core Windows 托管捆绑包
一旦安装了 IIS,我们就可以继续下载并安装ASP.NET Core 运行时。
It's strongly advisable to install the ASP.NET Core runtime after installing IIS because the package bundle will perform some modifications to the IIS default configuration settings.
要下载 ASP.NET Core 运行时,请访问以下 URL:https://dotnet.microsoft.com/download/dotnet-core/3.1
请务必为Windows x64选择ASP.NET Core 3.1.1 Runtime–Windows 主机包安装程序包,如以下屏幕截图所示:

这样的捆绑包包括.NET Core 运行时、ASP.NET Core 运行时和 ASP.NET Core IIS 模块,以及从 VM 运行.NET Core 和 Angular 应用所需的一切。
在 ASP.NET Core 运行时安装后重新启动 IIS
ASP.NET Core 运行时安装过程完成后,强烈建议发出停止/启动命令以重新启动 IIS 服务。
为此,请打开具有管理权限的命令提示符窗口,并执行以下控制台命令:
> net stop was /y
> net start w3svc
这些命令将允许 IIS 获取 Windows 主机包安装程序对系统PATH所做的更改。
发布和部署 HealthCities 应用
现在,我们必须找到发布HealthCities应用并将其部署到服务器的方法。为此,有许多备选方案,包括:
- 使用我们现有的文件夹发布配置文件,然后以某种方式将文件复制到 web 服务器。
- 在我们的 web 服务器上安装 FTP/FTPS 服务器,然后设置FTP 发布配置文件。
- 使用 Visual Studio 的Azure 虚拟机发布配置文件。
后者可以说是最明显的选择。然而,只要我们能妥善处理,其他替代方案也很好。
文件夹发布配置文件
由于我们在第 11 章中创建了文件夹发布配置文件,渐进式 Web 应用,因此我们也将在这里使用它。
如果我们想创建一个新的,下面是我们需要做的:
- 选择文件夹选项(或选择上一个发布配置文件)。
- 指定将包含已发布应用的文件夹的路径。
- 单击“创建配置文件”按钮以创建配置文件。
- 单击发布按钮将我们的
HealthCheck应用部署到所选的本地文件夹。
Visual Studio will suggest a path located within the application's /bin/Release/ subfolder; we can either use this or choose another folder of our choice.
否则,我们只需单击现有配置文件的“发布”按钮,就可以通过这种方式完成工作。
发布任务完成后,我们可以将整个HealthCheck文件夹复制到远程 VM 的C:\inetpub\文件夹中。一个简单的方法是使用远程桌面资源共享功能,它允许从远程实例访问本地硬盘。
**# FTP 发布配置文件
如果我们的 web 服务器可以接受 FTP(或 FTPS)连接,那么发布我们的项目的合适替代方式是创建基于 FTP 的发布配置文件,该文件将使用 FTP/FTPS 协议自动将我们的 web 项目上传到我们的 web 服务器。
If we don't want to use the built-in FTP server provided by Windows Server, we can install a third-party FTP server, such as Filezilla FTP Server, a great open source alternative that comes with full FTPS support. You can find Filezilla FTP Server at the following URL:
https://filezilla-project.org/download.php?type=server
为了使用 FTP 发布配置文件,我们还需要通过添加另一个入站安全规则来打开 VM 的 TCP 端口 21(或另一个非默认端口),就像我们对端口 443 和 3389 所做的那样。
我们需要做的就是使用 IIS 将 FTP 目标文件夹链接到一个新的网站项目,我们将能够实时发布/更新我们的网站,因为发布任务完成后,所有内容都将立即联机。
As we said earlier, we're doing all this assuming that we have a web server accessible through FTP or that we're willing to install an FTP server. If that's not the case, we might as well skip this paragraph and use a different publishing profile, such as Azure Virtual Machine or Folder.
要设置 FTP 发布配置文件,请选择 IIS、FTP 和其他图标,等待类似向导的模式窗口出现,然后选择以下选项:
- 发布方式:选择 FTP。
- 服务器:指定 FTP 服务器 URL,如
ftp.our-ftp-server.com。 - 站点路径:从 FTP 服务器根目录插入目标文件夹,如
/TestMakerFree/。我们还可以避免斜杠,因为该工具将自动处理它们。 - 被动模式、用户名、密码:根据我们的 FTP 服务器设置和给定凭证设置这些值。如果要让 Visual Studio 存储密码,请激活“保存密码”,这样就不必在每次发布尝试时都写入密码。
- 目标 URL:使用默认浏览器发布任务成功结束后,将自动启动此 URL。将其设置为 web 应用的基本域(如
www.our-website-url.com)或将其留空通常是明智的。
完成后,单击 Validate Connection(验证连接)按钮检查前面的设置,并确保我们能够通过 FTP 访问服务器。如果我们没有,那么最好执行全面的网络检查,查找防火墙、代理、防病毒软件或其他可以阻止建立 FTP 连接的软件。
Azure 虚拟机发布配置文件
Azure 虚拟机发布配置文件是实施连续集成和连续交付(CI/CD)DevOps 模式的一种很好的方式,因为它要么充当构建系统(用于生成包和其他构建工件)或者发布管理系统来部署我们的更改
要使用它,请选择Azure 虚拟机选项,单击浏览,然后选择我们刚才创建的 VM(请参见以下屏幕截图):

但是,为了做到这一点,我们需要对 VM 执行一些额外的配置更改,包括以下内容:
- 安装 WebDeploy 服务工具(就像我们在早期对 IIS 所做的那样)
- 打开 80 和 8172 个 TCP 端口(就像我们刚才对 443 和 3389 所做的那样)
- 为虚拟机设置全局唯一的 DNS 名称:这可以从我们虚拟机的 MS Azure portal 概览页面(DNS 名称属性)完成
由于空间的原因,我们将不进行这些设置。但是,有关上述任务的更多信息,请查看以下指南:https://github.com/aspnet/Tooling/blob/AspNetVMs/docs/create-asp-net-vm-with-webdeploy.md
完成这些设置后,我们将能够以无缝和透明的方式将 web 应用发布到 VM。
配置 IIS
既然我们的 web 应用的文件已经复制到服务器,我们需要配置 IIS 来发布它
在本节中,我们将配置 IIS 服务,使其使用进程内托管模型为我们的HealthCheckweb 应用提供服务,该模型自 ASP.NET Core 2.2 以来就已提供,与以前的进程外模型相比有了显著改进。
为了快速总结两种托管模型之间的差异,我们可以说:
- 在进程外托管模型中,IIS 将 HTTP 请求代理给 ASP.NET Core Kestrel 服务器,该服务器使用不同的 TCP 端口(仅供内部使用)直接为应用服务:换句话说,IIS 充当反向代理。
- 在进程中托管模型中,ASP.NET Core 应用托管在 IIS 应用池中;因此,所有 HTTP 请求都由 IIS 直接处理,无需代理到运行.NET Core 本机 Kestrel web 服务器的外部
dotnet.exe实例。
事实上,进程内模型根本不使用 Kestrel,而是使用直接托管在 IIS 应用池中的新 Web 服务器实现(IISHttpServer来替代它。因此,我们可以说,这个新模型在某种程度上类似于我们从 ASP.NET 版本 1.x(然后是 2.x,直到 4.x)开始使用的经典 ASP.NET 托管模型。
进程内模型是 ASP.NET Core 3.1 项目的默认方法,在为 ASP.NET Core web 应用提供服务时,它通常是一种更好的方法,为我们提供以下好处:
- 它提供了更好的性能,因为它不使用 Kestrel,而是依赖于直接与 IIS 请求管道接口的自定义
IISHttpServer实现 - 它的资源密集度较低,因为它避免了 IIS 和 Kestrel 之间额外的网络跳跃
在下一节中,我们将看到如何正确配置它。
添加 SSL 证书
由于我们的应用将使用 HTTPS 提供服务,因此我们有两种选择:
- 从第三方经销商处购买并安装 SSL 证书。
- 创建一个自签名的。
对于实际的生产场景,我们绝对应该遵循前一条路径:然而,在我们的部署示例中,我们将采用自签名路径,它提供了一种更快(且无成本)的替代方案来实现我们的目标。请遵循以下步骤:
- 打开 Internet Information Services(IIS)Manager 桌面应用,从左侧树状视图中选择
HealthCheck节点,然后单击服务器证书图标,如下图所示:

- 进入“服务器证书”面板后,单击右侧“操作”列中的“创建自签名证书”链接。
- 将出现一个模式窗口(请参见下面的屏幕截图),要求我们为证书指定一个友好的名称。键入
healthcheck.io,选择个人证书存储,然后单击“确定”创建自签名证书:

完成后,我们最终可以将HealthCheck网站条目添加到 IIS 中。
添加新的 IIS 网站条目
从 Internet 信息服务(IIS)管理器主页面,右键单击HealthCheck根节点并选择添加网站选项以创建新网站。
填写添加网站模式窗口,如以下屏幕截图所示:

以下是最相关设置的摘要:
- 站点名称:
healthcheck.io - 物理路径:
C:\inetpub\HealthCheck(复制开发机器本地部署文件夹的路径) - 绑定类型:https
- IP 地址:全部未分配
- 端口:
443 - 主机名:
healthcheck.io - 需要服务器名称指示:是
- 禁用 HTTP/2:否
- 禁用 OCSP 装订:否
- SSL 证书:
healthcheck.io(我们刚才创建的自签名证书) - 立即启动网站:是
完成后,单击“确定”添加新网站:HealthCheck/Sites文件夹右侧的树状视图中将显示一个新的healthcheck.io条目。
配置 IIS 应用池
您可能已经知道,IIS 服务在一个或多个应用池下运行各种配置的网站。每个配置的应用池将产生一个专用的w3wp.exeWindows 进程,该进程将用于为所有配置为使用它的网站提供服务。
根据我们需要托管的各种网站的发布要求,我们可以在几个应用池(甚至单个应用池)中运行所有网站,或者每个应用池都有自己的应用池。不用说,共享同一应用池的所有网站也将共享其各种设置,例如内存使用、管道模式、标识和空闲超时。
在我们的特定场景中,当我们在上一节中创建healthcheck.io网站时,我们选择创建一个具有相同名称的专用应用池,这是 IIS 的默认行为。因此,为了配置网站的应用池设置,我们需要在左侧树状视图中点击Application Pools文件夹,然后在Application Pools列表面板中双击healthcheck.io条目,如下图所示:

在编辑应用池模式窗口中,选择以下设置,如前一屏幕截图所示:
- .NET CLR 版本:无托管代码
- 托管管道模式:集成
我们可能想知道为什么选择不使用托管代码,因为我们显然使用的是 ASP.NET Core CLR。答案很简单:由于 ASP.NET Core 在单独的进程和 IIS 中运行,因此无需在 IIS 上设置任何.NET CLR 版本。
For additional information regarding the ASP.NET Core hosting model on IIS, including the various differences between the in-process and out-of-process hosting models, check out the following URL:
https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/
测试 HealthCheck web 应用
我们的 web 应用现在应该准备好接收来自远程机器的 HTTP 请求。
但是,由于我们已经将其配置为接受发往healthcheck.io主机名而不是数字 IP 的 HTTP 请求,因此我们需要找到一种方法,将该主机名映射到我们将在其中执行测试的基于 Windows 的开发机器上远程 VM 的 IP 地址。
在任何 Windows 系统上实现此结果的最简单和最有效的方法是编辑Windows\System32\drivers\etc\hosts文件,操作系统使用该文件在通过 DNS 查找解析主机名之前(而不是)将主机名映射到 IP 地址。
**For additional information about the Windows HOSTS file, read the following URL:
https://en.wikipedia.org/wiki/Hosts_(file)
更新测试机的主机文件
要更改 Windows 主机文件,我们需要使用文本编辑器打开以下文件,如notepad.exe:C:\Windows\System32\drivers\etc\hosts
然后,我们需要添加以下条目:
VM.IP.ADDRESS healthcheck.io
In order to edit the Windows HOST file, we'll need to acquire administrative privileges for it; otherwise, we won't be able to permanently change it on disk.
将前面的VM.IP.ADDRESS占位符替换为虚拟机的外部 IP 文件,以便 Windows 将healthcheck.io主机名映射到它,忽略默认 DNS 解析:换句话说,前面的一行将向我们的虚拟机发送所有寻址healthcheck.io主机名的 HTTP 请求,即使我们不拥有该域名。事实上,这是一种使用真实主机名(而不仅仅是 IP 地址)测试我们的应用的简单而有效的方法,而无需实际购买域。
使用 Google Chrome 测试应用
现在,我们终于可以启动我们最喜欢的 web 浏览器并调用以下 URL:https://healthcheck.io
For the sake of simplicity, we're going to use Google Chrome so that we'll be able to immediately check out the Web App Manifest file and the service worker, just like we did with the "local" publishing test that we performed in Chapter 11,* Progressive Web Apps*.
如果我们做的一切都正确,我们应该能够看到 HealthCheck web 应用的辉煌:

除了看到主视图,我们还应该能够看到以下内容:
- Google Chrome 开发控制台的应用清单面板中的应用清单文件(包含所有的HC图标)
- 服务人员在 Google Chrome 开发控制台的应用|服务人员面板中正确注册
- 发送此页面和在浏览器地址栏最右侧安装图标
In order to see those panels, remember to press Shift + Ctrl + J to bring the Google Chrome Development console into view.
从那里,我们现在可以安装应用并检查/取消检查其脱机状态,以测试服务人员的行为,就像我们在第 11 章、渐进式 Web 应用中所做的那样,当我们从标准 Visual Studio 调试运行中测试我们发布的应用时:如果我们一切正常,一切都应该以同样的方式工作和行为。
至此,我们完成了 Windows 部署之旅;我们的HealthCheckweb 应用已经实现了它的最终目标。
在下一节中,我们将学习如何将WorldCitiesweb 应用部署到完全不同的 Linux 机器上。
Linux 部署
在本节中,我们将学习如何在 MS Azure 上托管的 CentOS 7.7 Linux 服务器上部署WorldCitiesweb 应用。
*更准确地说,我们要做的是:
- 使用基于 CentOS 的 7.7 模板在 MS Azure上创建新 VM
- 将 VM 配置为接受到 TCP 端口 3389(用于远程桌面)和 443(用于 HTTPS)的入站呼叫
- 将 WorldCities 应用改编为 Nginx+Kestrel edge origin 托管模式
- **将世界城市应用*发布到我们刚刚设置的 Web 服务器上
** 从远程客户端测试 WorldCities 应用
*让我们开始工作吧!
It's worth noting that the CentOS 7.7 template that we're going to use in this deployment sample can be easily replaced—with minor variations—with any other Linux VM template available on MS Azure.
Needless to say, those who already have a production-ready Linux server could probably skip the paragraphs related to the VM setup and go directly to the following publishing topics.
在 MS Azure 上创建 Linux CentOS 虚拟机
同样,我们需要执行以下步骤:
- 访问微软 Azure 门户
- 添加并配置新 VM
- 设置入站安全规则从互联网访问云主机
但是,由于我们在本章的前面已经解释了 Microsoft Azure VM 在 Windows 服务器上的创建过程,因此我们将简要总结所有常见任务,并避免重新提交相同的屏幕截图。
Those who require additional explanations regarding the various required steps can check out the Creating a Windows Server VM on MS Azure section.
让我们再次回到微软 Azure!
添加和配置 CentOS 7.7 虚拟机
再一次,我们需要使用我们的(现有或新)帐户登录到 MS Azure,并访问 MS Azure 门户管理仪表板。
然后点击虚拟机图标,点击添加进入创建虚拟机面板,进入以下设置。
- 在“基本”选项卡中:
- 资源组:使用与 SQL 数据库相同的资源组(这是必需的,除非我们的数据库不在那里)。
- 虚拟机名称:使用 WorldCities(或任何其他合适的名称)。
- 地区:选择离我们地理位置最近的地区。
- 可用性选项:无需基础架构冗余。
- 图像:在我们的示例中,我们将使用基于 CentOS 的 7.7 默认图像;或者,我们可以选择任何其他基于 Linux 的 VM 模板,只要我们愿意并且能够根据不同 Linux 发行版之间的差异(可以说是微小的差异)调整以下说明。
- Azure Spot 实例:否。
- 大小:标准 B1ms(1 个 VCPU,2 个 GiB 内存)。如果我们愿意花更多的钱,请随意选择不同的尺寸:B1ms是一款入门级机器,具有非常有限的资源集,足以满足此部署示例,但在生产中性能不佳。
- 管理员帐户:选择密码身份验证类型,然后创建合适的用户名和密码集。请记住在一个安全的地方写下这些,因为我们肯定需要这些凭证来访问我们的机器。
- 公共入站端口:无(目前,我们稍后将以更安全的方式设置它们)。
- 在“磁盘”选项卡中:
- 操作系统磁盘类型:选择标准硬盘;这是最便宜的选择
- 数据盘:为操作系统创建一个新的标准 HDD(或者高级 SSD,如果我们愿意支付额外费用的话)磁盘,不需要额外的数据盘
- 在“网络”选项卡中:
- 虚拟网络:选择用于 SQL 数据库的同一个 VNET(或创建一个新的 VNET)
- 在“管理”选项卡中:
- 监控|启动诊断:关闭
完成后,单击 Review+create 按钮查看配置设置并启动 VM 部署过程。
部署完成后,我们可以单击转到资源按钮访问虚拟机概览面板
设置入站安全规则
转到设置|网络选项卡,记录机器的公共 IP 地址。然后,添加以下入站安全规则:
- TCP 端口 22,这样我们就可以使用 Secure Shell 协议(也称为SSH访问机器)
- TCP 端口 443,用于从互联网访问 HTTP 服务器(以及我们的
WorldCitiesweb 应用)
同样,请确保将对这些入站规则的访问限制为安全的源 IP 地址(或地址范围),该地址可以设置为静态 IP 地址或 ISP 的 IP 掩码。
配置 Linux 虚拟机
现在,我们可以使用 SSH 协议访问新的 Linux VM 并执行两组不同(但都是必需的)任务:
- 通过安装各种必需的包(ASP.NET Core 运行时、Nginx HTTP 服务器等)来设置和配置 VM
- 发布我们在第 11 章、渐进式网络应用中设置的发布配置文件生成的 WorldCities 文件夹(及其全部内容)
在前一组任务中,我们将使用Putty,这是一个用于 Windows 的免费 SSH 客户端,可用于远程访问 Linux 机器的控制台。后者将使用 Secure Copy(也称为SCP)进行处理,这是一种 Windows 命令行工具,允许将文件从(本地)Windows 系统复制到远程 Linux 机器。
Putty can be downloaded and installed from the following URL:
The SCP command-line tool is already shipped with most Windows versions, including Windows 10; for additional information on it, visit the following URL:
https://docs.microsoft.com/en-us/azure/virtual-machines/linux/copy-files-to-linux-vm-using-scp
连接到虚拟机
- 安装完成后,启动Putty并插入 VM 公共 IP 地址,如下图所示:

- 完成后,单击“打开”启动远程连接。
我们将被要求接受公共 SSH 密钥。一旦接受,我们将能够使用不久前在 MS Azure Portal 的虚拟机安装向导中指定的用户名和密码对自己进行身份验证:

一旦连接,我们将能够在远程 VM 上发出终端命令,根据我们的需要进行设置和配置。
安装 ASP.NET 运行时
成功登录到 Linux VM 终端后,我们可以开始配置远程系统,使其能够运行(并托管)ASP.NET Core 应用。要实现这一点,首先要下载并安装 ASP.NET Core 运行时
但是,在此之前,我们需要执行以下必要步骤:
- 注册 Microsoft 密钥
- 注册产品存储库
- 安装所需的依赖项
这些步骤需要在每台 Linux 机器上执行一次。幸运的是,所有这些步骤都可以通过以下命令完成:
$ sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/
packages-microsoft-prod.rpm
完成此操作后,我们将能够按以下方式安装 ASP.NET Core 3.1 运行时:
$ sudo yum install aspnetcore-runtime-3.1
Alternatively, if we don't want to install the .NET Core runtime on the Linux server, we could publish the app as a Self-Contained Deployment (SCD), as explained in the first section of this chapter.
安装 Nginx
下一步涉及安装 Nginx 服务器包。同样,在执行此操作之前,我们需要添加 CentOS 7 EPEL 存储库,这是 yum 查找要安装的 Nginx 包所必需的:
$ sudo yum install epel-release
完成后,我们可以通过以下方式反向代理 Kestrel 服务来安装 Nginx HTTP 服务器,我们将使用该服务器为我们的 web 应用提供服务:
$ sudo yum install nginx
For additional information about installing an ASP.NET Core web application on Linux with Nginx, check out the following URLs:
https://docs.microsoft.com/en-us/dotnet/core/install/linux-package-manager-centos7
https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/linux-nginx
启动 Nginx
当我们在 Windows 上安装 IIS 时,服务将自动启动,默认情况下将配置为自动启动类型。相反,Nginx 不会自行启动,也不会在启动时自动执行。
要启动 Nginx,请执行以下命令:
$ sudo systemctl start nginx
要将 Nginx 设置为在系统启动时自动运行,请使用以下命令:
$ sudo systemctl enable nginx
应用这些设置后,明智的做法是重新启动 Linux 计算机,以确保在重新启动时应用所有配置的设置。
检查 HTTP 连接
我们在此部署场景中使用的基于 CentOS7 的 MS Azure VM 模板没有本地防火墙规则阻止 TCP 端口 443。因此,一旦 Nginx 启动并运行,我们应该能够通过在开发机器的浏览器地址栏中键入 VM 的公共 IP 地址来正确连接到它。
在这里,我们不必直接使用数字 IP 地址,而是可以借此机会向 WindowsC:\Windows\System32\drivers\etc\hosts文件添加另一个映射:
VM.IP.ADDRESS worldcities.io
在这里,我们将前面的VM.IP.ADDRESS占位符替换为 VM 的外部 IP 文件,以便 Windows 将worldcities.io主机名映射到它(如 Windows 部署部分所述)。
完成后,我们应该能够使用该主机名地址连接到 VM 的 Nginx HTTP 服务器,如以下屏幕截图所示:

正如我们从前面的屏幕截图中看到的,我们可以跳过下面的一段,继续下一段。相反,如果无法建立连接,我们可能需要执行一些额外的步骤来打开 VM 的 TCP 443 端口。
Before altering the VM firewall rules, it might be wise to carefully check for the TCP 443 inbound security rule that we should have set on the MS Azure portal administration site, as explained in the Setting the inbound security rules section.
打开 443 TCP 端口
根据选择的 Linux 模板,可能需要更改本地防火墙设置以允许 443 TCP 端口的传入流量。执行此操作所需的命令可能会有所不同,具体取决于 Linux 发行版附带的内置防火墙抽象层。
在 Linux 中,基于内核的防火墙由iptables控制;然而,大多数现代发行版通常使用firewalld(CentOS、RHEL)或ufw(Ubuntu)抽象层来配置iptables设置。
**In a nutshell, both firewalld and ufw are firewall-management tools that can be used by the system administrators to configure the firewall features using a managed approach. We can think of them as front-ends for the Linux kernel's networking internals.
在 VM Azure 的基于 CentOS7 的 Linux 模板中,firewalld存在,但它通常被禁用(尽管它可以启动和/或启用,以便在每次启动时自动运行);但是,如果我们使用不同的模板/VM/Linux 发行版,花几分钟时间学习如何正确配置这些工具可能会很有用。
防火墙
以下是检查是否安装了 firewalld 的命令:
$ sudo firewall-cmd --state
如果命令返回除未运行之外的其他内容,则表示该工具已安装并处于活动状态。因此,我们需要执行以下firewalld命令来打开 TCP 端口 443:
$ sudo firewall-cmd --permanent --add-port=443/tcp
$ sudo firewall-cmd --reload
需要使用--reload命令立即应用firewalld设置,而无需重新启动。
ufw
下面是检查ufw是否正在运行的命令:
$ sudo ufw status
如果前面的命令返回的不是未找到的命令,这意味着该工具已安装并正在运行。
以下是打开 TCP 端口 443 所需的ufw终端命令:
$ sudo ufw allow 443/tcp
在执行这些命令之后,我们应该能够从开发人员机器连接 Nginx HTTP 服务器。
适应世界城市应用
在将WorldCities应用发布到 Linux 虚拟机之前,我们需要确保我们的 web 应用已正确配置为通过反向代理提供服务。
为此,我们需要使用来自Microsoft.AspNetCore.HttpOverrides包的转发头中间件。
When HTTPS requests are proxied over HTTP using an edge-origin technique, such as the one we're pulling off with Kestrel and Nginx, the originating client IP address, as well as the original scheme (HTTPS) are lost between the two actors. Therefore, we must find a way to forward this information. If we don't do this, we could run into various issues while performing routing redirects, authentication, IP-based restrictions or grants, and so on.
The most convenient way to forward this data is to use the HTTP headers: more specifically, using the X-Forwarded-For (client IP), X-Forwarded-Proto (originating scheme), and X-Forwarded-Host (host header field value). The built-in Forwarded Headers Middleware provided by ASP.NET Core performs this task by reading these headers and filling in the corresponding fields on the web application's HttpContext.
For additional information regarding forwarded headers middleware and its most common usage scenarios, check out the following URL:
https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer
在这里,我们还需要正确地检查我们在第 4 章、实体框架核心数据模型中设置的 SQL 数据库的连接字符串,以确保 Linux 虚拟机仍然可以访问它(或相应地更改它)。
添加转发头中间件
要添加转发头中间件,请打开 WorldCitiesStartup.cs文件,并在Configure()方法中添加以下突出显示的行:
using Microsoft.AspNetCore.HttpOverrides;
// ...
app.UseRouting();
// Invoke the UseForwardedHeaders middleware and configure it
// to forward the X-Forwarded-For and X-Forwarded-Proto headers.
// NOTE: This must be put BEFORE calling UseAuthentication
// and other authentication scheme middlewares.
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
});
app.UseAuthentication();
app.UseIdentityServer();
app.UseAuthorization();
// ...
如我们所见,我们告诉中间件转发X-Forwarded-For和X-Forwarded-Proto头,从而确保重定向 URI 和其他安全策略正常工作。
IMPORTANT: As written in the comments, this middleware must be put before calling UseAuthentication or other authentication scheme middlewares.
现在,我们可以进入下一步。
正在检查数据库连接字符串
从解决方案资源管理器中,打开appsettings.json文件,查看我们在第 4 章、数据模型和实体框架核心中设置的连接字符串,从那时起,该连接字符串可以说在我们的开发机上工作得非常完美。我们需要确保这样的连接字符串也能在我们的 Linux 虚拟机上工作。
如果 SQL 数据库托管在 MS Azure 或可公开访问的服务器上,我们就不必做任何事情;但是,如果我们使用了安装在开发机器上的本地 SQL 数据库实例,则需要选择以下可用的解决方法之一:
- 将
WorldCitiesSQL 数据库移动和/或复制到 MS Azure。 - 创建本地 SQL Server Express(或开发)实例后,立即在 CentOS VM 上安装该实例。
- 为我们在第 4 章、数据模型和实体框架核心中设置的自定义本地(或远程)SQL Server Express(或开发)实例配置入站规则,可能仅限制外部访问新 VM 的公共 IP 地址。
For workaround #1, right-click the local SQL Database instance and select Tasks > Deploy Database to MS Azure SQL Database; check out* Chapter 4, Data Model with Entity Framework Core*, for additional details.
For workaround #2, take a look at the following SQL Server Linux installation guide:
https://docs.microsoft.com/en-us/sql/linux/sql-server-linux-setup
For workaround #3, check out the following URL:
连接字符串不是我们需要在appsettings.json文件中检查和/或更新的唯一值。我们还需要正确配置IdentityServer的key设置,替换我们在第 10 章、认证和授权中设置的开发值:
"IdentityServer": {
"Key": {
"Type": "Development"
}
}
现在我们将在生产中部署我们的 web 应用,我们可以移动appsettings.Development.json文件中前面的整个IdentityServer部分,并用以下方式替换相应的appsettings.json块:
"IdentityServer": {
"Clients": {
"WorldCities": {
"Profile": "IdentityServerSPA"
}
},
"Key": {
"Type": "File",
"FilePath": "/var/ssl/worldcities.pfx",
"Password": "MyVerySecretCAPassword$"
}
}
这将确保IdentityServer在生产过程中检查真实的 SSL 证书。这样的证书还不存在,但我们稍后将从 LinuxVM 命令行生成它。
发布和部署 WorldCities 应用
现在,我们可以发布WorldCities应用并将其部署到 Linux 虚拟机服务器。这可以通过多种方式实现,包括:
- 使用我们现有的文件夹发布配置文件,然后使用 SCP 命令行工具将文件复制到 Web 服务器
- 使用我们现有的文件夹发布配置文件,然后使用基于 GUI 的 SFTP Windows 客户端将文件复制到 Web 服务器,例如:
- WinSCP:针对 Windows 的免费 SFTP、SCP、S3 和 FTP 客户端:https://winscp.net/
- FileZilla FTP 客户端:另一个免费开源 FTP 客户端,支持 TLS 上的 FTP(FTPS)和 SFTP:https://filezilla-project.org/
- 在我们的 web 服务器上安装 FTP/FTPS 服务器,然后设置FTP 发布配置文件
- 使用 Visual Studio 的Azure 虚拟机发布配置文件
在这个部署场景中,我们将使用第一个选项,这可以说是最容易实现的选项。
至于可选的 FTP/FTPS 和 Azure 发布选项,在上一章有关 Windows 部署的部分中已简要介绍了这些选项。
创建/var/www 文件夹
我们需要做的第一件事是创建一个合适的文件夹,将应用发布的文件存储在 Linux 虚拟机上。对于这个部署场景,我们将使用/var/www/<AppName>文件夹,从而遵循典型的 Linux 约定
由于 Azure CentOS7 模板没有现有的/var/www文件夹,因此我们也需要创建该文件夹。要执行此操作,请从 Linux VM 控制台执行以下命令:
$ sudo mkdir /var/www
这个/var/www/文件夹将与 WindowsC:\inetpub\文件夹相同,它是包含我们的 web 应用文件的目录。
紧接着,我们可以通过以下命令在那里创建一个新的/var/www/WorldCities子文件夹:
$ sudo mkdir /var/www/WorldCities
添加权限
现在,我们需要向 Nginx 默认用户的/var/www/WorldCities文件夹添加读写权限。
为此,请使用以下命令:
$ sudo chown -R nginx:nginx /var/www
$ sudo chmod -R 550 /var/www
这将使Nginx用户及其对应的Nginx组能够以读取和执行模式访问它,同时阻止任何其他用户/组的访问。
In this deployment scenario, we're taking for granted the fact that the Nginx instance is running with its default Nginx user and Nginx group. In other Linux environments, the username and/or group might vary—for example, in most Linux distributions, the N**ginx group is called www or www-data).
To determine which user Nginx is running in, use the following command:
$ ps -eo pid,comm,euser,supgrp | grep nginx
To list all available Linux users and/or groups, use the following commands:
$ getent passwd
$ getent group
在继续之前,还有一件事要做。由于我们将使用在 MS Azure 中设置的用户帐户发布我们的应用,因此我们还需要将其添加到 Nginx 组中;否则,它将无法在该文件夹上写入。
要执行此操作,请按以下方式使用usermodLinux 命令:
$ sudo usermod -a -G nginx <USERNAME>
将前面的<USERNAME>占位符替换为我们之前在 VM Azure 上设置的用户名(与我们登录 VM 终端时设置的用户名相同)。
复制 WorldCities 发布文件夹
在 Linux 虚拟机上正确设置/var/www/WorldCities文件夹后,我们可以打开命令提示符(具有管理权限)到本地开发机器,并发出以下 SCP 命令,将本地C:\Temp\WorldCities文件夹内容复制到那里:
> scp -r C:\Temp\WorldCities <USERNAME>@<VM.IP.ADDRESS>:/var/www
Remember to replace the <USERNAME> and <VM.IP.ADDRESS> placeholders with the actual values.
然后,SCP 命令将询问我们是否要连接到远程文件夹,如以下屏幕截图所示:

键入yes以授权连接,然后重复该命令将源文件夹复制到其目标。SCP 命令将开始将所有文件从本地开发计算机复制到 VM 文件夹,如以下屏幕截图所示:

既然我们的WorldCities应用文件已经复制到 Linux 虚拟机,我们只需要配置 Kestrel 服务,然后配置 Nginx 反向代理即可。
配置 Kestrel 和 Nginx
在启动之前,明智的做法是快速解释 Kestrel 服务和 Nginx HTTP 服务器将如何相互交互。
高级体系结构与自 ASP.NET Core 2.2 以来使用的 Windows 进程外托管模型非常相似:
- Kestrel 服务将在 TCP 端口 5000(或任何其他 TCP 端口;5000 只是默认端口)上为我们的 web 应用提供服务。
- Nginx HTTP 服务器将充当反向代理,将所有传入请求转发到 Kestrel web 服务器。
此模式称为边缘原点代理,可通过下图简要概括:

现在,我们已经了解了总体情况,让我们尽最大努力实现它。
创建自签名 SSL 证书
由于我们的应用将使用 HTTPS 提供服务,因此我们需要从第三方经销商处购买并安装 SSL 证书,或者创建一个自签名证书。对于这个部署场景,我们将坚持使用自签名方法,就像使用 Windows 一样。
在 Linux 中,我们可以使用OpenSSL命令行工具创建自签名证书
为此,请从 Linux VM 终端执行以下步骤:
- 使用
sudo mkdir /var/ssl创建/var/ssl文件夹。 - 使用以下命令创建自签名 SSL 证书(
worldcities.crt和私钥文件(worldcities.key):
$ sudo openssl req -x509 -newkey rsa:4096 -sha256 -nodes -keyout /var/ssl/worldcities.key -out /var/ssl/worldcities.crt -subj "/CN=worldcities.io" -days 3650
- 完成后,将证书和私钥合并到一个
worldcities.pfx文件中:
$ openssl pkcs12 -export -out /var/ssl/worldcities.pfx -inkey /var/ssl/worldcities.key -in worldcities.crt
- 当要求输入 PFX 文件密码时,请插入我们在
appSettings.json文件中早期指定的相同密码。这将确保IdentityServer能够找到并使用预期的密钥。
之后,设置新的文件和文件夹权限,使其可以从 Nginx 和应用访问:
$ sudo chown -R nginx:nginx /var/ssl
$ sudo chmod -R 550 /var/ssl
最后但并非最不重要的一点是,我们需要更改/var/ssl文件夹(及其所有包含文件)的安全上下文,以便 Nginx 能够访问它:
$ sudo chcon -R -v --type=httpd_sys_content_t /var/ssl
如果我们不执行前面的命令,安全增强型 Linux(SELinux)将阻止 HTTPD 守护进程访问/var/ssl文件夹,从而在 Nginx 启动阶段导致不必要的“权限被拒绝”错误。不用说,如果我们的 Linux 系统没有运行 SELinux,或者我们已经永久禁用了它,那么可以跳过前面的命令。但是,由于它在基于 MS Azure CentOS7 的 VM 模板中处于活动状态,我们可能需要执行它。
**SELinux **is an access control (MAC) security mechanism implemented in the CentOS 4 kernel. It is quite similar to the Windows UAC mechanism and has strong default values that can be relaxed in case of specific requirements.
To temporarily disable it, run the sudo setenforce 0 terminal command. Doing this can be useful when we run into permission issues to determine whether the problem may be related to SELinux.
For additional information regarding SELinux and its default security settings, check out the following URLs:
https://wiki.centos.org/HowTos/SELinux https://wiki.centos.org/TipsAndTricks/SelinuxBooleans
现在我们有了一个有效的自签名 SSL 证书,可以由 IdentityServer 或 Nginx 使用。
For additional information regarding the OpenSSL tool, check out the following URL:
https://www.openssl.org/docs/manmaster/man1/openssl.html
配置 Kestrel 服务
让我们首先在/etc/systemd/system/文件夹中创建服务定义文件。
为此,我们将使用nano,这是一个 Linux 开源文本编辑器,可以从命令行界面使用(类似于vim,但更易于使用)。让我们完成以下步骤:
- 执行以下命令创建一个新的
/etc/systemd/system/kestrel-worldcities.service文件:
$ sudo nano /etc/systemd/system/kestrel-worldcities.service
- 完成后,用以下内容填充新创建的文件:
[Unit]
Description=WorldCities
[Service]
WorkingDirectory=/var/www/thepaac.com
ExecStart=/usr/bin/dotnet /var/www/WorldCities/WorldCities.dll
Restart=always# Restart service after 10 seconds if the dotnet service crashes:
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=WorldCities
User=nginx
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
Environment=ASPNETCORE_URLS=http://localhost:5000
# How many seconds to wait for the app to shut down after it receives the initial interrupt signal.
# If the app doesn't shut down in this period, SIGKILL is issued to terminate the app.
# The default timeout for most distributions is 90 seconds.
TimeoutStopSec=90
[Install]
WantedBy=multi-user.target
- 完成后,按Ctrl+X退出,然后按Y将文件保存到磁盘上。
The kestrel-worldcities.service file is available in the /_LinuxVM_ConfigFiles/ folder of this book's GitHub repository.
如我们所见,Kestrel 将使用此文件的内容来配置我们应用的生产值,例如前面提到的ASPNETCORE_ENVIRONMENT变量,以及用于内部服务应用的 TCP 端口。
The preceding settings are OK for our current deployment scenario; however, they should be changed to comply with different usernames, folder names, TCP ports used, the web app's main DLL name, and so on. When hosting a different web application, be sure to update them accordingly.
- 现在我们已经配置了服务,只需启动它,可以使用以下命令完成:
$ sudo systemctl start kestrel-worldcities.service
- 如果我们还希望在每次 VM 重新启动时自动运行服务,请添加以下命令:
$ sudo systemctl enable kestrel-worldcities.service
- 在此之后,最好立即运行以下命令,以检查服务是否运行正常:
$ sudo systemctl status kestrel-worldcities.service
如果我们看到一条绿色的active (running)消息,比如下面屏幕截图中的消息,这很可能意味着我们的 Kestrel web 服务已经启动并运行。现在,我们只需要设置 Nginx 来反向代理它,就完成了:

如果 status 命令显示某些内容已关闭(红线或通知),我们可以通过使用以下命令查看详细的 ASP.NET 应用错误日志来解决此问题:
$ sudo journalctl -u kestrel.worldcities
-u参数只返回 kestrel worldcities 服务的消息,过滤掉所有其他信息。
由于journalctl日志很容易变得很长,即使使用前面的过滤器,也建议使用--since参数以以下方式限制其时间范围:
$ sudo journalctl -u kestrel-worldcities --since "yyyy-MM-dd HH:mm:ss"
确保用合适的日期时间值替换yyyy-MM-dd HH:mm:ss占位符。
最后但并非最不重要的一点是,我们可以使用-xe开关输出到上次记录的错误:
$ sudo journalctl -xe
这些命令对于有效地排除 Linux 上的大多数错误场景应该非常有用。
For additional information regarding the journalctl tool, check out the following URL:
https://www.freedesktop.org/software/systemd/man/journalctl.html
为什么我们不直接为 Kestrel 的 web 应用提供服务?
我们可能只想在 TCP 端口 443(而不是 TCP 5000)上配置 Kestrel web 服务,现在就完成这项工作,而不必处理 Nginx 和跳过整个反向代理部分。
尽管有 100%的可能性,但我们强烈建议不要这样做,原因与 Microsoft 在此陈述的相同:
Kestrel is great for serving dynamic content from ASP.NET Core. However, the web serving capabilities aren't as feature-rich as servers such as IIS, Apache, or Nginx. A reverse proxy server can offload work such as serving static content, caching requests, compressing requests, and SSL termination from the HTTP server. A reverse proxy server may reside on a dedicated machine or may be deployed alongside an HTTP server.
[Source: https://docs.microsoft.com/it-it/aspnet/core/host-and-deploy/linux-nginx]
简而言之,红隼并不打算在前线使用。因此,正确的做法是绝对远离边缘,将此任务留给 Nginx。
配置 Nginx 反向代理
我们需要做的最后一件事是将 Nginx HTTP 服务器配置为 Kestrel 服务的反向代理。请遵循以下步骤:
- 键入以下命令为该作业创建专用的 Nginx 配置文件:
$ sudo nano /etc/nginx/nginx-worldcities.conf
- 然后,使用以下配置设置填充新文件的内容:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /var/ssl/worldcities.crt;
ssl_certificate_key /var/ssl/worldcities.key;
server_name worldcities.io;
root /var/www/WorldCities/;
index index.html;
autoindex off;
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For
$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- 完成后,按Ctrl+X退出,然后按Y保存文件
- 紧接着,执行以下命令以授权 Nginx 服务连接到网络:
$ sudo setsebool -P httpd_can_network_connect 1
The preceding command is required to change the SELinux default settings, which prevents all HTTPD daemons (such as Nginx) from accessing the local network and, hence, the Kestrel service. If our Linux system is not running SELinux, or we have permanently disabled it, we don't need to execute the preceding command.
更新 nginx.conf 文件
需要在 Nginx 主配置文件中引用nginx-worldcities.conf文件;否则,它将不会被读取和应用
为此,使用以下命令编辑/etc/nginx/nginx.conf文件:
$ sudo nano /etc/nginx/nginx.conf
然后,将以下高亮显示的行添加到文件末尾附近,就在最后的结束方括号之前:
# ...existing code...
location / {
}
error_page 404 /404.html;
location = /40x.html {
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
include nginx-worldcities.conf;
}
这一行将确保我们的反向代理配置能够正常工作。新设置将在 Nginx 重新启动后立即应用,我们将在短时间内完成这项工作。
The nginx.conf and nginx-worldcities.conf files are both available in the /_LinuxVM_ConfigFiles/ folder of this book's GitHub repository.
Linux 上所有必需的部署任务都已完成。现在,我们只需要正确地测试WorldCitiesweb 应用,看看它是否工作。
测试 WorldCities 应用
测试阶段将与我们在 Windows 部署部分末尾所做的非常相似。请遵循以下步骤:
- 在离开 Linux VM 终端之前,明智的做法是以以下方式重新启动 Kestrel 和 Nginx 服务:
$ sudo systemctl restart kestrel-worldcities
$ sudo systemctl restart nginx
- 在此之后,立即使用以下命令检查它们的状态,以确保它们已启动并正在运行:
$ sudo systemctl status kestrel-worldcities
$ sudo systemctl status nginx
现在,我们准备切换到本地开发机器并开始测试。
更新测试机的主机文件
就像我们早期使用HealthCheck应用一样,我们要做的第一件事就是将worldcities.io主机名映射到远程 VM 的 IP 地址。请遵循以下步骤:
- 为此,编辑
C:\Windows\System32\drivers\etc\hosts文件并添加以下条目:
VM.IP.ADDRESS worldcities.io
- 将前面的
VM.IP.ADDRESS占位符替换为 VM 的外部 IP 文件,以便 Windows 将worldcities.io主机名映射到它
现在,我们可以使用 Google Chrome web 浏览器在开发机器上测试该应用。
使用 Google Chrome 测试应用
同样,我们将使用 GoogleChrome 执行这些测试,因为它内置的开发工具可以方便地检查 Web 应用清单和服务人员的存在。
启动 Google Chrome 并在浏览器的地址栏中写入以下 URL:https://worldcities.io
如果我们做的一切都正确,我们应该能够看到 WorldCities web 应用的主视图:

从那里,我们应该检查是否有以下商品:
- Google Chrome 开发控制台的应用清单面板中的应用清单文件(包含所有的HC图标)
- 服务人员在 Google Chrome 开发控制台的应用|服务人员面板中正确注册
- 发送此页面和在浏览器地址栏最右侧安装图标
- 检查和取消选中脱机状态以测试服务人员行为时的服务人员行为
- 访问 SQL 数据库
- “编辑城市”和“编辑国家”窗体
- 登录和注册工作流
如果一切正常,我们可以说 Linux 部署之旅也结束了。
故障排除
如果 web 应用遇到运行时错误,生产环境将不会向最终用户显示有关异常的任何详细信息。因此,除非切换到开发模式,否则我们将无法了解有关该问题的任何有用信息(请参阅以下屏幕截图):

这可以通过以下方式完成:
- 将
/etc/systemd/system/kestrel-worldcities.service文件的ASPNETCORE_ENVIRONMENT变量值更改为Development。 - 使用以下命令重新启动 Kestrel 服务(然后重新生成依赖关系树):
$ sudo systemctl restart kestrel-worldcities
$ sudo systemctl daemon-reload
但是,我们强烈建议您不要在实际生产环境中这样做,而是使用以下journalctl命令检查红隼的日志,正如我们之前建议的那样:
$ sudo journalctl -u kestrel-worldcities --since "yyyy-MM-dd HH:mm:ss"
$ sudo journalctl -xe
这种方法将为我们提供相同级别的信息,而不会向公众暴露我们的错误。
就这样。我们的 ASP.NET Core 和 Angular 部署任务已经结束。我们真诚地希望你和我们一样喜欢这次旅行。
总结
最后,我们通过 ASP.NET Core 和 Angular 的旅程结束了。我们的最后一项任务是让我们的 SPA 现在具备 PWA 最相关的功能,以便在合适的生产环境中发布。
我们做的第一件事是探索我们的后端和前端框架的一些关键部署技巧。由于 VisualStudio 模板已经实现了最重要的优化调整,我们花了一些宝贵的时间来正确地学习和理解各种技术,这些技术可用于在需要通过 web 发布 web 应用时提高 web 应用的性能和安全性。
紧接着,我们以一步一步的方式完成了 Windows 部署。我们在 MS Azure portal 上创建了一个 Windows Server 2019 VM,然后安装了 IIS 服务并对其进行了正确配置,以便通过 web 发布我们现有的HealthCheck应用。我们使用新的 ASP.NET Core 进程内托管模型实现了这一点,这是自 ASP.NET Core 2.2 以来基于 Windows 平台的默认(也可以说是最推荐的)托管模型。
然后,我们转向 Linux,在那里我们学习了如何在基于 CentOS7 的虚拟机上部署 WorldCities 应用。在正确配置之后,我们利用机会使用 Kestrel 和 Nginx 实现进程外托管模型,这是在基于 Linux 的平台上为 ASP.NET Core web 应用提供服务的标准方法。为了实现这一点,我们必须更改 WorldCities 应用的一些后端设置,以确保它们能够在反向代理后得到正确的服务。
完成所有这些之后,我们使用开发机器上的 web 浏览器彻底测试了前面部署工作的结果。对于这两种情况,我们没有购买真正的域名和 SSL 证书,而是使用了自签名证书和主机映射技术,这使我们能够在不花钱的情况下实现相同的结果。
我们与 ASP.NET Core 和 Angular 的冒险终于结束了。可以肯定的是,我们本可以就这两个框架进行更长时间的讨论,并花更多时间完善我们的应用。无论如何,我们应该对取得的成果和吸取的教训感到满意
我们希望你喜欢这本书。非常感谢您的阅读!
建议的主题
HTTPS、安全套接字层(SSL)、.NET Core 部署、HTTP 严格传输安全(HSTS)、通用数据保护法规(GDPR)、内容交付网络(CDN)MS Azure、开放式 Web 应用安全项目(OWASP)、SQL Server、SQL Server Management Studio(SSMS)、Windows Server、IIS、FTP 服务器、发布配置文件、,ASP.NET Core 进程内托管模型、ASP.NET Core 进程外托管模型、CentOS、Kestrel、Nginx、反向代理、转发头中间件、SCP、FileZilla FTP 客户端、WinSCP、journalctl、nano、主机映射、自签名 SSL 证书、openssl、增强安全性的 Linux(SELinux)。
工具书类
- 托管并部署 ASP.NET Core:https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/
- 使用 IIS 在 Windows 上托管 ASP.NET Core:https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/
- ASP.NET Core 性能最佳实践:https://docs.microsoft.com/en-us/aspnet/core/performance/performance-best-practices
- 在 ASP.NET Core:中使用多种环境 https://docs.microsoft.com/en-us/aspnet/core/fundamentals/environments
- 处理 ASP.NET Core中的错误 https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling
- 在 ASP.NET Core:中强制使用 HTTPShttps://docs.microsoft.com/en-us/aspnet/core/security/enforcing-ssl
- .NET Core 应用部署:https://docs.microsoft.com/en-us/dotnet/core/deploying/
- Angular-展开导轨:https://angular.io/guide/deployment
- 启用跨源资源共享:https://enable-cors.org/
- Angular:提前(AOT)编译器:https://angular.io/guide/aot-compiler
- 使用 WebDeploy:创建 ASP.NET 虚拟机 https://github.com/aspnet/Tooling/blob/AspNetVMs/docs/create-asp-net-vm-with-webdeploy.md
- 快速参考:IIS 应用池:https://blogs.msdn.microsoft.com/rohithrajan/2017/10/08/quick-reference-iis-application-pool/
- 配置 Windows 防火墙允许 SQL Server 访问:https://docs.microsoft.com/en-us/sql/sql-server/install/configure-the-windows-firewall-to-allow-sql-server-access
- 配置 ASP.NET Core 与代理服务器和负载平衡器协同工作:https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer
- 使用 Nginx:在 Linux 上托管 ASP.NET Core https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/linux-nginx
- Putty:Windows 的免费 SSH 和 Telnet 客户端:https://www.putty.org/
- 使用 SCP:在 Linux 虚拟机之间移动文件 https://docs.microsoft.com/en-us/azure/virtual-machines/linux/copy-files-to-linux-vm-using-scp
- CentOS 7 软件包管理器–安装.NET Core:https://docs.microsoft.com/en-us/dotnet/core/install/linux-package-manager-centos7
- Linux 上 SQL Server 安装指南:https://docs.microsoft.com/en-us/sql/linux/sql-server-linux-setup
- 将 ASP.NET Core Web 应用部署到 Linux CentOS:https://www.ryadel.com/en/asp-net-core-2-publish-deploy-web-application-linux-centos-tutorial-guide-nginx/
- 日记账 TL–查询系统日记账:https://www.freedesktop.org/software/systemd/man/journalctl.html
- openssl–openssl 命令行工具:https://www.openssl.org/docs/manmaster/man1/openssl.html****************


浙公网安备 33010602011771号