.NET 補丁王 Harmony Library -- Prefix 與 Postfix (2)

續上篇,繼續來實作不同情境的 Prefix 與 Postfix。

解決原始方法多載的問題

擴充前述範例的 OriginalClass ,多載 DisplayMessage 方法:

 public class OriginalClass
 {
     public static string DisplayMessage(string message)
     {
         Console.WriteLine("Executing Original Method...");
         return $"Original Message : {message}";
     }

     public static string DisplayMessage(string message, int number)
     {
         Console.WriteLine("Executing Original Method with Number...");
         return $"Original Message : {message}, Number: {number}";
     }
 }

這種情況下,HarmonyPatch attribute 必須要定義補丁對象的參數,如果沒有定義會在呼叫 PathAll 方法的時候發生例外。假設要補丁的對象是帶有兩個參數 (string,int) 的多載,那補丁類別就要宣告如下,HarmonyPatch attribute 的第三個參數就是對應 DisplayMessage(string message, int number) 方法:

 [HarmonyPatch(typeof(OriginalClass), nameof(OriginalClass.DisplayMessage), new Type[] {typeof(string), typeof(int)})]
 public static class PatchOriginal
 {
     public static void Prefix()
     {
         Console.WriteLine("Prefix: Before the original method.");
     }
     public static void Postfix()
     {
         Console.WriteLine("Postfix: After the original method.");
     }
 }

主程式內容如下:

 static void Main(string[] args)
 {
     var harmony = new Harmony("com.example.sampleApp002");
     harmony.PatchAll(Assembly.GetExecutingAssembly());
     string result = OriginalClass.DisplayMessage("Hello, Harmony!", 100);
     Console.WriteLine(result);
     string result2 = OriginalClass.DisplayMessage("Hello, Harmony!");
     Console.WriteLine(result2);
 }

從執行結果可以看出來只在執行 DisplayMessage(string message, int number) 前後有執行補丁;但 DisplayMessage(string message) 並沒有。[範例連結Sample002]

Prefix: Before the original method.
Executing Original Method with Number...
Postfix: After the original method.
Original Message : Hello, Harmony!, Number: 100
Executing Original Method...
Original Message : Hello, Harmony!
Prefix 處理參數與回傳值

這個例子模擬了一個深度嵌套運算的挑戰:某核心私有方法負責最終的除法運算,但在特定邊界條件下,傳入的除數可能為 0。由於該方法位於無法修改的第三方套件深處,且呼叫鏈過於複雜,從源頭過濾數值變成一個艱深的挑戰。
傳統做法多採用 try-catch 進行補救,但 try-catch 向來有增加額外的例外處理開銷的惡名,且程式碼侵入性較高。透過 Harmony,藉由 Prefix 攔截機制,我們能在除法執行前預先檢查所傳入的參數內容,若除數為 0 則直接返回 0 並跳過原方法執行。這不僅解決了例外崩潰問題,也維持了呼叫端邏輯的純粹。

假設函式庫內有這麼一個類別,從外部呼叫公開的 DisplayMessage 方法時會傳入兩個 double 的引數,而DisplayMessage 方法內部 (可能會是深層嵌套的呼叫) 有機率讓傳給私有方法 Divide 的 y 值變成 0 (我們的假設情況是 y = a - b,也就是 a==b 時會造成 y = 0),從程式碼可以看出來若 y 為 0 會直接拋出例外:

  public class OriginalClass
  {
      public static string DisplayMessage(string message, double a, double b)
      {
          double x = a;

          /* 假設在程式碼內呼叫許多層的方法後計算後會讓 y=0 (if a==b) , 導致 Divide 方法拋出例外
           這裡的計算只是為了模擬這種情況,實際上可能會有更複雜的邏輯導致 y=0 */
          double y = a - b;   
          double result = Divide(x, y);
          return $"Original Message : {message}, Result: {result}";
      }

      private static double Divide(double x, double y)
      {
          Console.WriteLine("Executing Original Method...");
          if (y == 0)
          {
              throw new DivideByZeroException("Cannot divide by zero.");
          }
          return x / y;
      }
  }

這個寫法當然是正確的,除以 0 拋出例外符合一般的概念。但外部呼叫者可能有個特定的邏輯是「當除以 0 的時候,結果就是 0」,此時採用 Prefix patch 來預先處理這個問題,俾使邏輯符合需求並且不耗費太多校能。

 [HarmonyPatch(typeof(OriginalClass), "Divide")]
 public static class PatchOriginal
 {
     public static bool Prefix(double x, double y, ref double __result)
     {
         if (y == 0)
         {
             Console.WriteLine("Prefix: Detected potential divide by zero. Modifying y to prevent exception.");
             __result = 0;
             return false; // Skip the original method
         }
         return true;
     }
 }
  1. 要存取或更改原始方法的特定參數,只需在補丁方法中重複使用相同的參數名稱即可。
    型別: 必須是可從原始參數型別賦值的(或直接使用 object)。
    名稱: 必須與原有名稱相同,或使用 __n 形式(n 為從 0 開始的參數索引)。
  2. 補丁可以使用 __result 來存取回傳值。型別必須與原始回傳型別匹配或可被賦值。
    在 Prefix 中 因為原始方法尚未執行,__result 為該型別的預設值(參考類型通常為 null)。若要修改回傳值,需使用 ref。
  3. 回傳值型別為 bool,決定是否呼叫原始方法。Prefix 內部直接判斷 y 值是否為 0,若 y 值為 0 則直接設定回傳值為 0,並跳過原始方法呼叫。

註:__result 屬於 Harmony 注入機制的一部分,欲詳細了解可以參考  [Common injected values]

這個範例主程式與執行結果如下 [範例連結Sample003]:

 static void Main(string[] args)
 {
     var harmony = new Harmony("com.example.sampleApp003");
     harmony.PatchAll(Assembly.GetExecutingAssembly());
     string message = "default message";
     message = OriginalClass.DisplayMessage("Hello, Harmony!", 10, 5);
     Console.WriteLine(message);
     Console.WriteLine("-----------------");
     message = OriginalClass.DisplayMessage("Hello, Harmony!", 10, 10);
     Console.WriteLine(message);
 }
Executing Original Method...
Original Message : Hello, Harmony!, Result: 2
-----------------
Prefix: Detected potential divide by zero. Modifying y to prevent exception.
Original Message : Hello, Harmony!, Result: 0
Prefix 與 Postfix 間傳遞狀態

讓我們暫時撇開 Benchmark.NET,假設我們想手動使用 Stopwatch 來量測特定方法的執行時間。在邏輯上,我們必須在 Prefix 中建立並啟動 Stopwatch 執行個體,接著在 Postfix 中停止它以取得耗費時間。(如果框架和C#版本允許,這種需求我會比較傾向用 Interceptor 來實作)

那麼,如何將 Prefix 中建立的執行個體傳遞給 Postfix 呢?這正是注入機制 __state 大顯身手的時候。透過在兩個補丁方法中定義同名的 __state 參數,Harmony 會自動幫我們完成執行個體的跨方法傳遞。

先建立一個跑起來夠久的 OriginalClass

 public class OriginalClass
 {
      public static void LongTimeMethod()
     {
         Console.WriteLine("executing LongTimeMethod.....");
         Enumerable.Range(0, 100000).Select(x => BigInteger.Pow(x, 10)).ToArray();
     }
 }

建立補丁,使用 ref Stopwatch __state 作為 Prefix 與 Postfix 之間傳遞的橋樑。

 [HarmonyPatch(typeof(OriginalClass), nameof(OriginalClass.LongTimeMethod))]
 public static class PatchOriginal
 {
     public static void Prefix(ref Stopwatch __state)
     {
         Console.WriteLine("Stopwatch start.....");
         __state = new Stopwatch();
         __state.Start();
     }

     public static void Postfix(ref Stopwatch __state)
     {
         __state.Stop();
         Console.WriteLine($"Stopwatch stop, elapsed {__state.ElapsedMilliseconds} ms");
     }
 }   

主程式與其執行結果 [範例連結Sample004]:

 static void Main(string[] args)
 {
     var harmony = new Harmony("com.example.sampleApp004");
     harmony.PatchAll(Assembly.GetExecutingAssembly());
     OriginalClass.LongTimeMethod();
 }
Stopwatch start.....
executing LongTimeMethod.....
Stopwatch stop, elapsed 42 ms

到此暫時打住,下一篇繼續探討 Prefix 與 Postfix 的其他議題。