.NET 補丁王 Harmony Library -- Finalizer

這一篇談論終結器補丁 – Finalizer。

甚麼是 Harmony Finalizer

Finalizer(終結器補丁)是一種特殊的補丁類型。它的核心作用是將原始方法(Original Method)以及所有其他補丁(Prefix, Postfix 等)封裝在一個 try-catch 區塊中。用個虛擬的程式碼來代表就像:

public void PatchedMethod()
{
    Exception __exception = null;
    try
    {
        // 1. 執行所有的 Prefix (前置補丁)
        // 2. 執行 原始方法 (Original Method)
        // 3. 執行所有的 Postfix (後置補丁)
    }
    catch (Exception ex)
    {
        // 如果上述過程出錯,捕獲異常
        __exception = ex;
    }
    finally
    {
        // 4. 執行你的 Finalizer (終結器補丁)
        // Finalizer 可以決定要拋出新的異常、原本的異常,還是返回 null 消除異常 
        // Finalizer 可以透過 __exception 參數取得發生的例外。     
    }
}

Finalizer 會在方法執行的最後階段運行。無論原始方法是否拋出異常(exception),Finalizer 都會被執行,這使得它非常適合作為「清理」或「保底」的代碼。

假設我們有一個補丁執行流程是 Prefix → Original method → Postfix, 一旦在 Original method 發生例外,Postfix 就會被跳過,此時某些資源很有可能沒被清理,要保證資源清理就必須加上 Finalizer 補丁。

大致上來說,Finalizer 補丁具備兩種作用:

  1. 處理異常 – 抑制 (通常不建議這麼做啦)、記錄、轉拋
  2. 確保程式碼一定被執行 – 像是必要的資源清理等等
Finalizer 的宣告

Finalizer 主要有兩大類宣告方式,區別在於你是否需要控制(攔截或更改)異常,還是僅僅想觀察(讀取或記錄)異常。

  1. 返回 void 的宣告 (僅觀察)
    如果你只需要在方法結束時執行某些邏輯(例如釋放資源、Log),且不打算干預異常的拋出,就使用 void。
    特點:即使發生了異常,異常依然會照常拋出,你無法阻止它。
    用途:純觀察、資源清理。
  2. 返回 Exception 的宣告 (完全控制)
    透過返回值,你可以決定該方法的最終命運。
    返回 null:抑制異常。即使原始方法崩潰了,調用方也不會察覺到任何錯誤。
    返回 __exception:維持原狀。將捕獲到的異常原封不動傳回,效果等同於沒攔截。
    返回 new Exception(...):替換異常。拋出一個全新的異常給調用方。

參數方面類似 Prefix/Postfix,可以藉由注入機制傳入 __instance、__result、__state 與原有參數等等,額外增加一個因應例外的 __exception。

資源清理

除非原始方法本身的資源清理機制存在缺陷,否則 Finalizer 最常見的應用場景是清理在 Prefix 中產生的資源。

設想一個情境:我們在 Prefix 中建立了一個必須手動釋放的資源(如Unmanaged Resources 或 IDisposable 物件),且該資源的生命週期必須延伸至原始方法執行完畢。若將清理邏輯置於 Postfix,一旦 Prefix 或原始方法拋出例外,Postfix 將會被跳過,導致資源洩漏。

因此,正確的防禦性作法是將清理作業移至 Finalizer 中,利用 try-finally 保護的特性,確保資源在任何執行路徑下都能被正確回收。

設計一個 Original class,刻意在方法內部拋出例外:

 public class OriginalClass
 {     
     public void DisplayMessage(string message)
     {
         Console.WriteLine($"Original Message : {message}");
         throw new InvalidOperationException("An error occurred in DisplayMessage.");
     }
 }

回到主專案 – SampleApp009,設計一個實作 IDisposable 的類別 OtherClass,在 Prefix 方法中會使用這類別產生執行個體,情境上模擬在原始方法執行後要呼叫 Dispose 釋放資源;也就是說這個執行個體的生命週期會長於 Prefix 方法:

 public class OtherClass : IDisposable
 {
     private bool disposedValue;

     protected virtual void Dispose(bool disposing)
     {
         if (!disposedValue)
         {
             if (disposing)
             {
                 // TODO: Dispose managed state (managed objects)
             }

             // TODO: Free unmanaged resources (unmanaged objects) and override finalizer
             // TODO: Set large fields to null
             disposedValue = true;
         }
     }
     
     public void Dispose()
     {
         // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
         Dispose(disposing: true);
         GC.SuppressFinalize(this);
         Console.WriteLine("OtherClass disposed.");
     }
 }

補丁類別,可以看到 Prefix 利用 __state 傳遞要清理資源的執行個體傳遞至 Finalizer ,在 Finalizer 中呼叫 Dispose 方法釋放資源 :

 [HarmonyPatch(typeof(OriginalClass), nameof(OriginalClass.DisplayMessage))]
 public static class PatchOriginal
 {
     public static void Prefix(string message, ref OtherClass __state)
     {
         Console.WriteLine($"Prefix: About to display message: {message}");
         __state = new OtherClass();
     }

     public static void Postfix(string message)
     {
         Console.WriteLine($"Postfix: Finished displaying message: {message}");
     }

     public static void Finalizer(ref OtherClass __state)
     {
         Console.WriteLine("Finalizer: Cleaning up resources.");
         __state?.Dispose();
     }
 }

主程式與執行結果:

 static void Main(string[] args)
 {
     var harmony = new Harmony("com.example.sample009");
     harmony.PatchAll(Assembly.GetExecutingAssembly());
     var originalObject = new OriginalClass();
     try
     {
         originalObject.DisplayMessage("Hello, Harmony!");
     }
     catch (Exception ex)
     {
         Console.WriteLine($"Original class exception occurred: {ex.Message}");
     }
 }
Prefix: About to display message: Hello, Harmony!
Original Message : Hello, Harmony!
Finalizer: Cleaning up resources.
OtherClass disposed.
Original class exception occurred: An error occurred in DisplayMessage.

執行結果顯示了 Postfix 方法並未被執行而是轉到了 Finalizer 中並且執行了 OtherClass.Dispose。[範例連結Sample009]

例外處理

處理例外這件事實在有點奇妙,因為技術上我們確實可以在『主程式的呼叫端』直接包裹 try-catch 來解決。正如前面『資源清理』的範例所示,主程式的 catch 塊確實能成功擷取到原始方法拋出的例外,甚至連補丁(Prefix 或 Postfix)中產生的例外也逃不過它的手掌心。從上一節的主程式與輸出結果很明顯可以看到輸出的最後一行就是由原始方法拋出的例外。

既然如此,Finalizer 的存在意義是什麼? 答案在於封裝性與維護成本。

這與我們在.NET 補丁王 Harmony Library -- Prefix 與 Postfix (3) 中「存取執行個體」一節中探討的邏輯如出一轍:如果某個方法在專案中被成百上千次地調用,我們不可能在每一處呼叫端都手動補上相同的 try-catch 邏輯,那會造成代碼冗長並增加維護成本。

Finalizer 真正的威力在於實現『局部邏輯的自治』:它讓補丁開發者能夠在不干擾呼叫端的情況下,於內部消化掉特定的異常、進行統一的日誌記錄,甚至是重包裝回傳值。它將『如何應對錯誤』的責任從調用方收回到補丁本身,確保了程式行為的一致性,而不必依賴外部環境的配合。

抑制並儲存記錄

抑制表示「吞掉這個例外」,平常是不建議這樣處理例外的;記錄的方式,在正式環境會建議你使用 .NET 本身的 ILogger API 或是第三方的 NLog 之類,不過在文章的範例會採用 Harmony Library 內建的 FileLog,這個 FileLog 通常會在桌面產生一個 harmony.log.txt,檔名和路徑還不能改,限制頗多,但寫寫範例還行。

 public static Exception Finalizer(Exception __exception)
 {
     if (__exception != null)
     {
         FileLog.Log($"{__exception.Message}");
     }
     return null;
 }

處理例外的時候,要將 Finalizer 方法的回傳型別宣告為 Exception,如果要抑制例外,則回傳 null 值即可。

抑制並改變回傳值

改變回傳值就要用到注入機制的 __result。

 public static Exception Finalizer(Exception __exception, ref double __result)
 {
     if (__exception != null)
     {
         __result = -1;
     }
     return null;
 }
重包裝例外

這個需求比較常有,先建立一個自己的例外類別:

public class ParserException : Exception
{
    public ParserException(string message) : base(message) { }
}

在 Finalizer 方法中,將收到的例外轉換為自訂的 ParserException,完整範例請參閱  [範例連結Sample010]:

 public static Exception Finalizer(Exception __exception)
 {
     if (__exception != null)
     {
         return new ParserException("An error occurred during parsing.");
     }
     return null;
 }

關於 Harmony Library 的  Finalizer 補丁到此告一段落。