C--和--NET-命令行应用构建指南-全-
C# 和 .NET 命令行应用构建指南(全)
原文:
zh.annas-archive.org/md5/77db9fe60d29f3a024109d415aebc788译者:飞龙
前言
CLI 应用程序无处不在!一旦您开始注意,您就会开始注意到它们。
有一个原因使得 CLI 应用程序无处不在:CLI 应用程序通过帮助我们专注于手头的任务并使重复性任务自动化(如果手动执行则可能出错)来提高生产力。事实上,许多图形用户界面(GUI)应用程序都包含 CLI 选项,正是出于这个原因。
通过学习如何开发自己的 CLI 应用程序,您可以为您的用户和客户带来显著的业务价值。
这正是我们将一起在这本书的页面上探索的内容!
这本书的适用对象
这本书是为那些认识到 CLI 应用程序价值并希望创建自己的应用程序的开发者、架构师和软件或 DevOps 工程师而写的——无论是为了满足个人需求还是客户的需求。
为了充分利用这本书,您应该具备.NET 的基础知识,以及 C#和 Git 的实际经验。
这本书涵盖的内容
第一章, CLI 应用程序简介,介绍了 CLI 应用程序并解释了为什么它们很重要。
第二章, 设置开发环境,描述了如何设置您的开发环境。
第三章, .NET 控制台应用程序的基本概念,解释了每个 CLI 应用程序背后都有一个控制台应用程序。因此,我们将从讨论控制台应用程序开始我们的旅程。
第四章, 命令行解析,将我们的控制台应用程序转换为真正的 CLI 应用程序,并学习如何解析其参数。
第五章, 输入/输出和文件处理,深入探讨了从文件读取和写入文件的基本概念,这是 CLI 应用程序的常见任务。
第六章, 错误处理和日志记录,解释了如何处理错误,以便应用程序能够优雅地处理这些错误,我们将学习如何记录相关信息以供后续分析。
第七章, 交互式 C**LI 应用程序,教授创建交互式和视觉吸引人的 CLI 应用程序以增强用户体验的技术。
第八章, 构建模块化和可扩展的 CLI 应用程序,深入探讨了使我们的 CLI 应用程序更容易维护和扩展的技术。
第九章, 使用外部 API 和服务,涵盖了如何消费和使用外部依赖项,包括 Web 服务来扩展我们的 CLI 应用程序的功能。
第十章, 测试 CLI 应用程序,解释了如何测试我们的 CLI 应用程序,因为测试是应用程序开发生命周期中的关键步骤之一。
第十一章,打包和部署,深入探讨了如何使用最广泛使用的分发方法将我们的 CLI 应用程序打包和分发给我们的用户和客户。
第十二章,性能优化和调整,介绍了如何发现性能问题以及提高我们应用程序性能的常见技术,从而使我们的用户感到满意。
第十三章,CLI 应用程序的安全考虑,探讨了关键的安全领域,探讨了帮助我们评估应用程序安全状况的工具,并教授了一些确保远程通信安全的技术。
第十四章,额外资源和库,探讨了帮助你深入理解本书中涵盖的各种概念的额外材料。
要充分利用本书
下表显示了运行本书示例所需的软件。所有代码示例都已使用.NET 8 在 Windows 上进行测试。然而,它们也应该适用于未来的版本发布。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| .NET 8 | Windows, macOS, 或 Linux |
| Visual Studio Code | |
| Git |
在第二章中,我们将详细解释设置开发环境所需的步骤。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub(github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们需要创建的第一件事是一个BookmarkService类。”
代码块应如下设置:
namespace bookmarkr;
public class BookmarkService
{
private readonly List<Bookmark> _bookmarks = new();
}
任何命令行输入或输出都应如下编写:
$ bookmarkr link add --name <name> --url <url>
小贴士或重要注意事项
看起来像这样。
联系我们
欢迎读者反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及本书标题。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 C# 和 .NET 构建 CLI 应用程序》,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 复印本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 书,您都可以免费获得该书的 DRM-free PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-83588-274-0
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。
第一部分:开始使用 CLI 应用程序
在本部分中,您将了解命令行界面(CLI)应用程序的概述,并了解它们在现代软件开发中的重要性。此外,您将学习设置高效的 CLI 编程开发环境,包括必要的工具和框架。最后,您将探索 .NET 控制台应用程序的基本概念,为您创建强大的 CLI 工具提供坚实的基础。
本部分包含以下章节:
-
第一章**,CLI 应用程序的介绍
-
第二章**,设置开发环境
-
第三章**,.NET 控制台应用程序的基本概念
第一章:CLI 应用简介
在计算领域,命令行界面(CLI)应用体现了基于文本的用户界面的持久力量和效率。与它们的图形界面版本不同,CLI 应用提供了一种简洁、无装饰的与软件交互的方法,使用户能够直接从终端执行命令、操作文件以及执行众多任务,甚至可以自动化这些任务,使得这些任务无需用户交互!
在本章中,我们将涵盖以下主题:
-
IT 专业人士的典型一天
-
CLI 应用是什么,它们的优点是什么,以及何时使用它们
-
流行的 CLI 应用
IT 专业人士的一天
今天是个美好的星期一早晨。今天,我和我的团队开始了一个新的项目。
项目是构建一个使用ASP.NET的 Web 应用,其中Entity Framework作为对象关系映射器(ORM),NuGet用于管理依赖项,Git作为代码版本控制系统,由于应用将部署在 Azure 上,因此使用Bicep作为基础设施脚本语言。
几年前,我会使用 GUI 应用来做这件事,比如完整的Visual Studio、GitHub Desktop或GitKraken,以及Azure 门户。
今天,我大部分的工作都在Visual Studio Code及其集成的终端中进行。
因此,我使用mkdir命令创建我的项目目录,cd定位到这个目录,dotnet new创建我的项目,git init初始化 Git 仓库,dotnet add package将 NuGet 包作为依赖项添加到我的项目中,dotnet ef dbcontext scaffold生成数据库上下文以及数据库的所有实体类型类,dotnet build编译我的应用,以及dotnet run运行它。当需要将我的应用部署到 Azure 时,我使用az login登录我的 Azure 账户,az account set定位到适当的订阅,最后,使用az deployment group create根据我的 Bicep 脚本部署 Azure 基础设施。
当我意识到这一点时,我停下来仔细观察了情况。哇,CLI 应用无处不在!它们确实是我们日常运营的一部分,无论我们在 IT 团队中扮演什么角色。
我想知道我是怎么错过这个的……然后我恍然大悟。这可能是由于 CLI 应用有点害羞(它们没有花哨的用户界面),因此我们可能并没有总是注意到它们,所以让我来告诉你一些常见的 CLI 应用:
-
如果你是一名开发者,你肯定使用过.NET CLI(
dotnet)、Node.js CLI(node)、npm 包管理器(npm)、Angular CLI(ng)、Python(python)、Git(git)、Docker(docker)、Kubernetes(kubectl)等等。 -
如果你是一名 DevOps 工程师,你可能正在使用 Git(
git)、GitHub(gh)、Azure DevOps(az devops)、Docker(docker)、Kubernetes(kubectl)、Ansible(ansible)等等。 -
如果你是一名系统管理员,你可能经常在各种操作系统上使用软件包管理器,例如 Linux 上的
apt,macOS 上的brew,甚至在 Windows 上的choco或winget。你也可能使用 Shell、自动化和配置工具,如 PowerShell 或 Bash。 -
如果你是一名云管理员或架构师,你可能在使用 Azure CLI(
az)、AWS CLI(aws)、Terraform(terraform)或 Bicep(bicep)等工具。 -
如果你是一名数据科学家,你可能在使用 Python(
python)、R(R)、Pandas(pandas)、SQL(sql)或 Jupyter Notebooks(jupyter notebook)。 -
如果你是一名视频或音频制作人、内容创作者,或者只是一个多媒体爱好者,你可能会使用
FFmpeg(是的,它是一个命令行应用程序)来操作、转换和分析媒体文件。 -
并且这个列表还在继续…
然后,我开始思考为什么会这样。我们是怎么从那些色彩鲜艳、动画丰富的美丽 UI 界面,那些邀请我们执行各种任务和活动的界面,切换到终端内闪烁的光标,等待我们告诉它做什么的呢?这看起来可能像是一个很大的倒退,对吧?
我知道我挣扎过这种感觉,直到我弄清楚为什么命令行应用程序如此出色!当然,它们让我们在麻瓜朋友和亲戚面前看起来很酷、很聪明。但这并不是全部。好吧…不仅仅是这样。因为,当我们独自工作在项目上时,没有多少人可以让我们留下深刻印象。
所以…
为什么要关心命令行应用程序?
因为它们通过让我们专注于手头的工作来提高我们的生产力。
你看,当我们在不同应用程序之间切换上下文时,我们可能会失去对所做事情的注意力,并被一些其他无关的活动所干扰。
通过依赖命令行应用程序,我们输入并执行的命令都发生在同一个终端中,所以我们更有可能专注于我们所做的事情,从而取得更多的成果。
用命令行还是不用命令行?
那就是问题。答案相当简单:你不必选择。在某些情况下,命令行应用程序非常合适,而在其他情况下则不合适。想象一下使用 Microsoft Teams、Slack 或任何 Adobe Creative Suite 的工具。作为命令行应用程序与之交互有意义吗?当然没有!(除非是为了安装和配置它们)。
所以,这里的关键点是你意识到了命令行应用程序的力量,并且你开始在日常工作流程中利用它们。它们并不是用来取代那些出色的图形用户界面应用程序的。
如我的妻子、高管教练 Lamia Rarrbo 所说:“这不仅仅是一个或的情况,而是一个和的情况。”
命令行应用程序作为创建工作站配置文件的基础
让我给你讲一个真实的故事。几年前,我的一个客户要求我提出一个解决方案,为他们在公司内部不同角色的工作站构建配置配置文件。
你可能会说这没什么新意,你完全正确!
那么,是什么让这种情况值得提及?是什么让它变得特殊?
多年来,这通常是通过根据你在组织中的角色使用定制的操作系统镜像来实现的。然而,这需要付出代价:
-
存储成本:这些镜像通常很大,因此需要大量的磁盘空间来存储。
-
操作系统更新的成本:当推出新的操作系统版本时,IT 部门必须重新创建所有镜像。
-
工具更新的成本:当各种配置文件使用的工具的新版本推出时,IT 部门必须重新创建受影响的镜像。
-
挫折的成本:由于计算机是通过这些镜像配置的,并且这项活动完全由 IT 部门执行,这导致两端都感到沮丧。首先,用户指责 IT 部门在提供新机器方面速度慢(“3 周来配置一台新笔记本电脑?!你在开玩笑吧!”。我们都在某个时候听说过这种说法,对吧?)。其次,IT 人员必须配置这些计算机 除了 他们其他任务之外。
我提出的解决方案是利用 CLI 应用程序为用户提供更多自主权,同时确保 IT 部门仍然对工作站上部署的内容保持控制。
因此,我为每个角色编写了配置配置文件作为 PowerShell 脚本。这个脚本依赖于 Chocolatey(后来还依赖于 WinGet)来安装给定用户角色所需的所有工具。这些脚本存储在用户可以访问的文件共享上,根据他们的角色(例如,如果你是分析师,你无法访问开发者配置文件,等等)。
这个解决方案提供了多个优点:
-
现在,IT 部门只安装操作系统,配置用户账户,然后将工作站交给用户。
-
用户现在可以导航到文件共享,并开始根据他们的配置文件安装他们的软件。
-
IT 部门可以更新配置配置文件,或者提供新的配置文件,而不会对用户产生影响,也不会让他们等待。
因此,通过利用 CLI 工具,我们能够根据用户的配置文件以个性化的方式自动化工作站配置。想象一下,如果我们不得不通过每个应用程序的 GUI 助手安装每个应用程序,这将有多么繁琐!
通过依赖 CLI 工具,我们能够提高 IT 部门的效率和用户的满意度。
即使是重型图形应用程序也有 CLI 工具!
为什么前面的故事有趣?
这并不是因为使用了 PowerShell、WinGet 或 Chocolatey。这些显然是 CLI 工具。
这是因为我们设计了一种从命令行安装软件的方法,而不涉及任何图形用户界面。这意味着即使我们在安装图形应用程序(如微软办公套件、网络浏览器和 Adobe 套件),我们也是通过依赖它们自己的 CLI 工具来完成的。
是的,这些图形应用程序提供了一个 CLI 工具,允许我们安装(有时甚至配置)这些应用程序。
即使 ChatGPT 也有 CLI!
快速进行一次谷歌搜索,我发现甚至有针对 ChatGPT 的 CLI 应用程序!你能相信吗?!
你可以在 github.com/kardolus/chatgpt-cli 和 github.com/marcolardera/chatgpt-cli 上查看一些。
我告诉你:CLI 应用程序确实无处不在,一旦你开始关注它们,你就会开始注意到它们在你的日常生活中有多么重要!
摘要
如果你从事 IT 工作,你很可能已经使用过至少一种类型的 CLI 应用程序(而且可能性很大,你使用了很多!),你可能甚至每天都在使用它。
我相信,到现在为止,你已经理解了 CLI 应用程序的价值以及它们在你日常工作中扮演的角色。
在这本书中,我们将探讨构建我们自己的 CLI 应用程序的各个方面,这些应用程序满足我们以及我们的用户和客户的需求。
感到兴奋吗?那么,拿起你的键盘,启动你的终端,让我们开始编码吧!
我们只是触及了 CLI 应用程序能做什么以及为什么它们是当今科技领域中一项基本工具的表面。我希望这本书能成为你的路线图和在这段激动人心的旅程中的指南。
哦,顺便说一下,不要只是阅读这本书,要体验它!
现在,翻到这一页,让我们继续一起的旅程。CLI 应用程序的世界在等待着你!
享受这段旅程。
第二章:设置开发环境
在本书中,我们将使用.NET 构建(并逐步改进)一个 CLI 应用程序。我们将从设置我们的开发环境开始,这意味着安装开始构建 CLI 应用程序所需的工具。
为了巩固和加强你的学习,从而最大限度地利用本书,我强烈建议你练习我将要展示的演示,并完成每章末尾的“你的机会!”部分中建议的练习。为此,你需要设置并正确配置你的开发环境。
更具体地说,在本章中,我们将做以下几件事:
-
安装 Visual Studio Code
-
安装所需的扩展
-
安装.NET SDK
-
安装和配置 Git
技术要求
本书中的代码可以在github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET找到。每章的代码将放在按章节划分的文件夹中,并在每章开头的技术要求部分中提及。
注意
本章将没有代码示例,因为我们将专注于安装必要的工具并准备好你的开发环境。请注意,所有必需的工具都可以免费获得。
在本书中,我将使用一台 Windows 11 开发机器。如果你正在使用 Linux 或 macOS,应该不会有任何重大差异,除了工具的安装,我将在本章需要时突出显示。
安装 Visual Studio Code
尽管可以使用各种代码编辑器和集成开发环境(IDE)来开发.NET 应用程序,但我们将依赖 Visual Studio Code。
Visual Studio Code 是来自微软的一个开源、免费、跨平台的强大代码编辑器,可用于开发多种技术(包括.NET)的应用程序。它的真正力量来自于可以添加到其中的各种扩展,这些扩展来自微软、第三方编辑器和社区,可以扩展其功能,使其成为独一无二的代码编辑器。据我拙见,随着扩展市场提供的所有可能性,Visual Studio Code 正在模糊“简单”代码编辑器和完整强大的 IDE 之间的界限。
我选择 Visual Studio Code 的首要原因是,由于其跨平台性,无论你在 Windows、Linux 还是 macOS 上运行,其使用方式都是相同的。因此,即使我将在 Windows 上运行,如果你在另一个平台上运行,你也不会有任何问题跟随我,这是一个巨大的优势!
下载和安装 Visual Studio Code 的最简单方法是访问code.visualstudio.com/网站并从那里下载。

图 2.1 – 下载 Windows 版 Visual Studio Code
重要提示
网站上的下载链接应根据您访问网站的平台进行适配。换句话说,如果您的计算机正在运行 Linux,链接应显示为下载 Linux。如果不是,请点击下载链接右侧的下拉符号以显示其他选项。
下载完成后,请转到您的下载文件夹,双击安装文件。这将启动安装过程。在您选择下一步和完成,接受默认值后,您将在您的开发工作站上安装 Visual Studio Code!
在安装向导的选择附加任务屏幕上,我建议您检查以下图中突出显示的两个复选框。这些复选框将帮助您从当前文件/文件夹的上下文中打开 Visual Studio Code。我发现这非常实用。

图 2.2 – “用 Code 打开”复选框
我们现在已安装并准备好使用代码编辑器。然而,Visual Studio Code 的真正力量在于其扩展。它们使开发过程变得顺畅且简单,同时提高开发者的生产力。
在本质上,扩展是一个软件包,它为 Visual Studio Code 编辑器添加新的功能、功能或自定义。
让我们安装一些扩展!
安装所需的扩展
在本节中,我们将安装一些我强烈推荐的扩展。
您也可以安装其他扩展。请通过社交媒体告诉我您安装了哪些其他扩展以及您为什么喜欢它们。我总是渴望学习新事物 😊。
我为这本书推荐的扩展如下:
-
C#:如果您想在 Visual Studio Code 中启用 C# 支持,此由微软提供的扩展是必选的。它提供语言支持(包括语法高亮和智能感知),调试功能以及代码补全。
-
C# 开发工具包:此由微软提供的扩展在 Visual Studio Code 中提供了完整功能的 Visual Studio 的解决方案资源管理器和测试资源管理器体验。如果您正在从 Visual Studio 迁移到 Visual Studio Code,或者您仍在使用两者,此扩展将在两个环境中为您提供类似的经验。
-
IntelliCode for C# 开发工具包:此由人工智能驱动的扩展提供整行补全、按排名的智能感知建议以及基于您的代码库的个性化见解。
-
GitLens:GitKraken 提供的此扩展通过提供诸如 Git 责任注释、代码导航和提交图导航等功能,极大地增强了您在 Visual Studio Code 中的 Git 体验。它大大提高了您的 Git 生产力。
重要提示
如果您在 Visual Studio Code 中使用 AI 助手扩展,如 GitHub Copilot,您可能会收到一条警告消息,指出如果启用了 AI 助手,IntelliSense 将无法工作。
要将扩展添加到您的 Visual Studio Code 环境,请通过点击 Visual Studio Code 界面左侧的相应图标来使用 扩展 窗口:

图 2.3 – Visual Studio Code 中的扩展窗口
从那里,您可以搜索(并安装)各种扩展。您也可能注意到 Visual Studio Code 会根据您正在开发的应用程序类型建议扩展。是否安装它们取决于您。只需记住,您安装的扩展越多,Visual Studio Code 消耗的计算机资源就越多。因此,您可能需要在您安装的扩展和您愿意接受的性能损失之间找到平衡。
让我们接下来安装 C# 开发工具包扩展。
首先,打开 C# 开发工具包。可能会返回多个结果,但我们寻找的应该是最上面的一个。在点击它以选择它并显示其产品页面之前,请确保它是正确的(由微软开发的 C# 开发工具包),然后点击下面的 安装 按钮。安装过程只需几秒钟。

图 2.4 – 安装 C# 开发工具包扩展
安装完成后,一旦打开了一个项目,C# 开发工具包扩展将添加 解决方案资源管理器 功能,这与我们在 Windows 上的 Visual Studio 经验类似。

图 2.5 – C# 开发工具包扩展的实际应用
重要提示
注意,当您安装 C# 开发工具包扩展时,C# 扩展也会自动安装。
其他扩展可以通过完全相同的流程进行安装。
安装 .NET SDK
如您所猜,.NET SDK 是必需的,因为我们将会使用 .NET 开发 CLI 应用程序。
虽然任何版本的 .NET 都可以,但我们将使用 .NET 8,以下是一些原因:
-
这是 .NET 的最新 长期支持 (LTS) 版本,并将支持到 2026 年 11 月 10 日 (
dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core) -
它是跨平台的,因此我们使用 .NET 8 构建的 CLI 应用程序可以在 Windows、Linux 或 macOS 上执行
在您安装 .NET 8 SDK 之前,您可以使用此命令来验证它是否已经安装(在 Windows、Linux 和 macOS 上均有效):
$ dotnet --list-sdks
这将返回您机器上安装的 .NET SDK 列表。
列表应如下所示:
3.1.424 [C:\program files\dotnet\sdk]
5.0.100 [C:\program files\dotnet\sdk]
6.0.402 [C:\program files\dotnet\sdk]
7.0.404 [C:\program files\dotnet\sdk]
如果您的机器上没有安装 .NET 8 SDK(如前所述列表所示,我的机器上没有安装),您可以通过访问此网站下载它:dotnet.microsoft.com/en-us/download

图 2.6 – 下载 Windows 版 .NET 8 SDK
下载完安装程序后,在您的下载文件夹中找到它,双击以启动安装过程。这需要点击几个按钮,然后是“下一步”和“完成”。
安装完成后,你会注意到不仅安装了 .NET 8 SDK,还安装了 .NET 运行时。
.NET 运行时是执行编译的 .NET 代码并提供内存管理、异常处理等运行时服务的核心组件。另一方面,.NET SDK 包含用于开发、构建、测试和调试 .NET 应用程序的工具和库。

图 2.7 – .NET 8 SDK 已安装
然后,我们可以运行之前的命令来列出已安装的 .NET SDK:
$ dotnet --list-sdks
这次,你会注意到 .NET 8 SDK 已经存在:
3.1.424 [C:\program files\dotnet\sdk]
5.0.100 [C:\program files\dotnet\sdk]
6.0.402 [C:\program files\dotnet\sdk]
7.0.404 [C:\program files\dotnet\sdk]
8.0.204 [C:\program files\dotnet\sdk]
如果你正在 macOS 计算机上运行,访问 .NET SDK 安装网站将看起来像这样:

图 2.8 – 下载 macOS 版 .NET 8 SDK
我们现在拥有开发应用程序所需的一切。然而,如果你对开发认真负责,你需要一个代码管理和版本控制工具。这就是 Git 发挥作用的地方。
安装和配置 Git
Git 是一个强大且广泛使用的分布式版本控制系统,允许开发者跟踪更改、在代码上进行协作、管理项目历史记录并有效地维护代码库的不同版本。它提供了许多好处,包括版本跟踪、分支和协作,使其成为软件开发团队的必备工具。
换句话说,如果你对开发认真负责,你需要使用 Git。
从我们的角度来看,因为我们将会处理托管在 GitHub 上的代码仓库,我们需要使用 Git(因此,需要在我们的开发工作站上安装它)。
Git 可以安装在 Windows、Linux 和 macOS 上。
根据你使用的操作系统,Git 可能已经安装。你可以在终端窗口中运行此命令来检查:
git --version
如果它已经安装,你可以通过运行此命令来更新它:
git update
一旦完成,你可以跳过本节的其余部分。
安装 Git 最简单的方法是访问 git-scm.com/downloads 网站,在那里你可以获取下载你选择平台 Git 的链接。
由于我正在 Windows 机器上运行,我将通过点击“为 Windows 下载”链接来下载 Git for Windows:

图 2.9 – 下载 Git for Windows
接下来,我将在 独立安装程序 下的 64 位 Git for Windows 安装程序 上点击,因为我正在运行 x64 机器:

图 2.10 – 选择 Git for Windows 的 64 位版本
下载完成后,双击下载文件夹中的可执行文件以启动安装过程。同样,安装过程包括点击 下一步 和 完成 按钮。除了两个例外,你应该接受大多数默认值。
第一个例外是,我在安装向导的 选择 Git 使用的默认编辑器 屏幕上选择 使用 Visual Studio Code 作为 Git 的默认编辑器:

图 2.11 – 将 Visual Studio Code 设置为 Git 的默认编辑器
第二个例外是,我要求 Git 使用 main 而不是 master 来覆盖默认分支名称,以使用一个更具包容性的名称。

图 2.12 – 在 Git 中覆盖默认分支名称
安装完成后,我可以通过打开终端窗口并输入以下内容来验证 Git 是否确实已安装:
$ git
当安装过程成功完成后,你应该看到以下输出:

图 2.13 – Git 已安装!
重要提示
如果你正在使用 Linux 工作站,那么 Git 可能已经安装了。所以在尝试安装之前,我建议你通过打开终端并简单地输入 git 来验证它是否已经安装。如果已经安装,输出将显示该命令的帮助信息。
一旦 Git 安装完成,我们将执行一些基本配置,特别是设置 Git 用户身份,以便其他开发者可以识别我们的贡献。
为了这个目的,我们将使用以下两个命令:
$ git config --global user.name "Tidjani Belmansour"
$ git config --global user.email "Tidjani.Belmansour@gmail.com"
完美!我们现在不仅能够编写代码,还能够对其进行版本控制和跟踪更改。
摘要
恭喜!你现在拥有了一个配置正确且准备就绪的开发环境。
作为提醒,我们已经安装了以下内容:
-
Visual Studio Code,它将作为我们的代码编辑器
-
C#、C# 开发工具包、IntelliCode for C# 开发工具包和 GitLens 扩展程序,这些将使开发、调试和测试过程更加顺畅
-
.NET SDK,显然是必需的,因为我们将要开发一个 .NET CLI 应用程序
-
Git,由于我们将要处理 GitHub 仓库,是必需的
现在我们已经完全设置好并准备就绪,我们可以开始我们的 CLI 应用程序之旅了。我们的第一站将带我们探索 CLI 应用程序的概念和解剖结构。
轮到你了!
几乎本书的每一章都以一个 你的机会! 部分结束,在这个部分中,您将接受挑战,通过应用您刚刚完成的章节中学到的知识来完成一个或多个任务。
由于本章全部是关于配置您的开发环境,您的挑战就是配置您的环境,以便您能够练习在接下来的章节中学到的内容。如果您已经完成了这个任务,做得好!您在这里没有更多的任务要完成,我将在下一章见到您。
第三章:.NET 控制台应用程序的基本概念
现在我们已经设置了开发环境,是时候开始使用 .NET 开发 CLI 应用程序之旅了。
然而,首先,我们将探索控制台应用程序!
你可能对控制台应用程序很熟悉,你可能想知道为什么我们需要在一本关于 CLI 应用程序的书中讨论控制台应用程序。原因在于每个 CLI 应用程序的核心都是一个控制台应用程序。这就是为什么在本章中,我们将花一点时间来探索控制台应用程序。此外,你知道他们说什么:提醒从不伤害人 😉。
控制台应用程序可以被视为可以构建的最简单的 CLI 应用程序。
因此,通过探索我们如何创建、运行和交互控制台应用程序,我们将获得如何使用 CLI 应用程序以及如何利用 .NET SDK 提供的控制台应用程序模板来构建它们的基本理解。
然后,我们将创建一个非常简单的 .NET 控制台应用程序,它接受一些输入参数并显示一条消息作为输出。然后我们将增强这个应用程序,使其能够对输入进行一些基本验证,并显示适当的消息作为输出。这条消息将根据其严重性以给定颜色显示。
具体来说,本章涵盖了以下主题:
-
学习如何创建和执行控制台应用程序
-
利用
System.Console类读取用户输入并输出响应
技术要求
本章的代码可以在本书配套的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter03/helloConsole。
创建(并执行)一个简单的控制台应用程序
让我们从打开 Visual Studio Code 并通过转到 视图 | 终端 来显示集成终端窗口开始。
接下来,确定你想要创建代码文件夹的位置(我总是在我的 C: 驱动器上的 code 文件夹中创建代码文件夹;我发现将所有代码集中在一个位置很方便)。
在确保你处于正确的工作目录后,输入以下命令以创建一个 .NET 控制台应用程序:
$ dotnet new console -n helloConsole -o helloConsole --use-program-main
让我们分解这个命令来了解它做了什么:
-
dotnet new console:这将要求 .NET CLI 工具创建一个新的控制台应用程序。这将使用 C# 作为语言,.NET 8 作为框架(因为这些是默认值)。 -
-n helloConsole:我们的应用程序将被命名为helloConsole。 -
-o helloConsole:将创建一个名为helloConsole的文件夹,其中将包含我们应用程序的所有代码文件。 -
--use-program-main:将包含Main方法的Program类添加到创建的Program.cs文件中。此方法是程序的入口点,我们在执行程序时将通过其参数将输入值传递给我们的控制台应用程序。当然,你可以跳过使用--use-program-main,但我更喜欢使用它,因为它使Program类对从旧版 .NET 版本迁移的开发者来说更加明确和熟悉。
命令执行完成后,你应该会看到一个类似以下输出的内容:
PS C:\code> dotnet new console -n helloConsole -o helloConsole --use-program-main
The template "Console App" was created successfully.
Processing post-creation actions...
Restoring C:\code\helloConsole\helloConsole.csproj:
Determining projects to restore...
Restored C:\code\helloConsole\helloConsole.csproj (in 121 ms).
Restore succeeded.
这确认了应用程序已成功创建。
生成项目的简要浏览
生成项目包含三个文件:
-
Main方法()。虽然在非常简单的应用程序中它可能包含所有应用程序的逻辑,但它通常作为起点,根据需要委托给其他类和方法。 -
.csproj文件对于 .NET 开发至关重要,因为它集中管理项目配置,使得管理、构建和在不同开发环境和构建系统之间共享项目变得更加容易。在现代 .NET 生态系统(该生态系统强调跨平台开发和灵活的项目结构)中尤为重要。在此文件中,我们通常可以找到有关项目定义、构建配置、依赖关系管理(包括项目引用和 NuGet 包的引用)、编译设置、资源包含、任何构建过程自定义、项目范围内的属性(如程序集名称和版本)、跨平台兼容性和 IDE 集成的信息。 -
.sln文件是一个基于文本的文件,用作组织和管理相关项目的容器。它在开发工作流程中扮演着至关重要的角色,尤其是在由多个项目组成的大型应用程序中。其目的是将多个相关项目组织成一个单一解决方案,为所有项目定义构建配置和平台,存储解决方案范围内的设置和元数据,并允许 Visual Studio 同时加载所有相关项目。
现在,让我们通过输入以下命令将项目加载到 Visual Studio Code 中:
$ code ./helloConsole
将打开一个新的 Visual Studio Code 实例,你将看到新创建的项目内容,其外观如下:

图 3.1 – 在 Visual Studio Code 中打开的 helloConsole 项目
不要混淆代码与代码!
前面的 code 命令是 Visual Studio Code 的可执行名称。它不应与 code 文件夹混淆 😉。
Program.cs 文件中的 Main 方法当前不包含太多代码。事实上,当应用程序执行时,它只显示一条 "Hello, World!" 消息:
namespace helloConsole;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
让我们执行此应用程序并查看它返回的内容。为此,我们需要输入以下命令:
$ dotnet run
重要的是要注意,这个命令可以在任何终端中执行:PowerShell 终端、CMD 控制台或 Bash 终端(如果你正在运行 Linux 或 macOS)。然而,由于我们正在使用 Visual Studio Code,最简单的方法是使用 Visual Studio Code 中的集成终端来运行命令。也就是说,你必须确保你处于项目文件夹中,这意味着dotnet run命令应该在.csproj文件相同的当前工作目录中执行。
这将构建并执行应用程序。输出如下所示:

图 3.2 – Hello, World! 输出
这目前并不很有用,对吧?
然而,我们可以注意到Main方法接受一个字符串数组作为参数(即作为输入参数)。因此,让我们使用它来向我们的程序传递一些参数。
首先,我们修改我们的应用程序代码以显示该参数的值,如下所示:
namespace helloConsole;
class Program
{
static void Main(string[] args)
{
Console.WriteLine($"Hello, {args[0]}!");
}
}
现在,一旦执行,我们的程序将显示Hello消息,然后是作为参数传递的值。
让我们试试:
$ dotnet run Packt
结果将如下所示:

图 3.3 – 传递一个参数
我们当然可以传递多个参数,如下所示:
$ dotnet run Packt Publishing
这次,结果将如下所示:

图 3.4 – 传递多个参数
重要提示
有三个注意事项你需要注意:
-
传递给
Main方法的参数是一个字符串数组。这意味着如果程序期望输入另一种数据类型(例如整数),你需要解析这些字符串。 -
由于这个参数是一个字符串数组,你可以通过指定它们的索引来使用传递的值,这代表它们从程序执行的位置。
-
如果你在代码中使用参数值但在执行程序时没有传递它,将会抛出异常。
从代码的角度来看,这些注意事项意味着什么?让我们考虑一些说明性的例子。
首先,我们将解决第一个注意事项。这个例子表明,尽管其值42代表整数,但输入参数的类型是string。为了将此值用作整数,我们需要解析它。

图 3.5 – 解析输入参数
接下来,让我们考虑第二个注意事项。这个例子表明,通过交换参数的索引,我们也会交换它们值的显示。

图 3.6 – 切换输入参数
最后,关于第三个注意事项,这个例子演示了在执行程序时未能为输入参数提供值会导致程序崩溃并抛出异常。

图 3.7 – 缺少输入参数
我们现在知道如何创建和执行 .NET 控制台应用程序。让我们看看我们如何与用户输入交互并更好地格式化我们的输出。
使用 System.Console 类
你可能已经注意到,到目前为止的所有示例中,我们都使用了 Console 类。更具体地说,我们只使用了它的一种方法(即 WriteLine)。这个方法显示传递给参数的表达式的值,然后转到下一行。如果我们不想转到下一行,我们可以使用 Write 方法代替。
然而,Console 类提供了其他有用的属性和方法。我们不会详细介绍所有这些(实际上,我建议你访问 learn.microsoft.com/en-us/dotnet/api/system.console)。相反,我将突出显示在控制台应用程序中最有趣的一些。
有用的属性
有三个属性我想特别介绍一下:BackgroundColor、ForegroundColor 和 Title。
前两个,正如其名称所暗示的,用于更改终端的背景和前景颜色。
第三个,Title,用于更改终端窗口的标题。请记住,您需要在外部终端(而不是 Visual Studio Code 集成终端)中执行程序才能看到更改终端标题的效果。
这里有一个示例:
class Program
{
static void Main(string[] args)
{
// performing a backup of the background and
//foreground colors
var originalBackroungColor = Console.BackgroundColor;
var originalForegroundColor = Console.ForegroundColor;
// changing the background and foreground colors
Console.BackgroundColor = ConsoleColor.Blue;
Console.ForegroundColor = ConsoleColor.Yellow;
// setting the title of the terminal while the
//application is running
Console.Title = "Packt Publishing Console App";
// displaying a message
Console.WriteLine($"Hello from Packt Publishing!");
// restoring the background and foreground colors
// to their original values
Console.BackgroundColor = originalBackroungColor;
Console.ForegroundColor = originalForegroundColor;
// waiting for the user to press a key to end the program.
// this is useful to see the altering of the terminal's title
Console.ReadKey(true);
}
}
如您所见,在程序开始时,我们备份了前景和背景颜色,并在程序结束时恢复它们。
对于终端的标题,没有必要这样做,因为设置的值仅在程序执行期间有效。
有用的方法
我还想谈谈一些有趣(且有用)的方法。以下是它们:
-
ReadLine -
ReadKey -
Clear
让我们从 ReadLine 开始。这个方法读取用户输入的所有字符,直到他们按下 Enter 键,并将用户输入作为 string 返回。因此,它对于收集用户输入,如姓名、年龄或地址非常有用。
这里有一个示例:

图 3.8 – 从控制台读取用户输入
接下来,让我们谈谈 ReadKey。这个方法读取用户按下的字符或功能键。它返回一个 ConsoleKeyInfo 类型的对象,其中包含有关按下的键的信息。它还接受一个可选的布尔参数,如果设置为 true,则不会在控制台显示按下的键,如果设置为 false,则显示。
这里有一个布尔参数设置为 true 的示例:

图 3.9 – 将布尔参数设置为 true 的 ReadKey 方法
现在,注意当布尔参数设置为 false 时的输出:

图 3.10 – 将布尔参数设置为 false 的 ReadKey 方法
你有没有注意到按下的键被显示两次:一次是在按下后,另一次是在输出消息时?
顺便说一句,你可以访问learn.microsoft.com/en-us/dotnet/api/system.consolekey以查找ConsoleKey枚举的所有值的列表。
最后,让我们看看Clear方法。正如其名所示,它清除了控制台:

图 3.11 – 使用 Clear 方法清除控制台
当用户按下C键后,控制台将被清除。
此命令通常用于以下场景:
-
在程序启动时:在这里使用它是为了确保你的程序通过删除来自其他程序或命令的所有先前输出而获得一个干净的界面
-
在进入新部分或菜单项时:这防止了先前部分的输出污染当前部分
有用的事件
如果你曾经运行过 CLI 应用程序,你可能知道Ctrl + C或Ctrl + Break键组合,这可以在任何时候通过终止程序来退出。
在.NET 控制台应用程序中,这些键组合引发一个名为CancelKeyPress的事件。当按下其中一个键组合时,事件被引发并中断正在执行的操作。我们的代码可以处理此事件,以便允许程序优雅地关闭,在关闭前清理和释放资源。
让我们用一个例子来说明这一点。考虑以下代码:
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
int counter = 1;
while(true)
{
Console.WriteLine($"Printing line number {counter}");
counter++;
Task delayTask = Task.Run(async () => await Task.
Delay(1000));
delayTask.Wait();
}
}
}
如你可能注意到的,此代码使用了一个没有退出条件的无限循环。因此,从其中退出的唯一方法是使用取消键组合之一(Ctrl + C或Ctrl + Break)。然而,当按下时,这将突然终止程序,而不给它机会执行一些动作以优雅地退出,例如以下操作:
-
保存执行状态
-
退出服务
-
关闭数据库连接
我们将修改之前的代码以处理该终止事件,允许程序优雅地终止:
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true; // This will prevent the program from
// terminating immediately
Console.WriteLine("CancelKeyPress event raised!\
nPerforming cleanup...");
// Performing cleanup operations (logging out of services,
saving progress state, closing database connections,...)
Environment.Exit(0); // This will terminate the program
// when cleanup is done
};
int counter = 1;
while(true)
{
Console.WriteLine($"Printing line number {counter}");
counter++;
Task delayTask = Task.Run(async () => await Task.
Delay(1000));
delayTask.Wait();
}
}
}
现在,执行的结果看起来像这样:

图 3.12 – 取消当前操作
因此,如果你的 CLI 应用程序与外部资源或服务交互(正如我们将在接下来的章节中做的那样),你应该利用Console.CancelKeyPress事件。
还有一件事
到目前为止,我们使用.NET CLI 的run命令执行了程序。
然而,如果你熟悉 CLI 应用程序,你会知道这类应用程序通常通过其可执行文件名来执行。
第一个浮现的问题可能是“为什么我们使用.NET CLI 的run命令来执行我们的程序?”。答案是“因为当你开发和测试 CLI 应用程序时,你就是这样做的。”
接下来的问题是“那么,我该如何使用它的可执行文件来执行我的程序呢?”这个问题的答案是“通过到达那个可执行文件的位置,并从那里运行程序。”
让我们看看我们是如何做到这一点的。
当你构建程序时,使用以下命令:
$ dotnet build
这将在你的硬盘上的bin\Debug\net8.0文件夹中生成可执行文件。
在 Windows 机器上,这看起来是这样的:

图 3.13 – 生成的可执行文件
要使用程序的可执行文件名来运行程序,请打开一个终端窗口,导航到该位置,然后输入以下命令(在这里,我们传递了42作为参数):
$ .\helloConsole 42
结果应该类似于这样:

图 3.14 – 从可执行文件运行程序
我应该使用dotnet run还是运行可执行文件?
使用dotnet run命令运行程序和从其可执行文件运行程序之间有一些区别。以下是最显著的几个:
-
dotnet run命令在运行程序之前会构建项目,这确保了我们总是运行代码的最新版本。可执行文件代表代码的最后编译版本。 -
dotnet run由于构建步骤,比直接从可执行文件运行程序要慢。虽然dotnet run命令可能需要几秒钟来构建和执行代码,但可执行文件通常在毫秒内执行。
这很重要,因为在开发阶段,你可能会依赖dotnet run来执行你的程序。然而,在测试和生产阶段,你将依赖可执行文件名。所以,确保你仔细选择你程序(及其可执行文件)的名称 😉。
摘要
在本章中,我们探讨了 CLI 应用程序的核心:控制台应用程序!这就是为什么学习如何与控制台应用程序一起工作至关重要,因为它们是构建更复杂 CLI 应用程序的基础。
我们看到了如何执行控制台应用程序,提供输入参数值,并解析这些输入参数值以将它们转换为程序期望的数据类型。我们看到了如何通过使用Console类的ReadKey和ReadLine方法来收集用户输入。最后,我们看到了如何处理由于缺少输入参数值而引发的异常。
然而,CLI 应用程序不仅仅是控制台应用程序。它包含命名参数、标志和子命令。
在接下来的章节中,我们将看到如何利用这些功能构建一个功能齐全的 CLI 应用程序。在下一章中,我们将从创建 CLI 应用程序开始,学习如何解析其输入,包括命令、标志和参数。
轮到你了!
跟随提供的代码是一种通过实践学习的好方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你完成以下任务:
创建一个控制台应用程序,执行以下操作:
-
将终端窗口的标题设置为 猜数字游戏!
-
生成一个介于 1 和 20 之间的随机整数
-
告诉玩家他们有三次机会猜出那个数字
-
在每位玩家的测试之后,程序应该执行以下操作:
-
如果玩家猜出了数字,程序将以绿色文字显示 恭喜你,你赢了,以及玩家猜出数字所需的尝试次数
-
如果玩家在三次尝试内未能猜出数字,程序应以红色文字显示 下次好运!要猜的数字是 以及要猜的数值
-
如果玩家按下 Enter 而没有提供数字,程序应将其视为零值并继续执行
-
如果玩家按下 Ctrl + C,程序应退出并显示,以黄色文字显示 感谢 您游玩!
-
第二部分:构建 CLI 应用程序的基础
在这部分,你将深入了解 CLI 应用程序开发的必要组件。你将探索命令行解析技术,学习如何有效地使用库处理用户输入和解析参数。接下来,你将了解输入/输出操作和文件处理,包括从文件读取和写入文件的方法,以及如何操纵文件流以进行高效的数据处理。最后,你将发现错误处理和日志记录的最佳实践,包括实现具有不同严重级别的结构化日志、优雅地管理异常,并在保持详细日志以供调试目的的同时,向用户提供信息丰富的错误消息。
本部分包含以下章节:
-
第四章**,命令行解析
-
第五章**,输入/输出和文件处理
-
第六章**,错误处理和日志记录
第四章:命令行解析
在上一章中,我们创建了一个控制台应用程序,并学习了如何向它传递参数,并在需要时将这些参数转换为预期的数据类型(记住,传递给控制台应用程序的参数是 String 类型)。
然而,尽管控制台应用程序是 CLI 应用程序的核心,但 CLI 应用程序不仅仅是控制台应用程序。CLI 应用程序包含命名参数、开关和子命令,以实现预期的目标。
借助我们创建控制台应用程序的知识,我们将在其基础上学习如何创建 CLI 应用程序。
要做到这一点,在本章中,我们将涵盖以下主题:
-
创建控制台应用程序
-
解析控制台应用程序的参数
-
从控制台到 CLI:使用现有库解析参数
到本章结束时,你将学习如何从一个简单的控制台应用程序开始,将其转换为处理命令、子命令和选项的强大 CLI 应用程序。
介绍 Bookmarkr
Bookmarkr 是我们将在这本书中构建的 CLI 应用程序的名称。
这是一个用于管理书签的命令行应用程序。
在本书的每一页,我们将让 Bookmarkr 生动起来,并为其添加更多功能。
为什么需要一个 书签管理器?
因为每个人都使用过,所以他们熟悉这种工具的工作方式和它提供的功能。
通过移除理解我们正在构建的“什么”业务背景的负担,我们就可以将全部注意力集中在“如何”构建它上。这正是我选择这个应用程序的原因。此外,它仍然非常有用 😉。
技术要求
本章的代码可以在本书配套的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter04。
创建控制台应用程序
让我们先创建控制台应用程序。为此,在 Visual Studio Code 中,通过转到 视图|终端 来显示 终端 窗口。
然后,将您定位到您想要创建代码文件夹的位置(我在上一章提到,我总是创建一个 C:\Code 文件夹,它将包含我所有的代码项目)。
从那里,键入以下命令以创建控制台应用程序:
$ dotnet new console -n bookmarkr -o bookmarkr --use-program-main
当在 Visual Studio Code 中加载时,.NET 项目看起来如下:

图 4.1 – 在 Visual Studio Code 中打开的 bookmarkr 项目
让我们在 Program.cs 文件中添加一些代码。
我们将实现的第一项功能是能够将新的书签添加到书签列表中。
要做到这一点,我们需要创建一个包含所有书签操作逻辑的 BookmarkService 类。通过遵循开发的最佳实践,我们将在这个名为 BookmarkService.cs 的单独代码文件中创建这个类:
namespace bookmarkr;
public class BookmarkService
{
}
接下来,我们需要向那个 BookmarkService 类添加一个 Bookmark 对象的列表:
namespace bookmarkr;
public class BookmarkService
{
private readonly List<Bookmark> _bookmarks = new();
}
我们还需要定义 Bookmark 类。在这里,我们再次遵循开发的最佳实践,并将创建这个类在其自己的代码文件中,聪明地命名为 Bookmark.cs 😊。这个类看起来是这样的:
namespace bookmarkr;
public class Bookmark
{
public required string Name { get; set; }
public required string Url { get; set; }
}
由于 Bookmark 对象的两个属性不能为 null,我们使用 required 修饰符来声明它们。
更新的 .NET 项目现在看起来是这样的:

图 4.2 – 在 Visual Studio Code 中打开的更新后的 bookmarkr 项目
现在我们已经准备好了所有部件,让我们看看我们如何处理用户请求。
解析控制台应用程序的参数
向 CLI 应用程序发出的请求通常包含命令的名称(以及可选的子命令)以及为命令所需的参数提供值的参数。
我们将要添加的第一个命令是能够将新的书签添加到书签列表中。
预期命令的语法如下:
$ bookmarkr link add <name> <url>
那么,让我们修改代码以处理这样的命令!
我们将从 Program 类的 Main 方法(位于 Program.cs 文件中)开始。为什么?因为这个方法是接收用户输入参数的方法。
由于我们可能在未来有多个命令,我们将添加一个 switch 语句来处理每一个。因此,代码将看起来像这样:
namespace bookmarkr;
class Program
{
static void Main(string[] args)
{
if(args == null || args.Length == 0)
{
Helper.ShowErrorMessage(["You haven't passed any argument.
The expected syntax is:", "bookmarkr <command-name>
<parameters>"]);
return;
}
var service = new BookmarkService();
switch(args[0].ToLower())
{
case "link":
ManageLinks(args, service);
break;
// we may add more commands here...
default:
Helper.ShowErrorMessage(["Unknown Command"]);
break;
}
}
static void ManageLinks(string[] args, BookmarkService svc)
{
if(args.Length < 2)
{
Helper.ShowErrorMessage(["Unsufficient number of
parameters. The expected syntax is:", "bookmarkr link
<subcommand> <parameters>"]);
}
switch(args[1].ToLower())
{
case "add":
svc.AddLink(args[2], args[3]);
break;
// we may add more subcommands here...
default:
Helper.ShowErrorMessage(["Unsufficient number of
parameters. The expected syntax is:", "bookmarkr link
<subcommand> <parameters>"]);
break;
}
}
}
让我们解释一下这段代码:
-
Main方法将每个命令的处理调度到Program类中的特定方法。由于现在我们只有一个命令(link),我们只有一个处理方法(ManageLinks)。 -
ManageLinks方法处理与链接相关的子命令。目前,我们只有一个子命令(add),但我们可以很容易地想象会有更多子命令,例如update(更新现有链接的 URL)、remove(删除现有链接)和list(列出所有现有链接)。 -
最后,
ShowErrorMessage方法是一个用于在红色文本中显示错误消息的实用方法。它的代码可以在Helper类中找到,这里省略因为它对我们讨论的主题没有提供价值。
如果你编写(或复制)这段代码,你会注意到它无法编译。这是因为 AddLink 方法在 BookmarkService 类中尚未可用。让我们添加它!
AddLink 方法的代码如下:
public void AddLink(string name, string url)
{
if(string.IsNullOrWhiteSpace(name))
{
Helper.ShowErrorMessage(["the 'name' for the link is
not provided. The expected syntax is:", "bookmarkr link
add <name> <url>"]);
return;
}
if(string.IsNullOrWhiteSpace(url))
{
Helper.ShowErrorMessage(["the 'url' for the link is
not provided. The expected syntax is:", "bookmarkr link
add <name> <url>"]);
return;
}
if(_bookmarks.Any(b => b.Name.Equals(name, StringComparison.
OrdinalIgnoreCase)))
{
Helper.ShowWarningMessage([$"A link with the name '{name}'
already exists. It will thus not be added",
$"To update the existing link, use the command: bookmarkr
link update '{name}' '{url}'"]);
return;
}
_bookmarks.Add(new Bookmark { Name = name, Url = url});
Helper.ShowSuccessMessage(["Bookmark successfully added!"]);
}
这段代码相当简单直接,但让我们简要解释一下:
-
我们首先确保提供了
name和url参数。如果没有,我们将向用户返回一个错误消息,其中包含命令的预期语法。 -
我们确保要添加的链接尚未存在于书签列表中。如果已存在,我们将通过警告消息通知用户,并邀请他们使用
update子命令来更新现有链接。 -
如果链接尚不存在,我们将它添加到书签列表中,并通过成功消息通知用户。
很简单,不是吗?😊
然而,这种方法构建 CLI 应用程序存在一个问题。你能猜到是什么吗?
它基于位置参数。换句话说,我们期望第一个参数是命令的名称,第二个参数是子命令的名称,第三个参数是 name 参数的值,第四个参数是 url 参数的值。
但存在一些问题:
-
用户如何知道这个顺序呢?
-
如果用户将
url值作为第三个参数,将name值作为第四个参数提供怎么办?
如果你熟悉使用 CLI 应用程序,你会知道 CLI 命令的常用语法看起来像这样:
$ bookmarkr link add --name <name> --url <url>
它也可以看起来像这样:
$ bookmarkr link add -n <name> -u <url>
这有助于用户了解预期的参数是什么,以及如何以适当的方式提供参数。
我们当然可以依赖参数的 args 列表,并将每个参数与预期值进行比较(例如,检查第三个参数的值是否为 --name 或 -n,这样我们就可以知道第四个参数代表链接的名称,依此类推),但这会使我们的代码过于复杂。
我知道你在想什么。你很聪明,你已经想出了解决这个问题的最佳方法,那就是开发一个库来解析这些参数并找出它们代表什么。
但是,因为我知道你很聪明,我知道你已经搜索过这样一个库。毕竟,你不想重新发明轮子,对吧?
从控制台到 CLI - 使用现有库解析参数
尽管存在许多针对不同编程语言的库,包括 .NET,但本书将专注于 System.CommandLine。
你可能想知道我们为什么选择这个库,尤其是如果你熟悉(或者听说过)CommandLineParser,这是一个针对该领域的另一个常见库。
这有多个原因。本质上,System.CommandLine 是一个更现代、功能更丰富、性能更好的库,而 CommandLineParser 则是一个更简单、更轻量级的替代方案。
此外,还有一些其他原因让我更喜欢 System.CommandLine:
-
System.CommandLine是由微软和社区开发的 .NET 基金会项目,而CommandLineParser是一个第三方库 -
System.CommandLine使用构建器模式和更声明性的方法来定义命令和选项,而CommandLineParser使用属性和更命令式的方法 -
System.CommandLine提供了更多开箱即用的高级功能,例如命令层次结构、响应文件、自动完成和解析指令,而CommandLineParser则更轻量级,专注于基本的命令行解析 -
System.CommandLine被认为是更快、更高效的,尤其是在处理大型命令行结构时 -
尽管这两个库都是跨平台的,但
System.CommandLine对平台特定约定的支持更好,例如在 Windows 上的不区分大小写
现在,让我们重写我们的应用程序以利用 System.CommandLine 库!
重要提示 #1
Program.cs 文件的上一版代码已被移至 Program.Console.txt 文件以供进一步参考。
重要提示 #2
虽然您可以通过其可执行文件名(位于 bin\Debug\net8.0\bookmarkr.exe)执行应用程序,但在开发阶段,使用 dotnet run 命令会更方便。
我更喜欢依赖于可执行文件名,因为它与我们将在生产中使用应用程序的方式相匹配。如果您更喜欢使用 dotnet run 命令,只需在以下执行语法中将 bookmarkr 替换为 dotnet run。
我们需要做的第一件事是将 System.CommandLine NuGet 包库添加到我们的项目中。为此,打开 Visual Studio Code 终端并输入以下命令:
$ dotnet add package System.CommandLine --prerelease
在撰写本章时,这个库仍然处于测试版。当它不再需要 --prerelease 开关时。
System.CommandLine 的工作方式是拥有一个 RootCommand 对象,它将作为 CLI 应用程序中所有其他命令的根命令。这意味着应用程序中的每个命令都有一个父命令,要么是 root 命令,要么是最终父命令是根命令的其他命令。
添加根命令
根命令是在用户不带参数调用 CLI 应用程序时被调用的命令。
根命令是 RootCommand 类的一个实例:
var rootCommand = new RootCommand("Bookmarkr is a bookmark manager provided as a CLI application.")
{
};
一个命令有一个处理程序,这是一个在用户调用该命令时被调用的方法:
rootCommand.SetHandler(OnHandleRootCommand);
SetHandler 方法接受一个指向实际执行工作的方法的委托:
static void OnHandleRootCommand()
{
Console.WriteLine("Hello from the root command!");
}
最后,由于 System.CommandLine 库遵循 Builder 模式,我们需要构建和调用解析器来启动操作:
var parser = new CommandLineBuilder(rootCommand)
.UseDefaults()
.Build();
return await parser.InvokeAsync(args);
最后但同样重要的是,让我们在文件顶部添加所需的 using 语句:
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
我们现在可以执行我们的应用程序了!输入以下内容:
$ bookmarkr
当执行时,应用程序将调用适当的命令(在这里,由于没有向执行应用程序传递任何参数,所以是根命令),然后它将调用其处理方法(OnHandleRootCommand),并且其执行结果将返回给用户。在这个例子中,命令将显示消息 "Hello from the" root command!"。

图 4.3 – 调用根命令
这不是令人兴奋吗?不?您是对的…这(目前)看起来不是一个 CLI 应用程序。让我们添加另一个命令!
添加链接命令
使用我们的 CLI 应用程序添加书签的语法应该是这样的:
$ bookmarkr link add <name> <url>
因此,我们需要创建一个link命令。此命令将以根命令作为父命令:
var linkCommand = new Command("link", "Manage bookmarks links")
{
};
rootCommand.AddCommand(linkCommand);
接下来,我们需要一个add命令,其父命令将是link命令:
var addLinkCommand = new Command("add", "Add a new bookmark link")
{
};
linkCommand.AddCommand(addLinkCommand);
addLinkCommand.SetHandler(OnHandleAddLinkCommand);
现在,让我们执行这个应用程序:
$ bookmarkr link add
很接近,对吧?
缺少的唯一元素是<name>和<url>部分。这些被称为选项,我们将在稍后探讨它们。但首先,让我们专注于命令。
关于命令
命令就像一棵树,其中根命令是…嗯,那棵树的根。
每个命令都有一个父命令,它可以是另一个命令(例如,add的父命令是link),或者根命令本身(例如,link命令)。
命令树决定了 CLI 应用程序的语法。例如,我们无法执行以下调用:
$ bookmarkr add
这是因为没有add命令以根命令作为父命令。
所有命令都需要有处理方法吗?
答案是,不,它们不需要。只有实际执行一些处理的命令才需要处理方法。在我们的例子中,根命令和link命令都不需要处理方法。
向链接命令添加选项
由于add命令需要两个参数(name和url),我们将向其中添加两个选项:
var nameOption = new Option<string>(
["--name", "-n"],
"The name of the bookmark"
);
var urlOption = new Option<string>(
["--url", "-u"],
"The URL of the bookmark"
);
如您所见,一个Option是通过以下方式定义的:
-
其值的类型(这里是一个字符串)
-
它的别名(对于
urlOption,这些是--url和-u) -
它的描述,当请求该命令的帮助菜单时将很有用(对于
urlOption,它是"The URL of the bookmark")
接下来,我们需要将这些选项分配给命令,如下所示:
var addLinkCommand = new Command("add", "Add a new bookmark link")
{
nameOption,
urlOption
};
然后,我们需要将这些选项传递给Handler方法:
addLinkCommand.SetHandler(OnHandleAddLinkCommand, nameOption, urlOption);
最后,我们在Handler方法中使用这些选项的值:
static void OnHandleAddLinkCommand(string name, string url)
{
// 'service' is an instance of 'BookmarkService'.
service.AddLink(name, url);
}
现在,如果我们执行应用程序,我们会得到预期的结果。这次,我们没有解析控制台应用程序的参数并将此委托给System.CommandLine库,因此节省了大约一半的代码:

图 4.4 – 我们成功添加了一个书签
注意,使用别名,我们可以使用以下语法执行 CLI 应用程序:
$ bookmarkr --name 'Packt Publishing' --url 'https://www.packtpub.com'
我们也可以使用以下语法:
$ bookmarkr -n 'Packt Publishing' -u 'https://www.packtpub.com'
结果将与图 4**.4中显示的相同。
我们可以使用哪些其他类型的选项?
System.CommandLine不仅支持string选项。Option<T>是一个泛型类型,因此您可以为任何数据类型创建一个Option<T>。例如,以下可以是以下内容:
-
int -
double -
bool -
DateTime -
Uri -
TimeSpan -
Regex -
Enum -
IPAddress -
FileInfo
如果您愿意,甚至可以是您自己的自定义类。
根据我们使用的数据类型,提供的选项值将被解析为该数据类型。如果解析失败,将引发异常,并在控制台显示错误信息。
这里有一个说明性的例子。根命令已被更新以接受一个整数选项。当程序执行时,如果为该选项提供了一个无法解析为整数的值,我们将得到以下错误信息:
$ bookmarkr --number toto
Cannot parse argument 'toto' for option '--number' as expected type 'System.Int32'.
嗯……虽然选项的名称表明它期望一个数字,但并不清楚这个数字是什么。它的目的是什么?这个数字的有效值范围是什么?一点帮助在这里……嗯,很有帮助 😊。
获取帮助
好消息是,当使用System.CommandLine时,我们自动获得为我们构建的帮助菜单。我们只需要为我们自己的命令和选项提供有意义的名称和描述,库就会完成其余的工作。
我们可以通过以下任一选项来获取帮助:
-
--help -
-h -
-?
结果看起来像这样:

图 4.5 – 帮助!
这也适用于子命令。例如,如果我们想获取有关link命令的帮助,我们可以输入以下内容:

图 4.6 – 获取链接命令的帮助
我们可以一次为一个子命令做这件事,例如link add命令,例如:

图 4.7 – 获取链接添加命令的帮助
重要提示
你可能已经注意到,获取帮助的dotnet run语法需要额外的--。这不是一个打字错误。这是必需的,因为否则,.NET CLI 工具会认为你正在请求dotnet工具的帮助。
额外的--用于将传递给dotnet run的参数与传递给正在运行的应用程序的参数分开。--之后的所有内容都被认为是应用程序的参数,而不是dotnet run的参数。
然而,我们应该记住的是,CLI 应用程序的使用(及其帮助)取决于应用程序的当前版本。但我们如何获取这些信息呢?
获取应用程序的版本号
有一个内置选项(--version)可以显示 CLI 应用的版本号。
要显示它,请按以下方式执行命令:
$ bookmarkr --version
但这个值是从哪里来的?
嗯,它可以在.csproj文件的开始处的<PropertyGroup>元素中找到:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
</PropertyGroup>
注意,如果没有提供<version>元素,则默认返回的值将是1.0.0。
重要提示
在这里,如果你想使用dotnet run语法查询应用程序的版本号,你将需要使用额外的--,如下所示:dotnet run -- --version。
就这样!你现在已经准备好创建你的第一个 CLI 应用程序了!
摘要
在本章中,我们开始构建我们自己的 CLI 应用程序,Bookmarkr,这是一个作为 CLI 应用程序提供的书签管理器,因此它可以在终端窗口中使用。
我们从一个控制台应用程序开始(因为记住,“每个 CLI 应用程序的核心都是一个控制台应用程序”),然后我们引入了一个用于解析其命令行参数的库,包括命令、子命令和选项,这样我们就不需要重新发明轮子。
在接下来的章节中,我们将看到如何控制输入和输出,以及如何从文件读取数据并将数据写入文件,这样我们就可以为我们的书签执行备份和恢复操作。这将对将书签导入和导出我们的书签管理器应用程序特别有用
轮到你了!
跟随提供的代码是一种通过实践学习的好方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下功能来改进Bookmarkr应用程序。
任务 #1 – 删除现有书签
语法如下:
$ bookmarkr link remove --name <name>
它也可以是这样的:
$ bookmarkr link remove -n <name>
如果请求的链接名称不存在,应用程序应向用户显示警告消息。否则,应用程序应删除该书签并向用户显示成功消息。
任务 #2 – 更新现有书签
语法如下:
$ bookmarkr link update --name <name> --url <url>
它也可以是这样的:
$ bookmarkr link update -n <name> -u <url>
如果请求的链接名称不存在,应用程序应向用户显示错误消息,并邀请他们使用add命令添加新的书签。否则,应用程序应使用新提供的 URL 更新现有书签,并向用户显示成功消息。
任务 #3 – 列出所有现有书签
语法如下:
$ bookmarkr link --list
它也可以是这样的:
$ bookmarkr link -l
如果书签列表中没有项目,应用程序应显示警告消息,说明书签列表为空,因此没有内容可显示。否则,应用程序应按以下方式呈现书签列表:
# <name 1>
<url 1>
# <name 2>
<url 2>
...
第五章:输入/输出和文件处理
在上一章中,我们为将 CLI 功能注入应用程序奠定了 System.CommandLine 库的基础。
目前,我们的 CLI 应用程序只包含一个命令(link),允许通过添加新书签或列出、更新或删除现有书签来管理书签。
通过本章,我们致力于两个目标:
-
为了进一步控制 CLI 应用程序命令选项的输入值,让我们更深入地探讨选项。
-
要了解如何在 CLI 应用程序中处理输入和输出文件。这可能对导入和导出操作很有用,使备份和恢复我们的应用程序数据以及与其他应用程序共享数据变得更加容易。
具体来说,我们将涵盖以下主要主题:
-
控制选项的输入值,确定何时使用必填或非必填选项,设置选项的默认值,控制选项允许的值集合,以及验证输入值
-
与 CLI 应用程序作为参数传递的文件一起工作,这些文件作为输入和输出文件,这将有助于为我们的 CLI 应用程序添加导入和导出功能
技术要求
本章的代码可以在本书附带的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter05。
控制选项的输入值
参数是任何应用程序的核心。它们允许用户指示他们想要执行哪个命令,并为输入参数提供值。这就是为什么在本节及其子节中,我们将介绍处理这些参数的细微差别。
必填与非必填选项
在当前状态下,添加新书签需要提供名称和 URL,这显然是我们想要的。
这意味着如果我们调用 link add 命令而没有传递这些选项或它们的值,我们应该得到如下错误:

图 5.1 – 书签的名称和 URL 应该是必填项
然而,如果我们运行程序而不传递这两个选项,我们目前得到以下结果:

图 5.2 – 添加的书签的名称和 URL 目前是可选的
注意如何将没有名称和 URL 的新书签添加到书签集合中。这显然不是我们想要的!
幸运的是,Option 类提供了一个布尔值来指定它应该是必填还是可选的。
要使名称和 URL 选项成为必填项,让我们将它们各自的 IsRequired 属性设置为 true:
nameOption.IsRequired = true;
urlOption.IsRequired = true;
如果我们现在运行程序而不传递选项或其值,我们会得到一个错误消息:

图 5.3 – 添加的书签的名称和 URL 现在是必需的
还要注意,帮助菜单清楚地说明了这两个选项是必需的。
到目前为止,我们有两个必需的选项。让我们添加一个可选的选项。
当一个选项不是必需的(即可选的)时,如果未传递该选项或其值,应用程序不应返回错误。
让我们用一个示例来说明。
假设我们想要按类别对我们的书签进行分类。通过这样做,我们可以想象我们可能只想列出属于特定类别的书签。
因此,我们首先将一个Category属性添加到Bookmark类中,如下所示:
public class Bookmark
{
public required string Name { get; set; }
public required string Url { get; set; }
public required string Category { get; set; }
}
然后,我们将为类别添加一个选项,并将其传递给add命令:
var categoryOption = new Option<string>(
["--category", "-c"],
"The category to which the bookmark is associated"
);
var addLinkCommand = new Command("add", "Add a new bookmark link")
{
nameOption,
urlOption,
categoryOption
};
接下来,我们更新处理方法及其与命令的关联:
addLinkCommand.SetHandler(OnHandleAddLinkCommand, nameOption, urlOption, categoryOption);
static void OnHandleAddLinkCommand(string name, string url, string category)
{
service.AddLink(name, url, category);
}
最后,不要忘记更新BookmarkService,以便它相应地处理Category属性。
现在,如果我们不传递类别就执行应用程序,不会返回错误:

图 5.4 – 类别选项是可选的
当然,如果我们传递一个类别,它也会正常工作。😊

图 5.5 – 为新添加的书签分配类别
然而,由于类别现在是可选的,如果我们不传递它,它的值会是什么?
双横线还是单横线?
你可能想知道何时应该使用双横线与单横线。我们甚至需要使用两者吗?
答案是否定的!只有当你想为传递选项提供长格式和短格式时,你才需要使用两者,但你绝对可以选择其中之一。
例如,虽然--set-max-concurrent-requests可能对刚接触你的 CLI 的用户来说更易于理解,但如果他们经常使用你的 CLI 应用程序,反复输入这种长格式可能会变得令人沮丧。这就是为什么简短的形式,如-m,将更加合适。
在现实世界中,你会注意到刚开始使用你的 CLI 应用程序的用户会依赖于长格式选项,并随着他们对 CLI 应用程序的熟悉程度提高,逐渐过渡到短格式。
因此,例如,Bookmarkr 的初级用户可能会更喜欢这种语法:
bookmarkr link add --name "Packt Publishing" --url "https://packtpub.com"
另一方面,经验丰富的用户可能会更喜欢这种语法:
bookmarkr link add -n "Packt Publishing" -u "https://packtpub.com"
那么,参数怎么样?
啊!我可以看出你已经学习了关于参数的知识。参数是Argument类的一个实例,它们代表了执行命令所必需的参数。
但等等…为什么不用参数而不是选项来传递必需参数?
当然可以!但我不喜欢这些,因为它们是位置参数而不是命名参数。这意味着只有它们的顺序指导用户了解它们的目的,对我来说,这牺牲了 CLI 请求的可读性。
为了说明我的观点,以下是将link add命令的调用方式,如果它依赖于参数而不是参数:
$ bookmarkr link add 'Packt Publishing' 'https://packtpub.com' 'Great tech books'
看看这比我们之前的请求(依赖于选项)的可读性差多少?
这就是为什么我不喜欢参数,而更喜欢使用选项,指定哪些是必选的,哪些是可选的。
因此,让我们回到探索选项。
设置选项的默认值
好吧,正如你可能猜到的,一个选项的默认值将是(默认情况下)其数据类型的默认值(你还在吗?)。
由于Category选项是string类型,其默认值是null。然而,Option类允许我们定义一个默认值。
让我们将Category选项的默认值设置为"以后阅读"。这可以通过调用SetDefaultValue方法并传入默认值来完成:
categoryOption.SetDefaultValue("Read later");
如果我们不提供Category选项的值来运行程序,我们可以看到它将使用默认值:

图 5.6 – 使用类别选项的默认值
然而,如果我们为类别提供了值,我们可以看到实际上使用了这个值:

图 5.7 – 使用提供的类别选项值
我们是否应该为必选选项提供默认值?
不,我们不应该这样做!这是因为如果我们这样做,必选选项将不再表现为必选,而是表现为可选。
为什么?因为我们没有提供它的值,所以将使用默认值。
这就是为什么默认值应该只与可选选项一起使用。
注意,在先前的例子中,用户可以为Category选项指定任何字符串值。但如果我们想控制允许的值集呢?这就是FromAmong方法发挥作用的地方。
控制选项的允许值
让我们假设在我们的应用程序中我们只允许一组类别。是的,在现实生活中,我们会允许用户创建他们想要的任何数量的类别,但这将服务于我们解释如何只允许选项的一组特定值的用途。
假设我们允许以下类别:
-
以后阅读(作为默认值)
-
科技书籍
-
烹饪
-
社交媒体
我们将通过将这些值传递给FromAmong方法来完成这项工作,如下所示:
categoryOption.FromAmong("Read later", "Tech books", "Cooking", "Social media");
如果我们通过传递一个允许的类别来运行应用程序,一切都会正常工作:

图 5.8 – 为类别传递一个允许值
然而,如果我们传递一个未分配的类别值,我们会得到一个错误消息:

图 5.9 – 为类别传递不允许的值
注意,错误消息指示了允许的值。我们还可以从帮助菜单中看到允许的值:

图 5.10 – 在帮助菜单中查看允许的值
使用FromAmong可以特别有用,以确保数据完整性和指导用户输入,尤其是在选项需要符合预定义的有效值集的场景中。
好的,那么让我们回顾一下。我们的 CLI 应用程序有必需和可选参数。它为它的可选参数指定了一个默认值,以及允许的值。然而,我们仍然缺少一些东西,一些重要的事情。你能猜到是什么吗?
是的,正是这样,确保为特定参数提供的值是有效的。
验证输入值
当添加新的书签时,我们需要为其传递一个 URL。但是,到目前为止,我们还没有检查提供的值是否确实是一个有效的 URL。让我们解决这个问题。
Option类允许我们配置验证函数。然后我们将为urlOption添加一个验证方法,以确保它只能获取有效的 URL。
这可以通过调用AddValidator方法来实现,如下所示:
urlOption.AddValidator(result =>
{
if (result.Tokens.Count == 0)
{
result.ErrorMessage = "The URL is required";
}
else if (!Uri.TryCreate(result.Tokens[0].Value, UriKind.Absolute,
out _))
{
result.ErrorMessage = "The URL is invalid";
}
});
在前面的代码片段中,AddValidator方法使用内联委托来确保提供给urlOption的值是有效的。在这种情况下,它确保它实际上是存在的(这就是if部分检查的内容)并且它是一个有效的 URL(这就是else if部分检查的内容)。
现在,如果我们用无效和有效的 URL 执行程序,我们可以看到它表现如预期:

图 5.11 – 验证 URL 选项的输入值
更高级的验证
验证可能比这更复杂。我们的应用程序旨在从网络上的任何地方收集书签。然而,如果您希望将其使用限制在,比如说,您的组织内,您可能希望在验证过程中检查书签的 URL 是否仅指向您的企业域名,并忽略其他所有内容。
完美!现在,Bookmarkr 允许我们管理书签,确保只有有效信息可以被传递到(并存储在)CLI 应用程序中。
然而,到目前为止,我们仍然一次只能添加一个书签。如果我们能够作为同一请求的一部分提供一组名称和 URL,并且让 Bookmarkr 一次性添加它们,那不是很好吗?
System.CommandLine有一个功能允许我们做到这一点 😉。
一次性添加多个元素
让我们尝试向同一个请求传递多个名称和 URL,例如:
dotnet run link add --name 'Packt Publishing' --url 'https://packtpub.com/' --name 'Audi cars' --url 'https://audi.ca'
但是如果我们这样做,我们会得到以下错误:

图 5.12 – 名称和 URL 选项默认只期望一个值
这是因为这些选项的参数数量。
那么,参数数量究竟是什么意思呢?
选项的参数数量表示如果指定了该选项,可以传递的值的数量。它用最小值和最大值来表示。
如果你的 CLI 应用程序通过一个或多个命令支持批量操作,这非常重要。在我们的例子中,我们想要同时添加多个书签的批量操作。
对于类型为 string 的选项,最小值和最大值都设置为 1,这意味着如果我们指定了选项,我们必须提供一个值。
布尔选项的最小值是 0,最大值是 1,因为我们不需要传递值,因为这两种语法都是有效的:
--force
-- force true
同样,一个元素列表的最小参数数量为 1,默认最大值为 100,000。
为了指定选项的参数数量,System.CommandLine 提供了一个名为 ArgumentArity 的枚举,它包含以下值:
-
Zero,意味着不允许任何值。所以,--force是有效的,但--force true则不是。 -
ZeroOrOne,意味着允许零个或一个值。 -
ZeroOrMore,意味着允许零个、一个或多个值。 -
ExactlyOne,意味着至少一个且最多一个值是被允许的。对于我们的字符串选项,名称和 URL 就是这种情况。 -
OneOrMore,意味着允许一个或多个值。
为了设置一个选项的参数数量,我们可以使用 ArgumentArity 枚举提供的值之一,如下所示:
nameOption.Arity = ArgumentArity.OneOrMore;
现在,我们应该能够为给定选项提供多个值。让我们试试这个:

图 5.13 – 未为给定选项提供多个值
哎呀,这不是我们预期的结果,对吧?
这里的问题在于,尽管 nameOption 可以接受多个值,但程序并不清楚如何将这些值转换成一个字符串。这就是为什么错误信息提到了自定义绑定器(因此它被告知如何执行这种转换)。
为了解决这个问题,我们需要告诉程序将这些输入视为单独的参数。这是通过将 AllowMultipleArgumentsPerToken 属性设置为 true 来实现的,如下所示:
nameOption.AllowMultipleArgumentsPerToken = true;
首先,让我们通过注释掉相应的代码行来暂时去掉参数数量。
现在,如果我们运行程序,我们可以看到错误已经消失了,但我们仍然没有得到预期的结果…

图 5.14 – nameOption 现在接受多个值
注意,只有最后的一对名称和 URL 被考虑并添加到书签列表中。
实际上发生的情况是,System.CommandLine注意到我们有两个名称和 URL 的出现,所以最后的那些覆盖了第一个,只有最后的那些实际上被传递给了Handler方法。这就是为什么我们只添加了一个带有最后对名称和 URL 值信息的书签。
但是,如果我们想要能够传递一个名称和 URL 的列表,并且让Handler方法添加与名称和 URL 对数量相等的书签,那会怎么样呢?
要做到这一点,我们需要两样东西。首先,让我们取消注释设置nameOption、urlOption和categoryOption的 arity 的代码行。
接下来,让我们更改名称、URL 和类别选项的声明,以及验证器和Handler方法的签名,以便它们接受字符串列表而不是单个字符串:
var nameOption = new Option<string[]>(
["--name", "-n"], // equivalent to new string[] { "--name", "-n" }
"The name of the bookmark"
);
nameOption.IsRequired = true;
nameOption.Arity = ArgumentArity.OneOrMore;
nameOption.AllowMultipleArgumentsPerToken = true;
var urlOption = new Option<string[]>(
["--url", "-u"],
"The URL of the bookmark"
);
urlOption.IsRequired = true;
urlOption.Arity = ArgumentArity.OneOrMore;
urlOption.AllowMultipleArgumentsPerToken = true;
urlOption.AddValidator(result =>
{
foreach (var token in result.Tokens)
{
if (string.IsNullOrWhiteSpace(token.Value))
{
result.ErrorMessage = "URL cannot be empty";
break;
}
else if (!Uri.TryCreate(token.Value, UriKind.Absolute, out _))
{
result.ErrorMessage = $"Invalid URL: {token.Value}";
break;
}
}
});
var categoryOption = new Option<string[]>(
["--category", "-c"],
"The category to which the bookmark is associated"
);
categoryOption.Arity = ArgumentArity.OneOrMore;
categoryOption.AllowMultipleArgumentsPerToken = true;
categoryOption.SetDefaultValue("Read later");
categoryOption.FromAmong("Read later", "Tech books", "Cooking", "Social media");
categoryOption.AddCompletions("Read later", "Tech books", "Cooking", "Social media");
static void OnHandleAddLinkCommand(string[] names, string[] urls, string[] categories)
{
service.AddLinks(names, urls, categories);
service.ListAll();
}
现在,如果我们运行程序,事情(终于)按预期工作!😊

图 5.15 – Bookmarkr 接受书签列表
由于每个选项都接受多个值,让我们看看我们是否可以简化以下 CLI 请求:
$ dotnet run link add --name 'Packt Publishing' --url 'https://packtpub.com/' --category 'Tech books' --name 'Audi cars' --url 'https://audi.ca' --category 'Read later'
我们将按以下方式简化它:
$ dotnet run link add --name 'Packt Publishing' 'Audi cars' --url 'https://packtpub.com/' 'https://audi.ca' --category 'Tech books' 'Read later'
注意,我们只需要指定一次--name、--url和--category。
由于这两个 CLI 请求是等效的,它们导致相同的结果:

图 5.16 – 简化的 CLI 请求
太棒了!这工作得非常好!
但是……当列表增长时,输入一系列名称、URL 和类别可能会很快变得繁琐。
如果我们能够简单地提供一个包含所有名称、URL 和类别的文件路径作为参数,让应用程序读取该文件并相应地创建书签,那岂不是很好?
同样,如果我们能够指定一个输出文件的路径来存储 CLI 应用程序持有的所有书签,那岂不是很好?
处理作为选项值传递的文件
文件可以作为选项值提供,作为输入或输出参数。
作为输入参数,可以读取文件内容以将数据导入 CLI 应用程序。在我们的例子中,我们可以将来自其他浏览器(如 Chrome 或 Firefox)的书签导入到 Bookmarkr 中。
作为输出参数,可以创建一个文件来导出 Bookmarkr 持有的数据,该数据反过来可以导入到其他浏览器,如 Chrome 或 Firefox。
这两个功能结合在一起可以启用备份和恢复,也可以实现数据共享和交换场景。
让我们把这些功能构建到 Bookmarkr 中!
重要提示
浏览器,如 Chrome 或 Firefox,都有自己的专有结构来导入和导出书签。
为了简化,我们不会对这些格式进行解析或转换。我们的目标是专注于将输入和输出文件作为 CLI 应用程序的一部分进行处理。然而,我们将以 JSON 格式导入和导出书签。
让我们从export命令开始。
此命令的目的是获取 Bookmarkr 管理的所有书签并将它们保存到一个 JSON 文件中,该文件的路径作为 --file 选项的值指定。当然,此选项是必需的。
首先,我们需要创建一个类型为 FileInfo 的选项,并且它是必需的:
var outputfileOption = new Option<FileInfo>(
["--file", "-f"],
"The output file that will store the bookmarks"
)
{
IsRequired = true
};
接下来,我们需要创建一个新的命令并将其添加到 root 命令中:
var exportCommand = new Command("export", "Exports all bookmarks to a file")
{
outputfileOption
};
rootCommand.AddCommand(exportCommand);
然后,我们需要为 export 命令设置一个 Handler 方法:
exportCommand.SetHandler(OnExportCommand, outputfileOption);
static void OnExportCommand(FileInfo outputfile)
{
var bookmarks = service.GetAll();
string json = JsonSerializer.Serialize(bookmarks, new
JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(outputfile.FullName, json);
}
Handler 方法调用 BookmarkService 来获取所有书签的列表,然后将它们转换为 JSON 并将此 JSON 内容保存到提供的文件中。如果文件已存在,则将其覆盖。
注意,你需要导入此命名空间才能使代码编译:
using System.Text.Json;
现在,让我们尝试它并看看它是否按预期工作!

图 5.17 – 导出所有书签
完美!这正是我们预期的结果!
但我们如何确保提供的文件具有有效的名称呢?
我们当然可以创建一个验证方法来检查这一点,但 System.CommandLine 已经为这个问题提供了一个扩展方法(并且我想让你知道 😉):
outputfileOption.LegalFileNamesOnly();
让我们尝试使用无效文件调用 export 命令:

图 5.18 – 处理无效文件
看见了吗?这个错误是由于调用 LegalFileNamesOnly 方法而引发的。
好的!现在让我们继续添加 import 命令!
作为提醒,从现有文件导入书签数据的语法如下:
$ bookmarkr import --file <path to the input file>
由于许多步骤与我们创建 export 命令时遵循的步骤非常相似,所以我们在这里只分享代码并讨论差异:
var inputfileOption = new Option<FileInfo>(
["--file", "-f"],
"The input file that contains the bookmarks to be imported"
)
{
IsRequired = true
};
inputfileOption.LegalFileNamesOnly();
inputfileOption.ExistingOnly();
var importCommand = new Command("import", "Imports all bookmarks from a file")
{
inputfileOption
};
rootCommand.AddCommand(importCommand);
importCommand.SetHandler(OnImportCommand, inputfileOption);
static void OnImportCommand(FileInfo inputfile)
{
string json = File.ReadAllText(inputfile.FullName);
List<Bookmark> bookmarks = JsonSerializer.
Deserialize<List<Bookmark>>(json) ?? new List<Bookmark>();
service.Import(bookmarks);
}
主要区别在于对 ExistingOnly 方法的调用。此方法确保 inputfileOption 只接受与现有文件对应的值,否则将引发错误。
另一个区别在于 OnImportCommand 处理方法的工作方式:它读取文件的内容,将其从 JSON 转换为 Bookmark 类型的项目列表,然后将这些项目传递给 BookmarkService 以将其添加到它管理的书签列表中(通过调用其 Import 方法)。
现在,让我们尝试这段代码!

图 5.19 – 从文件导入书签
如果文件不存在会发生什么?

图 5.20 – 处理不存在的文件
再次强调,我们可以看到我们得到了预期的结果!😊
好了,到此为止!你现在知道如何在你的 CLI 应用程序中处理输入和输出文件了。恭喜!现在让我们结束这一章。
摘要
在本章中,我们通过添加对命令选项输入值的更好控制来改进了我们的 CLI 应用程序 Bookmarkr(通过明确指出哪些选项是必需的,在适当的地方设置默认值,设计验证器以确保输入值符合预期的类型、格式或值范围,并启用自动完成以简化用户操作)。
我们还添加了从文件导入和导出应用程序数据的功能。这使得备份和恢复数据以及离线共享数据变得更加容易。
在接下来的章节中,我们将看到如何实现每个应用程序都非常重要的功能:日志记录和错误处理。
轮到你了!
跟随提供的代码是一种通过实践学习的好方法。
一种更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 验证输入文件的格式和访问能力
作为提醒,从现有文件导入书签数据的语法如下:
$ bookmarkr import --file <path to the input file>
如果无法访问输入文件,或者其数据不是预期的格式,那么应用程序应向用户显示相应的错误消息。否则,应用程序应从输入文件导入所有书签并向用户显示成功消息,指示已导入多少个书签。
任务 #2 – 合并输入文件中的现有链接
当从现有文件导入书签时,可能其中一些已经存在于应用程序持有的书签中。
在这种情况下,对于 CLI 应用程序来说,向用户提供一个选项来控制他们是否想要合并那些现有链接或简单地丢弃它们而不导入它们,是一种最佳实践。
在这个任务中,我挑战你通过在import命令中添加一个可选的--merge选项来实现这一最佳实践。
因此,带有--merge选项的import命令的语法如下:
$ bookmarkr import --file <path to the input file> --merge
当指定--merge选项时,import命令的预期行为是对于提供的输入文件中的每个书签,以下适用:
-
如果其 URL 已存在于应用程序持有的书签列表中,则现有书签的名称应更新为与输入文件中此 URL 对应的名称
-
否则,书签应简单地添加到应用程序持有的书签列表中
第六章:错误处理和日志记录
记录日志和错误处理是在构建任何应用程序时需要考虑(并实现)的两个重要概念,CLI 应用程序也不例外。
虽然错误处理确保了应用程序在面对意外事件时的优雅行为,但日志记录提供了对应用程序运行时行为的宝贵见解,并有助于故障排除和调试。
因此,在本章中,我们将涵盖这两个概念,从错误处理开始。
具体来说,本章将涵盖以下主要内容:
-
CLI 应用程序中的错误处理
-
CLI 应用程序中的日志记录
技术要求
本章的代码可以在本书附带的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter06。
CLI 应用程序中的错误处理
错误处理可能有两种形式:
-
由于意外事件(如无效输入或不可访问的依赖项)而引发异常
-
程序终止,我们希望通过允许它优雅地关闭来防止它崩溃
在本节中,我们将涵盖这两个主题。让我们从异常处理开始。
处理异常
与其他类型的应用程序相比,在 CLI 应用程序中处理异常并没有什么特别之处,因为它遵循相同的指南和最佳实践。这就是为什么在本节中,您可能会发现您已经知道我们将要讨论的所有概念,这是完全正常的,因为您很可能在其他应用程序中实现了它们,无论是 Web、API 还是桌面应用程序。
然而,值得注意的是,一个健壮的错误处理策略将对应用程序的质量、可靠性和弹性产生重大影响。这就是为什么花时间设计一个好的错误处理策略是值得的。
如您所知,每个错误处理策略都依赖于一个try-catch-finally块。但并非总是如此!并非每个方法都需要实现try-catch-finally块。实际上,错误处理的最佳实践表明,只有调用者方法(通常是顶级方法)应该处理异常,而被调用者方法应该让异常冒泡到被调用者方法捕获和处理。这导致方法更加精简、干净和专注。
另一个最佳实践是除非绝对必要,否则不要吞没异常。为什么?因为吞没异常隐藏了错误,使得代码看起来功能正常,而实际上却在失败。这掩盖了宝贵的错误信息,允许不可预测的行为和数据损坏。它使调试复杂化,并违反了快速失败的原则。换句话说,它导致难以检测和修复的静默失败,因此是一种不良实践。
finally 块很重要,尽管我经常看到它被遗忘。重要的是要记住,这个块用于确保即使在发生异常的情况下也能释放资源。
当捕获异常时,使用多个 catch 块,从最具体到最通用的异常进行捕获。这将确保比将所有异常作为通用异常捕获并应用相同的错误处理过程有更好的错误处理。一个例子是在处理文件时:我们不希望以处理文件找不到的方式处理文件无法写入的情况,因为权限不足。通过区分这些情况,我们可以应用特定的错误处理过程,并最终向用户提供发生情况的适当细节,而不是进行通用的处理并告诉用户我们无法写入文件。
我们还可以创建自己的异常。我经常这样做,因为这有助于提高代码的可读性。当 CreateNewUser 方法抛出 UserAlreadyExistsException 异常的实例时,很容易理解发生了什么,不是吗?
下面是这个自定义异常的外观:
public class UserAlreadyExistsException : Exception
{
public string UserId { get; }
public UserAlreadyExistsException(string userId)
: base($"User with ID '{userId}' already exists.")
{
UserId = userId;
}
public UserAlreadyExistsException(string userId, Exception
innerException)
: base($"User with ID '{userId}' already exists.",
innerException)
{
UserId = userId;
}
}
要捕获还是不要捕获异常?
现在,有一种反对抛出异常的运动,因为这样做可能会带来性能成本,我完全理解这一点。关于这个主题的一个很好的视频是由尼克·查帕斯(Nick Chapsas)制作的,标题为 Don’t throw exceptions in C#. Do this instead,你可以在 YouTube 上找到它。我鼓励你去看看,并形成你自己的看法。
然而,无论你选择抛出异常还是不抛出异常而处理它,你很可能会处理异常。此外,请记住,.NET 框架以及你可能使用的某些其他库可能正在抛出异常,你需要捕获这些异常来处理它们。正因为如此,这里描述的原则仍然有效,并且值得了解。
当捕获异常时,你也可以过滤它们。这是因为某些异常可能需要根据它们被抛出的原因采取不同的处理机制。
这里的一个很好的例子是 HttpResponseException 异常类型,如这里所示:
try
{
using var client = new HttpClient();
var response = await client.GetAsync("https://api.packtpub.com/
data");
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
Console.WriteLine("Resource not found (404)");
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
Console.WriteLine("Server error (401)");
}
正如你所看到的,我们在这里两次捕获了相同的异常(HttpResponseException),但在每个 catch 块中,我们关注的是一个非常具体的情况:在第一个中,我们处理资源未找到的情况,而在第二个中,我们处理访问资源的用户未认证的情况。
处理异常时的最后一个最佳实践是避免在抛出异常时丢失异常的堆栈跟踪。
为了说明这个原则,让我们考虑这个例子:
try
{
// Attempt to read from a file
string content = File.ReadAllText(fileName);
Console.WriteLine($»File content: {content}»);
}
catch (FileNotFoundException ex)
{
// Handle the specific exception
Console.WriteLine($"File not found: {fileName}");
Console.WriteLine($"Exception details: {ex.Message}");
// Rethrow the exception
throw ex;
}
如果我们使用 throw ex; 语句抛出异常,我们将丢失包含到目前为止发生详情的堆栈跟踪。正确的方法是简单地使用 throw 来确保堆栈跟踪被保留。
然而,在某些情况下,我们可能需要捕获异常,处理它,并通过将其封装到另一个异常类型中重新抛出,如下所示:
// Rethrow the exception by encapsulating it while preserving the
// stack trace
throw new IOException($"File process error{fileName}", ex);
在这种情况下,堆栈跟踪被保留。
现在,让我们将这些原则应用到 Bookmarkr 中,更具体地说,是应用到从 Bookmarkr 导出书签的能力。
如果您还记得上一章的内容,导出处理方法 (OnHandleExportCommand) 看起来是这样的:
static void OnExportCommand(FileInfo outputfile)
{
var bookmarks = service.GetAll();
string json = JsonSerializer.Serialize(bookmarks, new
JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(outputfile.FullName, json);
}
然而,请注意,WriteAllText 方法可能会抛出许多异常,例如以下这些:
-
如果没有足够的权限访问文件,将抛出
UnauthorizedAccessException -
如果路径无效,将抛出
DirectoryNotFoundException -
如果路径超过系统定义的最大长度,将抛出
PathTooLongException
因此,让我们处理这些异常。代码将如下所示:
static void OnExportCommand(FileInfo outputfile)
{
try
{
var bookmarks = service.GetAll();
string json = JsonSerializer.Serialize(bookmarks, new
JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(outputfile.FullName, json);
}
catch(JsonException ex)
{
Helper.ShowErrorMessage([$"Failed to serialize bookmarks to
JSON.",
$"Error message {ex.Message}"]);
}
catch (UnauthorizedAccessException ex)
{
Helper.ShowErrorMessage([$"Insufficient permissions to access
the file {outputfile.FullName}",
$"Error message {ex.Message}"]);
}
catch (DirectoryNotFoundException ex)
{
Helper.ShowErrorMessage([$"The file {outputfile.FullName}
cannot be found due to an invalid path",
$"Error message {ex.Message}"]);
}
catch (PathTooLongException ex)
{
Helper.ShowErrorMessage([$"The provided path is exceeding the
maximum length.",
$"Error message {ex.Message}"]);
}
catch (Exception ex)
{
Helper.ShowErrorMessage([$"An unknown exception occurred.",
$"Error message {ex.Message}"]);
}
}
在前面的示例中,我们正在处理最常见的异常,并且在出现意外异常的情况下,我们也在处理一般异常(是的,异常是例外情况,但仍然预期至少在大多数情况下会发生)。
注意,我们已经处理了序列化过程和文件写入过程中的异常。
如果您想了解更多关于处理异常的最佳实践,我建议您访问这个页面:learn.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions。
处理错误并不一定意味着处理异常
虽然这看起来可能令人惊讶,但这确实是事实,并且通过应用防御性编程技术,可以避免(至少在大多数情况下)异常的发生。
通过验证输入、强制执行先决条件和积极识别潜在失败场景,我们可以显著减少错误发生,并增强我们应用程序的整体弹性。
让我们看看在防御性编程方面我们能做些什么:
-
验证输入:我们可以确保输入文件存在,从而避免在文件不存在时引发异常。
-
如果
bookmarks列表不为空。否则,JsonSerializer将抛出NullReferenceException。我们还可以确保列表不为空,因为如果列表为空,尽管序列化将返回一个空的 JSON 数组,但我们可能不想将此写入文件,尤其是如果这意味着覆盖现有文件。 -
识别潜在失败场景:我们已经通过捕获最常见的异常并处理它们来做到这一点。
好的。所以,到目前为止,我们知道如何在我们的 CLI 应用程序中处理异常,并且我们在 Bookmarkr 中实现了这一点。
然而,存在另一种意外事件,实际上它代表了正常行为。我指的是程序终止。
处理程序终止
程序可能在任何时候通过按特定的键盘组合(通常是Ctrl + C或Ctrl + Break)来终止。当这种情况发生时,操作系统会向程序发送一个信号,指示它立即停止执行。这个信号,通常被称为中断或终止信号,允许程序在退出之前执行任何必要的清理操作,例如关闭文件、释放资源或保存状态。如果程序有针对此特定信号的信号处理程序,它可以执行自定义代码以优雅地处理终止。否则,程序将突然终止,任何未保存的数据或不完整的操作可能会丢失。
程序终止允许优雅地停止执行时间过长或已无响应的程序。
System.CommandLine提供了一个处理程序终止并执行自定义代码的机制,允许我们的 CLI 应用程序优雅地终止。
让我们实现它来处理用户在导出操作进行中终止程序的情况。
为了处理程序终止,我们需要修改SetHandler方法的委托以检索取消令牌并将其传递给处理方法本身:
exportCommand.SetHandler(async (context) =>
{
FileInfo? outputfileOptionValue = context.ParseResult.
GetValueForOption(outputfileOption);
var token = context.GetCancellationToken();
await OnExportCommand(outputfileOptionValue!, token);
});
现在,我们可以修改处理方法,使其处理程序终止(即捕获OperationCanceledException异常):
static async Task OnExportCommand(FileInfo outputfile, CancellationToken token)
{
try
{
var bookmarks = service.GetAll();
string json = JsonSerializer.Serialize(bookmarks, new
JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(outputfile.FullName, json,
token);
}
catch(OperationCanceledException ex)
{
var requested = ex.CancellationToken.IsCancellationRequested ?
"Cancellation was requested by you.": "Cancellation was NOT
requested by you.";
Helper.ShowWarningMessage(["Operation was cancelled.",
requested, $"Cancellation reason: {ex.Message}"]);
}
catch(JsonException ex)
// The rest of the method has been removed for brevity.
如果我们现在运行程序然后通过按Ctrl + C键盘组合来终止它,我们会得到以下控制台输出:

图 6.1 – 处理程序终止
值得注意的是,我们需要使用WriteAllText方法的异步版本(即WriteAllTextAsync),以便能够传递我们接收到的取消令牌,因此我们需要将OnExportCommand方法声明为async。
注意,通过处理程序终止,我们可以优雅地处理用户突然停止程序的情况。这导致程序优雅地关闭并释放所使用的资源,从而避免崩溃和错误信息。
为什么我们要将取消令牌作为参数传递给处理方法而不使用它?
这是一个非常好的问题!正如你所注意到的,尽管我们在OnExportCommand方法中作为参数接收了CancellationToken对象,但我们似乎并没有使用它。那么,我们最初为什么要传递它呢?
这与.NET 中取消令牌的工作方式有关。让我来解释一下!
当响应取消请求创建OperationCanceledException时,它通常包含有关触发取消的CancellationToken对象的信息。
就此而言,OperationCanceledException异常类有一个构造函数,它接受一个CancellationToken对象作为参数。
当.NET 框架异步方法创建此异常时,它们通常使用此构造函数并传递触发取消请求的取消令牌。
OperationCanceledException类有一个CancellationToken属性,可以用来获取与取消请求关联的令牌。
在我们的案例中,我们访问它是为了检查取消请求是否由用户发起。
在建立了一个健壮的错误处理框架之后,同样重要的是确保这些错误被记录和监控。这正是日志记录发挥作用的地方!
有效的日志记录不仅有助于诊断和解决问题,还提供了关于应用程序的行为、性能和使用的宝贵见解。在接下来的部分中,我们将深入了解实施全面日志记录机制的最佳实践和策略,这些机制补充了我们的错误处理策略。
到目前为止,我们已经涵盖了关于错误处理的大量信息。然而,错误处理与日志记录一起工作,以提高应用程序的可靠性和可维护性。因此,让我们将重点转向日志记录,并探讨如何捕获和保存有关错误以及程序执行期间发生的其他重要事件的宝贵信息。
在 CLI 应用程序中记录日志
当发生意外事件或错误时,错误处理更像是“即时”的补偿机制,但我们可能希望记录发生了什么,以便我们可以重现问题、分析它、了解它最初发生的原因,并修复它。
“记录发生了什么”的意思,要么是指导致意外行为或错误的事件的序列,要么是指错误发生时引发的异常的调用堆栈。
选择日志格式也很重要。我们希望在我们记录(并存储)的数据量和我们打算如何使用它的用途之间找到一个平衡。记录不必要的信息将使日志分析复杂化,增加存储(和保留)成本,也可能减慢日志记录过程。我们还需要确保我们没有记录敏感信息(如信用卡数据),如果确实记录了,那么我们必须以安全的方式进行。一些流行的日志格式包括 XML、JSON、CSV 和 syslog。
选择日志记录目的地同样重要。我们需要理解,没有好或坏的选择,只有根据我们的环境和需求是否合适的选择。如果我们打算分析日志,我们可能希望将这些日志存储在提供开箱即用日志分析机制的解决方案中,这样我们就不需要为它编写代码。这类解决方案的例子包括 Azure Log Analytics、Splunk、Datadog、Dynatrace、Serilog 和 Elasticsearch。
然而,请注意,依赖于云解决方案(如 Azure Log Analytics),我们的应用程序需要运行在云中或者保持对互联网的持续连接。当然,我们也可以构建我们的应用程序以遵循偶尔连接的应用程序(OCA)模式,这样当它离线运行时可以本地记录日志,当它重新上线时将它们发送到 Azure Log Analytics,但这里的想法是我们应该选择与我们的应用程序使用模式一致的日志解决方案。因此,对于打算本地运行的应用程序,我们将优先考虑本地运行的日志机制。
最后,定义日志保留期也很重要。这可以由组织的合规性规则或记录数据的关联性来强制执行:你还需要分析三年前发生的错误或客户行为的数据吗?如果不,你不需要保留这些数据。
无论哪种方式,将日志格式与您用于存储和分析这些日志的解决方案分开是很重要的。
由于我们的应用程序打算本地运行,我们将选择 JSON 作为日志格式,并使用 Serilog 作为日志机制。
为什么选择 JSON?
JSON 结构的日志易于阅读,并且可以很容易地被机器解析。许多现代日志管理解决方案可以接受 JSON 格式的日志,使其成为结构化日志的好选择。
此外,JSON 比 XML 更简洁,这导致文件体积更小,反过来又减少了我们需要存储它们的存储空间。
为什么选择 Serilog?
Serilog 是一个.NET 的诊断日志库。它是基于强大的结构化事件数据构建的,并支持各种“sink”,即日志事件可以写入的目的地。此类 sink 的例子包括文件、控制台、数据库或日志管理工具(如 Elasticsearch、Application Insights、Datadog 和 Splunk)。它易于设置,具有干净的 API,并且是可移植的。
Serilog 的一个关键特性是它能够记录结构化数据,这使得日志更有意义且可查询。它使用消息模板,这是一种简单的 DSL,扩展了.NET 格式字符串,可以捕获属性以及日志事件。
我确实喜欢 Serilog 的 NuGet 包的结构。首先,有一个基础包,它为我们代码中的 Serilog 提供功能。然后,有“sink”包,每个 sink 都有一个,而且有很多。如果我们需要的话,甚至可以创建我们自己的 sink。我还没有找到它的用途,因为几乎所有你能想到的东西都已经有了 sink...
哦!顺便说一下,Serilog NuGet 包(截至今天)已经被下载了超过 12.4 亿次!这应该意味着什么,对吧?😉
在我们将 Serilog 添加到我们的 CLI 应用程序之前,我们需要修改我们的代码以公开IServiceCollection属性,这样我们就可以配置我们的服务。
访问 IServiceCollection
我们需要采取的第一个步骤是将System.CommandLine.Hosting NuGet 包添加到我们的项目中。从 Visual Studio Code 终端,我们可以通过输入以下命令来完成:
dotnet add package System.CommandLine.Hosting --prerelease
然后,我们需要更新我们的CommandLineBuilder类实例化方式如下:
using System.CommandLine.Hosting;
using Microsoft.Extensions.Hosting;
var parser = new CommandLineBuilder(rootCommand)
.UseHost(_ => Host.CreateDefaultBuilder(),
host =>
{
host.ConfigureServices(services =>
{
});
})
.UseDefaults()
.Build();
return await parser.InvokeAsync(args);
现在,我们可以访问IServiceCollection,因此可以向这个集合添加服务并配置它们的行为。
将 Serilog 添加到 IServiceCollection
将 Serilog 添加到这个集合需要Serilog.Extensions.Hosting NuGet 包。所以,让我们添加它!
dotnet add package Serilog.Extensions.Hosting
这允许我们通过在先前的代码示例中添加这一行代码来将 Serilog 添加到IServiceCollection:
services.AddSerilog();
添加(并配置)所需的 Serilog 输出
如前所述,Serilog 提供了大量的输出。然而,由于我们正在构建一个命令行应用程序,我们只会使用两个输出:Console和File。
我们还提到,Serilog 的结构是这样的,每个输出都有自己的 NuGet 包。然后,我们需要使用这些命令添加适当的 NuGet 包:
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
我们可以通过在代码中配置输出来开始使用 Serilog。然后,用于实例化CommandLineBuilder类的更新代码如下:
using Serilog;
using System.CommandLine.Hosting;
using Microsoft.Extensions.Hosting;
var parser = new CommandLineBuilder(rootCommand)
.UseHost(_ => Host.CreateDefaultBuilder(),
host =>
{
host.ConfigureServices(services =>
{
services.AddSerilog((config) =>
{
config.MinimumLevel.Information();
config.WriteTo.Console();
config.WriteTo.File("logs/bookmarkr-.txt",
rollingInterval:RollingInterval.Day,
restrictedToMinimumLevel:Serilog.Events.
LogEventLevel.Error);
config.CreateLogger();
});
});
})
.UseDefaults()
.Build();
return await parser.InvokeAsync(args);
让我们更详细地看看配置 Serilog 的部分代码(即在AddSerilog方法内的委托函数):
-
这段代码作用于一个
LoggerConfiguration类的实例,用于配置 Serilog 及其输出的行为。 -
我们将最小日志级别定义为
Information。这意味着除非被特定的输出覆盖,否则所有信息性或更高级别的日志(如警告和错误)都会被记录。 -
我们注意到
File输出已经以这种方式覆盖了日志级别,即只有错误或更高严重性的事件(如Fatal)会被记录。 -
我们还可以注意到,
File输出已经定义了文件的位置(logs文件夹)和日志文件的命名约定(bookmark-.txt)。文件名中的破折号符号不是拼写错误,而是故意的!它在那里是因为 Serilog 会向该文件名追加一个唯一标识符。由于我们定义的滚动间隔是按日进行的,Serilog 将每天创建一个新的日志文件。因此,我们的日志文件夹将包含名为bookmark-20240705.txt、bookmark-20240706.txt等文件。 -
我们还注意到,我们明确告诉 Serilog 将日志记录到控制台。这是因为尽管我们添加了对
Serilog.Sinks.ConsoleNuGet 包的引用,我们仍然需要告诉 Serilog 实际使用这个输出。 -
最后,我们调用
CreateLogger方法,以便考虑所有这些配置。
在 appsettings.json 中配置输出
虽然在代码中直接配置 Serilog 及其输出的行为是完全可以的,但这种方式不够灵活。
如果我们想要添加一个新的输出或更新现有输出的配置呢?嗯,你已经猜到了,我们需要更新和重新部署代码。
将此配置移动到配置文件(如appsettings.json)使我们的应用程序更加灵活。
让我们看看我们如何做到这一点!
我们首先需要通过在 Visual Studio Code 终端中输入以下命令将Serilog.Settings.Configuration NuGet 包添加到我们的应用程序中:
dotnet add package Serilog.Settings.Configuration
然后,我们需要在我们的应用程序中添加一个appsettings.json文件。这可以通过在 Visual Studio Code 或你喜欢的代码编辑器中向项目中添加新文件轻松完成。
为了确保appsettings.json文件与我们的应用程序一起部署,我们需要确保其最新版本始终被复制到输出目录。这可以通过在bookmarkr.csproj文件中添加以下片段在</Project>元素之前完成:
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
现在,让我们将 Serilog 及其输出的配置从代码移动到appsettings.json配置文件。该文件的以下内容将是:
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}]
{Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/log-.txt",
"rollingInterval": "Day",
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}
{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
}
}
这个文件很容易阅读,相当直观。它描述了要使用的输出及其配置,并定义了最小日志级别。然而,这里有一件事需要注意!请注意,我们已经覆盖了Microsoft和System命名空间库的最小日志级别。这是因为这些库往往很健谈,这可能会导致大量不太有用的日志数据。通过将它们的最低日志级别设置为Warning,我们可以确保只捕获相关事件,如警告或错误。
Enrich部分是一个新的部分。正如其名所示,它在那里是为了通过添加额外的信息(如机器名和线程 ID)来丰富日志数据。如果你的 CLI 应用程序打算在你的组织内的多台计算机上执行,知道错误发生在哪台机器上可以帮助你缩小搜索范围。如果该应用程序打算在同一台机器上的多个实例中运行,线程 ID 将告诉你哪个实例记录了该信息。这在并发执行场景中可能很有帮助。
我们完成了吗?
不完全是这样……我们仍然需要更新ConfigureServices方法内部的代码。
由于所有配置都已移动到appsettings.json文件中,代码变得更加简单,正如你在这里可以看到的:
using Microsoft.Extensions.Configuration;
// Only the body of the ConfigureServices is shown here for clarity.
// To see the full version of the code, please refer to the GitHub
// repo of the book.
services.AddSerilog((config) =>
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
config.ReadFrom.Configuration(configuration);
});
代码中的配置还是文件中的配置?
你可能想知道是否应该在代码中还是在配置文件中配置你的日志记录器。这是一个很好的问题!
我个人同时依赖两者:我在代码中配置我的输出,在appsettings.json文件中配置日志级别。这样,我可以确保即使以后可能添加更多,我的“基本输出”始终在使用中。
这是一个个人选择,当然,你当然可以使用对你来说最有效的方法。
请记住,代码中的配置优先于配置文件中的配置。
让我们记录一些东西!
最后!到目前为止,我们只是配置了我们的日志记录器和其接收器。现在让我们看看它是如何工作的!
一切就绪并正确配置后,使用 Serilog 进行日志记录就相当简单了。
为了说明这一点,让我们举一个例子。
当我们在第五章中实现import命令时,如果现有的书签需要更新(因为一个具有相同 URL 但不同名称的书签已经存在于应用程序持有的书签列表中),我们在更新之前无法追踪那个冲突书签的名称。
如果这是一条关键信息(例如,出于合规性原因),日志记录将非常有用。
我们将重新访问此功能并实现日志记录以跟踪更新前后的名称,以及其 URL 和更新的时间日期。
那么日志格式将是以下这样:
<date and time> | Bookmark updated | name changed from '<old name>' to '<new name>' for URL '<Url>'
我们将要做的第一件事是为BookmarkService类创建import方法的新版本。这个新版本将接受一个书签作为参数,并检查是否已存在具有相同 URL 但名称不同的书签在应用程序持有的书签列表中。如果确实如此,它将用新名称替换现有的书签,然后返回一个包含原始和更新名称以及 URL 的BookmarkConflictModel类型的实例。如果没有检测到冲突,该方法简单地添加书签并返回null。
这是此方法的代码:
public BookmarkConflictModel? Import(Bookmark bookmark)
{
var conflict = _bookmarks.FirstOrDefault(b => b.Url == bookmark.
Url && b.Name != bookmark.Name);
if(conflict is not null)
{
var conflictModel = new BookmarkConflictModel { OldName =
conflict.Name, NewName = bookmark.Name, Url = bookmark.Url };
conflict.Name = bookmark.Name; // this updates the name of the
// bookmark.
return conflictModel;
}
else
{
_bookmarks.Add(bookmark);
return null;
}
}
BookmarkConflictModel 类的代码如下:
public class BookmarkConflictModel
{
public string? OldName { get; set; }
public string? NewName { get; set; }
public string? Url { get; set; }
}
最后,Import命令的处理方法代码被更新,以处理从文件中读取的每个书签,以便在检测到冲突时使用 Serilog 来跟踪它。
这是更新的代码:
static void OnImportCommand(FileInfo inputfile)
{
string json = File.ReadAllText(inputfile.FullName);
List<Bookmark> bookmarks = JsonSerializer.
Deserialize<List<Bookmark>>(json) ?? new List<Bookmark>();
foreach(var bookmark in bookmarks)
{
var conflict = service.Import(bookmark);
if (conflict is not null)
{
Log.Information($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")} | Bookmark updated | name changed from '{conflict.OldName}' to '{conflict.NewName}' for URL '{conflict.Url}'");
}
}
}
现在,如果我们运行这个程序,我们可以看到在发生冲突的情况下会记录日志:

图 6.2 – 发生冲突时的日志记录
如我们所见,此信息既被记录到控制台,也被记录到文件中。
我们现在能够使用 Serilog 来记录重要信息。但如果应用程序关闭或终止会发生什么?在这种情况下,我们可以依赖CloseAndFlush方法。
关闭并优雅地处理 Serilog
当使用 Serilog 时,会调用Log.CloseAndFlush方法以确保所有挂起的日志事件消息都被刷新到接收器中,并且日志系统被正确关闭。这对于有明确生命周期的应用程序尤为重要,例如控制台应用程序或批处理作业,以确保不会因为应用程序在日志完全写入之前关闭而错过任何日志条目。
当调用此方法时,会发生两件事:
-
Close:这向日志子系统发送一个信号,停止接受新的日志事件。这在我们决定关闭日志系统后,防止任何新的日志被生成非常重要。
-
Flush:这确保了所有已捕获并当前缓存的日志事件都被写入它们各自的接收器。Serilog 可能为了效率而在内存中缓冲事件,而刷新操作确保这些缓冲的事件不会丢失。
我建议在退出应用程序时调用此方法,无论是通过关闭还是通过终止。
就此而言,我总是创建一个方法(我称之为 FreeSerilogLoggerOnShutdown),该方法将订阅两个事件:
-
AppDomain.CurrentDomain.ProcessExit:当进程即将退出时,此事件被触发,允许我们执行清理任务或保存数据。 -
Console.CancelKeyPress:当用户按下 Ctrl + C 或 Ctrl + Break 时,此事件被触发,终止当前正在运行的应用程序。
在这两种情况下,这些订阅调用相同的委托方法(我称之为 ExecuteShutdownTasks),该方法将调用 Serilog 的 CloseAndFlush 方法。
下面是这两个方法的代码:
static void FreeSerilogLoggerOnShutdown()
{
// This event is raised when the process is about to exit,
// allowing you to perform cleanup tasks or save data.
AppDomain.CurrentDomain.ProcessExit += (s, e) =>
ExecuteShutdownTasks();
// This event is triggered when the user presses Ctrl+C or
// Ctrl+Break. While it doesn't cover all shutdown scenarios, it's
// useful for handling user-initiated terminations.
Console.CancelKeyPress += (s, e) => ExecuteShutdownTasks();
}
// Code to execute before shutdown
static void ExecuteShutdownTasks()
{
Console.WriteLine("Performing shutdown tasks...");
// Perform cleanup tasks, save data, etc.
Log.CloseAndFlush();
}
调用 FreeSerilogLoggerOnShutDown 方法的代码是 Program 类的 Main 方法的第一条指令。
虽然这不是一本关于 Serilog 的书(在我看来,Serilog 值得一本单独的书),但在本节中,我们涵盖了基础知识,这对于本书的目的已经足够。如果你想了解更多关于 Serilog 的信息,请访问 serilog.net/。
摘要
在本章中,我们通过添加错误处理和日志记录到应用程序中,改进了我们的 CLI 应用程序 Bookmarkr。
通过错误处理,我们在 CLI 应用程序中实现了优雅降级。这意味着我们的应用程序现在具有更高的容错性,在发生意外事件时不会突然崩溃。
通过日志记录,我们可以记录应用程序的活动、错误和异常,以便在稍后的时间点分析,以了解导致该错误或意外行为的事件的顺序。但日志记录也使得可以监控应用程序的健康状况和性能随时间的变化。
在即将到来的章节中,我们将看到如何使我们的 CLI 应用程序更加交互式和用户友好。
轮到你了!
按照提供的代码进行操作是学习实践的好方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 处理导入命令的错误
如果无法访问输入文件,或者其内容无法反序列化,代码可能会抛出异常。你的任务是识别可能抛出的异常,并相应地处理它们。
任务 #2 – 将错误记录到文件
在上一个任务中,目标是处理异常。然而,将这些异常的详细信息记录到文件中可能很有用,这样我们可以在以后回顾它们,并利用这些信息来提高我们应用程序的健壮性。
你的任务是使用 Serilog 按每日滚动间隔记录异常数据,并将这些日志文件存储在logs/errors文件夹中。
你还被要求自定义输出模板,以便日志包含以下信息:
-
事件的时间和日期
-
发生事件的机器名称
-
事件类型(警告、错误等)
-
异常的详细信息,包括其堆栈跟踪
第三部分:CLI 应用程序开发的高级主题
在本部分中,你将探索交互式命令行应用程序的世界,学习如何使用Spectre.Console等库创建引人入胜的用户体验。你将发现实现丰富提示、彩色输出和增强用户交互的交互式菜单的技术。接下来,你将深入研究构建模块化和可扩展的 CLI 应用程序,重点关注促进可维护性和可扩展性的架构模式。这包括对你的代码进行结构化以及将你的项目组织成逻辑组件。最后,你将了解如何将外部 API 和服务集成到你的 CLI 应用程序中。到本部分结束时,你将具备开发复杂的 CLI 工具的技能,这些工具可以消费各种外部服务和 API。
本部分包含以下章节:
-
第七章**,交互式命令行应用程序
-
第八章**,构建模块化和可扩展的 CLI 应用程序
-
第九章**,与外部 API 和服务协作
第七章:交互式命令行应用程序
到目前为止,与我们的 CLI 应用程序(Bookmarkr)的交互主要是基于文本的,这意味着应用程序对文本输入做出文本输出。从某种意义上说,它主要是一种请求-响应类型的应用程序。
是的,我们还在文本输出中添加了一些颜色,以便用户可以轻松地立即知道请求是否成功处理,或者它以错误或警告结束。
然而,尽管命令行应用程序不提供图形用户界面(GUI),但这并不意味着它们不能有趣! 😉
在本章中,我们将学习如何增强我们的 CLI 应用程序的输出,使其更易于使用。我们将学习如何添加以下元素:
-
进度条和勾选标记,让用户知道他们请求的进度。这对于耗时较长的长时间运行操作(如下载或编码文件)特别有用。
-
列表项的列表,使其更容易从预定义项中选择。请注意,此列表不必是静态的;它可以动态地适应用户的当前上下文(例如,他们的权限)和请求(例如,请求的命令及其选项的值)。
通过依赖这些增强功能,我们不仅使我们的命令行界面(CLI)应用程序更易于使用,而且使其更具交互性,因为它可以与用户进行对话,向他们更新操作进度,或向他们展示针对其特定上下文定制的选项列表。
技术要求
本章的代码可以在与本书配套的 GitHub 存储库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter07。
构建交互式命令行应用程序
正如你可能已经注意到的——我相信你确实做到了 😉——我们已经创建了一些辅助方法来在不同场景中以不同颜色显示文本:绿色用于成功消息,黄色用于警告,红色用于错误。这些辅助方法的代码可以在Helper.cs文件中找到。
我们当然可以向此文件添加更多方法以支持其他功能,例如进度条和下拉列表。然而,正如我之前提到的,我相信你很聪明,所以你不会想要重新发明轮子,而是依赖一个适合这项工作的现有库 😊。
尽管可能存在多个库来满足这一目的,但我们在本章中将使用的是Spectre.Console。
Spectre.Console库旨在通过超越彩色文本来增强创建视觉上吸引人的控制台应用程序。它还允许您渲染树、下拉列表、表格、进度条以及许多其他图形元素。
这广泛的特性允许我们在 CLI 应用程序的控制台环境中创建丰富的用户界面。
关于 Spectre.Console 的更多详细信息,您可以访问其 GitHub 页面 github.com/spectreconsole/spectre.console。特别是,我推荐查看以下两个存储库:
-
spectre.console:项目的官方存储库,这意味着它包含库的代码 -
示例:此存储库包含使用库的各种示例
因此,让我们首先将 Spectre.Console 库添加到我们的应用程序中。这可以通过以下命令实现:
dotnet add package Spectre.Console
让我们从添加一个名为 interactive 的新命令开始。这个命令将是 root 命令的子命令。同时,我们也为这个命令添加一个处理器:
var interactiveCommand = new Command("interactive", "Manage bookmarks interactively")
{
};
rootCommand.AddCommand(interactiveCommand);
interactiveCommand.SetHandler(OnInteractiveCommand);
让我们为这个新命令添加处理器方法:
static void OnInteractiveCommand()
{
}
由于我们正在使用 Spectre.Console 库,让我们引用它:
using Spectre.Console;
添加一个 FIGlet
FIGlet 是一种使用普通屏幕字符生成大型文本横幅的方式,以 ASCII 艺术的形式。最初于 1991 年发布,它因在终端会话中创建引人注目的文本而变得流行。因此,它非常适合使我们的 CLI 应用程序更易于使用!
因此,让我们向 Bookmarkr 添加一个 FIGlet!
由于 Spectre.Console 已经提供了这个功能,我们不需要自己创建 ASCII 艺术作品。所以,让我们利用它。
让我们更新我们的命令处理器方法,如下所示:
static void OnInteractiveCommand()
{
AnsiConsole.Write(new FigletText("Bookmarkr").Centered().
Color(Color.SteelBlue));
}
上述代码创建了一些 FIGlet 文本,将其居中,并以蓝色色调显示。
现在,让我们运行应用程序并查看我们目前所拥有的内容:
dotnet run -- interactive
我们应该看到以下输出:

图 7.1 – Bookmarkr 的一个 FIGlet
哇!这太棒了。这是我们交互式 CLI 应用程序的一个很好的开始,不是吗?😊
AnsiConsole 与 Console 的比较
如您所注意到的,我们不是使用 .NET 提供的 Console.Write 方法,而是使用 AnsiConsole 类提供的,它是 Spectre.Console 库的一部分。这是由于以下原因:
1. AnsiConsole 类相比 Console 类提供了一套更丰富的功能,例如高级文本格式化、颜色、样式和交互元素。
2. AnsiConsole 类旨在在不同操作系统和终端仿真器之间保持一致性。它自动检测当前终端的功能并根据其输出进行调整。
3. AnsiConsole 类支持 24 位颜色、文本样式(如粗体和斜体)以及各种小部件,如表格、树形图,甚至 ASCII 图像。
4. AnsiConsole 类使创建交互式提示、选择菜单和其他比标准 Console 类更复杂的用户输入机制成为可能。
-
AnsiConsole类支持实时渲染功能,允许你动态更新内容,如进度条和状态指示器。 -
AnsiConsole类提供了一种丰富的标记语言,使得在不复杂的代码中应用颜色和样式到文本变得容易。 -
使用
IAnsiConsole接口而不是静态的AnsiConsole类,使得对命令处理程序和其他与控制台相关的代码进行单元测试成为可能。
在这个阶段,我们已经为我们的 CLI 应用程序的交互式版本打下了基础。在下一节中,我们将添加更多功能,使 Bookmarkr 更加用户友好!
设计用户友好的 CLI 应用程序
尽管Spectre.Console提供了许多功能(并且我鼓励您通过访问spectreconsole.net上的文档来查看它们),但我们将专注于这些功能的一个子集,以增加我们的 Bookmarkr 应用程序的交互性。
使用标记增强文本显示
让我们通过利用Spectre.Console提供的标记功能来改进我们的Helper.cs类。这些功能允许我们样式化和格式化文本,甚至渲染表情符号!然而,请注意,某些终端环境(尤其是较旧的系统或受限环境,如 CI/CD 系统)可能不支持表情符号渲染。
首先,让我们回顾我们的三个方法:ShowErrorMessage、ShowWarningMessage 和 ShowSuccessMessage。
这些方法在显示接收到的文本参数的颜色上有所不同,但它们在遵循相同过程的方式上是相似的:
-
首先,它们将当前的前景色保存到一个临时变量中。
-
然后,他们将前景色更改为预期的颜色(红色、黄色或绿色)。
-
接着,它们使用该颜色显示接收到的文本。
-
最后,它们将前景色恢复到保存的颜色。
这得益于Spectre.Console库。我们不必保存和恢复当前的前景色(库会为我们做这件事)。它还打开了一些新的可能性。让我们在重新定义这些方法的同时探索一些!
ShowErrorMessage方法
我们仍然希望错误信息以红色显示,但我们还想使用表情符号使其更加引人注目。
代码看起来是这样的:
public static void ShowErrorMessage(string[] errorMessages)
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
AnsiConsole.MarkupLine(
Emoji.Known.CrossMark + " [bold red]ERROR[/] :cross_mark:");
foreach(var message in errorMessages)
{
AnsiConsole.MarkupLineInterpolated($"[red]{message}[/]");
}
}
在这个代码示例中有许多值得提及的地方:
-
首先,我们将编码设置为 UTF-8。这确保了表情符号能够正确渲染。否则,它们将被问号替换。
-
说到表情符号,请注意我们可以用两种方式显示它们:我们既可以使用
Emoji类下的Known枚举,也可以使用标记代码(这里我们使用了:cross_mark:)。支持的完整表情符号列表,包括它们的标记代码和枚举常量,可以在spectreconsole.net/appendix/emojis找到。 -
我们使用
AnsiConsole.MarkupLine(…)来显示带交叉标记的粗体和红色单词 ERROR。语法基于BBCode(en.wikipedia.org/wiki/BBCode)。 -
由于
ShowErrorMessage方法接收一个字符串数组,我们以红色显示每个字符串。然而,这次,我们依赖于AnsiConsole类的MarkupLineInterpolated方法,因为我们正在进行字符串插值。
让我们看看调用 ShowErrorMessage 方法会发生什么:

图 7.2 – 更新的 ShowErrorMessage 方法执行效果
再次注意,Hello! 消息没有以红色显示,而是在终端的先前前景色中显示,我们无需处理这一点。
现在,让我们更新 ShowWarningMessage 和 ShowSuccessMessage 方法。
ShowWarningMessage 方法
我们仍然希望以黄色显示警告消息,但让我们也使其居中显示在屏幕中央(是的,我们也会使用表情符号 😊)。
更新后的代码看起来是这样的:
public static void ShowWarningMessage(string[] errorMessages)
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
var m = new Markup(
Emoji.Known.Warning + " [bold yellow]Warning[/] :warning:"
);
m.Centered();
AnsiConsole.Write(m);
AnsiConsole.WriteLine();
foreach(var message in errorMessages)
{
AnsiConsole.MarkupLineInterpolated(
$"[yellow]{message}[/]"
);
}
}
这段代码与 ShowErrorMessage 的代码非常相似。然而,有一个细微的差别:我们不是调用 MarkupLine 方法来显示带表情符号的 Warning 文本,而是实例化一个 Markup 类型的对象,然后在其 Centered() 方法之前调用它,然后再将其作为参数传递给 Write 方法。这是必要的,以便我们可以将文本居中显示在屏幕上。
此外,注意在那之后,我们调用 WriteLine 方法而没有任何参数。这确保在显示警告信息之前执行换行。
让我们看看调用 ShowWarningMessage 方法会发生什么:

图 7.3 – 更新的 ShowWarningMessage 方法执行效果
再次注意,Hello! 消息没有以红色显示,而是在终端的先前前景色中显示,我们无需处理这一点。
ShowSuccessMessage 方法
我们仍然希望以绿色显示成功消息。
更新后的代码看起来是这样的:
public static void ShowSuccessMessage(string[] errorMessages)
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
AnsiConsole.MarkupLine(Emoji.Known.BeatingHeart + " [bold green]
SUCCESS[/] :beating_heart:");
foreach(var message in errorMessages)
{
AnsiConsole.MarkupLineInterpolated($"[green]{message}[/]");
}
}
这里绝对没有什么值得提及的,也许只是我们决定使用心形表情符号来庆祝操作的顺利完成!😊 恭喜你 – 你开始掌握使用 Spectre.Console 风格化和格式化文本的技能了!
让我们看看调用 ShowSuccessMessage 方法会发生什么:

图 7.4 – 更新的 ShowSuccessMessage 方法执行效果
再次注意,Hello! 消息没有以红色显示,而是在终端的先前前景色中显示,我们无需处理这一点。
现在我们可以让我们的文本更加引人注目,让我们通过添加更多功能来改进我们的交互式命令。
使用选择提示向用户提供选项
以视觉上令人愉悦的方式显示文本很重要,但与用户交互并获取他们的输入同样重要。幸运的是,Spectre.Console提供了许多与用户交互的方式。让我们探索其中的一种。
目前,我们的交互式命令只显示一个 FIGlet。这当然很棒,但让我们让它对用户更有价值。我们可以添加的一件事是列出用户可以执行的所有操作。为此,我们将使用选择提示。这将允许用户使用键盘上的上下箭头导航列表,并确认要执行的操作。
下面是它在实际操作中的样子:

图 7.5 – 选择提示在实际操作中的应用
下面是OnInteractiveCommand方法的更新代码:
static void OnInteractiveCommand()
{
bool isRunning = true;
while(isRunning)
{
AnsiConsole.Write(
new FigletText("Bookmarkr")
.Centered()
.Color(Color.SteelBlue)
);
var selectedOperation = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("[blue]What do you wanna do?[/]")
.AddChoices([
"Export bookmarks to file",
"View Bookmarks",
"Exit Program"
])
);
switch(selectedOperation)
{
case "Export bookmarks to file":
ExportBookmarks();
break;
case "View Bookmarks":
ViewBookmarks();
break;
default:
isRunning = false;
break;
}
}
}
让我们更仔细地看看这段代码:
-
我们希望用户能够继续选择操作,直到他们决定退出程序(通过从选择提示中选择退出程序选项)。这就是为什么我们将代码放在一个依赖于初始设置为
true的布尔变量的while循环中。当用户选择退出程序时,布尔变量被设置为false,程序退出。否则,在每次完成操作后,选择提示都会显示给用户。 -
我们实例化了
SelectionPrompt类(由Spectre.Console提供),指定了一个标题和将显示给用户的可用选项列表。用户可以使用上下箭头导航此列表,并通过按下Enter键确认他们的选择。当他们这样做时,selectedOperation变量被设置为所选值。 -
最后,我们使用
switch语句根据selectedOperation变量的值调用一个特定方法。该方法将处理请求的操作。请注意,这些方法尚未实现——我们将在接下来的章节中关注它们。
多选
Spectre.Console库还提供了多选提示,允许用户从可能的选项列表中选择多个项目。您可以在spectreconsole.net/prompts/multiselection找到一个说明性的示例,以及一个代码示例。
展示导出命令的实时进度
我们之前导出命令的迭代有点……枯燥!它确实按照预期将书签导出到指定的输出文件,但它没有提供关于操作进度的信息。我们无法知道它是否已经完成了一半,或者它是否仍然在千个项目的最前面几个。多亏了Spectre.Console的功能,我们可以在过程中显示实时进度。
展示实时进度的方法有两种:同步和异步。由于我们的导出代码是异步的,我们将使用后者。
因此,让我们实现上一节中看到的 ExportBookmarks 方法。以下是代码:
static void ExportBookmarks()
{
// ask for the outputfilePath
var outputfilePath = AnsiConsole.Prompt(
new TextPrompt<string>("Please provide the output file name
(default: 'bookmarks.json')")
.DefaultValue("bookmarks.json"));
// export the bookmarks to the specified file, while showing
// progress.
AnsiConsole.Progress()
.AutoRefresh(true) // Turns on auto refresh
.AutoClear(false) // Avoids removing the task list when
// completed
.HideCompleted(false) // Avoids hiding tasks as they are
// completed
.Columns(
[
new TaskDescriptionColumn(), // Shows the task
// description
new ProgressBarColumn(), // Shows the progress bar
new PercentageColumn(), // Shows the current
// percentage
new RemainingTimeColumn(), // Shows the remaining
// time
new SpinnerColumn(), // Shows the spinner,
// indicating that the
// operation is ongoing
])
.Start(ctx =>
{
// Get the list of all bookmarks
var bookmarks = service.GetAll();
// export the bookmarks to the file
// 1\. Create the task
var task = ctx.AddTask("[yellow]exporting all bookmarks to
file...[/]");
// 2\. Set the total steps for the progress bar
task.MaxValue = bookmarks.Count;
// 3\. Open the file for writing
using (StreamWriter writer = new
StreamWriter(outputfilePath))
{
while (!ctx.IsFinished)
{
foreach (var bookmark in bookmarks)
{
// 3.1\. Serialize the current bookmark as JSON
//and write it to the file asynchronously
writer.WriteLine(JsonSerializer.
Serialize(bookmark));
// 3.2\. Increment the progress bar
task.Increment(1);
// 3.3\. Slow down the process so we can see
// the progress (since this operation is not
// that much time-consuming)
Thread.Sleep(1500);
}
}
}
});
AnsiConsole.MarkupLine("[green]All bookmarks have been
successfully exported![/]");
}
让我们更仔细地看看这段代码:
-
首先,我们要求用户输入文件名。我们通过使用
AnsiConsole.Prompt并传递一个TextPrompt实例来实现这一点。此类还允许我们指定默认值,如果用户没有提供值或对此满意的话。 -
接下来,我们通过调用
AnsiConsole.Progress来显示进度条。导出操作的处理在Start方法内作为委托实现:-
我们首先检索要导出的书签列表。
-
接下来,我们创建一个
Task类的实例,该实例将负责跟踪导出操作的处理并增加进度条的百分比。我们还设置了该任务的最大值为要导出的书签数量。 -
然后,我们打开一个流写入器到导出文件。在任务未完成(由
ctx.IsFinished布尔值指示)时,我们将每个书签序列化为 JSON 并将其写入文件,增加任务(这将反过来更新进度条),并等待 1.5 秒(这是可选的,但由于导出操作并不耗时,添加延迟可以让我们看到进度条的动画 😊)。
-
-
你可能已经注意到了在调用
Start方法之前的一些代码行。这些代码在这里是为了配置进度条的外观和行为:-
AutoRefresh(true): 我们启用了进度条的自动刷新。否则,即使我们增加任务的值,进度条也不会动画化以反映更新的值。 -
AutoClear(false): 这将防止任务完成后被移除。这在显示多个并发操作的进度时特别有用。 -
HideCompleted(false): 这将防止任务完成后被隐藏。这在显示多个并发操作的进度时特别有用。 -
Columns: 这个集合控制进度条的外观。在这种情况下,我们决定显示任务的描述、进度条、当前操作百分比、完成操作剩余时间以及一个指示操作正在进行的旋转器(这在处理进度条可能更新间隔较长的耗时操作时很有帮助,因为旋转器会一直旋转,用户就会有一种操作仍在进行的感受)。
-
现在代码已经实现,让我们运行程序看看我们得到什么结果:

图 7.6 – 导出书签时显示实时进度
现在,我们的导出操作更加用户友好(坦白说,使用起来更有趣 😊)。用户会被告知导出操作的进度,随着操作的继续运行。
重要提示
如果终端不被认为是交互式的(例如,在持续集成系统中运行时),任何进度将以更简单的方式显示(例如,当前百分比值显示在新行上)。
有了这个,我们已经将书签导出到文件中,并且可以从中查看它们。太棒了!但如果我们想直接在 CLI 应用程序中查看它们怎么办?让我们看看我们如何能够有一个视觉上令人愉悦的书签表示。
以树形视图显示书签
Spectre.Console 库提供了各种选项来显示元素列表。我们可以使用表格、树、布局、面板、网格等等。
由于我们想要根据书签所属的类别显示书签列表,我们将使用树表示。因此,让我们实现 ViewBookmarks 方法。
这是这个方法的代码:
static void ViewBookmarks()
{
// Create the tree
var root = new Tree("Bookmarks");
// Add some nodes
var techBooksCategory = root.AddNode("[yellow]Tech Books[/]");
var carsCategory = root.AddNode("[yellow]Cars[/]");
var socialMediaCategory = root.AddNode("[yellow]Social Media[/]");
var cookingCategory = root.AddNode("[yellow]Cooking[/]");
// add bookmarks for the Tech Book category
var techBooks = service.GetBookmarksByCategory("Tech Books");
foreach(var techbook in techBooks)
{
techBooksCategory.AddNode($"{techbook.Name} | {techbook.
Url}");
}
// ... do the same for the other categories ;)
// Render the tree
AnsiConsole.Write(root);
}
让我们来解释一下这段代码:
-
首先,我们创建树的根元素,并将其标记为 Bookmarks。这个标签将显示出来,以指示这个树展示了哪些元素。
-
接下来,我们添加四个节点(每个类别一个)。这些标签是根节点的子节点,它们的标签以黄色显示。
-
然后,我们调用
BookmarkService的GetBookmarksByCategory方法来检索与指定类别(在这种情况下,Tech Books)关联的书签列表。之后,我们遍历这个列表,并将每个书签作为子节点添加到techBooksCategory节点。 -
我们对其他类别做同样的事情。前面的代码示例已经简化,只显示了 Tech Books 类别的代码以供清晰。然而,完整的代码可以在本书的 GitHub 仓库中找到。
-
最后,我们在控制台中显示标签。
很直接,不是吗?
现在,让我们运行程序并看看它的样子:

图 7.7 – 以树形视图显示书签,按类别分组
哇。多么大的改进!我们的 CLI 应用程序的交互式版本看起来很棒,更用户友好且更具吸引力,不是吗?😉
要交互还是不要交互?
提供一个 CLI 应用的交互式版本无疑增强了用户体验。那么,我们难道不应该总是提供它吗?它难道不应该成为默认版本吗?这些问题很棒。感谢提问!😊
让我们从第一个问题开始:我们难道不应该总是提供它吗? 我们当然应该提供,因为它(如前所述)可以改善用户体验和参与度。
它难道不应该成为默认版本吗? 它可以!这取决于你的目标受众:
-
如果你的 CLI 应用程序主要是为了由人类运行,那么是的!你应该将交互式版本设置为默认版本。在这种情况下,你的命令可以提供一个
--non-interactive选项,以便在程序执行该命令时(例如在 CI/CD 管道中)禁用此交互行为。 -
如果你的 CLI 应用程序主要是为了由程序运行(例如用于处理大量文件或 CI/CD 管道),你的命令可以提供一个
--interactive选项,以便在由人类执行时启用交互式行为。
换句话说,交互性非常棒,但请明智地使用它!
摘要
在本章中,我们通过引入进度条、勾选标记和项目列表,使 Bookmarkr 更加用户友好和图形化,以便于用户选择,并确保只选择有效值。
我们了解到,这些添加功能与文本着色相结合,有助于使我们的 CLI 应用程序更具吸引力和趣味性,并表明 CLI 应用程序在 GUI 应用程序面前没有任何可羞愧之处。
但这还不是全部!我们还了解到,这些添加功能有助于使我们的 CLI 应用程序与用户更加对话式(即交互式)。
在下一章中,我们将学习如何使我们的 CLI 应用程序具有更模块化的设计,以便更容易扩展。
轮到你了!
跟随提供的代码是一种通过实践学习的好方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加各种功能来改进 Bookmarkr 应用程序。
任务 1 – 以用户友好的方式展示书签
你被要求添加一个 show 命令,该命令接受书签的名称,并以三列网格的形式显示它 – 一列用于名称,一列用于 URL,一列用于分类。
网格应包含一行用于标题(名称、URL 和分类)。
名称应以黄色和粗体显示;URL 应作为链接呈现;分类应以斜体和绿色显示。
命令的语法应如下所示:
bookmarkr link show --name <name of the bookmark>
任务 2 – 交互式更改书签的分类
你被要求实现一个名为 category change 的新命令,该命令更改现有 URL 的分类。
命令必须显示现有分类的列表作为选择菜单;用户将不得不根据其 URL 选择将其设置为该书签的新分类。然后,此更新将被保存到数据库中。
命令的语法应如下所示:
bookmarkr category change --for-url <url of the bookmark>
第八章:构建模块化和可扩展的 CLI 应用程序
在这本书的每一页,我们都为我们的心爱命令行应用程序 Bookmarkr 添加了更多功能。
问题是我们还向 Program.cs 文件中添加了越来越多的代码行。该文件的长度从第三章末的 191 行代码增长到第七章末的 479 行代码。
在本章中,我们将退后一步,重构我们的代码,使其更加模块化。这将使其更容易扩展、测试和维护。
重构是开发生命周期的一个基本部分。它应该定期发生,以确保代码质量符合标准。
通过采取这一必要的步骤,我们将极大地简化添加更多功能,提高我们应用程序的可读性和稳定性,甚至将其引入可测试性。
更具体地说,在本章中,我们将涵盖以下主题:
-
构建当前应用程序的代码图
-
决定从哪里开始重构
-
设计项目结构以支持重构
-
重构一个命令
-
应用依赖倒置原则
-
重构
Program类 -
运行程序以验证重构没有破坏任何东西
-
将我们的重构推向新的边界
技术要求
本章的代码可以在本书配套的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter08。
第 1 步 – 构建应用程序的代码图
当你重构一个应用程序时,首先要了解你即将重构的内容。这意味着要有一个对应用程序及其依赖关系的高级视图。这有助于你可视化所有涉及的移动部分,并通过确定从哪里开始来更好地规划你的重构活动。
如果你拥有 Visual Studio Enterprise 版本,你可以使用其出色的架构功能(例如代码图和依赖关系图)来可视化你的代码及其依赖关系。然而,由于我们正在使用 Visual Studio Code(或者如果你没有 Visual Studio 的企业版),我们可以做些其他的事情…
当然,我们可以运行代码并识别其每一个移动部分,但既然这是一个命令行应用程序,让我们来做点更聪明的事情。😉
使用帮助菜单构建代码图
System.CommandLine 确实非常适合学习如何使用应用程序,但它也非常适合确定应用程序的代码图。
让我们从在根命令中通过输入以下命令来显示帮助菜单开始:
dotnet run -- -h
我们将得到以下结果:

图 8.1 – 根命令的帮助菜单
接下来,我们将重复此操作(即显示帮助菜单)针对根命令的每个子命令,然后针对每个子命令的每个子命令,然后……好吧,你明白了!😉
这里是link命令的一个示例:

图 8.2 – 链接命令的帮助菜单
我们应该在什么时候停止?嗯,当当前命令没有更多子命令时。以下是一个关于link add命令的示例:

图 8.3 – 链接添加命令的帮助菜单
完成这个练习后,我们将得到以下代码图:

图 8.4 – Bookmarkr 应用程序的代码图
好吧,现在我们已经对我们的应用程序中的运动部件有了更清晰的了解,下一步该做什么呢?
第 2 步 – 决定从哪里开始
现在是时候决定先重构什么了。
我建议不要首先从根命令开始,而是从该根命令的子命令开始。
从那里开始,没有对或错的决策。你可以选择你想要的任何子命令开始。在本章的剩余部分,我们将以export命令为例。
虽然export命令没有子命令,但它仍然有助于我们为Bookmarkr的重构版本奠定基础。更具体地说,它将帮助我们完成以下工作:
-
定义项目结构以支持我们的重构
-
重构它并隐藏其“复杂性”(即移动部件到根命令)
-
重构
Program类,使其更精简、更干净、更简洁 -
设置依赖注入以在命令和外部服务(如
BookmarkService)之间进行交互
让我们从设计支持我们重构活动的项目结构开始。
第 3 步 – 设计项目结构
虽然一个人可以根据他们的喜好设计他们的项目结构,但我设计我的项目结构是为了让任何查看我的项目的人都能理解它做什么以及每个运动部件在哪里。
按照这个原则,所有命令都将被分组在一个名为Commands的文件夹中。这个文件夹将位于项目结构的根目录。
由于我们将重构export命令,让我们创建一个名为Export的子文件夹,其中将包含所有涉及export命令的代码工件。
一旦我们开始重构另一个命令,我们将为它创建一个特定的文件夹。
命令的子命令怎么办?
遵循面向对象编程中的封装原则,并且由于子命令只能通过其父命令调用,我建议将子命令放在与其父命令相同的文件夹中。
例如,link add命令。add子命令只能通过其父命令(link)来调用。因此,它们的生命周期紧密相关。
关于这一点,add命令的代码工件将位于其父命令(link)的代码工件附近,在Link文件夹中。
第 4 步 – 重构导出命令
在Export文件夹中,让我们创建一个名为ExportCommand.cs的新 C#文件。
每个命令类(包括RootCommand)都继承自Command基类。此外,该基类提供了一个接受Command类型参数的AddCommand方法,这意味着任何继承自Command类的类。
带着这个认识,我们可以开始重构export命令,通过使ExportCommand类继承自Command类。
在导入所需的using语句、指定namespace名称和添加所需的类构造函数之后,我们类的第一次迭代看起来如下所示:
using System.CommandLine;
namespace bookmarkr.Commands;
public class ExportCommand : Command
{
#region Constructor
public ExportCommand(string name, string? description = null)
: base(name, description)
{
}
#endregion
}
首先要移动到这个类中的是选项。export命令只有一个选项,即outputfileOption。
我喜欢我的类中的每个组件都很好地分割开。这就是为什么我是区域(regions)的粉丝。因此,让我们添加一个专门用于选项的区域,并将outputfileOption选项的代码移动到这个区域中。
代码看起来如下所示:
#region Options
private Option<FileInfo> outputfileOption = new Option<FileInfo>(
["--file", "-f"],
"The output file that will store the bookmarks"
)
{
IsRequired = true
}.LegalFileNamesOnly();
#endregion
然后,我们需要将此选项与命令关联起来。我们将在构造函数体内部调用AddOption方法来完成此操作,如下所示:
#region Constructor
public ExportCommand(string name, string? description = null)
: base(name, description)
{
AddOption(outputfileOption);
}
#endregion
下一步要移动的是对SetHandler方法的调用,它将命令连接到其处理方法。因此,构造函数的更新版本如下所示:
#region Constructor
public ExportCommand(string name, string? description = null)
: base(name, description)
{
AddOption(outputfileOption);
this.SetHandler(async (context) =>
{
FileInfo? outputfileOptionValue = context.ParseResult.
GetValueForOption(outputfileOption);
var token = context.GetCancellationToken();
await OnExportCommand(outputfileOptionValue!, token);
});
}
#endregion
最后,要移动到新类中的最后一部分代码是命令处理方法。我们再次将创建一个新的区域来存放这部分代码,并将最后的代码移动过去。我们还将static修饰符更改为private。原因是类不是静态的(因此移除了static关键字),命令处理方法是私有的(因此使用了private关键字):
#region Handler method
private async Task OnExportCommand(FileInfo outputfile,
CancellationToken token)
{
// method body removed for brevity.
// It is exactly similar to the one from the previous chapters.
}
#endregion
如果你在这个过程中正在键入(或复制粘贴 😉)代码,你可以在这一点上看到代码因为两个错误而无法编译。
第一个问题很容易解决。只需在 C#文件顶部添加以下语句即可:
using System.Text.Json;
第二个问题不太明显,它表明类找不到BookmarkService类的实例。
当然,我们可以在当前类中简单地创建该服务的实例。然而,由于BookmarkService是ExportCommand类的外部依赖,这样做将违反面向对象编程所倡导的依赖倒置原则。
作为提醒,依赖倒置原则是面向对象编程和设计的五个 SOLID 原则之一。它指出,高级模块不应该依赖于低级模块;两者都应依赖于抽象。此外,抽象不应该依赖于细节;细节应该依赖于抽象。这个原则有助于解耦软件模块,使系统更加模块化、灵活,并且更容易维护。
这实际上意味着什么?这意味着我们应该将 BookmarkService 实例注入到 ExportCommand 类中。
让我们开始做吧!
第 5 步 – 应用依赖倒置原则
如果你熟悉依赖倒置原则,你肯定会已经注意到 BookmarkService 类没有实现任何接口。
让我们先解决这个问题。
我对依赖倒置原则不太熟悉!
如果你还没有这样做,有很多优秀的资源可以探索这个原则。这并不是一个难以理解的原则,坦白说,在你了解它之后,它将显得如此明显,以至于你会 wonder 为什么你之前不知道它。
可以在 www.c-sharpcorner.com/article/dependency-inversion-principle-in-c-sharp/ 找到该原则的出色解释。
我强烈建议你在应用依赖倒置原则之前和之后审查 BookmarkService 服务的实现,以便对它的好处有清晰的理解。
回到我们关于项目结构讨论的话题,我们首先将创建一个名为 Services 的新文件夹,用于组织我们所有的服务类。在这个文件夹内,为每个服务创建一个特定的文件夹。在我们的例子中,我们只有一个服务,所以让我们创建 BookmarkService 文件夹。这个文件夹将包含我们服务的接口和具体实现。
我们服务的文件夹结构将如下所示:

图 8.5 – BookmarkService 服务的文件夹结构
接下来,让我们将 IBookmarkService 接口从 BookmarkService 类中提取出来。该接口的代码如下:
namespace bookmarkr.Services;
public interface IBookmarkService
{
void AddLink(string name, string url, string category);
void AddLinks(string[] names, string[] urls, string[] categories);
void ListAll();
List<Bookmark> GetAll();
void Import(List<Bookmark> bookmarks);
BookmarkConflictModel? Import(Bookmark bookmark);
List<Bookmark> GetBookmarksByCategory(string category);
}
现在,让我们让 BookmarkService 类实现 IBookmarkService 接口:
namespace bookmarkr.Services;
public class BookmarkService : IBookmarkService
{
// method body removed for brevity.
// It is exactly similar to the one from the previous chapters.
}
注意,我们为了更好地传达它们的意图,已经更改了这些实体的命名空间名称。
现在剩下的工作就是将该服务注入到 ExportCommand 类中。这意味着两件事:
-
我们将在
ExportCommand类中添加一个private类型的IBookmarkService属性,这将允许我们在命令类内部(更具体地说,在OnExportCommand方法内部)调用该服务的方法。 -
我们将通过构造函数参数注入该服务的实例。
ExportCommand类的更新代码现在看起来是这样的:
using System.CommandLine;
using System.Text.Json;
using bookmarkr.Services;
namespace bookmarkr.Commands;
public class ExportCommand : Command
{
#region Properties
private IBookmarkService _service;
#endregion
#region Constructor
public ExportCommand(IBookmarkService service, string name,
string? description = null)
: base(name, description)
{
_service = service;
AddOption(outputfileOption);
this.SetHandler(async (context) =>
{
FileInfo? outputfileOptionValue = context.ParseResult.
GetValueForOption(outputfileOption);
var token = context.GetCancellationToken();
await OnExportCommand(outputfileOptionValue!, token);
});
}
#endregion
// The "Options" region hasn't changed and removed for brevity.
#region Handler method
private async Task OnExportCommand(FileInfo outputfile,
CancellationToken token)
{
// …
var bookmarks = _service.GetAll();
// …
}
#endregion
}
这段代码非常容易理解,不需要任何特别的解释。
依赖倒置原则可能会引入复杂性!
依赖注入可能会通过需要额外的设置和配置(例如添加额外的接口、类和间接引用)在简单应用中引入一些开销,这对于依赖较少的简单项目可能是多余的。
因此,找到在不给代码库增加太多复杂性的情况下应用此原则的平衡是很重要的。
这真是太好了。自从我们开始重构之旅以来,我们已经走了很长的路!
还有最后一段代码我们没有重构,这就是我们最初开始这段旅程的原因:Program类。
让我们现在将注意力转向这个类…
第 6 步 - 重构Program类
通过将命令重构到它们各自的类中,创建和处理这些命令的代码将从Program类中移除。
因此,Program类现在将仅用于组合我们的应用程序。更具体地说,Program类将执行以下操作:
-
实例化根命令并注册其子命令。
-
实例化和配置
CommandLineBuilder类,并启动程序。 -
配置日志。
-
配置
BookmarkService服务的依赖注入。
这里是Program类的重构代码(请注意,为了简洁和清晰,这里没有列出代码的部分,包括using语句):
using Microsoft.Extensions.DependencyInjection;
class Program
{
static async Task<int> Main(string[] args)
{
FreeSerilogLoggerOnShutdown();
/** DECLARE A VARIABLE FOR THE IBookmarkService **/
IBookmarkService _service;
/** INSTANTIATE THE ROOT COMMAND **/
var rootCommand = new RootCommand(
"Bookmarkr is a bookmark manager provided as a CLI
application.")
{
};
rootCommand.SetHandler(OnHandleRootCommand);
/** CONFIGURE DEPENDENCY INJECTION FOR THE IBookmarkService
**/
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
// Register your services here
services.AddSingleton<IBookmarkService,
BookmarkService>();
})
.Build();
_service = host.Services.GetRequiredService<IBookmarkService>();
/** REGISTER SUBCOMMANDS OF THE ROOT COMMAND **/
rootCommand.AddCommand(new ExportCommand(_service, "export",
"Exports all bookmarks to a file"));
/** THE BUILDER PATTERN **/
// code removed for brevity.
}
/** HANDLER OF THE ROOT COMMAND **/
static void OnHandleRootCommand()
{
Console.WriteLine("Hello from the root command!");
}
static void FreeSerilogLoggerOnShutdown()
{
// code removed for brevity.
}
static void ExecuteShutdownTasks()
{
// code removed for brevity.
}
}
这段代码主要很容易理解。
值得解释的唯一部分是我们如何执行服务的依赖注入(这里使用BookmarkService):
-
我们声明了一个
IBookmarkService类型的变量,该变量将用于检索注入的服务实例。 -
我们通过利用.NET 提供的
HostBuilder类并注册服务到IServiceCollection集合来配置依赖注入。 -
我们通过在
IServiceCollection集合上调用GetRequiredService来检索已注册的服务实例,并将检索到的服务引用存储到我们之前声明的变量中。 -
当创建新命令的实例时,我们将该变量作为参数传递给命令的构造函数,以便新命令接收服务实例。
哇!服务被自动实例化并注入到需要它的各种命令中。
这种方法的优点是,如果我们需要更改服务实现,我们只需修改服务注册到IServiceCollection,其余的将神奇地得到处理。
注意到Program.cs文件从 479 行代码缩减到了 115 行代码!
最好的部分是?将新命令注册到根命令只需要一行额外的代码(即,在根命令上调用AddCommand并传递要注册的新命令的实例),而注入新服务只需要两行代码:一行用于将服务添加到服务集合中,另一行用于获取该服务的引用以便传递给需要它的类。
小心陷阱!
依赖注入的常见陷阱包括循环依赖,其中类相互依赖,以及由于服务生命周期不当而可能出现的意外单例行为。过度注入依赖项可能会违反单一职责原则,而过度依赖服务定位器会复杂化测试。为了避免这些问题,仔细管理服务生命周期并遵循最佳实践至关重要。
我强烈建议您查看第十四章中的阅读推荐,以便在需要时更深入地探索这个主题。
第 7 步 – 运行程序
完美!我们已经完成了export命令的重构。让我们运行代码以确保它仍然按预期工作。
调用export命令的语法没有变化。所以,让我们像以前一样通过输入以下内容来调用它:
dotnet run export --file 'bookmarks33.json'
我们将得到以下结果:

图 8.6 – 修改后调用导出命令
太棒了!应用程序仍然按预期工作。
到目前为止,我们已经对我们的应用程序进行了大量的重构。但这就是全部吗?或者我们可以将其提升到另一个层次?
将重构提升到新的高度
你可能想知道为什么我们没有将选项和处理器方法提取到它们自己的代码工件(如类)中。
原因是选项和处理器方法(以及参数)通常对特定命令是唯一的。因此,它们在命令类中定义。
然而,在它们需要被多个命令使用的情况下,我们会将它们提取到它们自己的代码工件中。记住这个推理很重要,以避免通过过度抽象而使我们的设计过于复杂化。
在选项的情况下,我们会创建一个专用类。以下是我们ExportCommand类中使用的outputfileOption的示例:
using System.CommandLine;
namespace bookmarkr.Options;
public class FileInfoOption : Option<FileInfo>
{
public FileInfoOption(string[] aliases, string? description =
null, bool onlyAllowLegalFileNames = true, bool isRequired = true)
: base(aliases, description)
{
if(onlyAllowLegalFileNames == true)
{
this.LegalFileNamesOnly();
}
this.IsRequired = isRequired;
}
}
我们可以使用此选项在任何命令中,通过创建其实例,如下所示:
var outputfileOption = new FileInfoOption(["--file", "-f"], "The output file path");
下面是ExportCommand可能看起来像的样子:
public class ExportCommand : Command
{
#region Constructor
public ExportCommand(IBookmarkService service, string name,
string? description = null)
: base(name, description)
{
_service = service;
outputfileOption = new FileInfoOption(["--file", "-f"], "The
output file path");
AddOption(outputfileOption);
// remaining of the code removed for brevity.
}
#endregion
#region Options
private FileInfoOption outputfileOption;
#endregion
// remaining of the code removed for brevity.
}
请特别注意outputfileOption属性是如何声明的(在Options区域)以及它如何在构造函数中实例化和初始化。它的使用方式与之前没有不同。
在处理方法的情况下,我们会创建一个从Command派生的基类(让我们称它为CommandWithBaseHandler),向其中添加处理方法(允许它被覆盖),并使我们的命令类从那个CommandWithBaseHandler类派生,而不是从Command类派生。
CommandWithBaseHandler类可能看起来是这样的:
using System.CommandLine;
namespace bookmarkr.Commands.Base;
public class CommandWithBaseHandler : Command
{
public CommandWithBaseHandler(string name, string? description =
null)
: base(name, description)
{
}
public virtual async Task OnExportCommand(FileInfo outputfile,
CancellationToken token)
{
// method body removed for brevity.
}
}
注意到OnExportCommand方法已被标记为virtual。这意味着它提供了CommandWithBaseHandler类中的默认实现,但如果需要,允许该实现被覆盖。
然后,我们可以按照以下方式修改ExportCommand类:
using System.CommandLine;
using System.Text.Json;
using bookmarkr.Services;
using bookmarkr.Options;
using bookmarkr.Commands.Base;
namespace bookmarkr.Commands;
public class ExportCommand : CommandWithBaseHandler
{
// the remaining code is not shown for brevity.
// the OnExportCommand method is removed from this class since it
// has been moved to the CommandWithBaseHandler base class.
}
完美!但这些新的代码组件将如何融入我们的项目结构中?这是一个很好的问题!
让我们更新我们的项目结构以适应这些新的组件。
更新项目结构
按照迄今为止应用的项目结构原则,我建议以下做法:

图 8.7 – 命令的项目结构
太棒了!我们现在有一个更加模块化和易于扩展的应用程序。一切都有其合适的位置,这使得阅读和导航应用程序的代码变得更加容易。
等等,可扩展?!
你可能没有注意到,但我们在这章中进行的重构不仅从模块化的角度增强了我们的应用程序,还从可扩展性的角度进行了增强。
想想看:我们现在可以轻松地让其他团队成员参与到我们应用程序的开发中,使得向用户交付新特性变得更加迅速。
每个团队成员都可以专注于他们自己的命令,这只会影响一小部分代码组件,在大多数情况下,他们不会修改相同的文件,这减少了在将代码推送到源代码控制时可能发生的合并冲突的数量。
这次重构还允许加快新团队成员的入职流程。由于每个代码组件都有其合适的位置,代码更容易理解和掌握。如果你正在寻找为你的应用程序贡献者,这是一个非常重要的点需要记住!
摘要
在本章中,我们重构了Bookmarkr使其更加模块化。现在每个命令都有其自己的代码文件进行描述。
通过花时间重构我们的命令行应用程序,我们极大地提高了其可读性、可维护性、可测试性和可扩展性。现在添加新功能,例如新的命令(当然)以及现有命令的新特性,都变得更加容易。
说到这个,在下一章中,我们将看到如何调用外部服务和 API 来扩展我们应用程序的功能。
轮到你了!
通过跟随提供的代码进行实践是一种很好的学习方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下特性来改进Bookmarkr应用程序。
任务#1 – 重构剩余的命令
尽管本章只提出了一个挑战,但它将需要你付出努力!
在本章的每一页中,我们都重构了export命令。现在,你被要求重构Bookmarkr应用程序的其他命令。
关于这一点,你可以遵循我们在之前的重构活动中使用的相同策略和步骤。通过反复练习,你将掌握这一过程。
你可以在Program.Unrefactored.cs文件中找到尚未重构的代码版本。
让我们开始吧!
第九章:与外部 API 和服务一起工作
尽管一个自主的应用程序可以为用户提供很大的价值,但通过将应用程序的功能与其他应用程序集成,消费外部 API 和服务可以使它为这些用户提供更大的价值!
然而,消费外部 API 和服务为您的应用程序创建了新的依赖。虽然这听起来可能非常合理,但您必须知道如何与这些依赖项交互以及如何将它们集成到您的应用程序中,这样您就不会使应用程序过于紧密地耦合到外部依赖,并避免每次依赖项更改时都需要更改应用程序的代码。
更具体地说,在本章中,我们将讨论以下内容:
-
消费外部 API 的好处
-
通过消费外部 API 扩展 Bookmarkr 的功能
-
在 .NET 中正确消费外部 API 的方法
-
如何避免应用程序的命令与外部 API 之间的紧密耦合
技术要求
本章的代码可以在本书配套的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter09
为什么要消费外部 API?
在构建您的应用程序时,您必须考虑多个因素,有时还需要实现超出您专业领域的多个功能。
这是否意味着如果您不掌握每个功能就不应该构建应用程序?不!许多应用程序依赖于由其他人在非常具体的领域内更熟练和更有经验的人开发的代码。这些代码片段被打包成 API 和服务,这样我们就可以使用(即消费)它们,而无需理解它们包含的每一行代码。
当我们在 Bookmarkr 中添加日志记录时,我们已经遇到了这种情况。我们没有自己开发日志记录引擎。相反,我们依赖于一个由知道如何做(并且做得很好!)的组织提供的服务。通过依赖该服务,我们的应用程序能够从日志记录功能中受益,而无需成为日志记录业务领域的专家。
现在,我可以听到你的想法(是的,我可以——这是我的第六感 😊)。你认为开发自己的日志记录引擎似乎并不复杂,你可能是对的。这是一个商业决策:如果它是您核心业务的一部分,那么是的,投入时间、资源和金钱来开发、测试和维护自己的日志记录引擎是有意义的。但记住,虽然开发它可能很酷,但您将不得不维护它,这正是许多组织长期受苦的原因!你知道他们说什么……你建了它,你就得运行它! 😉
还要注意,构建自己的“依赖项”(即,不属于你核心业务的服务)并不总是容易。一个例子就是支付网关。在构建和提供此类服务时涉及大量的法规。如果你的核心业务不是(换句话说,如果你不是 Stripe 或此类公司),不要这样做!使用现有的服务。
通过消费外部 API 和服务,我们就可以专注于我们最擅长的事情,在我们的案例中,那就是管理书签!这就是消费外部 API 和服务的关键:拥有专注于我们核心业务的能力,并将其他关注点委托给那些关注点是他们的核心业务的人。
如何消费外部 API
.NET 提供了一种通过抽象构建我们自己的 HTTP 请求、处理底层网络细节、发送请求、接收响应(同时执行序列化和反序列化以及处理通信问题)的需求来与外部 API 和服务交互的方法。
因此,为了与这些外部 API 和服务交互,.NET 为我们提供了HttpClient类。然而,处理这个类的正确方式是通过IHttpClientFactory接口。这允许我们创建和管理HttpClient实例,以实现最佳性能和资源管理。
使用 IHttpClientFactory 的优点
使用 IHttpClientFactory 提供了几个优点:
-
HttpMessageHandler实例,有助于防止诸如套接字耗尽等问题 -
连接重用: 它重用底层的 HTTP 连接,提高性能
-
弹性: 它增加了对瞬时错误的弹性
-
HttpClient实例
Bookmarkr: 你的书签,无处不在!
到目前为止,Bookmarkr 一直在本地管理我们的书签。这意味着我们被绑定到我们计算机的物理边界。
但如果我们想从另一台计算机访问这些书签怎么办?
要实现这一点,我们需要扩展 Bookmarkr 的功能,使其超越本地计算机。为此,我们将使 Bookmarkr 调用一个外部 API,该 API 将负责存储和检索我们的书签。
关于这一点,我们将添加一个名为sync的新命令,该命令将负责同步本地书签与外部服务存储的书签。
关于外部服务
当你消费外部服务时,你不需要了解其内部(即,其架构、技术堆栈、应用程序代码和依赖项)。这符合面向对象编程的封装原则。
你需要知道的就是如何向它发送请求以及如何解释它返回的响应。
然而,既然我知道你很想知道更多关于它的细节,我已经在 GitHub 仓库的appendixA-bookmarkr-syncr分支中提供了其架构和应用程序及基础设施代码的详细信息。
让我们从添加新命令开始!
同步命令
按照我们在上一章中设计的项目结构,让我们在 Commands 文件夹下添加一个名为 Sync 的新文件夹,并在该文件夹内添加一个名为 SyncCommand.cs 的新代码文件。
此命令的启动代码如下:
public class SyncCommand : Command
{
#region Properties
private readonly IBookmarkService _service;
#endregion
#region Constructor
public SyncCommand(IBookmarkService service, string name, string?
description = null)
: base(name, description)
{
_service = service;
this.SetHandler(OnSyncCommand);
}
#endregion
#region Options
#endregion
#region Handler method
private async Task OnSyncCommand()
{
}
#endregion
}
这段代码相当直接,无需解释。
同步过程包括以下步骤:
-
本地书签通过 Bookmarkr 的
sync命令发送到外部服务(称为 BookmarkrSyncr)。 -
BookmarkrSyncr 将执行从 Bookmarkr 收到的本地书签与其数据存储中的书签之间的同步。
-
BookmarkrSyncr 将同步后的书签发送回 Bookmarkr
sync命令的处理方法。 -
sync命令的处理方法将接收到的书签存储在本地数据存储中。请注意,如果应用程序正在处理大型数据集或速率限制 API,则需要批处理和重试技术。
因此,sync 命令需要引用 IHttpClientFactory。让我们添加这个:
#region Properties
private readonly IBookmarkService _service;
private readonly IHttpClientFactory _clientFactory;
#endregion
#region Constructor
public SyncCommand(IHttpClientFactory clientFactory,
IBookmarkService service, string name, string? description = null)
: base(name, description)
{
_service = service;
_clientFactory = clientFactory;
this.SetHandler(OnSyncCommand);
}
#endregion
在这段代码中,我们添加了一个类型为 IHttpClientFactory 的 private 属性,并通过构造函数进行注入。
然后,我们在命令的方法处理程序中使用它:
#region Handler method
private async Task OnSyncCommand()
{
var retrievedBookmarks = _service.GetAll();
var serializedRetrievedBookmarks = JsonSerializer.
Serialize(retrievedBookmarks);
var content = new StringContent(serializedRetrievedBookmarks,
Encoding.UTF8, "application/json");
var client = _clientFactory.CreateClient("bookmarkrSyncr");
var response = await client.PostAsync(«sync», content);
if (response.IsSuccessStatusCode)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var mergedBookmarks = await JsonSerializer.
DeserializeAsync<List<Bookmark>>(
await response.Content.ReadAsStreamAsync(),
options
);
_service.ClearAll();
_service.Import(mergedBookmarks!);
Log.Information("Successfully synced bookmarks");
}
else
{
switch(response.StatusCode)
{
case HttpStatusCode.NotFound:
Log.Error("Resource not found"); break;
case HttpStatusCode.Unauthorized:
Log.Error("Unauthorized access"); break;
default:
var error = await response.Content.
ReadAsStringAsync();
Log.Error($"Failed to sync bookmarks | {error}");
break;
}
}
}
#endregion
这段代码很容易理解,并符合我们之前描述的同步过程。
然而,代码中有一个部分需要解释:
-
我们通过依赖命名客户端方法从
IHttpClientFactory实例创建一个 HTTP 客户端。正如你所见,我们向CreateClient方法提供了客户端配置的名称(在这里,bookmarkrSyncr)。我们稍后会回到这个配置。 -
接下来,我们向远程 Web 服务的
sync端点发出一个POST请求,传递之前使用StringContent类实例序列化为本地的书签列表:-
如果请求成功,我们将反序列化返回的书签列表(表示同步的本地和远程书签列表),并用这个新列表替换本地书签列表
-
如果请求不成功,我们将显示一个与返回的 HTTP 状态码相对应的错误消息
-
为了导入 IHttpClientFactory 接口,我们需要引用 Microsoft.Extensions.Http NuGet 包。正如我们之前所知道的,我们可以通过输入以下命令来完成:
dotnet add package Microsoft.Extensions.Http
在我们可以使用我们的新命令之前,让我们在 Program 类中注册它!
注册同步命令
让我们在 Program 类中注册 sync 命令。这只需要一行代码:
rootCommand.AddCommand(new SyncCommand(_clientFactory, _service, "sync", "sync local and remote bookmark stores"));
但是等等!_clientFactory 变量是从哪里来的?!
很好!你发现了!😊
如你所猜,这是对需要配置以实现魔法的 HttpClient 的引用。这就是我们将讨论之前提到的命名客户端方法的地方。
_clientFactory 变量是 IHttpClientFactory 类型。因此,我们首先需要在 Program 类的 Main 方法中声明它:
IHttpClientFactory _clientFactory;
这将允许我们稍后检索其引用,并在注册期间将其传递给 SyncCommand 的构造函数(正如我们之前看到的)。我们可以这样检索那个引用:
_clientFactory = host.Services.GetRequiredService<IHttpClientFactory>();
最后,让我们为 BookmarkrSyncr 服务注册 HTTP 客户端。我们是在 ConfigureServices 块中这样做的:
services.AddHttpClient("bookmarkrSyncr", client =>
{
client.BaseAddress = new Uri("https://bookmarkrsyncr-api.
azurewebsites.net");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Add("User-Agent", "Bookmarkr");
});
让我们解释一下这段代码的作用:
-
为注册的 HTTP 客户端提供了一个名称(
bookmarkrSyncr)。这就是我们为什么称这种方法为“named clients”。请注意,这与我们在前面看到的SyncCommand类中传递给CreateClient方法的名称相同。这就是如何选择适当的 HTTP 客户端的方式。 -
我们指定了服务的基准地址和一些请求头。请注意,基准地址没有指定
sync端点。它在执行请求时指定。这允许一个网络服务有不同的端点,并且可以在需要时调用这些端点,而无需反复指定基准地址。
关于基本地址
你可能已经注意到基本地址指向一个外部 URL。我在 Azure 的 App Service 上部署了 BookmarkrSyncr 服务的代码。
我会尽可能地保持这个服务运行,但请记住,如果你需要重新部署它,你可以在 appendixA-bookmarkr-syncr 分支中访问其基础设施和应用代码。
现在一切都已经设置好了。我们可以运行程序并看看会发生什么。
运行程序
要运行程序,我们只需执行这个命令:
dotnet run sync
结果将如下:

图 9.1 – 同步命令在行动
太棒了,不是吗?
如果我们列出所有可用的本地书签,我们会注意到它们确实已经与远程书签列表同步了。
关于安全性?
你当然可能已经注意到,可以不进行任何身份验证就使用该网络服务。换句话说,允许匿名请求,这可能会引起安全问题。
你完全正确,目前这是故意的,因为安全性将在第十三章中解决,我们将看到如何使用称为“个人访问令牌”的技术来验证用户,这类似于使用 API 密钥。
代码运行得很好,但实际上有一个缺点。
减少我们的应用程序与外部依赖项之间的耦合
在前面的章节中,尽管我们应用了消费外部 API 的最佳实践,但我们还是在我们的应用程序和那个依赖项之间创建了一种耦合…
注意,我们的应用程序实际上知道 API 返回的数据类型和结构。这意味着每当这个 API 发生变化时,我们都需要相应地更新我们的代码。
这也意味着我们的应用程序负责处理 API 可能返回的不同 HTTP 状态码。我们能否将这种复杂性抽象到某个地方,以便最终更改仅限于我们代码的一小部分?
当然我们可以!而且有一个模式,这被称为 服务代理。
关于服务代理模式
服务代理模式将 HTTP 通信的细节抽象到一个专门的服务中,允许其他服务(或在我们的情况下,命令)与外部系统交互,而无需直接处理 HTTP 请求和响应。
服务代理模式有许多好处,其中以下是一些:
-
抽象:它抽象了 HTTP 通信的复杂性,包括构建 HTTP 请求、处理响应和管理错误
-
封装:它封装了与特定外部服务或 API 通信相关的所有逻辑
-
可重用性:服务代理可以在应用程序的多个组件或服务中被重用
-
(
sync命令)从通信逻辑(在服务代理中) -
可维护性:对外部 API 或通信协议的更改只需要在服务代理的一个地方进行
我认为现在对你来说已经很清楚,我们的 CLI 应用程序可以从利用服务代理模式中受益良多。现在让我们看看我们如何实现它!
实现服务代理模式
这个模式通常使用 IHttpClientFactory 和命名或类型化的 HttpClient 实例来实现。
我们已经在使用这些工具,因此对于我们来说,将 HTTP 细节从 sync 命令抽象出来,并放入一个专门的服务代理类中将会非常直接。
我们将要执行的第一步是为服务代理创建一个文件夹结构。遵循我们在上一章中概述的项目结构,让我们创建一个名为 ServiceAgents 的文件夹和一个名为 BookmarkrSyncrServiceAgent 的子文件夹。
在这个子文件夹中,让我们创建两个代码工具:一个名为 IBookmarkrSyncrServiceAgent.cs 的接口文件和一个名为 BookmarkrSyncrServiceAgent.cs 的类文件。
下面是 IBookmarkrSyncrServiceAgent 接口的代码:
namespace bookmarkr.ServiceAgents;
public interface IBookmarkrSyncrServiceAgent
{
Task<List<Bookmark>> SyncBookmarks(List<Bookmark> localBookmarks);
}
此接口仅公开一个操作,SyncBookmarks,它接受本地书签列表(由 Bookmarkr CLI 应用程序持有)并返回同步后的书签列表,包括来自远程 Web 服务 BookmarkrSyncr 的书签。
现在让我们实现这个接口:
namespace bookmarkr.ServiceAgents;
public class BookmarkrSyncrServiceAgent : IBookmarkrSyncrServiceAgent
{
private readonly IHttpClientFactory _clientFactory;
public BookmarkrSyncrServiceAgent(IHttpClientFactory
clientFactory)
{
_clientFactory = clientFactory;
}
public async Task<List<Bookmark>> Sync(List<Bookmark> localBookmarks)
{
var serializedRetrievedBookmarks = JsonSerializer.
Serialize(localBookmarks);
var content = new StringContent(serializedRetrievedBookmarks,
Encoding.UTF8, "application/json");
var client = _clientFactory.CreateClient("bookmarkrSyncr");
var response = await client.PostAsync(«sync», content);
if (response.IsSuccessStatusCode)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var mergedBookmarks = await JsonSerializer.
DeserializeAsync<List<Bookmark>>(
await response.Content.ReadAsStreamAsync(),
options
);
return mergedBookmarks!;
}
else
{
switch(response.StatusCode)
{
case HttpStatusCode.NotFound:
throw new HttpRequestException($"Resource not
found: {response.StatusCode}");
case HttpStatusCode.Unauthorized:
throw new HttpRequestException($"Unauthorized
access: {response.StatusCode}");
default:
var error = await response.Content.
ReadAsStringAsync();
throw new HttpRequestException($"Failed to sync
bookmarks: {response.StatusCode} | {error}");
}
}
}
}
正如你可能已经注意到的,这个实现正在重用位于 Sync 命令处理方法主体中的代码,因此将其从该方法中抽象出来,并将其封装到服务代理类中。
因此,这个类的代码不需要太多的解释。然而,值得一提的是,在请求失败的情况下,我们返回一个包含问题详细信息的 HttpRequestException 实例。
接下来,我们需要更新SyncCommand类的代码,以抽象化IHttpClientFactory的使用,并使用我们新的服务代理。更新的代码如下:
public class SyncCommand : Command
{
#region Properties
private readonly IBookmarkService _service;
private readonly IBookmarkrSyncrServiceAgent _serviceAgent;
#endregion
#region Constructor
public SyncCommand(IBookmarkrSyncrServiceAgent serviceAgent,
IBookmarkService service, string name, string? description = null)
: base(name, description)
{
_service = service;
_serviceAgent = serviceAgent;
this.SetHandler(OnSyncCommand);
}
#endregion
#region Options
#endregion
#region Handler method
private async Task OnSyncCommand()
{
var retrievedBookmarks = _service.GetAll();
try
{
var mergedBookmarks = await _serviceAgent.
Sync(retrievedBookmarks);
_service.ClearAll();
_service.Import(mergedBookmarks!);
Log.Information("Successfully synced bookmarks");
}
catch(HttpRequestException ex)
{
Log.Error(ex.Message);
}
}
#endregion
}
这段代码相当简单且易于阅读。我们基本上是用IBookmarkrSyncrServiceAgent的使用替换了IHttpClientFactory的使用,并通过调用服务代理的Sync方法,移除了OnSyncCommand方法中处理 HTTP 通信的所有代码(我们将其抽象到服务代理类中)。因此,OnSyncCommand方法也更加精简,从 41 行代码缩减到 16 行。
注意
为了您的参考,我们提供了一个在引入使用服务代理类之前的SyncCommand类的副本。通过这样做,您可以轻松地比较两种实现之间的差异。在Commands/Sync文件夹中查找名为SyncCommand_NoServiceAgent.txt的文件。
最后一步是将服务代理注册到Program类中的ConfigureServices部分的服务列表中。
正如我们之前看到的,这可以通过添加以下代码行轻松完成:
services.AddScoped<IBookmarkrSyncrServiceAgent, BookmarkrSyncrServiceAgent>();
我们需要记住声明一个服务代理的变量:
IBookmarkrSyncrServiceAgent _serviceAgent;
接下来,我们需要检索该服务代理的实例:
_serviceAgent = host.Services.GetRequiredService<IBookmarkrSyncrServiceAgent>();
我们将其传递给SyncCommand类的构造函数:
rootCommand.AddCommand(new SyncCommand(_serviceAgent, _service, "sync", "sync local and remote bookmark stores"));
一切现在都已就绪。让我们确保应用程序仍然按预期工作。
重新运行程序
我们可以像之前一样运行程序,通过输入以下命令:
dotnet run sync
我们将得到完全相同的结果,证明应用程序仍然按预期工作:

图 9.2 – 使用服务代理执行同步命令
太棒了!通过利用服务代理模式,我们已经能够提供业务逻辑和 HTTP 通信细节之间的清晰分离。因此,我们可以在任何其他命令(使用BookmarkrSyncrServiceAgent类)中消费BookmarkrSyncr网络服务,而无需这个命令处理 HTTP 通信细节。
摘要
在本章中,我们学习了如何通过消费外部 API 和服务来扩展 Bookmarkr 的功能。
我们探讨了与外部依赖项通信的最佳实践,处理响应数据、代码和错误,以及以不创建外部依赖项和我们的应用程序之间紧密耦合的方式设计该集成,这样如果证明这是必要的,就可以轻松替换该依赖项。
在下一章中,我们将介绍构建应用程序的关键方面之一,即测试这些应用程序。
轮到你了!
跟随提供的代码是一种很好的通过实践学习的方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 添加 SQLite 作为数据存储
谁说 API 是应用程序可以依赖的唯一外部依赖项?当然不是我了!😊
到目前为止,我们的应用程序已经将书签存储在内存中。你一定会同意我的观点,这不是一个理想的解决方案,因为一旦应用程序终止或重启,书签就会丢失。
你被要求向 Bookmarkr 应用程序添加一个新的依赖项——一个SQLite数据库!这将允许 Bookmarkr 以更持久的方式存储书签,使其对我们的用户更有用 😉。
为什么选择 SQLite?你可能想知道...
SQLite 是一个多才多艺且轻量级的数据库解决方案,旨在简单易用,同时需要最少的设置和管理。它最显著的优势之一是其可移植性:整个数据库存储在一个单独的文件中,这使得移动、备份和分发变得容易。其自包含的特性还意味着 SQLite 不需要单独的服务器进程或系统配置,简化了其部署。这就是为什么它非常适合 CLI 应用程序!
现在,你还需要修改BookmarkService的代码,以便从 SQLite 数据库中检索书签并将其存储在 SQLite 数据库中。
考虑使用.NET 的Microsoft.Data.Sqlite库,因为它是一个可靠且轻量级的库。考虑添加迁移并确保 SQLite 在并发 CLI 场景中的线程安全访问。
任务#2 – 根据 URL 检索网页名称
到目前为止,在添加新的书签时,我们必须传递网页名称和 URL。
既然我们已经知道如何处理外部依赖项,那么让我们调整link add命令,使其通过提供的 URL 发送 HTTP 请求以检索要书签的网页名称。如果无法检索名称,我们可以使用作为命令选项传递的名称。
如果找不到网页,书签的名称应该是未命名书签。如果请求超过 30 秒,则终止请求并将名称设置为未命名书签。
第四部分:测试和部署
在本部分中,你将探索测试、打包和部署 CLI 应用程序的关键方面。你将学习各种测试 CLI 工具的策略,包括对单个组件进行单元测试。接下来,你将深入了解如何打包你的 CLI 应用程序以进行分发,使用最常用的机制,如 Docker、.NET Tool 和 Winget。你将了解如何指定入口点、定义依赖项以及为最佳打包结构化你的项目。最后,你将探索部署方法,学习如何通过包管理器分发你的 CLI 工具并确保它在不同的环境中保持一致性。
本部分包含以下章节:
-
第十章**,测试 CLI 应用程序
-
第十一章**,打包和部署
第十章:测试命令行应用程序
测试是任何软件开发项目的重要阶段。测试的目的是确保我们交付给用户的应用程序按预期运行,不会对用户造成任何伤害(通过泄露个人信息或允许恶意行为者利用安全漏洞来伤害用户)。
在本章中,我们将讨论为什么测试如此重要,并探讨不同的测试技术和工具,这些技术和工具将帮助我们实现这一目标。具体来说,我们将讨论以下内容:
-
为什么测试很重要
-
不同的测试类型
-
要测试什么,如何进行测试,以及何时运行这些测试
-
在编写单元测试时如何模拟外部依赖
技术要求
本章的代码可以在本书配套的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter10。
为什么测试如此重要?
经过多年的实践,测试在为用户提供优秀的软件和数字体验方面已被证明非常有价值。任何认真对待软件开发项目、真正关心用户及其使用应用程序体验的开发者、团队或组织都将投资于软件测试。
测试使我们能够确保我们交付给用户的应用程序具有高质量、可靠性、性能和安全性。
在测试您的应用程序时,您可以期待以下关键好处:
-
确保您的应用程序质量、可用性和可靠性:测试确保应用程序按预期运行,满足利益相关者的需求,遵守业务需求和技术规范,并为用户提供价值。这有助于提高用户(和客户)的满意度,并防止设计或开发不良的应用程序对组织的声誉产生负面影响。例如,这可以通过验证 API 响应和验证 CLI 应用程序的输出格式来实现。
-
确保您的应用程序安全性和合规性:测试在验证应用程序安全性方面发挥着至关重要的作用。它有助于识别可能被恶意行为者利用的潜在漏洞和弱点。它还确保应用程序符合行业标准、法规以及其他组织(或行业)可能面临的关键要求。
-
作为您应用程序的文档:如果您曾经参与过 IT 项目,您就知道维护准确和最新的文档是多么困难。软件测试的一个副产品是它也充当了您应用程序的实时文档:您可以在任何时间运行它来了解应用程序的行为。
-
简化应用程序的演变:如果你曾经参与过 IT 项目,你就知道当你不得不修改一个运行良好的应用程序的代码时是多么可怕。我们甚至为此有一个说法:“如果它没有坏,就不要修它!”然而,通过拥有一个高效的测试套件,修改代码就不再那么可怕了,因为我们知道我们可以依赖这个测试套件来确保我们没有在现有代码和功能中引入任何错误(我们称之为回归)。
-
实现成本节约和效率:众所周知,在生产环境中找到并修复一个错误可能比在开发或测试阶段找到并修复它要贵 100 倍。这种成本还包括效率损失的成本,因为团队必须停止开发新功能,集中精力修复那个错误。
这不是一本关于测试的书!
在我的职业生涯中,我为我所工作的组织和我们的客户提供了开发和测试方面的指导和培训,以及提高对测试重要性的认识。这就是为什么我不能不涵盖这个主题就写一本关于开发的书籍。
然而,尽管我们将在这一章中提供非常有价值的信息和指导,但请记住,这不是一本关于软件测试的书。因此,测试驱动开发、行为驱动开发和代码覆盖率等概念将不会涉及。
话虽如此,如果你想要深入了解这个迷人的主题,我在第十四章中提供了许多参考资料。
正如你所见,有各种类型的测试。让我们突出它们!
测试类型
测试类型多种多样。它们可以分为两大类:
-
功能测试:这类测试验证应用程序是否按照其规格执行其预期功能。换句话说,它确保应用程序做它应该做的事情(它被设计来做什么)。
-
非功能测试:这类测试验证应用程序是否以符合用户期望和质量标准的方式执行其预期功能。
每个类别都由各种类型的测试组成。这个图展示了这种关系:

图 10.1 – 软件测试的类别和类型
让我们简要描述一下这些测试类型:
-
单元测试:在这里,我们专注于单独测试方法(即,不依赖于它们的依赖项,如数据库或外部服务。为了实现这一点,我们使用模拟技术(更多内容将在后面介绍)来避免依赖这些依赖项。这些测试通常很快,并提供即时反馈。因此,它们有助于确保代码的特定部分(方法)确实按预期执行。如果在测试中发现了错误,此类测试非常有用,因为它将问题缩小到导致错误的代码行!然而,重要的是不要过度使用模拟,因为这可能导致不反映现实的测试,使它们变得毫无价值。
-
集成测试:此类测试验证应用程序不同组件之间的交互,确保集成部分按预期协同工作。它还帮助确保这些组件之间的数据流和通信是正确的,并且可以暴露集成问题,通常是由于接口缺陷引起的。
-
系统测试:此类测试验证完整的应用程序是否符合指定的要求。这包括端到端的功能和行为,并依赖于外部依赖。这些测试在类似于生产环境的环境中执行(通常是预生产环境或测试环境)。
-
验收测试:此类测试验证应用程序是否符合用户需求和业务要求。主要区别在于,验收测试通常由用户或利益相关者执行,并在投入生产前作为最终批准。
我们刚刚描述了功能测试的类型。现在让我们描述非功能测试的类型:
-
安全测试:此类测试旨在揭示应用程序中的漏洞和安全漏洞,旨在保护用户免受数据泄露、未经授权的访问和一般网络攻击。
-
性能测试:此类测试旨在通过测量响应时间和资源使用情况,以及在各种工作负载下识别可扩展性或容量限制来识别性能问题和瓶颈。这为需要特别关注的应用程序部分提供了宝贵的见解,例如重新设计或重构,以满足性能要求和用户期望。
-
可用性测试:此类测试侧重于评估应用程序的用户友好性和易用性。这包括让真实用户通过完成任务来测试应用程序,在过程中收集反馈和指标(例如,用户体验总体情况、导航应用程序的难易程度、完成任务所需的时间以及用户的总体评价)。
-
交互式命令,这意味着它运行的终端符合要求。然后我们应该确保当这些要求得到满足时,它按预期工作,并在它们不满足时优雅地降级(例如,转换为基于文本的输出)。
关于可用性测试
如你所猜,可用性测试旨在手动执行。虽然不一定总是能够聚集真实用户来执行这些测试,但在 CLI 应用程序的上下文中实现一个命令,允许用户提供反馈,是一种实现这一目标的方法。
这里是一个例子,说明 Azure CLI 团队是如何做的:他们提供了一个survey命令,将用户引导到一个在线表单,他们可以在那里提供反馈。

图 10.2 – 允许用户提供反馈
测试(软件)金字塔
许多人对功能测试和非功能测试的概念可能不太熟悉,但你们可能对测试金字塔很熟悉。值得一提的是,这个金字塔包括了我们在讨论中提到的许多功能测试和非功能测试类型。
作为提醒,测试金字塔看起来是这样的:

图 10.3 – 测试(软件)金字塔
将其呈现为金字塔的原因是为了说明每个步骤预期的测试数量。步骤越大,预期的测试就越多。例如,一个项目可能比系统测试有更多的单元测试,比 UAT 测试有更多的系统测试。这是由于创建和维护此类测试的成本。
需要记住的是,区分这些不同类型测试的是它们的范围和意图,而不是实现它们的框架或库。
还有一点很重要,那就是要记住,如果你需要的话,你可以创建自己的测试类型。让我给你举几个例子。
关于自定义测试类型
在我的职业生涯中,我与那些拥有适合其需求和政策的自定义测试类型的组织合作过。
例如,一些组织可能有架构测试,这些测试是为了确保应用程序(及其组件)按照其架构标准开发,例如哪些组件可以引用哪些组件,确保每个服务类都公开了一个接口,等等。
我还见过另一种类型的测试,可以称为命名约定测试。这些测试的目的是确保每个组件(如类、服务或库)都按照组织的命名约定和标准命名。
这两种类型的测试旨在简化代码审查过程,可以作为验证拉取请求的一部分进行自动化。
我们现在对各种测试类型有了更好的理解。下一个合乎逻辑的问题(每次我与客户讨论软件测试时都会遇到的问题)是:“我们应该测试什么?”让我们现在讨论这个问题。
我们应该测试什么?
这是一个很好的问题!
很容易说你应该测试应用程序中每个可能的场景。然而,我们需要考虑以下因素:
-
你如何定义“每个场景”?
-
你真的能测试“每个场景”吗?这意味着需要多少个测试?
简而言之,您的测试套件应该涵盖以下两个方面:
-
愉快的路径:这意味着测试在应用程序所需输入提供且格式正确的情况下的情况
-
不愉快的路径:在这里,我们测试应用程序在意外情况下的行为,例如输入格式错误、用户错误、网络问题(当依赖外部依赖项时)、用户取消任务等
再次强调,这不是一本关于软件测试的书,但我愿意给你一些在不同情况下应该测试什么的指导:
| 输入参数 | 测试有效和无效的值。例如,如果一个方法只接受一个整数参数,有效值的范围在 1 到 100 之间,我们也应该使用这个范围之外的值进行测试,例如-1、0和2000(我们通常称之为"bonjour"或1.23。 |
|---|---|
| 列表 | 在处理列表时,我们应该确保列表只包含预期的元素,不多也不少。仅检查列表中的元素数量是不够的,因为如果代码中的错误导致元素插入多次或插入不适当的元素,计数可能达到预期值,但列表可能不包含适当的元素。 |
| 异常 | 如果应用程序抛出异常,这也应该被测试以确保抛出了正确的异常类型,并且具有预期的详细信息。 |
| 方法/服务 返回值 | 验证数值或字符串值与预期值匹配很容易,但如果返回值是一个对象(或对象列表),我们应该验证所有有意义的属性值与预期值匹配。 |
| 方法/服务 行为 | 虽然大多数开发者在实现测试时验证方法或服务的返回值,但他们未能验证这些方法或服务是否按预期行为。验证方法或服务的行为意味着我们需要确保:
-
正确的后续方法以预期的参数被调用。这些可能是日志记录或缓存方法,或者是对数据库或外部 API 等外部依赖项的调用。
-
对象、类或服务内部的状态变化是准确且预期的。
-
诸如数据库更新或文件更改之类的副作用是准确且预期的。
-
当适当的时候会发生幂等性,这意味着如果同一个方法被多次调用,它将保持系统的连贯性。想象一下一个执行对支付网关或预订系统的调用的方法。我们当然不希望顾客为同一笔购买支付两次费用,也不希望为同一预约做出多次预订。
|
图 10.4 – 需要测试的内容
了解需要测试的内容和了解不需要测试的内容同样重要。
不需要测试的内容
你不应该测试外部框架和库,因为这属于它们的创建者和维护者的责任。很可能在你有机会使用它们之前,它们已经被测试过了。所以,请不要这样做!
其他不需要测试的代码组件是模型类和数据传输对象(DTOs),因为它们只应包含属性,而不是方法,因为它们不执行任何类型的处理,只是移动数据。
此外,你可能听说过不建议测试私有方法。关于我们是否应该测试私有方法,存在激烈的争论。我个人的观点是,你不应该测试,主要有两个原因:
-
私有方法是为了至少被一个公共方法调用:因此,当测试那个公共方法时,你也在测试私有方法。
-
Object来传递参数给它。因此,在运行时很容易破坏这些测试(尽管它们可以编译),因为方法的名字通常作为字符串传递,并且由于每个数据类型都继承自Object类,如果我们更改给定参数的数据类型或结构,它仍然会继承那个相同的基类,即使私有方法不再期望它了。
测试是一个安全网
正如我告诉我的客户和学生一样,测试套件就像一个安全网:
-
你覆盖的测试用例越多,安全网就越宽。如果你从 30 英尺的高度落下,而你的安全网只有 2 英寸乘 2 英寸,那么它可能不会有所帮助。
-
另一方面,如果你有一个 50 英尺乘 50 英尺的安全网,但它的网眼是 3 英尺乘 3 英尺大的,那么它也不会有所帮助。我的意思是,如果你在测试套件的数量上非常广泛,但这些测试没有覆盖有意义的场景,那么你的测试套件就没有任何用处。
因此,我们现在已经了解了不同类型的测试,也知道应该测试什么。但是,我们应该在什么时候运行这些测试呢?让我们来讨论这个问题。
我们应该在什么时候运行测试?
我们讨论的各种类型的测试旨在在开发生命周期的不同阶段运行。
一个组织(甚至是一个开发者或一个团队)可能有政策和偏好,但总的来说,以下建议被行业所采纳:
-
单元测试:这些测试旨在在开发期间运行。换句话说,开发者在编写代码时应运行它们。一些 IDE(如 Visual Studio Enterprise)甚至允许你配置单元测试在编写代码时在后台运行!这必须谨慎配置和使用,因为它可能会很快变得繁琐。然而,你必须在提交更改之前、在创建或更新拉取请求之前运行它们。单元测试通常也是 CI/CD 管道的一部分。因此,单元测试应该始终自动化,实际上自动化起来非常容易。
-
集成测试:这些测试应在单元测试通过(即成功运行)后运行。
-
冒烟测试:这些测试应在新构建部署后、QA 测试人员开始更广泛的测试之前运行。因此,这些测试通常由 CI/CD 管道在部署操作完成后立即触发。
-
系统测试:这些测试在发布应用程序到生产环境之前,在预发布或预生产环境中运行。这些测试应在集成测试通过后触发。
-
回归测试:这些测试至少应在提交更改之前运行。它们也应作为 CI/CD 管道的一部分。
-
性能测试:当添加新功能(尤其是处理外部依赖项的功能)或进行任何重大代码更改时,应该运行这些测试。
-
用户验收测试(UAT):这些特殊测试由用户(或其代表,如利益相关者)执行,目的是在将应用程序发布到生产之前获得最终批准。因此,这些测试通常是手动的。
如您可能已经注意到的,我们并没有涵盖所有可能的测试类型。我们只关注了软件测试金字塔中的那些测试。
好的。现在我们了解了软件测试的重要性,也知道要测试什么,让我们将测试实现到我们的 CLI 应用程序中。
将测试项目添加到 Bookmarkr
为了为我们的 CLI 应用程序添加测试项目,我需要对项目结构进行一些微调,即从项目目录中提取解决方案文件(.sln),并编辑它以更新 .csproj 文件的路径。这允许我们创建测试项目并将其添加到解决方案中。
接下来,让我们输入以下命令来创建测试项目:
dotnet new mstest -n bookmarkr.UnitTests
这将创建一个新的目录,命名为 bookmarkr.UnitTests,其中将存放测试项目的所有内容。
目前,这个目录只包含两个文件:
-
Bookmarkr.UnitTests.csproj,它描述了项目、其配置及其依赖项 -
UnitTest1.cs,它作为一个示例测试类
值得注意的是,.csproj 文件已经引用了一些测试库和框架,特别是 MS Test 测试框架,这是我们将在本章中使用的框架。
关于测试框架
虽然有许多测试框架,其中最常见的是NUnit、xUnit和MS Test,但我们决定使用后者,原因有很多:
-
MS Test 是微软的测试框架,广为人知且广泛使用
-
MS Test 在多年中不断发展,具有丰富的功能集,例如内置对并行代码测试、数据驱动测试和测试分组功能的支持
话虽如此,无论你喜欢的测试框架是什么,概念都是相似的,本章中涵盖的内容也适用。主要区别将在于每个测试框架提供的关键字。
同样有趣的是,测试项目被定义为不可打包的。这意味着该项目不会作为应用程序的一部分进行打包和分发,这在逻辑上是完全合理的。
但在我们继续之前,让我们使用以下命令将测试项目添加到解决方案中(该命令必须在.sln文件相同的目录下运行):
dotnet sln add bookmarkr.UnitTests/bookmarkr.UnitTests.csproj
现在,如果我们打开 Visual Studio 中的解决方案,它将包含代码和测试项目。
下一步是让我们的测试项目引用实际项目。这是必要的,这样我们才能测试实际代码。所以,让我们导航到测试项目的目录并输入以下命令:
dotnet add reference ../bookmarkr/bookmarkr.csproj
在我们开始实现单元测试之前,我们还需要采取最后一步,即定义我们的测试项目结构。
测试项目的结构
每个开发者、团队或组织在结构化他们的测试项目时都会有自己的偏好。在本节中,我将向你介绍我在职业生涯中实施并认为有价值的测试项目结构化方法。
首先,如果你的应用程序由多个组件组成,并且每个组件都有自己的 Visual Studio 项目,你将希望为每个组件创建一个单独的单元测试项目,同时保留一个单独的集成测试项目。这种结构可能类似于以下内容:

图 10.5 – 测试项目的结构
在本章中,我们将仅关注单元测试,但同样的原则也适用于集成测试。这就是为什么我们只有一个bookmarkr.UnitTests项目的原因。
然而,如果你在解决方案中创建了多个测试项目,请记住始终在所有项目中使用相同的测试框架版本,以防止由于兼容性问题而产生的副作用。
现在,让我们来构建我们的单元测试项目。在这里,同样,根据个人选择和团队/组织的政策,有无限的可能性。我的方法是为每个代码类创建一个测试类。测试类的名称将与代码类相同,并在后面加上Tests后缀。我还喜欢在测试项目中复制与代码项目相同的文件夹结构,因为我发现由于两个项目之间的结构对等性,这使得在测试项目中导航更容易。
应用这些原则后,我们的测试项目结构如下:

图 10.6 – 测试项目的结构
现在我们已经设置了测试项目的结构,我们可以开始实现我们的单元测试。但是等等!是否有不应该被测试的代码工件?是的,确实有!
不应该被测试的代码工件
以下这些工件不需要进行测试,因为它们不执行任何处理:
-
Bookmark.cs和BookmarkConflictModel.cs,因为它们只是模型类,因此只用于在数据之间移动。 -
Program.cs:这个类的作用是配置 CLI 应用程序,配置日志,识别哪个命令是根命令,并构建命令层次结构。 -
Helper.cs:这个辅助类的方 法用于使用不同的颜色和格式来格式化文本输出。因此,这个类更适合 UI 测试而不是单元测试。因此,它被排除在单元测试之外。然而,它可以作为手动测试或端到端测试的一部分进行测试。
请记住,尽管我们出于有效的原因决定不测试这些代码工件,但 MS Test 会在测试结果中告诉我们这些工件没有被测试。
我们可以使用[ExcludeFromCodeCoverage]属性通知 MS Test 我们不打算测试这些工件。这个属性非常灵活:它可以应用于属性、方法、类,甚至程序集级别。此属性还允许我们传递一个字符串来解释我们的决定。
例如,我们将如何将其应用于Program类:
using System.Diagnostics.CodeAnalysis;
//…
[ExcludeFromCodeCoverage(Justification="CLI application configuration. No processing is performed in this class.")]
class Program
{
// …
}
我们终于准备好开始实现一些测试了。让我们深入进去!
编写有效的测试
我们将通过为link和import命令编写测试来学习如何实现测试。
我们需要做的第一件事是为每个命令添加一个测试类。我们已经有了文件夹结构,所以让我们添加测试类。正如我之前提到的,我发现将测试类命名为实际类名后加上后缀Tests很有用。因此,我们的测试类将被命名为LinkCommandTests和ImportCommandTests。
现在,让我向您介绍结构化测试类及其测试方法的最佳实践(是的,我们还将再次讨论结构!😊):
-
使用 MS Test,一个测试类会被
[TestClass]属性装饰。如果您不提供此属性,则该类将不会被考虑为测试类,它包含的测试方法也不会被执行。 -
测试类通常由多个测试方法组成。测试方法的名字应该传达其意图。这很重要,因为测试报告只会展示方法的名称以及一个图标来指示这个测试方法的测试结果(通过、失败、跳过等)。通常的做法是,测试方法的名称由其名称、输入参数的值和预期结果组成。这样的名称示例有
GetEmployeeById_ValidId_ReturnsTheExpectedEmployeeObject和GetEmployeeById_InvalidId_ThrowsEmployeeNotFoundException。 -
使用 MS Test,测试方法用
[TestMethod]属性装饰。如果你不提供这个属性,类将不会被考虑为测试方法,并且不会被运行。 -
测试方法应该只测试一个结果(无论是结果还是行为)。这很重要,因为我们需要能够知道,如果测试方法失败,那是因为它没有达到预期的结果(结果或行为)。然而,为了达到这个目的,一个测试方法可能包含多个断言,只要这些多个断言服务于验证一个结果的目的。
-
为了最大化清晰度和可读性,建议将测试方法的主体分成三个部分(也称为 3A):
-
准备:创建并初始化执行测试所需的所有对象。
-
执行:调用要测试的代码工件并收集结果。
-
断言:将获得的结果(通常称为“实际”结果)与预期结果进行比较。如果两者匹配,则测试被认为是成功的。然而,如果两者不匹配,则测试被认为是失败的。
-
拥有这些新知识,我们拥有了编写第一个测试所需的一切。让我们从编写link命令的测试开始。
查看LinkCommand类的代码,我们发现它没有任何方法。然而,我们看到它的构造函数调用了AddCommand方法来设置LinkAddCommand作为Linkcommand的子命令(这就是为什么add作为link命令的子命令出现)。
在这个情况下,我们的测试方法将不会验证结果,而是验证行为。在这种情况下,我们想要验证LinkAddCommand确实是LinkCommand的一个子命令。
下面是这个测试方法的代码:
[TestMethod]
public void LinkCommand_CallingClassConstuctor_EnsuresThatLinkAddCommandIsTheOnlySubCommandOfLinkCommand()
{
// Arrange
IBookmarkService service = null;
var expectedSubCommand = new LinkAddCommand(service, "add", "Add a
new bookmark link");
// Act
var actualCommand = new LinkCommand(service, "link", "Manage
bookmarks links");
var actualSubCommand = actualCommand.Subcommands[0];
// Assert
Assert.AreEqual(1, actualCommand.Subcommands.Count);
CollectionAssert.AreEqual(actualSubCommand.Aliases.ToList(),
expectedSubCommand.Aliases.ToList());
Assert.AreEqual(actualSubCommand.Description, expectedSubCommand.
Description);
}
虽然这段代码是自我解释的,并且容易理解,但我想要指出几个关键点:
-
注意应用到测试方法名称上的命名约定。它清楚地表明了其意图:我们正在测试
LinkCommand类,并且我们的测试包括调用类构造函数并确保LinkAddCommand是其唯一的子命令。 -
注意,我们应用了 3A 原则来构建测试方法的主体。
-
注意,我们执行了三个断言来验证预期的行为。此外,请注意
CollectionAssert的使用,它有助于断言集合及其项。当处理列表和元素集合时,它比使用Assert方便得多。这是我为你保留的小秘密,因为许多开发者可能不知道它或者不自然地倾向于使用它 😉。
我们现在准备好运行我们的测试了。让我们看看我们如何做到这一点。
运行我们的测试
.NET CLI 为此提供了一个命令:
dotnet test
此命令将编译代码和测试项目,发现测试类和测试方法,执行测试,并返回结果。
Visual Studio Code 还提供了一个用于列出和执行测试的图形用户界面(GUI)。可以通过点击相应的图标来显示此面板,如下面的图所示:

图 10.7 – 运行测试
此 GUI 还提供了对测试方法状态的视觉识别。在前面的屏幕截图中,我们可以看到我们的测试方法已经成功完成。
从这个图形用户界面(GUI)中,我们也可以调试测试!这是一个非常棒的功能,它通过策略性地应用断点并在调试模式下重新执行,帮助我们理解为什么测试失败。
太棒了!我们可以实现更多的测试。
但等等!
你有没有注意到我们传递了一个null实例的BookmarkrService作为参数?这没关系,因为我们正在进行的测试不依赖于该参数。但如果我们确实(就像我们在测试import命令时那样)需要它,我们希望为它提供一个实例。
我们显然不想使用该服务的实际实例,因为它是该命令的依赖项,它也可能依赖于外部依赖项,例如存储书签的数据库。
然后,我们需要提供它的一个假表示。这就是模拟(mocks)发挥作用的地方!
模拟外部依赖项
模拟(Mocking)在模拟依赖项的行为而不实际依赖它们时非常有用。这很强大,因为它允许我们在隔离其环境的情况下测试我们的应用程序。我们想要这样做的原因是,要确保应用程序的代码在依赖项的状态无关的情况下也能正确工作。
模拟的作用
让我通过一个例子来澄清这一点。假设你有一个将书签存储到数据库中的方法。你编写了一个测试方法来验证这一点,但它失败了。你再次运行它,它通过了。你能否在不调查的情况下判断这是由于瞬时的数据库连接问题还是由于代码中的错误?你不能!但如果从等式中移除依赖项(即数据库)并且出现了相同的行为,你就可以(有很高的信心)判断这是由于代码中的错误。
值得注意的是,我们通常编写将外部依赖排除在外的测试(即单元测试)和考虑这些外部依赖的测试(如集成或系统测试)。因此,当我们面临此类问题时,我们可以查看单元和集成或系统测试,以确定问题是由依赖(如通信问题)还是由代码本身引起的。
如何模拟外部依赖
如前所述,我们可以编写自己的模拟实现来模拟真实依赖的行为,但这种方法的主要缺点是,如果真实依赖的行为发生变化,我们必须维护(并可能重写)这些实现。
依靠一个会为我们执行此任务的模拟框架更明智。我们需要做的是提供一个接口给那个依赖,模拟框架将在运行时创建其假表示。我们还可以向模拟框架传递指令,以根据我们需要执行的测试以某种方式配置假依赖的行为。例如,我们可以指示模拟框架在调用具有某些参数值的依赖时模拟特定的错误或异常,以验证在这些情况下应用程序的行为。
关于模拟框架
正如有许多测试框架一样,也有许多模拟框架供您选择。其中最常用的一种,也是我个人最喜欢的,是 NSubstitute。我喜欢它是因为它既强大又易于学习和使用。
您可以通过访问其网站了解更多关于 NSubstitute 的信息:nsubstitute.github.io/help/getting-started/。
让我们使用 NSubstitute 模拟 BookmarkService 服务。
模拟 BookmarkService 服务
我们需要做的第一件事是将 NSubstitute 添加到测试项目中。我们可以通过导航到测试项目的目录并输入以下命令来实现这一点:
dotnet add package NSubstitute
现在,让我们通过模拟 BookmarkService 服务来更新 LinkCommandTests 测试类。更新的代码如下:
using NSubstitute;
…
namespace bookmarkr.Tests;
[TestClass]
public class LinkCommandTests
{
[TestMethod]
public void LinkCommand_CallingClassConstuctor_
EnsuresThatLinkAddCommandIsTheOnlySubCommandOfLinkCommand()
{
// Arrange
IBookmarkService service = Substitute.For<IBookmarkService>();
…
}
}
正如你所见,我们在这里并没有做太多。我们只是添加了一个 using 语句用于 NSubstitute,并且我们没有将服务初始化为 null 值,而是要求 NSubstitute 根据其接口(被称为 mock)提供对其的模拟。结果是基于 IBookmarkService 接口结构构建的临时内存对象,我们可以根据所进行的测试配置其行为。
如果我们现在再次执行测试,它仍然通过。由于我们在测试期间没有调用服务,我们不需要配置其行为。但我们将需要为即将实施的测试方法做这件事。
使用 BookmarkService 服务的模拟版本
让我们先创建一个用于 import 命令的测试类。我们首先在 Commands\Import 目录中创建 ImportCommandTests.cs 文件。此文件将包含所有与 import 命令相关的测试方法。
接下来,我们概述测试类的基结构,如下所示:
using bookmarkr.Commands;
using bookmarkr.Services;
using NSubstitute;
namespace bookmarkr.Tests;
[TestClass]
public class ImportCommandTests
{
}
此命令没有子命令,因此我们不需要测试这一点,或者我们可以编写一个测试来验证这一事实。如果你想这样做,你可以遵循 LinkCommandTests 类中的相同程序。
在我们开始实现测试之前,我们需要确定我们想要执行的测试用例。在此期间,请反思这一点,但以下是一个非详尽的测试用例列表:
-
OnImportCommand处理方法将调用BookmarkService的Import方法。 -
测试用例 #2:如果文件名无效,应返回一个错误消息(例如,指示文件名中存在非法字符)。
-
测试用例 #3:如果文件未找到,应返回一个错误消息,指示文件不存在。
-
测试用例 #4:在导入书签时,如果没有检测到冲突,导入的书签应出现在本地书签集合中。
-
也会调用
Log方法。
对于本章的目的,我们只实现测试用例 1 和 5。其余的测试用例留给你作为挑战。
然而,在我们能够实现这些测试用例之前,需要对应用程序的代码进行一些修改。
必须对代码进行修改!
这里是思路:有时,你需要对代码进行修改,以便代码可以被测试。这是可以接受的,因为 .NET 框架中的一些类天生不可测试。
在我们的情况下,这是 FileInfo 类的情况,它是一个密封类,不公开任何接口,因此不能被重写或模拟。
幸运的是,有一个库允许我们绕过这个限制。然后我们需要将以下 NuGet 包添加到应用程序和测试项目中:
dotnet add package System.IO.Abstractions
对于测试项目,我们还需要添加这个 NuGet 包,这将有助于测试:
dotnet add package System.IO.Abstractions.TestingHelpers
我们还需要对 ImportCommand 类进行以下修改:
-
我们将添加一个类型为
IFileSystem的私有属性。 -
我们将在默认构造函数中将此属性初始化为
FileSystem类的实例。 -
我们将添加一个第二个构造函数,它将仅用于测试。此构造函数将接受一个额外的参数,类型为
IFileSystem。 -
最后,我们将添加一个
OnImportCommand处理方法的重载版本,它接受一个IFileInfo参数,其唯一目的是调用原始版本的OnImportCommand方法,并传递基于它接收到的IFileInfo对象的FileInfo实例。
再次调用 import 命令,我们发现它仍然按预期工作。
我们现在可以实施这些测试用例。
回到实现测试用例
让我们从测试用例 1 开始。以下是相关的测试方法:
[TestMethod]
public void OnImportCommand_PassingAValidAndExistingFile_CallsImportMethodOnBookmarkService()
{
// Arrange
var mockBookmarkService = Substitute.For<IBookmarkService>();
string bookmarksAsJson = @"[
{
""Name"": ""Packt Publishing"",
""Url"": ""https://packtpub.com/"",
""Category"": ""Tech Books""
},
{
""Name"": ""Audi cars"",
""Url"": ""https://audi.ca"",
""Category"": ""See later""
},
{
""Name"": ""LinkedIn"",
""Url"": ""https://www.linkedin.com/"",
""Category"": ""Social Media""
}
]";
var mockFileSystem = new MockFileSystem(new Dictionary<string,
MockFileData>
{
{@"bookmarks.json", new MockFileData(bookmarksAsJson)}
});
var command = new ImportCommand(mockBookmarkService,
mockFileSystem, "import", "Imports all bookmarks from a file");
// Act
command.OnImportCommand(mockFileSystem.FileInfo.New("bookmarks.
json"));
// Assert
mockBookmarkService.Received(3).Import(Arg.Any<Bookmark>());
mockBookmarkService.Received(1).Import(Arg.Is<Bookmark>(b =>
b.Name == "Packt Publishing" && b.Url == "https://packtpub.com/"
&& b.Category == "Tech Books"));
mockBookmarkService.Received(1).Import(Arg.Is<Bookmark>(b =>
b.Name
== "Audi cars" && b.Url == "https://audi.ca" && b.Category == "See
later"));
mockBookmarkService.Received(1).Import(Arg.Is<Bookmark>(b =>
b.Name == "LinkedIn" && b.Url == "https://www.linkedin.com/" &&
b.Category == "Social Media"));
}
这段代码值得解释,所以下面我们就来解释一下:
-
我们首先创建了一个
BookmarkService的模拟,就像之前的例子一样。 -
然后,我们为测试所需的三本书签创建 JSON 内容的字符串表示。
-
接下来,这也是我们为什么要对代码进行我们之前描述的更改的原因,我们创建了一个文件系统的模拟,并模拟了一个名为
bookmarks.json的文件的存在,该文件包含我们在上一步创建的 JSON 表示。 -
之后,我们使用我们添加的新构造函数创建
ImportCommand类的一个实例,这个构造函数允许我们传递模拟文件系统作为参数。 -
我们现在准备通过依赖模拟文件系统并传递我们之前模拟的
bookmarks.json文件名来调用OnImportCommand。这里需要注意的是,如果我们传递一个不在模拟中的文件名,测试将失败。 -
我们现在准备验证我们的断言是否正确。请仔细注意我们是如何做到的:我们首先确保调用
OnImportCommand方法触发了对BookmarkService的Import方法的三个调用(在这里,这些调用实际上是针对服务的模拟版本进行的,因为我们不希望激活服务,而只是想验证它是否按预期被调用)。然而,这还不足以验证测试是否成功,因为这些三个调用可能包括意外的调用。为了确保这些调用是合法的,我们逐一验证它们,确保它们的有意义属性与预期相符。
测试用例#1 就到这里。现在让我们继续到测试用例#5。
下面是这个测试用例的代码:
[TestMethod]
public void ImportCommand_Conflict_TheNameOfTheConflictingBookmarkIsUpdated()
{
// Arrange
var bookmarkService = new BookmarkService();
bookmarkService.ClearAll();
bookmarkService.AddLink("Audi Canada", "https://audi.ca", "See
later");
string bookmarksAsJson = @"[
{
""Name"": ""Packt Publishing"",
""Url"": ""https://packtpub.com/"",
""Category"": ""Tech Books""
},
{
""Name"": ""Audi cars"",
""Url"": ""https://audi.ca"",
""Category"": ""See later""
},
{
""Name"": ""LinkedIn"",
""Url"": ""https://www.linkedin.com/"",
""Category"": ""Social Media""
}
]";
var mockFileSystem = new MockFileSystem(new Dictionary<string,
MockFileData>
{
{@"bookmarks.json", new MockFileData(bookmarksAsJson)}
});
var command = new ImportCommand(bookmarkService, mockFileSystem,
"import", "Imports all bookmarks from a file");
// Act
command.OnImportCommand(mockFileSystem.FileInfo.New("bookmarks.
json"));
var currentBookmarks = bookmarkService.GetAll();
// Assert
Assert.AreEqual(3, currentBookmarks.Count);
Assert.IsTrue(currentBookmarks.Exists(b => b.Name == "Packt
Publishing" && b.Url == "https://packtpub.com/" && b.Category ==
"Tech Books"));
Assert.IsTrue(currentBookmarks.Exists(b => b.Name == "Audi cars"
&& b.Url == "https://audi.ca" && b.Category == "See later"));
Assert.IsTrue(currentBookmarks.Exists(b => b.Name == "LinkedIn"
&& b.Url == "https://www.linkedin.com/" && b.Category == "Social
Media"));
Assert.IsFalse(currentBookmarks.Exists(b => b.Name == "Audi
Canada" && b.Url == "https://audi.ca" && b.Category == "See
later"));
}
这个测试用例的代码与测试用例#1 的代码非常相似,有两个明显的不同点:
-
我们正在调用
BookmarkService的真实实现,而不是模拟。原因是我们要确保书签已经被正确导入,并且冲突的书签已经被相应地重命名。如果该服务依赖于数据库,我们可以模拟那个数据库。 -
最后一个断言,虽然不是必需的,但确保原始的冲突书签不再存在,因为它已经被更新了。
现在,如果你一直和我一起编码,你肯定已经注意到这段代码不起作用。事实上,它甚至无法编译!别担心,这是故意的 😊。目的是教你如何控制测试实体的可见性。
内部可见性
在 ImportCommand 类中,我们添加了一个第二个构造函数(它接受一个类型为 IFileSystem 的参数)以及 OnImportCommand 处理方法的重载。这两个方法都被标记为 internal,这仅仅意味着它们在当前项目的所有代码部分都是可见的,除非我们另外指定,否则它们在该项目之外是不可见的。
当你添加专门用于测试目的的工件时,这是推荐的方法。
internal 访问器非常有趣。它允许我们控制其可见性。在这种情况下,我们只想让测试项目看到这些内部代码工件。
为了做到这一点,我们需要更新 bookmarkr.csproj 文件(这些标记为 internal 的代码工件所在的文件)以指示我们只想让测试项目能够访问它们。我们可以通过添加以下条目来实现这一点:
<ItemGroup>
<InternalsVisibleTo Include="bookmarkr.UnitTests" />
</ItemGroup>
这意味着标记为 internal 的 bookmarkr 项目中的代码工件只能被 bookmarkr.UniTests 项目“看到”。
现在,你会注意到代码按照预期编译并执行。
回顾 ImportCommandTests 类的测试方法,你肯定已经注意到,在这两个测试方法中,我们都以相同的方式实例化和初始化了 MockFileSystem 以及书签 JSON 结构的字符串表示。因此,这段代码是冗余的,如果它随时间变化,我们需要在这两个地方更新这段代码。随着测试方法数量的增加,这会变得更糟。
幸运的是,MS Test 提供了一种集中初始化的方法。让我们看看它是如何工作的。
集中化测试初始化
MS Test 提供了一个 [TestInitialize] 属性,可以用来装饰一个方法,其中任何常见的实例化、初始化或配置都可以集中化。
此方法随后由 MS Test 框架在调用每个测试方法之前自动调用。这还有一个好处:每个测试方法都会获得初始化方法中实例化的对象的全新实例,从而防止一个测试方法的执行对下一个测试方法的执行产生影响和影响。
测试初始化方法的代码如下所示:
public required IBookmarkService _bookmarkService;
public required MockFileSystem _mockFileSystem;
[TestInitialize]
public void TestInitialize()
{
string bookmarksAsJson = @"[
{
""Name"": ""Packt Publishing"",
""Url"": ""https://packtpub.com/"",
""Category"": ""Tech Books""
},
{
""Name"": ""Audi cars"",
""Url"": ""https://audi.ca"",
""Category"": ""See later""
},
{
""Name"": ""LinkedIn"",
""Url"": ""https://www.linkedin.com/"",
""Category"": ""Social Media""
}
]";
_mockFileSystem = new MockFileSystem(new Dictionary<string,
MockFileData>
{
{@"bookmarks.json", new MockFileData(bookmarksAsJson)}
});
}
太棒了!现在我们知道了实现我们 CLI 应用程序有意义的和高效的测试所需的一切。
但在结束这一章之前,还有最后一件事我想和你讨论。我想告诉你如何使用你正在实施的这些测试来识别和消除错误。
如何追踪错误
测试在追踪错误和确保它不会再次出现中扮演着关键角色。为了做到这一点,你应该遵循一个流程。
无论何时发现(由你或你的团队)或报告(由用户)错误,你应该编写重现它的测试。这些测试可以是不同类型的。这是为了确保错误不会再次出现。
现在,运行你的测试,你应该会注意到测试失败了。覆盖范围更广的测试(例如系统或集成测试)会告诉你错误发生在应用程序的哪个组件中。然后,更细粒度的测试(例如单元测试)会告诉你错误隐藏在哪个类中,最终在哪个方法中。
通过使用范围广泛和细粒度测试的智能组合,你将能够追踪到错误。记住,断点将是你的强大盟友。
就这样!我们现在拥有了利用软件测试提高应用程序质量所需的一切。
摘要
在本章中,我们学习了为什么测试是开发任何应用程序(包括 CLI 应用程序)过程中的重要步骤。我喜欢称测试为你的安全网:它们不仅确保你的新功能按预期工作,而且确保你不会意外地在现有功能中引入错误(我们称之为回归)。我强烈建议你编写有效的测试并经常运行它们。
我们还探讨了测试的分类和角色,并学习了测试应用程序的技术,并将它们应用于我们的 CLI 应用程序 Bookmarkr。
我们的应用程序现在具备了所需的功能,并且通过测试确保了这些功能按预期工作。现在是时候将应用程序交付给用户了!
正因如此,在下一章中,我们将探讨不同的技术,这些技术将使我们能够打包、分发和部署我们的应用程序。
轮到你了!
跟随提供的代码进行实践是学习的好方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 为剩余的功能编写所需的单元测试
在本章中,我们只为 link 和 import 命令编写了测试。因此,你被挑战为其他命令编写测试。你必须弄清楚要考虑哪些测试用例,并实现它们。
任务 #2 – 为同步命令编写集成测试
sync 命令处理数据库。为了单元测试的目的,你可以使用 NSubstitute 模拟数据库。然而,在实现集成测试时,你需要一个真实的数据库。你将面临编写 sync 命令的集成测试的挑战。你必须提供一个测试数据库,并使用适当的连接字符串,这取决于应用程序是在生产模式还是测试模式下运行。
第十一章:打包和部署
现在我们已经完成了应用程序的开发和测试,是时候将其发布到世界上了!我们需要打包和部署它,以便将其分发到(数百万)全球用户。
每个平台(如 Windows、macOS 和 Linux)都有自己的应用程序分发方法。由于 .NET 8 是跨平台的,我们可以将 Bookmarkr 分发给更多用户,无论他们使用什么平台。
然而,在我们打包和分发应用程序之前,确保我们在每个目标平台上对其进行测试是很重要的。
在本章中,我们将探讨不同的打包和部署技术,这些技术将帮助我们实现这一目标。具体来说,我们将做以下几件事:
-
探索打包和分发 CLI 应用程序的不同选项
-
学习如何打包和分发跨平台 CLI 应用程序
-
学习如何将 CLI 应用程序部署到多个平台
-
学习如何管理分布式应用程序的版本
技术要求
本章的代码可以在本书配套的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter11。
一些术语
在本章中,您将遇到“打包”、“分发”和“部署”这些术语。对于那些不熟悉这些术语的人来说,以下是每个术语的简要定义:
-
打包:打包是指为发布准备应用程序的过程。这包括将所有必要的文件、库和资源捆绑成一个单元,以便我们的用户可以轻松安装或执行。有效的打包确保应用程序与各种环境兼容,并简化了安装过程。这通常涉及创建安装程序或存档,以简化应用程序的部署。
-
(
apt-get仓库)。分发的目标是使应用程序对其目标受众可访问,同时确保以安全高效的方式到达他们。 -
部署:部署是通过在用户的计算机上安装和使分布式应用程序运行的操作机制。这可能涉及配置设置、与现有系统集成以及确保所有组件适当工作。部署可以是手动进行的,也可以通过各种工具和脚本自动化。目标是使用户能够有效地访问和使用应用程序。
如您所想,使应用程序(包括 CLI 应用程序)可供用户使用是一个三步过程,可以用以下图表来概括:

图 11.1 – 使应用程序可供用户使用的流程
现在我们已经了解了术语,让我们首先探讨在打包、分发和部署 CLI 应用程序时可供选择的各种选项。
CLI 应用程序的打包和分发选项
当涉及到打包 CLI 应用程序时,存在几种方法,选择最合适的方法取决于我们打算如何分发它。
最常见的选项如下:
-
MSI 安装程序:此选项允许您获得更传统的安装体验,可以使用 WiX 或 Visual Studio 安装程序项目等工具实现。请注意,此选项仅适用于 Windows。因此,如果我们打算将我们的 CLI 应用程序分发到多个平台,此选项可能不是最佳选择。
-
dotnet tool install命令。由于我们的 CLI 应用程序是使用跨平台版本的 .NET 构建的,我们可以将其作为 .NET 工具分发到各种平台。这种方法的缺点在于安装机制:它需要 .NET CLI。如果我们的受众是开发人员或 IT 专业人员,这很好,但如果不合适,则不太适用。我们只有在 CLI 应用程序是开发人员或 IT 管理员工具的情况下才应考虑这种方法,而我们的书签管理应用程序并非如此。 -
Docker 容器:这也是多平台分发的绝佳选择。Docker 容器的一个明显优势是它在本地机器上的占用空间更小,因为不需要进行本地安装,并且对系统的访问有限。Docker 容器是一个自包含的文件。然而,与 .NET 工具选项一样,此选项主要针对开发人员或 IT 管理员,因为用户需要了解如何使用 Docker 才能部署我们的应用程序。
-
apt-get软件包管理器,macOS 提供了 Homebrew,Windows 提供了 WinGet。这些选项很棒,因为每个平台的用户都熟悉它们,无论他们的技术知识如何。这意味着这些分发机制不仅针对开发人员和 IT 管理员,而且针对所有人!再次强调,由于我们的 CLI 应用程序是用跨平台技术 (.NET) 构建的,我们可以使用相同的代码并将它打包以在每个这些平台上分发。
如您所见,我们提供了多种打包和分发选项,您可以使用最适合您情况的选项。在本章中,我们将探讨最后三个打包和分发选项:.NET 工具、Docker 容器和 WinGet(作为特定平台的打包选项)。
让我们开始吧!
打包和分发 CLI 应用程序
在本节中,我们将探讨使用三种不同的选项打包和分发我们的应用程序,Bookmarkr 的细微差别。我们将借此机会解释每种方法何时最合适。
选项 #1 – 作为 .NET 工具
通过将我们的应用程序作为 .NET 工具打包和分发,我们的用户将能够使用 .NET CLI 安装它。然而,重要的是用户要确保他们已安装适当的 .NET 版本,以避免版本不匹配,这可能导致应用程序中出现意外的行为。
第 1 步 – 打包
第一步是修改 .csproj 文件,添加表示它应被打包为工具的属性。这些属性应添加到 <PropertyGroup> 部分:
<PackageId>bookmarkr</PackageId>
<Version>1.0.0</Version>
<Authors>Tidjani Belmansour</Authors>
<Description>Bookmarkr is a bookmarks manager provided as a CLI application.</Description>
<PackAsTool>true</PackAsTool>
<ToolCommandName>bookmarkr</ToolCommandName>
<PackageOutputPath>./nupkg</PackageOutputPath>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Copyright>Tidjani Belmansour. All rights reserved.</Copyright>
<PackageProjectUrl>https://github.com/PacktPublishing/Building-CLI-Applications-with-C#-and-.NET</PackageProjectUrl>
<RepositoryUrl>https://github.com/PacktPublishing/Building-CLI-Applications-with-C#-and-.NET</RepositoryUrl>
<PackageTags>.net cli;bookmark manager;.net 8</PackageTags>
让我们解释我们刚刚添加的内容:
-
PackageId: 这代表我们包的唯一标识符。 -
Version: 这表示我们包的版本。当我们需要打包新版本时,我们需要更改此值。 -
Authors: 这代表包的作者(或作者列表)。 -
Description: 这提供了应用程序的简要描述。 -
PackAsTool: 设置为true,表示应用程序应被打包为 .NET 工具。 -
ToolCommandName: 这是用户将在他们的终端中输入以执行我们的应用程序的命令名称。 -
PackageOutputPath: 由于 .NET 工具被打包为 NuGet 包,因此将生成一个.nupkg文件。此属性表示此文件将在何处生成。 -
PackageLicenseExpression: 我选择提供 MIT 许可的代码,因为它是一种允许在任何项目中重用代码的许可,只要在所有副本或软件的实质性部分中包含原始版权声明和许可即可。 -
PackageReadmeFile: 这指向一个 Markdown 文件,其中我们解释了应用程序的目的、如何开始使用它以及指向其文档的链接等。此 Markdown 文件的内容将在 NuGet 网站上的包页面上显示供用户阅读。您将在代码存储库中找到该文件。 -
Copyright: 这展示了应用程序的版权详情。 -
PackageProjectUrl: 这指向项目网站的首页。 -
RepositoryUrl: 这指向应用程序代码所在的存储库。 -
PackageTags: 这展示了一个分号分隔的关键词列表,可以在搜索包时使用。
为了指定 README.md 文件的位置和如何处理它,我们需要将以下 XML 代码添加到 .csproj 文件中:
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\"/>
</ItemGroup>
第二步是打包应用程序。这是通过运行以下命令来实现的:
dotnet pack --configuration Release
请记住,它将在 PackageOutputPath 属性的值所指示的位置生成。
我们的包现在已准备好分发。
第 2 步 – 分发
分发 .NET 工具最常见的方式是通过位于 www.nuget.org 的 NuGet 网站。
因此,让我们前往 NuGet 网站,并点击页面右上角的 Sign in 链接:

图 11.2 – 登录到 NuGet 网站
我将使用我的个人账户登录并授予 NuGet 网站所需的权限,如下所示:

图 11.3 – 授予 NuGet 网站所需的权限
由于这是我第一次使用此账户登录,NuGet 网站要求我提供一个用户名:

图 11.4 – 为 NuGet 网站选择用户名
就这样!作为 NuGet 包的发布者,我现在已经设置完毕,可以开始上传我的包:

图 11.5 – 作为 NuGet 包发布者已设置完毕
现在我们来上传我们的包!
我们需要做的只是点击我们之前生成的 .nupkg 文件。然后包将被分析,并显示验证结果:

图 11.6 – 上传包到 NuGet 网站
由于我们的包是有效的,我们可以通过点击页面底部的 提交 按钮来提交它。
上传后,通常需要大约 15 分钟(但有时可能需要长达一小时)的时间来验证和索引包,然后它才会出现在搜索结果中:

图 11.7 – 等待验证和索引的包
一旦包验证和索引完成,它将像任何其他 NuGet 包一样出现在 NuGet 网站上:

图 11.8 – Bookmarkr 可在 NuGet 网站上找到!
现在,我们的应用程序可以被用户找到,让我们看看它是如何被部署的。
第 3 步 – 部署
用户可以通过输入以下命令非常容易地在他们的机器上部署(即安装)我们的应用程序:
dotnet tool install --global bookrmarkr
安装完成后,用户可以通过输入以下命令来执行我们的应用程序:
bookrmarkr
就这样!我们已经打包、分发和部署了 Bookmarkr 作为 .NET 工具。
现在我们来看看如何将 Bookmarkr 作为 Docker 容器交付给我们的用户。
选项 #2 – 作为 Docker 容器
将我们的应用程序打包并作为 Docker 容器分发,允许用户通过减少应用程序在他们的环境(即操作系统和数据)中的占用,来安装和使用我们的应用程序。
第 1 步 – 打包
如果你熟悉容器,你可能已经知道,为了创建容器镜像,需要一个 Dockerfile。
Dockerfile 是一个没有扩展名的文件,应该位于项目目录的根目录。对于我们的应用程序,其内容如下:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /app
COPY *.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o out
FROM mcr.microsoft.com/dotnet/runtime:8.0
WORKDIR /app
COPY --from=build /app/out .
ENTRYPOINT [«dotnet», «bookmarkr.dll»]
从本质上讲,此文件指示 Docker 构建并发布应用程序(其前六行),然后从发布的应用程序构建容器镜像(剩余的五行)。
在我们运行实际构建镜像的命令之前,我们需要确保 Docker Desktop 和Windows 子系统(WSL)都已安装并正在运行。请注意,Docker Desktop 需要在本地机器上具有管理员权限。
可以使用以下命令安装 Docker Desktop:
winget install Docker.DockerDesktop
可以使用以下命令安装 WSL(安装 WSL 后,Windows 需要重启):
wsl --install
构建 Docker 镜像的命令如下:
docker build -t bookmarkr .
-t bookmarkr参数用于标记要生成的 Docker 镜像,并带有名称和可选版本号(稍后会有更多介绍)。
最后一个点字符既不是打字错误也不是可选的。它指的是我们所说的构建上下文。更具体地说,它指示 Docker 在哪里查找 Dockerfile,在这种情况下,是当前目录。
这个操作大约需要五分钟,一旦完成,Docker 镜像将被创建,可以通过输入以下内容来检索:
docker images
注意,容器镜像已经在我们的本地机器上生成。然而,我们应该通过一个每个人都可以轻松找到的位置来分发它。
第 2 步 – 分发
分发 Docker 镜像最常见的方式是通过 Docker Hub。
为了做到这一点,我们需要前往位于hub.docker.com的 Docker Hub 门户。如果您还没有 Docker Hub 账户,您可以从那里创建一个。我已经有一个这样的账户,我的用户名是theAzurian。
因此,让我们按照步骤将我们的本地 Docker 镜像推送到 Docker Hub。
首先,让我们使用以下命令登录到我们的 Docker Hub 账户:
docker login -u theazurian -p ****
我正在传递我的-p参数。这个 PAT 是通过 Docker Hub 门户创建的。
接下来,我们需要标记镜像以包含其作者的 Docker Hub 用户名、应用程序的名称及其版本,例如以下内容:
docker tag bookmarkr theazurian/bookmarkr:1.0.0
最后,我们需要使用以下命令将标记的镜像推送到 Docker Hub:
docker push theazurian/bookmarkr:1.0.0
我们可以通过前往门户并在我们的 Docker Hub 个人资料中查找它来确保镜像已成功推送到 Docker Hub:

图 11.9 – Bookmarkr 可在 Docker Hub 门户中找到!
我们也可以在 Docker Hub 门户中搜索它:

图 11.10 – 在 Docker Hub 门户中搜索 Bookmarkr
我们的应用程序现在可以被我们的用户找到。让我们看看它是如何部署的。
第 3 步 – 部署
为了让用户在 Windows 机器上运行 Docker,他们还需要安装 Docker Desktop 和 WSL。
可以使用以下命令安装 Docker Desktop:
winget install Docker.DockerDesktop
可以使用以下命令安装 WSL(安装 WSL 后,Windows 需要重启):
wsl --install
现在,我们的用户可以通过输入以下命令从 Docker Hub 获取应用程序:
docker pull theazurian/bookmarkr:1.0.0
他们可以通过输入以下命令来执行它:
docker run theazurian/bookmarkr:1.0.0
Bookmarkr 可以作为 Docker 容器在用户的计算机上运行:

图 11.11 – Bookmarkr 作为 Docker 容器运行
就这样!我们已经将 Bookmarkr 打包、分发和部署为 Docker 容器。
现在我们来看看如何将 Bookmarkr 作为 WinGet 包提供给我们的用户。
选项 #3 – 作为 WinGet 包
通过将我们的应用程序打包和分发为 WinGet 包,我们允许我们的用户像使用 WinGet 安装的任何其他应用程序一样安装它,Microsoft 的包管理器。
打包
要将 .NET CLI 应用程序打包以通过 WinGet(官方 Windows 包管理器)分发,我们首先需要创建一个清单文件。
虽然可以在 GitHub 的 WinGet 包存储库(github.com/microsoft/winget-pkgs)上手动创建和提交清单,但最简单的方法是使用 WingetCreate CLI。
让我们先使用这个命令来安装它:
winget install wingetcreate
在我们创建新的清单之前,我们首先需要使用此命令将我们的 CLI 应用程序构建为一个自包含的 .NET 应用程序:
dotnet publish -c Release -r win-x64 -p:selfcontained=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishSingleFile=true
让我们更仔细地看看这个命令:
-
-c Release: 由于这是一个生产就绪版本的应用程序,我们希望使用 Release 配置来发布它,以确保其性能优化。 -
-r win-x64: 由于 WinGet 是 Windows(仅限 Windows)的包管理器,我们指定目标运行时为 Windows 的 64 位版本。 -
-p:selfcontained=true: 一个自包含的应用程序已经包含了 .NET 运行时,因此用户的机器不需要安装它。应用程序将携带运行所需的所有内容,包括运行时、库和依赖项。 -
-p:IncludeNativeLibrariesForSelfExtract=true: 这确保了平台特定的库,以及非托管的本机库,都包含在发布的应用程序中。如果我们使用一些特定的 Serilog 沉淀物或 SQLite 库,这很有用。 -
-p:PublishSingleFile=true: 这指示 .NET 将所有内容(包括应用程序代码、.NET 运行时和依赖项)打包成一个单独的可执行文件。虽然这使得分发更加方便(因为我们只分发一个文件),但生成的文件大小比框架依赖的发布文件要大。
应用程序将在 bin\Release\net8.0\win-x64\publish 目录中生成。
接下来,我们将将其上传到一个 WinGet 工具可以访问的位置。通常会选择一个远程的、公开可访问的、只读位置。我决定使用 Azure 存储账户。因此,可执行文件的位置将是 bookmarkr.blob.core.windows.net/releases/1.0.0/。
关于 GitHub 发布
如果您的应用程序是在 GitHub 上作为开源项目构建的,您可能会将可执行版本作为发布版提供。然而,GitHub 上的发布遵循某些指南,这些指南远远超出了本书的范围。如果您对此主题感兴趣,我建议您通过访问github.com/github/docs/blob/main/content/repositories/releasing-projects-on-github/about-releases.md来探索这些指南。
让我们现在创建我们的清单文件!我们可以使用以下命令来完成:
wingetcreate new https://bookmarkr.blob.core.windows.net/releases/1.0.0/bookmarkr.exe
工具将询问生成清单文件所需的一系列问题。以下是一个示例:

图 11.12 – 生成 WinGet 清单文件
将生成三个文件:
-
版本清单(theAzurian.bookmarkr.yaml):包含有关正在打包的应用程序特定版本的元数据。
-
安装程序清单(theAzurian.bookmarkr.installer.yaml):详细说明应用程序的安装细节。
-
默认区域设置清单(theAzurian.bookmarkr.locale.en-CA.yaml):定义应用程序的区域设置。它确保用户接收到的应用程序版本是适当本地化的,通过以他们首选的语言呈现信息来增强用户体验。
我个人喜欢将这些文件保存在我的 Visual Studio 项目中,在项目根目录下创建的以下文件夹结构中:
/manifests/ApplicationName/Version
因此,在我们的情况下,这个文件夹结构将看起来像这样:
/manifests/Bookmarkr/1.0.0
在我们将清单提交给 WinGet 团队之前,建议我们在本地进行测试以确保其按预期工作。这很重要,因为如果清单中存在问题,提交我们的包可能会导致 WinGet 审批过程中的延迟。
要做到这一点,我们首先需要激活从本地清单安装应用程序的能力。这可以通过在以管理员身份运行的终端中执行以下命令来完成:
winget settings --enable LocalManifestFiles
接下来,我们运行以下命令,提供manifests.json文件的路径:
winget install --manifest "C:\code\Chap11\bookmarkr\manifests\Bookmarkr\1.0.0\"
如我们所见,应用程序已安装并按预期运行:

图 11.13 – 在提交前在本地测试 WinGet 包
我们现在已准备好将我们的清单提交给 WinGet 团队!
将我们的清单提交到 WinGet 包存储库需要我们为我们的 GitHub 账户生成一个 PAT。我们可以使用wingetcreate token命令来完成此操作,或者我们可以跳过此步骤,当提交清单时,wingetcreate将提示我们验证我们的 GitHub 账户。让我们这样操作吧!
让我们运行以下命令:
wingetcreate submit "C:\code\Chap11\bookmarkr\manifests\Bookmarkr\1.0.0\"
这将打开浏览器并带我们到 GitHub 登录页面。我们将需要登录到我们的账户。一旦完成,我们需要提供所需的授权:

图 11.14 – 授权 WingetCreate 对我们的 GitHub 账户
然后,它将带我们到拉取请求页面,我们可以跟踪其进度。大约 30 分钟后,拉取请求完成,该软件包可供我们的用户安装。
用户可以通过输入以下命令使用 WinGet 安装 Bookmarkr:
winget install --id theAzurian.Bookmarkr
哇!我们已经将 Bookmarkr 打包、分发和部署为一个 WinGet 软件包。
因此,我们已经看到了三种不同的打包、分发和部署我们应用程序的方法。但我们是怎样管理该应用程序的多个版本的呢?这就是我们将在下一节中要探讨的内容。
管理应用程序的版本
我们之前提出的所有选项都提供了版本管理机制。版本管理与我们选择的打包和分发机制一样重要。
随着我们的应用程序发展和新功能的添加、修改或删除,我们希望为用户提供一种方便地消费这些更新的方式。这就是版本控制发挥作用的地方。
目前,我们只分发我们应用程序的一个版本。因此,我们可以通过省略其版本号或明确指出它来安装它。
但如果我们更新了应用程序呢?我们如何分发新版本?而且如果新版本引入了错误,我们需要回滚到先前的版本怎么办?
让我们探索我们如何实现我们之前涵盖的每种分发方法。
语义版本控制入门
在我们深入探讨管理应用程序的不同版本之前,让我们首先介绍语义版本控制。
如果你熟悉这种应用程序版本控制方法,你知道这可能是行业中最常见和最广泛采用的方法。如果你之前没有听说过它,让我给你做一个简要的介绍。如果你想更深入地探索语义版本控制,我建议你访问其官方网站semver.org。
从本质上讲,语义版本控制使用以下格式来表示版本号:
Major.Minor.Patch
这里,我们有以下内容:
-
主要:表示此版本的应用程序包含与之前主要版本不兼容的破坏性更改。
-
次要:表示此版本的应用程序仅添加与之前相同主版本的先前版本向后兼容的新功能。
-
补丁:表示此版本的应用程序包含与之前相同主版本的先前版本向后兼容的错误修复。
每个部分都表示为一个数字,每次新版本发布时都会递增。
现在我们已经理解了语义版本控制,让我们用它来管理我们应用程序的不同版本。
管理一个.NET 工具的版本
如果你运行了我们提供的作为 .NET 工具的 Bookmarkr 版本,你肯定会注意到它返回以下错误信息:

图 11.15 – 将 Bookmarkr 作为 .NET 工具执行失败
然后让我们修复问题并分发新版本。
错误来自 appsettings.json 文件在执行 dotnet pack 命令时没有被包含在包中。
幸运的是,修复这个问题很简单。在 .csproj 文件中找到以下条目:
<None Update="appsettings.json">
用以下内容替换它:
<None Update="appsettings.json" Pack="true" PackagePath="\">
现在,由于我们即将打包和分发的这个新版本只提供错误修复,我们将增加补丁号,因此版本号看起来如下:
<Version>1.0.3</Version>
我们现在可以通过遵循我们之前描述的相同步骤来打包和分发新版本。
然而,在分发之前,建议使用以下命令在本地进行测试:
dotnet tool install --global bookmarkr --version 1.0.3 --add-source "C:\code\Chap11\bookmarkr\nupkg"
--add-source 参数允许我们指定一个位置,从该位置部署包。在这里,我指定了在本地机器上生成的 NuGet 包的路径。
在确保这个新版本在本地机器上成功运行后,我们可以继续将其推送到 NuGet 网站。
用户可以通过提供版本号作为参数来获取工具的特定版本。在这种情况下,可以通过输入此命令来实现:
dotnet tool install --global bookmarkr --version 1.0.3
或者,他们可以简单地输入以下命令以获取最新版本:
dotnet tool install --global bookmarkr
一旦执行此命令,工具的旧版本将被新版本替换。
通过运行应用程序的新版本,我们现在可以看到错误已被解决:

图 11.16 – 将 Bookmarkr 作为 .NET 工具成功运行
就这样!我们现在知道如何管理 .NET 工具的版本。
让我们看看我们是如何管理 Docker 容器的版本的。
管理 Docker 容器的版本
如您所注意到的,当我们把 Docker 镜像推送到 Docker Hub 时,我们用版本号进行了标记。因此,如果我们想分发新版本,我们可以用不同的版本号标记新镜像。
然而,如果你熟悉 Docker,你可能知道可以在不提供版本号或使用 latest 标签的情况下与容器一起工作。
当分发多个容器版本时,通过使用 latest 标签标记该版本,指明这些版本中的哪一个是最新的,这是很重要的。
因此,让我们假设我们正在分发 Bookmarkr 的新版本,并且我们想要表明这个新版本是最新版本。我们可以这样做:
docker build -t theazurian/bookmarkr:2.0.0 .
docker tag theazurian/bookmarkr:2.0.0 theazurian/bookmarkr:latest
docker push theazurian/bookmarkr:2.0.0
docker push theazurian/bookmarkr:latest
让我们解释这些命令:
-
第一个创建了一个带有版本 2.0.0 标签的新 Docker 镜像
-
第二个将版本 2.0.0 标记为最新版本
-
第三个命令将带有版本 2.0.0 标签的镜像推送到 Docker Hub
-
第四步将标记为最新版本的镜像推送到 Docker Hub
我们使用两个不同的标签推送相同的镜像的事实允许我们的用户(使用docker pull命令)指定或不指定其版本号来获取它。因此,随着我们不断更新应用程序并推送新的 Docker 镜像,我们将使用latest标签标记最新版本。如果该版本包含错误,我们可以通过使用latest标签将用户引导到之前的版本。
如果我们访问 Docker Hub 门户,我们将看到新版本已成功推送。注意,有同一镜像的两个版本:一个以版本号 2.0.0 作为标签,另一个以latest标签。

图 11.17 – 推送到 Docker Hub 的 Docker 镜像的新版本
在用户端,他们可以通过明确提及其标签来获取特定版本,如下所示:
docker pull theazurian/bookmarkr:2.0.0
或者,他们可以通过省略标签来获取最新版本(即标记为latest的版本),如下所示:
docker pull theazurian/bookmarkr
用户将看到以下内容:

图 11.18 – 运行 Docker 容器的最新版本
就这样!我们现在知道如何管理 Docker 镜像的版本。
让我们看看我们是如何管理 WinGet 包的版本的。
管理 WinGet 包的版本
为了提交应用程序的新版本,在更新应用程序的代码或功能之后,我们首先需要更新.csproj文件中的版本号(<Version>元素)。
接下来,我们需要使用之前看到的相同命令再次发布应用程序:
dotnet publish -c Release -r win-x64 -p:selfcontained=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishSingleFile=true
然后,我们需要将生成的二进制文件上传到我们的分发位置,即我们的 Azure 存储账户,同时要注意我们应该为新版本创建一个新的目录。对于版本 1.0.3,路径将如下所示:
https://bookmarkr.blob.core.windows.net/releases/1.0.3/bookmarkr.exe
下一步是使用此命令更新清单:
wingetcreate update theAzurian.Bookmarkr --version 1.0.3 https://bookmarkr.blob.core.windows.net/releases/1.0.3/bookmarkr.exe
新的清单已生成并准备好提交。
然而,正如我们之前学到的,在提交之前在本地测试新版本始终是一个好习惯。为此,我们将执行之前相同的命令:
winget install --manifest "C:\code\Chap11\bookmarkr\manifests\Bookmarkr\1.0.3\"
一旦测试成功,我们使用以下命令提交新版本:
wingetcreate submit "C:\code\Chap11\bookmarkr\manifests\Bookmarkr\1.0.3\"
剩余步骤与我们提交应用程序初始版本时遵循的步骤类似。
一旦新版本被批准并添加到 WinGet 包仓库中,用户就可以找到它并安装它。他们可以使用以下命令安装最新版本:
winget install --id theAzurian.Bookmarkr
或者,他们可以通过将期望的版本号作为参数传递给命令来安装特定版本,如下所示:
winget install --id theAzurian.Bookmarkr --version 1.0.3

图 11.19 – Bookmarkr 的更新版本在 WinGet 中可用
就这样!这就是我们管理 WinGet 包多个版本的方法。
摘要
在本章中,我们学习了如何打包和部署 Bookmarkr 到不同的平台,以便向全球各地的用户分发,无论他们选择哪个平台,无论是 Windows、Linux 还是 macOS。
这是我们取得的一个相当大的里程碑,从我们的 CLI 应用程序想法的诞生,到将其推广到全球数百万用户手中。让我们花点时间庆祝这个成就,为自己感到自豪。恭喜!🎉
然而,一些用户告诉我们,应用程序有时运行缓慢。我们并没有经历过这些性能问题,因为我们运行在快速且强大的计算机上,但并非所有用户都是如此。尽管我们可以简单地指定运行 Bookmarkr 的最低要求,但我们不想限制能够从中受益和使用它的用户数量。因此,我们决定看看我们是否可以做一些事情。
在下一章中,我们将探讨不同的技术,这些技术将使我们能够优化应用程序的性能。
轮到你了!
跟随提供的代码进行实践是学习的好方法。
更好的方法是通过挑战自己完成任务。因此,我挑战你通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 允许 Linux 用户使用 apt-get 安装 Bookmarkr
目前,Bookmarkr 可以使用 WinGet 在 Windows 上部署。然而,这在 Linux 上不起作用,Linux 用户通常使用 apt-get 部署应用程序。因此,你被挑战将 Bookmarkr 作为 apt-get 软件包进行分发,以便 Linux 用户也能享受使用它。
任务 #2 – 允许 macOS 用户使用 Homebrew 安装 Bookmarkr
对于 macOS 用户来说,情况也是如此:他们通常使用 brew 命令安装应用程序。因此,你被挑战将 Bookmarkr 作为 Homebrew 公式进行分发。
第五部分:高级技术和最佳实践
在本部分,你将探索 CLI 应用程序开发的关键方面,这些方面可以增强性能、安全性和功能性。你将深入了解性能优化和调整技术,学习如何分析你的 CLI 应用程序,识别瓶颈,并实现高效的算法和数据结构。这包括缓存、负载均衡和代码重构等策略,以提高执行速度和资源利用率。接下来,你将专注于 CLI 应用程序特有的安全考虑,涵盖最佳实践和防范常见漏洞。你将学习如何实现强大的身份验证机制,为敏感数据使用加密,并在你的 CLI 工具中遵循最小权限原则。最后,你将探索额外的资源和库,这将使你能够深入了解本书中提出的各种概念和技术。
本部分包含以下章节:
-
第十二章**,性能优化和调整
-
第十三章**,CLI 应用程序的安全考虑
-
第十四章**,附加资源和库
第十二章:性能优化和调整
性能将使那些被使用和喜爱的应用程序与那些被卸载并被永远遗忘的应用程序区分开来。
仅有一个能够响应用户需求的应用程序是不够的。为了被频繁使用(并且很可能是每天使用),一个应用程序需要快速启动并执行任务。
这种速度和响应性直接影响到用户满意度,因为人们对数字体验的期望越来越高。研究表明,即使是加载时间或任务完成的小幅延迟也会显著降低用户参与度和整体满意度。
在本章中,我们将讨论可以提升性能的不同领域以及我们可以使用哪些技术来实现这一点。更具体地说,我们将涵盖以下内容:
-
需要考虑的不同领域以提升应用程序性能
-
如何对应用程序进行仪表化以识别性能问题
-
如何提升你的应用程序性能
技术要求
本章的代码可以在本书附带的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter12。
性能优化领域
应用程序的性能并不仅仅是代码的问题。它是一系列在不同层面上进行的精细调整,有助于实现性能效率。
因此,性能优化发生在以下领域:
-
应用程序设计和架构:你必须走的路越长,到达目的地所需的时间就越长。正如我总是告诉我的客户,你可能跑得比我快两倍,但如果你的路是我路的两倍长,我们将在同一时间到达目的地。这里的观点是,如果你架构不够高效,那么使用高性能的框架和库是没有什么用的。我经常看到过度解耦的架构,有太多的跳跃和上下文切换,导致应用程序性能不佳且速度慢。关键是构建一个在性能和最佳解耦水平之间取得平衡的架构。从设计角度来看,我经常看到可以通过找到更短的路径来实现目标(从而产生更高效和性能更好的应用程序)的设计。当然,你不会对每一行代码都这样做,但你将希望将注意力集中在热点路径上,即用户经常使用的路径。对于一年只被一个用户使用一次的功能,优化其性能可能没有意义。你必须找到优化努力的成本(它需要时间,因此有成本)和从它期望得到的收益之间的平衡。
-
基础设施:如果我们托管应用程序在基础设施上,我们必须确保这个基础设施是高效的,并且已经优化以最大化应用程序的吞吐量同时最小化其延迟。然而,在 CLI 应用程序的上下文中,应用程序在用户的计算机上运行,所以我们可能会认为这里没有什么可做的,但我们会犯错误!我们可以执行一些调整任务,这将积极影响性能。例如,我们可以减少资源利用率,这样在用户的计算机上运行应用程序将消耗最少的资源,因此即使计算机同时运行其他应用程序或性能较弱,应用程序的执行也将是高效的。
-
框架和库:当然,使用高效且性能良好的框架和库有助于提高应用程序的性能。例如,.NET 的每个新版本都承诺更好的性能。因此,升级.NET 版本可以是我们提高应用程序性能的一种简单方法。同样适用于我们使用的库:一些库的性能比其他库更好。
-
编码实践:拼图的最后一块是编码实践。我们已经提到了热点和热点路径,但编码实践还包括使用最合适的数据结构。
在我们开始优化应用程序的性能之前,我们需要对其进行仪器化并识别其热点和热点路径。
仪器化.NET 应用程序
存在多个工具可以帮助我们为.NET 应用程序进行仪器化。这些工具之间的主要区别在于它们的作用范围。
尽管如此,仪器化的一个关键好处是能够检测内存泄漏并识别缓慢的代码路径。
仪器化可以在开发阶段实现,也可以在应用程序在生产环境中运行时持续进行。
| 开发时性能分析 | Visual Studio 诊断工具、BenchmarkDotNet、dotTrace、dotMemory和PerfView非常适合进行 CPU、内存泄漏和分配以及应用程序性能的性能分析。 |
|---|---|
| 生产时监控 | Azure Application Insights、AppDynamics 和 New Relic 有助于在生产环境中实时监控和诊断性能问题。 |
表 12.1 – 一些流行的仪器化工具
你可能已经注意到了术语“性能分析”和“监控”。它们之间存在一些关键区别:
-
性能分析提供了对应用程序性能的详细、细粒度视图,通常关注特定的代码部分或方法。这包括每个功能或方法的 CPU 使用情况、内存分配、执行时间和方法调用频率及持续时间。
-
监控通常在生产环境中进行,提供了应用程序健康状况的概述,随着时间的推移查看更广泛的性能趋势和运营数据,而不是专注于单个代码路径。这包括整个应用程序的 CPU 和内存使用情况,错误率(异常、失败),响应时间和吞吐量(例如,请求需要多长时间,每秒多少个请求),以及应用程序的资源使用情况(磁盘 I/O、网络使用等)。
由于 CLI 应用程序在用户的计算机上运行,可能更难对其进行监控。它需要用户授权以收集必要的数据,通常在频繁的时间间隔内进行。我们可能预期用户会拒绝共享遥测数据,因此监控可能无法进行。
虽然了解帮助我们进行应用程序度量的工具很重要,但同样重要的是要了解在哪里使用它们,换句话说,如何识别这些可能适合性能优化的区域。在这方面,能够识别热点和热路径非常重要。
热点与热路径
在本章中,我并不是第一次提到热点和热路径。然而,我还没有花时间解释它们。让我们立即解决这个问题!
热点是指代码中活动密集的区域,通常指的是频繁执行且消耗大量执行时间的方法。因此,热点代表了提高应用程序整体性能的潜在优化目标。
热路径指的是代码中频繁执行的执行路径,因此对应用程序的运行时间有显著的贡献。热路径可以帮助定位使用不充分的资源,例如内存使用和分配。
可能会出现的疑问是“我们可以遵循什么流程来识别应用程序的热点和热路径?”
识别应用程序的热点和热路径
幸运的是,识别应用程序的热点和热路径不必盲目进行。相反,我们可以遵循一个由三个步骤组成的结构化流程:分析、分析和优化。如果实现了监控,它将作为该过程的输入,因为此过程应定期执行,以确保应用程序的最佳性能。
以下表格描述了该过程:
| 步骤 | 要做什么 |
|---|
|
- 分析和数据收集
|
-
使用性能分析器来收集应用程序执行的数据。例如,
BenchmarkDotNet库可以收集关于 CPU 使用、内存消耗和执行时间的详细信息。 -
收集方法执行时间、资源使用和调用频率的指标,以识别性能瓶颈。
|
|
- 分析和识别
|
-
分析分析器输出并找到:
-
执行时间长的方法
-
经常调用的方法
-
高 CPU 或内存使用区域
-
长时间运行的数据库查询或 I/O 操作
-
-
寻找数据中的模式,这些模式表明潜在的热点或热路径:
-
消耗过多资源的函数
-
经常执行且对总体运行时间贡献显著的执行路径
-
|
|
- 优化
|
-
一旦确定了热点和热路径,就针对这些区域实施优化。
-
使用基准测试工具,如
BenchmarkDotNet,来测量和比较优化前后的代码性能,以评估性能提升。您还可以测量和比较不同的实现,以确定最优的实现。
|
表 12.2 – 识别热点和热路径
我们提到BenchmarkDotNet可以帮助我们分析应用程序。那么,现在是时候学习如何使用它了。
使用 BenchmarkDotNet 分析 Bookmarkr
虽然BenchmarkDotNet被认为是一个基准测试库(即,它用于将不同的实现方案与基线进行比较,以确定哪个是最高效的),但如果我们有策略地使用它,它也可以识别我们代码中的热点和热路径。
让我们看看我们如何利用这个库来分析我们的 CLI 应用程序。
我们需要做的第一件事是引用BenchmarkDotNet库。这可以通过执行以下命令来实现:
dotnet add package BenchmarkDotNet
下一步是配置基准测试收集和报告。为此,让我们在Main方法的非常开始处添加以下代码块:
if(args.Length > 0 && args[0].ToLower() == "benchmark")
{
BenchmarkRunner.Run<Benchmarks>();
return 0;
}
这允许我们在执行应用程序并传递benchmark作为参数时运行基准测试。
这段代码所做的就是请求BenchmarkDotNet(通过BenchmarkRunner类)运行在Benchmarks类中找到的所有基准测试。
让我们创建那个Benchmarks类!
按照我们在前几章中定义的文件夹结构约定,我们将在其中创建一个Benchmarks文件夹,并在其中创建一个Benchmarks.cs文件。
我们可以选择将所有基准测试都放在一个单独的类中,或者为每个要基准测试的命令或服务创建一个基准测试类。在本章中,我们将采用第一种方法,因为我们只对export命令进行基准测试。
让我们添加我们的第一个基准测试方法。其代码如下所示:
public async Task ExportBookmarks()
{
var exportCmd = new ExportCommand(_service!, "export", "Exports
all bookmarks to a file");
var exportArgs = new string[] { "--file", "bookmarksbench.json" };
await exportCmd.InvokeAsync(exportArgs);
}
此方法创建ExportCommand类的实例,并通过调用其InvokeAsync方法来执行它,传递命令所需的参数。
目前,这个方法还没有被BenchmarkRunner类视为基准测试。原因是,要使一个方法被视为基准测试,它需要用[Benchmark]属性进行装饰。让我们来修复这个问题!
[Benchmark]
public async Task ExportBookmarks()
{
var exportCmd = new ExportCommand(_service!, "export", "Exports
all bookmarks to a file");
var exportArgs = new string[] { "--file", "bookmarksbench.json" };
await exportCmd.InvokeAsync(exportArgs);
}
太棒了!但我们还没有准备好运行它…
看看还缺少什么?
你明白了!ExportCommand类接受一个类型为IBookmarkService的实例作为参数,但我们迄今为止还没有提供这样一个对象的实例。
由于我们已经在 Program 类中定义了这样一个实例,您可能期望我们可以通过其构造函数将其传递给 Benchmarks 类,这将会是一个完全合理的假设。然而,BenchmarkRunner 类不允许我们这样做(至少在当前版本的 BenchmarkDotNet 中不允许)。
我们将采取的做法是直接在 Benchmarks 类中实例化此对象。然后代码将看起来像这样:
#region Properties
private IBookmarkService? _service;
#endregion
#region GlobalSetup
[GlobalSetup]
public void BenchmarksGlobalSetup()
{
_service = new BookmarkService();
}
#endregion
注意,服务的实例化不是在类构造函数中进行的,而是在一个带有 [GlobalSetup] 属性的函数中进行的。这个特殊属性指示 BenchmarkDotNet 在执行每个基准方法之前调用此方法一次。这是为了确保每个基准方法都有一个干净的服务实例,从而防止之前基准测试的副作用。
全局设置与类构造函数
在计算基准测试方法执行时间时,不考虑 [GlobalSetup] 方法的执行时间,这与构造函数的执行时间不同。虽然这看起来可能微不足道,但如果该方法需要执行很多次,那么它就不会微不足道了。
我们现在可以执行基准测试了。
要做到这一点,我们首先需要构建应用程序,但这次我们需要在 Release 模式下构建它。否则,BenchmarkDotNet 将生成错误。原因是与在 Release 模式下运行程序相比,在 Debug 模式下运行程序不是最优的,并且与在 Release 模式下运行程序相比,性能成本显著。因此,在生产环境中运行应用程序时,应该以最佳性能模式运行应用程序。因此,在基准测试我们的应用程序时,我们应该在它的最佳性能模式下进行。
Debug 与 Release 模式
在 Debug 模式下构建代码会产生未优化的代码,带有完整的符号 debug 信息,这使得调试和设置断点更加容易。相比之下,Release 模式生成优化后的代码,以获得更好的性能和更小的文件大小。Release 构建通常省略 debug 符号、内联方法和应用各种优化,这些优化可能会使调试更具挑战性,但会导致更快的执行速度。虽然 Debug 构建非常适合开发和故障排除,但在部署到生产环境时通常使用 Release 构建。
要在 Release 模式下构建应用程序,可以输入以下命令:
dotnet build -c Release
我们然后通过输入以下命令来运行基准测试:
dotnet C:\code\Chap12\bookmarkr\bin\Release\net8.0\bookmarkr.dll benchmark
C:\code\Chap12\bookmarkr\bin\Release\net8.0 是 Bookmarkr 应用程序生成的 DLL 文件的位置。
结果如下:

图 12.1 – 导出命令的基准测试
基准测试方法已运行 98 次,平均每次运行 export 命令需要 6.356 毫秒,这并不算坏,对吧?
您可以在屏幕中间看到表格。该表格按基准方法编译指标。让我们解释一下每一列代表什么:
-
Mean:这代表基准测试方法在其所有执行中的平均持续时间(在我们的例子中是 98)。 -
Error:简单来说,这个值代表测量平均值精度的值。误差越小,平均值的测量越精确。例如,由于我们的平均值是 6.356 毫秒,误差是 0.7840 毫秒,所有测量值都在 6.356 毫秒 ± 0.7840 毫秒的范围内,这意味着在 5.572 毫秒和 7.140 毫秒之间。 -
StdDev:这个值代表所有测量的标准差。它量化了执行时间的变化或分散程度。换句话说,StdDev的值越低,表示执行时间越接近平均值。
基准测试不仅适用于命令!
虽然我们在这里对命令进行基准测试,但重要的是要注意,基准测试不仅适用于命令,而且适用于所有可能影响应用程序性能的代码工件,这包括服务。因此,通过基准测试命令以及它们使用的服务,我们可以确定执行时间和内存消耗中有多少可以归因于服务本身和命令。
太好了!然而,这里还有一个我们没有看到的测量值,那就是内存消耗的测量。让我们来解决这个问题!
要收集关于内存消耗的数据,我们只需在Benchmarks类顶部添加[MemoryDiagnoser]标签,如下所示:
[MemoryDiagnoser]
public class Benchmarks
{
// …
}
现在,如果我们以完全相同的方式运行代码,我们会得到以下结果:

图 12.2 – 基准测试内存消耗
注意,现在我们有一个新的列叫做Allocated,它代表基准测试方法每次执行分配的内存量,以千字节为单位。这个列有两个有趣的原因:
-
这允许我们看到基准测试的方法是否使用了比预期多得多的内存。这可以表明我们的代码中存在需要进一步调查的内存泄漏。
-
当我们优化代码时,我们可以看到新的实现是否对内存消耗有影响。例如,我们可以提出一种实现,以牺牲显著的内存消耗为代价来加快执行时间。
执行时间与内存消耗优化
你可能想知道我们是否应该专注于优化内存消耗或执行时间。关于在哪里集中我们的注意力和精力,取决于我们最重视的是什么,内存消耗还是执行时间。值得注意的是,在某些情况下,我们甚至可能同时优化两者!为了做到这一点,我们必须提出一个创造性的实现,通过利用我们使用的框架和库的高级功能,结合高级和创造性的算法来解决这两个问题。
虽然 BenchmarkDotNet 帮助我们在开发阶段识别优化机会,但实施监控同样重要,这样我们才能在生产使用过程中持续检查应用程序的性能。
使用 Azure Application Insights 监控 BookmarkrSyncr
我们之前提到,命令行界面(CLI)应用程序在用户的计算机上本地运行,用户可能会拒绝我们收集对监控至关重要的遥测数据。这就是为什么我们不会在 Bookmarkr 中实现监控,而是在由 Bookmarkr 调用的外部网络服务 BookmarkrSyncr 中实现监控。由于这是一个由我们托管和管理的网络服务,我们可以实现监控并确保收集遥测数据,从而确保监控可以实施。
由于这个网络服务部署到了 Microsoft Azure 云平台,我们将依赖 Azure Application Insights,这是 Microsoft Azure 云平台提供的原生 应用程序性能监控(APM)解决方案。
当我们将 BookmarkrSyncr 部署到 Microsoft Azure 时,我们创建了一个托管它的基础设施。更具体地说,我们创建了一个 Azure App Service 实例。在创建此服务的过程中,我们有机会创建 Azure Application Insights 服务的实例。这项服务是由 Microsoft 提供和管理的监控解决方案。
Azure Application Insights 是一项出色的服务,它允许我们监控性能、可用性、失败的请求、异常、页面浏览量、跟踪、浏览器时间、使用情况(包括 用户流程,这使我们能够识别应用程序中的热点路径),甚至可以实时访问实时指标。Azure Application Insights 的另一个出色功能是能够配置警报,当某个指标达到某个阈值时触发,例如,如果服务器响应时间(衡量从接收 HTTP 请求到向客户端发送响应之间的持续时间)超过了我们组织标准定义的最大允许值。当警报被触发时,我们可以触发自动处理或通知(例如,向特定人群发送电子邮件)。
要了解使用 Azure Application Insights 的监控可能是什么样子,请查看(Microsoft Learn 上的这篇文章,可在 learn.microsoft.com/en-us/azure/azure-monitor/app/overview-dashboard 找到)。
好的。现在我们知道了如何识别应用程序中需要性能调优的区域(使用分析器和监控),让我们讨论一下我们可以用来提高应用程序性能的最常见技术。
常见的性能优化技术
值得注意的是,我们在这里讨论的技术不仅适用于 CLI 应用程序,而且可以应用于任何类型的应用程序。让我们根据我们之前提出的类别来分解这些技术。对于每个类别,我将为您提供一个常见技术的列表。
应用程序设计和架构:
-
建立实现目标的最短路径,移除所有不必要的中间环节。
-
这可以通过使用高效的算法来实现。
-
在解耦和低延迟之间找到最佳平衡。
-
对于不需要立即使用的资源,使用懒加载。
-
实现高效的错误处理和日志记录机制。
-
从一开始就设计可扩展性。
基础设施:
-
在打包和分发您的应用程序时,请以
Release模式编译。虽然Debug模式在开发阶段很棒,但它可能会增加显著的性能开销。 -
此外,当打包和分发您的应用程序时,如果目标平台在事先已知或打包和分发机制不是跨平台的情况下,请将其编译为特定平台。例如,将我们的应用程序作为Winget包分发意味着它将仅用于 Windows 平台。同样,apt-get 包(其中应用程序将仅运行在Linux上)和Homebrew(其中应用程序将仅运行在macOS上)也是如此。因此,很容易知道应该使用哪种平台特定编译,这将使.NET 应用所有可能的优化,这是它如果目标平台事先未知(例如文件处理,它在 Windows、Linux 和 macOS 上有所不同)不会做的事情。这将导致一个在目标平台上以最有效方式运行的应用程序版本。
-
您还可以选择使用AOT(提前编译)来预编译您的代码为本地代码(而不是依赖于JIT),以实现更快的启动时间或减少对运行时编译的依赖。如果您针对的是移动(iOS/Android)或 WebAssembly 等环境,其中 JIT 可能不可行,这可能特别有用。请注意,平台目标化和 AOT 可以结合使用,以实现更好的性能优化。
框架 和库:
-
除非绝对必要,否则避免使用依赖反射的库。
-
选择与您特定需求相匹配的轻量级框架和库。当您引用它们时,要警惕那些拉入大量其他库的库。
-
保持依赖项更新,以从性能改进中受益。
-
考虑使用适用于较小、专注任务的微框架。
编码实践:
-
在可能的情况下,依赖异步操作。这将避免阻塞主线程,并增加应用程序的响应感。
-
选择最适合追求目的的数据类型或数据结构。这将确保我们在计算机资源上占用最小的空间。
-
在可能的情况下,尽量以尽可能少的内存分配来完成一项任务。例如,在撰写本文时,.NET 9 已发布,并引入了通过调用
AsSpan().Split(…)而不进行内存分配的分割操作。 -
实现缓存机制以避免对外部依赖(如 Web 服务或数据库)进行不必要的调用。
-
优化数据库查询并实现适当的索引。
-
说到数据库,如果你使用
AsNoTracking()显著提高查询性能并减少内存使用,尤其是在处理大数据集或只读操作时。此方法告诉 ORM 不要跟踪检索到的实体的更改,绕过更改跟踪机制,从而实现更快查询和更低的内存开销。 -
使用连接池,它涉及重用已建立的数据库连接,而不是为每个请求创建一个新的连接。这是因为建立数据库连接可能很昂贵,因此连接池减少了连接延迟,并允许服务器实现高数据库吞吐量(每秒事务数)。
-
实现适当的内存管理并释放未使用的资源。
我们已经看到了许多常用的技术,这些技术可以用于优化任何使用任何技术栈构建的应用程序的性能,包括使用 .NET 构建的 CLI 应用程序。
现在,让我们应用一些这些技术来增强 Bookmarkr 的性能。
优化 Bookmarkr 的性能
我们不能优化已经完美的事物,对吧?
开个玩笑。当然我们可以!总有改进的空间。
让我们看看我们可以应用的一些快速胜利,以增强我们心爱的 CLI 应用程序的性能。
查看处理 ExportCommand 类的方法(即 OnExportCommand),我们可以看到它已经利用了异步操作。这是一个很好的开始,实际上也是我们之前描述的技术之一。
然而,处理方法可以进行优化。为了说明这一点,让我们创建 ExportCommand 类的副本并将其命名为 ExportCommandOptimized。让我们直接复制 ExportCommand 中的代码,我们将在稍后对其进行优化。
我们创建原始类的副本而不是直接优化它的原因是为了能够为优化版本添加基准方法,并与原始版本进行比较。
在 ExportCommandOptimized 类的处理方法中,让我们更改这两行代码:
string json = JsonSerializer.Serialize(bookmarks, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(outputfile.FullName, json, token);
将它们替换为以下两行:
using var fileStream = new FileStream(outputfile.FullName, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
await JsonSerializer.SerializeAsync(fileStream, bookmarks, new JsonSerializerOptions { WriteIndented = true }, token);
让我们看看我们做了什么:
-
使用
JsonSerializer.SerializeAsync对于大数据集来说更高效,因为它直接将 JSON 流式传输到文件中,而不需要在内存中保留整个序列化字符串。 -
使用
FileStream和异步操作可以更好地控制文件 I/O 操作,并可以提高性能,特别是对于大文件。
好吧。让我们比较这个新实现和原始实现。
要做到这一点,让我们向Benchmarks类添加以下基准方法:
[Benchmark]
public async Task ExportBookmarksOptimized()
{
var exportCmd = new ExportCommandOptimized(_service!, "export",
"Exports all bookmarks to a file");
var exportArgs = new string[] { "--file", "bookmarksbench.json" };
await exportCmd.InvokeAsync(exportArgs);
}
这个benchmark方法与上一个方法完全相同。嗯,几乎相同……唯一的区别是我们实例化(并调用)的是ExportCommandOptimized类,而不是ExportCommand类。
由于我们想要将新的、优化的实现与原始实现进行比较,我们将修改原始方法的[Benchmark]属性,使其看起来像这样。
这指示BenchmarkDotNet使用此方法作为比较的基线:
[Benchmark(Baseline = true)]
让我们重新构建应用程序(当然是在Release模式下)并执行基准测试。
结果如下:

图 12.3 – 将新实现与原始实现进行基准测试
注意出现了两个新的列:
-
Ratio: 这表示相对于基线基准方法的性能的平均度量 -
RatioSD: 这表示相对于基线基准方法的标准偏差的平均标准偏差
Ratio列中的0.91值表示优化后的实现(ExportCommandOptimized)平均比基线实现(ExportCommand)快 9%。我们之前提到,我们在ExportCommandOptimized中做出的实现特别适用于处理大文件。因此,我们可以预期,随着输出文件变大,它甚至会比基线实现更快。
太棒了!我们现在知道如何提高我们心爱的 CLI 应用程序的性能,并且让我们的用户感到满意。
摘要
在本章中,我们探讨了性能优化的各个领域,我们学习了识别性能热点和热点路径的技术,并看到了如何提高它们的性能,最终目标是向我们的用户提供一个他们喜欢使用的出色且高效的应用程序。
希望你已经理解了,没有单一的区域或行动会导致更好的性能,而是一系列在此处和彼处的微调才能达到效果。
太棒了!所以我们有一个高效提供出色功能的应用程序。
然而,当我们谈到构建 CLI 应用程序时(以及任何类型的应用程序),有一个关键领域我们还没有涉及。这个关键领域是安全性,这也是下一章的主题。
你的回合!
跟随提供的代码是一种通过实践学习的好方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 编写更多基准测试
在本章中,我们通过仅编写export命令的基准来展示了编写基准方法。然而,正如我们之前提到的,基准不仅适用于命令,它们也可以适用于服务。
正因如此,你被分配了为每个命令以及由 Bookmarkr 应用程序使用的服务编写额外的基准方法。
任务 #2 – 优化 Bookmarkr 以获得最佳性能
在本章中,我们并没有实现每一个性能优化的机会,我们可能也错过了一些(这是故意的吗?眨眼)。因此,你被分配了在 Bookmarkr 中识别其他潜在的性能优化并实施它们。
第十三章:CLI 应用程序的安全考虑因素
安全性是任何应用程序开发项目中最重要的关注点之一。有趣的是,在许多项目中,开发团队往往认为,由于他们实施了防止应用程序代码受到 SQL 注入、XSS 攻击或类似攻击的措施,他们的应用程序就是安全的。
然而,重要的是要记住,安全性具有不同的形式,覆盖不同的领域,这意味着它不仅关乎应用程序代码的安全性或其使用,还扩展到整个开发生命周期的安全性。
在本章中,我们将讨论这些不同领域以及它们如何与确保 CLI 应用程序的安全性相关,并涵盖您需要考虑的关键领域,以增强您的 CLI 应用程序和开发生命周期的安全性。更具体地说,我们将做以下几件事:
-
讨论应该考虑安全性的不同领域
-
学习如何评估 CLI 应用程序的安全状况
-
学习如何实现身份验证以确保用户数据的访问安全
技术要求
本章的代码可以在本书配套的 GitHub 仓库中找到,github.com/PacktPublishing/Building-CLI-Applications-with-C-Sharp-and-.NET/tree/main/Chapter13
安全性领域
如前所述,安全性不仅限于应用程序代码,也不能仅通过实现身份验证来实现,尽管这些领域非常重要。
让我们先强调一下在整个生命周期中确保应用程序安全性的关键领域。我必须在这里强调(或提醒)这一点,因为我仍然遇到一些客户,他们在应用程序的生命周期中引入安全性太晚(通常是在应用程序开发并发布到生产之后),期望安全专家能够创造奇迹,以最小的或没有修改应用程序的方式确保应用程序的安全,这显然是不现实的。
总是要牢记,安全性应该贯穿应用程序的整个生命周期。具体来说,安全性应该从设计应用程序的早期阶段就成为一个关注点:
-
在设计阶段:定义安全需求和目标,并使用 STRIDE 等方法进行威胁建模,以识别潜在的安全风险,以便我们可以将适当的安全控制措施纳入初始设计,这是非常重要的。STRIDE 帮助将威胁分类为六个组:欺骗、篡改、否认、信息泄露、拒绝服务和权限提升。在识别威胁后,我们可以使用 DREAD 模型根据其损害潜力、可重复性、可利用性、受影响用户和可发现性进行定量评估和优先排序。在此阶段,我们还应考虑隐私和数据保护措施,如加密、身份验证和授权。
-
在架构阶段:我们在这里讨论的不仅仅是软件架构,还包括基础设施和网络架构。当然,我们需要设计一个包含深度防御策略并集成安全机制(如身份验证和授权)的安全架构。但我们还需要计划安全的通信通道和数据存储,使用诸如静态和传输中的加密以及网络分段等技术,以确保只有适当的通信路径、通过适当的网络地址、端口和域名,并使用适当的协议被允许。
-
在开发阶段:通过依赖编码标准,如实现输入验证和清理,并确保我们的应用程序不受常见的 OWASP Top 10 安全风险的影响,应用安全编码实践非常重要。同样重要的是,在代码审查期间也要应用安全编码实践。但这还不够!您应该使用安全的库和框架。换句话说,确保您的框架和库仍然受到支持并接收安全更新。由于我们在这里使用.NET 8,我们知道它仍然受到支持(并且将接收安全更新)直到 2026 年 11 月 10 日(我在 2024 年 11 月写下这些)。随着框架或库变得不再受支持,重要的是要计划迁移到较新版本(顺便说一下,不一定是最新的版本 😉)。
假设我们的应用程序与外部依赖项进行通信,例如 Web 服务。在这种情况下,我们必须确保这些通信以安全的方式进行(通过利用身份验证和授权)并使用适当的协议(例如 HTTPS)。
-
在基础设施配置阶段:如果应用程序运行的基础设施不安全,那么所有提高应用程序安全性的努力都可能徒劳。在 CLI 应用程序的情况下,这些应用程序在用户的计算机上运行,责任通常委托给用户或其组织的 IT 部门,该部门通常控制员工的工作站。
-
在测试阶段:在测试阶段应进行特定于安全的测试,包括渗透测试和漏洞评估,以确保用户数据不会泄露,用户账户不会被破坏,并且应用程序不会被用于恶意活动。执行安全测试可以让我们验证是否满足安全要求。成熟的安全开发运营(DevSecOps)团队会定期进行此类测试。然而,安全测试至少应在将应用程序发布到生产之前进行。
-
在部署阶段:每个 DevSecOps 工程师都知道,如果你的 CI/CD 管道没有得到适当的安全保护,它可能成为安全威胁。秘密(如密码、API 密钥、服务连接、连接字符串等)应该保密。每个 CI/CD 工具都有自己的秘密管理机制,这通常以密钥库的形式出现,但我们也可以依赖为这些目的设计的工具,如 Azure Key Vault 或 HashiCorp Vault。对这些秘密的访问通常通过角色和权限进行限制。
-
在应用程序使用期间:对于不成熟的小组和组织来说,认为一旦应用程序发布到生产环境,安全工作就完成了,这是一个常见的错误。错误!这正是一切开始的地方。你可能认为我们在发布给用户之前投入了大量精力来确保应用程序的安全性,你是对的。然而,通过将我们的应用程序发布到野外,它将经历各种使用模式和用户环境,远远超出我们所能预期的、考虑的和计划的。因此,我们必须通过实施日志记录和审计机制,并定期更新和修补应用程序及其依赖项,来监控安全事件和异常。
-
Destructure.ByMaskingProperties方法用于在记录日志时忽略一系列属性。 -
随着新版本或错误修复的发布:无论是引入新功能还是修复错误,对引入的更改进行安全影响分析都是非常重要的。这可以通过执行回归测试来完成,以确保现有的安全控制仍然满足要求,并且没有引入任何漏洞。
幸运的是,有各种各样的工具可以帮助我们完成每个阶段。如果你的团队或组织已经采用了 DevSecOps 文化,你将已经了解其中许多工具。
由于 DevSecOps 超出了本书的范围,我们不会讨论你可能在每一步使用的工具的广泛范围。然而,我想介绍一些可以帮助我们评估和增强 CLI 应用程序安全状态的工具。
评估 CLI 应用程序的安全状态
有多种工具可以帮助评估应用程序的安全状态(包括 CLI 应用程序)。在广泛使用的工具中,我们发现以下工具:
-
使用
.csproj文件来检测第三方库中的漏洞。 -
Snyk:这个流行的工具为 .NET 项目提供漏洞报告和扫描。它可以集成到本地开发环境中,也可以集成到 CI/CD 管道中进行持续监控。
-
Mend Bolt:之前被称为 WhiteSource Bolt,这是一款安全扫描工具,可以与 Azure DevOps 或 GitHub 管道集成,扫描 .NET 项目中的开源漏洞并生成详细报告。
-
OWASP Dependency-Check:这个工具对于扫描第三方库和依赖项非常有效,这对于经常依赖外部包的 .NET 应用程序至关重要。
-
GitHub Advanced Security:这个工具将安全功能直接集成到 GitHub 或 Azure DevOps 工作流程中,执行代码扫描(它使用静态分析来检测潜在的安全漏洞和编码错误)和秘密扫描(它识别密码、API 密钥和其他秘密的模式,并检测它们是否以明文形式存储在存储库中),并执行依赖项审查并突出显示易受攻击的项。
这些工具的共同点是它们会告诉我们易受攻击的库,并为我们提供关于漏洞本身及其推荐修复的详细信息。
这些工具要么是免费的(例如 Mend Bolt 和 OWASP Dependency-Check),要么提供免费计划但功能有限(例如 SonarQube、Snyk 和 GitHub Advanced Security)。
它们在设置和配置的复杂性和所需努力方面也有所不同。因此,你会发现 SonarQube 设置最复杂,Snyk 设置中等容易,而 Mend Bolt、OWASP Dependency-Checker 和 GitHub Advanced Security 则是最容易设置的。
我建议从 Mend Bolt 开始,因为它作为 Azure DevOps 或 GitHub 的免费扩展提供,可以从它们各自的市场中获得。因此,这些工具通常是为组织或大型团队设计的。
然而,好消息是 .NET 已经为我们提供了一个现成的工具来评估和增强我们应用程序的安全状态。
这个工具通常被称为 dotnet-audit,它通过依赖 GitHub Advisory Database 来检测 .NET 项目依赖项(特别是 NuGet 包)中的漏洞。对于仅使用 .NET 的项目,例如 Bookmarkr,这个工具是完美的起点!
要执行此工具,我们只需输入以下命令:
dotnet list package --vulnerable
结果将在终端窗口中显示,看起来像这样:

图 13.1 – 列出易受攻击的包
如您在此图中可能已经注意到的,该命令并未在 Bookmarkr 的代码上执行,而是针对另一个应用程序。结果证明,我们对没有在 Bookmarkr 中检测到漏洞感到高兴(是的!),但随着时间的推移,随着引用库中漏洞的发现,这可能会发生变化。

图 13.2 – Bookmarkr 没有易受攻击的包
请记住,此命令可能要求您在执行之前运行dotnet restore。如果这是您第一次克隆 Git 仓库,这尤其正确。
另有一个命令不会直接扫描漏洞,但我推荐使用。此命令列出过时的包。虽然这些包可能没有已知的漏洞,但过时意味着它们将不再接收安全更新。我的建议是您考虑将这些包升级到较新的受支持版本(再次强调,如果这引入了破坏性更改,则不一定是最新版本)。
要运行此命令,只需键入以下内容:
dotnet list package --outdated
如您从执行此命令的结果中可以看到,尽管在 Bookmarkr 的依赖项中没有检测到漏洞,但其中一些已经过时:

图 13.3 – 列出过时的包
升级这些包的适当方式是创建一个新的分支,更新包,测试应用程序(如第十章中所述,使用手动和自动测试),以确保应用程序仍然按预期工作,并且我们没有引入回归,最后,提交一个拉取请求以将修改合并到main分支。
我们现在拥有必要的知识和工具来评估,并最终增强我们 CLI 应用程序的安全态势。让我们将注意力集中在保护我们的 CLI 应用程序与外部服务之间的通信上,以防止未经授权的访问并确保适当的管理用户。这可以通过身份验证来实现。
使用身份验证来保护远程通信
在第九章中,我们介绍了sync命令,它允许 Bookmarkr 将本地书签备份到远程位置,并在需要时检索它们。在执行此操作时,该命令还会同步本地和远程书签。
到目前为止,本地 CLI 应用程序与远程外部服务之间的通信一直是以不安全的方式进行。这意味着任何调用sync命令的人都可以检索您的个人书签,这显然不是您想要的,对吧?
为了解决这个问题,我们需要实现身份验证。
为什么身份验证很重要?
你可能想知道为什么在 CLI 应用程序的上下文中需要认证。毕竟,CLI 应用程序是在用户的计算机上运行的,这已经要求用户认证到他们的会话。
在 CLI 应用程序的上下文中,认证通常在与外部服务通信时(即向这些服务发送数据并从这些服务检索数据)是必需的。这确保了用户通过证明自己的身份,可以访问远程位置的数据。
如何进行认证
认证可以通过许多方式实现。最常见的一种是外部服务提供商为你提供一个个人访问令牌(PAT)。通常,你可以通过访问服务提供商的网站并登录到你的账户来获取或生成这样的令牌。从那里,在你的账户设置页面,你应该能够获取该令牌或生成一个新的令牌。这样的令牌通常在给定的时间内有效,并在之后过期。
一旦你有了这个令牌,你就可以将其作为参数传递给执行对外部服务调用的命令。一个例子就是sync命令,它可以按照以下方式调用:
bookmarkr sync --pat YOUR_PAT
在这里,YOUR_PAT最常见的是一个 GUID 值。
这个调用将使用接收到的 PAT 值对用户进行认证,然后再执行同步操作。
正如你可能注意到的,这种方法可能会很快变得繁琐,因为记住 PAT 的值并不容易。因此,CLI 应用程序通常会将这样的值存储在本地配置文件中、环境变量中,或者操作系统的密钥库中。这使用户可以在不总是需要传递 PAT 作为参数的情况下调用命令。命令将足够智能,能够在本地配置文件或环境变量中查找它,并在存在时使用它。如果不存在,命令应显示错误消息,提示用户提供它。如果 PAT 无效或已过期,命令还应能够通知用户这一点。
这是 GitHub 采用的方法——例如:你可以在 GitHub 账户内创建一个个人访问令牌(PAT),然后你可以使用它来从外部服务或应用程序中认证和访问 GitHub 资源。在我们的用例中,外部服务是提供和管理令牌的那个,而 CLI 应用程序只负责将这些令牌发送到外部服务。
另一种常用的方法是使用 auth 对用户进行身份验证。调用此命令通常会触发默认网络浏览器并将用户重定向到应用程序提供者的登录页面。身份验证成功后,身份提供者提供访问权限和一个 ID 令牌,CLI 可以使用该令牌进行后续对外部服务的请求。这些令牌,就像 PAT 一样,通常在对外部服务进行请求时包含在 HTTP 头部中。这种方法利用了 OAuth 2.0 和 OpenID Connect 协议。
在本章的剩余部分,我们将探讨如何为 Bookmarkr 实现身份验证以保护与 BookmarkrSyncr 外部服务的通信。
实现身份验证
在这里,我们将利用 PAT 方法,因为它更方便,甚至可以在无法启动网络浏览器的环境中工作,例如 CI/CD 管道。
为了说明如何实现这一点,我们需要在两个级别上实现功能:
-
BookmarkrSyncr:外部服务将接收 PAT,验证它,如果有效则对用户进行身份验证
-
Bookmarkr:CLI 应用程序的责任是将令牌传递给外部服务并根据发送的请求的响应采取行动
让我们从将所需功能添加到 BookmarkrSyncr 开始。
使用 PAT 验证外部服务
为了简化问题,我们假设 BookmarkrSyncr(其代码可在 AppendixB 文件夹中找到)持有两个 PAT 令牌:一个有效的和一个已过期的。用户传递的所有其他值都将被视为无效,并将因此被拒绝。
令牌验证服务也将是一个非常基础的。
尽管大部分代码保持不变,但我们不得不对传递给 MapPost 方法的参数进行以下更改:
app.MapPost("/sync", async ([FromHeader(Name = "X-PAT")] string pat, List<Bookmark> bookmarks, ITokenValidator tokenValidator, HttpContext context) =>
{
…
}
让我们来解释这些更改:
-
我们指出 PAT 令牌的值来自名为
X-PAT的 HTTP 头部,并且该值将存储在名为pat的输入参数中。 -
我们传递一个类型为
ITokenValidator的参数,这是一个我们创建的服务,用于检索和验证 PAT 令牌。 -
我们传递当前的 HTTP 上下文,我们将需要为 HTTP 响应设置 HTTP 头部,特别是通知客户端收到的 PAT 令牌是无效或已过期的。我们使用不同的响应头部以确保客户端确切地知道请求为何未经授权,因为这是一种良好的编程实践。
然后,我们调用 TokenValidator 服务的相关方法来检查 PAT 令牌是否无效或已过期,如果是这样,我们在响应对象中设置适当的 HTTP 头部。代码如下:
// Ensure the Personal Access Token (PAT) is valid
if (!tokenValidator.IsValid(pat))
{
context!.Response.Headers["X-Invalid-PAT"] = pat;
return Results.Unauthorized();
}
// Ensure the Personal Access Token (PAT) is not expired
if (tokenValidator.IsExpired(pat))
{
context!.Response.Headers["X-Expired-PAT"] = pat;
return Results.Unauthorized();
}
注意我们是如何设置 HTTP 头部的。我们使用索引器语法。这将设置头部。如果头部已经存在,它将用新值替换现有值。
我们最后需要做的是注册 TokenValidator 服务。这可以通过使用这个广为人知的语法为任何服务完成:
builder.Services.AddScoped<ITokenValidator, TokenValidator>();
TokenValidator 服务非常基础,其代码如下所示:
public class TokenValidator : ITokenValidator
{
private readonly List<PatToken> _tokens = new();
public TokenValidator()
{
// we are simulating a token store here...
_tokens.Add(new PatToken { Value = "4de3b2b9-afaf-406c-ab0d-
d59ac534411d", IsExpired = false });
_tokens.Add(new PatToken { Value = "16652977-c654-431e-8f84-
bd53b4ccd47d", IsExpired = true });
}
public bool IsExpired(string token)
{
var retrievedToken = _tokens.FirstOrDefault(t => t.Value.
Equals(token, StringComparison.OrdinalIgnoreCase));
if(retrievedToken == null) return false;
return retrievedToken.IsExpired;
}
public bool IsValid(string token)
{
var retrievedToken = _tokens.FirstOrDefault(t => t.Value.
Equals(token, StringComparison.OrdinalIgnoreCase));
return retrievedToken != null;
}
}
值得注意的是,我们在这里模拟了一个令牌存储(在类的构造函数中)。在现实世界的场景中,我们会有一个持久的令牌存储(例如数据库)。但为了演示的目的,将这些 PAT 令牌存储在内存中使得代码更容易理解。我还想让你注意到,为了演示的目的,我们有两个令牌:一个是有效的令牌,另一个代表已过期的令牌。任何其他值都将被视为无效,因为它无法在令牌存储中找到(顺便说一句,这是现实的)。因此,客户端(CLI 应用程序)将根据令牌的有效性表现出不同的行为。
太棒了!现在我们已经准备好了外部服务,是时候更新客户端,使其能够传递 PAT。
将 PAT 从 CLI 应用程序传递到外部服务
为了实现这一点,我们需要修改 sync 命令和服务代理的代码。
更具体地说,我们需要做以下事情:
-
在
sync命令中添加一个--pat参数。 -
修改 HTTP 客户端对外部服务发出的请求,以便发送 PAT 令牌。
-
修改代码以将 PAT 令牌存储在环境变量中,并在调用
sync命令时未指定--pat参数时从那里检索它。
让我们从第一步开始。
让我们转到 SyncCommand.cs 文件,在 Options 区域中,让我们添加一个用于 PAT 的 Option,如下所示:
private Option<string> patOption = new Option<string>(
["--pat", "-p"],
"The PAT used to authenticate to BookmarkrSyncr"
);
如我们之前提到的,这个 Option 是可选的。
接下来,我们需要让命令使用这个 Option。为此,我们需要将此指令添加到类的构造函数中:
AddOption(patOption);
现在,让我们修改命令的处理方法,以便 HTTP 客户端可以向 BookmarkrSyncr 服务发送 PAT。为此,我们需要修改处理方法签名,将 PAT 作为参数传递,如下所示:
private async Task OnSyncCommand(string patValue)
{
...
}
再次回到类的构造函数,我们需要修改对 SetHandler 方法的调用,以便传递 PAT Option 作为参数。修改后的方法调用如下所示:
this.SetHandler(OnSyncCommand, patOption);
接下来,我们需要将此令牌传递给服务代理。为此,我们首先需要修改处理方法中对服务代理的调用,如下所示:
var mergedBookmarks = await _serviceAgent.Sync(patValue, retrievedBookmarks);
然后,我们需要更新 BookmarkrSyncrServiceAgent 类的代码,以确保在调用外部服务之前令牌的存在。
我们首先需要做的是,显然地,将 PAT 值作为参数传递给其 Sync 方法,如下所示:
public async Task<List<Bookmark>> Sync(string pat, List<Bookmark> localBookmarks)
之后,方法应该做的第一件事是确保 PAT 的存在(即确保我们没有向外部服务发送空或空值)。我们通过在方法开头添加以下代码来实现这一点:
// ensure that the pat is present
if(string.IsNullOrWhiteSpace(pat))
{
string? value = Environment.GetEnvironmentVariable("BOOKMARKR_
PAT");
if(value == null) throw new PatNotFoundException(pat);
pat = value;
}
在这里需要注意以下几点:
-
我们既不验证令牌,也不检查它是否已过期。这是 BookmarkrSyncr 外部服务的角色。
-
用于存储 PAT 令牌的环境变量名为
BOOKMARKR_PAT。这个名字确保这个变量不会与用户计算机上设置的任何其他变量冲突。 -
我们创建了一个自定义异常类
PatNotFoundException,以便在找不到 PAT 令牌时通知客户端。
现在,由于已经检索到 PAT 令牌,我们需要将其发送到外部服务。因此,我们需要修改 HTTP 客户端,以便在请求的 HTTP 标头中传递令牌,如下所示:
var client = _clientFactory.CreateClient("bookmarkrSyncr");
// Add the PAT to the request header
client.DefaultRequestHeaders.Add("X-PAT", pat);
var response = await client.PostAsync("sync", content);
如果对外部服务的请求成功,我们知道 PAT 令牌是有效的。如果它尚未存在于那里,我们将将其保存到环境变量中。因此,更新的代码如下:
if (response.IsSuccessStatusCode)
{
// saving the PAT to the environment variable, if not already
string? value = Environment.GetEnvironmentVariable("BOOKMARKR_
PAT");
if(value == null || !value.Equals(pat)) Environment.
SetEnvironmentVariable("BOOKMARKR_PAT", pat);
// remaining of the code
}
然而,如果 PAT 令牌无效或已过期,我们需要通知客户端,以便向用户显示适当的错误消息。因此,我们需要修改处理 Unauthorized 状态码的代码块,如下所示:
switch(response.StatusCode)
{
case HttpStatusCode.Unauthorized:
if (response.Headers.TryGetValues("X-Invalid-PAT", out var
headerValues))
throw new PatInvalidException(pat);
if (response.Headers.TryGetValues("X-Expired-PAT", out var
headerValues2))
throw new PatExpiredException(pat);
throw new HttpRequestException($"Unauthorized access:
{response.StatusCode}");
// remaining of the code
}
这段代码简单易懂。不过,值得一提的是,我们在这里又创建了自定义异常类,即 PatInvalidException 和 PatExpiredException,以便在抛出它们时清楚地表达错误意图。这些异常类可以在 BookmarkrSyncrServiceAgent 文件夹中的 Exceptions.cs 文件中找到。
最后一步是回到 SyncCommand 类,处理服务代理的响应,并向用户显示适当的错误消息。为此,我们需要修改 OnSyncCommand 处理方法中的代码,以捕获之前描述的自定义异常。catch 块如下:
catch(PatNotFoundException ex)
{
Helper.ShowErrorMessage([$"The provided PAT value ({ex.Pat}) was
not found."]);
}
catch(PatInvalidException ex)
{
Helper.ShowErrorMessage([$"The provided PAT value ({ex.Pat}) is
invalid."]);
}
catch(PatExpiredException ex)
{
Helper.ShowErrorMessage([$"The provided PAT value ({ex.Pat}) is
expired."]);
}
就这样 – 我们已经完成了 Bookmarkr 为确保通过 PAT(it was about time, right? 😉)与外部服务 BookmarkrSyncr 安全通信所需的所有代码修改。
让我们看看这行不行!
第一个测试是不传递 PAT 令牌调用 sync 命令。鉴于它不会在 BOOKMARKR_PAT 环境变量中找到,我们预期会看到以下错误:

图 13.4 – 未找到 PAT 令牌
第二个测试是使用无效的 PAT 令牌调用 sync 命令。这里也预期会看到一个错误消息:

图 13.5 – PAT 令牌无效
下一个测试是使用过期的 PAT 令牌调用 sync 命令。这里也预期会看到一个错误消息:

图 13.6 – PAT 令牌已过期
最后的测试是使用有效的 PAT 令牌调用 sync 命令。我们可以看到书签已经同步,并且 PAT 令牌已保存到 BOOKMARKR_PAT 环境变量中:

图 13.7 – PAT 令牌有效
太棒了!我们现在已经知道所有需要了解的知识和技能来评估和增强我们的 CLI 应用程序的安全性。
摘要
在本章中,我们探讨了安全可以采取的各种形式,并了解到它不仅限于应用程序代码的安全性,而是涵盖了整个生命周期中的广泛领域。我们学习了我们可以使用哪些工具和技术来评估和增强我们的 CLI 应用程序的安全状况。我们还学习了如何通过实现身份验证和授权来保护用户数据的访问,尤其是在处理外部服务时。
恭喜!您现在拥有了构建、安全、测试、打包和发布您自己的 CLI 应用程序到世界上的所有必要知识和技能。看看你——你取得了多么大的成就。花点时间为自己感到自豪,并庆祝一下!
在这本书的每一页上,与您的旅程一起,我的旅程几乎结束了。但在您翻到最后一页并轻轻合上这本书的封面之前,我还有最后一件事想与您分享,那就是一个最后的章节,其目的是为您提供额外的学习材料,帮助您继续您的旅程,并指向一些有用的工具和库。
轮到你了!
按照提供的代码进行实践是学习的好方法。
更好的方法是挑战自己完成任务。因此,我挑战您通过添加以下功能来改进 Bookmarkr 应用程序。
任务 #1 – 更新依赖项版本
我们已经看到了如何列出已达到其支持结束的过时包,并讨论了更新这些包的重要性。然而,我还没有更新这些。
您的任务是将代码克隆到您的 GitHub 或 Azure DevOps 账户中,并更新它。
一旦依赖项已更新,您需要验证应用程序的行为是否受到影响,换句话说,您需要确保应用程序仍然按预期运行。这可以通过运行您之前开发的测试来完成。
任务 #2 – 使用 Mend Bolt 扫描代码中的漏洞
您的任务是将代码克隆到您的 GitHub 或 Azure DevOps 账户中,启用 Mend Bolt,并运行安全扫描。如果发现任何漏洞,请修复它!
任务 #3 – 允许 BookmarkrSyncr 管理多个用户
尽管我们更新了 BookmarkrSyncr 以接收和验证 PAT,但它并没有使用此令牌来检索和更新适当用户的数据。
您的任务是更新代码以实现这一点。最简单的方法是为每个与 PAT 值匹配名称的用户创建一个单独的 JSON 文件。因此,每个用户的书签都可以存储和检索于此。
第十四章:其他资源和库
到目前为止,您已经拥有了开始构建出色的 CLI 应用程序所需的一切。在这本书的每一页中,我们都涵盖了每一步:设置您的开发环境、理解 CLI 应用程序的基础、开发 CLI 应用程序——从基本概念到最先进的概念、测试、安全性、性能优化,最后是打包和部署。
然而,在开发您的 CLI 应用程序时,您可能会遇到需要加深在这些一个或多个领域技能的情况。您也可能遇到需要设计复杂功能或代码的情况。这些是每个开发者都会遇到的情况,无论他们构建什么类型的应用程序,您也不例外。
正因如此,我设计了最后一章:为您提供在这些情况下的指导,以免您迷失方向。在需要指导时,请将其作为参考。
进一步阅读和资源
虽然在网上有很多很有趣的内容可以阅读,但我将提到一些 Packt Publishing 收集中我发现特别有用的书籍。
《C# 12 和 .NET 8》,作者:Mark J. Price
如果您正在学习 .NET,不可能不推荐 Mark J. Price 的书。这本书涵盖了您需要了解的所有关于 .NET 开发的内容。此外,Mark 还会随着每个新的 .NET 版本更新他的书。截至写作时,最新版本是涵盖 .NET 8 的版本。

要了解更多关于这本书的信息,请访问 www.packtpub.com/en-ca/product/c-12-and-net-8-modern-cross-platform-development-fundamentals-9781837635870。
《使用 C# 进行重构》,作者:Matt Eland
当您构建 CLI 应用程序时,您很可能会遇到现有的代码,并且可能需要对其进行重构以提高其安全性和性能。在这种情况下,您会很高兴拥有这本书。
通过阅读这本书,您将学习到许多利用最新 C# 特性的重构技术。您还将了解如何利用 AI 助手,如 GitHub Copilot Chat,来帮助您进行重构、测试和文档编写。

要了解更多关于这本书的信息,请访问 www.packtpub.com/en-ca/product/refactoring-with-c-9781835089989。
《C# 和 .NET 的实用测试驱动开发》,作者:Adam Tibi
如果你不实施自动化测试,你根本无法保证应用程序的质量。虽然你可以不依赖测试驱动开发(TDD)来实施自动化测试,正如我经常在每个教授这个主题的课堂上所说的:“自动化测试确保你的代码执行它被设计要执行的任务,而 TDD 确保你的代码执行你的业务需求所声明的任务。”这总结了简单自动化测试和 TDD 实践之间的区别。
本书不仅涵盖了你在应用程序中实现单元测试(包括模拟外部依赖项)所需了解的内容,还涵盖了集成 TDD 实践。

要了解更多关于这本书的信息,请访问www.packtpub.com/en-ca/product/pragmatic-test-driven-development-in-c-and-net-9781803230191。
《C# 7 和.NET Core 2.0 高性能》,作者:Ovais Mehboob Ahmed Khan
尽管本书依赖于.NET 的较旧版本,但它所教授的概念和原则仍然有价值且适用。
通过阅读这本书,你将了解影响应用程序性能的常见陷阱。你还将学习如何测量应用程序的性能(整体上,但更重要的是在其热点路径上)。然后,你将学习设计和内存管理技术(包括异步编程、多线程和优化数据结构),这些技术将帮助你提高应用程序的性能。

要了解更多关于这本书的信息,请访问www.packtpub.com/en-ca/product/c-7-and-net-core-20-high-performance-9781788470049。
这些阅读将使你对本书涵盖的各个主题有更深入的知识和理解。
然而,你会发现,在许多情况下,已经存在一个库可以满足你想要实现的功能。你应该考虑使用这样的库。
我在这里特意强调“应该”。我的观点是,你不必使用任何库,但至少在分析它并决定它不符合你的需求后,你应该有意识地忽略它。
通过依赖现有的库,你可以避免编写额外的代码、维护它以及测试它。你只需将其作为实现目标的一种快速方式。
如果你已经开发了一段时间,你很可能遇到过一些你认为有用并且反复在项目中使用的库。在下一节中,我将与你分享我在旅途中发现的一些库(以及我使用过的)。
CLI 应用程序开发的实用库
有很多有用的库,本节并不打算列出它们全部(那样需要一本百科全书了😉)。此外,这里列出的也绝不是最好的。它们是我沿途发现并经常使用的。这些库与我们在这本书的各页中已经使用的库相加。
Polly
Polly 是一个弹性和暂态故障处理的库。对于 CLI 应用程序,它可以帮助管理在与外部服务或数据库交互时出现的网络故障、超时和其他暂态错误。这确保了您的 CLI 应用程序即使在不可靠的环境中也能保持稳健和响应。
要了解更多关于 Polly 的信息,请访问 github.com/App-vNext/Polly。
HangFire
HangFire 是一个不依赖于 Windows 服务或单独进程的后台作业处理库。在 CLI 应用程序中,它可以用于异步地安排和执行长时间运行的任务。这允许您的 CLI 应用程序在不阻塞主执行线程的情况下处理耗时操作。
要了解更多关于 HangFire 的信息,请访问 github.com/HangfireIO/Hangfire。
StackExchange.Redis
StackExchange.Redis 是一个高性能的 Redis 客户端。虽然主要用于 Web 应用程序,但它对需要缓存或需要与 Redis 数据库进行数据存储或检索的 CLI 应用程序非常有用。这允许您的 CLI 应用程序从外部服务检索数据并将其本地缓存,从而减少对网络连接的依赖,同时提高应用程序的整体性能。
要了解更多关于 StackExchange.Redis 的信息,请访问 github.com/StackExchange/StackExchange.Redis。
MediatR
MediatR 实现了中介者模式,这有助于简化并解耦 CLI 应用程序中组件之间的通信。它特别适用于组织命令处理和实现复杂的工作流程。
要了解更多关于 MediatR 的信息,请访问 github.com/jbogard/MediatR。
MassTransit
MassTransit 是一个分布式应用程序框架。对于需要与消息队列交互或实现事件驱动架构的 CLI 应用程序非常有用。为此,MassTransit 使得创建利用基于消息的、松散耦合的异步通信的应用程序和服务变得容易,从而提高了可用性、可靠性和可伸缩性。
要了解更多关于 MassTransit 的信息,请访问 github.com/MassTransit/MassTransit。
BenchmarkDotNet
BenchmarkDotNet是一个强大的基准测试库。它对于需要测量和优化性能的命令行应用程序来说极其有用,它允许你识别应用程序中最慢的路径,比较不同的实现,并做出基于数据的决策。
要了解更多关于BenchmarkDotNet的信息,请访问github.com/dotnet/BenchmarkDotNet。
Portable.BouncyCastle
BouncyCastle是一个加密库。它允许你使用不同的设置生成随机密码,以满足 OWASP 的要求。对于命令行应用程序来说,当你想要为用户账户生成默认密码或一次性密码(例如,用于访问加密文件)时,这可能很有用。
要了解更多关于BouncyCastle的信息,请访问github.com/novotnyllc/bc-csharp。
NSubstitute
NSubstitute是一个用于单元测试的模拟框架。它在执行单元测试时模拟外部依赖项时非常有用。
要了解更多关于NSubstitute的信息,请访问github.com/nsubstitute/NSubstitute。
AutoFixture
AutoFixture是一个.NET 开源库,它通过自动化测试数据生成过程,帮助开发者通过简化编写和维护单元测试的过程来编写可维护的单元测试。它与流行的测试框架(如 MS Test、NUnit 和 xUnit)以及流行的模拟框架(如 NSubstitute 和 Moq)集成。
要了解更多关于AutoFixture的信息,请访问github.com/AutoFixture/AutoFixture。
RichardSzalay.MockHttp
MockHttp是 Microsoft 的HttpClient类的测试层。它允许为匹配的 HTTP 请求配置模拟响应,并且可以在不实际执行网络调用的情况下测试你的应用程序的服务层。在测试阶段,它对于模拟 CLI 应用程序可能依赖的外部 API 和服务的调用非常有用。
要了解更多关于MockHttp的信息,请访问github.com/richardszalay/mockhttp。
摘要
在这些阅读材料和图书馆的帮助下,你可以将你的命令行应用程序提升到下一个层次,并构建一些相当令人印象深刻且实用的应用程序。我迫不及待地想看看你会创造出什么。不要犹豫,让我知道你的想法。
还有一件事:这个列表不是一成不变的。它是活生生的,你应该保持它的活力。如何做到?通过不断地回顾和更新它,随着技术和趋势的发展。我将这个任务传给了你。
你的回合!
跟随提供的代码是一种通过实践学习的好方法。
一个更好的方法是挑战自己完成任务。因此,我挑战你通过添加以下功能来改进Bookmarkr应用程序。
任务 #1 – 为 Bookmarkr 列出附加功能
这时,我将向你介绍Bookmarkr,让你可以将其变成你自己的。
我在挑战你列出一些你希望在Bookmarkr中拥有的额外功能。你可以将它们作为功能请求添加到 GitHub 上,这样你或者其他人就可以实现它们。
任务 #2 – 列出实现功能所需的技能和库
一旦你决定了想要实现的功能,首先列出你需要用到的技能和库。这一步的目的是为了避免重新发明轮子,而是利用现有的资源,并利用它们来实现新的目标。
记住:你的用户对你的编码技能不感兴趣(他们甚至看不到代码)。他们感兴趣的是你的(命令行界面)应用程序为他们带来的价值。


浙公网安备 33010602011771号