DispatchProxy 能把記錄、量測與政策檢查集中在介面邊界,但它不是單純的前後處理器。代理程式必須保留原方法的呼叫次數、回傳型別、非同步完成時機與例外語意;任何一項改變,都可能讓橫切關注點反過來破壞業務行為。本文以 .NET 9 的最小測試專案說明可重現的設計基線,不引用特定環境資料,也不把來源筆記中的個案當成成果敘事。

DispatchProxy 攔截器正確性的核心結論

.NET 9 DispatchProxy 攔截器正確性的最低標準,是每次代理呼叫只執行一次目標方法,原樣回傳同步值或 Task,並讓呼叫端觀察到原始例外型別與可追查的堆疊。

這個結論看似保守,卻能釐清大多數攔截器問題。代理層的責任是轉送與觀察,不是偷偷重試、同步等待、改造錯誤物件,或序列化完整參數與結果。若政策真的需要重試、逾時或回退,應以明確、可測試且了解冪等性的元件實作,不能因為 Invoke 集中處理所有方法,就把額外執行混進通用攔截器。

DispatchProxy.Create() 會建立實作介面 T 的執行階段代理,而 TProxy 必須繼承 DispatchProxy。實測也確認代理基底類別不能宣告為 sealed,因為執行階段需要產生其衍生型別。這是建立代理時就能觀察的硬性條件,不是程式碼風格偏好。

先建立不可破壞的四項語意

安全的代理設計應先寫下四項不變條件:呼叫次數不增加、回傳契約不改變、非同步工作不被同步阻塞、錯誤不被轉成一般資料。這四項比記錄格式更優先。

第一項是「一次呼叫」。targetMethod.Invoke(Target, args) 的結果必須先存入區域變數或直接回傳,後續分支不可為了判斷 Task、取得結果或補記錄而再次呼叫。同步方法、Task、Task 與丟出例外的方法都要各自測試;只測成功的同步回傳值,無法捕捉雙重執行或完成時機漂移。

第二項是「型別與完成時機一致」。同步方法回傳整數,代理就回傳該整數;非同步方法回傳 Task 或 Task,代理就回傳相容物件,讓原呼叫端自行 await。若代理呼叫 Wait()、.Result 或 GetAwaiter().GetResult(),就把非同步等待變成同步阻塞,還可能改變例外呈現方式與執行緒使用情形。

第三項是「錯誤仍是錯誤」。同步目標透過反射執行時,目標例外會由 TargetInvocationException 包住。代理應取得 InnerException,再用 ExceptionDispatchInfo.Capture(...).Throw() 重新擲出,使上層看到原始例外型別並保留可診斷堆疊。把例外物件當成方法回傳值會直接破壞型別契約;對值型別回傳甚至可能立刻再產生轉型錯誤,遮蔽真正原因。

第四項是「觀察資料最小化」。追蹤欄位只記方法識別、成功或失敗、耗時區間、集合筆數等低敏感度摘要。不要把完整參數、查詢結果或任意物件圖序列化到遙測。這不只降低資料暴露面,也避免代理為了觀察而遍歷大型物件、觸發延遲載入,或在循環參考上失敗。

一次 Invoke 的基線實作

基線實作刻意保持短小:驗證方法資訊、只呼叫一次、原樣回傳結果,並只拆封同步反射例外。它沒有偷偷加入重試、阻塞等待或完整物件序列化。

以下程式可放進 .NET 9 測試專案。代理類別不可是密封類別;Target 由建立代理的組合根設定。範例只展示正確性邊界,實際專案還應限制可代理介面、處理空值註記,並把低敏感度遙測委派給獨立元件。

using System.Reflection;
using System.Runtime.ExceptionServices;

public class OnceProxy : DispatchProxy where T : class
{
    public T Target { get; set; } = default!;

    protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
    {
        ArgumentNullException.ThrowIfNull(targetMethod);
        try
        {
            return targetMethod.Invoke(Target, args); // exactly once
        }
        catch (TargetInvocationException ex) when (ex.InnerException is not null)
        {
            ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
            throw; // compiler reachability; Throw does not return
        }
    }
}

這段程式對同步回傳值會直接傳回反射結果;對 Task 與 Task 則傳回目標方法建立的工作,完成與失敗仍由呼叫端的 await 觀察。非同步方法在第一個非同步邊界之後發生的錯誤通常會記錄在回傳工作中,而不是由 MethodInfo.Invoke 當場丟出,因此不能假設同步的 TargetInvocationException 分支會處理所有非同步失敗。

若要記錄工作最終狀態,建議另外建立型別明確的包裝器,分別處理 Task 與 Task,並證明包裝後仍保留回傳型別、取消與例外。不要用一次不受約束的反射分支猜測泛型結果,也不要用同步等待換取一個方便記錄的完成點。正確性的順序是先維持契約,再增加觀察能力。

Task、Task 與同步方法要分開思考

三種回傳路徑共享「只呼叫一次」原則,但完成與錯誤被觀察的位置不同,因此測試案例必須分開。把它們視為同一條同步流程,最容易引入阻塞與重複執行。

同步方法在 Invoke 返回前已完成,代理可以量測呼叫前後的時間區間;若目標丟出錯誤,反射層會以包裝例外呈現。Task 方法則可能在取得工作時只執行到第一個暫停點,真正完成發生在稍後;代理若只記 Invoke 的執行時間,量到的是「取得 Task」的時間,而非完整非同步操作時間。

Task 還多了結果型別。透明轉送時不需要讀取 Result,只要回傳原工作即可。若觀察需求必須在完成後記錄摘要,型別化包裝器應 await 原工作、只記允許的摘要,然後回傳同一個 T 值。這種包裝會建立新的 Task,但對呼叫端應維持成功、失敗、取消與結果值等可見語意,且必須用測試證明。

回傳 void 的同步方法沒有結果物件,但一樣不能把錯誤吃掉。至於 async void,它缺少可等待的完成契約,不適合作為一般服務介面方法;代理無法用 Task 觀察其最終狀態。介面設計階段應優先使用 Task,讓完成與錯誤可組合、可測試。

例外拆封與堆疊保留

同步反射呼叫的例外處理目標不是讓記錄看起來成功,而是讓原始失敗以原始型別繼續傳遞。ExceptionDispatchInfo 提供了保留堆疊資訊的明確機制。

常見錯誤是 catch (Exception ex) { return ex; }。此作法同時犯下三個問題:方法契約要求的不是例外物件、上層正常錯誤流程不會啟動、遙測可能把錯誤誤算成成功回傳。另一個錯誤是 throw ex;,它會讓重新擲出位置干擾堆疊;若只在同一個 catch 重新丟目前例外,C# 的 throw; 較合適,而拆封 InnerException 時則使用 ExceptionDispatchInfo。

測試不應只比對訊息文字。至少要驗證呼叫端收到 InvalidOperationException 而不是 TargetInvocationException,並檢查堆疊包含目標方法識別。訊息可作為固定測試標記,但不能取代型別與堆疊驗證。對非同步錯誤,應在 await 點驗證原始錯誤;不要期待代理同步 catch 到工作稍後記錄的失敗。

遙測最小化與效能邊界

攔截器位於高頻共同路徑,因此每一項額外工作都會套用到所有被代理呼叫。最安全的預設不是「先收集再刪除」,而是只允許經審查的低基數摘要。

建議欄位包含穩定的方法名稱、結果狀態、耗時區間、重試次數若該政策明確存在,以及集合筆數。禁止記錄完整請求物件、完整回傳集合、自由文字內容與任意 ToString() 結果。方法參數名稱也不代表內容安全;採用允許清單比依名稱排除可靠。

效能主張必須靠量測,不能宣稱某種攔截器一定更快。本文沒有提供 benchmark,也沒有捏造延遲百分比。可執行的判準是:代理不得因觀察需求再次執行目標、不得同步等待 Task、不得遍歷完整結果。若要比較額外配置與延遲,應在隔離測試專案固定 SDK、資料形狀、暖機與樣本方法,再公開完整方法;不同工作負載不能直接套用同一數字。

驗證與重現:固定 .NET 9.0.305

本次在 Windows 排程環境以 .NET SDK 9.0.305 實際執行隔離測試,未連線外部資料來源,也沒有不可逆操作。結果通過一次呼叫、回傳值與例外拆封三項檢查。

前置條件是已安裝 .NET SDK 9.0.305,空資料夾中只有測試專案,global.json 將 rollForward 設為 disable。請依下列順序重現:

  1. 建立 net9.0 Console 專案,加入 IProbe、計數用 Probe 與前述 OnceProxy;確認代理類別未使用 sealed。
  2. 依序呼叫同步方法、Task 方法與 Task 方法,每次只增加一次計數;在三次呼叫後斷言計數為三、回傳值分別符合固定值。
  3. 呼叫固定丟出 InvalidOperationException 的同步方法,斷言呼叫端取得原始型別與固定標記,再斷言最終計數為四。
  4. 執行下列完整指令;只要出現未處理例外、非零結束碼、計數不同或缺少任一 PASS 行,就判定失敗。
dotnet --version
dotnet run --configuration Release

本次真實輸出為 SDK 9.0.305,接著顯示 PASS once-and-return: calls=3 sync=7 asyncValue=9、PASS exception-unwrapped: InvalidOperationException expected-marker、PASS final-call-count: calls=4,程序結束碼為零。初版測試也真實捕捉到代理類別若宣告 sealed,建立代理時會拋出 ArgumentException;移除密封宣告後才通過。這項失敗沒有被隱藏,並已轉化為可檢查的前置條件。

程式碼審查清單

審查 DispatchProxy 時,先追蹤可見語意,再檢查遙測內容。清單的目的不是證明「有代理」,而是證明「加入代理後行為沒有被意外改寫」。

  • 搜尋 targetMethod.Invoke,每條可達路徑都只能執行一次;條件分支不得再次執行目標。
  • 搜尋 .Wait()、.Result 與 GetAwaiter().GetResult();通用非同步攔截路徑不應使用同步等待。
  • 驗證同步例外被拆封,呼叫端收到原始型別,堆疊仍能指向目標方法。
  • 分別測試同步值、void、Task、Task、取消與失敗,不以單一成功案例代替完整契約。
  • 遙測採允許清單,只記方法識別、狀態、耗時與筆數等摘要,不序列化完整物件圖。
  • 若加入工作包裝器,驗證成功值、錯誤與取消皆維持相同可見語意。
  • 將 SDK 版本與通過條件寫進測試說明,使未來升級可重新執行,而不是沿用舊結論。

結論

可靠的 DispatchProxy 是透明邊界,不是第二個執行引擎。只呼叫一次、原樣傳遞 Task、正確拆封同步反射例外,以及最小化遙測,是可以用隔離專案反覆證明的基線。

先用計數器與固定錯誤建立回歸測試,再加入記錄、量測或政策功能;每增加一項能力,就重新驗證回傳型別、完成時機、錯誤與取消。如此能讓橫切關注點保持可觀察、可撤除,也避免代理層成為難以定位的共同故障來源。