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中使用。它只是沒有多餘的裝飾,例如獲取錯誤和帶有行號的警告詳細信息,以顯示在構建錯誤列表中。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章