持續介紹 Prefix 和 Postfix 的其他使用方式。
存取執行個體成員
假設我們有這個一個第三方的 BankAccount class,很不幸沒有在 Deposit 方法內部控制帳戶被凍結的狀態下禁止存款行為,而主專案十分龐大到處散落著呼叫不受控制的Deposit 方法:
public class BankAccount
{
public string Owner { get; set; }
public decimal Balance { get; private set; }
public bool IsFrozen { get; set; }
public void Deposit(decimal amount)
{
Balance += amount;
}
}我們有幾個選擇:
- 找出所有呼叫的位置,一一加上 if 分支,判斷 IsFrozen 決定是否執行 Deposit。
- 建立一個 Helper 類別,將 if 分支判斷放在這邊,並且修改所有原始呼叫轉向這個 Helper。
- 如果嫌前面花的時間太多,當然你也可以用 AI 執行前述 1 或 2 的步驟。
- 用 C# Interceptor + Source generator。
- 乾脆就 Harmony 包一包
- 當然還有其他方式
既然文章是談 Harmony ,那當然以下的示範是採用 5。此時我們要在 Postfix 中取得該執行個體的 IsFrozen 屬性才能判斷是否執行 Deposit,這邊可以使用 __instance 注入該執行個體,感覺有點像擴充方法使用 this 來指定接收器。這個補丁就會像這樣:
[HarmonyPatch(typeof(BankAccount), nameof(BankAccount.Deposit))]
public static class PatchBankAccount
{
public static bool Prefix(BankAccount __instance, decimal amount)
{
if (__instance.IsFrozen)
{
Console.WriteLine($"Cannot deposit to frozen account of {__instance.Owner}.");
return false;
}
return true;
}
}主程式裡就假設 Alice 的帳戶初始狀態是被凍結的,於是 Prefix 會阻止呼叫 Deposit,接著將帳戶解凍,Deposit 就會恢復正常運作 [範例連結Sample005]:
static void Main(string[] args)
{
var harmony = new Harmony("com.example.sampleApp005");
harmony.PatchAll(Assembly.GetExecutingAssembly());
var account = new BankAccount { Owner = "Alice", IsFrozen = true };
account.Deposit(1000m); // This will trigger the Harmony patch and print a message about the account being frozen.
Console.WriteLine($"Account balance for {account.Owner}: {account.Balance}"); // Balance should remain unchanged due to the patch.
Console.WriteLine("UnFrozen account ...");
account.IsFrozen = false;
account.Deposit(1000m); // This will allow the deposit to proceed since the account is no longer frozen.
Console.WriteLine($"Account balance for {account.Owner}: {account.Balance}"); // Balance should reflect the deposit now.
}Cannot deposit to frozen account of Alice.
Account balance for Alice: 0
UnFrozen account ...
Account balance for Alice: 1000如果目標型別是 internal ?
回頭看一下 Sample003 的例子,如果 Divide 方法是存在於 internal class 中,那表示在另一個組件 (Assembly) 中是無法直接存取 Divide 所在的型別 (前提是假設我們無法修改函式庫組件)。在如此的情境設定下,HarmonyPatch attribute 就無法在建構時期直接傳入型別,這就得讓一個特殊的方法 TargetMethod 上場救援,先來看函式庫的內容,Divide 位於 internal class Calculator 中:
public class OriginalClass
{
public static string DisplayMessage(string message, double a, double b)
{
double x = a;
double y = a - b;
double result = Calculator.Divide(x, y);
return $"Original Message : {message}, Result: {result}";
}
}
internal static class Calculator
{
internal 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;
}
}補丁型別中就得多加一個 TargetMethod 方法,這個方法需要注意的是靜態方法與回傳值型別一定是 MethodBase。
在 TargetMethod 中取得處理對象方法可以採用 .NET Reflection,也可以用其他函式庫,例如 Harmony 內建的 AccessTools 類別,以下列出這兩種方式:
private static MethodBase GetMethodByReflection()
{
Assembly sampleLibraryAssembly = Assembly.Load("SampleLibrary006");
Type calculatorType = sampleLibraryAssembly.GetType("SampleLibrary006.Calculator", throwOnError: true);
return calculatorType.GetMethod("Divide", BindingFlags.NonPublic | BindingFlags.Static);
}
private static MethodBase GetMethodByAccessTools()
{
Type calculatorType = AccessTools.TypeByName("SampleLibrary006.Calculator");
if (calculatorType == null)
{
throw new Exception("無法找到類型 'SampleLibrary006.Calculator'");
}
return AccessTools.Method(calculatorType, "Divide");
}其他就和 Sample003 差不多,除了Harmony attribute 採用無參數建構式,完整的補丁類別如下 [範例連結Sample006]:
[HarmonyPatch]
public static class PatchOriginal
{
public static MethodBase TargetMethod()
{
/* 這裡提供兩種方式來獲取目標方法的 MethodBase:
1. 使用 AccessTools,這是 HarmonyLib 提供的工具類,可以更方便地獲取方法信息,尤其是對於內部方法。
2. 使用傳統的 Reflection 方法來獲取 MethodBase。
你可以根據需要選擇其中一種方式。AccessTools 通常更簡潔且更適合 Harmony 的使用場景。*/
/* Here are two ways to obtain the target method's MethodBase:
1. Use AccessTools, a utility provided by HarmonyLib, which makes it easier to retrieve method information, especially for internal methods.
2. Use traditional Reflection methods to obtain the MethodBase.
You can choose one of these methods based on your needs. AccessTools is usually more concise and better suited for Harmony's use cases.)*/
return GetMethodByAccessTools();
//return GetMethodByReflection();
}
private static MethodBase GetMethodByReflection()
{
Assembly sampleLibraryAssembly = Assembly.Load("SampleLibrary006");
Type calculatorType = sampleLibraryAssembly.GetType("SampleLibrary006.Calculator", throwOnError: true);
return calculatorType.GetMethod("Divide", BindingFlags.NonPublic | BindingFlags.Static);
}
private static MethodBase GetMethodByAccessTools()
{
Type calculatorType = AccessTools.TypeByName("SampleLibrary006.Calculator");
if (calculatorType == null)
{
throw new Exception("無法找到類型 'SampleLibrary006.Calculator'");
}
return AccessTools.Method(calculatorType, "Divide");
}
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;
}
}同場加映 – 手動補丁
前述的方式都是採用 annotation 補丁,我們拿 Sample006 的例子來改成手動補丁,Original class 沒有改變,下列程式碼展示在主程式中使用手動補丁,首先加入一個類別包含補丁用的程式碼,可以察覺這個類別沒有掛上任何關於 Harmony Library 的 attribute:
public static class PatchOriginal
{
public static bool Divide(double x, double y, ref double __result)
{
if (y == 0)
{
Console.WriteLine("Prefix Divide: Detected potential divide by zero. Modifying y to prevent exception.");
__result = 0;
return false; // Skip the original method
}
return true;
}
}手動補丁是呼叫 Harmony.Patch method,它是 Harmony 的核心方法,以手動指定的方式,把自己寫的 patch 方法掛接(hook)到目標方法上,不需要標示 attribute:
public MethodInfo Patch(
MethodBase original,
HarmonyMethod prefix = null,
HarmonyMethod postfix = null,
HarmonyMethod transpiler = null,
HarmonyMethod finalizer = null
)參數說明
| 參數 | 型別 | 說明 |
| original | MethodBase | 必要,攔截或修改的原始方法 |
| prefix | HarmonyMethod | optional,在原始方法之前執行的方法 |
| postfix | HarmonyMethod | optional,在原始方法之後執行的方法 |
| transpiler | HarmonyMethod | optional,直接修改原始方法的 IL 指令 (進階用法) |
| finalizer | HarmonyMethod | optional,類似 finally 區塊,無論是否拋出例外都會執行 |
在這個範例裡面會需要傳入 original 和 prefix,在 program 中加入兩個方法先取得要應用的 MethodInfo:
private static MethodInfo GetOriginalMethod()
{
var harmony = new Harmony("com.example.sampleApp006");
var originalType = AccessTools.TypeByName("SampleLibrary007.Calculator");
if (originalType == null)
{
throw new Exception("無法找到類型 'SampleLibrary007.Calculator'");
}
return AccessTools.Method(originalType, "Divide");
}
private static MethodInfo GetPrefixMethod()
{
return AccessTools.Method(typeof(PatchOriginal) , nameof (PatchOriginal.Divide));
}建立一個 Patch method 執行補丁流程:
static void Patch()
{
var harmony = new Harmony("com.example.sampleApp007");
var original = GetOriginalMethod();
var prefix = GetPrefixMethod();
harmony.Patch(original, prefix: new HarmonyMethod(prefix));
}主程式直接呼叫 Patch() [範例連結Sample007]:
static void Main(string[] args)
{
Patch();
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);
}非同步議題
使用 Harmony Postfix 和 Prefix 修改非同步方法會有一些麻煩,平常也不太建議這麼做 (註);但試一下也無妨,試試不用錢的,對吧?
如果你對非同步有點了解,應該會知道非同步方法會被展開成一個些許複雜的狀態機 (State Machine) 型別,執行時期實際運作的程式碼會存在於編譯器自動生成的 IAsyncStateMachine.MoveNext 方法中。麻煩就來了,要修改的原始方法並不是那個非同步方法,而是 IAsyncStateMachine.MoveNext 方法,而這方法又會被呼叫多次 – 狀態機就是這麼一回事,並且每當 MoveNext 執行一次,Postfix 和 Prefix 就會被呼叫一次。,所幸在這個狀態機裡面用一個名為「<>1__state」的私有欄位來表示目前的狀態。
- -1 初始狀態,狀態機剛建立但尚未開始執行
- -2 終止狀態,狀態機已完成執行(成功或例外)
- 0, 1, 2, ... 非負整數,代表在不同 await 點暫停的位置
有了這些前置資訊,就先來寫一個被修改的對象 OriginalClass,簡單用 Task.Delay 來拖時間:
public class OriginalClass
{
public static async Task LongTimeMethodAsync()
{
Console.WriteLine("executing LongTimeMethod.....");
await Task.Delay(200);
}
}補丁類別會用到 TargetMethod 來取得 IAsyncStateMachine.MoveNext:
static MethodBase TargetMethod()
{
var method = typeof(OriginalClass).GetMethod(nameof(OriginalClass.LongTimeMethodAsync));
var attr = method.GetCustomAttribute<AsyncStateMachineAttribute>();
return attr.StateMachineType.GetMethod("MoveNext", BindingFlags.NonPublic | BindingFlags.Instance);
}因為 MoveNext 會被執行好多次,所以用 __state 來傳遞 Stopwatch 變成不可能,只能建立一個 ConditionalWeakTable 型別私有靜態欄位來保存:
public static class PatchOriginal
{
private static readonly ConditionalWeakTable<object, StateMachineInfo> _stopwatches = new();這個 ConditionalWeakTable Key 會是狀態機的 instance,StateMachineInfo 是一個自訂的類別,用來儲存 Stopwatch 與 「<>1__state」欄位的 FieldInfo:
public class StateMachineInfo
{
public FieldInfo Field { get; set; }
public Stopwatch Stopwatch { get; set; }
}補丁類別的 Prefix 中先檢查這個狀態機是否已經被追蹤,如果沒有就透過反射取得狀態機的內部欄位 <>1__state,同時建立新的 Stopwatch,將兩者指派給 StateMachineInfo 後塞進 ConditionalWeakTable,然後開始計時。
if (!_stopwatches.TryGetValue(__instance, out var info))
{
// The state machine has an internal field called "<>1__state"
// -1 indicates the state machine has just started execution
var stateField = __instance.GetType().GetField("<>1__state", BindingFlags.Public | BindingFlags.Instance);
var sw = new Stopwatch();
info = new StateMachineInfo { Field = stateField, Stopwatch = sw};
_stopwatches.Add(__instance, info);
sw.Start();
}至於 Postfix,則在 MoveNext 後會先檢查 ConditionalWeakTable 對應此狀態機執行個體的 StateMachineInfo,接著檢查 <>1__state 的值是否為 -2 (意即非同步方法是否結束),如果是 -2,則停止計時並輸出。
static void Postfix(object __instance)
{
if (_stopwatches.TryGetValue(__instance, out var info))
{
int internalState = (int)info.Field.GetValue(__instance);
if (internalState == -2) // State -2 indicates the async state machine has completed execution
{
info.Stopwatch.Stop();
Console.WriteLine($"[Harmony] LongTimeMethod 執行完畢,耗時: {info.Stopwatch.ElapsedMilliseconds} ms");
_stopwatches.Remove(__instance);
}
}
}組合出完整的補丁類別:
[HarmonyPatch]
public static class PatchOriginal
{
private static readonly ConditionalWeakTable<object, StateMachineInfo> _stopwatches = new();
static MethodBase TargetMethod()
{
var method = typeof(OriginalClass).GetMethod(nameof(OriginalClass.LongTimeMethodAsync));
var attr = method.GetCustomAttribute<AsyncStateMachineAttribute>();
return attr.StateMachineType.GetMethod("MoveNext", BindingFlags.NonPublic | BindingFlags.Instance);
}
static void Prefix(object __instance)
{
if (!_stopwatches.TryGetValue(__instance, out var info))
{
// The state machine has an internal field called "<>1__state"
// -1 indicates the state machine has just started execution
var stateField = __instance.GetType().GetField("<>1__state", BindingFlags.Public | BindingFlags.Instance);
var sw = new Stopwatch();
info = new StateMachineInfo { Field = stateField, Stopwatch = sw};
_stopwatches.Add(__instance, info);
sw.Start();
}
}
static void Postfix(object __instance)
{
if (_stopwatches.TryGetValue(__instance, out var info))
{
int internalState = (int)info.Field.GetValue(__instance);
if (internalState == -2) // State -2 indicates the async state machine has completed execution
{
info.Stopwatch.Stop();
Console.WriteLine($"[Harmony] LongTimeMethod 執行完畢,耗時: {info.Stopwatch.ElapsedMilliseconds} ms");
_stopwatches.Remove(__instance);
}
}
}
}至於主程式就簡單多了[範例連結Sample008]:
async static Task Main(string[] args)
{
var harmony = new Harmony("com.example.SampleApp008");
harmony.PatchAll();
await OriginalClass.LongTimeMethodAsync();
}註:如果情境適合的話,使用 Interceptor + Source generator 會是比較建議的做法。
Postfix 與 Prefix 的說明就在這篇打住,如果還想到甚麼新鮮的情境再另開新篇,下一篇來聊其他的補丁方式。