在上一章中,我们为 Bookmarkr(用于管理书签的 CLI 应用程序)奠定了基础。我们从一个基础的控制台应用程序开始,并利用 System.CommandLine 库为该应用程序注入了 CLI 功能。
目前,我们的 CLI 应用程序仅包含一个命令(link),该命令允许通过添加新书签或列出、更新、删除现有书签来管理书签。
通过本章节,我们将致力于实现两个目标:
- 深入探讨如何进一步控制 CLI 应用程序命令选项的输入值。
- 了解如何在 CLI 应用程序中处理输入和输出文件。这对于导入导出操作非常实用,能更方便地备份恢复应用数据,并与其他应用程序共享数据。
具体而言,我们将涵盖以下主要主题:
- 控制选项的输入值,确定何时使用必选与非必选选项,设置选项的默认值,控制选项允许的值集,以及验证输入值
- 处理作为 CLI 应用程序参数传递的文件(包括输入和输出文件),这将有助于为我们的 CLI 应用程序添加导入导出功能
控制选项的输入值
参数是任何应用程序的核心。它们允许用户指定要执行的命令,并为输入参数提供值。因此,在本节及其子章节中,我们将深入探讨处理这些参数的细节。
必填项与可选项
当前状态下,添加新书签需要同时提供名称和 URL,这显然是我们所期望的。
这意味着如果我们调用添加链接命令时未传递这些选项或其值,就会收到如下错误提示:
图 5.1 - 书签名称和 URL 应为必填项
然而,如果我们运行程序时不传递这两个选项,目前会得到以下结果:
图 5.2 - 当前添加的书签名称和 URL 是可选的
请注意,一个没有名称和 URL 的新书签被添加到了书签集合中。这显然不是我们想要的结果!
幸运的是,Option 类提供了一个布尔值来指定该选项是必需还是可选的。
要使名称和 URL 选项成为必填项,我们需要将它们的 IsRequired 属性设为 true:
nameOption.IsRequired = true;
urlOption.IsRequired = true;
如果现在运行程序时不传入选项或其值,就会收到错误提示:
图 5.3 - 现在添加书签时必须提供名称和 URL
还需注意的是,帮助菜单中明确标注了这两个选项是必填项。
目前,我们有两个必选选项。现在让我们添加一个可选选项。
当选项为非必选(即可选)时,如果我们不传递该选项或其值 ,应用程序不应返回错误。
让我们看一个示例说明。
假设我们希望按类别对书签进行分类。通过这种方式,可以想象我们可能只想列出属于特定类别的书签。
为此,我们首先要在 Bookmark 类中添加一个 Category 属性, 如下所示:
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 应用,反复输入这个长格式可能会让人沮丧。这就是为什么短格式(比如 -m)会更合适。
在实际应用中,你会发现刚开始使用 CLI 应用程序的用户会依赖长格式选项,随着对 CLI 应用的熟悉程度提高,他们会逐渐转向使用短格式。
例如,Bookmarkr 的初级用户会更倾向于使用这种语法:
bookmarkr 链接添加 --name "Packt Publishing" --url "https://packtpub.com"
而熟练用户则可能更喜欢这种语法:
bookmarkr 链接添加 -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 – 使用 category 选项的默认值
然而,如果我们确实为类别提供了值,可以看到该值实际被使用了:
图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 应用程序通过一个或多个命令支持批量操作,这一点非常重要。在我们的示例中,我们希望执行批量操作以同时添加多个书签。
对于字符串类型的选项,最小值和最大值均设置为 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 设置参数数量的代码行.
接下来,我们需要修改名称、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 等浏览器拥有各自专有的书签导入与导出结构。
为了简化流程,我们不会对这些格式进行解析或转换。我们的目标是专注于作为命令行应用程序的一部分处理输入和输出文件。不过,我们将以 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 – 合并输入文件中的现有链接
当从现有文件导入书签时,部分书签可能已存在于应用程序当前保存的书签中。
这种情况下,命令行应用程序的最佳实践是为用户提供选项,让他们选择是合并这些现有链接,还是直接丢弃并不导入它们。
在此任务中,我要求您通过为 import 命令添加一个可选的 --merge 选项来实现这一最佳实践。
因此,带有 --merge 选项的 import 命令语法如下所示:
$ bookmarkr import --file <path to the input file> --merge
当指定 --merge 选项时,import 命令的预期行为是:对于输入文件中的每个书签, 适用以下规则:
- 如果其 URL 已存在于应用程序保存的书签列表中,则现有书签的名称应更新为输入文件中该 URL 对应的名称
- 否则,书签应直接被添加到应用程序保存的书签列表中