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

目录

介绍

概念化这个混乱

处理命令行参数

异常处理

用户界面

过期文件处理

编码此混乱

MSBuild支持


用于在C#中构建命令行工具应用程序的入门代码。该样板代码为应用程序提供异常处理和命令行参数解析。

介绍

我编写了许多命令行实用程序——通常是代码生成器等。这些工具遵循命令行解析、使用报告和异常处理的基本总体模式。由于各个工具的基本结构相似,因此我从一些样板代码开始,然后对其进行修改以创建它们。我将在这里分享和解释该代码。多年来,在尝试了许多不同的技巧来构建这些工具之后,我决定了一个运行良好且从此代码开始的基本过程。

概念化这个混乱

我们在这里需要解决几个问题。这些问题实际上对于任何命令行工具应用程序都是普遍的:我们需要提供用户界面、处理命令行参数和报告错误。

处理命令行参数

使用我们的技术,开关采用以下形式 /<switch> {<arguments>}

使用列出的代码,在进行任何切换之前,它还需要可变数量的未切换参数。可以在代码中更改。

处理命令行参数几乎是可以生成或概括的。事实是,可以,但是增加的复杂性成本通常是不值得的。即使我们有一种概括参数处理的方法,它仍然认为添加、删除或更改参数将需要更改应用程序本身的逻辑,因此您并没有真正删除太多内容(如果有的话)。在大多数情况下,这是我的经验。另一个问题是,虽然很容易归纳80%的论点案例,但其他20%并非无关紧要。它不如编写专门的参数处理代码合理。

我们要做的是在Main()中获取C#提供给我们的部分处理过的string[] args数组,并对其进行简单循环,驱动一个switch/case来处理我们的标志。好处是,它易于维护和修改,并且可以处理引用的文件名等。缺点是它是基本的,并且也会奇怪地接受诸如"/switch"这样的东西,并在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。这样我们就可以根据需要对返回值进行尽可能多的控制,这对于在批处理文件或构建步骤中使用此工具至关重要。但是,在大多数情况下,我们将像往常一样通过抛出异常来处理错误,并让样板逻辑将其转换为退出代码。

下一个兴趣点是命令行arg变量列表。我喜欢为每个参数提出一个。每当我们添加或删除命令行参数时,都应在此处声明或删除其对应的变量。这使得将它们全部声明为一处变得更加清晰。每当我修改这些代码时,我要做的下一件事就是相应地修改_PrintUsage()例程,这样我就不会忘记。

现在我们有了output参数。应该将其设置为Console.Out,如果/output未指定,或通过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需要一个参数,所以我们检查以确保我们不在最后,如果存在则抛出。否则,我们将i1,然后设置适当的命令arg变量。可以将其复制并粘贴以制作新的接受单个参数的开关,如下所示:

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设置先前声明的命令行变量。

有时,您会在其他命令行参数旁边指定非法的命令行参数。例如,您可能有一个无法使用/optimize选项指定的/debug选项。切换循环完成后,您将需要对命令行变量进行任何后期验证,以处理这些情况,并根据需要抛出异常。这里没有代码,因为样板代码中没有这样的场景。

现在我们继续/ifstale功能。和以前一样,除非输入比输出新,或者可执行文件本身比输出新,否则它将跳过输出的生成。处理此问题的代码位于上面的post-validation之后的部分。您可能需要更改的唯一一件事是,如果仅使用单个输入文件,您必须删除陈旧检查代码块中的循环,并使其在inputFile上工作,而不是在inputFiles上工作

在所有这些之后,我们在这里,在else块中进行注释,这是我们工作的地方。此处的步骤是收集数据,处理数据,然后最后打开output流并生成输出。您可以在此处委派一个例程来完成工作,这可能是个好主意,但我不想混淆流程。这里委派的唯一问题是您可能需要传递很多变量——即您已声明的大多数命令行参数变量。在实践中,我确定是否以及如何执行此过程在很大程度上取决于应用程序,但是在实践中,我发现只需在此处完成很多工作即可,这些工作本身会委托给其他事情,例如代码生成器类,这会更容易。

在随后的finally代码块中,您将要释放output之外的所有资源,例如是否从先前的假设中声明了数据库连接。记住要检查是否为空。

除了_PrintUsage()之外,您不需要在您的应用程序中进行任何修改,因为所有这些都是收集程序集属性并比较文件日期的支持代码。请注意,当我们比较文件时,我们不依赖File.Exists(),因为它不适合UNC网络路径。

MSBuild支持

让你的应用程序MSBuild“友好地与Visual Studio这样的东西进行沟通,当运行作为一个预构建步骤时,这涉及到按照MSBuild喜欢的方式来组织你的控制台消息。您必须修改错误报告,并且还必须小心如何构造状态消息,但这超出了本文的范围。即使您的工具没有执行此操作,它仍然可以在Visual Studio中使用。它只是没有多余的装饰,例如获取错误和带有行号的警告详细信息,以显示在构建错误列表中。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值