.NET Exception.TargetSite null 並不一定代表執行階段遺失資訊。例外物件若只被建立、包進 InnerException,卻從未真正拋出,就可能沒有堆疊與拋出方法。可靠的全域處理器應把診斷欄位視為可缺資料的證據,而不是永遠存在的契約。

為什麼 Exception.TargetSite 會是 null

TargetSite 依賴例外的拋出狀態與堆疊;只有建構但未拋出的內層例外,可能同時沒有 TargetSite 與 StackTrace,這是合法狀態而非另一個錯誤。

最容易誤判的情境,是開發者看到外層例外已經被拋出,便推論整條 InnerException 鏈上的每個物件都曾經通過 throw。實際上,程式完全可以先建立一個細節例外,再把它傳給另一個例外的建構子,最後只拋出外層。外層會取得拋出位置,內層則仍只是資料物件。此時呼叫 GetBaseException() 會走到最深層,卻不會替那個物件補造不存在的堆疊。

Microsoft 的 Exception.TargetSite 文件將此屬性定義為取得拋出目前例外的方法,並明確說明當堆疊為 null 時,TargetSite 也會回傳 null。因此,診斷程式碼不能直接串接 exception.TargetSite.DeclaringType.FullName。鏈上任何一段缺值,都可能讓原本負責記錄錯誤的程式再次拋出 NullReferenceException,最後只剩下處理器自己的失敗。

這個差異也解釋了為何單元測試若只使用 throw new InvalidOperationException(),常常測不出問題。該寫法讓例外立即取得堆疊,而正式程式可能接收由驗證器、轉接層或包裝器建立但尚未拋出的內層物件。測試資料必須同時包含「已拋出」與「僅建構」兩種狀態,才能覆蓋真實的診斷邊界。

GetBaseException 不是完整診斷紀錄

GetBaseException() 的工作是沿著例外鏈找出根因物件,不是保證根因曾被拋出,也不是保證其方法、檔名、行號與堆疊都可用。

官方 API 說明將回傳值描述為一連串例外的根本原因。這個「根本」是物件鏈上的位置,不等於遙測資料已經完整。若最深的例外只被當成原因說明,GetBaseException() 仍會正確回傳它;呼叫者必須接受它的 TargetSite 與 StackTrace 皆為 null。換句話說,導覽成功與診斷完整是兩個不同判斷。

實作上應保留外層與根因兩個視角。外層通常描述目前抽象層的操作,例如「資料匯入失敗」;根因可能描述較底層的格式或 I/O 問題。若只記錄根因,會失去業務操作脈絡;若只記錄外層,又可能看不到最具體原因。穩健紀錄可包含例外型別、訊息、完整 ToString()、根因型別與根因訊息,再以 null-safe 方式補充方法名稱。

不要把 TargetSite 當成分類或告警路由的唯一鍵。編譯器產生的方法、非同步狀態機、最佳化與跨邊界處理都可能讓方法呈現方式和原始碼直覺不同。分類應優先使用穩定的例外型別、明確的錯誤代碼與所在操作;方法資訊適合作為佐證,而不是決定是否忽略錯誤的唯一條件。

同理,Message 是給人閱讀的描述,不適合當穩定機器協定。不同執行階段、套件版本或語系可能改變文字。若應用程式需要依失敗原因採取動作,應在受控的例外型別、結果物件或明確欄位中表達,而不是用字串包含判斷。

如何寫出不會二次失敗的全域處理器

全域例外處理器必須採取 fail-safe 設計:先保存原始例外,再以不假設診斷欄位存在的方式建立紀錄,任何次要失敗都不能覆蓋最初證據。

第一條規則是縮小處理器工作。它不應在錯誤路徑執行複雜資料查詢、呼叫多個外部元件或組裝依賴大量物件圖的通知。越多步驟就越多二次失敗點。最低限度應先用既有 logger 寫入完整例外,再嘗試建立補充欄位;通知失敗時,至少仍保有第一份紀錄。

第二條規則是逐段 null 防護。TargetSite、DeclaringType、FullName、InnerException、StackTrace 與來源資訊都不是應用程式可無條件控制的值。可用 null 條件運算子與替代文字,讓缺資料本身成為可觀察結果,例如 (unavailable),而不是讓格式化程式終止。替代文字必須表示未知,不可憑空推測呼叫點。

第三條規則是隔離處理器自己的失敗。若 logger、序列化或通知通道可能丟出例外,應有範圍很小的保護層,將次要錯誤送往最簡單的備援輸出;但不可使用空白 catch。完全吞掉錯誤只會把「處理器壞了」偽裝成「系統沒有錯」,也讓監控無法區分沒有事件與事件遺失。

第四條規則是避免在全域邊界承諾復原。全域處理器通常位於要求即將終止的位置,適合做紀錄、關聯與安全回應,不適合任意繼續半完成的操作。若交易或狀態需要回復,應在擁有該資源與操作語意的較近層級完成,再把不可處理的例外向上傳遞。

throw、throw ex 與 ExceptionDispatchInfo 的差異

在目前的 catch 內重新拋出同一例外時使用裸 throw;;若要跨越目前處理器稍後再拋,使用 ExceptionDispatchInfo 保存原始狀態。

Microsoft 的例外最佳實務文件指出,throw ex; 會從目前方法重新開始堆疊,導致原始拋出點與目前處理器之間的呼叫資訊遺失。這會直接削弱故障定位,而且可能讓 TargetSite 與堆疊看起來像是處理層造成問題。當目的只是記錄後繼續向上傳遞,正確寫法是 throw;。

跨執行緒、佇列或延後工作時,原本的 catch 已經結束,不能再使用裸 throw;。此時可在處理器內用 ExceptionDispatchInfo.Capture(exception) 捕捉,再由保存的物件呼叫 Throw()。官方 API 說明指出,Throw() 會先還原捕捉時儲存的例外狀態,再拋出所代表的例外。這不代表可隨意延後每個錯誤,而是提供保留來源證據的標準機制。

包裝例外也要有目的。當某一層需要補充可理解的操作脈絡,可建立新的領域例外並把原例外放入 InnerException;但不能只複製訊息而丟棄原物件。保留原例外能留下其型別、堆疊與資料。同時,紀錄系統應避免每一層都重複記錄同一失敗,否則一個事件會膨脹成多筆無法關聯的告警。

例外過濾器 catch (...) when (...) 適合做不改變狀態的判斷,但過濾條件也必須簡單、安全且沒有副作用。把遠端呼叫或複雜格式化放進過濾器,會使失敗路徑更難理解。決策原則很清楚:在能處理的層級處理;不能處理就保留證據向上傳遞。

可重現驗證:確認 null 與堆疊保存

以下驗證只建立本機 .NET 10 主控台程式,不讀取部署資料;它同時檢查未拋出內層例外、已拋出例外與 ExceptionDispatchInfo 三種狀態。

  1. 確認 dotnet --version 為 10.0.400,且 .NET 10 執行階段可用;版本不同時,請在隔離測試目錄使用對應 SDK 後再執行。
  2. 建立 net10.0 主控台專案,將下列核心片段放入 Program.cs,並加入會在條件不符時結束的斷言。
  3. 執行 dotnet run -c Release,要求程序結束碼為 0,且輸出清楚標示未拋出內層物件的兩個欄位都是 null。
  4. 再確認已拋出的例外具有拋出方法,並且 ExceptionDispatchInfo.Throw() 後的堆疊仍包含原始方法名稱。
var detail = new InvalidOperationException("generic detail");
var wrapper = new ApplicationException("generic wrapper", detail);
var root = wrapper.GetBaseException();

Console.WriteLine(root.TargetSite is null);
Console.WriteLine(root.StackTrace is null);

try
{
    ThrowFromOrigin();
}
catch (Exception ex)
{
    var captured = ExceptionDispatchInfo.Capture(ex);
    captured.Throw();
}

本次排程已在 Windows 上以 .NET SDK 10.0.400、執行階段 10.0.11 實際執行完整斷言程式。觀察結果是未拋出內層例外的 TargetSite=null、StackTrace=null;真正拋出的例外則能辨識包含 ThrowFromOrigin 的方法名稱,經 ExceptionDispatchInfo 重拋後原始方法仍存在於堆疊。這是功能查核,不是效能測試,也不推論其他執行階段必然顯示完全相同的編譯器產生名稱。

上線前檢查清單與結論

把例外診斷視為可缺值、可降級且不可覆蓋原始證據的管線,才能避免真正故障被告警程式自己的例外取代。

  • 同時測試已拋出例外與只被建構的內層例外。
  • 對 TargetSite、DeclaringType、StackTrace 與 InnerException 做 null 防護。
  • 保留外層操作脈絡與最深根因,不把 GetBaseException() 誤當完整遙測。
  • 在目前 catch 中使用 throw;,不要用 throw ex; 重設堆疊。
  • 跨處理器重拋時以 ExceptionDispatchInfo 保存原始狀態。
  • 先寫最小可靠紀錄,再做通知與補充格式化。
  • 不使用空白 catch,次要失敗也要留下可觀察訊號。
  • 不依賴訊息字串或方法名稱作為唯一機器決策條件。

核心結論不是「永遠不要讀 TargetSite」,而是不要把它當成必填欄位。例外物件的建立、包裝與拋出是不同事件;GetBaseException() 只導覽物件鏈;throw; 與 ExceptionDispatchInfo 才負責在不同重拋情境保留來源。只要全域處理器先保全原始例外、對診斷欄位採 null-safe 設計,並用可重現測試覆蓋缺值狀態,就能讓故障處理本身成為可靠邊界。