ASP-NET8-最佳实践-全-
ASP.NET8 最佳实践(全)
原文:
zh.annas-archive.org/md5/9d6c90285a5660ecc8526cbe7d170150译者:飞龙
前言
欢迎阅读 ASP.NET 8 最佳实践!
ASP.NET 8 最佳实践包含了超过 100 条在 ASP.NET 社区中使用的最佳实践,涵盖了如何从你的 ASP.NET Web 应用程序中获得最佳性能、可用的 Entity Framework 模式、如何设计最小化的 Web API,以及如何根据项目类型来结构化 Visual Studio 项目等主题。
本书涵盖的标准包括版本控制、创建软件管道、创建结构化中间件、安全实践、Entity Framework Core 模式和技巧,以及自动化耗时客户端任务。
我们还将探讨在测试代码时、何时以及如何应用异常处理、如何设计最佳 API 以供 Web 应用程序使用、如何优化 Web 应用程序以提升性能,以及最后,在构建 ASP.NET Web 应用程序时,回顾常见的术语和指南。
尽管最佳实践被认为是编写专业代码的建议,但总会有例外。正如开发者所知,有无数种编写代码以实现相同结果的方法。有些方法比其他方法更好。我认为这些“更好的方法”就是最佳实践。
在我们介绍每个主题时,我们将提供尽可能多的细节和参考资料来解释为什么这些技术和模式确实是编写专业代码的更好方式。然而,这并不意味着它们是固定不变的。如前所述,总会有例外。
对于新进入 ASP.NET 生态系统的开发者来说,构建一个新的 ASP.NET 网站并看到与之相关的所有内容可能会感到不知所措。本书旨在通过理解与 ASP.NET 网站“中心”相连的每个技术“辐条”来减轻这种感觉。它探讨了创建“简单”网站所涉及的所有内容。
对于已经使用 ASP.NET 的资深开发者,可以将本书作为参考,了解我在 20 年职业生涯中收集的建议和观察。甚至包括我与过去和现在的同事们的经验也包含在本书中。
这本书适合谁
这本书是为那些对 ASP.NET 有实际了解的开发者而写的,他们希望通过学习开发者社区或企业环境中的最佳实践来追求自己的职业发展。虽然可能会有一些对你来说是新主题,但本书可以作为参考,在任何时候帮助你更清晰地理解 ASP.NET 的相关主题。完成本书后,无论你是独立开发者还是在 500 强公司团队中工作,你都将对整个行业中的常用实践有更深入的理解。
这本书涵盖了什么内容
第一章,使用源代码控制来掌握控制权,讨论了源代码控制的重要性,识别和选择分支工作流程,为什么标签很重要,以及正确的提交礼仪。
第二章, CI/CD – 自动构建高质量软件,定义了什么是 CI/CD,理解并准备代码以供管道使用,以及当出现错误时识别两种“失败”的方法,如何部署数据库,以及各种 CI/CD 提供商,如 Azure、AWS 和 Google Cloud Platform。
第三章, 中间件的最佳实践,解释了什么是中间件以及如何使用请求委托和扩展方法优化中间件管道,以及构建一个示例中间件组件。
第四章, 从一开始就应用安全性,涵盖了为什么在编写代码之前、期间和之后,安全性都应该是一个首要关注的问题,以及常见的安全实践。章节最后讨论了互联网上最严重的三个安全威胁。
第五章, 使用 Entity Framework Core 优化数据访问,探讨了 Entity Framework 的不同实现类型以及每种类型的使用方法,为什么日志记录和 async/await 很重要,如何使用资源处理大量种子数据,以及为什么有时绕过 LINQ 并使用存储过程会更好。
第六章, Web 用户界面的最佳实践,检查了任务运行器是什么,为什么它们很重要,以及如何使用一个创建工作流程,以及审查 UI 标准,如集中化站点 URL,为什么控制器/页面应该小,为什么ViewComponents 是有益的,以及如何创建 SEO 友好的 URL。
第七章, 测试您的代码,解释了在 ASP.NET 应用程序中使用的各种测试概念,为什么编写单元测试很重要,为什么“100%测试覆盖率”不是必要的,如何使用三 A 方法(AAA)正确地构建单元测试,何时避免编写额外的单元测试辅助工具,以及如何使用测试作为文档。
第八章, 使用异常处理捕获异常,回顾了不同类型的异常处理,何时以及如何使用异常处理,以及使用全局异常处理。它还涵盖了日志记录,单元测试和异常处理之间的相似之处,为什么空的 try..catch 块是浪费的,如何使用异常过滤和模式匹配,以及何时使用 finally 块。
第九章, 创建更好的 Web API,展示了各种实用的 API 技术,例如快速设计、创建和测试 API,以及将正确的 HTTP 动词和状态代码应用于 API,如何实现分页结构,版本控制和将 DTOs 集成到 API 中,以及为什么应该避免创建新的HttpClient。
第十章,通过性能提升你的应用程序,将我们在各章节中学到的所有内容应用于性能视角。我们将涵盖为什么性能如此重要以及它为什么重要,如何建立客户端、C#代码和数据库基线,以及图像优化、最小化请求、使用 CDN、实现 async/await、如何自动优化 HTML、Entity Framework Core 优化和缓存策略。
第十一章,附录,将回顾整个行业中使用的根本术语,如 DRY、YAGNI、KISS 原则,以及关注点的分离、如何重构代码、理解 SOLID 原则,以及如何在 Visual Studio 中结构化各种项目类型。
为了充分利用这本书
您将需要本书中展示的代码的 Visual Studio 版本。您应该了解如何使用 Visual Studio,并理解如何打开解决方案和调试应用程序。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Visual Studio 2022(任何版本)或支持.NET 8 的 favorite IDE | Windows、macOS 或 Linux |
| Git(可选) | Windows、macOS 或 Linux |
| SQL Server Management Studio(版本 16 或更高) | Windows |
如果您使用的是本书的数字版,我们建议您自己输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
书的理想开发者设置是已安装 Visual Studio 2022 Enterprise、Git 和 SQL Server Management Studio。
下载示例代码文件
您可以从 GitHub(github.com/PacktPublishing/ASP.NET-8-Best-Practices)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“一个更好的方法是将最新的TryParse与var一起使用,如下所示。”
代码块设置如下:
var number = "2";
if (!int.TryParse(number, out var result))
{
result = 0;
}
// use result
粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“取消选择使用控制器选项以使用最小 API。”
小贴士或重要提示
看起来像这样。
联系我们
欢迎读者反馈。
一般反馈: 如果你对本书的任何方面有疑问,请通过客户关怀@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《ASP.NET 8 最佳实践》,我们很乐意听到你的想法!请点击此处直接转到该书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
你喜欢随时随地阅读,但无法携带你的印刷书籍吗?你的电子书购买是否与你的选择设备不兼容?
别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠不仅限于此,你还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。
按照以下简单步骤获取福利:
- 扫描下方二维码或访问以下链接

packt.link/free-ebook/978-1-83763-212-1
-
提交你的购买证明
-
就这些!我们将直接将你的免费 PDF 和其他福利发送到你的邮箱。
第一章:使用源代码管理来掌握控制权
源代码管理是开发者的最佳拍档,并为他们提供了一个在不丢失重要更改的情况下实验代码的方法。源代码管理是在整个开发过程中跟踪和维护对代码所做的更改的能力。虽然这可能包括代码,但它也可以用于文档、资产(如图像)和其他资源。能够测试某些条件,以及无需担心代码库就能重构代码,是大多数开发者认为的超级能力。
在团队环境中工作,源代码管理非常重要。如果开发者提交了代码,后来意识到他们犯了一个错误,源代码管理为开发者提供了一个回滚更改或更新分支并重新提交的方法。几乎总是不使用某种类型源代码管理的企业会引发红旗。
在本章中,我们将探讨开发者在使用源代码管理时在行业中使用的常见做法。我们还将涵盖实现代码分支工作流程的各种方法,并检查每个流程中的不同分支类型。为了结束本章,我们将回顾开发者在使用源代码管理时的常见礼仪。
在本章中,我们将涵盖以下主题:
-
分支策略
-
创建短期分支
-
提交前始终“获取最新”
-
理解常见实践
到本章结束时,你将学会以有组织的方式创建代码库的最佳方法,以及开发社区中的常见指南。
技术要求
虽然本节涉及许多有关源代码管理的指南,但本章的唯一要求是任何操作系统的计算机。Git 是可选的。
如果你没有安装 Git,你可以从以下网址下载并安装它:
我们已经使用 mermaid-js 来直观地展示分支策略。截至 2022 年 2 月,GitHub 页面现在支持 Mermaid-js。一些章节将包含 mermaid 图表来展示不同的分支层次结构。
如需了解更多关于 Mermaid-js 的信息,请访问以下网址:
分支策略
在本节中,我们将探讨各种分支策略,解释每个策略的工作原理,并突出它们之间的差异。
虽然每个公司都有其独特的流程,但我们将关注行业中的常用策略。
由于 GitFlow 是初始流程,因此在行业中每个人都熟悉它,其后续者通过微调流程进行了改进。
在接下来的章节中,我们将讨论每个流程,但首先,我们必须了解 GitFlow 的基础知识。
GitFlow
行业中最常见且最成熟的流程之一是 GitFlow。它由文森特·德里森于 2010 年创建。
最小 Git 仓库应包含以下分支:
-
main/master/trunk(从现在起称为 main)
-
develop
main 分支是在创建新仓库时开始的。这个分支的目的是始终拥有稳定且可用于发布的生产就绪代码。
develop 分支用于编写新代码,并防止未经测试的代码被合并到 main。
如果你是一个独立开发者,正在从事一个侧项目,这可能是一个合适的流程。如果 develop 上的所有内容都正常,你可以将你的更改合并到 main 并部署你的第一个版本。
好消息是你可以进一步发展你的分支层次结构。你可以轻松创建额外的分支,例如功能分支、发布分支或热修复分支,以获得更好的工作流程,我们将在后面介绍。
请记住,以下讨论的每个分支工作流程都允许任何团队,无论是 1 个开发者还是 50 个开发者,都能对 GitFlow 有一个稳固的理解。
在任何源代码管理系统中,通常有三种类型的分支用于帮助管理软件工作流程:功能分支、发布分支和热修复分支。
功能分支
功能分支将新功能隔离到单个分支中,这样开发者就可以编写代码,而不用担心会影响 develop 分支中的核心代码。
在以下示例中(见 图 1**.1),一个团队创建了一个 GitHub 仓库。它将其主分支命名为 main,将开发分支命名为 develop。

图 1.1:GitFlow 中的功能分支
一旦每个人都收到了他们的任务,一位开发者被分配从 develop 分支创建 feature/settings。
另一位开发者被分配了从 develop 分支来的 feature/printing 分支。
分支命名
除了标准化的 main 和 develop 分支之外,一种常见的命名分支的方式是使用前缀。以下是一些示例:
-
feature/,features/, 或feature-: 分支名称应尽可能具有描述性和帮助性。例如,feature/1234-settings与feature/jd-settings相关)。 -
"bug/<userstory/task number>-<problem>/": 这个例子有助于立即识别错误。这种技术的例子可以是bug/1234-string-overflow。在分支前缀为“bugfix”也是可接受的。
一旦完成并得到批准,每个开发者都会将他们的更改合并到 develop 分支。
发布分支
发布分支用于最后的润色、小错误修复以及/或为你的软件的新版本做准备(多么令人兴奋啊!)
让我们来看看发布分支是如何融入我们之前的示例中的。以下图表展示了 GitFlow 中发布分支的样子:

图 1.2:GitFlow 中的发布分支
初始时,开发者根据分配的任务创建一个功能分支。一旦他们将更改合并到develop,就会从develop分支创建一个新的发布。发布分支会合并到main并打上版本号。现在,main分支将合并到develop分支,以便开发者在发布过程中如果有代码更改,可以拥有最新的更改。
它与功能分支完全相同,但如果你注意到,我们是从develop分支而不是从main分支创建release分支的。
在创建并确认release分支按预期工作后,将release分支合并到main分支。
一旦合并到main,建议以某种方式标识一个成功的发布。按照惯例,带有版本号的标签是最好的方法。
热修复分支
虽然大多数开发者不会犯编码错误(嗯嗯),但有时需要对main分支进行即时更改。
回到我们的例子,似乎有一个开发者的代码出现了问题。当任何人选择设置选项时,应用程序会崩溃,使得应用程序无法使用。这需要一个热修复分支。
以下图表展示了如何实现热修复分支的示例:

图 1.3:GitFlow 中的热修复分支
热修复分支是从main分支创建的,一旦代码经过验证,需要合并回main分支和develop分支。
在 GitFlow 中,长期运行的分支是main和develop。短期分支包括功能、热修复和错误修复分支。
现在我们已经介绍了 GitFlow 及其分支类型,接下来我们将探讨下一个工作流程,称为 GitHub Flow,以及它与 GitFlow 的不同之处。
GitHub Flow
随着时间的推移,GitFlow 已经演变成更简单的流程。这些流程中的第一个是 GitHub Flow,它于 2011 年创建。
GitHub Flow 旨在通过删除develop分支并在main分支上创建功能来简化流程。
以下图表展示了功能分支和热修复分支是如何工作的。

图 1.4:GitHub flow 中的热修复分支
在图 1.4中,创建了两个特征,并将这两个特征合并回main。立即,发布了版本 1.0.0。在 1.0.0 版本发布后,网站上的一些文本出现了错误,法律团队要求修复这些问题。
其中一位开发者创建了一个热修复分支,更改了标签,提交了一个 PR,得到了批准,将更改合并到main,更新了版本,并立即将代码部署到生产环境中。
热修复和功能分支之间的区别是什么?热修复是从main/master创建的分支,代码已检查、审查、更新,并立即合并回main/master。功能分支更像是组织或计划的方法。功能分支是从develop分支创建的,代码已检查、审查并合并回功能分支。功能分支被计划合并到发布分支。
那么,发布分支在哪里?在每个工作流程中,都存在某种类型的发布分支,我们将如下进行审查。这个分支的概念是始终有一个没有版本错误、经过测试且随时可以部署的版本。一些小型初创公司在起步时使用这种类型的工作流程。由于 GitFlow 在行业中被视为基准,当团队扩大并寻求更结构化的工作流程时,很容易应用 GitFlow 的概念。
在 GitHub Flow 中,这里的长期分支仍然是main,而短期分支是功能、热修复和错误修复分支。
在审查了 GitHub Flow 之后,让我们继续到最后一个常用的分支策略,称为 GitLab Flow。
GitLab Flow
我们将要介绍的最后一个工作流程是 GitLab Flow。GitLab Flow 于 2014 年创建,它对 GitFlow 工作流程采取了不同的方法,并结合了以功能驱动的发展来使用功能分支和问题跟踪。
GitLab Flow 将发布分支转换为稳定的环镜分支,例如生产和 QA。当然,你可以根据需要创建尽可能多的“环境分支”。如果我们有一个 QA 环境分支,这可能被用来测试最终产品。在图 1.5 中,我们看到从main分支创建的标准feature分支,以及另外两个环境分支(预生产和生产)。

图 1.5:GitLab Flow
在 GitLab Flow 中,main被视为一个测试分支。无论是 QA 还是经理,它都是一个测试功能分支的地方。
与 GitHub Flow 类似,所有内容都合并到main分支。当功能分支被提交时,会进行代码审查(这是强制性的),并合并到main,所有测试(是的,所有)都会运行。如果测试运行时间超过五分钟,请将它们配置为并行运行。
一旦在main上完成测试,main就会被推送到预生产进行进一步测试,最后推送到生产环境。由于 GitLab Flow 中的发布基于标签,每个标签都应该创建一个新的发布。
如果开发者引入了一个错误,它首先必须在主分支上修复,然后是环境分支。开发者必须创建一个修复错误的分支,提交带有 PR 批准的代码,进行代码审查,并在继续工作流程之前,合并代码并运行与错误相关的测试。
一旦在主分支上测试通过,它就会被标记并自动提升到预生产环境,然后是生产环境。在这个工作流程中,长期运行的分支包括主分支和环境分支。短期分支是功能、热修复和错误修复分支。
在本节中讨论的每个策略中,我们都看到了每个策略是如何从最初的 GitFlow 发展而来,并且(请原谅这个双关语)分支到一个更好的工作流程。
下一节将介绍使用源代码控制时的常见礼仪。
创建短期分支
一旦您初始化了您的仓库并创建了您的第一个分支,您就可以开始为您的功能编写代码。
虽然这很令人兴奋,但这个指南更多的是针对团队,而不是个人构建的副项目。团队越大,这对您的工作流程就越关键。
让我们通过 图 1.6 来看看一个使用多个功能分支的例子。

图 1.6:长期存在的功能分支(功能/设置)
每个人都被分配了他们各自的功能分支,这些分支是为他们创建的。正如您所看到的,开发者在完成他们的功能并将它们检查到 develop 中。
然而,正在设置功能(feature/settings)的开发者落后了。由于他们一周内没有更新代码,他们的分支正在变得过时。它只包含他们第一次创建分支时的功能。
如果他们决定在未更新分支的情况下将代码提交到仓库,你认为会发生什么?会有很多不高兴的开发者。为什么?
feature/settings 分支将被提交并覆盖合并到 develop 分支的所有人的更改。您的分支寿命越短,您遇到合并冲突的可能性就越小。
如果不是每天两次,最好是每天进行更新,以防止您的分支变得过时。
理解常见实践
从技术上讲,了解如何使用源代码控制只是战斗的一半。另一半是在使用源代码控制的同时作为一个团队玩家。考虑到你的队友的能力将使你在职业生涯中走得更远,成为一个周到和值得信赖的开发者。
以下各节旨在作为指南,帮助您在团队环境中取得成功。如果您作为一个个人开发者在一个开源项目中工作,实施这些实践也不会有害。
私下重置,公开合并
当在私有功能分支上工作时,可能会有多个提交是必要的。这些提交会给 main/master 分支添加不必要的噪音。
变基代码会将多个本地提交合并为一个提交,并更新另一个分支。这本质上重写了提交历史。当然,这与合并不同。合并是将一个分支的所有提交合并到另一个分支的过程。合并保留了所有提交的历史。
将变基想象成在向潜在买家展示房子之前打扫房子。如果你在本地分支中犯了许多错误,你希望主/主分支提供清晰简洁的注释,说明提交代码时应用了哪些代码。
提交代码前始终“获取最新版本”
在保持分支最新这个话题上,在你提交代码之前“获取最新版本”是一个好习惯。
“获取最新版本”是指从中央仓库检索任何更新并将这些更新应用到你的本地代码仓库。
无论你使用 Git、团队基础服务器(TFS)还是其他源代码控制系统,你都需要始终考虑团队,获取最新的代码更新。每个源代码工具都有获取代码最新版本的方式。无论你使用什么工具,获取最新版本总是一个好习惯。
由于 Git 因其灵活性和对源控制的细粒度方法而被视为行业标准,大多数开发环境都提供与 Git 交互的界面(图形界面或命令行界面)。
使用 Git,有几种推送和拉取更改的方法:
-
拉取:从远程仓库检索元数据到本地仓库。
-
拉取:检索远程元数据并从远程仓库拉取这些更改的副本到本地仓库。
-
推送:将提交推送到远程分支。
-
同步:先执行一次拉取,然后执行一次推送。记住,获取最新更改并将这些更改应用到你的代码上,然后推送你的提交更改到服务器的仓库。
在提交代码之前,最好先执行一次拉取操作,以获取所有内容。
提交代码前始终构建和测试
虽然在提到我们之前的指南后,这听起来可能是一个简单的概念,但仍有不少开发者继续在提交代码时遗漏这一步。一旦你拉取了最新版本,下一步就是编译代码,并运行本地单元测试。不要假设你拉取的代码没有错误。
周五下午,开发者 B 执行了拉取操作来更新他们的代码,提交代码时没有编译,然后匆匆离开。
他们不知道的是,开发者 A 在开发者 B 之前提交了代码。代码无法编译,而开发者 A 已经离开去周末了。现在开发者 B 拉取了代码,但他无法编译它。
在周一,他们发现他们的代码根本无法构建,单元测试也没有通过。
或者更糟糕的是,他们在周五晚上接到这个消息的电话。
避免提交二进制文件
源代码控制系统已经存在了一段时间,大多数都已经过时(比如 SourceForge,对吗?),但它们都已被用作源代码仓库。
最近,有相当多的内容管理系统(CMSes)使用源代码系统作为内容存储库,在那里它们管理和版本控制网站的资产,例如图片、MP3 和视频。
然而,对于开发者来说,我们的内容是我们的源代码。大多数开发者仓库甚至没有 Word 文档大。
如果开发者想要通过提交所有内容来保持系统的“快照”,这将违背源代码控制的目的。
对于.NET 应用程序,编译应用程序意味着\bin和\obj文件夹将包含程序集。这些程序集在编译时自动创建,不需要提交到仓库。
在大多数源代码控制系统中,存在某种类型的忽略文件,用于在提交代码之前过滤和删除仓库中的冗余。例如,在 Git 中,有一个.gitignore文件,它应该包括这些\bin和\obj目录以及其他对于构建解决方案或项目不必要的文件类型。
作为一般准则,当你从一个仓库克隆并立即在新机器上构建它时,无论是内部企业项目还是 GitHub 上的开源框架,都不应该出现错误。
如果你从自己的项目或第三方项目中提交程序集到 Git,目的是保持其可运行状态,那么你做错了。最好不要将任何二进制文件提交到 Git。
如果你需要一个第三方库的特定版本,考虑使用 NuGet 包管理器。当你将 NuGet 包添加到项目中时,它会自动连接并检索特定版本,并将其放置到\bin文件夹中,使应用程序每次都能编译、构建并成功运行。
使用标签进行版本控制
标签在源代码控制中使用时非常有帮助。事实上,它们是 GitLab Flow 的驱动力。虽然标签对于源代码控制来说很棒,但它们也可以被用于邪恶的目的。例如,一些公司在整个工作流程过程中使用标签作为注释,这是不建议的。标签提供了一种方式,可以在地面上放置一个标志来表示,“这是版本 x.x.x。”它们是稳定发布的代码快照的标记。这表示这个标签下的代码应该构建、编译、测试和运行不修改代码和没有错误。
在整个工作流程中,最好严格使用标签来版本控制你的发布。
摘要
在本章中,你学习了可用的不同分支工作流程,包括 GitFlow、GitHub Flow 和 GitLab Flow,以及每个工作流程是如何运作的。除了工作流程,你还学习了行业标准化的分支名称,例如 main/trunk/master、develop、features、release 和 hotfix 分支,以及它们在每个工作流程中的运作方式。你还学习了与源代码控制一起工作的正确方法,例如始终获取最新代码,何时进行变基而不是合并,让分支拥有短暂的生命周期,在提交前编译和测试你的代码,永远不要提交程序集,以及正确使用标签。
在下一章中,我们将从我们的仓库中提取源代码,创建一个自动构建过程以生成工件。我们还将介绍如何自动将其部署到服务器上。
第二章:CI/CD – 自动构建高质量软件
在我的职业生涯中,有人曾经对我说,“CI/CD 已死,长命 CI/CD。”当然,这句话并不意味着它已经完全死亡。它只是意味着 CI/CD 现在已经成为软件开发的标准,是开发者在软件开发生命周期中应该采用和学习的常见做法。现在,它被视为你开发过程的一部分,而不是一个光鲜的新流程。
在本章中,我们将回顾 持续集成/持续部署(CI/CD)的含义以及如何为管道准备你的代码。一旦我们涵盖了需要包含在代码中的必要更改,我们将讨论构建软件的常见管道是什么样的。一旦我们理解了管道过程,我们将探讨两种从失败的部署中恢复的方法以及如何通过管道部署数据库。我们还将涵盖你可用的三种不同类型的云服务(本地和远程以及混合型)并回顾互联网上顶级 CI/CD 提供商的列表。最后,我们将带你了解为示例应用程序创建构建的过程,以及其他类型的项目。
在本章中,我们将涵盖以下主题:
-
什么是 CI/CD?
-
准备你的代码
-
理解管道
-
两种“坠落”方法
-
部署数据库
-
三种构建提供者类型
-
CI/CD 提供商
-
Azure Pipelines 演示
完成本章后,你将能够在准备软件部署代码时识别软件中的缺陷,了解常见管道在产生高质量软件时包含的内容,识别两种从失败的部署中恢复的方法,知道如何通过管道部署数据库,了解不同类型的 CI/CD 提供商,以及了解 CI/CD 提供商空间中的关键参与者。
最后,我们将通过 Azure Pipelines 中的一个常见管道来回顾本章所学的一切。
技术要求
对于本章,唯一的技术要求是能够访问一台笔记本电脑,以及 CI/CD 提供商 部分中提到的云服务提供商的账户(最好是微软的 Azure Pipelines – 不要担心,它是免费的)。
一旦你了解了如何创建管道,你将能够将相同的概念应用到其他云服务提供商及其管道策略上。
什么是 CI/CD?
在本节中,我们将了解持续集成和持续部署对开发者意味着什么。
持续集成(CI)是将所有开发者的代码合并到主线中,以触发自动构建过程,这样你可以快速使用单元测试和代码分析来识别代码库中的问题。
当开发者将他们的代码检入分支时,它会被同行开发者审查。一旦被接受,它就会被合并到主线中,并自动开始构建过程。这个构建过程将在稍后介绍。
持续部署(CD)是持续创建软件以便在任何时候都可以部署的过程。
一旦所有内容都通过自动化过程构建完成,构建过程会准备编译后的代码并创建工件。这些工件用于在各种环境中的一致部署,例如开发、测试和生产环境。
实施 CI/CD 管道的好处超过了没有它的情况:
-
自动化测试:当提交被触发时,你的测试会自动与构建一起执行。想象一下,有人在每次提交时都会检查你的代码。
-
更快的反馈循环:作为开发者,总是收到即时的反馈以了解某件事是否工作是非常好的。如果你收到一封构建失败的邮件,那么你只能自己处理。
-
一致的构建:一旦你的项目在构建服务器上构建,你就可以创建带有测试的按需构建——并且是一致的。
-
团队协作:我们都在同一个战壕里,CI/CD 包括开发者、系统管理员、项目经理/敏捷大师以及 QA 测试员等,以实现创建优秀软件的目标。
在本节中,我们回顾了在自动化方式开发软件时,持续集成和持续部署的定义以及实施 CI/CD 管道的好处。
在下一节中,我们将了解在自动化软件构建时应避免的某些代码实践。
准备你的代码
在本节中,我们将讨论你的代码的某些方面以及它们如何可能影响你软件的部署。这些软件问题可能包括代码无法编译(构建失败)、避免使用相对路径名称,以及确保你编写了适当的单元测试。这些是我多年来遇到的一些常见错误;在本节中,我还会提供如何修复它们的解决方案。
在我们审查 CI 管道之前,有一些注意事项我们需要事先解决。尽管我们在上一章中已经涵盖了版本控制的大部分内容,但你的代码需要处于某种状态才能实现“一键”构建。
在接下来的章节中,你将了解如何准备你的代码以便它“CI/CD 就绪”,并检查你在部署软件时可能遇到的问题以及如何避免这些问题。
无缝构建
如果新员工被雇佣并立即开始工作,你希望他们能够迅速上手,开始开发软件而无需延迟。这意味着能够将他们指向一个仓库并拉取代码,这样你就可以立即运行代码,并且最小化设置。
我说“最小化设置”,因为可能涉及权限问题,以便访问公司中某些资源,以便它们可以在本地运行。
尽管如此,代码应该处于可运行状态,引导你到一个简单的屏幕,并通知用户跟进权限问题或提供一些通知以解决问题。
在上一章中,我们提到了代码应该始终编译。这意味着以下内容:
-
代码应该在克隆或检出后始终编译
-
单元测试应该包含在构建中,而不是在单独的项目中
-
你的版本控制提交信息应该是有意义的(它们可能用于发布说明)
这些标准允许你的管道落入成功的陷阱。当你的代码处于干净状态时,它们帮助你更快、更轻松地创建构建。
避免使用基于文件的操作的相对路径名称
这些年来,我在处理 Web 应用程序时遇到的一个棘手问题是文件如何在 Web 应用程序中被访问。
我也见过通过网页进行基于文件的操作,其中文件使用相对路径移动,结果出了问题。这涉及到删除目录,结果并不好。
例如,假设你有一个指向图像的相对路径,如下所示:
../images/myimage.jpg
现在,假设你坐在一个网页上,比如https://localhost/kitchen/chairs。
如果你退回到一个目录,你会在厨房里找到一个缺失的图像,而不是在网站的根目录。根据你的相对路径,你正在寻找https://localhost/kitchen/images/myimage.jpg中的图像目录。
更糟糕的是,如果你使用自定义路由,这甚至可能不是正常的路径,谁知道它在哪里寻找图像。
在准备你的代码时,最佳做法是在你的 URL 开头使用单个斜杠(/),因为它被认为是“绝对”的:
/images/myimage.jpg
这使得在网站上定位文件时更容易导航到根目录,无论你处于什么环境。无论你是位于www.myfakewebsite.com/还是localhost/,根目录就是根目录,你将始终使用源开头的一个斜杠找到你的文件。
确认你的单元测试是单元测试
代码中的测试是为了提供检查和平衡,以确保代码按预期工作。每个测试都需要仔细检查,以确认它没有做任何不寻常的事情。
单元测试被认为是针对内存中代码的测试,而集成测试是需要任何外部资源的测试:
-
你的测试访问任何文件吗?集成测试。
-
你是否连接到数据库来测试某些内容?集成测试。
-
你正在测试业务逻辑吗?单元测试。
正如你开始推测的那样,当你在一个其他机器上构建你的应用程序时,云服务无法访问你的数据库服务器,也可能没有每个测试通过所需的附加文件。
如果你正在访问外部资源,将你的测试重构为更内存驱动的方法可能是一个更好的选择。我将在第七章中解释原因,届时我们将涵盖单元测试。
创建环境设置
无论你是在项目中间,还是第一次点击 创建新项目…,你都需要一种方法来为你的 Web 应用程序创建环境设置。
在 ASP.NET Core 应用程序中,我们默认提供了appsettings.json和appsettings.Development.json配置文件。appsettings.json文件旨在作为一个基本配置文件,并且根据环境的不同,每个appsettings文件都会应用,并且只覆盖现有的属性到appsettings.json文件。
这的一个常见例子是连接字符串和应用程序路径。根据环境的不同,每个文件都会有自己的设置。
环境也需要提前定义。总会有开发和发布环境。可能有一个选项在另一台机器上创建另一个名为 QA 的环境,因此需要一个appsettings.qa.json文件,其中包含其自己的特定环境设置。
确认这些设置已为每个相关环境保存,因为它们在 CI/CD 流水线中很重要。这些环境设置应始终与你的解决方案/项目一起提交到版本控制中,以帮助流水线将正确的设置部署到正确的环境中。
在本节中,我们介绍了如何通过确保我们可以在克隆或本地拉取存储库后立即构建来准备代码以供 CI/CD 流水线使用,为什么我们应该避免基于相对路径的文件路径,并确认我们正在使用特定环境的应用程序设置,这使得构建和部署我们的应用程序变得容易。
在代码已提交后,我们现在可以继续前进,描述常见流水线的所有阶段。
理解流水线
在本节中,我们将介绍在 CI/CD 服务中构建软件时,常见流水线包括的步骤。当你到达本节的结尾时,你将理解常见流水线中的每个步骤,以便你可以生产出高质量的软件。
CI 流水线是一系列必要的步骤,用于编码、构建、测试和部署软件。每个步骤不是由特定个人拥有,而是由一个共同协作并专注于生产卓越软件目标的团队拥有。好消息是,如果你遵循了上一章的建议,你已经领先一步了。
每个公司的流水线可能因产品而异,但 CI 过程总会有一个共同的步骤集。这取决于你的需求,流水线的阶段可能会受到参与过程中的每个利益相关者的影响。当然,对于开发者来说,需要拉取代码、构建和测试是必需的,但 QA 团队需要将最终产品(工件)发送到另一台服务器进行测试。
图 2.1 展示了一个常见的流水线:

图 2.1 – 构建流水线的一个示例
如图 2**.1所示,在创建软件部署时,过程是顺序的。以下是步骤的总结:
-
从单个存储库拉取代码。
-
构建应用程序。
-
在步骤 2中构建的代码上运行单元测试/代码分析。
-
创建工件。
-
创建容器(可选)。
-
将工件部署到服务器(开发/测试/预发布/生产)。
现在我们已经定义了一个通用管道,让我们深入了解每个步骤,了解在构建您的软件时每个过程包含什么。
在以下子节中,我们将根据这里定义的步骤详细检查每个过程。
拉取代码
在我们构建应用程序之前,我们需要在我们的管道中确定我们要构建的项目。管道服务需要一个存储库位置。一旦您提供了存储库 URL,该服务就可以在他们的服务器上为编译准备存储库。
在上一节中,我们提到了为什么在克隆后您的代码需要完美编译。代码是在与您的完全不同的机器上克隆和构建的。如果应用程序只在您的计算机上工作,而其他人则不行,就像行业中的一句俗语所说,“我们得把您的电脑运给所有我们的用户。”虽然这是一句行业中的幽默说法,但在现实世界中编写和部署软件时通常是不受欢迎的。
每个 DevOps 服务都有其优点。例如,Azure Pipelines 可以检查您的存储库,并根据您项目的结构做出假设。
分析项目后,它使用一种称为 YAML(发音为 Ya-mel)的文件格式来定义项目应该如何构建。虽然 YAML 现在被认为是行业标准,但我们不会深入探讨 YAML 所包含的所有内容。YAML 功能可能是一本单独的书。
Azure 会根据如何构建您的应用程序创建一个 YAML 模板。
它知道如何编译应用程序,确定容器是否包含在项目中,并在构建之前检索 NuGet 包。
最后要提到的是,大多数 DevOps 服务允许每个项目一个存储库。这种方法的优点包括以下内容:
-
简单性:管理并构建一个应用程序比在一个项目中编排数百个应用程序要简单得多。
-
协作:与其让多个团队专注于一个大项目,不如让一个或两个较小的团队在一个更易于管理的单一项目上工作。
-
更快地构建:CI/CD 管道旨在提供快速的反馈和更快的改进。项目越小,构建、测试和部署就会越快。
话虽如此,我们现在已经准备好构建应用程序。
构建应用程序
如前所述,YAML 文件定义了服务如何构建您的应用程序。
总是确认在构建之前 YAML 文件包含你所需的一切是一个好习惯。如果你有一个简单的项目,向导中包含的样板文件可能就足够了,但它允许你在需要时进行更新,或者进行其他应用程序检查。
可能需要尝试几次来调整 YAML 文件,但一旦文件处于稳定状态,看到一切按预期工作是非常令人欣慰的。
确保在构建应用程序之前已经检索了所有代码。如果这一步失败,流程将退出管道。
如果你提交了糟糕的代码并且构建失败,根据警报级别,适当的权威机构(开发者或管理员)将被通知,并且你将因为破坏构建而得到一个傻瓜帽或填充的猴子,直到有人打破它。
接下来,我们将专注于对应用程序运行单元测试和其他测试。
运行单元测试/代码分析
构建完成后,我们可以继续进行单元测试和/或代码分析。
单元测试应该针对编译后的应用程序运行。这包括单元测试和集成测试,但如我们之前提到的,要小心集成测试。管道服务可能无法访问某些资源,这可能导致你的测试失败。
单元测试,按其本质,应该非常快。为什么?因为你不希望等待 30 分钟来运行单元测试(这是痛苦的)。如果你有单元测试需要那么长时间,请识别运行时间最长的单元测试并进行重构。
一旦代码编译并加载,单元测试应该每 10-30 秒运行一次,这是一个一般性指南,因为它们是基于内存的。
虽然单元测试和集成测试在大多数测试场景中很常见,但你可以在你的管道中添加额外的检查,包括识别安全问题和代码度量,以便在构建结束时生成报告。
接下来,我们的构建会创建用于部署的工件。
创建工件
一旦构建成功并且所有测试通过,下一步就是创建我们的构建工件并将其存储在中央位置。
作为一般规则,最好只创建一次二进制文件。一旦它们被构建,它们可以随时使用。这些工件可以随意将版本部署到服务器,而无需再次通过整个构建过程。
工件应该是防篡改的,并且任何人都不应该修改它们。如果工件存在问题,管道应该从头开始并创建一个新的工件。
让我们继续讨论容器。
创建容器
一旦创建了自包含的工件,一个可选的步骤是围绕它构建容器或将其安装到容器中。虽然大多数企业使用各种平台和环境,如 Linux 或 Windows,但使用 Docker 等工具“容器化”应用程序允许它在任何平台上运行,同时隔离应用程序。
考虑到容器已成为行业标准,因此创建一个容器以便它可以轻松地部署到任何平台,如 Azure、亚马逊网络服务(AWS)或 Google Cloud Provider 是有意义的。再次强调,这是一个可选步骤,但在行业中它正变得不可避免。
当使用 Visual Studio 创建新项目时,你将自动通过生成的 Docker 文件获得一个容器包装器。这个 Docker 文件定义了容器将如何允许访问你的应用程序。
一旦你将 Docker 文件添加到你的项目中,Azure 就会将其识别为容器项目,并使用包含的项目创建容器。
最后,我们将检查软件的部署。
部署软件
一切生成完毕后,我们所需做的就是部署软件。
记得你appsettings.json文件中的环境设置吗?这对于部署非常有用。
根据你的环境,你可以在部署时将适当的 JSON 文件合并到appsettings.json文件中。
一旦你的环境设置就绪,你可以以任何你喜欢的任何方式定义你的部署目的地。
部署可能包括将工件 FTP 或 WebDeploy 到服务器,或将容器推送到某个服务器。所有这些选项都是现成的。
然而,你必须以相同的方式部署到每个环境。唯一改变的是appsettings文件。
在成功(或失败)部署后,应向所有参与部署结果的人员发送报告或通知。
在本节中,我们学习了常见的管道包括什么以及每个步骤如何依赖于成功的上一个步骤。如果在管道中的任何步骤失败,过程将立即停止。这种“传送带”方法为软件开发提供了可重复的步骤、以质量驱动的软件和可部署的软件。
两种“回退”方法
在本节中,我们将了解两种从失败的软件部署中恢复的方法。完成本节后,你将知道如何使用这两种方法对不良部署的恢复做出合理的决定。
在标准管道中,公司有时在部署到 Web 服务器时会遇到软件故障。当用户在网站上执行操作时,他们可能会看到错误消息。
当软件不符合预期时,你会怎么做?这在 DevOps 管道中是如何工作的?
每次构建软件时,总有可能出错。在软件部署之前,你总是需要一个备份计划。
让我们来看看当软件部署失败时我们可以使用的两种恢复方法。
回退(或回退)
如果产品中引入了各种错误,而前一个版本似乎没有这些错误,那么回滚软件或回退到前一个版本是有意义的。
在管道中,末尾的过程创建工件,这是您产品的自包含、可部署版本。
这里有一个向后跌倒的例子:
-
您上周的软件部署成功,并被标记为版本 1.1(v1.1)。
-
在两周内,开发人员为软件创建了两个新功能,并希望尽快发布它们。
-
创建并发布了一个新版本,称为版本 1.3(v1.3)。
-
当用户在使用最新版本(v1.3)时,他们遇到了一个新功能的问题,导致网站显示错误。
-
由于上一个版本(v1.1)没有这个问题,且影响不严重,开发人员可以将 v1.1 部署到服务器上,以便用户可以继续保持生产力。
这种类型的发布被称为向后跌倒。
如果您必须用旧版本(v1.1)替换当前版本(v1.3)(数据库除外,我稍后会讨论),您可以轻松地识别并部署最后一个已知的工件。
向前跌倒
如果回退方法不是一个可行的恢复策略,那么替代方案就是向前跌倒。
当向前跌倒时,产品团队接受带有错误的部署(包括所有瑕疵),并继续推出新的版本,同时将这些错误置于高度优先级,并承认这些错误将在下一个或未来的版本中得到修复。
这里有一个向前跌倒的类似例子:
-
再次,上周的软件部署成功,并被标记为版本 1.5(v1.5)。
-
在接下来的两周内,开发人员为软件创建了另一个新的大型功能。
-
创建并发布了一个新版本,称为版本 1.6(v1.6)。
-
当用户在使用最新版本(v1.6)时,他们遇到了一个新功能的问题,导致网站显示错误。
-
经过分析,开发人员意识到这是一个“快速修复”,创建了适当的单元测试以证明问题已修复,通过管道推送了新的发布,并立即在新版本(v1.7)中部署了修复后的代码。
这种类型的发布被称为向前跌倒。
产品团队可能必须检查每个错误,并决定哪种恢复方法最适合产品的声誉。
例如,如果产品功能(如业务逻辑或用户界面更新)是问题所在,那么最好的恢复方法可能是向前跌倒,因为对系统的影响最小,用户的流程不会被打断,且保持生产力。
然而,如果涉及代码和数据库更新,更好的方法是将回退——即恢复数据库并使用工件的前一个版本。
如果这是一个关键功能,且无法回滚,那么可能需要采用“热修复”方法(如前一章所述)来修复软件。
再次,这取决于每个问题对系统造成的影响,以确定哪种恢复策略是最好的方法。
在本节中,我们学习了两种从失败的软件部署中恢复的方法:向后回退和向前跌倒。虽然这两个选项都不是强制性的选择,但每种方法都应根据错误类型、修复的恢复时间和软件的部署计划进行慎重考虑。
部署数据库
部署应用程序代码是一回事,但如果操作不当,部署数据库可能是一项令人畏惧的任务。在部署数据库时有两个痛点:结构和记录。
在数据库结构方面,您会遇到向表中添加、更新和删除列/字段的问题,以及更新相应的存储过程、视图和其他与表相关的功能,以反映表更新。
在记录方面,这个过程并不像更改表结构那样复杂。更新记录的频率并不规律,但一旦发生,您可能希望用默认记录填充数据库,或者用新值更新这些种子记录。
以下几节将介绍在 CI/CD 管道中部署数据库的一些常见做法。
部署前备份
由于公司数据对业务至关重要,在修改或更新数据库之前必须对其进行备份。
一项建议是将整个数据库部署过程分为两步:首先备份数据库,然后应用数据库更新。
DevOps 团队可以在应用数据库更新之前包含一个预部署脚本,以自动备份数据库。如果备份成功,您可以继续部署您的更改到数据库中。如果不成功,您可以立即停止部署并确定失败的原因。
如前文所述,这是为了采用“回退”方法而不是“向前跌倒”策略。
制定表结构策略
更新表的一种策略是采取一种非破坏性的方法:
-
添加列:在添加列时,为创建记录时该列放置一个默认值。这将防止在添加记录时应用程序出错,通知用户该字段没有值或为必填项。
-
更新/重命名列:更新列略有不同,因为您可能正在更改数据库中的数据类型或值。如果您正在将列名和/或类型更改为其他内容,请添加一个具有新列类型的新列,确保设置默认值,然后继续在应用程序代码中使用它。一旦代码稳定并且按预期运行,从表中删除旧列,然后从代码中删除。
-
删除列:处理此过程有几种不同的方法。如果字段是用默认值创建的,请在你的应用程序代码中做出相应的更改以停止使用该列。当记录添加到表中时,默认值不会创建错误。一旦应用程序代码已更新,重命名表中的列而不是删除它。如果你的代码仍在使用它,你将能够识别代码问题并修复它。一旦你的代码运行无误,就可以安全地从表中删除该列。
在对表结构进行适当的更改时,不要忘记更新额外的数据库代码以反映表更改,包括存储过程、视图和函数。
创建数据库项目
如果你的 Visual Studio 解决方案连接到数据库,你需要将另一种项目类型添加到你的解决方案中,称为数据库项目类型。当你将此项目添加到解决方案中时,它会对你的数据库进行快照,并将其作为代码添加到项目中。
为什么要在你的解决方案中包含它?包含它的有三个原因:
-
当你从头创建数据库时,它提供了一个数据库模式作为 T-SQL。
-
它允许你根据基础设施即代码(IaC)范式对数据库进行版本控制。
-
当你在 Visual Studio 中构建解决方案时,它会自动从数据库项目生成 DAC 文件,以便与自定义脚本一起部署。将 DAC 包含在解决方案中,管道可以首先使用 DAC 文件部署和更新数据库。一旦数据库部署(和备份)完成,管道可以部署工件。
如你所见,将其包含在解决方案中非常方便。
使用 Entity Framework Core 的迁移
Entity Framework 自从早期以来已经取得了长足的进步。迁移是另一种通过 C#而不是 T-SQL 来包含数据库更改的方法。
在创建迁移时,Entity Framework Core 会对数据库和DbContext进行快照,并使用 C#创建数据库模式和DbContext之间的差异。
在初始迁移中,整个 C#代码都通过Up()方法生成。
任何后续的迁移都将包含一个Up()方法和一个Down()方法,分别用于升级和降级数据库。这允许开发者保存他们的数据库增量更改,以及他们的代码更改。
Entity Framework Core 的迁移是使用 DAC 和自定义脚本的替代方案。这些迁移可以根据 C#代码执行数据库更改。
如果你需要种子记录,则可以使用 Entity Framework Core 的.HasData()方法轻松为表创建种子记录。
在本节中,我们学习了如何通过始终创建备份来准备我们的数据库部署,查看添加、更新和删除表字段的一种常见策略,并学习了如何使用 DAC 或 Entity Framework Core 的迁移在 CI/CD 管道中部署数据库。
三种构建提供商类型
现在我们已经了解了标准管道的工作原理,在本节中,我们将探讨不同类型的管道提供商。
三种提供商类型是本地、离场和混合。
本地(意味着现场或本地)与您拥有的软件相关,您可以在公司的地点使用它来构建您的产品。本地构建服务的优势是,一旦您购买了软件,就拥有它;没有订阅费。因此,如果构建服务器出现问题,您可以轻松地在本地上查看软件以识别和解决问题。
离场(或云)提供商是现在更常见的服务。由于每个人都希望立即得到一切,因此设置起来更快,通常也是创建软件管道的即时方式。
如你所猜,混合服务是本地和离场服务的混合。一些公司喜欢控制软件开发的一些方面,并将工件发送到远程服务器进行部署。
虽然混合服务是一个选项,但使用离场服务进行自动化的软件构建更有意义。
在本节中,我们了解了三种提供商类型:本地、离场和混合服务。虽然这些服务在各种公司中使用,但大多数公司倾向于使用离场(或云)服务来自动化他们的软件构建。
CI/CD 提供商
在本节中,我们将回顾互联网上的一些当前提供商列表,以帮助您自动化构建。虽然还有其他提供商可用,但这些被认为是行业中开发者使用的标准。
由于我们针对 ASP.NET Core,请放心,这些提供商在其构建过程和部署中都支持 ASP.NET Core。
微软 Azure 管道
由于微软创建了 ASP.NET Core,因此提及其离场云服务是合理的。它也提供本地和混合支持。Azure 管道为 ASP.NET Core 应用程序和部署机制提供了迄今为止最自动化的支持。
虽然 Azure 被认为是世界上最大的云服务提供商之一,但我认为 Azure 管道是 Azure 品牌下的一个较小组件。
重要提示
您可以在此处了解更多关于 Azure 管道的信息:azure.microsoft.com/en-us/products/devops/pipelines/。
GitHub Actions
当微软在 2018 年 6 月收购 GitHub 后,GitHub 在同年的 10 月推出了带有 GitHub Actions 的自动化管道。
由于 GitHub 是所有与源代码相关事物的提供者,GitHub Actions 被认为是将代码部署为可部署的必然步骤。
在注册 Actions 之后,您会注意到屏幕非常“Azure 风格”,在构建软件管道时提供非常相似的界面。
重要提示
您可以在此处了解更多关于 GitHub Actions 的信息:github.com/features/actions。
Amazon CodePipeline
随着 Amazon 在电子商务领域的领先地位以及其 Amazon Web Services (AWS)提供的服务,它还为开发者提供了自动化的管道。
它的管道被分为几个类别:
-
CodeCommit:用于识别源代码存储库 -
CodeArtifact:构建工件的中心位置 -
CodeBuild:一个基于存储库更新的产品构建服务,这些更新在CodeCommit中定义。 -
CodeDeploy:用于管理软件部署的环境 -
CodePipeline:将所有内容粘合在一起
您可以根据需求选择所需的服务。Amazon CodePipeline 与大多数云服务类似,您可以使用一个服务或所有服务。
重要提示
您可以在此处了解更多关于 Amazon CodePipeline 的信息:aws.amazon.com/codepipeline/。
Google CI
最后一个云提供商非 Google CI 莫属。Google CI 也提供了执行自动化构建和部署所需的工具。
Google CI 提供类似工具,如工件注册库、源存储库、Cloud Build 以及甚至私有容器注册库。
如前所述,一旦您了解了一个云提供商的工作方式,您就会开始在其他云提供商中看到类似的提供。
重要提示
您可以在此处了解更多关于 Google CI 的信息:cloud.google.com/solutions/continuous-integration。
在本节中,我们探讨了四个 CI/CD 云提供商:Microsoft 的 Azure Pipelines、GitHub Actions、Amazon 的 CodePipeline 和 Google 的 CI。这些提供商中的任何一个都适合创建 ASP.NET Core 管道。
Azure 管道的概述
在我们讨论了所有这些之后,本节将带我们了解每个开发者都应该熟悉的:ASP.NET Core Web 应用程序的标准管道。
如果您有自己的 Web 应用程序,您将能够跟随并修改您的 Web 应用程序。
在本节中,我们将通过考虑一个示例应用程序并遍历所有将使其成为成功构建的组件来展示管道由什么组成。
准备应用程序
在我们继续前进之前,我们需要确认我们版本控制中的应用程序是否已准备好进行管道:
-
应用程序是否编译和克隆时没有错误?
-
伴随应用程序的所有单元测试都通过了吗?
-
您的应用程序中是否有正确的环境设置?(例如,
appsettings.json、appsettings.qa.json等。) -
您是否要将此应用程序部署到 Docker 容器中?如果是这样,请确认您在应用程序的根目录中有一个 Dockerfile。
再次强调,Dockerfile 是可选的,但大多数公司都包含一个,因为它们在多种操作系统上运行着多个环境。我们将把 Dockerfile 包含在我们的 Web 应用程序中,以完成此演练。
一旦在我们的清单中确认了一切,我们就可以继续前进并创建我们的管道。
介绍 Azure Pipelines
Azure Pipelines 是一个免费服务,供开发者使用来自动化、测试和将他们的软件部署到任何平台。
由于 Azure 是针对特定用户的,您必须登录到您的 Azure Pipelines 账户或在新 azure.microsoft.com/en-us/products/devops/pipelines/ 上创建一个新账户。别担心——注册和创建管道是免费的:
- 要继续此演练,请点击如图 图 2.2 所示的 免费使用 GitHub 开始 按钮:

图 2.2 – Azure Pipelines 网页
一旦您登录到 Azure Pipelines,您就可以创建一个项目了。
-
在右上角点击 新建项目。输入 项目名称 和 描述 的详细信息,并确定它是 私有 还是 公共。
-
点击 创建 后,我们需要定义在管道中使用哪个存储库。
识别存储库
我们还没有为 Azure Pipelines 指定一个存储库来使用。因此,我们需要导入一个现有的存储库:
-
如果您点击 文件 下的任何选项,您会注意到一条消息说 <在此处输入您的项目名称> 是空的。添加一些代码!。听起来像是一条很好的建议。
-
点击 导入存储库 部分下的 导入 按钮,如图 图 2.3 所示:

图 2.3 – 导入存储库
-
点击 导入 按钮将弹出一个侧面板,询问您的源代码位于何处。目前,只有 Git 和 团队基础版本控制(TFVC)。
-
由于
DefaultWebApp的代码在 Git 中,我复制了克隆 URL 并将其粘贴到文本框中,然后点击侧面板底部的 导入 按钮,如图 图 2.4 所示:

图 2.4 – 识别 Azure Pipelines 将使用的存储库
Azure Pipelines 将继续导入存储库。下一个屏幕将是大家熟悉的标准 资源管理器 视图,您的存储库左侧有树状视图,右侧是当前目录的详细文件列表。
这样,我们就已经将存储库导入到 Azure Pipelines 中了。
创建构建
现在我们已经导入了我们的存储库,Azure Pipelines 通过添加一个名为设置构建的按钮,使这个过程对我们来说变得极其简单,如图 2.5 所示:

图 2.5 – 导入的存储库,下一步是“设置构建”按钮
尽管 Azure Pipelines 的功能可能非常丰富,但仍有几个预设模板可用于构建。每个模板都针对.NET 生态系统中的特定项目,以及一些不太常见的项目:
-
对于我们的目的,我们将选择ASP.NET Core (.NET 框架)选项。
-
在我们的向导中的配置步骤之后(见顶部?),我们将进入审查步骤,在那里我们可以检查 YAML 文件。
-
话虽如此,你任何时候都可以添加任务。有显示助手可以帮助你将新任务添加到现有的 YAML 文件中。
对于 DefaultWebApp 示例,我们不需要更新我们的 YAML 文件,因为我们没有进行任何更改;这是因为我们想要创建一个非常简单的构建。默认的 YAML 文件看起来像这样:
# ASP.NET Core (.NET Framework)
# Build and test ASP.NET Core projects targeting the full .NET Framework.
# Add steps that publish symbols, save build artifacts, and more:
# https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core
trigger:
- master
pool:
vmImage: 'windows-latest'
variables:
solution: '**/*.sln'
buildPlatform: 'Any CPU'
buildConfiguration: 'Release'
steps:
- task: NuGetToolInstaller@1
- task: NuGetCommand@2
inputs:
restoreSolution: '$(solution)'
- task: VSBuild@1
inputs:
solution: '$(solution)'
msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true /p:DesktopBuildPackageLocation="$(build.artifactStagingDirectory)\WebApp.zip" /p:DeployIisAppPath="Default Web Site"'
platform: '$(buildPlatform)'
configuration: '$(buildConfiguration)'
- task: VSTest@2
inputs:
platform: '$(buildPlatform)'
configuration: '$(buildConfiguration)'
Azure Pipelines 创建的新文件被称为azure-pipelines.yml。那么,当它创建时,这个新的azure-pipelines.yml文件在哪里?它被提交到存储库的根目录。一旦我们确认 YAML 文件中的所有内容看起来都很好,我们就可以点击保存并运行按钮。
完成此操作后,将出现一个侧面板,要求你输入提交消息和可选的描述,以及指定是否直接提交到主分支或为这次提交创建新分支的选项。一旦你在侧面板底部点击保存并运行按钮,它将立即将新的 YAML 文件提交到你的存储库并执行管道。
创建工件
一旦构建开始运行,你会看到类似于 图 2.6 的内容:

图 2.6 – 排队我们的 DefaultWebApp 构建过程
如前一个屏幕截图底部所示,我的作业状态是排队。一旦它离开队列并开始执行,你可以通过点击底部蓝色时钟旁边的作业来监视构建进度。
在 DefaultWebApp 方面,构建过程看起来就像 图 2.7 中所示:

图 2.7 – DefaultWebApp 的构建进度
恭喜!你已经成功创建了一个管道和工件。
为了不写一本关于 Azure Pipelines 的整本书,接下来,我们将继续创建发布。
创建发布
完成并成功构建后,我们现在可以专注于发布我们的软件。请按照以下步骤操作:
-
如果你点击发布,你会看到我们需要创建一个新的发布管道。点击新建 管道按钮。
-
立即,您将看到一个侧边面板出现,其中列出了您可以从中选择的模板。在侧边面板的顶部选择空作业,如图图 2.8所示:

图 2.8 – 选择空作业模板
在发布中有一个术语称为阶段,您的软件可以在发送到最终阶段之前经过几个阶段。这些阶段也可以与环境同义。这些阶段包括开发、质量保证、预发布和生产。一旦一个阶段获得批准(开发),它将移动到下一个阶段(质量保证),直到最终的阶段,通常是生产。然而,这些阶段可能会变得极其复杂。
-
点击应用按钮后,您将看到一个侧边面板,您可以在其中定义您的阶段。由于我们只是部署网站,我们将称之为推送到网站阶段。
-
输入您的阶段名称(听起来并不正确)后,点击X按钮关闭侧边面板并检查管道。
如图 2.9所示,我们需要添加一个工件:

图 2.9 – 推送到网站阶段已定义,但没有工件
- 当您点击添加工件时,另一个侧边面板将滑出并要求您添加工件。由于我们在前面的子节中创建了一个工件,我们可以使用DefaultWebApp项目和源填充所有输入,如图图 2.10所示:

图 2.10 – 将 DefaultWebApp 工件添加到我们的发布管道中
- 点击添加将您的工件添加到管道中。
部署构建
一旦我们定义了我们的阶段,我们可以在每个阶段之前和之后附加某些部署条件。定义部署后批准、门控和自动重新部署触发器的功能是可能的,但默认情况下对每个阶段都是禁用的。
在任何阶段,您都可以通过点击每个阶段名称下的“x 作业,x 任务”链接来添加、编辑或删除任何您想要的任务,如图图 2.11所示:

图 2.11 – 阶段允许您添加任意数量的任务
每个阶段都有一个代理作业,可以执行任何数量的任务。可供选择的任务列表令人眼花缭乱。如果您能想到的,就有相应的任务。
例如,我们可以使用 Azure、IIS Web Deploy 或甚至简单地复制一个目录到另一个目录的文件来部署网站。想要通过 FTP 将文件传输到服务器?点击实用工具选项卡并找到FTP 上传。
您添加的每个任务都有每个主题的参数,并且可以轻松修改以满足开发者的需求。
在本节中,我们介绍了如何通过准备应用程序以满足某些要求来创建管道。我们通过登录并添加我们的示例项目、确定我们将在管道中使用的存储库,并创建构建来实现这一点。一旦完成这些,我们就找到了我们的工件,创建了发布,并找到了部署构建的方法。
摘要
在本章中,我们确定了为我们的代码准备 CI/CD 管道的方法,以便我们可以完美构建,避免在基于文件的操作中使用相对路径名称,确认我们的单元测试确实是单元测试,并为我们的应用程序创建环境设置。一旦我们的代码准备就绪,我们就检查了常见 CI/CD 管道中包含的内容,包括拉取代码、构建它、运行可选的代码分析单元测试、创建工件、将我们的代码封装在容器中,以及部署工件。
我们还介绍了两种使用回退或前向方法从失败的部署中恢复的方法。然后,我们讨论了为部署数据库做准备的一些常见方式,这包括备份您的数据、制定修改表的战略、将数据库项目添加到您的 Visual Studio 解决方案中,以及使用 Entity Framework Core 的迁移,以便您可以使用 C#修改您的表。
我们还回顾了三种 CI/CD 提供者类型:本地、远程和混合提供者,每种提供者都针对公司的特定需求,然后检查了提供完整管道服务的四个云提供者:微软的 DevOps 管道、GitHub Actions、亚马逊的 CodePipeline 和谷歌的 CI。
最后,我们学习了如何通过准备应用程序以满足某些要求来创建一个示例管道,登录到 Azure Pipelines 并定义我们的示例项目,确定我们将在管道中使用的存储库,并创建构建。一旦构建完成,它就生成了我们的工件,我们学习了如何创建发布并找到部署构建的方法。
在下一章中,我们将了解一些在 ASP.NET Core 中使用中间件的最佳方法。
第三章:中间件的最佳方法
中间件是 ASP.NET Core 中最强大的概念之一。对于传统的 ASP.NET 开发者来说,中间件是一个相对较新的术语。在中间件之前,有 HTTP 处理程序和模块,它们需要通过web.config进行单独的代码配置。现在,中间件被认为是 ASP.NET 应用程序中的一等公民,使得在单个代码库中维护它变得更加容易。中间件首次在 ASP.NET Core 1.0 中引入,常见的请求和响应概念被认为是应用程序的管道,具有控制请求和响应体的能力。这为创建 ASP.NET Core Web 应用程序的惊人功能打开了众多可能性。
在本章的开头,我们将探讨如何使用中间件以及几乎每个 ASP.NET Core 应用程序中都存在的常见内置中间件组件。接下来,我们将检查三个请求委托(Run、Map和Use)并解释在管道中每个委托的用途。我们还将介绍一些清理中间件的方法,最后将这些概念应用于构建一个简单的中间件示例。
在本章中,我们将涵盖以下主要主题:
-
使用中间件
-
中间件的常见实践
-
创建一个表情符号中间件组件
到本章结束时,您将学习中间件的工作原理,如何编写自己的中间件时使用请求委托和标准,以及如何创建自己的中间件组件。
技术要求
由于这是第一章(我们现在是编码领域,所以将有许多章节),包含技术要求,选择支持 ASP.NET Core 7.0 或更高版本和 C#代码的您最喜欢的编辑器将是理想的。我的前三款编辑器如下:
-
Visual Studio(最好是 2022 或更新的版本)
-
Visual Studio Code
-
JetBrains Rider
我们将使用的编辑器是 Visual Studio 2022 Enterprise,但任何版本(社区版或专业版)都适用于本章。
本章的代码位于 Packt Publishing 的 GitHub 仓库中,链接如下:github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices。
使用中间件
中间件是在应用程序启动时配置的软件。需要注意的是,您添加的中间件应基于您的应用程序需求。没有必要添加每个单独的组件。简化中间件管道非常重要,我们将在稍后讨论这一点。
据说,库和框架之间的区别在于,库是从您的应用程序中调用的代码,而框架是以某种方式结构化的,以便调用您的代码。这就是中间件从 ASP.NET 早期版本演变而来的。
在本节中,我们将介绍中间件管道的常见流程以及如何控制中间件组件中的操作。在本节结束时,你将了解中间件管道是如何工作的。
理解中间件管道
当你的 Web 应用程序启动时,中间件会在每个应用程序生命周期中调用和构建一次。一旦中间件组件被注册,它们将按照一定的顺序执行。在整个管道中,这个顺序很重要,因为每个中间件组件都可以依赖于之前注册的组件。
例如,在配置授权组件之前,认证组件非常重要,因为在我们确定某人能做什么之前,我们需要知道他们是谁。
在图 3.1中,我们可以看到标准中间件管道在 Web 应用程序中的组成,我们将在下一节中讨论:

图 3.1 – ASP.NET 8 Web 应用程序的标准中间件管道
这些组件都是可选的,但某些中间件组件依赖于其他组件。当用户请求一个 URL 时,第一个中间件组件会被调用。在这种情况下,它是ExceptionHandler。一旦ExceptionHandler完成,管道将移动到下一个组件,即 HSTS 组件。随着我们通过每个中间件组件前进,我们最终会到达端点。一旦端点被处理,响应将通过中间件管道以相反的顺序发送回来。
如本节开头所述,你的中间件取决于在添加额外组件时你的应用程序需要什么。如果你的应用程序是一个单页应用程序(SPA),包含 CORS、静态文件和路由中间件将非常重要。
每个中间件组件负责根据你的配置将信息传递给下一个组件,或者终止进程。如果他们决定终止管道,它们被称为终端中间件组件。它们故意停止中间件处理任何其他请求并退出管道。
使用请求代理 - 运行、使用和映射
到目前为止,我们已经讨论了这么多,你可能想知道我们是如何创建管道的。
可用的三个请求代理是Run、Use和Map扩展方法。你无疑在Program.cs代码中多次使用过它们,但三者之间有什么区别呢?
运行
Run()请求代理是严格终端中间件,这意味着它将运行并立即退出管道。它不包含next参数。它只是运行并立即终止管道。
如果我们查看以下代码,这将立即终止管道的执行:
app.Run(async context =>
{
await context.Response.WriteAsync("This will terminate the web app.");
});
注意在委托中没有引入next参数。前面的代码将消息"This will terminate the web app."写入浏览器,并立即终止管道。
使用
Use()请求委托用于在管道中链接多个请求委托。
实现适当的Use请求委托的关键是使用await next.Invoke()。next.Invoke()将按顺序执行下一个中间件组件。在此行之前的内容将在请求上处理,在此行之后的内容将在响应用户时处理。
让我们看看以下代码片段中两个匿名中间件组件的代码示例:
app.Use(async (context, next) =>
{
await context.Response.WriteAsync("In the first middleware call.\ r\n");
await context.Response.WriteAsync("Executing the next Middleware...\r\n");
await next();
await context.Response.WriteAsync("In the first middleware call…on the return trip.\r\n");
});
app.Use(async (context, next) =>
{
await context.Response.WriteAsync("We're in the second middleware call\r\n");
await next();
await context.Response.WriteAsync("On our way back from the second middleware call\r\n");
});
此代码生成以下输出:
In the first middleware call.
Executing the next Middleware...
We're in the second middleware call
On our way back from the second middleware call
In the first middleware call…on the return trip.
你会注意到在执行next.invoke()代码行之前的内容,然后执行顺序移动到下一个中间件。一旦我们到达中间件管道的末尾,我们就返回,这会执行每个中间件await next();语句之后的全部代码。
在每个中间件组件执行之后,应用程序运行并按相反顺序返回。
映射
Map()请求委托旨在根据特定的请求路径或路由分支管道。虽然这是针对特定的中间件条件,但创建一个新的映射几乎是不可能的。通常最好使用预构建的中间件组件,例如.MapRazorPages()、.MapControllers()或任何其他.MapXxxx()方法。这些方法已经设置了预定义的路由。大多数路由发生在其他扩展中,如之前提到的中间件方法。
此外,还有一个MapWhen()扩展方法,用于根据给定谓词的结果进行条件中间件分支。例如,如果你想为你的网站创建一个受控的维护页面,你可以使用一个简单的布尔值underMaintenance,并使用它来显示一条简单的消息,直到你的网站再次可用:
app.MapWhen(_ => underMaintenance, ctx =>
ctx.Run(async context =>
{
await context.Response
.WriteAsync("We are currently under maintenance.");
})
);
在前面的代码中,我们添加了.MapWhen()委托来使用特定的布尔值来识别我们是否处于维护状态。注意我们使用.Run委托,因为我们不想继续中间件管道的任何进一步操作。这种方法只是中间件灵活性的一个示例。
使用内置中间件组件
虽然你可以创建自己的中间件组件,但最好的方法是查看是否已经存在大量内置组件中的一个中间件组件。整个列表位于learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-7.0#built-in-middleware。此图表提供了每个中间件组件的描述以及在中间件管道中放置的位置。除了内置组件之外,还可以使用 NuGet 查找创新的中间件组件。
在本节中,我们介绍了中间件管道,学习了如何使用请求代理以及每个请求代理可以做什么,还了解了所有可用于 ASP.NET Web 应用程序的内置中间件组件。在下一节中,我们将检查使用中间件的常见做法。
中间件的常见做法
在本节中,我们将回顾在编写自己的中间件时的一些常见做法,以确保你的 Web 应用程序中一切运行得尽可能优化。让我们开始吧!
延迟异步操作
当与中间件一起工作时,我们希望获得尽可能好的性能,以便我们的用户可以开始使用应用程序。随着更多用户继续使用应用程序,性能可能会受到影响。
同步操作是指代码执行,应用程序必须等待其完成,这意味着它是单线程的,在应用程序的主线程上运行,但当异步操作执行时,它会创建一个新的线程,并让框架知道在处理完成后调用什么。这通过async/await关键字表示。
对于大多数中间件操作,当适用时最好使用异步调用。这将提高中间件(和应用程序)的性能,同时提供更好的可伸缩性和响应性。
优先级排序
设置你的中间件时,一个非常重要的点是确认所有内容都处于正确的顺序。
将你的应用程序需求与之前的图表进行比较,以确定你需要哪些中间件组件以及它们在 Web 应用程序中的正确顺序。
例如,如果你想包含一个 W3C 日志中间件组件(该组件包含在 Microsoft 提供的内置中间件组件中),它必须位于管道的起始位置,以便记录应用程序中发出的任何后续请求。每个组件在管道中都有其位置。
合并现有的中间件
当你创建一个新的 ASP.NET 项目时,你会在Program.cs中注意到列出的app.UseXxx()集合。虽然这是准备你的管道的“开箱即用”方法,但还有其他方法可以组织和注册应用程序的组件。
一种方法是根据你逻辑上如何将使用情况划分为相似的分组,同时保持组件的相同顺序来使用扩展方法。
一个例子是将所有客户端中间件移动到其自己的扩展方法.UseClientOptions()中:
public static class WebApplicationExtensions
{
public static void UseClientOptions(this WebApplication app)
{
app.UseHttpsRedirection();
app.UseStaticFiles();
}
}
现在,你的Program.cs文件中只包含一行代码,你确切地知道扩展方法的作用:
app.UseClientOptions();
当使用这种方法时,你的Program.cs文件会更加整洁、易于维护,并且包含更少的代码行。
其他可能的分区区域如下:
-
UseDataXxxxx()– 应用连接字符串的集中位置 -
UseMapping()/UseRouting()– 为你的应用和 API 创建一组路由 -
RegisterDependencyInjection()– 将类集中到一系列类似于这种分组方法的扩展方法中,但按应用中的部分进行分区 – 例如,RegisterDIPayroll()用于注册与应用工资部分相关的类
虽然这只是一些建议,但其概念是缩减Program.cs文件的大小,以便其他开发者能够通过更少的代码行数理解这种方法,并且为其他开发者提供足够的清晰度,以便进一步扩展技术。
作为建议,首先包含所有重要的中间件组件,并确认应用按预期运行,然后通过创建你的合并组进行重构。记住,中间件组件的顺序很重要。
封装你的中间件
当你创建你的第一个中间件组件时,你可能会有这样的冲动:创建它并以这种方式使用:
app.Use(async (context, next) =>
{
app.Logger.LogInformation("In our custom Middleware...");
// Prepare work for when we write to the Response
await next();
// work that happens when we DO write to the response.
});
这种方法的一个问题是,如果有很多自定义中间件组件,前面的代码可能会使你的Program.cs文件看起来有些杂乱。
一旦你的自定义组件开始工作,最好是将其封装到自己的类中以提高重用性。如果我们使用之前的例子,我们的新类将看起来像这样:
public class MyFirstMiddleware
{
private readonly ILogger _logger;
private readonly RequestDelegate _next;
public MyFirstMiddleware(ILogger logger, RequestDelegate next)
{
_logger = logger;
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
_logger.LogInformation("In our custom Middleware...");
// Prepare work for when we write to the Response
await _next(context);
// work that happens when we DO write to the response.
}
}
在这个例子中,MyFirstMiddleware组件是一个简单的类,它只能包含一个Invoke或InvokeAsync方法。如前所述,我们将使用InvokeAsync异步方法。
如果你想知道ILogger是如何传递的,ASP.NET Core 有一系列类自动注册到其开箱即用的依赖注入库中。ILogger就是其中之一,所以我们不需要担心将其传递到我们的MyFirstMiddleware组件中。
我们可以在Program.cs文件中使用我们的类,如下所示:
app.UseMiddleware<MyFirstMiddleware>();
然而,由于我们是优秀的 ASP.NET 开发者,我们当然可以改进代码。大多数中间件组件都有附加的扩展方法来简化它们的使用(我们现在将添加以下代码):
public static class MyFirstMiddlewareExtensions
{
public static IApplicationBuilder UseMyFirstMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<MyFirstMiddleware>();
}
}
现在的Program.cs文件更加简单和整洁:
app.UseMyFirstMiddleware();
这些简单的实践使得开发者在重用和封装方面的生活更加轻松。
在本节中,我们通过使用异步调用、优先考虑组件的顺序以及将现有的中间件合并到扩展方法中,介绍了一系列编写可维护和高效中间件的标准方法。我们还探讨了如何通过创建类和扩展方法来封装组件,使代码更容易阅读。
创建 Emoji 中间件组件
随着表情符号(对不起,是 emoji)在 2000 年代的兴起,许多遗留网站使用老式的基于文本的表情符号而不是更现代的 emoji。遗留的内容管理系统(CMS)在其内容中必须有很多这些基于文本的字符。要将网站的内容更新为用适当的 emoji 替换所有这些表情符号,听起来非常耗时。
在本节中,我们将应用我们的标准来创建一个 emoji 中间件组件,其中如果检测到基于文本的表情符号,它将将其转换为更现代的 emoji。
封装中间件
使用这个新的中间件组件,我们希望在EmojiMiddleware.cs中创建它自己的类。
这里是我们组件的第一个草稿:
public class EmojiMiddleware
{
private readonly ILogger _logger;
private readonly RequestDelegate _next;
public EmojiMiddleware(ILogger logger, RequestDelegate next)
{
_logger = logger;
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
}
}
public static class EmojiMiddlewareExtensions
{
public static IApplicationBuilder UseEmojiMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<EmojiMiddleware>();
}
}
虽然这并不非常令人兴奋,但这个样板代码符合之前提到的构建中间件组件的所有标准,包括以下内容:
-
封装的中间件组件
-
使用异步方法(
InvokeAsync()) -
用于重用和可读性的扩展方法
我们现在可以专注于转换过程。
检查组件的管道
在中间件中,有两种处理请求和响应的方式:使用流或管道。虽然管道是高性能的更好选择,但我们将专注于我们的EmojiMiddleware流。我们将在后面的章节中探讨管道。
我们的中间件流位于HttpContext的HttpRequest.Body和HttpResponse.Body中。在我们的Invoke方法中,我们方便地传入一个HttpContext。
我们的首要任务是创建EmojiStream。这将接受一个简单的响应流并将其读入内存。一旦我们有了 HTML,我们就可以搜索和替换我们的表情符号。我们需要一个映射来识别基于文本的字符以及在我们的 HTML 中用哪个图像来替换它们。
为了让我们的工作更轻松一些,我们将继承自Stream基类,并简单地重写特定方法以满足我们的需求。我们的EmojiStream类只需要实现基于文本的表情符号到表情的映射和.Write()方法,如下面的代码所示:
public class EmojiStream: Stream
{
private readonly Stream _responseStream;
private readonly Dictionary<string, string> _map = new()
{
{ ":-)", " :) " },
{ ":)", " :) " },
{ ";-)", " ;) " }
};
public EmojiStream(Stream responseStream)
{
ArgumentNullException.ThrowIfNull(responseStream);
_responseStream = responseStream;
}
public override bool CanRead => _responseStream.CanRead;
public override bool CanSeek => _responseStream.CanSeek;
public override bool CanWrite => _responseStream.CanWrite;
public override long Length => _responseStream.Length;
public override long Position
{
get => _responseStream.Position;
set => _responseStream.Position = value;
}
public override void Flush()
{
_responseStream.Flush();
}
public override int Read(byte[] buffer, int offset, int count)
{
return _responseStream.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
return _responseStream.Seek(offset, origin);
}
public override void SetLength(long value)
{
_responseStream.SetLength(value);
}
public override void Write(byte[] buffer, int offset, int count)
{
var html = Encoding.UTF8.GetString(buffer, offset, count);
foreach (var emoticon in _map)
{
if (!html.Contains(emoticon.Key)) continue;
html = html.Replace(emoticon.Key, emoticon.Value);
}
buffer = Encoding.UTF8.GetBytes(html);
_responseStream.WriteAsync(buffer, 0, buffer.Length);
}
}
在代码的开始处,我们创建了一个映射,用于在 HTML 中查找表情符号。EmojiStream类相当常见,除了WriteAsync()方法。我们将使用GetString()方法获取 HTML 并搜索响应中的每个表情符号。如果我们找到它,我们将用图像标签替换它,最后将字节写回流中。
由于我们专注于在中间件中使用流,我们将流传递给构造函数而不是创建一个新的实例。
在中间件部分完成后,我们可以在我们的类中使用 EmojiStream:
public class EmojiMiddleware
{
private readonly RequestDelegate _next;
public EmojiMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
using var buffer = new MemoryStream();
// Replace the context response with our buffer
var stream = context.Response.Body;
context.Response.Body = buffer;
// Invoke the rest of the pipeline
// if there are any other middleware components
await _next(context);
// Reset and read out the contents
buffer.Seek(0, SeekOrigin.Begin);
// Adjust the response stream to include our images.
var emojiStream = new EmojiStream(stream);
// Reset the stream again
buffer.Seek(0, SeekOrigin.Begin);
// Copy our content to the original stream and put it back
await buffer.CopyToAsync(emojiStream);
context.Response.Body = emojiStream;
}
}
虽然我们的中间件组件接受一个简单的 RequestDelegate,但组件的大部分代码都在 InvokeAsync() 方法中。首先,我们为我们的响应创建一个新的流。接下来,我们用我们自己的流替换标准响应。当我们从端点返回时,我们创建我们的 EmojiStream 实例并将我们的自定义流传递给 Response.Body。
由于 HttpContext 将 HttpRequest.Body 和 HttpResponse.Body 作为流公开,因此将 HttpContext 传递到自定义中间件组件中更容易。
当然,我们不能忘记我们的扩展方法,它在这里显示:
public static class EmojiMiddlewareExtensions
{
public static IApplicationBuilder UseEmojiMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<EmojiMiddleware>();
}
}
这个扩展方法被视为一个外观,用于隐藏我们的 EmojiStream 在幕后执行的具体细节。虽然我们可以在 Program.cs 文件中使用 builder.UseMiddleware<EmojiMiddleware>() 语法,但扩展方法使其看起来更专业。
最后需要做的是将 EmojiMiddleware 添加到 Program.cs 文件中的管道:
app.UseEmojiMiddleware();
在创建一个新的 ASP.NET Core 网站之后,我们将在 Index 页面的底部添加以下 HTML:
<div class="text-center">
<h2>Smile, you're on candid camera. :-) :)</h2>
<p>It even works inside ;-) a paragraph.</p>
</div>
当我们运行应用程序而不使用我们的中间件组件时,我们得到以下输出(图 3.2):

图 3.2 – 在我们的 EmojiMiddleware 添加到管道之前
当我们将我们的 Emoji 中间件添加到管道并再次运行我们的应用程序时,我们收到以下输出(图 3.3):

图 3.3 – 在我们的 EmojiMiddleware 添加到管道之后
在本节中,我们通过将逻辑封装在类中来构建我们的第一个中间件组件,使用流检查了组件管道,并在 web 应用程序中使用了中间件组件。
摘要
在本章中,我们深入理解了中间件管道、其组件以及在使用 ASP.NET Core 中间件的最佳实践。我们还学习了使用中间件时的常见做法,例如始终使用异步方法、优先考虑顺序、将你的中间件组件组合成组,并将你的中间件封装成类。了解这些中间件概念对于创建可维护和可读的代码至关重要。
最后,我们通过创建一个简单的组件来替换流中的文本为图像来结束本章。
在下一章中,我们将开始探讨将安全性应用于新和现有应用程序。
第四章:从一开始就应用安全
除了性能之外,在构建网络应用程序时,安全始终应该是首要任务。在互联网威胁不断演变的背景下,如跨站脚本(XSS)和注入技术,创建安全网络应用程序的能力仍然是一个问题。尽管最好的开发者可以保护应用程序免受最严重的威胁,但大多数攻击是通过人为交互和环境问题成功的。开发者保护其应用程序的最佳方法是从零开始,并尽可能多地设置障碍,以阻止最警觉的攻击者访问他们的系统。
首先,我们将学习如何识别高度敏感数据以及如何确保访问安全。然后,我们将继续讨论常见的安全实践,并提供各种 ASP.NET Core 特性,你可以将这些特性应用到你的应用程序中。最后,我们将回顾根据开放式全球应用程序安全项目(OWASP)评定的前三大安全威胁,以及如何保护你的应用程序。
在本章中,我们将涵盖以下主要主题:
-
开发安全
-
常见安全实践
-
防御前三大安全威胁
到本章结束时,你将了解什么被认为是敏感数据,行业中的各种常见安全实践,以及根据 OWASP 基金会如何保护自己免受前三大威胁。
技术要求
尽管我们将在本章讨论安全,但大部分讨论将包含你可以包含到你的项目中的小段代码。理解开发者级别的安全基本要素不需要访问代码编辑器。
本章的代码文件可以在以下位置找到:https://github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices。
开发安全
在本节中,我们将检查有关如何识别需要保护的数据的术语和概念,并解释三种极其重要的保护网站的方法。
太频繁地,当开发者开始构建一个 ASP.NET 网络项目时,安全通常是在项目结束时才被应用,而不是主动地意识到安全措施。实现安全的一种方法是在你的应用程序中检查,并寻找以下这些高度敏感的数据:
-
名称和位置
-
用户名和密码
-
联系信息(电话号码、电子邮件地址等)
-
社会安全号码
-
财务信息(客户计划、信用卡等)
-
数据库连接
-
自定义设置
根据网络应用程序的目的,可能还会涉及其他类型,例如对权限隐含的特定区域的访问。根据行业或甚至政府法规,某些类型的数据可能被认为是敏感的。
应根据以下章节中讨论的标准来检查应用程序中的安全。
我是否有任何敏感数据需要保护?
根据您应用程序和上一节中的列表,问问自己:“如果任何数据泄露并公开,会出现问题吗?”
从上述来源暴露任何数据将是一场灾难。使用加密、访问控制和安全的编码实践来保持敏感信息的安全,并且仅在必要时使用。
我是否通过应用程序暴露了任何内容?
当从一个网页切换到另一个网页时,我在浏览网站时是否传递了敏感信息?应用程序是否在 URL 中使用主键?数据是如何传递到下一页的?
注意用户可见的线索,这些线索包含诸如主键或敏感信息等信息。我们将在本章后面讨论这一点。
我是否清理了用户输入?
当从用户那里请求输入时,始终是一个好习惯对数据进行清理。清理,或称为擦洗,是获取用户输入并确认它不是可能损害系统的恶意内容的过程。一种哲学是永远不要信任提交的数据。
在客户端使用 JavaScript/HTML 进行轻量级验证,同时在服务器端进行大量验证和数据清理,这一点极其重要。
轻量级验证包括确保所需字段已填充,并包含最小和最大数据长度,以及某些字段符合特定格式(如电话号码、信用卡等)。
重量级验证将重申轻量级验证,但也会确认各种场景,例如用户有权访问某些内容,引用的实体存在,或数据伪装以造成恶意活动。
保护访问
在构建网站时,最好考虑用户是否需要登录到您的网站。如果您正在创建一个博客,用户不需要登录就可以查看帖子。他们只需匿名查看即可。
然而,如果您要求用户登录到您的网站,您必须了解至少三个强制性的要求,才能开始保护您的应用程序。我们将在接下来的章节中探讨它们。
验证
当验证用户时,您在用户登录到您的系统时正在识别和验证他们的身份。
这是微软身份框架的核心概念。它提供了各种方法来验证用户,无论是使用用户名/密码,还是使用第三方社交网络(如 Facebook、Google 或 Twitter),使用双因素认证(2FA),甚至使用第三方验证器。
您可能已经在网站上体验过这种情况,您需要输入用户名和密码。这是验证用户的第一步。一旦验证通过,您将被要求输入发送到您的电子邮件或手机上的验证器应用程序的代码。这是第二步。
许多网站使用用户名和密码进行登录。虽然这是保护网站的最基本方法,但在验证用户时实施额外的安全措施可能会有所帮助。
再次强调,创建尽可能多的额外障碍来保护你的应用程序免受攻击者侵害是一个更好的方法。障碍越多,你的网站被破坏的可能性就越小。
授权
一旦用户完成身份验证,他们可以在系统中做什么?这就是授权介入的地方。
授权是在系统或网站上允许做某事的进程。例如,博客的作者在登录时可以更新他们的文章,但除非管理员授权,否则他们不允许编辑其他文章。为了实现这一点,需要一个授权系统。
如在身份验证部分所述,Microsoft 的 Identity 框架包含各种技术,用于在整个系统中实现基于角色和基于用户的声明。在我们之前的例子中,我们提到作者只能更新他们自己的文章。在一个基于角色的系统中,作者可以被分组到一个“作者”角色中,允许他们创建和更新他们自己的文章。在一个基于用户的系统中,可以在用户级别分配特殊权限,例如编辑其他作者的文章。
虽然 Microsoft Identity 足够灵活,可以整合任何类型的授权机制,但开发者在编写代码之前应该从一开始就考虑如何构建应用程序级别的授权。
当你确定登录用户可以在你的网站上做什么(以及不能做什么)时,授权非常重要。
安全套接字层(SSL)
如果你正在构建一个网站,SSL 是绝对必需的。
拥有启用 SSL 的网站是必要的,原因如下:
-
你想让你的访客知道他们在一个安全的网站上。
-
它防止同一网络上的其他人查看你的登录凭证。
-
HTTPS 有助于防止中间人攻击(MITM),攻击者将自己插入两个用户之间的对话中,可能会更改数据交换。
-
搜索引擎优化(SEO)。谷歌和其他搜索引擎将 HTTPS 作为排名信号(参考:https://developers.google.com/search/blog/2014/08/https-as-ranking-signal)。如果你想增加你的网站在搜索结果中达到第一名的机会,你应该让你的网站启用 SSL。
大多数托管公司免费为你的网站提供 SSL 证书。这就是 SSL 对网站的重要性。
在本节中,我们确定了被认为是敏感数据的内容,并了解了在构建 ASP.NET Core 应用程序时如何使用三个关键概念来确保访问安全。
在下一节中,我们将回顾一些你可以立即在你的应用程序中开始使用的常见安全实践。
常见的安全实践
作为一名开发者,安全有时似乎是一个黑盒子。你总是听到网站被黑客攻击的事件,但你自己可能会想,“那不可能发生在我身上,”直到它真的发生。当你亲眼目睹你构建的网站被攻击时,这是一种令人谦卑的经历。
尽管我们即将介绍的技巧只是对 ASP.NET 网站表面的触及,但它们鼓励开发者在其编码中更加主动,而不是等到发现被黑客攻击后立即变得被动。
在本节中,我们将回顾行业内常见的安全实践,你可以使用这些实践来保护自己,以便你知道你的系统在做什么,并且不会向世界暴露太多。我们将了解不同类型的日志、如何更新库和框架以及如何删除头部信息。我们将通过学习如何加密 Entity Framework Core 数据库列来结束本章。
记录
在你创建网站之后,在你可以为所有人铺上红地毯之前,还需要一些额外的功能。
你如何知道你的网站发生了什么?当有人删除帖子时,你会怎么知道?关于交易呢?你的 Web API 完成整个数据呈现请求需要多长时间?这些问题应该通过创建审计跟踪并启用应用程序的一般记录来回答。
审计跟踪是一种日志类型,你可以跟踪用户在你的系统中执行的每一个操作。Microsoft Identity 应该已经内置了用于在应用程序中散布的日志代码。
IIS 日志是一种审计跟踪。每个访问你系统的用户,包括匿名用户,都会通过 IIS 进行记录。这里显示了一个简单的日志条目:
192.168.15.11, -, 01/01/22, 7:55:20, W3SVC2, -, 182.15.22.90, 4502, 163, 3223, 200, 0, GET, /Index, -,
审计跟踪中使用的标准数据将包含以下内容:
-
日期/时间
-
IP 地址/端口
-
URL
-
执行的操作
-
执行操作的用戶
-
执行操作前后的实体状态(可选)
一般记录通常在应用层面而不是系统层面进行。大多数一般记录包括如下数据:
-
日期/时间
-
URL
-
日志类型(信息性、警告或错误)
-
关于操作的评论
-
执行操作的方 法/操作/部分名称
-
过程持续时间(可选)
这些类型的日志在 API 世界中至关重要。这些日志由开发者创建并存储在磁盘或数据库中。一旦你创建了一个 Web API,你可能会想知道它在做什么,以及为什么它需要这么长时间来完成一个请求。日志是了解系统的窗口。那里到底发生了什么?
当涉及到安全时,你的日志就是金子。如果有人冒充其他用户,你可以立即检查日志,识别用户和 IP,并采取必要的措施防止其再次发生。这可以通过重置密码、断开或禁用用户登录,甚至从系统中删除用户来实现。
没有日志记录,你将无法了解系统中发生的事件。
保持您的框架和库最新。
每个开发者都有自己的库和框架。在使用.NET 时,有时框架需要更新以防止可能的安全威胁。
一旦你意识到这些安全更新,就有责任更新框架和/或库,或者通知某人可以执行更新(如果开发者不允许更新服务器),以防止基于更新漏洞的任何类型的安全威胁。
在我的职业生涯中,有两个.NET 版本存在安全问题,并发布了安全更新以应用于框架。补丁没有立即应用。两周后,发生了安全漏洞,结论是如果两周前应用了补丁,漏洞本可以预防。
对公司来说,那是一个糟糕的日子。
要查看.NET 是否有安全补丁,请参考微软更新目录,网址为 https://www.catalog.update.microsoft.com/home.aspx。
总是强制使用 SSL。
如果访客通过 HTTP URL 而不是 HTTPS 到达,最好将他们重定向到您网站的加密部分。
HTTP 严格传输安全协议(HSTS)是通过响应头由 Web 应用指定的安全增强功能。当浏览器接收到 HSTS 头时,它阻止用户使用不受信任或无效的证书。
然而,使用它有一些限制:
-
现代客户端必须支持 HSTS。
-
HSTS 必须建立一个 HTTPS 连接来建立 HSTS 策略。
-
Web 应用必须检查每个 HTTP 请求,并重定向或拒绝 HTTP 请求。
要在代码中实现这一点,你必须重新检查中间件,并将 HSTS 扩展添加到生产环境中。如果你刚刚创建了一个新的 Web 应用,这将默认自动添加。以下是一个示例:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
app.UseHttpsRedirection() 方法必须在 app.UseHsts() 扩展方法之后出现,以确保用户将访问一个启用了 SSL 的网站。
永远不要信任客户端。
我总是将这种方法比作古老的谚语,“腰间系上背带,再系上皮带。”至少你不会被发现裤子掉下来(我将其用作安全隐喻)。
如本章开头所述,这里的意图是尽可能使用 JavaScript 和 HTML 验证和清理客户端提交的数据,然后在表单提交时使用 C#进行额外的验证。
例如,HTML 5 现在几乎在所有浏览器中都可用,能够将某些类型应用于文本输入,例如 type="number" 或 type="date"。这个输入类型的集合中一个受欢迎的补充是能够添加正则表达式模式,使客户端侧的验证更加容易:
<input type="text"
placeholder="Enter a Columbus Phone Number"
title="Enter either a 740 or 614 area code using this format: (740) 999-9999"
pattern="^\(?(740|614)\)?(\s+)?[0-9]{3}-?[0-9]{4}$"
required />
此模式允许电话号码中包含 740 或 614 区号。如果模式不匹配,你将收到一个提示信息,说明为什么它无效:

图 4.1 – 输入验证失败的影响
然而,这并不能证明在服务器上忽略验证是合理的。对于用户输入数据的每个字段,当服务器接收到数据时,都应该应用相同的验证努力。
总是编码用户输入
在服务器上清理用户输入的最简单方法之一是编码。
如果用户输入的数据将在任何时间显示在页面上,最好对数据进行编码以防止 XSS 攻击。编码用户输入的最简单方法是将HtmlEncoder依赖注入到方法中以执行编码,如下面的代码片段所示:
public async Task<IActionResult> OnGet(
[FromServices] HtmlEncoder htmlEncoder,
string q = "")
{
PageResults = await PerformTheSearch(htmlEncoder.Encode(q));
return Page();
}
.NET 已经定义了各种可注入的服务。HtmlEncoder就是其中之一,并且可以通过添加[FromServices]属性自动注入。一旦我们有了编码器,我们就可以对传入的字符串进行编码,并安全地执行请求的操作。
在本节中,你学习了如何从客户端编码用户输入,使你的网站免受恶意数据的影响。
在下一节中,你将学习如何隐藏服务器向世界传达的信息,以及如何创建可重用的中间件组件。
保护你的头信息
默认情况下,HTTP 请求会添加几个头信息以识别服务器、使用的版本、所使用的技术堆栈以及支撑网站的技术。虽然这些默认头信息很有帮助,但其中一些不是必需的,而另一些可以使你的网站更加安全。
在本节中,我们将专注于通过 ASP.NET 的中间件来保护推荐的头部更改。
移除服务器头信息
通常情况下,向世界宣布你正在运行的服务器和版本并不是一个好主意,尤其是对于一个匿名用户来说。这样做会暴露你正在运行的 Web 服务器类型,并允许攻击者找到针对 IIS 的特定技术来访问你的 Web 服务器。
在 ASP.NET 中,你可以在Program.cs中禁用 Kestrel(ASP.NET 中使用的开源服务器)的服务器头:
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel(options => options.AddServerHeader = false);
当我们将AddServerHeader设置为false时,头信息不会显示服务器类型和版本。
除了服务器头之外,我们还需要移除X-Powered-By头,以避免暴露过多信息。这可以通过中间件实现,如下所示:
app.Use(async (context, next) =>
{
context.Response.Headers.Remove("Server");
context.Response.Headers.Remove("X-Powered-By");
await next();
});
然而,你还需要将其添加到web.config文件中,该文件应位于项目的根目录下。以下是web.config文件应存在于项目中的唯一原因:
-
压缩配置
-
移除特定的 IIS 头信息
-
自定义 MIME 映射
如果它不存在,将其添加到你的项目中,并添加以下路径以移除X-Powered-By头:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
需要移除的其他头部值包括 X-Aspnet-Version 和 X-AspnetMvc-Version。你应该移除这些头部的原因是它们提供了关于你在服务器上运行的技术详细信息的详细资料。如果有针对 ASP.NET 或 ASP.NET Core MVC 的特定安全漏洞,这些头部会使你的网站更容易被攻击者缩小攻击范围,并导致不可避免的安全事件。
要移除这两个头部,请将以下两行添加到你的 Program.cs 文件中的中间件:
context.Response.Headers.Remove("X-Aspnet-version");
context.Response.Headers.Remove("X-AspnetMvc-version");
禁止嗅探
当你在头部包含 X-Content-Type-Options 时,这告诉浏览器遵守 Content-Type 头部中注册的 MIME 类型。这些不应该被更改或遵循:
context.Response.Headers.Add("X-Content-Type-Options", new
StringValues("nosniff"));
这个标记告诉浏览器这些 MIME 类型是故意配置为避免 MIME 类型嗅探的。这有助于防止基于 MIME 类型混淆的攻击,其中非 MIME 类型可能会被视为有效的 MIME 类型。
也禁止框架使用
当浏览器看到 X-Frame-Options 头部响应时,它表示浏览器是否应该在 <frame>、<iframe>、<embed> 或 <object> 中渲染网页:
context.Response.Headers.Add("X-Frame-Options", new
StringValues("DENY"));
X-Frame-Options 头部防止点击劫持攻击,其中有人可能会使用框架、嵌入或对象将你的内容嵌入到其他网站。将此设置为 DENY 可以保护你免受此类攻击。
创建一个安全中间件组件
为了完成本节,我们将创建一个简单的中间件组件,我们可以在我们的 .NET Core Web 应用程序中重用它。
由于我们在 第三章 中创建了我们的中间件框架,我们可以重用 RemoveInsecureHeadersMiddleware 组件的代码,如下所示:
public class RemoveInsecureHeadersMiddleware
{
private readonly RequestDelegate _next;
public RemoveInsecureHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext httpContext)
{
httpContext.Response.OnStarting((state) =>
{
httpContext.Response.Headers.Remove("Server");
httpContext.Response.Headers.Remove("X-Powered-By");
httpContext.Response.Headers.Remove("X-Aspnet-version");
httpContext.Response.Headers.Remove("X-AspnetMvc- version");
httpContext.Response.Headers.Add("X-Content-Type-Options",
new StringValues("nosniff"));
httpContext.Response.Headers.Add("X-Frame-Options",
new StringValues("DENY"));
return Task.CompletedTask;
}, null!);
await _next(httpContext);
}
}
不要忘记我们的扩展方法:
public static class RemoveInsecureHeadersMiddlewareExtensions
{
public static IApplicationBuilder RemoveInsecureHeaders(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<RemoveInsecureHeadersMiddleware>();
}
}
我们可以在我们的 Program.cs 文件中使用新创建的安全扩展:
app.RemoveInsecureHeaders();
虽然我们已经添加了最明显的头部,但好消息是你可以通过添加额外的头部来更新这个组件,从而进一步提高你网站的安全性。
在本节中,你学习了如何保护你的头部并创建一个可重用的中间件组件,用于所有你的 Web 应用程序。在下一节中,你将学习如何通过加密数据、使用存储过程和使用参数化查询来保护 Entity Framework。
保护 Entity Framework Core
Entity Framework Core 是那些让我持续感到惊奇的技术之一。Entity Framework Core 每次发布的版本都提供了一些新的性能提升、改进的技术方法,或者一些其他方法来让我们的工作变得稍微容易一些。
在本节中,我们将了解如何在数据库级别加密我们的数据。
加密您的数据
对公司来说最有价值的东西之一就是数据。为了防止攻击,你可以采取的一种安全措施是在表中加密数据。
在本章的开头,我们解释了需要特别注意的数据类型,例如电话号码、电子邮件地址和信用卡数据。
最佳做法是在数据库级别应用安全,无论使用的是 SQL Server 还是类似的数据库。
SQL Server 通过使用 SQL Server Management Studio(SSMS)中的“加密列…”选项来加密特定的列,如图4.2所示:

图 4.2 – SQL Server Management Studio 中的“加密列…”选项
如果您正在使用 Entity Framework,生成 DbContext 时会考虑安全列。同样,在数据库级别创建加密时,这将是阻止攻击者访问敏感数据的另一个障碍。
在本节中,我们探讨了保护数据的最佳方法——即通过使用 SQL Server 的“加密列…”功能来加密数据。在下一节中,我们将探讨如何保护您的页面免受跨站请求伪造(XSRF)攻击。
使用 Microsoft Entra 保护应用程序
如前所述,在登录网站时,最好通过加密数据库来保护数据库免受入侵者侵害。这意味着使用现有方法而不是编写自定义加密算法。应避免创建自定义加密算法,因为大多数算法都很容易被黑客工具破解。最好使用现有的框架,如 Microsoft Identity(现在称为 Entra)。
随着 Blazor 和 SPA(单页应用程序)的流行,使用 API 保护应用程序可能会很困难。以前,通过 API 使用 Microsoft Identity 需要大量工作,这使得实现安全应用程序变得更加困难。
在最新的.NET 8 中,Microsoft Entra 为单页应用程序(SPA)的 Web 应用程序的每个安全方面引入了基于 API 的调用。在创建新应用程序时,以下代码将一个启用 Entra 的基于 REST 的 API 添加到应用程序中:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection(“AzureAd”));
builder.Services.AddAuthorization();
上述代码创建我们的 Web 应用程序,并定义了一个 JwtBearerDefault 身份验证方案,并添加了一个专门针对 Microsoft Identity 的 Web API。
如果 JWT 令牌不是选项,.NET 8 还引入了 Bearer 令牌,如下所示:
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services
.AddAuthentication()
.AddBearerToken();
builder.Services.AddAuthorization();
快速编写 API 以及简单的身份验证和授权能力,为使用 Blazor 和 SPA(单页应用程序)编写 Web 应用程序的 Web 开发者提供了更多选择。在探讨创建更好的 Web API 时,我们将在第九章中更详细地介绍 Microsoft Entra。
使用反伪造保护您的页面
跨站请求伪造,或 XSRF,是指攻击发生的地方,欺骗用户在当前已认证的 Web 应用程序中执行不受欢迎的操作。例如,用户可能会被欺骗在不知道的情况下使用他们的信用卡在不同的网站上。
为了防止通过您的 Web 表单进行 XSRF(跨站请求伪造)攻击,建议的方法是使用反伪造令牌。
要添加一些到我们的中间件中,我们将它们添加到我们的管道中,如下面的代码片段所示:
services.AddAntiforgery();
当创建 HTML 表单时,如果<form>标签包含method="post"并且以下条件之一为真,则会自动生成反伪造令牌:
-
动作属性为空(
action="") -
动作属性未提供(
<form method="post">)
如果您在表单标签中附加了其他属性,您可以在表单标签内显式添加一个名为AntiForgeryToken()的HtmlHelper:
@Html.AntiForgeryToken();
这将生成一个具有任意值的隐藏输入。如果客户端返回的值与服务器最初发送的不相同,则请求将被拒绝。
在本节中,您了解了审计跟踪和常规日志记录,如何保持您的框架和库最新,如何始终强制使用 SSL 以使您的连接安全,以及永远不要信任客户端的输入。您还了解到,每当服务器接收到用户输入时,都应该对其进行编码,如何保护您的标题,如何使用 Entity Framework Core 保护您的数据库,以及最后如何通过使用.AddAntiForgery()中间件服务来保护您的表单免受跨站请求伪造攻击。
在下一节中,我们将探讨一些现实世界的问题以及如何根据 OWASP 解决前三大威胁。
防御前三大安全威胁
开放式全球应用程序安全项目(Open Worldwide Application Security Project,简称 OWASP)是一个非营利性基金会,致力于提高软件的安全性。由于新的威胁不断出现,他们保持一个名为 OWASP Top 10 的列表,旨在使软件开发者了解最新的安全威胁以及如何预防它们。Top 10 列表包括以下安全威胁:
-
破坏的访问控制
-
密码学失败
-
注入
-
不安全的设计
-
安全配置错误
-
易受攻击和过时的组件
-
身份识别和身份验证失败
-
软件和数据完整性失败
-
安全日志记录和监控失败
-
服务器端请求伪造(SSRF)
在本节中,我们将介绍前三大威胁以及如何保护您的 ASP.NET Core 应用程序免受这些威胁——即破坏的访问控制、密码学失败和注入。
破坏的访问控制
破坏的访问控制是指用户可以在系统外部执行超出其预期权限的特定操作。软件中可能缺少权限检查,或者软件中可能没有正确检查权限。
在这里需要关注的重点关键字是授权。将用户授权进入您的系统是一项重大的责任。
让我们看看如何提高访问控制的水平。
默认拒绝访问
当有人访问网站时,请将其视为匿名用户,并限制其访问管理区域。当管理员将某人添加到系统中时,他们现在已通过身份验证,应该能够登录到系统中。
您的授权系统应该进行全面测试。即使用户被允许登录,除非管理员授权,否则他们不应能够做任何事情。
“默认拒绝”意味着当用户使用系统时,他们应该被拒绝访问,直到权限被授予。
对于 Razor Pages,您可以使用.AddRazorPages()中间件组件配置来授权某些页面和文件夹,如下面的代码片段所示:
services.AddRazorPages(options =>
{
options.Conventions.AuthorizeAreaFolder("Admin", "/Areas/Admin");
options.Conventions.AllowAnonymousToFolder("/");
});
在AddRazorPages方法中,我们只允许认证用户进入Admin区域;匿名用户只能访问网站的根目录。
对于基于控制器的页面,例如 ASP.NET MVC,您可以使用[Authorize]属性允许认证用户查看页面,如下所示:
[Authorize]
public class MySecretController : Controller
{
public ActionResult Index()
{
}
}
在前面的代码中,由于MySecretController上存在[Authorize]属性,认证用户无法访问Index页面。
如果您只想让认证用户可以访问Index页面,请将[Authorize]属性放在Index()方法上,如下所示:
[Authorize]
public ActionResult Index()
{
}
这些技术默认拒绝匿名用户,这应该是正确的做法。
避免暴露密钥
当您构建博客时,最好有一个不带帖子 ID 的 URL 作为整数。当我写博客文章时,我见过在页面公开之前我的页面上的点击。看到人们对我的最新帖子感兴趣真是太好了,但他们是如何到达那里的呢?他们会去我的博客,拉出最新帖子,然后给帖子 ID 加1。那里就是——一篇未完成的帖子,以其未完成的所有荣耀呈现。
想象一下在银行网站上出现这种情况。用户登录到他们的账户,他们看到以下 URL:https://www.bobsbank.com/view/accountid=511324。
一个好奇的用户可能会将账户 ID 加1,然后查看另一个人的账户。
避免向用户暴露账户或主键,并且如果用户猜出了账户号码,在查看之前确认认证用户是该账户的所有者。
关于损坏的访问控制的最后注意事项
这里还有一些您应该考虑的事项:
-
审计跟踪和日志是宝贵的。它们将帮助您识别用户的风险和模式。
-
通过运行单元测试和集成测试来确认您的授权系统是否正常工作。我们将在后面的章节中介绍单元和集成测试。
在本节中,我们学习了如何通过默认拒绝用户、隐藏主键以及确认用户是否有权查看特定页面来保护自己免受损坏的访问控制的影响,以及如何实现审计跟踪和日志并测试我们的授权系统。
在下一节中,我们将探讨如何防止密码学失败。
密码学失败
OWASP 将加密失败视为未加密的敏感数据、使用无效访问控制的安全措施,甚至包括过时的服务器环境,如不包含最新安全补丁的服务器。这包括使用已在 Microsoft Entra 中包含的行业标准加密算法。
以下部分详细介绍了行业中更常见的事件。
明文传输
如果你正在通过电线传输敏感数据,应该使用 SSL 连接对其进行加密。
一个普遍的规则是,客户端应该是向服务器发送敏感数据的一方,而不是反过来。
如果你需要将敏感数据发送回客户端以供批准,最好以某种方式对数据进行屏蔽以供显示(例如,使用 XXXX-XXXX-XXXX-9999 作为信用卡号)并在更新时,通过让已认证的用户重新输入密码或提供某种方式再次验证他们来确认这一点。
无效/过期 SSL 证书
一旦你的代码到达服务器,其主要任务是以尽可能快和尽可能安全的方式交付数据。
证书对于 SSL 来说,是创建具有过期日期的安全连接所必需的。应该有一些提醒或通知让管理员知道证书何时过期。不建议在网站上继续使用过期的证书。
未加密的数据库
再次强调,如果你的数据库包含敏感信息,最好采取主动措施,使用数据库推荐的加密方法对数据库进行加密。
关于加密失败的最后注意事项
让我们来看一些最后的注意事项:
-
避免使用 MD5、SHA1 或 PKCS 1 v1.5 等弱算法,这些算法很容易被破解。
-
避免将敏感数据发送到客户端。如果这是必要的,请屏蔽数据。
-
使用适当的访问密钥管理,将密钥存储在安全位置,如 Microsoft 的密钥保管库、Google 的云密钥管理或 Amazon 的密钥管理服务。
在本节中,我们学习了如何通过避免发送明文、更新过时或无效的 SSL 证书以及始终对包含敏感数据的数据库进行加密来避免加密失败。
在最后一节中,我们将探讨注入如何影响你的应用程序。
注入
2017 年,OWASP 报告称,在编写 Web 代码时,SQL 注入是最大的威胁。现在,他们的前 10 名包括仅仅是“注入”,这是一个涵盖 SQL 注入和 XSS 的通用术语。
SQL 注入
我们已经提到,你永远不应该信任客户端,并且始终清理和编码用户输入,但由于它仍然被视为一种威胁,即使它已经下降到第三位,这一点仍然值得重复。
好消息是,Entity Framework Core 支持参数化查询,可以帮助你避免 SQL 注入。然而,这并不意味着你不需要对用户输入进行清理和编码。
脚本注入
脚本注入是指有人在一个文本框中输入一个脚本标签,其值被接受并保存在数据库中。当数据在页面上显示时,脚本被触发并执行特定的操作。
这里有一个简单的扩展方法,它使用正则表达式搜索并销毁 HTML 中的恶意标签:
public static class StringExtensions
{
public static string Sanitize(this string content)
{
// Replace the malicious tags with nothing.
var maliciousTagsPattern =
@"<(applet|embed|frameset|head|noframes|noscript|object| form|select|option|script|style|title)(.*?)>"+
"((.|\n)*?)"+
"</(applet|embed|frameset|head|noframes|noscript|object| select|form|option|script|style|title)>";
var options = RegexOptions.IgnoreCase | RegexOptions. Multiline;
var regex = new Regex(maliciousTagsPattern, options);
content = regex.Replace(content, @"");
// Remove the Javascript function on the tags (i.e. OnChange="Javascript:<blah blah blah>")
var inlinePattern = @"<[^>]*=""javascript:[^""]*""[^>]*>";
options = RegexOptions.IgnoreCase;
var regex2 = new Regex(inlinePattern, options);
return regex2.Replace(content, @"");
}
}
虽然.Sanitize()扩展方法会从字符串中移除任何恶意标签,如果你传递的是 HTML 格式的文本,它也会移除任何带有 JavaScript 事件的标签(例如onclick='alert("gotcha");')。然后返回清洗后的字符串以供使用。
就像使用其他任何字符串扩展方法一样使用这个扩展方法:
var sanitizedString = inputFromUser.Sanitize();
你甚至可以进一步扩展该方法,使其包括其他安全措施,例如在返回之前对字符串进行编码。
总是验证、过滤和清洗用户输入。无论什么情况。
注射攻击的最终注意事项
这里有一些你应该考虑的最终事项:
-
你能将用户(以及有恶意意图的用户)与数据库保持的距离越远,就越好。
-
确保在单行输入上有一个
maxlength属性,以最小化可接受的字符数并限制在 HTML 输入字段中允许脚本的能力。
注射攻击持续构成一个可信的威胁,并且始终被列入 OWASP 的前 10 大风险列表。
摘要
在本章中,我们学习了如何通过理解敏感数据是什么以及如何使用身份验证、授权和启用 SSL 的连接来保护我们的代码。
在本章的第二部分,我们回顾了行业中的某些常见标准,例如日志记录、保持我们的框架和库更新,以及始终重定向到启用 SSL 的站点。之后,我们学习了永远不要信任客户端数据,我们应该验证、过滤和清洗它,并且始终编码它,不要通过添加或删除安全头信息向世界宣布我们正在运行的服务器和版本。我们甚至创建了一个可重用的安全中间件组件。
我们还提到了如何使用 SQL Server 加密数据库列,以及通过主动保护字段的重要性,以及为什么应该避免创建自定义加密算法。我们还学习了如何通过使用反伪造令牌来避免跨站请求伪造。
最后,我们检查了由 OWASP 基金会确定的三大威胁,以及如何正确保护自己免受破坏性访问控制、加密失败和所有类型的注入攻击。
在下一章中,我们将再次讨论 Entity Framework Core,并学习如何通过使用一些直观的技术来优化使用 Entity Framework Core 的数据访问。
第五章:使用 Entity Framework Core 优化数据访问
在 2008 年 Entity Framework 介绍之前,开发者使用 ActiveX 数据对象(ADOs)和 对象链接和嵌入数据库(OLE DB)来访问其应用程序的数据。自其引入以来,Entity Framework 在这些年来已经发展成为一个高性能的桥梁,连接 面向对象(OO)系统和关系数据库。它使开发者能够使用 语言集成查询(LINQ)语法,通过 C# 执行复杂的查询。然而,一些 LINQ 语句可能会让新开发者感到不知所措。由于这是数据访问中最常讨论的话题之一,我们将涵盖使用 Entity Framework Core 时各种标准和实现。
本章将与 第二章,CI/CD – 使用软件构建高质量软件 相似,我们将回顾实现 Entity Framework Core 访问数据的模式,同时还将查看一些行业中对 Entity Framework Core 的常见使用。
在本章中,我们将涵盖以下主要主题:
-
Entity Framework Core 实现
-
常见的 Entity Framework Core 实践
-
实现主题公园示例
当您阅读完本章后,您将更好地理解您可以使用 Entity Framework Core 的各种设计模式和采用的方法,以及涉及的各种标准;我们将通过将这些标准应用于主题公园示例来结束本章。
让我们开始,通过检查许多开发者使用的各种 Entity Framework Core 常见实现来入门。
技术要求
我建议使用您最喜欢的编辑器来查看 GitHub 仓库。我们的建议包括以下内容:
-
Visual Studio(最好是 2022 或更新版本)
-
Visual Studio Code
-
JetBrains Rider
我们将要使用的编辑器是 Visual Studio Enterprise 2022,但任何版本(社区版或专业版)都可以与代码一起使用。
我们还将使用 SQL Server Management Studio(SSMS)在本章末尾的示例中。但是,如果您觉得您不需要下载另一个工具,您也可以通过 Visual Studio 2022 查看 SQL Server 数据,而无需安装 SSMS。
下载 SQL Server 开发者版
要运行本地副本的 SQL Server,请从 www.microsoft.com/en-us/sql-server/sql-server-downloads 下载 SQL Server 开发者版。
本章的代码位于 Packt Publishing 的 GitHub 仓库中,您可以在此导航:github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices。
Entity Framework Core 实现
这些年来,开发者以各种方式使用了 Entity Framework Core。其中一些非常 有创意。
在本节中,我们将讨论以下架构方法:
-
仓库/工作单元
-
规格
-
扩展方法
虽然这些实现基于现实世界的经验,但它们仅仅是观察,如第一章中提到的,决定将取决于团队和/或社区成员,以及应用程序的正确方法和所承受的权衡。
仓库/工作单元
虽然这种实现已经引起了一些开发者的摩擦,但它对于早期采用 ASP.NET Entity Framework 应用程序的开发者(包括我)来说是一个常见的模式。然而,社区成员表示,由于仓库的重复,这并不是在架构上高效使用 Entity Framework 的方法。
实现
默认情况下,DbContext 遵循 仓库 和 工作单元 设计模式。
仓库设计模式是一个类,它管理业务域和数据库属性映射之间的对象,使用列表和单个域对象。
仓库在 DbContext 中是自包含的,这被认为是工作单元设计模式。工作单元模式管理一系列对象(如仓库所做的那样),使用 ChangeTracker 来跟踪事务状态中的更改,并组织每个更改应该如何保存以及如何解决并发问题。
在 Entity Framework 中,仓库以 DbSet 实例的形式表示在 DbContext 中,其中 DbContext 本身就是工作单元。
让我们来看一个例子。我们有一个名为 ThemePark 的数据库,它包含两个表:景点和位置。我们还创建了一个 ThemeParkDbContext 类来管理我们的实体。如果你在任何时候使用过 Entity Framework,你很可能遇到过以类似方式实现的仓库设计模式:
public class AttractionRepository
{
private readonly ThemeParkDbContext _context;
public AttractionRepository(ThemeParkDbContext context)
{
_context = context;
}
public List<Attraction> GetAttractions()
{
return _context.Attractions.ToList();
}
public Attraction GetAttraction(int id)
{
return _context.Attractions.FirstOrDefault(e => e.Id == id, null);
}
}
这种实现有什么问题?虽然这确实将业务规则与数据访问分离,但当 DbContext 中已经存在仓库层时,对于应用程序来说,这是一个不必要的层。
我从开发者社区中喜欢的一个笑话是,“计算机科学中最难的两个问题是什么?缓存失效、命名事物和 off-by-one 错误。”
将这视为一个命名错误的情况。如果我们将其名称更改为 Service,会怎样呢?
public class AttractionService
{
private readonly ThemeParkDbContext _context;
public AttractionService(ThemeParkDbContext context)
{
_context = context;
}
}
为什么是 Service?仓库模式已经包含在 DbContext 中的 DbSet<Attraction> 中。服务使用仓库模式来检索数据并在返回数据之前执行额外的更新。将类名从 Repository 更改为 Service 表示我们不需要在现有的仓库之上再使用仓库模式。当为单个仓库(DbSet 实例)创建服务时,这提供了以下多个好处:
-
DbContext。在DbContext上添加.Include()以检索相关实体。 -
通过构造函数传递
DbContext实例,允许采用多种方法访问数据,包括 LINQ 语句、存储过程,甚至调用原始 SQL。 -
附加处理——在调用数据访问服务时,有时数据在返回结果之前需要更多的处理。虽然在这个服务中这是暂时可接受的,但它可能需要重构到适当的企业实体或通过另一个类进行处理。
通过这种方法看到的益处可以轻松地将现有的存储库命名约定转换为服务名称。
规范模式
开发者总是寻求重用现有代码,并使其尽可能灵活以方便维护;更新一行代码可能会改变所需的数据检索。
随着测试驱动开发(TDD)的兴起,规范模式正在解决将需求附加到对象上的问题,这使得通过查询检索的结果更容易理解。它使用基类以列表或单个实体形式检索数据,同时编写最少的代码。
实现
在创建规范类时,你需要两个类:一个用于处理请求,另一个用于规范所需的内容。规范类构建得正好像其名称所暗示的那样:它定义了单个实体或实体列表的过滤器、排序和分组,以及包含各种相关实体。你可以使用 LINQ 查询做的任何事情都可以在规范类中定义。
一个示例规范(接口)的结构可能如下所示:
public interface ISpecification<T>
{
Expression<Func<T, bool>> Filter { get; }
Expression<Func<T, object>> OrderBy { get; }
Expression<Func<T, object>> OrderByDescending { get; }
List<Expression<Func<T, object>>> Includes { get; }
Expression<Func<T, object>> GroupBy { get; }
}
Specification类的实现可能如下所示:
public class Specification<TEntity>: ISpecification<TEntity> where TEntity: class
{
public Expression<Func<TEntity, bool>> Filter { get; }
public Expression<Func<TEntity, object>> OrderBy { get; set; } = null!;
public Expression<Func<TEntity, object>> OrderByDescending { get; set; } = null!;
public Expression<Func<TEntity, object>> GroupBy { get; set; } = null!;
public List<Expression<Func<TEntity, object>>> Includes { get; } = null!;
public Specification(Expression<Func<TEntity, bool>> filter)
{
Filter = filter;
}
}
如本节开头所述,类中的属性代表检索数据的各个方面:过滤、包含、排序和分组。
一旦定义了规范,我们需要一种基于规范构建查询的方法。在这种情况下,我们将创建一个SpecificationBuilder<T>类:
public static class SpecificationBuilder<TEntity> where TEntity: class
{
public static IQueryable<TEntity> GetQuery(IQueryable<TEntity> inputQuery,
ISpecification<TEntity> specification)
{
var query = inputQuery;
if (specification == null)
{
return query;
}
if (specification.Filter != null)
{
query = query.Where(specification.Filter);
}
if (specification.Includes != null
&& specification.Includes.Any())
{
foreach (var include in specification.Includes)
{
query = query.Include(include);
}
}
if (specification.OrderBy != null)
{
query = query
.OrderBy(specification.OrderBy);
}
else if (specification.OrderByDescending != null)
{
query = query
.OrderByDescending(specification.OrderByDescending);
}
if (specification.GroupBy != null)
{
query = query
.GroupBy(specification.GroupBy)
.SelectMany(x => x);
}
return query;
}
}
在前面的代码片段中,我们的SpecificationBuilder类创建了一个 LINQ 查询来检索数据。由于一切都是自包含的,并且完全独立于自身,因此该类被标记为静态。
对于每个规范类,我们需要一种检索数据的方法。我们将使该类抽象并命名为BaseSpecificationService<TEntity>:
public abstract class BaseSpecificationService<TEntity> where TEntity : class
{
private readonly ThemeParkDbContext _context;
protected BaseSpecificationService(ThemeParkDbContext context)
{
_context = context;
}
protected ISpecification<TEntity> Specification { get; set; } = null!;
protected IQueryable<TEntity> GetQuery()
{
return SpecificationBuilder<TEntity>
.GetQuery(_context.Set<TEntity>().AsQueryable(), Specification);
}
}
在前面的代码中,BaseSpecificationService是我们将用来创建特定数据需求的。我们需要一个规范属性以及根据该规范检索查询的方法。
使用规范模式,你的类名就是所需数据的规范。
让我们看看另一个使用简单的Product类的示例:
public class Product
{
public string Name { get; private set; }
public int Price { get; private set; }
}
如果你需要一个所有产品价格低于$5.00 的列表,一个规范类可能看起来像以下这样:
public class GetProductsLessThanFiveDollars : BaseSpecificationService<Product>
{
public GetProductsLessThanFiveDollars(InventoryDbContext context) : base(context)
{
Specification = new Specification<Product>(product => product.Price <= 5);
}
}
此代码创建规范并使用它来检索结果:
var productsBelowFiveDollarsSpecification = new GetProductsLessThanFiveDollars(_context);
var results = productsBelowFiveDollarsSpecification.GetQuery().ToList();
上述代码将生成一个低于 5 美元的产品列表。
虽然这是一个简单的例子,但还有更多致力于此类 Entity Framework 设计模式的广泛库,例如位于 specification.ardalis.com 的 Steve Smith 的规范库。
扩展方法
如果我们回顾仓库/单位工作方法,特定业务逻辑与数据之间的关联应该相对接近 DbContext。为什么不直接将数据访问附加到实际的 DbSet 实例本身作为 IQueryable 扩展方法,而不是创建传递 DbContext 的服务类呢?
能够向 DbContext 或 DbSet 实例添加特定调用非常吸引人,因为它们可以放置在你的项目的任何位置。
扩展方法方法在特定实体方面确实需要一定的纪律性。例如,如果你创建了一个产品实体,你的 IQueryable 扩展方法应该只返回产品,而不是订单实体。将订单扩展方法与产品扩展方法混合通常是不受欢迎的。
实现
扩展方法允许你的代码在访问数据时更加直接。
由于我们可以将扩展方法附加到接口,让我们为我们的 ThemeParkDbContext 类定义一个简单的接口,如下所示:
public interface IThemeParkDbContext
{
DbSet<Attraction> Attractions { get; set; }
DbSet<Location> Locations { get; set; }
DbSet<TEntity> Set<TEntity>() where TEntity : class;
DatabaseFacade Database { get; }
}
在我们的主题公园想法中,我们为数据访问创建了 AttractionExtensions 和 LocationExtensions 文件,如下所示:
public static class AttractionExtensions
{
public static List<Attraction> GetAttractions(this IThemeParkDbContext context)
{
return context.Attractions.ToList();
}
public static Attraction GetAttraction(this IThemeParkDbContext context, int id)
{
return context.Attractions
.Include(t => t.Location)
.FirstOrDefault(e => e!.Id == id, null)!;
}
}
我们的 AttractionExtensions 文件中只有两个方法,GetAttractions() 和 GetAttraction(),我们将它们附加到 ThemeParkDbContext 类。
我们的 LocationExtensions 文件同样小巧紧凑,如下所示:
public static class LocationExtensions
{
public static List<Location> GetLocations(this IThemeParkDbContext context)
{
return context.Locations.ToList();
}
public static Location GetLocation(this IThemeParkDbContext context, int id)
{
return context.Locations.FirstOrDefault(e => e!.Id == id, null)!;
}
}
在本节中,我们回顾了一些在现实世界应用程序中使用的更常见的 Entity Framework Core 设计模式,例如仓库和单位工作模式、规范模式和访问数据的扩展方法方法。
虽然这些在 .NET 社区中很常见,但还有其他模式可用于 Entity Framework Core,允许更容易地访问你的数据,但它们确实有缺点。让我们更仔细地看看这些:
-
应用程序中的每个分区功能都会存在一个
DbContext实例;例如,一个DbContext实例用于Books和BookAuthors表,另一个DbContext实例用于Orders和Books表。一个可能的缺点是多个DbContext实例之间可能存在状态冲突(不推荐)。 -
在不使用 LINQ 的情况下直接调用存储过程时,将
DbContext实例作为通道。一个缺点是,当对存储过程进行更改且映射代码没有反映返回的结果时,会导致错误。
在下一节中,我们将介绍在现实世界应用程序中使用 Entity Framework Core 的常见实践。
常见的 Entity Framework Core 实践
尽管 Entity Framework 模式为您的代码提供了额外的结构,但在使用 Entity Framework 构建应用程序时,还有一些常见的实践需要牢记。
在本节中,我们将回顾 Entity Framework 的一些更常见的用法及其好处:async/await如何使您的应用程序更具可扩展性,记录查询以优化 SQL 输出,创建资源文件以保存表的种子数据,了解延迟执行,使用名为 .AsNoTracking() 的只读方法来加快访问速度,在合理的地方利用数据库,以及使用 AutoMapper 将源对象映射到目标对象。
确认您的模型
如果您正在使用数据库优先的方法(即您有一个现有的数据库来工作)来生成模型,在使用 Scaffold-DbContext 之前,请确认您所有的索引、关系、标识字段和外键都相应地表示了您的模型。Scaffold-Database 命令基于现有数据库创建您的 DbContext 实例。在创建 DbContext 实例时,该命令会考虑所有因素。
如果您的关系不正确,这将导致在通过您的 DbContext 实例访问它们时,您的模型上的导航属性出现问题。本质上,您的导航属性将是空的。
使用 Async/Await
对于 I/O 密集型活动,例如数据库操作,使用 async/await 来创建可扩展的应用程序是有意义的。虽然当在开发机器上运行 Web 应用程序时可能不明显,但使用 async/await 的真正好处在于当有成百上千的人同时访问网站时。
使用 async/await 的原因是为了避免阻塞线程请求。当一个请求进入网络服务器时,.NET Framework 维护一个线程池来处理这些传入的请求。对于每个请求,都会从池中取出一个线程来同步处理该请求。在线程被使用期间,直到处理完成之前,没有任何东西可以使用它(“阻塞线程”)。一旦处理完成,线程就会被释放并返回到线程池以处理下一个请求。
当您使用 async/await 时,您并没有从线程池中取出一个线程。在 async 和 await 之间的任何内容都不使用线程,这意味着您在长期运行中节省了内存,并允许您的应用程序表现更好。
当涉及到调用 Entity Framework Core 时,出于性能原因,最好使用 async/await。
记录您的查询
大多数 OptionsBuilder 类专门用于日志记录,以帮助解决这个问题。
对于使用 Entity Framework 的简单日志记录,将 .LogTo() 方法放入您的 DbContext 实例的 onConfiguring() 方法中,如下面的代码片段所示:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.LogTo(Console.WriteLine);
}
}
.LogTo() 方法接受一个动作或一个函数,用于指定发送日志数据的位置。在这个片段中,我们只是将日志记录到调试窗口中。
将简单的日志记录到调试窗口是最容易实现的,因为它是一个简单的Console.Write()方法,不需要任何第三方包,但还有其他类型的日志记录可供选择,它们同样容易集成到 Entity Framework Core 中。
其他日志记录选项
对于 Entity Framework Core 中的其他日志记录方法,请访问以下 URL:learn.microsoft.com/en-us/ef/core/logging-events-diagnostics/
使用资源文件处理大量种子数据
如果您需要少于 20 条记录的初始种子数据,只需在您的DbContext实例中即时使用.HasData()手动编码记录即可。
但如果您有一个在初始加载时需要数百条记录的表怎么办?使用代码手动输入记录可能会很痛苦。
.NET 中的一个隐藏宝藏是使用资源文件来存储简单的字符串(通常用于本地化/翻译),但它也可以用于填充种子数据。
让我们使用我们的Attraction/Location表示例,并展示在资源文件中创建种子数据的步骤:
-
打开 SQL Server Management Studio。
-
确认每个表(
Attraction和Location)中创建的种子记录符合您的期望。 -
执行一个带有 JSON 子句的
SELECT操作,如下面的 SQL 片段所示:SELECT [ID] ,[Name] ,[LocationID] FROM [dbo].Attraction FOR JSON AUTO, INCLUDE_NULL_VALUES -
点击结果,将打开一个新的结果面板,其中包含您的 JSON。JSON 将看起来像这样:
[{ "ID": 1, "Name": "Twirly Ride", "LocationID": 2 }, { "ID": 2, "Name": "Mine car Coaster", "LocationID": 5 }, { "ID": 3, "Name": "Haunted House", "LocationID": 3 }, { "ID": 4, "Name": "Dragon Ride", "LocationID": 2 }, { "ID": 5, "Name": "Gift Shop", "LocationID": 1 }, { "ID": 6, "Name": "Space Ride", "LocationID": 4 }, { "ID": 7, "Name": "Shootout at OK Corral\/Lazer Tag", "LocationID": 5 } ] -
复制返回的 JSON。
-
打开 Visual Studio,并通过 Visual Studio 添加资源文件。以下屏幕截图说明了该过程:

图 5.1 – 在 Visual Studio 中创建名为 SeedResource.resx 的资源文件
-
您的资源文件将自动打开。在我们的示例中,我们将使用以下参数创建资源:
-
AttractionRecords -
值:在此粘贴您的 JSON
-
注释:(这些是可选的,但添加以供其他开发者识别)
-
访问修饰符:将其更改为内部
-
您的资源记录应如下所示:

图 5.2 – Resources.resx 文件中的一个示例记录,包含用于吸引表的 JSON
-
保存您的资源文件。
-
打开您的
AttractionConfiguration类,并在您的DbContext实例中找到您的.HasData()吸引操作,并将其替换为以下代码:var records = JsonSerializer.Deserialize<Attraction[]>( SeedResource.AttractionRecords); if (records != null) { builder.HasData(records); } -
保存并编译您的代码。
如果您需要大量的种子数据,最好创建包含 JSON 数据的资源文件,而不是手动将所有记录编码到代码中。在DbContext配置中找到大段大段的 JSON 字符串可能会让一些开发者感到震惊。
理解延迟执行
在调用 Entity Framework 时进行延迟执行意味着 LINQ 查询将延迟到需要实际值时才执行。
以下是一个示例:
var products = this.Products.ToList().Where(r => r.CategoryId == 15);
虽然这将返回正确的项目列表,但它的性能并不如它本可以做到的那样好。在Products DbSet实例之后添加.ToList()方法后,整个Products表被加载,然后才执行.Where()方法。
以下代码片段展示了处理此调用的一种更好的方法:
var products = this.Products.Where(r => r.CategoryId == 15).ToList();
这也将返回正确的产品列表。然而,生成的 SQL 查询将包含一个WHERE子句,用于过滤并返回正确的记录列表。这里的区别是第一个查询将返回Products表中的所有记录,然后使用.Where() LINQ 方法过滤出结果产品列表。记住——LINQ 也可以与数组一起工作。
在第二个查询中,当遇到.ToList()方法时,会创建一个WHERE子句,并返回一个记录子集,并将其“物化”为实体,这使得此查询非常快。
延迟执行意味着你正在构建查询而不是立即执行它。当你完成查询构建并想要结果时,使用.ToList()方法结束你的 LINQ 查询。如果你正在寻找一个实体,使用.Single()、.SingleOrDefault()、.First()或.FirstOrDefault()方法结束你的 LINQ 查询。
使用.ReadOnly 状态与.AsNoTracking()
当使用 LINQ 检索数据时,DbContext实例有一个名为ChangeTracker的东西,当实体的状态发生变化时,它会更新。这需要开销——虽然开销很小,但毕竟还是有开销。
如果你在一个只读情况下使用DbSet,请在 LINQ 语句的开始处使用.AsNoTracking()方法,让 Entity Framework Core 知道它不需要跟踪返回的模型的状态。
例如,以下 LINQ 查询将检索一个Attraction对象,而不会更新ChangeTracker:
public Attraction GetAttraction(int id)
{
return _context.Attractions
.AsNoTracking()
.FirstOrDefault(e => e!.Id == id, null)!;
}
在前面的代码片段中,我们在DbSet实例之后放置了.AsNoTracking()方法,让 Entity Framework Core 知道不要跟踪任何内容。
利用数据库
虽然创建所有内容在 Entity Framework 中很有吸引力,但有时让数据库执行数据密集型操作会更好。
在一个项目中,我需要在代码中编写大型的 LINQ 查询来将实体检索到内存中。然后,我继续编写代码来计算项目数量、汇总总额,最后将所有类型的子实体关联到主实体集合中。
我意识到我可以通过存储过程来实现所有这些,并且完全绕过 Entity Framework。存储过程处理了这些细节,而 Entity Framework Core 只是检索了结果。
有时,让数据库执行计算数据的繁重工作并提供结果给应用程序是有意义的,因为那是它的职责。
避免手动属性映射
当向客户端发送要渲染的实体时,最好创建 数据传输对象(DTOs)。你只想发送与当前显示的网页相关的最小数据量。
然而,手动编写从左到右的属性赋值会让人感到疲倦。一个建议是使用 AutoMapper。
AutoMapper 可以自动化将属性从一个源对象映射到目标对象。在下面的示例中,我们正在将一个 Attraction 对象的属性复制到一个新的 AttractionDto 对象。AutoMapper 通过匹配属性并将数据复制到目标对象来为我们处理繁重的工作:
var config = new MapperConfiguration(cfg =>
cfg.CreateMap<Attraction, AttractionDto>());
IMapper mapper = new Mapper(config);
var dest = mapper.Map<Attraction, AttractionDto>(attractionObject);
AutoMapper 非常灵活,满足特定需求,已被从 NuGet 上下载超过 400,000 次,并且被行业中的超过 100,000 名开发者使用。
AutoMapper 库
之前的功能仅触及了 AutoMapper 为开发者所能做的功能表面。要了解 AutoMapper 的全部潜力,请访问以下 URL 的完整文档网站:docs.automapper.org/。
在本节中,我们了解了 Entity Framework Core 在行业中的常见用法。我们学习了在创建数据库-first DbContext 实例之前最好确认你的数据库,以及如何利用数据库的能力而不是手动编写一切,以及如何使用 .AsNoTracking() 执行只读查询,为什么最好使用 async/await,以及为什么在编写 LINQ 查询时理解延迟执行很重要。最后,我们探讨了如何记录你的查询,如何使用资源文件来初始化你的表,以及如何通过使用 AutoMapper 避免手动从左到右映射属性。
在下一节中,我们将对一个简单的 Entity Framework 应用程序应用我们所有的标准,并且甚至学习一些新技术。
实现主题公园示例
当涉及到 Entity Framework Core 及其所有功能时,有很多东西需要消化。关于 Entity Framework Core 的书籍有很多;本章将仅触及表面。
Entity Framework Core 推荐
要深入了解 Entity Framework Core,我推荐阅读《Mastering Entity Framework Core 2.0》www.packtpub.com/product/mastering-entity-framework-core-20/9781788294133。
在本节中,我们将更新一个小型 ASP.NET 应用程序,该应用程序使用 Entity Framework Core,并包含上一节中讨论的所有标准,以及一些额外的技术来更好地理解 Entity Framework Core。
概述
在这个示例中,我们将使用我们之前的 DbContext 实例以及 Attractions 和 Locations 表,并使用 SQL Server 创建一个数据库。
运行 Web 应用程序
本节使用的应用程序可在 Packt Publishing 的 GitHub 仓库中的 Ch5/EFApplication 下找到。
我们将保持我们的数据访问简单。我们将使用服务方法,接受一个 DbContext 实例来检索我们的数据,并从我们的 DbContext 模型在 SQL Server 中创建数据库。
创建数据库
为了使我们的应用程序正常工作,我们需要在 SQL Server 中创建我们的数据库。由于我们已经创建了 DbContext 实例(使用模型优先方法),我们可以使用 Entity Framework Core 迁移来构建我们的表。
要创建你的本地数据库版本,请在包管理控制台(通过 视图 | 其他窗口 | 包 管理器 控制台)中输入以下内容:
Update-Database
一旦你按下 Enter,Entity Framework Core 将定位到 DbContext 实例,读取配置文件(appsettings.json),并使用连接字符串来创建我们的数据库和表,其中包含种子数据。
添加异步只读模式
我们当前的首要任务是应用 async/await 到正确的服务中,以便我们以后可以扩展应用程序。如果你正在使用带有 Entity Framework 的现有 ASP.NET 应用程序并想使用 async/await 方法,最好从数据库开始。如果你正在创建一个“绿色”项目(即从头开始),立即使用 async/await 方法以避免将来头疼。
在 AttractionService 和 LocationService 类中,我们可以将所有 LINQ 调用转换为以下形式:
public List<Attraction> GetAttractions()
{
return _context.Attractions
.ToList();
}
我们将它们转换为以下形式,使用 async/await:
public async Task<List<Attraction>> GetAttractionsAsync()
{
return await _context.Attractions
.ToListAsync();
}
此外,由于我们不会创建、更新或删除数据,我们可以安全地说这是一个只读查询。因此,我们可以将 .AsNoTracking() 方法应用于查询,如下所示:
public async Task<List<Attraction>> GetAttractionsAsync()
{
return await _context.Attractions
.AsNoTracking()
.ToListAsync();
}
如前所述,.AsNoTracking() 方法将减少 Entity Framework 的开销,因为我们不是跟踪模型的状态,而只是简单地填充模型。
包含子实体
当我们查询我们的景点时,我们还想获取景点的位置。我们如何在查询中包含位置(存储在单独的表中)?
当我们调用 GetAttractionsAsync() 方法时,我们放置一个 .Include() 方法来检索相关实体。我们修改后的 GetAttractionsAsync() 方法如下所示:
public async Task<List<Attraction>> GetAttractionsAsync()
{
return await _context.Attractions
.AsNoTracking()
.Include(r=> r.Location)
.ToListAsync();
}
.Include() 方法在很大程度上依赖于你在构建模型时创建的数据库关系。我将回想起我们之前讨论的“确认你的模型”最佳实践。Entity Framework Core 使用模型的关系来加载相关实体。
扩展你的模型
在我们的 Location 模型中,我们需要一种方式来知道一个位置上有多少个景点;我们需要一个新的属性,称为 AttractionCount。
虽然这是一个添加到 Location 类中的简单属性,但这里需要做出多个决定。
首先,让我们在 Partials 文件夹下创建一个具有相同类名的新文件,名为 Location:
namespace EFApplication.DataContext.Models;
public partial class Location
{
public int AttractionCount { get; set; }
}
在前面的代码片段中,某些内容可能看起来有些奇怪。尽管文件位于 Partials 文件夹中,但部分命名空间必须与实体主模型相同的命名空间,部分才能正常工作。.NET 项目通常遵循命名空间与文件夹结构匹配的约定。
当我们运行应用程序时,我们应该会遇到 Location 模型当前存在的问题,如下所示:

图 5.3 – 在实体框架对象上创建属性时的错误信息
Entity Framework Core 告诉我们的是,表中没有 AttractionCount 字段,因为它不存在,所以它不能填充该属性。
我们有三个选项,如下所示:
-
在属性上放置一个
[NotMapped]属性,这样它就不会尝试填充属性,并手动计算我们的景点数量。 -
创建一个 SQL Server 函数来计算一个名为
AttractionCount的计算属性,并返回它,以便它可以填充我们的额外属性。 -
自动计算模型中已有的景点数量。
让我们专注于快速成功实施选项 3。
虽然我们确实需要一个 [NotMapped] 属性,这样 Entity Framework Core 就不会尝试加载它,但我们将属性改为 expression-bodied 属性。我们可以将其制作为一个自动属性({get;set;}),但我们只使用它作为 get 属性,如下面的代码片段所示:
public partial class Location
{
[NotMapped]
public int AttractionCount => Attractions.Count;
}
请记住,这是假设你在 SQL Server 实例中创建了一个外键关系,用于在加载位置时加载景点。如果你没有 .Include() 方法,你的景点计数将为 0。
在本节中,我们学习了如何使用模型优先方法创建数据库,如何使用 .AsNoTracking() 方法添加异步、只读模式,以便状态不会附加到对象上,如何在检索父模型时包含子实体,以及最后如何使用部分类扩展模型,并将 [NotMapped] 属性附加到属性上,让 Entity Framework 知道它是否应该将字段映射到属性。
摘要
在本章中,我们学习了三个不同的 Entity Framework Core 模式,包括仓储模式和单位工作模式、规范和扩展方法,以及如何将它们实现到自己的项目中。
然后,我们检查了行业的一些标准,例如确认你的模型,将 async/await 添加到你的 LINQ 调用中,实现日志记录,使用资源文件进行数据初始化,以及理解延迟执行。
我们还回顾了如何执行只读查询以及如何通过让数据库执行数据密集型过程来利用数据库。
最后,我们将这些标准应用于一个现有应用程序,通过使用模型优先的方法创建我们的数据库,然后检查如何使用 .AsNoTracking() 方法添加异步、只读模式,以便状态不会附加到对象上,如何在检索父模型时包含子实体,以及最后如何在扩展模型的同时让 Entity Framework 知道哪些属性需要填充以及哪些属性需要忽略。
在下一章中,我们将学习使用 MVC、Razor Pages、ViewComponents、HTMLHelpers 和 Task Runners 的 UI 标准。
第六章:Web 用户界面的最佳实践
当使用 ASP.NET 8 创建 用户界面(UI)时,可能会让人感到害怕,因为大多数开发者都习惯了使用 C#。在创建 Web 应用程序时需要考虑许多因素,例如避免重复、识别可重用组件的类似界面、创建结构化的网站,以及使搜索引擎更容易索引网站——这个过程称为 搜索引擎优化(SEO)。
在本章中,我们将涵盖以下主要主题:
-
使用任务运行器
-
将标准应用于 UI
-
介绍 Buck’s 咖啡店项目
在第一部分,我们将探讨为什么任务运行器对开发者来说如此重要,如何设置和自动运行它,以及如何打包和压缩脚本。然后,在下一部分,我们将回顾与 ASP.NET 8 网站相关的许多常见标准,包括集中链接、保持控制器和 Razor 页面小巧、为什么 ViewComponent 类比 HTMLHelper 类或部分更好,用标签助手替换 HTML 助手,以及为什么创建对搜索引擎友好的 URL 很重要。
最后,我们将创建一个名为 Buck’s Coffee Shop 的新项目,应用我们所学的一切。
到本章结束时,我们将了解如何将客户端资源包含到任务运行器中,实施 UI 的常见实践,理解如何使用任务运行器工具将附加功能构建到现有的 Web 应用程序中,使用扩展方法合并链接,并通过创建我们自己的 HTML 标签来扩展 HTML。
技术要求
即使是一个简单的 Web UI,Visual Studio 家族的产品也提供了一个使用 IntelliSense 轻松构建 Web 应用程序的方法。我们建议使用您最喜欢的编辑器查看 GitHub 仓库。我们推荐以下选项:
-
Visual Studio(最新版本)
-
Visual Studio Code
-
JetBrains Rider
我们将使用的编辑器是 Visual Studio 2022 Enterprise,但任何版本(社区版或专业版)都可以与代码一起使用。
第六章 的代码位于 Packt Publishing 的 GitHub 仓库中,可在 github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices 找到。
使用任务运行器
在本节中,我们将解释什么是任务运行器,它的职责是什么,如何在构建解决方案时自动执行它,并提供一些使用示例。
作为开发者,我们总是在寻找更快地自动化任务的方法。在使用 JavaScript 框架时,这项任务至关重要,尤其是在构建 持续集成/持续交付(CI/CD)管道中的解决方案时。某些任务的持续重复变得单调乏味,并占用了开发时间。为什么不让计算机处理这项工作呢?
作为开发者,在这一章中包含任务运行器部分的目的在于向开发者展示如何自动化任务,以使客户端工作更加高效。近年来,我遇到了一些从未使用过任务运行器进行客户端任务甚至不知道它是什么的开发者。任务运行器对所有开发者来说都是一个巨大的好处。
什么是任务运行器?
随着 JavaScript 的流行,开发者想要一种方法来构建他们的 JavaScript 以及构建他们的解决方案。除了使用 JavaScript 之外,TypeScript 被创建出来,以给 JavaScript 带来更强的类型感,并且也需要一个编译步骤。
除了“编译”TypeScript 之外,大多数网站还需要额外的任务,如压缩和打包 JavaScript 以及优化。当与 C# 和 JavaScript 一起工作时,需要一个工具来使开发者的体验无缝。
这就是为什么创建并集成到 Visual Studio 中的 Task Runner 工具的原因。其主要责任是使用 Grunt 或 Gulp 以及构建脚本来自动化任务。
Gulp 和 Grunt 也是任务运行器,但在定义任务时,每个都有不同的 JavaScript 文件格式。虽然我们可以在 Visual Studio 任务运行器中使用 Grunt 或 Gulp 脚本,但我们将使用 Gulp 格式。
Visual Studio 中的任务运行器是我认为的“客户端的迷你管道”。任务运行器是开发者学习为应用程序创建 CI/CD 管道的一个很好的入门。
设置任务运行器
任务运行器在运行脚本时严重依赖于 Node.js。默认情况下,Node.js 应该已经通过 Visual Studio 安装。
要检查您的机器上是否已安装 Node.js,请执行以下操作:
-
从 视图 | 其他窗口 中选择 包管理器控制台。
-
当出现提示时,输入
npm --version。如果显示版本号,我们可以进行下一步。如果没有显示,可能需要通过 Visual Studio 安装程序修复 Visual Studio。 -
输入
npm install -g gulp --save-dev。-g选项是为了全局安装 Gulp,而--save-dev是为了将依赖项保存到package.json文件中。因此,解决方案中应该添加一个package.json文件。 -
在项目的根目录中添加一个新的 JavaScript 文件,命名为
gulpfile.js。
根据我们是否使用 Grunt 或 Gulp,任务运行器需要特定的文件。在示例(如我之前提到的)中,我们将使用 Gulp 格式。
gulpfile 的结构
创建 gulpfile 时,应命名为 gulpfile.js。文件应放置在解决方案的根目录中。
gulpfile 的结构包括以下内容:
-
定义的包—这些是在 gulpfile 中使用的模块。
-
处理和清理任务—每个任务将包含一个处理任务和一个清理任务。
-
导出分组—这提供了一个特定顺序中要执行的任务的列表。通常,首先使用清理程序,然后是常规处理任务。
Gulp 需要一个 gulpfile.js 文件位于解决方案的根目录。gulpfile 的标准布局通常使用以下结构进行分段:
-
npm安装。 -
在
gulpfile.js文件中,应该有两个任务:一个处理任务和一个清理任务。处理任务旨在实现我们想要自动化的内容,而清理任务旨在删除创建或处理过的文件。 -
全局默认和清理任务—在构建脚本时将处理和清理任务分段到逻辑分组中。
由于我们已经有我们的文件,我们将为我们的解决方案创建一个简单的任务。将以下 JavaScript 代码复制到新的 gulpfile.js 文件中:
const { series } = require('gulp');
// Packages defined at the top
const gulp = require('gulp');
// Tasks (no cleanup for Hello World) ;-)
function testTask(done) {
console.log('Hello World! We finished a task!');
done();
}
// Global default and cleanup tasks
exports.build = series(
testTask
);
由于这是一个构建脚本,我们将使用 Gulp 包中的 series() 函数,它定义了顺序过程。这通过第一行表示。第二行创建了一个代表 Gulp 包的 const 实例,使我们能够使用 Gulp 函数。
我们为 Task Runner 定义的简单任务是全世界闻名的“Hello World!”任务,通过 testTask 函数定义,并传递 done 函数,Gulp 为我们处理。
最后,我们将一个 build 属性附加到我们的导出中,它定义了一系列来自前面代码的任务。series 函数定义在 Gulp 包中。exports 命名空间之后的名称可以是 Task Runner 中出现的任何名称。如果构建任务在 Task Runner 中没有显示,请点击 gulpfile.js 文件。
当在 build 选项上右键单击(或双击),构建将执行并显示结果,如下面的截图所示:

图 6.1 – 我们第一次 Task Runner 流程的结果
在下一节中,我们将学习如何在构建我们的解决方案时自动运行我们的 gulpfile。
自动运行
为了帮助自动运行我们的 gulpfile.js 文件,我们可以在构建解决方案时将某些任务绑定到事件。在 Task Runner 中右键单击任务以选择构建的适当操作,如下所示:
-
Before Build—在编译解决方案之前执行任务
-
After Build—在解决方案编译后执行任务
-
Clean Build—在执行“Clean Solution”或“Clean
”操作时执行任务 -
Project Open—在项目开启后执行任务
当我们选择 gulpfile.js 文件时:
/// <binding BeforeBuild='build' />
当我们构建应用程序时,Task Runner 将执行构建任务,如果成功,它将继续编译应用程序,如下面的截图所示。如果不成功,Task Runner 将在结果窗格中显示错误消息:

图 6.2 – 客户端任务和解决方案的成功构建
虽然这为我们应用程序提供了一个基本的基础,但我们需要定义我们的客户端目录结构,以便在添加资源(如图片、脚本和样式)时,我们的任务知道在哪里找到这些资源。
在下一节中,我们将探讨如何设置客户端工作流程的结构。
创建工作流程结构
在我们为gulpfile.js文件编写脚本之前,我们需要为我们的任务运行器定义一个简单的工作流程。通常,我们的 gulpfile 至少包含一个 JavaScript 和 CSS 打包器和压缩器。哪个先执行并不重要,但它们甚至可以是并行任务集合的候选者。虽然 Visual Studio 为我们创建了服务器端应用程序,但客户端结构需要更多的关注,以确定在整个应用程序中放置文件的位置。
在每个 ASP.NET 8 应用程序中,我们都有一个包含客户端脚本、样式和静态内容(如图片)的wwwroot文件夹。这被认为是我们的静态内容根路径,因此如果我们有一个名为www.mywebsite.com/的网站,wwwroot 文件夹将是我们的根。例如,如果我们想访问我们的 CSS 文件夹,我们将通过https://www.mywebsite.com/css/来访问它。虽然每个资源(如脚本和样式)都有其自己的处理方式和创建生产级输出文件的方式,但需要在我们的任务运行器中有一个工作流程来指导每个任务如何执行。
当前目录结构如下:

图 6.3 – 常见的 ASP.NET 8 结构
上述结构是为生产级应用程序设计的。css目录不包含任何用于处理的 SASS 或 LESS 文件,js目录只包含简单的 JavaScript 文件。我们需要额外的目录来确保我们的工作流程正常工作。
对于我们的应用程序,我们将使用 TypeScript 和 SASS。SASS 是 CSS 文件的预处理器,需要一个用于源文件的目录(扩展名为.scss)。TypeScript 还需要转换为 JavaScript 文件,因此我们将为 SASS 文件创建一个scss目录,为 TypeScript 文件创建一个src目录。
在wwwroot文件夹下创建一个src文件夹和一个scss文件夹。这些文件夹将包含我们应用程序的源代码。src文件夹将包含应用程序的所有 TypeScript 文件,而scss文件夹将包含所有准备编译成生产级 CSS 文件的样式。
以下步骤展示了任务运行器的一个常见工作流程:
-
TypeScript 文件:
- 将
src文件夹中的 TypeScript 文件转换为 JavaScript 文件。输出文件将位于src文件夹中。输出文件是 JavaScript 文件。
- 将
-
JavaScript 文件:
-
使用打包器处理
.js文件。打包器应该自动知道如何根据 JavaScript 模块模式包含所有文件。 -
使用压缩器处理捆绑的 JavaScript 文件。输出文件从
src文件夹复制到js文件夹。这些文件也被重命名,以添加.min.js后缀来标识它们为压缩文件。
-
-
样式:
- 使用“样式编译器”例如 LESS 或 SASS。我们将使用 SASS 来预编译我们的样式。这些文件将位于
scss文件夹中。一旦编译完成,文件夹将包含.css文件,并不可避免地移动到css文件夹。
- 使用“样式编译器”例如 LESS 或 SASS。我们将使用 SASS 来预编译我们的样式。这些文件将位于
我们应用程序的工作流程结构意味着 js 和 css 文件夹是生产级别的文件夹,而我们的 src 和 scss 文件夹是针对开发者修改的特定文件夹。
定义我们的工作流程路径
由于我们创建了工作流程结构,我们需要告诉 Gulp 在我们的应用程序中源文件夹和目标文件夹的位置。path 模块包含一个 resolve 函数,用于在执行过程中连接文件夹名称,如下所示:
// Packages defined at the top
const gulp = require('gulp'),
path = require('path');
// define our paths for our app
const basePath = path.resolve(__dirname, "wwwroot");
__dirname 是一个保留字,基于当前目录(在我们的情况下,这是应用程序的根目录)。basePath 用于仅在 wwwroot 文件夹及其以下操作,并且不会干扰我们的 ASP.NET 8 应用程序。
转译 TypeScript
我们的首要任务是转译我们的 TypeScript 文件为 JavaScript 文件。转译是将 TypeScript 转换为 JavaScript 的过程。对于我们的大多数 TypeScript 项目,我们几乎总是有一个 tsconfig.json 配置文件,它将位于 wwwroot 文件夹的根目录。然而,我们需要一个特殊的包来专门读取 Gulp 的配置文件。
要包含该包,请按以下步骤操作:
-
在
npm install -g gulp-cli。然后应将其添加到您的package.json文件中。 -
在
npm install gulp@4中安装 Gulp 作为项目依赖项。 -
最后一次在
npm install gulp-typescript typescript gulp-clean --save-dev中安装 TypeScript、gulp-typescript以及删除文件的能力 (gulp-clean)。 -
在
gulpfile.js文件顶部定义模块,如下所示:const gulp = require('gulp'), path = require('path'), tsConfig = require("gulp-typescript"); -
使用
createProject函数通过引用tsconfig.json文件的路由来加载配置,如下所示:// define our paths for our app const basePath = path.resolve(__dirname, "wwwroot"); const tsProject = tsConfig.createProject(path.resolve(basePath, 'tsconfig.json')); -
定义 TypeScript 源文件夹,如下所示:
const tsSource = path.resolve(basePath, "src"); -
我们将通过一个源对象来跟踪路径。我们的
srcPaths对象将包含一个指向源代码的js路径:const srcPaths = { js: [ path.resolve(tsSource, '**/*.js') ] }; -
我们需要为 TypeScript 创建处理 (
ts_transpile) 和清理 (ts_cleanup) 函数。以下代码实现了这一点:function ts_transpile(done) { tsProject .src() .pipe(tsProject()).js .pipe(gulp.dest(tsSource)); done(); } function ts_clean(done) { gulp.src(srcPaths.js, {allowEmpty: true}) .pipe(clean({ force: true })); done(); } -
ts_transpile函数使用tsProject配置来定位源文件(通过tsconfig.json文件末尾附近的include属性)并将所有 TypeScript 文件转换为同一目录中的 JavaScript 文件。 -
我们的
ts_clean函数将简单地从每个目录中删除所有 JavaScript (不是 TypeScript) 文件。 -
一旦我们定义了 TypeScript 任务,我们只需将它们添加到我们的
gulpfile.js文件底部的构建过程中,如下所示:// Global default and cleanup tasks exports.build = series( ts_clean, ts_transpile ); -
首先,我们使用
ts_clean删除所有 JavaScript 文件,然后执行ts_transpile进行转换。 -
如示例所示,我们的工作流程结构定位所有 TypeScript 文件并将它们转换为 JavaScript 文件:

图 6.4 – src 文件夹中的 TypeScript 文件转换为 JavaScript
由于我们的 TypeScript 现在已转换为 JavaScript,我们可以专注于打包和压缩文件。
打包和压缩
从本质上讲,JavaScript 在浏览器中加载需要一段时间。更糟糕的是,如果 JavaScript 文件格式化为可读性,文件加载时间会更长,因此下载速度会变慢。需要一个压缩过程尽可能缩短加载过程,以给用户更好的体验。
打包是将应用程序的脚本和样式合并为一个脚本或样式的过程,而不是加载多个文件。
由于空格和制表符占用空间,压缩脚本和样式是将客户端文件缩小到更小尺寸的过程,以便更快地发送到浏览器。
要打包我们的脚本,我们需要一个名为 Browserify 的模块来处理我们的 Gulp 脚本。让我们开始向 gulpfile.js 文件添加打包功能。以下是步骤:
-
在
npm install -g browserify gulp-rename vinyl-source-stream vinyl-buffer vinyl-transform gulp-uglify-es中。我们应该看到这些模块被添加到package.json文件中。 -
一旦这些安装到
package.json文件中,我们需要包含另一个srcPath对象,其中包含我们想要打包/压缩的所有文件:const srcPaths = { js: [ path.resolve(tsSource, '**/*.js') // all *.js in every folder ], jsBundles: [ path.resolve(tsSource, 'site.js') // specific files to bundle/minify ] };
我们将属性命名为 jsBundles。由于每个 TypeScript 文件都已转换为 JavaScript 文件,我们必须确定要加载哪些文件。一个良好的做法是将文件名与实际页面名称相同。好消息是 Browserify 模块将遵循每个文件的导入并将它们包含在打包中。随着我们向项目中添加更多的 TypeScript,将主要脚本添加到列表中以便自动编译。
-
为网络应用程序中的脚本创建一个新的变量:
const destPaths = { jsFolder: path.resolve(basePath, 'js') // wwwroot/js };
我们将变量命名为 destPaths。
-
接下来,我们创建打包和压缩脚本的预处理和清理:
/* JavaScript */ function js_bundle_min(done) { srcPaths.jsBundles.forEach(file => { const b = browserify({ entries: file, // Only need initial file, browserify finds the deps transform: [['babelify', { 'presets': ["es2015"] }]] }); b.bundle() .pipe(source(path.basename(file))) .pipe(rename(path => { path.basename += ".min"; path.extname = ".js"; })) .pipe(buffer()) .pipe(uglify()) .pipe(gulp.dest(destPaths.jsFolder)); done(); }); } function js_clean(done) { gulp.src(path.resolve(destPaths.jsFolder, '**/*.js'), { read: false }) .pipe(gp_clean({ force: true })); done(); }
在前面代码片段中显示的 js_bundle_min 函数中,我们遍历所有要打包的文件。我们创建一个带有设置选项的 browserify 对象并按文件启动打包过程。
第一个过程是获取当前处理文件的基准名称。一旦我们有了文件名,我们可以将基准名称从 site 改为 site.min,然后在文件末尾添加 .js 扩展名。然后我们缓冲文件并对它执行 uglify 操作,这意味着我们压缩它。在打包和压缩完成后,我们将文件写入目标文件夹,即 wwwroot/js。
js_clean 函数会从 wwwroot/js 文件夹中删除所有的 .js 文件。
-
最后,我们可以在文件的底部将
js_bundle_min和js_clean函数添加到我们的构建导出中:// Global default and cleanup tasks exports.build = series( ts_clean, js_clean, ts_transpile, js_bundle_min );
保存 gulpfile 后,双击构建,文件应该会出现在 wwwroot/js 文件夹中。如果我们双击 JavaScript 文件,我们会看到它已经被打包和压缩。
实现附加任务
虽然打包和压缩脚本是一个大问题,但我们还可以向任务运行器添加其他任务。这些任务可能包括以下内容:
-
SCSS/LESS 预编译
-
图片优化
-
发布说明
-
在构建时从
node_modules复制dist文件夹到lib文件夹
这些只是准备网络应用程序时可用的一些简单任务。对于更多任务,请参考 GitHub 上 TaskRunner 项目的源代码中的 第六章,项目地址为 github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices。
在本节中,我们回顾了任务运行器的定义及其对开发者的帮助,并展示了如何在构建解决方案时自动执行它。我们还通过创建工作流程结构和定义我们的工作流程路径、如何转换 TypeScript、如何压缩和打包脚本来展示了任务运行器的强大功能。正如我们所看到的,这通过自动化客户端任务和使用单一构建来简化了开发者的体验。
在下一节中,我们将探讨更多的 UI 标准,以及当涉及到 ASP.NET 8 网络应用程序时,哪些被认为是常识。
将标准应用于 UI
ASP.NET 的网页模型在多年中不断发展。在 Web Forms 中,它是 ViewState 和组件。在 MVC 中,它有部分和 HTML 辅助器。现在,ASP.NET 8 提供了使用 ViewComponent 类和 TagHelper 类的更高级技术。
对于本节,我们将检查 ASP.NET 8 如何使用其特定于语言的特性来构建更快、更灵活的 UI。我们将看到如何在整个网站上合并链接,为什么保持控制器/页面小很重要,ViewComponent 类比部分和 HTMLHelper 类更好,以及如何创建 SEO 友好的路由。
集中您的网站链接
如果我们有一个拥有数百个链接的大型网站,开发者会明白当一个页面被重命名、移动或(天哪)删除意味着什么。在大型网站上更改每个链接肯定是一个耗时的工作。虽然 TagHelper 类是有帮助的,但一个常见的做法是使用 UrlHelper 类来合并链接。
让我们检查以下 ASP.NET 8 TagHelper 类的网站:
<a asp-page="Index">Go to Main Page</a>
想象一下所有这些在二级页面中指向主页面 Index.cshtml,并且我们收到一个请求要更改页面的名称为 Index2。
使用扩展方法,我们可以为每个页面创建自定义的特定站点的UrlHelper类 URL,如下例所示:
Public static class SiteLinkExtensions
{
public static string HomeUrl(this UrlHelper helper) =>
helper.RouteUrl(new UrlRouteContext
{
RouteName = "Default",
Values = new
{
Controller="Home",
Action ="Index"
}
});
}
扩展方法需要三个东西:一个静态类、一个静态方法,并且方法签名中的第一个参数必须声明this。
什么是扩展方法?
关于使用扩展方法的更多详细信息,请导航至learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods。
我们可以使用PageLink方法将此技术应用于 Razor 页面,如下所示:
Public static string DetailsUrl(this IurlHelper helper, string blogId) =>
helper.PageLink("Detail", values: new { Id = blogId, area = "admin" });
这两种方法之间的唯一区别是扩展方法附加到IurlHelper类而不是UrlHelper类,对于 Razor 页面,我们使用.PageLink方法。
如果使用带有asp-page属性的TagHelper锚点,这种方法限制了我们的灵活性,因为我们正在多个地方定义一个页面。通过向UrlHelper类添加扩展方法,我们可以通过使用 HTML 的href属性来简化它,如下所示:
<a href="@Url.HomeUrl()">Go to Main Page</a>
使用UrlHelper扩展方法,整个网站的所有链接都更容易集成且更新效率更高。
保持控制器/页面的小型化
当 ASP.NET MVC 被引入时,控制器通常是大多数代码的垃圾场,包括对数据库的调用、创建模型和验证模型。
MVC 控制器(以及现在的 Razor 页面)应该是“交通警察”,根据页面的功能将逻辑引导到应用程序的特定部分。
虽然以下列表远非完整,但某些方面可以卸载到 ASP.NET 8 的其他部分,以使控制器和页面更加精简:
-
[Required]和[Email]。 -
DbContext实例和使用 Entity Framework。如果 Entity Framework 不是一个可行的解决方案,请将数据库处理卸载到Service类中(参见第五章关于 Entity Framework Core)。 -
次要过程——如果应用程序包含发送电子邮件、处理记录或构建对象的代码,请将其重构到自己的类中,并将其注入到控制器/Razor 页面中,使其更加整洁且易于测试。
在控制器或 Razor 页面中,大型方法在尝试测试或调试代码时可能会使问题更加复杂。在这种情况下,较小的代码是更好的方法。
使用视图组件
当 MVC 引入 HTML 辅助器和部分视图时,将对象传递到一个小部分并创建用于视图的 HTML 片段的能力对开发者来说是一个伟大的功能。然而,使用这两个功能有一些缺点。
使用 HTML 助手的缺点是能够通过代码创建新的 HTML 片段并将其渲染回视图。如果 HTML 发生变化,则需要更新和重新编译代码,违反了关注点分离(SoC)(“你的 C# 在我的 HTML 中”)。没有与助手关联的 HTML;它必须使用代码创建。
反过来,部分组件引入了在部分中拥有无代码 HTML 的能力,并且可以将对象传递到部分中。这种方法的缺点是能够在 HTML 中放置 if…then 语句。当在 HTML 中引入 if…then 语句时,这被视为代码异味(看起来不属于或看起来可疑的代码),并且意味着是业务规则。HTML 应该是声明性的——几乎像模板一样,不涉及分支。
在 ASP.NET Core 中,引入了 ViewComponent 类,虽然它们不是为了取代 HTMLHelper 类或部分组件,但满足了构建模块化 Web 应用程序更大的需求。被视为“迷你控制器”的 ViewComponent 类为 ASP.NET 开发者提供了以下原因的更好方法:
-
SoC——通过 HTML 和 C# 的组合,这允许以更好的方式编写模块化组件。
-
ViewComponent类仅渲染视图的一部分而不是整个响应。这使得组件可以独立快速渲染。 -
ViewComponent类在本质上被隔离,这使得它们非常容易测试。创建一个新的ViewComponent类,传递参数,并测试它是否返回了预期的结果。
ViewComponent 类为我们提供了两全其美的解决方案,在单个组件中渲染 HTML 的同时使用 C# 应用业务规则。
额外的 ViewComponent 材料内容
关于 ViewComponent 类的更多详细信息,请访问 learn.microsoft.com/en-us/aspnet/core/mvc/views/view-components。
使用标签助手而不是 HTML 助手
虽然 HTML 助手提供了创建小段 HTML 的能力(但不要过度使用),但 TagHelper 类更进一步。
标签助手和 HTML 助手之间的区别在于 TagHelper 类允许我们通过代码构建自己的标签元素,而 HTMLHelper 类则直接在 HTML 中作为方法调用。
例如,如果我们正在构建一个酒店应用程序,一些 TagHelper 类将包括 <calendar>、<availability> 和 <room-gallery> 标签。这些在 HTML 中看起来像全新的标签,但 ASP.NET 会将它们在服务器上渲染,并根据提供的数据模型创建 HTML。
标签助手(Tag Helpers)是开发者创建自定义领域特定 HTML 标签库的极有力功能。
创建 SEO 友好的 URL
在构建网站时,网站架构很重要。如果是一个面向公众的网站,网站应该易于爬取,并且具有简单的 URL。
从另一个角度来看,检查以下两个 URL:
-
https://www.mysite.com/Blog/my-first-blog-post -
https://www.mysite.com/?blogpost=E1FCFB35-87C7-442F-9516-7C8585E8CD49
如果我们找到了这些链接,并且正在与某人通电话,我们会告诉他们跟随哪一个?
创建友好的 URL 提供了以下好处:
-
更容易识别页面—我们知道第一个 URL 是某人的第一篇博客文章。第二个 URL 是……嗯,我们不知道它是什么类型的页面。
-
易于重复—在电话中说第一个 URL 比说 GUID 更容易。
-
更好的 SEO—如果我们有机会,总是好的,帮助搜索引擎识别创建的页面类型。
谷歌 URL 结构的最佳实践
为了更好地命名 URL 以及谷歌推荐的做法,请导航至developers.google.com/search/docs/crawling-indexing/url-structure。
创建这些 SEO 友好的 URL 需要使用映射方法,如.MapRoute(),以及如果使用 Razor Pages,命名与内容相关的页面。
在本节中,我们学习了如何使用扩展方法对链接进行分类,如何尽可能使控制器和页面保持小,ViewComponent类如何提高编写模块化代码的效率,为什么TagHelper类可以将 HTML 提升到新的高度,以及为什么创建 SEO 友好的 URL 如此重要。
在最后一节中,我们将应用我们对实现这些概念的知识到应用程序中。
介绍巴克咖啡店项目
在上一节中,我们涵盖了大量的内容,解释了各种概念。解释ViewComponent和TagHelper类是一回事,但我们在网站上如何应用这些概念呢?
在本节中,我们将将这些概念应用到全新的项目中。我们的朋友想要他的咖啡店有一个新网站,所以我们使用 ASP.NET 8 Web 应用程序模板作为网站的起点。
设置巴克网站
由于我们有一个新的网站,我们想要创建客户端管道,这样我们就可以专注于网站的功能。
这听起来像是任务运行器的工作。
虽然我们在项目中包含了 TypeScript 的转译和 JavaScript 的打包/压缩,但我们还可以添加额外的任务来使我们的生活更加轻松。
一个简单的任务是将我们的样式使用 SASS 打包和压缩,如下所示:
-
在
npm install --save-dev sass gulp-sass中。这些模块应该添加到package.json文件中。 -
一旦这些被安装到
package.json文件中,我们就在gulpfile.js文件的开头按需包含模块,如下所示:gp_sass = require('gulp-sass')(require("sass")); -
创建一个包含 SCSS 文件路径的变量:
const sassSource = path.resolve(basePath, "scss"); -
将样式文件添加到
srcPaths对象(在sassSrc属性中):const srcPaths = { js: [ path.resolve(tsSource, '**/*.js') // all *.js in every folder ], jsBundles: [ path.resolve(tsSource, 'site.js') // specific files to bundle/minify ], sassSrc: [ path.resolve(sassSource, 'site.scss') ] } -
将样式路径添加到
destPaths对象(使用cssFolder属性):const destPaths = { jsFolder: path.resolve(basePath, 'js'), // wwwroot/js cssFolder: path.resolve(basePath, 'css') // wwwroot/css }; -
添加处理和清理函数:
/* SASS/CSS */ function sass_clean(done) { gulp.src(destPaths.cssFolder + "*.*", { read: false }) .pipe(gp_clean({ force: true })); done(); } function sass(done) { gulp.src(srcPaths.sassSrc) .pipe(gp_sass({ outputStyle: 'compressed' })) .pipe(rename({ suffix: '.min' })) .pipe(gulp.dest(destPaths.cssFolder)); done(); } -
最后,将函数添加到构建过程中:
// Global default and cleanup tasks exports.build = series( ts_clean, js_clean, sass_clean, ts_transpile, js_bundle_min, sass );
那些 JavaScript 库呢?它们位于令人讨厌的node_modules文件夹中。大多数 JavaScript 库都有一个dist文件夹用于分发。当我们安装 Bootstrap 和 Font Awesome 库时就是这种情况。为什么不为我们的本地用途将它们复制到我们的/lib文件夹中呢?
-
在
gulpfile.js文件中创建一个变量,包含到node_modules文件夹的路径。这应该在解决方案的根目录:const moduleSource = path.resolve(__dirname, "node_modules"); -
创建一个新变量,包含放置我们的
dist包的目的地(我们的/lib文件夹):const libSource = path.resolve(basePath, "lib"); -
在一个名为
lib的属性中定义复制细节:const srcPaths = { js: [ path.resolve(tsSource, '**/*.js') // all *.js in every folder ], jsBundles: [ path.resolve(tsSource, 'site.js') // specific files to bundle/minify ], sassSrc: [ path.resolve(sassSource, 'site.scss') ], // local dev (copy dist into lib) lib: [ { src: path.resolve(moduleSource, 'bootstrap/ dist/**/*'), dest: path.resolve(libSource, 'bootstrap/') }, { src: path.resolve(moduleSource, '@fortawesome/ fontawesome-free/**/*'), dest: path.resolve(libSource, 'fontawesome/') } ] }; -
添加处理和清理函数:
/* Copy Libraries to their location */ function copyLibraries(done) { srcPaths.lib.forEach(item => { return gulp.src(item.src) .pipe(gulp.dest(item.dest)); }); done(); } function cleanLibraries(done) { srcPaths.lib.forEach(item => { return gulp.src(item.dest + "/*.*") .pipe(gp_clean({ force: true })); }); done(); } -
最后,将我们的库复制添加到构建过程中:
// Global default and cleanup tasks exports.build = series( cleanLibraries, copyLibraries, ts_clean, js_clean, sass_clean, ts_transpile, js_bundle_min, sass );
这使我们能够自动接收应用程序的最新版本包。当更新我们的package.json文件时,我们将从node_modules中的最新版本中受益,直接送到我们的/lib文件夹。
更新链接
由于我们有一个新的应用程序,我们将创建Url助手来帮助编目网站。根据模板,我们有两条链接:Home和Privacy。让我们创建这些Url助手,如下所示:
public static class BucksUrlExtensions
{
public static string HomeUrl(this IUrlHelper helper) =>
helper.PageLink("/Index")!;
public static string PrivacyUrl(this IUrlHelper helper) =>
helper.PageLink("/Privacy")!;
}
这使得我们的 HTML 更容易阅读。我们可以用 Url Helper 替换锚点标签助手。以下是替换隐私锚点标签助手的示例:
<footer class="border-top footer text-muted">
<div class="container">
© 2023 - Buck's Coffee Shop - <a href="@Url.PrivacyUrl()">Privacy</a>
</div>
</footer>
虽然这是页面上的一个实例,但我们已经看到了我们努力的回报。注意导航栏吗?
在导航栏中,还有一个位置我们可以移除硬编码的 URL 并使用强类型的UrlHelper类,如下所示:
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" href="@Url.HomeUrl()">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="@Url.PrivacyUrl()">Privacy</a>
</li>
</ul>
我们替换了Privacy和Home链接。
这种技术也适用于控制器或 Razor 页面。如果我们需要重定向到另一个页面,默认情况下有一个UrlHelper类可供我们使用,如下所示:
public IActionResult OnGet()
{
return Redirect(Url.HomeUrl());
}
这消除了硬编码 URL 的需求,为在大型网站上引用链接提供了一种更有效的方法。
创建 OffCanvas 标签助手
由于响应式网站很重要,我们需要有一个OffCanvas菜单,以便用户在用移动设备查看网站时使用。
当在移动设备上点击汉堡菜单(显示为重叠的三条线)时,OffCanvas菜单被激活。OffCanvas菜单在网页的主内容中隐藏,因此得名。它们只有在实际需要时才隐藏导航项。
我们正在为 Buck 的网站创建一个OffCanvas菜单。然而,我们希望将其用于多个网站,因此需要创建一个可重用的组件。
Bootstrap 有一个OffCanvas组件,虽然它是简单的 HTML,但我们可以通过标签助手将其转换为可重用的组件。
首先,我们需要标签助手的结构。以下是我们需要执行的代码:
[HtmlTargetElement("offcanvas")]
public class OffCanvasTagHelper: TagHelper
{
[HtmlAttributeName("id")]
public string Id { get; set; }
[HtmlAttributeName("tabindex")]
public string TabIndex { get; set; }
public override async Task ProcessAsync(TagHelperContext context,
TagHelperOutput output)
{
var childData = (await output.GetChildContentAsync()).GetContent();
output.Attributes.Clear();
output.TagName = "div";
output.Attributes.Add("class", "offcanvas offcanvas-start");
if (!string.IsNullOrEmpty(Id))
{
output.Attributes.Add("id ", Id);
}
if (!string.IsNullOrEmpty(TabIndex))
{
output.Attributes.Add("tabindex", TabIndex);
}
output.Content.SetHtmlContent(childData);
}
}
在代码的开始部分,我们需要确定我们想要为我们的标签助手使用哪个 HTML 标签。在这种情况下,它是一个简单的offcanvas标签。由于 HTML 标签、属性和 CSS 类默认都是小写,所以对OffCanvas类的每个引用都应该包含一个标签的小写字符串。
我们希望根据示例包含一个 ID 和一个 tab index,因此我们需要两个属性,分别称为Id和TabIndex,每个属性都应用了[HtmlAttributeName]数据注解。
主要方法是ProcessAsync(或Process)方法。我们立即获取offcanvas标签内的任何子元素并处理子标签,我们将在本节稍后讨论。
我们将tagname设置为DIV,将classname设置为offcanvas,设置属性,最后将innerHTML设置为从ProcessAsync方法开始检索的子数据。
根据 Bootstrap OffCanvas文档,我们需要一个标题和一个主体。我们可以轻松地复制此代码来为offcanvasTagHelper创建一个header和body标签:
[HtmlTargetElement("header", ParentTag = "offcanvas")]
public class OffCanvasHeaderTagHelper: TagHelper
{
public override async Task ProcessAsync(TagHelperContext context,
TagHelperOutput output)
{
var childData = (await output.GetChildContentAsync()).GetContent();
output.TagName = "div";
output.Attributes.Add("class", "offcanvas-header");
var header = new TagBuilder("h5")
{
TagRenderMode = TagRenderMode.Normal
};
header.Attributes.Add("id", "offcanvasLabel");
header.AddCssClass("offcanvas-title");
header.InnerHtml.Append(childData);
var button = new TagBuilder("button")
{
TagRenderMode = TagRenderMode.Normal
};
button.AddCssClass("btn-close");
button.Attributes.Add("type","button");
button.Attributes.Add("data-bs-dismiss","offcanvas");
button.Attributes.Add("aria-label","Close");
output.Content.AppendHtml(header);
output.Content.AppendHtml(button);
}
}
HTMLTargetElement数据注解有一点不同。我们称这个标签为header。这不会与常规的 HTML 标题标签冲突吗?只要我们将ParentTag作为第二个参数包含进来,说明这个元素只在一个offcanvas元素内有效。
在这个Process方法中,我们创建一个标题和按钮,并将 HTML 内容附加到输出内容的底部。这个内容作为子数据发送回父offcanvasTagHelper实例。
我们只需要使用OffCanvasBodyTagHelper创建主体,如下面的代码所示:
[HtmlTargetElement("body", ParentTag = "offcanvas")]
public class OffCanvasBodyTagHelper: TagHelper
{
public override async Task ProcessAsync(TagHelperContext context,
TagHelperOutput output)
{
var childData = (await output.GetChildContentAsync()). GetContent();
output.TagName = "div";
output.Attributes.Add("class", "offcanvas-body");
output.Content.SetHtmlContent(childData);
}
}
这将包含与我们的标题相同的HTMLTargetElement数据注解,但我们将称之为body标签。同样,由于我们处于<offcanvas>元素内部,它不会与标准的 body HTML 标签冲突。我们获取子数据(应该是大量的 HTML),设置类和TagName属性,最后将内容设置为 body 标签内的内容。
最后,为了让我们的标签助手工作,我们需要通过_ViewImports.cshtml文件将项目中的所有TagHelper实例包含进来,如下所示:
@addTagHelper *, BucksCoffeeShop
我们可以在 HTML 中添加一个按钮来触发offcanvas,如下所示:
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasExample" aria-controls="offcanvasExample">
Open
</button>
我们现在可以在我们的 HTML 中创建一个简单的offcanvas组件,如下所示:
<offcanvas id="offcanvasExample" tabindex="-1">
<header>Buck's Coffee Shop</header>
<body>
<p>
Content for the offcanvas goes here.
You can place just about any Bootstrap
component or custom elements here.
</p>
</body>
</offcanvas>
一旦渲染完成,它将生成一个有效的OffCanvas组件的 Bootstrap 结果。
这种强大的技术可以应用于创建任何领域的简化 HTML 语言,即使非开发者也能理解网页。
在本节中,我们选取了一个示例网站并优化了我们的客户端脚本和样式,通过创建 URL Helper 扩展方法集中了我们的链接,并最终构建了一个OffCanvas Tag Helper 来展示我们如何创建自己的 HTML 库中的强大元素。
摘要
在本章中,我们首先讨论了任务运行器对于客户端任务的重要性以及如何设置任务运行器,然后了解了 gulpfile 的结构以及如何创建工作流程结构。一旦我们的任务运行器开始工作,我们就将其用于将 TypeScript 转换为 JavaScript,并将其捆绑和压缩成适合生产网站的内容。
我们继续探讨在 ASP.NET 8 中 UI 的标准是什么。我们学习了如何通过集中所有链接在一个地方来节省时间,为什么保持控制器和 Razor Pages 小很重要,以及为什么应该使用ViewComponent类的关键原因。我们还学习了为什么TagHelper类比HTMLHelper类更好,以及为什么网站应该使用 SEO 友好的 URL。
为了完成本章,我们查看了一个示例网站,并使用任务运行器捆绑和压缩样式,同时在构建过程中自动更新库。我们还应用了 URL Helper 扩展方法到网站上,使其更容易更改网站范围内的链接。最后,我们将 Bootstrap 的offcanvas HTML 组件转换成了一个可重用的TagHelper类,以展示 Tag Helper 的强大功能。
在下一章中,我们将探讨不同的测试类型以及测试我们代码的最佳方法。
第七章:测试您的代码
测试代码在开发中是一个多义词。它可以指代多个概念,例如负载测试、单元测试或集成测试,仅举几例。所有测试概念对于开发者理解软件开发生命周期(SDLC)都至关重要。每个概念都有其目的,并且对于提供稳定性和信心同样重要,甚至可以提供文档。
在本章中,我们将介绍以下主要主题:
-
理解测试概念
-
测试的最佳方法
-
测试 Entity Framework Core
在第一部分,我们将介绍测试的基本概念,包括单元测试、集成测试、回归测试、负载测试、系统测试和 UI 测试。
接下来,我们将回顾编写稳健测试软件的一些最佳方法,包括为什么测试是必要的,需要多少单元测试,在编写单元测试时使用安排(Arrange)、执行(Act)、断言(Assert)(AAA)技术,为什么应该避免单元测试脚手架,为什么应该避免大型单元测试,如何以及为什么应该避免静态方法,以及最后如何使用测试进行文档化。
最后,我们将通过创建一个完整的集成测试来应用本章的知识,使用 Entity Framework Core。
技术要求
我们建议使用您喜欢的编辑器查看本书的 GitHub 仓库。我们的建议包括以下内容:
-
Visual Studio(最好是 2022 版,尽管任何版本都可以)
-
Visual Studio Code
-
JetBrains Rider
我们将使用的编辑器是 Visual Studio 2022 Enterprise,但任何版本(社区版或专业版)都可以与代码一起使用。
本章的代码位于 Packt Publishing 的 GitHub 仓库中,位于Chapter07文件夹,网址为github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices。
理解测试概念
单元测试对于开发者来说很重要,因为它们为软件提供稳定性,对其代码有信心,并且提供了记录复杂代码的额外好处。在本章中,我们将看到单元测试提供了许多好处。
在本节中,我们将回顾测试的概念以及为什么每个概念对于构建网站时的稳定性和信心都至关重要。
单元测试
我们能编写的最小测试来确认代码按预期工作的是单元测试。单元测试通常用于测试小(或较小)的方法,以及测试发送到这些方法的多参数并期望得到特定结果。
确定要编写的单元测试类型是一个简单的过程,即找到方法中的条件并编写相关的测试以实现该行为。
以下场景可以证明在方法中编写单元测试的合理性:
-
方法的成功流程(即,成功的路径)
-
方法行为的失败(即,不愉快的路径)
-
任何分支或条件(例如 if..then、switch、内联 if 等)
-
方法中传递的不同参数
-
异常处理
如果我们有一个大型系统,单元测试的数量预计会有数百个,因为这些小而细粒度的方法构成了系统的大部分。
以下示例展示了一个扩展方法,用于格式化日期/时间对象以供显示。由于它是自包含的,我们可以简单地创建一个日期/时间对象并对其进行测试:
[TestMethod]
public void FormattedDateTimeWithTimeTest()
{
// Arrange
var startDate = new DateTime(2023, 5, 2, 14, 0, 0);
const string expected = @"May 2<sup>nd</sup>, 2023 at 2:00pm";
// Act
var actual = startDate.ToFormattedDateTime();
// Assert
Assert.AreEqual(expected, actual);
}
此代码创建了一个新的日期和时间。单元测试使用 AAA 技术进行格式化(我们将在稍后介绍),并返回一个要在我们的 HTML 中显示的字符串。
一旦设置了单元测试,下一步就是提供集成测试。
集成测试
测试的下一级别是集成测试,这需要额外的系统测试功能。集成测试涉及将模块分组并能够作为一组测试这些组件,这与单元测试形成对比。
根据我的经验,集成测试和单元测试之间的主要区别是外部资源。如果一个单元测试访问外部资源(例如磁盘驱动器、数据库、网络调用或 API),它可以被认为是集成测试。
以下示例展示了一个简单的集成测试,其中我们连接到本地测试数据库以确认数据库的连接并返回一个有效的 IndexModel 对象:
[TestClass]
public class PageTests
{
private DbContextOptions<ThemeParkDbContext> _options;
private ThemeParkDbContext _context;
[TestInitialize]
public void Setup()
{
_options = new DbContextOptionsBuilder<ThemeParkDbContext>()
.UseSqlServer("Data Source=localhost;Initial Catalog=ASPNetCore8BestPractices;" +
"Integrated Security=True;MultipleActiveResultSets=True;")
.Options;
var config = new Mock<IConfiguration>();
_context = new ThemeParkDbContext(_options, config.Object);
}
[TestMethod]
[TestCategory("Integration")]
public void ReturnAnIndexModelTest()
{
// Arrange
var logger = new Mock<ILogger<IndexModel>>();
var service = new AttractionService(_context);
// Act
var actual = new IndexModel(logger.Object, service);
// Assert
Assert.IsNotNull(actual);
Assert.IsInstanceOfType(actual, typeof(IndexModel));
}
}
在 DbContextOptionsBuilder 中,我们连接到我们的本地数据库,创建了一个有效的 AttractionService,同时传递了我们的有效 ThemeParkDbContext,并确认我们拥有正确的模型类型。
最后,有不同方式进行集成测试,例如创建模拟的数据库或 API、复制环境,甚至为我们的测试创建新的服务器。
回归测试
回归测试是我们过去执行的功能性和非功能性测试。根据其本质,这些是我们对系统运行过的成功测试。回归测试是进行以确认新功能不会破坏现有功能的测试类型。这包括单元测试和集成测试。
负载测试
一旦你在 CI/CD 流程中运行了测试(单元和集成测试)(见第二章)并且预期会有大量用户访问网站,为该网站创建负载测试(或多个负载测试)是有利的。
当对网站进行负载测试时,将其置于单个开发者独立运行网站时体验到的压力水平之上。负载测试模拟了大量用户同时访问网站,并报告网站是否能够处理大量用户涌入。
负载测试的结果可能需要多个团队成员帮助提高网站的性能。不仅开发者会参与,还包括数据库管理员(DBAs)、系统管理员,甚至架构师都会参与修复网站的性能问题。
网站的性能持续提升应该是目标,创建负载测试来衡量性能是实现这一目标的关键。
系统测试(端到端或 E2E)
系统测试基于某些场景,是团队协作的结果。系统用户测试系统中新引入的功能,而其他团队成员则进行回归系统测试,以确认新功能不会破坏现有功能。
团队成员为用户创建场景。然后要求这些用户逐一走过每个场景,并提供关于其是否有效的工作反馈。
一个示例场景可以分解为几个类别,如下所示:
-
购物车:
-
登录网站
-
将商品放入购物车
-
点击结账
-
收到带有订单号的确认页面
-
如果场景成功,顶级场景(“购物车”)将包含一个绿色的勾选标记,表示所有步骤都通过且没有问题。如果某个步骤失败,则会出现一个带有原因的红色“X”,并将其放入待办事项中供开发人员稍后检查。
这些类型的测试需要多个用户通过网站并找出特定场景中的问题。有时手动测试网站是必要的,但如果有时间,可能更有意义的是使用用户界面(UI)测试方法来自动化这些场景。
UI 测试
UI 测试通过如 Selenium 或 Cypress 等软件工具进行视觉操作,并自动化最终用户在给定场景下的点击或客户在网站上的导航。这要求测试人员了解 UI 测试软件的工作原理;他们应该知道如何访问页面上的元素,知道如何将这些值放入这些元素中,以及知道如何激活事件,如点击和模糊,以模拟最终用户点击按钮。
这些类型的测试通常在下班后通过 CI/CD 管道进行,但也可以在工作时间内在专用服务器(即 QA 服务器)上运行,以尽早识别问题。
在本节中,我们回顾了各种类型的测试,包括单元测试、集成测试、回归测试、负载测试、系统测试和 UI 测试,以及每种测试的重要性及其如何利用其他测试。
在下一节中,我们将探讨开发人员对单元测试的习惯,包括为什么编写单元测试,驳斥 100%测试覆盖率神话,使用 AAA 是单元测试的绝佳方法,为什么我们应该编写单元测试库,创建大型单元测试,为什么应该避免不必要的模拟,以及为什么单元测试具有额外的文档优势。我们还将学习如何识别缓慢的集成测试,何时编写单元测试,以及如何避免测试.NET 代码。
测试的最佳方法
每家公司都有其测试软件的方式。无论是手动还是自动化,对开发人员来说都是一项要求。开发人员正在成为不仅仅是编写代码的人。我们被要求设计、编写、测试、调试、编写文档、构建和部署软件。
使用 CI/CD 方法自动化测试可以帮助公司在向公众发布软件时节省时间,并提供一致性和质量。
在本节中,我们将讨论测试为什么重要,如何避免 100%测试覆盖率的神话,AAA 是什么以及为什么它是单元测试的绝佳方法,如何避免编写单元测试库、大型单元测试和不必要的模拟。我们还将了解为什么测试具有额外的文档优势,如何识别缓慢的集成测试,何时编写单元测试,以及如何避免测试.NET 代码。
我们为什么编写测试?
当有些人认为测试应该从开发者开始时,我认为它应该从管理层开始。
开发者认为在编写软件时测试是一个绝对的要求,但如果管理层认为测试是浪费时间,那么可能是时候更新简历了。
管理层以及可能的一些开发者对软件的稳定性以及测试对时间表的影响有不同的看法。
测试的原因很简单:它允许开发者确认他们编写的代码按预期执行。此外,如果测试提供了简单的方式来消费某个代码模块,那么这些测试也为其他人以及原始作者提供了清晰度。
“100%测试覆盖率”的神话
当我们将摆锤摆向另一边时,一些管理者要求 100%的测试覆盖率。尽管有些人认为这是可能的,但 100%是无法实现或理想的。
测试应该在需要的地方创建,而不仅仅是为了覆盖率的 sake。如果为了满足某个指标而包含一些单元测试,这会创造一个虚假的指标,并呈现出“100%测试覆盖率”的幻觉。开发者可能会为了“完成配额”而构建测试,以实现这个神话般的指标。每个测试都应该提供与坚实结果和价值的完整性。
这个指标与代码行数(LoC)的虚假指标也密切相关,因为能够用最少的行数编写代码的开发者比编写效率低下的开发者更有效率。更多并不总是意味着更好。
使用 AAA
当我们编写单元测试时,最好的方法是使用 AAA 技术:
-
安排:为测试初始化代码
-
行动:执行实际测试
-
断言:确定结果是否是预期的行为
安排步骤应该初始化代码,并尽可能保持最小化。
行动步骤应该执行相关代码,并且应该看起来类似于(如果不是完全一样)生产环境中的代码。
最后,断言步骤将结果与我们期望从代码中返回的内容进行比较。
AAA 技术提供了识别系统测试如何进行的最容易的方法。
避免为你的代码编写单元测试代码
虽然 AAA 概念是编写单元测试的简单方法,但我认为还有另一种方法,我认为这是一种代码异味。
想象这个场景:开发者必须编写一个单元测试,其中他们必须使用 Entity Framework Core 进行数据库调用。安排步骤有 30 行代码来准备行动步骤以正常工作。开发者将这些 30 行代码移动到一个库中,使它们可用于其他单元测试。
这 30 行代码是我提到的代码异味。将代码重构并进一步抽象以简化代码更有意义。安排步骤不应包含一个额外的自定义代码库来运行单元测试。它应该专注于测试已经编写好的生产代码,而不是编写额外的测试代码来使代码通过。
然而,如果需要辅助库,它不应包含任何分支语句,这将需要单元测试单元测试辅助库的需求。
避免大型单元测试
开发者对“大”的定义各不相同。应避免具有超过一页(一个屏幕)的代码或超过 30 行代码的单元测试。
这些类型的单元测试方法存在一个问题:它们仅一步之遥就快要创建一个用于我们之前提示的安排步骤的库。
再次强调,这被认为是一种代码异味,可能需要某人退一步,寻找更好的方法来产生更小的设置,而不是大块的初始化代码。
避免不必要的模拟、伪造或存根
有时,一个类上的方法没有任何依赖项,并且是完全隔离的。当我们遇到这种情况时,我们可能不需要创建模拟对象来完全单元测试该方法。
模拟是我们想要测试预定义行为时的情况,存根返回预定义的值。伪造是具有工作实现的完整填充对象。
在本章的开头,我们提到了单元测试是什么。我们还创建了一个简单的扩展方法,称为 .ToFormattedDateTime()。由于它是一个独立的方法,我们不需要模拟日期/时间对象。我们只需调用该方法。
例如,如果我们有一个包含字符串的大库的扩展方法,我们可以创建一个单元测试,创建一个字符串,传递它,并检查返回值是否如预期。如果需要进一步测试,可以用不同的参数重复这个过程。
当有一个简单的方法时,有时测试其功能更容易。不需要模拟、伪造或存根。
使用测试作为文档
每个单元测试都应该包含一些解释,说明测试了什么,无论是在方法签名中还是在注释中解释情况。
单元测试应该对同行(以及我们的未来自己)具有信息性,并表明对完成每个单元测试所涉及的内容以及它与测试的生产代码的关系的了解。
除了信息性单元测试外,目录结构也可以作为文档,并且可以走很长的路。我们应该在单元测试中反映应用程序的目录结构。
如果我们在应用程序(或项目中)看到一个名为Data的文件夹,为单元测试创建一个类似的文件夹或项目,命名为Data.Tests。虽然这可能是一个简单的概念,但它帮助我们的同事立即知道测试与项目中的哪些部分相关。
例如,如果我们查看一个带有单元测试的示例项目,你可能已经看到了这种结构:

图 7.1 – 项目良好结构化测试的示例
虽然在ThemePark.Tests项目中可能缺少一些文件夹,但我们可以立即看到Extensions、Pages和Services文件夹至少包含一种测试类型。存在与项目文件夹相对应的测试文件夹表明它们包含单元或集成测试,并表明需要在测试项目中包含额外的测试。
识别缓慢的集成测试
集成测试使用外部资源(如数据库、文件系统或 API)进行。因此,集成测试的运行速度总是比单元测试慢。
如果我们正在使用测试环境来模拟另一个环境(例如,安排一个 QA 环境来模拟生产环境),检测缓慢连接的能力提供了一个保障,即环境按预期工作。你以前听说过“在 QA 中工作得很好,但在生产中不行”的说法吗?
然而,如果我们正在处理环境的内存表示,确定测试是否缓慢就没有意义,对吧?与实际环境相比,环境的内存表示总是运行得更快。
例如,使用Stopwatch类来衡量过程(页面或 API)以确定它们是否运行得快或非常快。
如果我们查看我们的 Entity Framework 示例来自第五章,并且我们向项目中添加一个集成测试,我们可以创建一种简单的方法来识别我们的页面调用是否缓慢,如下面的示例所示:
using System.Diagnostics;
using EFApplication.Pages;
using EFApplication.Services;
using Microsoft.Extensions.Logging;
using Moq;
namespace ThemePark.Tests.Pages;
[TestClass]
public class PagesTest
{
[TestMethod]
[TestCategory("Integration")]
public void ConfirmTheMainPageReturnsWithinThreeSecondTest()
{
// Arrange
var logger = new Mock<ILogger<IndexModel>>();
var service = new Mock<IAttractionService>();
var stopwatch = Stopwatch.StartNew();
// Act
_ = new IndexModel(logger.Object, service.Object);
// Assert
// Make sure our call is less than 3 seconds
stopwatch.Stop();
var duration = stopwatch.Elapsed.Seconds;
Assert.IsTrue(duration <= 3);
}
}
在这个集成测试中,我们正在测试我们的ThemePark应用程序的主页,以确定其性能是否良好。首先,我们安排我们的类,因为IndexModel接受ILogger<PageModel>和IAttractionService。一旦我们创建好计时器,我们就调用IndexModel(Act)并立即停止计时器。我们将它转换为秒,并执行我们的断言步骤。
当然,我们正在进行内存集成测试以供说明,但这个概念最适合与外部资源一起进行的集成测试,以识别延迟问题。
在上面的示例中,仅通过阅读测试我们无法判断它是否在内存中。它被封装在IndexModel中,其目的是确定它是否执行得快。
找到错误,编写测试
单元测试对于稳定的产品至关重要,无论它是 Web 应用程序还是智能手机应用程序。每个开发者都肯定会遇到应用程序中的错误,因此始终保持单元测试是最新的是有意义的。
当我们(或用户)遇到错误时,重复这个咒语:找到错误,编写测试。这可能是一个简单的概念,但这是推荐的。
当任何人发现应用程序中的错误时,立即为该错误编写一个单元测试。这使我们部署应用程序时感到安心。如果我们有一个错误并且确认单元测试修复了问题,我们将有足够的信心说它在部署前已经过测试。修复问题是一回事,但添加测试使代码更加坚不可摧,并提供了信心,即错误不会再次发生。
避免测试 .NET
.NET 是一个庞大的框架。单元测试旨在测试特定的代码。你的代码。当微软已经测试过时,就没有必要为 .NET 代码(或其他库/框架)创建单元测试。
例如,如果有一个测试用于确定子字符串方法是否返回正确的值,这实际上是测试 .NET 框架。不要编写这个 单元测试。
我们的努力更适合于方法的高层次范围。专注于测试我们代码所在的位置的调用方法,而不是 .NET 方法。
在本节中,我们探讨了创建单元测试的重要性以及为什么 100% 的测试覆盖率是一个神话。我们还了解了各种常见的单元测试策略,例如如何使用 AAA 框架进行单元测试,为什么在创建大型单元测试时编写额外的单元测试库被认为是代码异味,以及为什么有时不需要为所有内容使用模拟库。
我们还学习了如何使用注释和文件夹将测试作为文档,通过添加计时器来识别慢速集成测试,找到错误并立即编写测试以进一步增强代码的坚不可摧性,以及如何避免测试 .NET 方法。
在下一节中,我们将回顾我们的策略并将它们应用于第五章中的 ThemePark 应用程序。
测试数据访问
几年来,Entity Framework 一直努力成为可单元测试的,并在与数据访问工作时应开发者拥有更高的信心水平。
基于第五章中我们使用 Entity Framework Core 创建示例数据库的情况,在本节中,我们将介绍一种使用 SQLite 内存数据库来确认应用程序功能的方法……即使我们没有数据库连接。使用内存提供者选项,微软建议避免这种方法,而是使用 SQLite 进行数据库调用或使用生产(或更好的,QA)数据库进行查询。
避免内存提供者
由于内存是一个极其简化的数据库实现,Microsoft 建议使用替代方法进行测试,并避免使用内存提供程序进行数据库测试。有关详细信息,请参阅以下 URL:learn.microsoft.com/en-us/ef/core/testing/testing-without-the-database。
添加 SQLite 提供程序
由于我们还没有在测试中访问数据的方法,我们必须添加 SQLite 以尽可能接近实现。使用 NuGet,我们必须添加以下 NuGet 包:
-
Microsoft.EntityFrameworkCore.Sqlite -
Microsoft.EntityFrameworkCore.InMemory
一旦我们在测试中有了这些,我们就可以继续创建我们的AttractionService和LocationService测试,以确认它们按预期工作。
创建 AttractionService 测试
由于我们将AttractionService用作“仓库”,我们只需要传入DbContext即可按预期工作。目前,ThemeParkDbContext为空数据库创建种子数据。
这非常适合我们的需求,因为当传入ThemeParkDbContext时,DbContext可以是内存中的表示形式或实际连接到生产数据库。在这种情况下,我们正在创建一个内存中的 SQLite 数据库以供我们的目的使用。
SQLite 提供程序在调用时打开连接,并在关闭时删除连接。我们在设置期间创建连接,并提供一个[Cleanup]属性来释放连接。这专门针对 SQLite。
我们的AttractionService集成测试示例如下:
using Microsoft.EntityFrameworkCore;
using System.Data.Common;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Moq;
using ThemePark.DataContext;
using ThemePark.Services;
namespace ThemePark.Tests.Services;
[TestClass]
public class AttractionServiceTest
{
private DbConnection _connection = null!;
private DbContextOptions<ThemeParkDbContext> _options = null!;
private IThemeParkDbContext _context = null!;
[TestInitialize]
public void Setup()
{
_connection = new SqliteConnection("Filename=:memory:");
_connection.Open();
// These options will be used by the context instances in this test suite,
// including the connection opened above.
_options = new DbContextOptionsBuilder<ThemeParkDbContext>()
.UseSqlite(_connection)
.Options;
var config = new Mock<IConfiguration>();
// Create the schema and seed some data
_context = new ThemeParkDbContext(_options, config.Object);
_context?.Database.EnsureCreated();
}
[TestCleanup]
public void Cleanup()
{
_connection.Dispose();
}
[TestMethod]
public async Task ReturnAllAttractionsTest()
{
// Arrange
var service = new AttractionService(_context);
// Act
var records = await service.GetAttractionsAsync();
// Assert
Assert.IsTrue(records.Any());
}
}
在前面的示例中,我们使用[TestInitialize]属性告诉我们的测试运行设置方法,并使用[TestCleanup]属性清理我们的混乱。[TestInitialize]属性用于初始化目的的方法。[TestCleanup]属性标识一个用于清理由[TestInitialize]属性初始化的内容的方法。
由于我们使用 SQLite 作为数据库,我们必须在Setup()方法中创建一个连接并打开它。一旦打开,我们需要为我们的模拟数据库创建DbContextOptions。最后一步是确保数据库已为我们测试创建。
这里有两个需要注意的地方。首先,我们不需要为DbContext创建模拟对象。在DbContext的OnConfiguring()配置方法中,如果我们有配置(例如appsettings.json文件),我们应该使用它。如果没有,我们应该为测试创建一个 SQLite 内存数据库。
第二个需要注意的地方是我们集成测试中的 Act 步骤。这一行应该与我们在生产代码中拥有的相同。我们越能使测试调用与生产中的调用相匹配,我们就越有信心,同时也会对代码、测试的准确性和价值更有信心。
创建 LocationService 测试
由于我们现在有了测试的结构,我们可以使用这些测试来针对LocationService。我们的LocationService测试包括两个方法——GetAllLocationsAsync()和GetLocationAsync(int):
[TestMethod]
public async Task ReturnAllLocationsTest()
{
// Arrange
var service = new LocationService(_context);
// Act
var records = await service.GetLocationsAsync();
// Assert
Assert.IsTrue(records.Any());
}
[TestMethod]
[TestCategory("Integration")]
public async Task ReturnOneLocationByIdTest()
{
// Arrange
var service = new LocationService(_context);
// Act
var record = await service.GetLocationAsync(1);
// Assert
Assert.IsNotNull(record);
Assert.IsTrue(record.Id==1);
}
再次注意,我们不需要模拟DbContext。我们通过传递ThemeParkDbContext来创建LocationService,并像在生产环境中一样使用它。设置和拆除完整数据库的能力是测试数据库功能的最有效方法之一。虽然使用现有数据库同样有益,但这提供了一种更快的“设置”和“拆除”数据库功能的方法,避免了当其他人更新数据库时的混乱或修改。如果其他人使用现有数据库,这可能导致集成测试在 CI/CD 管道中失败。
在本节中,我们学习了如何使用 SQLite 设置测试,以及如何使用内存数据库执行查询来模拟生产数据库。我们还提供了三个测试 Entity Framework Core 的示例。
摘要
测试和文档通常是开发团队优先级较低或被忽视的领域。然而,测试是代码库的必要要求。作为一个最后的观点,开发者应该尽可能使用接近生产环境的代码在 Act 步骤中使测试尽可能小和快。
在本章中,我们介绍了不同类型的测试,包括单元测试、集成测试、回归测试、负载测试、系统(或端到端测试)和 UI 测试。
一旦我们理解了这些测试类型之间的区别,我们就探讨了为什么创建单元测试很重要,以及为什么测试覆盖率目标不应该达到 100%。然后我们介绍了常见的单元测试策略,例如如何为我们的单元测试使用 AAA 脚手架,为什么为单元测试编写过多的代码被认为是代码异味,以及为什么不需要模拟库。
最后,我们学习了如何通过使用注释和文件夹来补充文档,如何通过添加计时器来识别慢速集成测试,如何找到错误并立即编写测试来进一步增强我们的代码的安全性,以及如何避免测试.NET 方法。
在下一章中,我们将介绍异常处理以及处理应用程序错误的一些更好的方法。
第八章:使用异常处理捕获异常
在构建网络应用程序时,我们总是尽力使我们的代码尽可能稳定,但有时我们无法捕获所有内容。这就是为什么异常被认为是开发的基础部分。异常处理对于防止网络应用程序崩溃并在页面上显示丑陋的错误消息至关重要。用 try/catch 或 try/finally 语句包裹一切是很诱人的。但这应该避免。在应用程序中使用 try/catch/finally 语句应该是例外。
本章中的常见编码标准旨在消除这些类型的场景,并提供更好的开发者体验。
在本章中,我们将探讨异常处理对开发者的意义,何时使用它,以及在哪里处理全局异常,并检查性能考虑。一旦我们理解了异常处理的基础知识,最后一节将涵盖一些常见的异常处理实践,例如将“预防胜于异常”原则应用于代码,使用日志记录,了解异常处理与单元测试的相似之处,以及为什么应该避免空的 catch 块。
最后,我们将学习如何使用 .NET 的新异常过滤器,结合模式匹配,何时使用 finally 块,以及为什么重新抛出异常是个好主意。
在本章中,我们将涵盖以下主要主题:
-
使用异常处理
-
处理全局异常
-
性能考虑
-
常见的异常处理技术
完成本章后,您将更好地理解异常处理,何时以及如何使用它,如何实现全局异常处理,以及在使用异常处理时如何知道性能是否成为问题。
技术要求
我们建议使用您喜欢的编辑器,在本章中添加异常处理代码片段。我们的建议如下:
-
Visual Studio(最好是最新版本)
-
Visual Studio Code
-
JetBrains Rider
我们将要使用的编辑器是 Visual Studio 2022 Enterprise,但任何版本(社区版或专业版)都可以与代码一起使用。
使用异常处理
在本节中,我们将讨论什么是异常处理,两种错误处理类型,何时在应用程序中使用错误处理,以及异常如何影响性能。
什么是异常处理?
异常处理是在代码运行时从意外情况中优雅恢复的能力;我们如何处理在应用程序中遇到的问题或错误?它还涉及在出现问题时清理分配的资源,以避免内存泄漏。
有两种类型的错误:
-
运行时错误:这些是我们运行应用程序时遇到的意外错误。
-
在方法开头使用
ArgumentNullException.ThrowIfNull()来确认参数是否为 null。
由于本书主要面向中级到高级的开发者,我们假设调试 ASP.NET 应用程序是一个常见的流程;我们都知道调试和异常处理是相辅相成的。当开发者用try/catch块包裹代码时,他们应该对这段代码的功能有一个大致的了解。创建有用的异常的能力非常重要。异常应该是清晰和简单的。
例如,在我职业生涯的早期,一位开发者遇到了一条错误信息,告诉他们他们的磁盘空间即将耗尽。其他开发者也在应用程序中遇到了相同的错误,并疯狂地试图找出问题。问题最终被证明是由一位开发者创建的一个糟糕的错误信息,以及一个磁盘空间耗尽的服务器,而不是个别开发者的机器。这可以通过创建更好的日志消息或检测是否有可用磁盘空间来避免。虽然我们可以在异常处理程序中编写更好的错误信息,但我们只能保护代码到一定程度。
说真的——在编码时创建简单明了的错误信息可能是一个挑战,但从长远来看,这确实会带来影响。我们将在下一节中介绍一些常见的异常处理技术。
何时使用异常处理
识别代码是否需要异常处理的能力可能很棘手。除了是否需要try/catch/finally块之外,是否涉及我们需要清理的资源?
在异常处理方面,上下文很重要。根据我的经验,在添加异常处理之前,我总是要问自己三个问题:
-
使用
TryParse而不是完整的try/catch/finally块,或者由于无效参数而手动抛出错误。 -
外部资源是否会抛出异常? 例如,Web API、存储问题、文件丢失等。
-
如果发生错误,我必须自己清理吗? 例如,丢失文件连接、加载位图或数据库连接。
开发者只有在遇到他们无法处理且被认为是超出他们控制范围的代码行时才应使用异常处理,类似于磁盘驱动器空间不足的可能性。
在本节中,我们回顾了异常处理是什么以及何时正确使用它。
处理全局异常
如本章前面所述,当我们谈到 Web 应用程序时,我们只能处理这么多错误。但如果我们想为所有未处理的异常提供一个通用的解决方案呢?
对于全局异常,我们需要重新审视中间件。在Startup.cs文件中有一个名为UseExceptionHandler()的方法,它指向一个/Error页面(无论是 Razor 还是 MVC),如下面的代码片段所示:
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
特别注意 env.IsDevelopment() 条件。错误页仅用于非开发环境查看。正如我们在第四章中提到的关于安全性的内容,始终要小心在这个页面上显示什么。它可能会暴露系统数据,例如包含凭证或其他敏感数据的数据库连接字符串。
要通过错误页访问异常,我们需要通过 HttpContext.Features 获取 IExceptionHandlerPathFeature 实例。这可以在以下 /Error 页面的 OnGet() 方法中看到:
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
var exceptionFeature =
HttpContext.Features.Get<IExceptionHandlerPathFeature>();
// Access the Exception through exceptionFeature?.Error
// Access the Path through exceptionFeature?.Path
if (exceptionFeature?.Path == "/")
{
ErrorMessage ??= string.Empty;
ErrorMessage += " We have bigger problems if the main page is bombing.";
_logger.Log(LogLevel.Error, exceptionFeature?.Error, ErrorMessage);
}
}
HttpContext.Features 给我们提供了访问错误的权限。从那里,我们需要确定要在页面上显示什么。在这种情况下,我们可以看到主页面包含了错误。一旦我们确定了问题,我们可以创建一个公共消息,记录错误,并将其存储在 ErrorMessage 中,以便主页面可以显示它。
虽然这是一个简单的例子,但我们可以使用我们的中间件来捕获全局错误。我们不需要传递页面位置,可以在中间件中使用 Lambda 来处理异常,如下面的代码片段所示:
app.UseExceptionHandler(handler =>
{
var logger = loggerFactory.CreateLogger("Middleware");
handler.Run(async context =>
{
context.Response.StatusCode = StatusCodes. Status501NotImplemented;
context.Response.ContentType = MediaTypeNames.Text.Plain;
await context.Response.WriteAsync("Uh-oh...an exception was thrown.");
var exceptionFeature =
context.Features.Get<IExceptionHandlerPathFeature>();
if (exceptionFeature?.Path == "/")
{
var message = " Yep, the home page isn't implemented yet.";
await context.Response.WriteAsync(message);
logger.Log(LogLevel.Error, exceptionFeature.Error, $@"Error:{message}");
}
});
});
我们以与在 /Error 页面上相同的方式检索 ExceptionHandlerPathFeature 实例。当然,我们总是想记录错误,以便知道要修复什么(“又是那个讨厌的首页”)。
在本节中,我们学习了如何使用中间件创建全局异常处理器。这使我们能够集中处理错误,并避免在应用程序中设置过多的异常处理器。接下来,我们将关注编写异常处理器时的性能考虑。
性能考虑
关于异常处理的一个常见误解是它不会影响性能。如果异常严重损害了应用程序的性能,那么这是一个迹象,表明异常被过度使用了。异常绝对不应该控制应用程序的流程。
理想情况下,代码应该流畅无中断。在小型具有少量用户的网络应用程序中,这种方法可能是足够的,但对于高性能、高流量的网站,在频繁调用的代码中放置 try/catch 块可能会使网站的性能受到影响。
在本节中,我们介绍了异常处理的概念,回顾了应用程序中的两种错误类型,确定了异常处理理想的位置,并讨论了有关异常的一些性能误解。接下来,我们将探讨一些常见的异常处理技术。
常见的异常处理技术
异常在 .NET 中代价高昂。当应用程序中发生异常时,会有一套资源来开始错误处理过程,例如堆栈跟踪过程。即使我们在捕获和处理错误时,ASP.NET 仍在创建 Exception 对象及其所有相关内容,并沿着调用堆栈向上查找处理器。
在本节中,我们将探讨行业中的常见方法,通过“预防异常”来最小化异常,为什么使用日志记录,为什么单元测试与异常处理相似,为什么应该避免空的catch块,如何使用异常过滤和模式匹配简化异常,为什么在释放资源时块很重要,以及如何正确地重新抛出异常。
在异常之前进行预防
正如我们在上一节中说的,当遇到异常时,异常会中断应用程序的流程,并可能造成比预期更多的问题,例如释放之前分配的资源,并在调用栈中触发多个异常。如果我们正在编写try/catch块来控制程序的流程,我们就是在做错事。最好在用try/catch块包装代码之前进行检查。
如果从这个章节中只能学到一点,那就是这个原则:预防异常。
预防异常的概念是指尝试使用更少破坏性的方法来防止错误发生,例如完全停止网站的执行。
例如,检查以下代码:
var number = "2";
int result;
try
{
result = int.Parse(number);
}
catch
{
result = 0;
}
// use result
在一个愉快的路径中,数字将被解析,结果将是2。然而,如果字符串包含值为“hi”,结果将变为零。
更好的方法是使用最新的TryParse与var结合,如下所示:
var number = "2";
if (!int.TryParse(number, out var result))
{
result = 0;
}
// use result
采用这种方法,我们试图将数字转换并存储转换后的值到一个名为result的新变量中。TryParse在转换成功时返回true,不成功时返回false。如果转换失败,我们将result设置为0。
我们通过更简单的方式处理转换来预防异常。在.NET 中有大量的转换方法,我们可以不使用try/catch/finally块来完成相同的事情。
对于简单的异常,查看try/catch块,并询问我们是否可以在创建异常之前应用某种预防措施。
使用日志记录
在创建try/catch块时,最好展示我们想要从代码中获得的目的。错误是我们能够恢复的还是需要调查的?
如果是后者,在抛出错误之前最好向记录器发送一个构造良好的错误消息。正如在各个章节中提到的,最好使用日志策略来识别代码库中的问题。
如果我们使用之前的例子来确定导致错误的值,我们可以创建一个日志条目,如下所示:
var number = "hi";
int result;
try
{
result = int.Parse(number);
}
catch
{
// gives us "OnGetAsync:invalid number - hi"
_logger.LogInformation($"{MethodBase.GetCurrentMethod()}:invalid number - {number}");
result = 0;
}
// use result
虽然这为我们提供了清晰的日志条目,但对于我们的try/catch块,我们也可以将这一行复制到TryParse示例中。
理念是提供足够的信息给开发者,同时又不让错误打扰用户的体验。
应用单元测试方法
如果我们不得不使用 try/catch 块,就要把它看作是一个单元测试。我们提到单元测试有 AAA 方法(安排、行动和断言)。我们希望创建尽可能少的代码来完成工作并继续前进。
在开始时初始化对象(安排),并在导致错误的可疑行周围包裹一个 try/catch 块(行动)。尽量减少 try/catch 块内的代码量。
再次,我们可以将这种方法应用于前面修改过的代码示例,如下所示:
// Arrange
var number = "2";
int result;
try
{
// Act
result = int.Parse(number);
}
// Assert
catch
{
// gives us "OnGetAsync:invalid number - hi"
_logger.LogInformation($"{MethodBase.GetCurrentMethod()}:invalid number - {number}");
result = 0;
}
// use result
在 try 语句之上的任何内容都会被认为是 Arrange 部分;try 括号内的单行语句被认为是 Act。Assert 部分将是 catch 语句的一部分。这个语句将相当于一个失败的 Assert。
避免空的 catch 语句
虽然防止错误对我们应用程序至关重要,但请考虑以下示例中的空 catch 语句:
private void Deposit(Account myAccount, decimal amount)
{
try
{
myAccount.Deposit(amount);
}
catch { }
}
当 Deposit() 方法不起作用时会发生什么?如果我们代码中确实有错误,catch 语句应该包含一些内容来让开发者知道发生了错误。至少,应该有一个 Log 语句来通知团队有关问题。
虽然空的 try/catch 块可以防止程序崩溃,但它会引发更大的问题。一旦有人发现存款有问题,由于没有记录问题,可能很难找到问题所在。
使用异常过滤和模式匹配
如果我们正在处理代码段中的多个异常,.NET 中的一个新特性,称为异常过滤,可以使异常处理更加简洁。如果我们有紧凑的代码,异常过滤可以提供更高效和现代的代码库。
例如,文件处理常常会导致各种异常。考虑以下代码片段:
FileStream fileStream = null;
try
{
fileStream = new FileStream(@"C:\temp\myfile.txt", FileMode.Append);
}
catch (DirectoryNotFoundException e)
{
_logger.Log(LogLevel.Information, MethodBase.GetCurrentMethod()+":Directory not found - " + e.Message);
}
catch (FileNotFoundException e)
{
_logger.Log(LogLevel.Information, MethodBase.GetCurrentMethod()+":File Not Found - " + e.Message);
}
catch (IOException e)
{
_logger.Log(LogLevel.Information, MethodBase.GetCurrentMethod()+":I/O Error - " + e.Message);
}
catch (NotSupportedException e)
{
_logger.Log(LogLevel.Information, MethodBase.GetCurrentMethod()+":Not Supported - " + e.Message);
}
catch (Exception e)
{
_logger.Log(LogLevel.Information, MethodBase.GetCurrentMethod()+":General Exception - " + e.Message);
}
// Use filestream
在前面的代码中,我们记录每个异常,并根据特定的异常提供消息。使用异常过滤,我们可以缩短行数,使其更容易阅读:
FileStream fileStream = null;
try
{
fileStream = new FileStream(@"C:\temp\myfile.txt", FileMode.Append);
}
catch (Exception e) when
( e is DirectoryNotFoundException
|| e is FileNotFoundException)
{
_logger.Log(LogLevel.Warning, $"{MethodBase.GetCurrentMethod()}:{nameof(e)} - {e.Message}");
}
catch (Exception e) when
(e is NotSupportedException
|| e is IOException)
{
_logger.Log(LogLevel.Error, $"{MethodBase.GetCurrentMethod()}:{nameof(e)} - {e.Message}");
}
catch (Exception e)
{
_logger.Log(LogLevel.Error, $"{MethodBase.GetCurrentMethod()}:{nameof(e)} - {e.Message}");
}
// Use filestream
在前面的代码中,我们将 DirectoryNotFoundException 和 FileNotFoundException 进行分组,并将它们记录为警告。当我们遇到 NotSupportedException 或 IOException 错误时,我们认为这是一个更大的问题,并将这些错误记录为错误。任何其他通过的内容都将被捕获为一般异常,并以消息的形式记录为错误。
除了异常过滤之外,.NET 还引入了另一个新特性,称为模式匹配。使用模式匹配,我们可以进一步缩短代码:
FileStream fileStream = null;
try
{
fileStream = new FileStream(@"C:\temp\myfile.txt", FileMode.Append);
}
catch (Exception e)
{
var logLevel = e switch
{
DirectoryNotFoundException => LogLevel.Warning,
FileNotFoundException => LogLevel.Warning,
_ => LogLevel.Error
};
_logger.Log(logLevel, $"{MethodBase.GetCurrentMethod()}:{nameof(e)} - {e.Message}");
}
// Use filestream
前面的代码在 catch 括号内使用 switch 语句来识别我们正在经历的异常类型。将 switch 模式匹配视为内联的 if 语句。前面的行根据异常类型返回 logLevel 的值。
下划线(_)被称为丢弃变量(它类似于 switch 语句中的默认值)。如果其他所有情况都通过 switch,那么我们将默认将日志级别设置为LogLevel.Error,并使用当前方法、异常类型名称和异常消息记录消息。
异常处理可能会很冗长,例如,基于 I/O 的方法和连接。异常过滤和模式匹配可以帮助减轻捕获各种异常的冗长性。
使用finally块进行清理
当处理数据库连接、基于文件的操作或资源时,最好使用finally来进行清理。
例如,如果我们连接到数据库并在之后想要关闭连接,我们就必须创建一段类似于以下代码的代码:
using System.Data.SqlClient;
var connectionString = @"Data Source=localhost;Initial Catalog=myDatabase;Integrated Security=true;";
using SqlConnection connection = new SqlConnection(connectionString);
var command = new SqlCommand("UPDATE Users SET Name='Jonathan' WHERE ID=1 ", connection);
try
{
command.Connection.Open();
command.ExecuteNonQuery();
}
catch (SqlException ex)
{
Console.WriteLine(ex.ToString());
throw;
}
finally
{
connection.Close();
}
在前面的代码中,连接对象被传递到SqlCommand构造函数中。当我们执行 SQL 查询时,命令被传递到连接并执行。一旦我们的代码执行完毕,我们在finally语句中关闭连接。由于我们有using语句,并且SqlConnection类实现了IDisposable接口,它将自动被销毁。
有时我们需要finally语句来进行清理,但有时它们是不必要的。
知道何时抛出
在之前的代码示例中,我们使用了throw;而不是throw ex;。
如果我们运行上一节中的代码示例并使用throw;,我们会在 Visual Studio 的调用栈窗格中看到栈跟踪:

图 8.1 – 使用简单throw;的调用栈快照
如果我们将该行更改为throw ex;会发生什么?

图 8.2 – 使用throw ex;的调用栈快照
栈跟踪完全消失。如果没有栈跟踪,错误将更难追踪。有时我们只想简单地抛出异常。总是重新抛出异常更好,这样栈跟踪就可以保持完整,以便定位错误。
在本节中,我们讨论了许多在代码中应用异常处理时被认为是实用的标准。我们学习了如何通过“预防胜于异常”来最小化异常,为什么记录日志很重要,以及为什么异常处理就像单元测试一样。
我们还学习了如何避免空的捕获块,使用异常过滤和模式匹配简化异常,何时使用finally块,以及如何正确地重新抛出异常。
摘要
异常处理很重要,但在编写真正健壮的应用程序时需要一定的经验。应用程序需要恢复,以便用户不会有一个令人不快的体验。
在本章中,我们学习了异常处理、何时使用它以及它在哪些情况下是有意义的,以及性能考虑。
我们通过理解“预防胜于异常”原则、了解日志记录的重要性以及异常处理为何像单元测试一样重要,来结束本章的学习。
我们还了解到空捕获块是浪费的,如何通过异常过滤和模式匹配简化异常,何时使用 finally 块,以及如何正确地重新抛出异常。
在下一章中,我们将探讨 Web API 标准以及它们对 ASP.NET Core 生态系统极端重要性的原因。
第九章:创建更好的 Web API
Web API 是互联网的核心。它们为开发者提供了网络的开放性和访问互联网上任何数据的可能性。然而,有一些最佳实践是针对 API 的。选择正确的 HTTP 动词、如何记录 API 以及测试 API 只是我们将要讨论的一些主题。
话虽如此,本章中涵盖的技术广泛且密集。我们将尽量提供尽可能多的信息,以帮助构建高质量的 API。我们还将提供相关链接以供进一步研究。
在本章中,我们将涵盖以下主要主题:
-
快速创建 API
-
设计 API
-
测试 Web API
-
标准化的 Web API 技术
在本章中,我们将学习如何设计、创建、测试和记录 API,以及如何通过 CI/CD 管道执行 API 的全面端到端测试。
我们将通过回顾一些编写 API 的更常见技术来结束本章,例如使用正确的 HTTP 动词和状态码、如何避免大型依赖资源、如何在 API 中实现分页、API 版本控制、使用 DTO 而不是实体,以及从 .NET 调用其他 API 的最佳方式。
技术要求
在 .NET 8 中,Web API 占据了主导地位。Visual Studio 增加了新功能,使构建和测试 Web API 更加容易。对于本章,我们建议使用 Visual Studio 2022,但查看 GitHub 仓库的唯一要求是一个简单的文本编辑器。
第九章 的代码位于 Packt Publishing 的 GitHub 仓库中,网址为 github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices。
快速创建 API
使用 .NET 8,API 已集成到框架中,这使得创建、测试和记录 API 更加容易。在本节中,我们将学习一种快速简单的方法来使用 Visual Studio 2022 创建最小 API,并浏览它生成的代码。我们还将了解为什么最小 API 是构建基于 REST 的服务的最佳方法。
使用 Visual Studio
.NET 8 的一个特性是能够极快地创建最小 REST API。一种方法是通过使用 dotnet 命令行工具,另一种方法是通过使用 Visual Studio。要这样做,请按照以下步骤操作:
-
打开 Visual Studio 2022 并创建一个 ASP.NET Core Web API 项目。
-
在选择项目目录后,点击 下一步。
-
在项目选项下,进行以下更改:
-
取消选中 使用控制器 选项以使用最小 API
-
选中 启用 OpenAPI 支持 以包括使用 Swagger 的 API 文档支持:
-

图 9.1 – 网络最小 API 项目的选项
- 点击 创建。
就这样——我们得到了一个简单的 API!虽然它可能不是特别复杂,但仍然是一个完整的 API,带有 Swagger 文档。Swagger 是一个用于创建 API 文档并实现 OpenAPI 规范的工具,而 Swashbuckle 是一个使用 Swagger 实现微软 API 的 NuGet 包。如果我们查看项目,会发现有一个名为 Program.cs 的单个文件。
-
打开
Program.cs将会显示整个应用程序。这是 .NET 的一个优点——能够相对快速地创建一个脚手架式的 REST API:var builder = WebApplication.CreateBuilder(args); // Add services to the container. // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; app.MapGet("/weatherforecast", () => { var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast ( DateOnly.FromDateTime(DateTime.Now.AddDays (index)), Random.Shared.Next(-20, 55), summaries[Random.Shared.Next( summaries.Length)] )) .ToArray(); return forecast; }) .WithName("GetWeatherForecast") .WithOpenApi(); app.Run(); internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); }
在前面的代码中,我们通过 .CreateBuilder() 方法创建了我们的“应用程序”。我们还添加了 EndpointsAPIExplorer 和 SwaggerGen 服务。EndpointsAPIExplorer 允许开发者查看 Visual Studio 中的所有端点,我们将在后面介绍。另一方面,SwaggerGen 服务在通过浏览器访问时创建 API 的文档。下一行使用 .Build() 方法创建我们的应用程序实例。
-
一旦我们有了应用程序实例并且处于开发模式,我们就可以添加 Swagger 和 Swagger UI。
.UseHttpsRedirection()的目的是在网页协议为 HTTP 时重定向到 HTTPS,以使 API 安全。 -
下一行使用
.MapGet()创建我们的 GETweatherforecast路由。我们添加了.WithName()和.WithOpenApi()方法来标识要调用的主要方法,并让 .NET 知道它使用 OpenAPI 标准。最后,我们调用了app.Run()。 -
如果我们运行应用程序,我们将看到有关如何使用我们的 API 以及可用的文档化的 API。运行应用程序会产生以下输出:

图 9.2 – 我们文档化的 Web API 的屏幕截图
如果我们调用 /weatherforecast API,我们会看到返回带有 200 HTTP 状态的 JSON。

图 9.3 – 我们 /weatherforecast API 的结果
将这个小型 API 想象成将 API 控制器与中间件结合到一起的一个紧凑文件(Program.cs)。
为什么需要最小 API?
我认为最小 API 是 .NET 8 的一个特性,尽管它是一个语言概念。如果应用程序非常大,添加最小 API 应该以以下四种方式吸引人:
-
自包含:一个文件内的简单 API 功能对其他开发者来说很容易理解
-
性能:由于我们没有使用控制器,因此在使用这些 API 时不需要 MVC 的开销
-
跨平台:使用 .NET,API 现在可以部署在任何平台上
-
自文档化:虽然我们可以将 Swashbuckle 添加到其他 API 中,但它也会为最小 API 构建文档
接下来,我们将使用这些最小 API 并开始查看 Visual Studio 的测试功能。
在本节中,我们在 Visual Studio 中创建并审查了一个最小 API 项目,并讨论了为什么最小 API 对我们的项目很重要。
在下一节中,我们将探讨设计 API 以帮助消除长资源(URL)名称和标准化 API 命名的最佳方法。
设计 API
在本节中,我们将介绍向用户提供直观和清晰的 API 的最佳方法。API 的设计应该是经过深思熟虑的,当用户希望发起请求时,它应该是有意义的。
要创建一个真正基于 REST 的 API,我们必须使用不同的思维方式。我们必须把自己当作用户,而不是开发者。在编写 API 时,API 的用户是其他开发者。
断开与现有模式的连接
在设计 API 时,我们需要从用户的角度出发,而不是基于类层次结构或数据库模式来构建 API。虽然开发者可能会认为基于类层次结构或数据库模式创建 API 是一种捷径,但它可能会在检索数据时产生更多的复杂性。一个例子是使用订单资源来查找联系人。虽然 Entity Framework Core 中的订单实体可能包含一个Company属性,而我们需要公司的联系人信息,我们不会编写https://www.myfakesite.com/Order/15/Company/Contact。基于现有层次结构或模式构建 URL 结构应该避免。
在设计合理的 API 时,忽略现有模式至关重要。用新的视角看待 API 以获得最佳设计。最受欢迎的 API 是最干净和最直观的,因为它们使用了collection/item/collection语法。一个很好的例子是 /orders/15/companys。
识别资源
在系统中,观察用户如何与网站交互,并从特定场景中提取名词。这些将成为 API 的资源。
例如,用户可以在购物车系统中执行以下操作:
-
查看产品列表
-
查看产品
-
将产品添加到购物车
-
从购物车中移除产品
-
结账
从这些场景中,我们可以提取以下资源:
-
产品
-
产品
-
购物车
我们开始根据系统中的资源识别和逻辑分区我们的 API。
从这里,我们可以根据每个场景为每个资源应用一个 HTTP 动词。
将 HTTP 动词与资源相关联
一旦我们有了主要资源,我们就可以根据上一节中定义的特定场景为每个资源应用一个 HTTP 动词。
当创建一个 API 时,可能会倾向于使用名词/动词语法——例如,www.myurl.com/products/get 或 www.myurl.com/getproducts。这种方法是适得其反的,因为网络标准已经存在用于此目的。
虽然这样做是可行的,但它违反了一些 REST 原则(我们将在以下部分讨论标准化的 Web API 技术)。现在,让我们一步一步来,创建一个简单的购物车 API。
每个 HTTP 动词都有基于其上下文的默认操作:
-
GET:返回资源 -
POST:创建新的资源 -
PUT:根据标识符替换整个资源 -
PATCH:根据标识符更新资源中的特定项 -
DELETE:删除资源
例如,上一节中的场景可以根据资源和它们的动词开始成形:
-
GET /api/products:查看产品列表 -
GET /api/product/{id}:查看产品 -
POST /api/cart/{cartId}:使用POST数据(即new { ProductId = {productId}, Qty =1 })将产品添加到购物车 -
PATCH /api/cart/{cartId}:使用POST数据(即new { ProductId = {productId}, Qty = {``productId} })从购物车中删除产品 -
GET /api/cart/{cartId}:检索包含购物车中所有产品的购物车 -
POST /api/cart/{cartId}/checkout:结账
一旦我们将资源与已定义的场景匹配,我们就可以继续返回状态码给调用者。
返回 HTTP 状态码
在定义了资源之后,我们需要知道请求是否成功。这就是我们返回 HTTP 状态码的地方。
这些状态码分为以下类别:
-
1xx:信息代码
-
2xx:成功代码
-
3xx:重定向代码
-
4xx:客户端代码
-
5xx:服务器错误
与单元测试类似,我们查看“快乐”路径和“破损”路径。但是,对于 API,我们需要添加一个不可恢复路径,以防发生不可恢复的错误。
让我们看看两个 URL 及其应该返回的状态码。
GET /api/products 将返回以下状态码:
-
200 Success:成功返回产品
-
500 Internal Server Error:如果发生问题则可选
如果 API 成功,它将以 200 状态码返回产品列表。如果存在问题,它将返回 500 状态码。API 还可以返回其他状态码。例如,如果对特定用户进行 API 调用,API 可以返回 401,这是一个未授权状态码。
POST /api/cart/{cartId}带有正文(new { ProductId = {productId}, Qty = 1 })将返回以下状态码:
-
201 Created:项目已创建并添加到购物车
-
202 Accepted:项目已添加到购物车
-
404 Not Found:购物车或产品未找到
-
500 Internal Server Error:发生了不可恢复的错误
使用此 API,我们可以返回 201 Created 或 202 Accepted 状态码。如果我们找不到要添加到购物车的购物车或产品,则返回 404 状态码。否则,返回 500 状态码。
虽然这两个例子并不是一成不变的,但它们应该为团队提供一个讨论模板,以确定哪些业务规则决定了返回给用户的状态码。无论返回什么状态码,它们都应该提供足够的信息,以了解通过 API 发出的请求。
一些在野外看到的 API 使用全有或全无的方法;它们要么返回 200,要么返回 500。这取决于我们想要向客户端发送多少信息。这类 API 好像缺少更多功能,例如未经授权(401)或未找到(404)状态码。将尽可能多的信息包含到 API 的调用者中是一种最佳实践。
HTTP 状态码
HTTP 状态码在 Web 开发中是标准的,并通过 RFC HttpStatusCodeEnum 类以及每个状态码在 learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode 以及 IActionResults,如 Ok(object) 来呈现。具体的状态码可以在 learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.results.statuscode 找到。
在本节中,我们学习了如何设计 API,并分解了每个步骤——即断开与技术的连接,识别资源,知道应用于资源的正确动词,以及向我们的 API 提供正确的响应代码。
在下一节中,我们将探讨两种测试我们的 API 的方法:一种是在 Visual Studio 中使用新的 Endpoints Explorer,另一种是通过创建完整的集成测试。
测试 Web API
一旦我们设计和创建了我们的 API,我们需要一种方法在我们的 IDE 和集成测试中测试它们。幸运的是,Visual Studio 添加了新的 Endpoints Explorer。
在本节中,我们将学习两种测试我们的 API 的方法。一种是通过我们的开发环境使用 Visual Studio。第二种测试我们的 API 的方法是通过集成测试。如果我们有一个 CI/CD 管道(我们应该从 第二章 开始),这些将自动运行以确认我们的 API 如预期那样工作。
Visual Studio Endpoints Explorer
历史上,使用 Visual Studio 的开发者必须运行一个单独的工具来测试他们的 API,但随着 .NET 8 最新版本的推出,Visual Studio 团队增加了一个名为 Endpoints Explorer 的新面板:

图 9.4 – Endpoints Explorer
如果我们在 Program.cs 文件中定义了一组 API,我们的集合将如下所示:

图 9.5 – Endpoints Explorer 中的 API 集合
右键单击一个 API 将在新的 HTTP 编辑器中生成一个请求。HTTP 编辑器允许为列出的 API 自定义定义变量:

图 9.6 – HTTP 编辑器中的示例 API 集合
在 图 9.6 中,HTTP 编辑器使用以下命令来发出 HTTP 请求:
-
@: 为文件创建一个变量(例如,@variable=value) -
//: 这指定了注释 -
###: 这指定了 HTTP 请求的结束 -
<HTTP Verb>:创建基于 REST 的请求,包括DELETE、GET、HEAD、OPTIONS、PATCH、POST、PUT和TRACE请求 -
<Headers>:在定义 URL 后直接添加标题,以便它们包含在请求中
一旦我们定义了 API,左侧的空白区域会出现绿色箭头。运行应用程序以在本地测试 API。当 API 正在运行时,按下左侧空白区域最远的箭头将在右侧面板中产生结果:

图 9.7 – /attractions请求的结果
在这个例子中,我们测试了/attractions请求,接收了数据,并在右侧显示它。
为什么这很重要?
通过使用这个新的 Visual Studio 功能,我们获得了以下优势:
-
集中式 API:我们在一个地方拥有所有 API 的目录
-
.http文件,执行示例请求,并了解每个 API 在系统中的功能 -
集成开发环境集成:测试我们的 API 不需要额外的工具
这个新功能对于想要在本地测试现有 API 的开发者来说非常有帮助,同时也补充了系统中新引入的最小 API。
端点资源管理器额外材料
关于端点资源管理器的额外材料,赛义德·易卜拉欣·哈希米提供了一篇关于它所能做一切的精彩文章,请参阅devblogs.microsoft.com/visualstudio/web-api-development-in-visual-studio-2022/#endpoints-explorer。
在本节中,我们了解了端点资源管理器,我们如何使用它来帮助本地测试 API,以及为什么它很重要。在下一节中,我们将学习如何使用集成测试来快速生成结果。
集成测试 API
在上一节中,我们学习了如何使用端点资源管理器来测试我们的 API。然而,我们不应该需要在服务器上安装 Visual Studio 来测试我们的 API。
在本节中,我们将探讨为我们的 API 应用集成服务器以实现完整的端到端测试。
当我们在第八章中创建单元测试时,我们创建了一个数据库的内存表示。我们可以创建一个类似的环境,在那里我们可以为 API 测试启动和关闭整个环境。
在我们的 CI/CD 管道中,我们可以为我们的集成测试构建一个可丢弃的服务器,以提供 API 和服务以及可丢弃数据库的全端到端测试。
构建集成服务器
由于.NET 为我们提供了简单的Program.cs文件来构建应用程序,我们可以将整个应用程序包装起来,并用一个 Web 和数据库服务器替换我们想要模拟的服务。
我们可以使用WebApplicationFactory类来设置环境。我们将最小 API 项目作为依赖项包含在我们的Api.Tests项目中。一旦我们在程序中有了依赖项,我们就可以创建我们的WebApplicationFactory类:
using System.Data.Common;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using ThemePark.Data.DataContext;
namespace ThemePark.Api.Tests;
public class TestThemeParkApiApplication :
WebApplicationFactory<Program>
{
protected override IHost CreateHost(
IHostBuilder builder)
{
var root = new InMemoryDatabaseRoot();
builder.ConfigureServices(services =>
{
services.RemoveAll(typeof(
DbContextOptionsBuilder<ThemeParkDbContext>
));
services.AddScoped(sp => new
DbContextOptionsBuilder<ThemeParkDbContext>()
.UseInMemoryDatabase("TestApi", root)
.UseApplicationServiceProvider(sp)
.Options);
services.AddDbContext<ThemeParkDbContext>(
(container, options) =>
{
var connection = container
.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
services.AddTransient<IThemeParkDbContext,
ThemeParkDbContext>();
});
return base.CreateHost(builder);
}
}
在前面的代码示例中,我们继承了WebApplicationFactory<Program>。泛型<Program>来自我们引用的包含的依赖项。然后,我们为我们的内存数据库创建了一个根,并继续通过删除所有DbContextOptionsBuilder<ThemeParkDbContext>实例来配置我们的服务。一旦我们删除了这些,我们就可以使用更新的数据库设置创建一个新的作用域引用到同一类型。
接下来,我们使用 SQLite 数据库添加了我们的新ThemeParkDbContext和更新的连接。记住,Entity Framework Core 会自动使用.EnsureCreated()方法创建我们整个数据库的结构。最后,我们为应用程序中的服务添加了对IThemeParkDbContext的依赖注入注册。
我们的集成服务器就到这里。现在,我们可以在集成测试中使用TestThemeParkApiApplication。例如,如果我们想为我们的/attractions API 创建一个测试,我们的集成测试将如下所示:
using Microsoft.Extensions.DependencyInjection;
using ThemePark.Data.DataContext;
namespace ThemePark.Api.Tests;
[TestClass]
public class ApiTests
{
private TestThemeParkApiApplication _app;
[TestInitialize]
public void Setup()
{
_app = new TestThemeParkApiApplication();
using (var scoped = _app.Services.CreateScope())
{
var context = scoped.ServiceProvider
.GetService<IThemeParkDbContext>();
context?.Database.EnsureCreated();
}
}
[TestMethod]
[TestCategory("Integration")]
public async Task GetAllAttractions()
{
// Arrange
var client = _app.CreateClient();
var expected = TestData.ExpectedAttractionData;
// Act
var response = await
client.GetAsync("/attractions");
var actual = await response.Content
.ReadAsStringAsync();
// Assert
Assert.AreEqual(expected, actual);
}
}
在前面的代码片段中,我们在设置时初始化了TestThemeParkApiApplication,以便通过.EnsureCreated()方法确保每个实例都是新的。_app.CreateClient为我们提供了HttpClient来调用 URL。我们调用我们的/attractions API,并将其与创建的资源字符串进行比较,而不是在我们的测试方法中添加大量的 JSON 字符串。最后,我们的测试将 JSON 结果与 API 返回的结果进行比较。
能够创建整个前后端集成测试,证明 API、Entity Framework 查询和数据库代码在 CI/CD 管道中运行时按预期工作,应该会增加对代码的信心。
在本节中,我们学习了如何在 Visual Studio 的端点资源管理器中测试 API,以及如何通过使用WebApplicationFactory包装我们的 API 项目,使这些 API 在 CI/CD 管道中可测试。
在下一节中,我们将介绍在构建 API 时行业常用的某些常见做法。
标准化的 Web API 技术
在本节中,我们将学习如何正确使用 HTTP 动词和状态码,如何避免大型依赖资源,如何为 API 创建分页,如何对 API 进行版本控制,使用 DTO 而不是实体,以及从.NET 中调用 API 的最佳方式。
使用正确的 HTTP 动词和状态码
到目前为止,我们已经探讨了如何使用 HTTP 动词以及如何返回状态码。虽然这看起来可能是一件微不足道的事情,但一些系统忽略了这些标准,总是使用 POST,无论功能如何。
Swagger 为 API 文档提供了一个很好的模板,而 Visual Studio 的新端点资源管理器将这一基本文档带到了开发者的 IDE 中,使得 API 更容易阅读和在其他项目中实现,向开发者展示了应该使用哪些动词以及预期的状态码。
在本章前面提到的购物车 API 示例中,用户本打算将产品添加到购物车并继续结账。他们将使用购物车开始这个过程。结账的功能让我们使用了购物车 API,并采用结账方法(/cart/checkout),这非常合理。我们应该将用户的行为与 API 中的行为相匹配。
小心依赖资源
但我如何根据资源来扩展我的 API?如果一个资源属于另一个资源,而这个资源又依赖于另一个资源,依此类推怎么办?
以下是一个示例:/users/{userId}/projects/{projectId}/tasks。
我们想要获取一个用户的项目任务,但这个 URL 似乎有点长,不是吗?我们如何将其分解成更易于管理的东西?任何超过三个级别的都只是自找麻烦。
此 URL 需要一个更细粒度的方法——也就是说,将每个资源分离出来。而不是前面的 URL,更好的方法是用/users/{userId}/projects来检索用户正在进行的项目的列表。下一个 URL 将根据所选项目提供任务,看起来像/projects/{projectId}/tasks。
作为开发者,我们都知道一切都是妥协。在这种情况下,我们提供了一个更简单的 API,但需要两次调用而不是一次。
这些是与团队成员讨论的话题,但基本上,URL 越小,实现起来越容易。URL 越长,为了满足请求,所需的资源查找就越多。
API 结果中的分页
对于大多数 API 调用,结果以原始的JavaScript 对象表示法(JSON)格式返回,通常作为一个集合或单个项目。如果客户端需要分页结果,但现在只想获取一页数据怎么办?
为了协助客户端开发者,JSON 结果可以包含以下内容:
{
"total": 7,
"pageSize": 7,
"currentPage": 1,
"next": false,
"previous": false,
"results":
{
"id": 1,
"name": "Twirly Ride",
"locationId": 2,
"locationName": "Fantasy"
},
{
"id": 2,
"name": "Mine car Coaster",
"locationId": 5,
.
.
虽然通常需要将结果作为集合返回,但在头部返回的字段如下:
-
Total: 记录总数 -
PageSize: 此响应中返回的记录数 -
TotalPages: 根据页面大小指定总页数 -
CurrentPage: 指定我们当前所在的页面 -
Next和Previous:是否有足够的记录可以向前或向后移动? -
Sort: 指定结果的排序方式 -
Filter: 指定应用于结果中的过滤器
头部旨在帮助我们的同行客户端开发者充分利用响应。虽然这不是包含字段的完整列表,但它应该在客户端显示记录子集时,在每次响应中保持一致性。
在头部避免使用“状态码”字段或“成功”字段,因为 HTTP 状态码被认为是预期的响应。
API 版本控制
在创建 API 时,默认情况下,它们很可能会处于原始状态,没有版本控制。有四种类型的版本控制:
-
无版本控制:当我们创建第一个 API 时
-
(省略部分内容)
-
(省略部分内容)
-
使用
custom-header将版本放入头部:GET /users Custom-Header: api-version=1
最常用的版本化技术是 URI 版本化。虽然每个人的效果可能不同,但这种技术很有吸引力,因为它立即显而易见我们正在使用哪个版本。
使用 DTO 而不是实体!
在测试我们的 API 时,我们没有返回实体(Attraction或Location)。相反,我们返回的是数据传输对象(DTOs),这是属性的一个子集。
我们的安全章节([第四章)提到,在涉及主键或敏感信息时不要暴露太多。DTOs(数据传输对象)给开发者提供了一个选择哪些属性应该暴露给客户端的机会。
例如,我们的Attraction DTO 旨在提供最小量的信息;我们将在查看以下代码示例之后讨论这一点:
public static class AttractionExtensions
{
public static AttractionDto ToDto(
this Attraction attraction)
{
return new AttractionDto
{
Id = attraction.Id,
Name = attraction.Name,
LocationId = attraction.LocationId,
LocationName = attraction.Location == null
? string.Empty
: attraction.Location.Name
};
}
}
在这里,我们有一个简化版的AttractionDto类,包含简单的属性。我们还有一个基于我们依赖的Location类的LocationName属性。
虽然我们有一个.ToDto()方法,但我们也可以创建其他 DTO 扩展方法,在.ToDifferentDto()方法(或我们想叫的任何名字)中返回不同的数据。
使用 DTO 而不是 Entity Framework 实体另一个原因是导航属性的潜在递归性质。当一个实体从 API 返回时,它会被转换成一个 JSON 对象。如果我们有一个嵌套的实体,它将沿着链继续。当它们从 API 返回时,将实体属性隔离并提炼到它们的原生类型,以便在客户端进行基本消费,这会更好。
避免创建新的 HttpClient 实例
尽管本章的大部分内容讨论了创建和测试 API,但我感觉我们还需要提到如何在.NET 应用程序中消费它们。
消费 Web API 有多种方式,例如使用WebRequest或WebClient,但对于大多数目的,由于其灵活性和现代化,推荐使用HttpClient类。WebRequest和WebClient类被包含在内,是为了过渡到遗留应用程序。话虽如此,创建一个HttpClient的新实例很容易,但这并不是最佳方法。
微软表示,HttpClient应该在整个应用程序的生命周期中只使用一次。如果我们在我们应用程序的多个位置创建HttpClient的实例,我们将阻碍性能和可扩展性的机会。如果请求速率过高,这会导致 TCP 端口耗尽的问题,因此最好避免以下代码:
// Bad use of HttpClient
var client = new HttpClient();
一些开发者可能会更进一步,认为以下代码片段更好,因为它通过using语句正确地销毁了HttpClient类:
// Still not good
using (var client = new HttpClient())
{
.
.
}
这个代码的问题是我们仍在创建另一个HttpClient实例,仍然会导致端口耗尽,并且在我们很可能需要它的时候仍然会销毁它。
在 .NET Core 2.1 中,Microsoft 创建了一个 IHttpClientFactory 类来提供单个 HttpClient 实例。我们可以简单地请求一个 HttpClient 实例,并且我们会收到一个。最好的消息是它可以进行依赖注入。
一旦我们通过构造函数注入了类,代码就会变得更容易处理,如下面的代码片段所示:
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> _logger;
private readonly IHttpClientFactory _factory;
public IndexModel(
ILogger<IndexModel> logger,
IHttpClientFactory factory)
{
_logger = logger;
_factory = factory;
}
public async Task OnGet()
{
// Bad use of HttpClient
// var client = new HttpClient();
// Still not good
//using (var client = new HttpClient())
//{
// .
// .
//}
// Best way to use HttpClient
var client = _factory.CreateClient();
// use client.GetAsync("https://www.google.com") to
grab HTML
}
}
当我们使用 .CreateClient() 从 HttpClientFactory 获取客户端时,除非必须,否则它不会创建一个新的 HttpClient 实例。
将 .CreateClient() 方法视为在幕后使用单例设计模式,类似于以下代码所示:
private static HttpClient _client { get; set; }
public HttpClient CreateClient()
{
if (_client == null)
{
_client = new HttpClient();
}
return _client;
}
作为旁注,前面的代码不是线程安全的;它被提供出来以展示单例设计模式的概念。
我们总是得到一个 HttpClient 实例,这是进行服务器端 API 调用的更好方式。
摘要
在本章中,我们学习了几个技术,例如如何通过成为应用程序的用户、识别资源、使用正确的 HTTP 动词和状态码来设计 API。我们还学习了如何创建、测试和记录 API,以及为什么最小 API 很重要。之后,我们学习了如何在 Visual Studio 2022 中使用新的端点探索器,以及如何在 CI/CD 管道中构建我们 API 的自动化端到端测试。
一旦我们了解了编写 API 的过程,我们就检查了在行业中用于创建通用和有用 API 的标准,例如使用正确的 HTTP 动词和状态码、避免大型 URL、如何使用 API 进行分页、API 版本化、使用 DTO 而不是实体,以及从 .NET 进行 API 调用时使用 HttpClient 的最佳方式。
在下一章中,我们将探讨如何提高本书中涵盖的各个主题的性能,并提供一些新的性能提示。
第十章:用性能推动你的应用程序
随着每个新版本的ASP.NET发布,ASP.NET 团队继续将性能作为优先事项。当 ASP.NET Core 引入了一种使用流线化增强(包括中间件和Razor Pages)构建 Web 应用程序的新方法时,重点始终是改进 C#语言。正是这些技术赋予了 ASP.NET 其火花和速度。
ASP.NET 是跨平台的,内置了对依赖注入的支持,是开源的,并且在行业中是性能最快的框架之一。
虽然这是一本关于性能的 ASP.NET 书籍,但其中还将包含同样重要的 Web 开发的其他方面。我们将尽可能专注于 ASP.NET 和 C#的性能。
在本章中,我们将涵盖以下主要主题:
-
性能为何重要
-
建立基线
-
应用性能最佳实践
到本章结束时,你将了解性能在你应用程序中的重要性,如何建立客户端和服务器端基线和优化客户端资源以提高交付速度的技术,以及最后如何通过优化 HTML、实施各种缓存技术以及识别慢查询等服务器端性能技术来更快地交付内容。
技术要求
在创建基线和测试 Web 应用程序的性能时,你需要一个你感到舒适的 IDE 或编辑器来编写代码。我们建议使用你最喜欢的编辑器来查看 GitHub 仓库。我们的建议包括以下内容:
-
Visual Studio(最好是最新版本)
-
Visual Studio Code
-
JetBrains Rider
本章的代码位于 Packt Publishing 的 GitHub 仓库中,网址为github.com/PacktPublishing/ASP.NET-Core-8-Best-Practices。
性能为何重要
在 Web 开发中,性能有多种形态和形式,因为有许多移动部件使网站始终准备好并可供我们的用户使用。作为一名开发者,如果有人请求关于网站速度慢的帮助,你会推荐什么建议?在没有检查网站的情况下,这是一个难以用言语回答的问题。对于网站来说,可能有时性能不仅仅是一种技术;问题可能不止一个瓶颈。
例如,当在浏览器中加载网页时,你是否看到内容出现,但图片加载很慢,并且逐行渲染?关于访问数据库呢?你是否遇到服务器需要一分钟才能检索记录的慢查询?Web API 的每个请求是否超过两秒?
如你所见,性能是对整个网站的分析,包括浏览器、服务器、C#、API 和数据库。
亚马逊发布了一项研究,计算如果他们的网站页面加载速度慢了 1 秒钟,可能会给他们造成 16 亿美元的销售额损失。
一秒钟可能让亚马逊损失 16 亿美元销售额
这项研究由 Fast Company 报道,可在 fastcompany.com/1825005/how-one-second-could-cost-amazon-16-billion-sales 找到。
虽然这很引人注目,但最近还有一篇关于 Netflix 如何用纯 JavaScript(通常称为 Vanilla JavaScript)取代 React 的文章。这带来了巨大的性能提升。在案例研究中,它报告了一个页面有 300 KB 的 JavaScript,这相当多。然而,与其他网站如 CNN.com(4.4 MB 的 JavaScript)和 USAToday.com(1.5 MB 的 JavaScript)相比,300 KB 的 JavaScript 被认为是微不足道的。
Netflix 网络性能案例研究
Google Chrome 工程主管 Addy Osmani 写了一篇关于 Netflix 通过优化获得性能提升的文章。案例研究可在 medium.com/dev-channel/a-netflix-web-performance-case-study-c0bcde26a9d9 找到。
通过这些特定的场景和案例研究,许多公司开始关注性能。甚至微软也通过向 TechEmpower 的行业框架基准结果提交其结果,将精力集中在性能上。由于持续的改进,ASP.NET 现在被评为最快的网络平台之一。
TechEmpower 框架基准结果
每年,TechEmpower 都会更新其结果,这些结果可以在 techempower.com/benchmarks/ 的图表中找到。截至 2022 年 7 月 19 日,ASP.NET 在性能排名中位列第 9。
最后,作为搜索引擎行业中最具主导地位的玩家,谷歌将页面加载速度与你的 搜索引擎结果页面(SERPs)联系起来。也就是说,你网站的加载速度是影响你在搜索结果中排名高低的重要因素(我们将在下一节中讨论)。
谷歌在网页搜索排名中使用网站速度
在谷歌的博客上,他们提到,在排名你的网站时,页面速度是考虑的另一个因素。该帖子可在 developers.google.com/search/blog/2010/04/using-site-speed-in-web-search-ranking 找到。
性能是我最喜欢的主题之一。通过进行小的改动来获得大的性能提升的想法无疑是令人兴奋的。它也可以直观地显现出来。本章旨在帮助使用技术和工具来识别任何网站上的性能问题。
好消息是,在过去的章节中,我们已经提到了一些提高性能的具体方法,当相关时我们将回顾这些方法。正如我在第四章中说的,性能在构建 ASP.NET Web 应用程序时应该是首要任务,其次是安全性。
性能始终是艺术和科学的结合,正如你将在本章的一些部分中看到的那样。有感知性能,然后有实际性能。
实际性能是一个测量活动或任务立即响应并通知用户已完成的活动。立即响应是一个目标。感知性能是一个主观的测量,用户将活动或任务体验为快速,即使它并不真正如此。感知性能的一个例子是当用户请求一个网页,浏览器立即渲染页面。内容在后台继续加载,同时通过允许用户滚动页面等待额外内容来保持用户的注意力。因此,用户认为网站“快速”,因为它立即响应。旋转器和进度条是其他在处理过程中实现感知性能的方法。
虽然感知性能是在等待过程完成时分散用户注意力的方式,但本章将更多地关注实际性能。
在下一节中,我们将学习如何使用公共 Web 工具和特定的服务器工具,如Visual Studio 性能分析器、Benchmark.net和Application Insights,来创建客户端和服务器端代码的基线。
建立基线
那么,你如何知道你在网站上遇到了速度减慢的问题?是因为最近发布了软件产品,还是安装了新的 NuGet 包导致速度减慢?
在识别问题时,你可能自己在问,“发生了什么变化?”但每个人都应该问的问题是“你如何衡量性能?”为了衡量它,需要有一个关于性能预期的基线。
应用程序的每个部分都应该包含性能测试。无论是前端、C#子系统、Web API 还是数据库,都应建立适当的系统来通知团队当系统未按预期表现时。
使用客户端工具
客户端的问题大多是由于加载时间、未找到的资源(如 HTML 页面、图像、CSS 和 JavaScript)的交付,或者一般的 JavaScript 错误。然而,这并不意味着整个问题都在客户端。
在开发过程中,应该通过像Cypress或Selenium这样的测试工具为客户端代码创建基线,并记录测试的持续时间。将最新的场景与之前的测试结果进行比较,以查看时间差异在哪里。
确定基线的另一种方法是使用网络上的各种工具,例如本节中列出的工具。将这些工具想象成像把您的车送到机械师那里进行维护一样。这些工具扫描您的公共网站,分析网站的所有方面,并提供一份关于如何修复每个发现的问题的报告。
一些可以为您提供网站性能洞察的工具包括以下内容:
-
Google PageSpeed Insights (
pagespeed.web.dev): Google 使用其搜索引擎对您的网站进行排名,并提供一个出色的工具来帮助解决网站问题。 -
Lighthouse (
developer.chrome.com/docs/lighthouse/): 如果您的网站无法公开访问以供这些工具分析,您可以使用 Lighthouse 扩展在内部运行网站测试。Lighthouse 会生成一份全面的报告,推荐如何使您的网站表现更好。 -
GTMetrix (
gtmetrix.com): 多年来我一直使用 GTMetrix,并且它每年都在给人留下深刻印象并不断改进。它提供性能摘要、速度可视化和推荐。 -
Google Search Console (
search.google.com/search-console): Google 为网站管理员创建了这个工具,用于识别性能问题以及其他一般维护工具,例如人们输入 Google 搜索以找到您的网站的内容。 -
DevTools: DevTools 是位于 Google Chrome、Mozilla Firefox、Apple Safari 和 Microsoft Edge 内的网页开发者工具面板,用于帮助开发者分析网页,并且它正成为互联网的 IDE。在浏览器中按 F12 键将打开此面板。
这些工具非常适合评估您的网站在互联网上的表现以及基于最新修订的表现如何。如果您的上一个版本加载需要 0.5 秒,而最新版本现在需要三秒,那么是时候检查一下发生了什么变化。有什么比在部署网站之前通过报告性能问题来自动化这个过程(参考 第二章)更好的方法吗?
使用服务器端工具
使用 ASP.NET,创建代码基线与您可用的各种工具一样简单。
在本节中,我们将回顾一些可用于创建代码基线的工具,例如 Visual Studio、Benchmark.net、Application Insights 以及其他工具,如 NDepend。
Visual Studio 性能工具
由于 Visual Studio 在业界是一个稳固的 IDE,因此评估 C# 性能的能力变得越来越普遍,因为如果代码运行缓慢,开发者希望有一种方法来定位瓶颈。

图 10.1 – Visual Studio 2022 中的性能分析器
当启动 性能分析器 时,您将看到一个选项列表:

图 10.2 – 运行性能分析器之前可用的选项列表
如您所见,有大量选项跨越多个接触点。例如,有一个数据库选项来查看您的查询在应用程序中的性能。
数据库的度量与解释查询执行持续时间的 Entity Framework 细节相似。另一个选项是确定异步/等待问题可能发生的地方,以及内存使用和对象分配。
Benchmark.net
如果需要较小的、自包含的方法进行测试,Benchmark.net 是进行微基准测试的最佳工具之一(benchmarkdotnet.org/)。
Benchmark.net 对特定方法进行测试,并使用不同的场景进行测试。需要注意的是,Benchmark 项目必须是一个控制台应用程序。
例如,如果我们想测试一个古老的争论,即字符串连接和StringBuilder类哪个更快,我们会编写两个基准测试来确定哪个更快,如下所示:
public class Benchmarks
{
[Benchmark(Baseline = true)]
public void StringConcatenationScenario()
{
var input = string.Empty;
for (int i = 0; i < 10000; i++)
{
input += «a»;
}
}
[Benchmark]
public void StringBuilderScenario()
{
var input = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
input.Append(«a»);
}
}
}
在前面的代码中,我们在一个场景中创建了一个字符串,在另一个场景中创建了一个StringBuilder()类的实例。为了达到相同的目标,我们添加了 10,000 个‘a’并开始基准测试。
根据图 10.3 中的结果,显然的选择是使用StringBuilder()进行大型字符串连接:

图 10.3 – 比较字符串连接与StringBuilder()类的性能
关于创建基线,我们在第一个场景中为我们的[Benchmark]属性添加了一个额外的参数,称为Baseline,并将其设置为true。这告诉 Benchmark.net 在测量其他方法的性能时使用这个作为我们的基线。你可以有任意数量的方法来实现相同的结果,但所有内容都将与Benchmark属性中Baseline=true的方法进行比较。
对于小型、紧凑的方法,Benchmark.net 无疑是提供关于如何使用微优化创建更快代码洞察的绝佳工具。
应用洞察
微软的应用洞察(Application Insights)旨在成为一个通用的分析工具,用于收集关于应用程序所做一切活动的遥测数据。一旦设置好,应用洞察可以收集以下数据:
-
请求 – 网页和 API 调用
-
依赖项 – 应用程序在幕后加载了什么?
-
异常 – 应用程序抛出的每个异常
-
性能计数器 – 自动识别减速
-
心跳 – 应用程序是否仍在运行?
-
日志记录 – 收集应用程序所有类型日志的集中位置
当添加 Application Insights 时,Application Insights 确实需要一个 Azure 订阅。
Application Insights 补充材料
设置 Application Insights 有多种方式,本章中涵盖的太多,无法一一介绍。有关 Application Insights 的更多信息以及如何为你的应用程序设置它,请访问 learn.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core。
创建基线和识别瓶颈的一些其他建议包括以下内容:
-
JetBrains dotTrace/dotMemory – dotTrace 是一个性能分析工具,dotMemory 是一个内存分析工具。这两个工具都非常出色,能够深入了解你的应用程序性能。dotTrace 和 dotMemory 允许你比较一组结果与另一组结果的基础(“比较快照”)。
-
RedGate ANTS 性能分析器/内存分析器 – ANTS 性能和内存分析器具有分析 .NET 代码和内存分配的能力,在运行代码时进行深度分析,展示了类似性能和内存分析的方法。
-
if..else或开关(switches)。这些也可以由用户自定义,以满足你的代码质量要求,使用LINQ 代码查询(CQLinq)。NDepend 还具有集成到你的 CI/CD 管道中来自动化此过程的能力。 -
自定义指标 – 在 第七章 中,我们解释了如何“识别慢速集成测试”。使用单元、集成和 API 上的诊断计时器,你可以在发布之前执行和报告这些指标。
当这些工具检查你的应用程序时,它们会通过寻找热点来分析如何优化你的代码。如果热点被调用足够多次,你的应用程序的性能将受到影响。到达这个热点的路径被称为热路径。
数据库
虽然你可以使用数据库创建基线,但大多数优化都是在数据库级别通过分析存储过程、索引管理和模式定义来完成的。每种数据库类型都有自己的性能工具来查找瓶颈。现在,我们将专注于 SQL Server 工具。
SQL Server Management Studio (SSMS) Profiler
使用 SSMS 的分析器界面,开发者能够识别特定的即席查询、存储过程或表是否未按预期执行。
SQL Server Profiler 位于 工具 选项下的第一个菜单项,如图 图 10.4 所示:

图 10.4 – SSMS 中的 SQL Server Profiler
在运行 SQL Server 分析器时,发送到数据库的每个请求都会被记录,包括它花费了多长时间,需要多少读取和写入,以及返回的结果。
查询存储
SQL Server 2016 的最新功能之一是Query Store。Query Store 为您提供了关于如何提高 SQL Server 性能的见解。
一旦启用(右键单击数据库 | 属性 | Query Store | 操作模式:开启),它将在积极使用时开始分析您的 SQL Server 工作负载,并提出改进性能的建议。
数据收集完成后,可以通过存储过程使用指标来识别性能较慢的查询。
Query Store 附加材料
关于 Microsoft 的 Query Store 的附加材料,请访问learn.microsoft.com/en-us/sql/relational-databases/performance/manage-the-query-store。有关使用 Query Store 进行性能调整的信息,请访问learn.microsoft.com/en-us/sql/relational-databases/performance/tune-performance-with-the-query-store。
在本节中,我们讨论了为什么建立基线很重要,并列出了各种客户端工具,如 Google Page Speed Insights、Lighthouse、GTMetrix、Google Search Console 和 Chrome DevTools,用于衡量性能。我们还探讨了服务器端工具,如 Visual Studio 性能分析器、Benchmark.net、Application Insights、JetBrains dotMemory 和 dotTrace、RedGate ANTS 性能分析器/内存分析器和 NDepend,用于识别代码库中的问题。对于数据库,我们提到了两个用于识别性能瓶颈的工具:SQL Server Management Studio Profiler 和 Query Store。我们还提到了热点,或热点路径,频繁调用的未优化代码可能导致您的应用程序出现性能问题。
下一节将涵盖一些客户端和服务器端技术的最佳实践,但主要将侧重于使用 C#进行服务器端优化。
应用性能最佳实践
如本章开头所述,本章内容适用于客户端和服务器技术,以充分利用您的 ASP.NET 网站。
在本节中,我们首先将重点放在通过应用图像优化、最小化请求、使用 CDN 和其他提示来优化客户端性能。然后我们将关注服务器端技术,例如优化您的 HTML、缓存以及Entity Framework Core性能技术,以及识别慢查询。
优化客户端性能
在本节中,我们将学习关于图像优化、识别 Google 的核心 Web Vitals指标、在适用时使用 CDN、如何最小化请求以及在哪里放置脚本和样式。
修复图像优化
根据《网络年鉴》(almanac.httparchive.org/en/2022/media#bytesizes),图像优化是网络上的一个严重问题。需要支持的设备数量并没有使这个问题变得更容易。让我们看看我们如何可以优化这个体验。
下面是 <img> 标签的基本用法:
<img width="100" height="100"
src="img/logo.jpg"
alt="Buck's Coffee Shop Logo"
然而,对于响应式布局,<img> 标签有一个 srcset 属性:
<img src="img/logo-400.jpg"
alt="Buck›s Coffee Shop Logo"
width="100"
height="100"
loading="lazy"
srcset="logo-400.jpg 400w,
logo-800.jpg 800w,
logo-1024.jpg 1024w"
sizes="(max-width: 640px) 400px, 800px, 1024px">
上述代码识别视口大小(网页)并加载适当的图像。max-width 媒体条件表示,如果视口为 640px,则使用 400px 图像。如果最大宽度超过 640px 且小于 800px,则使用 800px 图像。
这允许您支持多种不同的响应式布局。一旦您为您的网站定义了布局,图像也应与布局大小相匹配。这意味着什么?对于每张图像,您应该为每个响应式布局创建一个图像。例如,前面的默认图像标志应该有三个图像:logo-400.jpg、logo-800.jpg 和 logo-1024.jpg。
此外,loading="lazy" 指示浏览器在确定视口大小以显示正确图像之前延迟加载图像。
最后,图像可以变得非常大,并且可能包含编码数据,如 GPS 数据,当拍照时。压缩图像是移除额外数据的过程,使图像变小并加快在浏览器中的加载速度。这是一个服务器端任务,可以将其作为客户端任务运行器中的任务(之前在 第六章 中讨论过)。
图像优化的最小步骤应如下所示:
-
确定网站的响应式布局 – 确定您需要的图像大小(400px、800px 等)
-
根据布局创建图像 – 应该为每个布局大小提供一个调整大小的图像。
-
优化图像 – 对于您网站上每张图像,通过移除附加到每张图像的额外数据来压缩图像,使它们更小并加载更快。使用图像服务,如 Optimazilla (
imagecompressor.com/) 或 TinyPNG (tinypng.com/)。 -
使用
srcset和sizes属性,以便浏览器可以根据视口大小确定要显示的最佳图像。
图像优化是一个太大的主题,不适合小章节,但这个快速概述应该足以为网站用户提供更好的体验。
最小化请求
大多数上述客户端工具都可以用来识别资源请求的多个位置。平均而言,网站有 58 次对 JavaScript 和 CSS 的请求(这还不包括图像)。每次请求都会造成延迟,并且根据资源的不同,加载时间可能会超过用户愿意等待的时间。
我们已经在第六章中学习了如何使用更好的方法来结构化 JavaScript 和 CSS,从而减少了 JavaScript 和 CSS 文件的大量请求。
最后,如果有大量大小一致的图像,并且你分别调用每个图像,那么一个更好的方法是将所有图像创建为一个大型图像(精灵图),并使用 CSS 来显示它们。而不是让浏览器请求 15 个社交网络标志,你可以调用一个图像,并使用 CSS 将它们分割出来,如图图 10.5所示:

图 10.5 – 32x32 社交网络图标精灵图
要使用这个精灵图,CSS 将看起来如下:
.bg-YouTube_32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -1px -1px;
}
.bg-facebook_32x32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -35px -1px;
}
.bg-github_32x32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -1px -35px;
}
.bg-Instagram_32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -35px -35px;
}
.bg-LinkedIn_32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -69px -1px;
}
.bg-quora_32x32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -69px -35px;
}
.bg-RSS_32x32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -1px -69px;
}
.bg-Twitter_32 {
width: 32px; height: 32px;
background: url('css_sprites.png') -35px -69px;
}
背景通过使用偏移的顶部和左侧位置来标识要使用的图像作为背景。要在 HTML 中显示 RSS 图标,它将呈现如下:
<div class="bg-RSS_32x32"></div>
创建精灵图的服务包括 CodeShack 的图像到精灵图生成器(codeshack.io/images-sprite-sheet-generator/)和 Toptal 的 CSS 精灵生成器(www.toptal.com/developers/css/sprite-generator)。
使用 CDN
如果一个网站使用大量的静态文件,使用内容分发网络(CDN)提供了基于位置提供内容所需的服务。这些基于地理位置的服务器缓存文件,以便根据用户的位置更快地提供。
例如,如果加利福尼亚的人请求内华达州的文件,比从英国请求文件要快。内容越接近,用户接收的速度就越快。
关于客户端性能的最终想法
尽管我们可以覆盖大量的客户端技巧,但让我们以一些关于使客户端更快的最终想法来结束这一节:
-
脚本在底部,样式在顶部 – 避免在页眉中放置脚本,但绝对将样式放在页眉中。将脚本放在底部可以确认文档对象模型(DOM)已完全加载,如果立即执行,JavaScript 能够找到 DOM 元素,因为它们已经被渲染。
-
将 Google 的核心 Web Vitals 应用于您的网站 – 如果你使用 Lighthouse 或 Google 的 Page Speed Insights,你会注意到以下缩写用于识别您网站的性能:FCP(首次内容填充),LCP(最大内容填充),CLS(累积布局偏移),和 FID(首次输入延迟)。在
web.dev/vitals上查看这些术语,以提供更好的用户体验。 -
<details>/<summary>HTML 标签可能就足够了。此外,浏览器正变得越来越现代化和进化,出现了如<dialog>标签等新标签,其中不需要 JavaScript。有关浏览器支持情况,请参考caniuse.com/。
在本节中,您学习了如何通过优化图像来优化客户端,以及如何通过 CDN 提高您的静态内容加载速度,以及如何最小化您的请求以降低延迟问题。在我们的最后笔记中,我们检查了一些提示,例如将脚本放在底部,将样式放在顶部,将 Google 的 Core Web Vitals 应用到网站上,提供无论设备如何都响应式的网站,以及在合理的地方使用 HTML 而不是 JavaScript。
在下一节中,我们将从客户端转向服务器端,并查看在优化 C# 和 Entity Framework Core 时的一些常见实践。
常见的服务器端实践
由于 C# 是一种如此健壮的语言,因此有如此多的方式来创建网络应用程序。正如您在 第五章 中使用 Entity Framework Core 所见,每种设计模式都满足特定的需求,但无论模式如何,它们都工作得一样好。这些性能技术的好消息是,它们适用于业界已经使用的网络标准和设计模式。一个这样的例子是 ETags。在某个时刻,它们被认为是一个独立的网络概念,需要特定的代码。现在,当使用静态文件时,这些 ETags 无需任何额外编码就集成到网站中。它们被视为浏览器中的网络标准。
在本节中,我们将讨论如何通过将以下网络标准和设计模式添加到我们自己的网络应用程序中,以提升性能。
在本节中,我们将了解如何使用 C# 对您的代码应用各种性能提升,包括您可以立即应用到您自己的网站上的快速性能提升,我们将学习如何添加中间件组件以优化您的 HTML,仅用四个字母就能提高 Entity Framework Core 的性能,并识别缓慢的 Entity Framework Core 查询。
应用快速性能提升
虽然其中一些快速技巧是众所周知的(并且一些已经在之前的章节中介绍过),但回顾它们以从您的网站中获得最佳性能并不会造成伤害:
-
关闭调试模式 – 当您以调试模式运行应用程序时,为调试目的,将编译额外的信息到每个程序集。当切换到发布模式时,您将获得用于部署的优化版本程序集。
-
使用 async/await – 如前几章所述,使用 async/await 提供性能优势,并且应该用于涉及文件 I/O、数据库和 API 调用的任务。
-
使用数据库 – 当使用 Entity Framework Core 时,尝试评估目标并评估最佳方法:是使用 Entity Framework Core 简单数据访问方法,还是存储过程能提供更快的性能。
-
使用
.AsNoTracking()来减少更新实体时的 Entity Framework 开销的ChangeState管理。
虽然这些是一些给 Web 应用快速提升性能的技巧,但我们现在准备深入到更复杂的基于代码的技术。
优化 HTML
由于我们已经学习了优化图像(在上一个章节中)和优化 JavaScript 和 CSS(在第6 章中),我们现在需要关注其他客户端资源:HTML。
当你在浏览器中“查看源代码”时,你希望看到这个格式美观、人人都能理解的文档。但当一个浏览器接收到这个文档时,它并不关心它有多大,甚至不关心它有多“漂亮”。浏览器只是简单地解析和渲染传入的 HTML。
你有没有注意到为了格式化,在这个文档中浪费了多少空间?例如,让我们加载“Buck’s Coffee Shop”网页。
在 Chrome DevTools 的网络标签页中,我们看到它是 4.1 KB:

图 10.6 – Buck’s Coffee Shop 带有空格的大致大小(4.1 KB)
由于浏览器并不关心,如果我们能减小 HTML 的大小,岂不是更好?
中间件可以协助完成这项工作。如果我们使用第2 章中的标准中间件模板,我们可以创建一个HtmlShrink组件:
public class HtmlShrinkMiddleware
{
private readonly RequestDelegate _next;
public HtmlShrinkMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
using var buffer = new MemoryStream();
// Replace the context response with our buffer
var stream = context.Response.Body;
context.Response.Body = buffer;
// Invoke the rest of the pipeline
// if there are any other middleware components
await _next(context);
// Reset and read out the contents
buffer.Seek(0, SeekOrigin.Begin);
// Adjust the response stream to remove whitespace.
var compressedHtmlStream = new HtmlShrinkStream(stream);
// Reset the stream again
buffer.Seek(0, SeekOrigin.Begin);
// Copy our content to the original stream and put it back
await buffer.CopyToAsync(compressedHtmlStream);
context.Response.Body = compressedHtmlStream;
}
}
public static class HtmlShrinkMiddlewareExtensions
{
public static IApplicationBuilder UseHtmlShrink(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<HtmlShrinkMiddleware>();
}
}
上述代码包含我们熟悉的中间件脚手架。我们的HtmlShrinkMiddleware组件现在实例化一个HtmlShrinkStream类来执行我们的压缩,移除 HTML 中的任何空白。同时,我们在代码底部创建了标准的扩展。
以下是我们HtmlShrinkStream类的示例:
public class HtmlShrinkStream: Stream
{
private readonly Stream _responseStream;
public HtmlShrinkStream(Stream responseStream)
{
ArgumentNullException.ThrowIfNull(responseStream);
_responseStream = responseStream;
}
public override bool CanRead => _responseStream.CanRead;
public override bool CanSeek => _responseStream.CanSeek;
public override bool CanWrite => _responseStream.CanWrite;
public override long Length => _responseStream.Length;
public override long Position
{
get => _responseStream.Position;
set => _responseStream.Position = value;
}
public override void Flush() => _responseStream.Flush();
public override int Read(byte[] buffer, int offset, int count) =>
_responseStream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) =>
_responseStream.Seek(offset, origin);
public override void SetLength(long value) =>
_responseStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count)
{
var html = Encoding.UTF8.GetString(buffer, offset, count);
var removeSpaces = new Regex(@"(?<=\s)\s+(?![^<>]*</pre>)", RegexOptions.Multiline);
html = removeSpaces.Replace(html, string.Empty);
var removeCrLf = new Regex(@"(\r\n|\r|\n)", RegexOptions.Multiline);
html = removeCrLf.Replace(html, string.Empty);
buffer = Encoding.UTF8.GetBytes(html);
_responseStream.WriteAsync(buffer, 0, buffer.Length);
}
}
在我们的HtmlShrinkStream类中,我们的努力集中在Write()方法上。我们查看接收到的缓冲区,将其转换为 HTML 字符串,使用正则表达式替换所有空白,最后将buffer写入responseStream。
我们现在可以通过在Program.cs文件中添加以下行来将我们的HtmlShrink中间件扩展添加到我们的管道中:
app.UseHtmlShrink();
一旦添加,浏览器接收到的任何 HTML 都将去除任何空白。如果我们查看 Buck’s Coffee Shop 的主页,我们可以看到一切正常,但如果我们查看源代码,我们可以看到一切变得更加紧凑:

图 10.7 – 查看 Buck’s Coffee Shop 主页的源代码
它可能看起来不太美观,但如果我们查看 Chrome DevTools 中的网络标签页,我们可以看到发送到浏览器的内容之间的差异:

图 10.8 – 没有空格的 Buck 咖啡店主页大小(3.3 KB)
这几乎比原始大小小了 20%。
启用 DbContext 池
连接池是能够为多个用户重用连接的能力。默认情况下,数据库连接已经通过SqlConnection使用连接池。这个概念被应用到 Entity Framework Core 的DbContext中。
如果一个 Web 应用程序大量使用 Entity Framework Core,你希望获得最佳性能。只需更新你的中间件DbContext连接即可。
例如,我可能在中间件中有以下一行:
services.AddDbContext<MyDbContext>(options =>
options.UseSqlServer(connectionString));
我们可以通过在这行添加四个字母立即提高我们的性能:
services.AddDbContextPool<MyDbContext>(options =>
options.UseSqlServer(connectionString));
使用AddDbContextPool<>()方法包含相同的语法,但DbContext完成后,它将重置其状态并将其存储起来,以便在需要新的DbContext实例时使用。我们正在回收我们的DbContext!
根据你的DbContext大小,每次创建新实例时,创建 DbContext 都会花费时间。使用.AddDbContextPool<>()方法为我们提供了所需的性能提升。
Entity Framework Core DbContext 池基准测试
微软对有和无 DbContext 池的进行了基准测试。在实现 DbContext 池后,性能提高了超过 50%。微软甚至包括了基准代码的源代码。结果可以在learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics#benchmarks找到。
识别慢查询
由于我们在 Visual Studio 中,发送查询到数据库时可能看不到幕后发生的事情,因此识别慢查询有时可能很困难。那么,我们如何在 Web 应用程序中找到这些慢查询呢?
在 DbContext 的OnConfiguring()方法中,向你的DbContextOptionsBuilder添加.LogTo()方法,你将看到每个数据库调用及其执行时间:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
var connString = _configuration.GetConnectionString(«DefaultConnection»);
if (!string.IsNullOrEmpty(connString))
{
optionsBuilder.UseSqlServer(connString)
.LogTo(Console.WriteLine, LogLevel.Information);
}
}
}
.LogTo()方法将生成以下日志条目:
Microsoft.EntityFrameworkCore.Database.Command: Information: Executed DbCommand (46ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [a].[ID], [a].[LocationID], [a].[Name], [l].[ID], [l].[Name]
FROM [Attractions] AS [a]
INNER JOIN [Locations] AS [l] ON [a].[LocationID] = [l].[ID]
对于这个特定的查询,执行耗时为46ms。.LogTo()方法提供了一个简单的方法来识别查询是否正在最佳性能下运行,或者是否可能是优化的候选。
在本节中,我们学习了一些小型的优化,以及一个新的中间件来缩小 HTML,如何使用 DbContext 池加快 Entity Framework Core 的速度,以及如何在应用程序中定位慢查询。
在下一节中,我们将关注各种缓存类型以及每种类型如何不同,并且如何协同工作以改善应用程序的整体性能。
理解缓存
由于缓存对 Web 应用程序至关重要,因此它自然应该有一个单独的部分来涵盖所有可能的缓存类型。在业界,有一句俗语:“最好的数据库调用就是根本不调用。”他们可能是在指缓存。
在本节中,我们将学习包括响应和输出缓存、数据缓存以及缓存静态文件在内的不同类型的缓存。
使用响应缓存和输出缓存
不论是调用网页还是 API,缓存数据的能力都极其重要。实施简单的缓存策略以立即返回数据是高效的。
ResponseCaching 是一个中间件扩展,非常适合来自客户端的 GET 或 HEAD API 请求。当使用响应缓存时,.NET 使用标准的 HTTP 缓存语义。
RFC 9111:HTTP 缓存
更多关于 HTTP 缓存的资料,请访问 www.rfc-editor.org/rfc/rfc9111。
要添加响应缓存,构建器必须将其添加到服务和应用程序(app)中,如下所示:
Var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCaching();
var app = builder.Build();
app.UseHttpsRedirection();
// If using Cors, UseCors must be placed before the UseResponseCaching
// app.UseCors();
app.UseResponseCaching();
一旦实施,任何 API 调用都会默认通过浏览器提供缓存的数据。
ResponseCaching 中间件
更多关于 ResponseCaching 的详细信息,请访问 learn.microsoft.com/en-us/aspnet/core/performance/caching/middleware。
然而,对于大多数 Web UI,如 Razor Pages,OutputCaching 是更好的选择,因为浏览器会设置请求头以防止缓存。OutputCaching 的配置与 ResponseCaching 类似,如下所示:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache();
// Add services to the container.
builder.Services.AddRazorPages();
var app = builder.Build();
// Configure the HTTP request pipeline.
If (!app.Environment.IsDevelopment())
{
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();
app.UseRouting();
// if using Cors, UseOutputCache must be placed AFTER useCors().
//app.UseCors();
app.UseOutputCache();
在中间件配置中,我们在服务集合中添加 AddOutputCache() 方法,并在 UseRouting() 方法之后(如果使用了,则在 UseCors() 方法之后)放置 UseOutputCache() 方法。
当 OutputCache 被添加到中间件时,这并不意味着我们自动缓存了我们的 UI 页面。我们还需要通过在 Razor 页面类中添加 [OutputCache] 属性来标识哪些页面被缓存:
[OutputCache]
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> _logger;
public IndexModel(ILogger<IndexModel> logger)
{
_logger = logger;
}
public void OnGet() { }
}
如果在属性中未定义任何参数,缓存页面的默认策略如下:
-
HTTP 200 状态码被缓存
-
HTTP GET 或 HEAD 请求被缓存
-
设置了 cookie 的响应不会被缓存
-
对认证请求的响应不会被缓存
响应缓存旨在客户端或通过浏览器进行缓存,而输出缓存则是在服务器上进行缓存。如果两个用户从两个不同的浏览器访问同一页面,响应缓存将不起作用,因为每个浏览器都会在每个浏览器中缓存页面。然而,如果实现了输出缓存,这将会在服务器上缓存页面,并快速将页面提供给两个用户。
缓存页面与数据缓存结合使用时,可以为用户提供更好的体验,我们将在下一节讨论。
实现数据缓存
当用户访问一个网站时,他们会根据他们的身份看到一定量的数据。例如,当第一个用户访问一个博客时,他们可能会看到下一个访问该网站的访客相同的数据。如果数据不经常改变,就没有必要回到数据库去检索相同的数据。数据缓存帮助我们解决这个问题。数据缓存是将常用数据存储一段时间。
让我们通过一个示例来展示这种方法。由于我们使用的是 Entity Framework Core,我们将有一个现有的服务 (CoffeeService),其中包含一个简单的 .GetAll() 方法,返回所有的咖啡。我们可以在服务周围包装一个新的缓存类,称为 CacheCoffeeService,如下所示:
public class CacheCoffeeService : CoffeeService, ICachedCoffeeService
{
private const string keyCoffeeList = «EntireCoffeeList»;
private readonly IMemoryCache _cache;
public CacheCoffeeService(IBucksDbContext dbContext,
IMemoryCache cache)
: base(dbContext)
{
_cache = cache;
}
public List<Coffee> GetAll(bool reload = false)
{
// If we can't find it in the cache or want to reload...
if (!_cache.TryGetValue(keyCoffeeList, out List<Coffee> coffees) || reload)
{
coffees = base.GetAll();
_cache.Set(keyCoffeeList, coffees,
new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(60)) // 1min
.SetAbsoluteExpiration(TimeSpan.FromSeconds(3600)) // 6min
.SetPriority(CacheItemPriority.Normal)
);
}
return coffees;
}
}
public interface ICachedCoffeeService
{
List<Coffee> GetAll(bool reload = false);
}
CacheCoffeeService 继承自 CoffeeService 并使用 ICachedCoffeeService 接口。ICachedCoffeeService 接口应该与 CoffeeService 完全相同,除了一个小的细节:每个调用都添加了一个默认为 false 的重新加载参数。
如果我们在缓存中找不到完整的咖啡列表,或者我们决定要重新加载整个咖啡列表,我们将调用基类 (CoffeeService.GetAll()),将新列表保存到缓存中,并返回整个列表。
默认情况下,当你不带参数调用 CachedCoffeeService.GetAll() 时,你会得到列表的缓存版本。传递一个 true 给 .GetAll(),你将刷新你的缓存并接收最新的咖啡列表。
这种方法提供了将缓存层与标准数据访问相结合的好处,让我们两全其美。在创建这些数据缓存时,好处立即显而易见:通过使用内存作为数据库来提高性能,这是线程安全的。然而,要注意你在缓存中存储了多少表或多少数据。
虽然使用内存作为数据库可能看起来是一种权衡,但另一种缓存选项是使用分布式缓存。分布式缓存是在多个应用服务器之间共享的缓存,并提供了以下好处:
-
它对服务器间的请求是一致的/有意识的
-
如果服务器断电,缓存的数据会持久化
-
如前所述,分布式缓存不使用本地内存
数据缓存的最佳候选者是一些小的查找表(< 100 条记录)和很少访问的表数据。
缓存静态文件
由于所有这些静态文件(如图像、CSS 和 JavaScript)都可用于我们的 Web 应用程序,你可能会认为有方法可以缓存这些文件。
在 .UseStaticFiles() 方法中,存在一个包含 HttpContext 的上下文参数,因此我们可以使用响应对象来更改静态文件的缓存控制头:
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
// Cached for 24 hours.
var response = ctx.Context.Response;
var duration = 60 * 60 * 24; // 24h duration.
response.Headers[HeaderNames.CacheControl] =
"public,max-age="+duration;
}
});
之前的代码将我们的静态文件中间件组件与一个 StaticFileOptions 实例相结合,该实例还提供了一个可供我们使用的 OnPrepareResponse 事件。对于我们的缓存持续时间,我们将每个静态文件头部的缓存持续时间设置为 24 小时。
如果我们想要禁用缓存,我们会修改响应以更改以下头信息:
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var response = ctx.Context.Response;
// disable all caching
response.Headers[HeaderNames.CacheControl] = "no-cache, no-store";
response.Headers[HeaderNames.Pragma] = "no-cache";
response.Headers[HeaderNames.Expires] = "-1";
}
});
之前的代码示例禁用了每个静态文件的缓存。
再次强调,尽管这些文件在服务器本地内存中缓存,但请注意,当电源关闭时,缓存也会消失。
如果你想要缓存某个文件夹或文件类型,ctx 参数不仅包含 HttpContext 类型的 Context 属性,还包含一个包含 IFileInfo 类型的 File 属性,该属性包含 FileInfo 数据。
摘要
尽管我们在本章中涵盖了大量内容,但还有其他方法可以通过更高级的技术在 Web 应用程序中实现性能。本章中介绍的方法是实现 ASP.NET Web 应用程序性能的最佳方法。现在,性能被认为是 Web 应用程序中更重要的特性之一,因为它通常与公司的财务状况紧密相关。
在本章中,我们首先通过展示慢速网站的影响,说明微小的调整可以产生巨大的回报,以及搜索引擎如何奖励性能改进的网站,来了解性能的重要性。
我们学习了如何使用性能工具分析客户端和服务器端代码来创建基线以识别可能的瓶颈。
然后,我们学习了客户端优化图像的技术,使用 CDN 来提高静态内容的加载,以及如何最小化请求以降低延迟问题。我们还考察了一些快速提示,例如将脚本放在底部,将样式放在顶部,回顾 Google 的 Core Web Vitals 以了解它们如何衡量网站性能,以及在相关的地方使用 HTML 而不是 JavaScript。
最后,我们通过回顾一些小的、立即的优化以及通过在发送回客户端之前优化 HTML 来提高性能,专注于服务器端。从那里,我们学习了如何通过添加 DbContext 池和识别慢查询来加快 Entity Framework Core 的速度。我们性能章节的最后一部分是实现缓存,这包括学习响应缓存、输出缓存、数据缓存以及如何缓存静态文件。
在附录中,我们将检查一些编程指南,以及现在的 ASP.NET 8 项目看起来是什么样子。
附录
在编写代码时,每种语言都有其细微差别和标准。.NET 在一般指南方面也不例外。一个例子是在方法签名末尾放置括号比将括号放在下一行或立即在 if 语句的同一行放置返回语句要好。这更多的是一种个人偏好。编程指南为开发者提供了一种在编写代码时保持平衡的方法。这些编程指南在整个行业中作为标准实践被使用。
在附录中,我们将涵盖以下主要主题:
-
编程指南
-
项目结构
以下各节中讨论的指南在行业中普遍使用。它们为开发者提供了方向,并提供了如何构建和编写代码的指导,不仅是为了自己,也是为了未来的开发者和其他同行(包括我们未来的自己)。
在第一部分,我们将回顾一些编程指南,如 DRY、YAGNI 和 KISS 原则,以及关注点分离、SOLID 概念,以及重构是一个过程而不是一次性快速修复。我们将通过查看推荐的.NET 项目和文件夹结构的组织方式来结束附录,并基于其功能确定代码的位置。
技术要求
附录的唯一技术要求是访问您的编辑器,因为我们将会涵盖一般编程指南。虽然我们会在过程中提供代码片段,但它们并不需要自己的代码仓库。它们只是为了巩固对概念的理解。
编程指南
在整本书中,我推荐了与特定主题或技术相关的各种中级和高级编写代码的技术。虽然这些技术旨在在需求和技术的平衡之间为开发者提供帮助,但也需要提供常见的编程指南,以遵循某些模式,使同事和同行更容易理解代码库。成功的开发者会在编写和维护代码时考虑这些指南。
在本节中,我们将回顾 DRY、YAGNI、KISS 和 SOLID 原则,以及理解关注点分离,以及重构是一个过程。
DRY
我们将要回顾的第一个缩写可能是最简单的遵循指南之一。DRY原则代表不要重复自己。
如果你的应用程序的不同位置有多个执行相同任务的方法,那么可能需要重构和合并代码。
YAGNI
我们接下来要讨论的常见缩写是YAGNI(发音为 yag-nee),代表你不会需要它。
也被称为“建造通向无人的桥”,这个缩写背后的概念是让开发者知道他们只有在有需求时才应该编写代码。他们不应该添加可能不会实现的未来增强功能的代码。
KISS
由于这个缩写词有如此多的含义,我们将尽量保持简单(因此得名)。KISS 代表 keep it simple, stupid。
Albert Einstein mentioned "Make everything as simple as possible, but not simpler," and Steve Jobs of Apple always said, "Simplify."
保持你的代码单元足够简单,以便理解。这可以包括以下内容:
-
更小的方法 – 方法越小,就越容易阅读和理解
-
语言增强 – 基于多年来 .NET 的 C# 语言改进,可能存在更好的(更简短)的编写代码的方式
-
简化复杂性 – 当简化复杂性时,系统变得更加可测试,并且可能成为自动化测试的候选者。
目标是通过为同行和同事创建更好的代码库来创造更多价值。
关注点的分离
当你开始编写自己的应用程序,运行它并看到它在屏幕上第一次执行时,这是一项巨大的成就。
随着时间的推移,应用程序需要数据库。然后需要电子邮件功能。然后是日志记录。然后是身份验证。需求不断增长,如此等等。
关注点的分离概念涉及你如何逻辑地将应用程序划分为不同的层。例如,如果一个应用程序需要电子邮件模块,它将是解决方案中名为 MyApplication.EmailModule 的独立项目。这个电子邮件模块将为应用程序提供以下好处:
-
如果需要,
EmailModule可以在另一个应用程序中重用。 -
EmailModule不需要任何外部依赖;它是自包含的。 -
EmailModule,单元测试(以及可能的集成测试)变得更容易。 -
EmailModule相比整个应用程序。当专注于特定部分时,不需要了解整个应用程序。只需要了解项目知识即可。
行业内听到的其中一个概念是“大泥球”。这个概念涉及一个项目中包含的所有应用程序代码,这是一个难以维护的代码库。这与“单体”类似,由于规模庞大,应用程序难以维护。因此,应用程序中的概念没有被分解成模块化的工作单元。如果应用程序中的所有内容都耦合在一起……到处都是,系统就会变得脆弱。如果一个开发者修改了某个位置的代码,它可能会修复当前的问题,但会在其他位置引入错误,在整个代码库中产生连锁反应。
关注点的分离是经验丰富的开发者应该通过代码审查与同行分享的,以在更大范围内改进软件并提供关于该主题的健康讨论。
重构作为一个过程
虽然重构是开发者的基本概念,但在重构代码库时涉及各种努力水平。
一个简单的例子可能是方法的重命名。一旦开发者重命名了一个方法,开发者就必须更改代码库中对该方法的全部引用。一个更高级的例子是将业务规则引擎重构以增加灵活性。虽然两者都是重构,但一个比另一个更容易。
重构应该是一个过程。多年来我使用的一个过程如下:
-
编写功能性代码 – 编写能够工作的功能性代码
-
确保代码通过测试 – 创建测试以确认代码按预期行为
-
重构并优化代码 – 重构和优化代码
你编写的代码应该(通常)有测试(参见第七章中的“100%测试覆盖率”神话)。
话虽如此,如果你要重构代码,拥有测试将非常有用,以确认你的重构努力没有白费。一旦有了测试,你就可以自由地重构和修改所需的代码,以达到你的目标。
在我的职业生涯中,业务规则引擎是一个例子,代码是功能性的,并且有大量的测试(约 700 个通过)。然而,团队遇到了一个问题,代码需要更灵活的方法,因此必须进行重构。两位团队成员花了三天时间重构代码。一旦完成重构,他们运行了最终的单元测试,发现只有两个失败的单元测试。这两个失败的单元测试是因为他们没有正确地重命名方法名称。想象一下没有测试的重构。
重构可以像代码库允许的那样复杂或简单。始终记住,重构是一个多步骤的过程,需要测试来确认重构后的代码按预期工作。
书籍推荐
我强烈推荐的一本书是 《使用 C#重构》,作者:Matt Eland,Packt Publishing,可在www.packtpub.com/找到。
SOLID 原则
SOLID原则为编写代码提供了更深入的指导。SOLID 是一个缩写,由 Robert C. Martin 在 2000 年创建。
几年来,SOLID 原则已成为编写高质量软件的标准,并为开发者提供了一种根据代码是否符合每个原则的标准来评估代码的方法。开发者可能对构成 SOLID 代码的内容有不同的看法,但再次强调,这些讨论应该与同行或团队会议中进行。
单一职责原则
单一职责原则(SRP)规定,一个类应该只有一个且仅有一个改变的理由。
以下代码违反了单一职责原则(SRP):
public class User
{
public string Name { get; set; }
public string Email { get; set; }
public bool IsValid()
{
// Validate the user data here
if (string.IsNullOrEmpty(Name) || string.IsNullOrEmpty(Email))
{
return false;
}
return true;
}
public void Save()
{
// Save user data to database here
}
}
User类有两个属性:Name和Email。然而,我们有一些执行其他职责的额外方法:一个IsValid()方法和一个Save()方法。我们的User类做得比它应该做的更多。我们应该创建两个新的类:一个叫做UserValidation用于验证,另一个叫做UserService或UserRepository用于数据库操作。
我们创建了两个额外的类,但提供了更好的软件组合。如果我们向User类添加一个新属性,并且它需要验证,开发者只需要在一个地方进行更改:UserValidation类。
开闭
开闭原则描述了软件组件应该如何对扩展开放但对修改封闭。
大多数违反开闭原则的情况通常由长分支语句(如长的if..then或switch语句)指示。
以下代码提供了一个示例:
public class ComicBook
{
public string Title { get; set; } = string.Empty;
public string Issue { get; set; } = string.Empty;
public decimal Grading { get; set; }
public string GetGradeName() =>
Grading switch
{
10.0m => "Gem Mint",
9.9m => "Mint",
9.8m => "NM/M",
>= 9.6m => "NM+",
>= 9.4m => "NM",
>= 9.2m => "NM-",
>= 9.0m => "VF/NM",
>= 8.5m => "VF+",
>= 8.0m => "VF",
>= 7.5m => "VF-",
>= 7.0m => "FN/VF",
>= 6.5m => "FN+",
>= 6.0m => "FN",
>= 5.5m => "FN-",
>= 5.0m => "VG/FN",
>= 4.5m => "VG+",
>= 4.0m => "VG",
>= 3.5m => "VG-",
>= 3.0m => "G/VG",
>= 2.0m => "G",
>= 1.8m => "G-",
>= 1.5m => "Fa/G",
>= 1.0m => "Fa",
_ => "Poor"
};
}
在这个ComicBook类中,我们有三个属性,称为Title、Issue和Grading。我们类的一个要求是根据Grading属性返回评分名称。这违反了开闭原则。
为什么呢?尽管我们已经有完整的成绩列表,但GetGradeName()方法和添加新的成绩和名称。
支持开闭原则的更好实现如下所示:
public class Grade
{
public decimal Value { get; }
public string Name { get; }
private Grade(decimal value, string name)
{
Value = value;
Name = name;
}
public static Grade FromDecimal(decimal value) =>
value switch
{
10.0m => new Grade(value, "Gem Mint"),
9.9m => new Grade(value, "Mint"),
9.8m => new Grade(value, "NM/M"),
>= 9.6m => new Grade(value, "NM+"),
>= 9.4m => new Grade(value, "NM"),
>= 9.2m => new Grade(value, "NM-"),
>= 9.0m => new Grade(value, "VF/NM"),
>= 8.5m => new Grade(value, "VF+"),
>= 8.0m => new Grade(value, "VF"),
>= 7.5m => new Grade(value, "VF-"),
>= 7.0m => new Grade(value, "FN/VF"),
>= 6.5m => new Grade(value, "FN+"),
>= 6.0m => new Grade(value, "FN"),
>= 5.5m => new Grade(value, "FN-"),
>= 5.0m => new Grade(value, "VG/FN"),
>= 4.5m => new Grade(value, "VG+"),
>= 4.0m => new Grade(value, "VG"),
>= 3.5m => new Grade(value, "VG-"),
>= 3.0m => new Grade(value, "G/VG"),
>= 2.0m => new Grade(value, "G"),
>= 1.8m => new Grade(value, "G-"),
>= 1.5m => new Grade(value, "Fa/G"),
>= 1.0m => new Grade(value, "Fa"),
_ => new Grade(value, "Poor")
};
}
public class ComicBook
{
public string Title { get; set; } = string.Empty;
public string Issue { get; set; } = string.Empty;
public Grade Grading { get; set; }
}
虽然看起来我们只是移动了switch语句,但我们做了其他事情。我们创建了一个Grade类。
创建了一个Grade类后,我们可以将任何类型的成绩分配给ComicBook类。如果创建了新的成绩类型,我们可以轻松地将其添加到我们的列表中,而无需修改ComicBook类。我们还在代码中实现了工厂模式。
以前,我们是根据十进制值比较字符串。现在,如果需要额外的属性来评分,我们可以扩展我们的Grade类以包含更多信息。
对扩展开放,对修改封闭。
Liskov 替换
Liskov 替换原则解释了任何派生类型都可以被其基类型替换。Liskov 替换背后的概念基于继承的类型和/或接口。
继续我们的漫画书示例,以下代码显示了一个简单的BasePublisher类:
public class MyNewPublisher : BasePublisher
{
public MyNewPublisher(): base(nameof(MyNewPublisher)) { }
}
public class BasePublisher
{
public string Name { get; set; }
protected BasePublisher(string name)
{
Name = name;
}
public Address GetAddress()
{
return Address.Empty;
}
}
public class Address
{
public static Address Empty => new();
public string Address1 { get; set; } = string.Empty;
public string Address2 { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string ZipCode { get; set; } = string.Empty;
}
BasePublisher类包含出版者的名称和地址。当我们创建一个新的出版者(如前面的MyNewPublisher类)时,我们将能够访问基类中所有可用的内容。
用MyNewPublisher类替换BasePublisher类的功能将是 Liskov 替换原则的一个例子。
接口分离
接口分离原则解释了客户端不应该被迫实现他们不会使用的非必要方法。
在应用程序中创建的每个接口中,定义的每个方法和属性都应该在具体类中实现。定义的接口不应在实现中浪费。
例如,假设我们为ComicBook类有一个接口。接口和实现代码如下所示:
public interface IComicBook
{
string Title { get; set; }
string Issue { get; set; }
string Publisher { get; set; }
void SaveToDatabase();
}
public class ComicBook : IComicBook
{
public string Title { get; set; }
public string Issue { get; set; }
public string Publisher { get; set; }
public void SaveToDatabase()
{
throw new NotImplementedException();
}
}
我们ComicBook类中的每一件事都有合理的解释,除了SaveToDatabase()方法。创建一个新的ComicBook实例暗示我们将每次都使用数据库。这违反了接口分离原则。
更好的实现是将数据库访问拆分到一个具有SaveToDatabase()方法的IComicBookWriter中,如下面的代码所示:
public interface IComicBook
{
string Title { get; set; }
string Issue { get; set; }
string Publisher { get; set; }
}
public interface IComicBookWriter
{
void SaveToDatabase();
}
public class ComicBook : IComicBook, IComicBookWriter
{
public string Title { get; set; }
public string Issue { get; set; }
public string Publisher { get; set; }
public void SaveToDatabase()
{
// Implementation
}
}
示例代码展示了如何通过继承自IComicBookWriter为ComicBook类提供持久化数据的方式。
接口分离原则的目标是避免在接口中包含你不会使用的方法。
这个例子也违反了单一职责原则,因为这个类也在访问数据库。
依赖倒置
依赖倒置原则解释了我们应该依赖于抽象而不是具体实现。在.NET 中,依赖注入是默认可用的。有了自动可用的依赖注入,这满足了依赖倒置原则的一半。
虽然我们可以将具体类注入到构造函数中,但更好的实现是创建一个具体实现的接口。使用接口鼓励我们在整个代码库中实现松散耦合。
例如,回到第五章中,使用 Entity Framework,我们为了这个原因创建了一个简单的接口来支持我们的DbContext。我们不是注册一个DbContext的具体实现,而是使用其接口。
我们注册了我们的抽象(接口)以支持我们的依赖倒置原则。
在本节中,我们讨论了 DRY、YAGNI 和 KISS 等术语,以及关注点分离的含义以及重构是一个过程而不是单一任务。我们通过学习每个 SOLID 实践结束本节,即单一职责、开闭、里氏替换、接口分离和依赖倒置原则。
在下一节中,我们将学习基于项目类型的文件夹组织。
项目结构
如第七章中所述,在测试方面,文件夹结构可以揭示应用程序的意图并提供文档。
在本节中,我们将学习 ASP.NET Web 应用程序的文件夹结构。我们还将学习根据意图放置代码的位置,例如放置 API 代码或 Entity Framework 代码的位置。
理解项目景观
每个项目都有其基于类型的结构。例如,Razor Page项目的布局与模型-视图-控制器(MVC)项目或 API 项目不同。
让我们检查这些常见项目中包含哪些文件夹。
首先,以下是一个 ASP.NET Razor Page 项目的示例:

图 11.1 – Razor 页面项目的常见文件夹结构
接下来是一个 ASP.NET MVC 项目的示例:

图 11.2 - MVC 项目的常见文件夹结构
随着我们逐个项目进行,我们将解释每个文件夹的功能以及在应用程序中的目的。
wwwroot 文件夹
在上述任何项目类型中,wwwroot 文件夹包含网站上使用的所有静态内容。添加到该目录的任何文件夹都是静态内容,并且对浏览器可见。
一个例子是图像文件夹。如果我们向 wwwroot 文件夹添加一个图像文件夹,该图像文件夹的 URL 将如下所示:
https://localhost:xxx/images/funnyimage.jpg
对于 JavaScript 框架(如 Angular、React 等),应在 wwwroot 文件夹下创建一个名为 source 或 src 的文件夹来存放客户端源代码。JavaScript 框架应转换到您选择的另一个文件夹,例如 js 或 app 文件夹,以便公开供浏览器使用。我们曾在第六章中提到这些文件夹,当时我们使用任务运行器构建客户端任务。
页面文件夹
在 Razor Page 项目中,Pages 文件夹是服务器端页面所在的位置。创建的每个文件夹都是一个页面的路径。
例如,如果我们创建了一个 Setup 文件夹并添加了一个 Index.cshtml 文件,执行和查看该页面的 URL 将如下所示:
https://localhost:xxx/setup/
其他在 Pages 目录下创建的文件夹将遵循相同的路径,如图 图 11**.3 所示:

图 11.3 – MenuManager 页面的文件夹结构
根据图 图 11**.3 中的目录结构,MenuManager 的 URL 将如下所示:
https://localhost:xxx/setup/menumanager/
文件夹结构越简单,定位页面和识别页面功能就越容易。
共享文件夹
Shared 文件夹用于布局页面、ViewComponents、部分、EditorTemplates 和 DisplayTemplates 等公共组件。这些共享组件可以通过 Pages 文件夹中的网页(如果是一个 Razor Pages 项目)或 Views 文件夹(如果是一个 MVC 项目)访问。
控制器文件夹
MVC 项目总是包含一个 Controllers 文件夹,并且是网络应用程序的交通警察。
MVC 网络模型使用“约定优于配置”的概念,其中控制器的名称是路径,控制器类内部的方法是页面名称。
例如,在上面的 Controllers 文件夹中,我们有一个名为 HomeController 的类。如果我们查看 HomeController,我们会看到一个名为 Index() 的方法:
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}
存在 HomeController 类告诉我们三件事:
-
我们将有一个带有
Index()方法的/HomeURL 作为默认页面 -
Home文件夹位于/``Views文件夹下 -
由于
HomeController中有一个Index()方法,因此/``Views/Home目录中应该有一个Index.cshtml文件
Index() 方法告诉我们当调用 https://localhost:xxx/Home URL 时。它将自动调用这个 Index() 方法,并且默认情况下,会在 /``Views/Home 目录中查找 Index 视图。
功能文件夹
MVC 应用程序的一个秘密是能够将控制器移动到应用程序内部的任何文件夹中。在初始启动时,ASP.NET 框架定位应用程序中所有可用的控制器,并为传入的 Web 请求创建一个路由表。基于这种方法,社区中的开发者创建了功能文件夹。
功能文件夹通常包含在根目录下的/Features文件夹中,下面有文件夹来标识实现的功能。虽然/Features文件夹是最常见的,但开发者有权将文件夹命名为他们想要的任何名称。他们还可以将控制器放在项目中的任何文件夹下。ASP.NET 在启动时可以定位所有控制器。
这些文件夹通常包含至少一个控制器、一个 ViewModel 和一个 View。它们还可以包含与功能相关的支持类。文件夹的命名基于要实现的功能。
例如,如果你的 MVC 应用程序中有一个图像查看功能,它看起来会像图 11.4.4:

图 11.4 – ImageViewer 功能文件夹示例
这种文件夹结构提供了以下好处:
-
重点 – 每个功能都是隔离的,这样团队成员可以在不引起合并问题的前提下构建功能
-
整合 – 而不是在整个项目中的文件夹之间移动,功能被限制在一个文件夹中,这使得编码过程更加高效
-
Features/AccountsReceiveable文件夹
在 MVC 中,视图路径可以修改以适应你的需求。在这种情况下,定义一个自定义路径到你的视图提供了更多灵活的配置选项。
Features文件夹技术通过提供垂直切片,正成为创建可扩展、基于功能的 Web 应用程序的更可行选项。垂直切片是在所有层(表示层、领域和数据访问)上为整个功能编写代码的过程。功能文件夹简化了这一过程,并在应用程序中传达了隔离的功能。
模型文件夹
Models文件夹包含用于你的视图的所有模型。这与 ViewModel 不同。模型和 ViewModel 之间的区别在于 ViewModel 被传递到视图中,并且可以包含支持 ViewModel 的模型。
以下代码片段展示了 ViewModel 的一个示例:
public class HomeController : Controller
{
public IActionResult Index()
{
return View(new IndexViewModel
{
Title = "Home Page",
Product = new ProductDto
{
Name = "Sunglasses",
Price = 9.99m
}
});
}
}
public class IndexViewModel
{
public string Title { get; set; }
public ProductDto Product { get; set; }
}
public class ProductDto
{
public string Name { get; set; }
public decimal Price { get; set; }
}
ViewModel 被发送到视图(IndexViewModel),其中可以包含支持 ViewModel 的数据模型(ProductDto)。
两种常见的做法包括在Models文件夹下创建一个ViewModels目录,或者在项目根目录下创建一个ViewModels目录。
视图文件夹
在 MVC 项目中,Views文件夹相当于 Razor Pages 项目中的Pages文件夹。它包含与 Razor Pages 项目相同的文件夹结构。
创建项目层
当创建一个新的 Web 应用程序时,默认的 Web 项目包含在浏览器中运行所需的最基本内容。但你是如何将应用程序分割成多个部分,以避免出现一大堆难以管理的代码呢?
层或层是应用程序的一部分,被分割成模块或项目,旨在以某种方式执行。表示层包含用户界面,用户如何与网站交互,而数据访问层则检索应用程序的数据。
识别项目层可能是一项有些令人畏惧的任务,但最好的方法是根据其功能创建应用程序层。每个项目都将根据其功能采用一致的命名约定。
虽然以下推荐了项目层和名称,但架构师和团队的建议可能会覆盖这些选择:
-
<ProjectName>.Domain或<ProjectName>.Core. -
<ProjectName>.Web或<ProjectName>.UI. -
<ProjectName>.Data或<ProjectName>.Infrastructure. -
/api) 或包含在名为<ProjectName>.Api的单独项目中。 -
基础设施项目,基础设施项目中的代码量可能会变得难以管理。一个服务项目可以提供对基础设施项目的替代方案。服务可能包括MailService或<Entity>Service。这些项目通常命名为<ProjectName>.Services.
这些代码层在组织项目时提供了最佳的布局。每个项目名称都描述了意图,并为开发者提供了一个对整个解决方案的清晰表示。
摘要
在本附录中,我们学习了 DRY、YAGNI 和 KISS 原则,以及关注点的分离、SOLID 概念,以及重构是一个过程而不是一次性的快速修复。
我们继续探讨两个常见的 ASP.NET Web 应用程序的结构以及每个文件夹代表的内容。一旦我们理解了一个项目的文件夹结构,我们就根据代码的意图来检查代码将驻留的位置,例如 Entity Framework 或服务类。
感谢!
最佳实践被认为是正确的、常见的,并且被该领域的其他人所接受的。本书中包含的最佳实践是多年观察、经验和来自同行、导师以及开发社区反馈的结合。
我希望这些最佳实践能成为您 ASP.NET 开发生涯的参考,并且您能像我体验新的编程技术或技术时一样,达到同样的兴奋程度。
感谢您的阅读!
开发者们,继续编码吧...


浙公网安备 33010602011771号