目錄
用於在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需要一個參數,所以我們檢查以確保我們不在最後,如果存在則拋出。否則,我們將i加1,然後設置適當的命令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中使用。它只是沒有多餘的裝飾,例如獲取錯誤和帶有行號的警告詳細信息,以顯示在構建錯誤列表中。