C# 中的样板命令行工具应用程序





5.00/5 (1投票)
从命令行参数处理和控制台实用程序的异常处理基础开始
引言
我编写了许多命令行实用程序——通常是代码生成器等。这些工具遵循命令行解析、使用情况报告和异常处理的基本总体模式。由于这种基本结构在不同工具中是相似的,我从一些样板代码开始,然后修改它们来创建它们。我将在这里分享和解释这些代码。多年来尝试了许多不同的构建这些工具的技术后,我确定了一个运行良好的基本流程,并且它从这些代码开始。
概念化这个混乱的局面
我们这里有几个问题需要解决。这些问题几乎适用于任何命令行工具应用程序:我们需要提供一个使用屏幕、处理命令行参数并报告错误。
处理命令行参数
使用我们的技术,开关采用 /<switch> {<arguments>}
的形式
使用列出的代码,它还接受可变数量的未切换参数,这些参数在任何开关之前。这可以在代码中更改。
处理命令行参数似乎可以生成或泛化。事实是,它可以,但额外的复杂性成本通常不值得。即使我们有一种方法来泛化参数处理,添加、删除或更改参数仍然需要更改应用程序本身的逻辑,所以你并没有真正减少多少工作——如果说有的话。这在很大程度上是我的经验。另一个问题是,虽然泛化 80% 的参数情况相当容易,但另外 20% 并非微不足道。这使得它不如编写专门的参数处理代码那么合理。
我们将要做的,是获取 C# 在我们的 Main()
中提供给我们的部分处理过的 string[] args
数组,并对其进行一个简单的循环,驱动一个处理我们标志的 switch/case
。优点是它易于维护和修改,并且可以处理带引号的文件名等。缺点是它很基础,并且还会奇怪地接受诸如 "/switch"
这样的带引号的开关本身。没关系,这只是不理想,但它也不会造成任何伤害。当你接受任意数量的未命名参数在任何开关之前时,会有一个小小的麻烦。我们会在提供的样板代码中处理这个问题。
顺便提一下,经验告诉我,尽管从 STDIN
接收输入可以实现管道操作,但这不是一个好主意,因为控制台会“烹饪”输入,这可能会损坏输入,导致难以追踪的错误。同时,将数据发送到 STDOUT
用于显示或打印目的,或直接发送到文件以获取“未烹饪”的副本,可能是可取的。因此,我的工具要求您至少指定一个输入文件(如果它们接收输入的话),但它们不要求指定输出文件。这是您只能从经验中学到的事情之一。我的一些早期项目是为管道操作设计的,它可能会产生问题,尤其是对于 Unicode 流。
另外请注意,因为我们不需要输出文件,所以我们将所有消息发送到 Console.Error
,而不是 Console
/Console.Out
。这是因为如果我们将输出发送到 STDOUT
,我们希望带外信息发送到 STDERR
。这对于简洁的命令行界面很重要。
异常处理
我们的可执行文件的想法是报告任何错误,然后在成功时返回 0 作为其退出代码,或在失败时返回其他值。我们通过将整个过程包装在一个 try/catch/finally
块中,然后使用 catch
部分从抛出的异常中返回一个错误代码来处理这个问题。这里有一个小小的麻烦是,这种全局异常处理不是我们在调试时想要的,所以如果我们在使用 DEBUG
编译时常量进行编译,我们就会取消 catch
块,只是让异常被抛出。这大大简化了应用程序的调试。
使用屏幕
使用屏幕是我们内置的“帮助”功能。它提供了应用程序的基本描述、版本以及命令行使用信息和开关的含义。每当发生错误时,我们都会报告它,因为我们假设输入参数一定有问题。如果这种行为不合乎要求,您可以更改它。我们从 AssemblyInfo.cs 中指定的程序集属性收集大部分信息。
陈旧文件处理
我之所以包含这个功能,是因为我在几乎所有的命令行生成器工具中都使用了它。对于需要很长时间才能工作的工具,例如 DFA 词法分析器和解析器生成器,这至关重要,但对于许多项目来说,情况可能如此,我认为这个功能的使用场景多于反对它的情况。如果您的工具生成输出文件并且可能需要大量时间执行,那么提供一个仅当输出比输入旧时才重新运行的功能会很有帮助。这样,工具只有在输入文件发生更改时才会执行工作。我们通过 /ifstale
开关来实现此功能。如果您的工具不生成文件,则不需要此功能,但这里提供了它,因为工具通常会生成文件。请注意,我们还会检查可执行文件本身是否比输出文件新。这使得在相关项目中作为预构建步骤使用变得更容易,同时也在开发工具本身。基本上,如果可执行文件已更改,它将重新运行生成过程。
编写这个混乱的程序
这是样板代码的几乎全部内容。我唯一省略的是周围的命名空间和 using 声明。否则,我们将从头到尾介绍代码
class Program
{
static readonly string _CodeBase =
Assembly.GetEntryAssembly().GetModules()[0].FullyQualifiedName;
static readonly string _File = Path.GetFileName(_CodeBase);
static readonly Version _Version = Assembly.GetEntryAssembly().GetName().Version;
static readonly string _Name = _GetName();
static readonly string _Description = _GetDescription();
static int Main(string[] args)
{
int result=0; // the exit code
// command line args
List<string> inputFiles = new List<string>(args.Length);
string outputFile = null;
bool ifStale = false;
// holds the output writer
TextWriter output = null;
try
{
// no args prints the usage screen
if (0 == args.Length)
{
_PrintUsage();
result = -1;
}
else if (args[0].StartsWith("/"))
{
throw new ArgumentException("Missing input files.");
}
else
{
int start = 0;
// process the command line args:
// process input file args. keep going until we find a switch
for (start = 0; start < args.Length; ++start)
{
var a = args[start];
if (a.StartsWith("/"))
break;
inputFiles.Add(a);
}
// process the switches
for (var i = start; i < args.Length; ++i)
{
switch (args[i].ToLowerInvariant())
{
case "/output":
if (args.Length - 1 == i) // check if we're at the end
throw new ArgumentException(string.Format("The parameter
\"{0}\" is missing an argument", args[i].Substring(1)));
++i; // advance
outputFile = args[i];
break;
case "/ifstale":
ifStale = true;
break;
default:
throw new ArgumentException
(string.Format("Unknown switch {0}", args[i]));
}
}
// now that the switches are parsed
// would be a good time to validate them
// now let's check if our output is stale
var stale = true;
if (ifStale && null != outputFile)
{
stale = false;
foreach (var f in inputFiles)
{
if (_IsStale(f, outputFile) || _IsStale(_CodeBase, outputFile))
{
stale = true;
break;
}
}
}
if (!stale)
{
Console.Error.WriteLine("{0} skipped generation of {1}
because it was not stale.", _Name, outputFile);
}
else
{
// DO WORK HERE!
// TextWriter output will be cleaned up automatically on exit,
// so set it to your output source when ready to generate.
// It's a good idea not to open the output until everything
// else has been done so that errors in the input will not
// cause an existing file to be overwritten.
}
}
}
#if !DEBUG
// error reporting (Release only)
catch (Exception ex)
{
result = _ReportError(ex);
}
#endif
finally
{
// clean up
if (null != outputFile && null != output)
{
output.Close();
output = null;
}
}
return result;
}
static string _GetName()
{
foreach (var attr in Assembly.GetEntryAssembly().CustomAttributes)
{
if (typeof(AssemblyTitleAttribute) == attr.AttributeType)
{
return attr.ConstructorArguments[0].Value as string;
}
}
return Path.GetFileNameWithoutExtension(_File);
}
static string _GetDescription()
{
foreach (var attr in Assembly.GetEntryAssembly().CustomAttributes)
{
if (typeof(AssemblyDescriptionAttribute) == attr.AttributeType)
{
return attr.ConstructorArguments[0].Value as string;
}
}
return "";
}
#if !DEBUG
// do our error handling here (release builds)
static int _ReportError(Exception ex)
{
_PrintUsage();
Console.Error.WriteLine("Error: {0}", ex.Message);
return -1;
}
#endif
static bool _IsStale(string inputfile, string outputfile)
{
var result = true;
// File.Exists doesn't always work right
try
{
if (File.GetLastWriteTimeUtc(outputfile) >= File.GetLastWriteTimeUtc(inputfile))
result = false;
}
catch { }
return result;
}
static void _PrintUsage()
{
var t = Console.Error;
// write the name of our app. this actually uses the
// name of the executable so it will always be correct
// even if the executable file was renamed.
t.WriteLine("{0} Version {1}", _Name,_Version);
t.WriteLine(_Description);
t.WriteLine();
t.Write(_File);
t.WriteLine(" <inputfile1> { <inputfileN> } [/output <outputfile>] [/ifstale]");
t.WriteLine();
t.WriteLine(" <inputfile> An input file to use.");
t.WriteLine(" <outputfile> The output file to use - default stdout.");
t.WriteLine(" <ifstale> Do not generate unless
<outputfile> is older than <inputfile>.");
t.WriteLine();
t.WriteLine("Any other switch displays this screen and exits.");
t.WriteLine();
}
}
您会注意到的第一件事是几个 static readonly
字段。它们包含有关我们可执行文件的基本信息,主要用于使用屏幕,但您的代码中其他地方也可能用到,因此为了方便访问,它们在此处提供。
之后,是 Main()
例程。请注意,我们在这里返回一个 int
。这是为了让我们能够尽可能多地控制返回值,如果这个工具要在批处理文件或构建步骤中使用,这一点至关重要。然而,在大多数情况下,我们将像往常一样通过抛出异常来处理错误,并让样板逻辑将其转换为退出代码。
下一个关注点是命令行参数变量列表。我喜欢每个参数一个变量。每当我们添加或删除命令行参数时,它的对应变量都应该在此处声明或删除。这样,将它们全部声明在一个位置会更清晰。每当我修改这些变量时,接下来我做的就是相应地修改 _PrintUsage()
例程,以免忘记。
现在我们有了 output
参数。如果未指定 /output
,则应将其设置为 Console.Out
,或者通过 StreamWriter
等设置为指定的 outputFile
。当程序退出时,它将自动关闭。重要的是只在最后可能的时候设置它,这样如果之前发生任何错误,它就不会擦除输出文件的先前内容。显然,如果您不打算有输出文件,则应删除所有相应的代码。
现在我们可能需要为以后需要关闭的任何资源保留变量。例如,如果您访问数据库,您可能希望保留一个连接,然后稍后关闭它。如果是这样,请在此处声明一个变量并将其设置为 null。稍后填充它。在下面的 finally 块中,我们将关闭它。
最后,我们开始我们的 try/catch/finally
块,它包围了我们大部分的代码。在这里,我们开始做真正的工作。
之后,我们进行一些参数前验证,首先是打印使用屏幕,如果没有指定参数则退出。
接下来,我们循环直到找到一个表示开关的开头 /
。在此之前出现的每个参数都会进入 inputFiles
列表。如果您的应用程序不接受多个输入文件,您会希望修改此代码,只将第一个参数读取到 inputFile
变量中,而不是循环读取到 inputFiles
中。显然,如果您根本不使用输入文件,所有相关代码都应删除。
现在我们进行开关处理。基本上,我们进行一个循环,在每次迭代中,我们查看当前处于哪个开关。如果开关需要一个参数,我们需要检查以确保我们不在最后一个参数上,然后,在存储结果后,我们需要额外递增 i
一次,如下所示:
case "/output":
if (args.Length - 1 == i) // check if we're at the end
throw new ArgumentException(string.Format
("The parameter \"{0}\" is missing an argument", args[i].Substring(1)));
++i; // advance
outputFile = args[i];
break;
在这里,由于 /output
期望一个参数,我们检查以确保我们不在末尾,如果我们在末尾则抛出异常。否则,我们将 i
增加 1,然后设置相应的命令参数变量。这可以复制粘贴以创建接受单个参数的新开关,如下所示:
case "/name":
if (args.Length - 1 == i) // check if we're at the end
throw new ArgumentException(string.Format
("The parameter \"{0}\" is missing an argument", args[i].Substring(1)));
++i; // advance
name = args[i];
break;
我已将两个粗体更改突出显示,以说明 /name
开关接受单个参数。该代码是为了复制粘贴而制作的。
布尔开关的情况也类似
case "/ifstale":
ifStale = true;
break;
需要进行与上面相同的两个基本更改才能添加更多。
如果您需要创建一个接受可变数量参数的开关,您将在新开关下创建的代码与 inputFiles
收集代码非常相似,只是它将使用 i
而不是 start
作为其工作变量。
default case
会抛出异常,因为它表示无法识别的开关。
如果还不清楚,总体的想法是,这个 switch/case
设置了之前声明的命令行变量。
有时,您会遇到一些命令行参数,它们不能与其他命令行参数同时指定。例如,您可能有一个 /debug
选项,不能与 /optimize
选项一起指定。在开关循环结束后,您需要对命令行变量进行任何后期验证,以处理这些情况,并在必要时抛出异常。这里没有这样的代码,因为样板代码中没有这种情况。
现在我们来看 /ifstale
功能。与之前一样,它会跳过输出的生成,除非输入比输出新,或者可执行文件本身比输出新。处理此问题的代码位于紧接在上面后期验证之后的节中。您可能唯一需要更改的是,如果您只使用单个输入文件,则必须删除陈旧性检查代码块中的循环,并使其在 inputFile
而不是 inputFiles
上工作。
所有这些之后,我们在这里,在注释的 else
块中是我们进行工作的地方。这里的步骤是收集数据,处理数据,然后最后打开 output
流并生成输出。您可以委托给一个例程来完成工作,这可能是一个好主意,但我不想混淆流程。这里委托的唯一问题是您可能需要传递大量变量——即您声明的大部分命令行参数变量。实际上,我发现是否以及如何准确地做到这一点在很大程度上取决于应用程序,但在实践中,我发现直接在这里做很多工作更容易,而这本身又委托给其他事物,比如代码生成器类。
在接下来的 finally
块中,除了 output
之外,您还需要释放任何资源,就像您之前假设声明了一个数据库连接一样。请记住检查 null 值。
除了 _PrintUsage()
之外,接下来的内容在您的应用程序中都不需要修改,因为它都是用于收集程序集属性和比较文件日期的支持代码。请注意,当我们比较文件时,我们不依赖 File.Exists()
,因为它不喜欢用于 UNC 网络路径。
MSBuild 支持
使您的应用程序在与 Visual Studio 等通信方面对 MSBuild “友好”,即当作为预构建步骤运行时,涉及以 MSBuild 喜欢的方式构建您的控制台消息。您必须修改错误报告,并且除了您如何构建您的状态消息之外,您还必须小心,但这超出了本文的范围。即使您的工具没有这样做,它仍然会与 Visual Studio 一起工作。它只是不会有一些额外的功能,比如在构建错误列表中显示带有行号的错误和警告详细信息。但要知道这是可能的。
历史
- 2020年5月5日 - 初次提交