每個開發人員都知道單元測試提高了代碼的質量。我們還從靜態代碼分析中獲益,並在我們的構建管道中使用SonarQube等工具。然而,我仍然發現許多開發人員並不知道檢查代碼有效性的一種更古老的方法:斷言。在這篇文章中,我將向您介紹使用斷言的好處,以及.NET應用程序的一些配置技巧。我們還將學習.NET和Windows如何支持它們。
什么是斷言,什么時候使用它們
斷言聲明某個謂詞(真-假表達式)在程序中的特定時間必須為真。當斷言的計算結果為false時,會發生斷言失敗,這通常會導致程序崩潰。我們通常在調試版本中使用斷言,並在調試器或某些特殊日志中處理斷言異常(稍后我們將重點討論配置)。在.NET中,有兩種使用斷言的方法:Debug.Assert or Trace.Assert.第一個方法的定義如下:
[System.Diagnostics.Conditional("DEBUG")] public static void Assert(bool condition, string message) { TraceInternal.Assert(condition, message); }
如您所見,只有在定義調試編譯符號(通常僅用於調試生成)時,它才會出現在生成的IL中。另一方面,Assert使用跟蹤編譯符號,默認情況下,編譯器不會在發布版本中剝離它。我更喜歡使用Debug.Assert方法,並且在推送到生產環境的二進制文件中沒有斷言。
讓我們來看看我們可能使用斷言的一些場景。我將使用corecrl存儲庫中的代碼片段。
驗證內部方法參數
斷言是執行內部/私有方法參數驗證的極好方法。我們應該將它們放在方法的開頭,這樣任何計划使用我們方法的人都會立即看到它的期望值,例如:
private static char GetHexValue(int i) { Debug.Assert(i >= 0 && i < 16, "i is out of range."); if (i < 10) { return (char)(i + '0'); } return (char)(i - 10 + 'A'); }
或者
internal static int MakeHRFromErrorCode(int errorCode) { Debug.Assert((0xFFFF0000 & errorCode) == 0, "This is an HRESULT, not an error code!"); return unchecked(((int)0x80070000) | errorCode); }
通常真假表達式就足夠了,但是對於更復雜的場景,我們可以考慮使用Debug.Assert(bool condition,string message)變量(如上面的示例所示),在這里我們可以解釋我們的需求。
我們不能使用斷言來驗證公共API方法參數。首先,斷言將在發布版本中消失。其次,我們的API客戶機期望某些特定類型的異常。如果仍要在公共API方法中使用斷言,則應同時使用異常和斷言來驗證參數,例如:
public User FindUser(string login) { if (string.IsNullOrEmpty(login)) { Debug.Assert(false, "Login must not be null or empty"); // or equivalent: Debug.Fail("Login must not be null or empty"); throw new ArgumentException("Login must not be null or empty."); } }
驗證正在執行上下文
要查看使用斷言進行邏輯驗證的示例,我們將分析StringBuilder類的Length屬性和AssertInvariants方法。注意,斷言(突出顯示)如何在方法執行的各個階段驗證上下文。它們反映了編寫代碼的開發人員的假設,同時幫助我們更好地理解代碼的邏輯:
/// <summary> /// Gets or sets the length of this builder. /// </summary> public int Length { get { return m_ChunkOffset + m_ChunkLength; } set { //If the new length is less than 0 or greater than our Maximum capacity, bail. if (value < 0) { throw new ArgumentOutOfRangeException(nameof(value), SR.ArgumentOutOfRange_NegativeLength); } if (value > MaxCapacity) { throw new ArgumentOutOfRangeException(nameof(value), SR.ArgumentOutOfRange_SmallCapacity); } int originalCapacity = Capacity; if (value == 0 && m_ChunkPrevious == null) { m_ChunkLength = 0; m_ChunkOffset = 0; Debug.Assert(Capacity >= originalCapacity); return; } int delta = value - Length; if (delta > 0) { // Pad ourselves with null characters. Append('\0', delta); } else { StringBuilder chunk = FindChunkForIndex(value); if (chunk != this) { // We crossed a chunk boundary when reducing the Length. We must replace this middle-chunk with a new larger chunk, // to ensure the original capacity is preserved. int newLen = originalCapacity - chunk.m_ChunkOffset; char[] newArray = new char[newLen]; Debug.Assert(newLen > chunk.m_ChunkChars.Length, "The new chunk should be larger than the one it is replacing."); Array.Copy(chunk.m_ChunkChars, 0, newArray, 0, chunk.m_ChunkLength); m_ChunkChars = newArray; m_ChunkPrevious = chunk.m_ChunkPrevious; m_ChunkOffset = chunk.m_ChunkOffset; } m_ChunkLength = value - chunk.m_ChunkOffset; AssertInvariants(); } Debug.Assert(Capacity >= originalCapacity); } } [System.Diagnostics.Conditional("DEBUG")] private void AssertInvariants() { Debug.Assert(m_ChunkOffset + m_ChunkChars.Length >= m_ChunkOffset, "The length of the string is greater than int.MaxValue."); StringBuilder currentBlock = this; int maxCapacity = this.m_MaxCapacity; for (;;) { // All blocks have the same max capacity. Debug.Assert(currentBlock.m_MaxCapacity == maxCapacity); Debug.Assert(currentBlock.m_ChunkChars != null); Debug.Assert(currentBlock.m_ChunkLength <= currentBlock.m_ChunkChars.Length); Debug.Assert(currentBlock.m_ChunkLength >= 0); Debug.Assert(currentBlock.m_ChunkOffset >= 0); StringBuilder prevBlock = currentBlock.m_ChunkPrevious; if (prevBlock == null) { Debug.Assert(currentBlock.m_ChunkOffset == 0); break; } // There are no gaps in the blocks. Debug.Assert(currentBlock.m_ChunkOffset == prevBlock.m_ChunkOffset + prevBlock.m_ChunkLength); currentBlock = prevBlock; } }
在.NET核心源代碼中還有許多其他地方可以找到正在使用的斷言。尋找它們並學習.NET開發人員如何使用它們可能是一個有趣的練習,特別是當您對在代碼中使用斷言有疑問時。
斷言實現詳細信息
我們已經介紹了一些可以使用斷言的示例場景,因此現在應該進一步了解.NET和Windows如何支持斷言。在默認配置中,當斷言失敗時,您將看到這個漂亮的對話框:
顯示此消息框的是DefaultTraceListener的失敗,或者更確切地說是AssertWrapper的ShowMessageBoxAssert方法。窗口的標題描述了您擁有的選項。如果按Retry,應用程序將調用Debugger.Break方法,該方法將發出一個軟件中斷(int 0x3),將執行傳輸到內核中的KiBreakpointTrap方法,然后再傳輸KiExceptionDispatch。后者也是處理“正常”異常分派的方法,是Windows提供的結構化異常處理(SEH)機制的一部分。因此,您可以將斷言失敗視為未處理異常的特殊類型。從Vista開始,有另一個特別為斷言創建的軟件中斷(int 0x2c),但是我還沒有找到一種不使用pinvoking從.NET調用它的方法。盡管在某種程度上系統處理它們的方式沒有多大區別。因此,當您單擊“重試”時,Windows將檢查注冊表中的AeDebug鍵中是否配置了任何默認調試器。如果存在,它將啟動並附加到您的應用程序,並在發生斷言失敗的地方停止。如果AeDebug密鑰中沒有調試器,Windows錯誤報告將嘗試解決該問題,這可能會導致向Microsoft發送新的報告。
在.NET應用程序中處理斷言輸出
如您所料,對於失敗的斷言,MessageBox顯示並不總是所需的行為。對於在會話0中運行的進程(例如,在IIS上托管的Windows服務或ASP.NET web應用程序),這樣的消息框將完全阻止應用程序,而不提供交互選項(會話0中沒有桌面)。另一個例子是自動測試,它也可能無限地掛起。為了解決這些問題,我們在應用程序配置文件中有一個標志,用於禁用assert UI並將日志重定向到某個文件:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <system.diagnostics> <assert assertuienabled="false" logfilename="C:\logs\assert.log" /> </system.diagnostics> </configura
如果不設置logfilename屬性,斷言消息將只出現在調試輸出上。禁用斷言消息框的另一種方法是從偵聽器集合中移除DefaultTraceListener(通常應為發布版本執行此操作):
<?xml version="1.0" encoding="utf-8"?> <configuration> <system.diagnostics> <trace> <listeners> <remove name="Default" /> </listeners> </trace> </system.diagnostics> </configuration>
不幸的副作用是不會報告斷言失敗。因此,如果要刪除DefaultTraceListener,請添加Fail方法的自定義實現。然后您可以按自己的方式記錄錯誤或調用調試器。立即中斷。
在處理失敗的斷言時,我非常喜歡在應用程序中發生未處理的異常時創建轉儲。我通常安裝procdump作為默認的系統異常處理程序。您也可以使用Visual Studio或WinDbg。請記住,這對於會話0中運行的進程不起作用。作為procdump的替代,特別是在某些服務器計算機上,可以考慮配置Windows錯誤報告。