
以很容易找到 Redis 进行方法注入的源码这相当于是一个 AOP 切面实现方法[InstrumentMethod( AssemblyName StackExchange.Redis, TypeName StackExchange.Redis.ConnectionMultiplexer, MethodName ExecuteAsyncImpl, ReturnTypeName System.Threading.Tasks.Task1T, ParameterTypeNames new[] { StackExchange.Redis.Message, StackExchange.Redis.ResultProcessor1[!!0], ClrNames.Object, StackExchange.Redis.ServerEndPoint }, MinimumVersion 1.0.0, MaximumVersion 2.*.*, IntegrationName StackExchangeRedisHelper.IntegrationName)] [InstrumentMethod( AssemblyName StackExchange.Redis.StrongName, TypeName StackExchange.Redis.ConnectionMultiplexer, MethodName ExecuteAsyncImpl, ReturnTypeName System.Threading.Tasks.Task1T, ParameterTypeNames new[] { StackExchange.Redis.Message, StackExchange.Redis.ResultProcessor1[!!0], ClrNames.Object, StackExchange.Redis.ServerEndPoint }, MinimumVersion 1.0.0, MaximumVersion 2.*.*, IntegrationName StackExchangeRedisHelper.IntegrationName)] [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] public class ConnectionMultiplexerExecuteAsyncImplIntegration { /// summary /// OnMethodBegin callback /// /summary /// typeparam nameTTargetType of the target/typeparam /// typeparam nameTMessageType of the message/typeparam /// typeparam nameTProcessorType of the result processor/typeparam /// typeparam nameTServerEndPointType of the server end point/typeparam /// param nameinstanceInstance value, aka this of the instrumented method./param /// param namemessageMessage instance/param /// param nameresultProcessorResult processor instance/param /// param namestateState instance/param /// param nameserverEndPointServer endpoint instance/param /// returnsCalltarget state value/returns internal static CallTargetState OnMethodBeginTTarget, TMessage, TProcessor, TServerEndPoint(TTarget instance, TMessage message, TProcessor resultProcessor, object state, TServerEndPoint serverEndPoint) where TTarget : IConnectionMultiplexer where TMessage : IMessageData { string rawCommand message.CommandAndKey ?? COMMAND; StackExchangeRedisHelper.HostAndPort hostAndPort StackExchangeRedisHelper.GetHostAndPort(instance.Configuration); Scope scope RedisHelper.CreateScope(Tracer.Instance, StackExchangeRedisHelper.IntegrationId, StackExchangeRedisHelper.IntegrationName, hostAndPort.Host, hostAndPort.Port, rawCommand); if (scope is not null) { return new CallTargetState(scope); } return CallTargetState.GetDefault(); } /// summary /// OnAsyncMethodEnd callback /// /summary /// typeparam nameTTargetType of the target/typeparam /// typeparam nameTResponseType of the response, in an async scenario will be T of Task of T/typeparam /// param nameinstanceInstance value, aka this of the instrumented method./param /// param nameresponseResponse instance/param /// param nameexceptionException instance in case the original code threw an exception./param /// param namestateCalltarget state value/param /// returnsA response value, in an async scenario will be T of Task of T/returns internal static TResponse OnAsyncMethodEndTTarget, TResponse(TTarget instance, TResponse response, Exception exception, in CallTargetState state) { state.Scope.DisposeWithException(exception); return response; } }这段代码是一个用于监控和跟踪 StackExchange.Redis 库的 APM应用性能监控工具集成。它针对StackExchange.Redis.ConnectionMultiplexer类的ExecuteAsyncImpl方法进行了注入以收集执行过程中的信息。使用了两个InstrumentMethod属性分别指定StackExchange.Redis和StackExchange.Redis.StrongName两个程序集。属性包括程序集名称、类型名、方法名、返回类型名等信息以及版本范围和集成名称。ConnectionMultiplexerExecuteAsyncImplIntegration类定义了OnMethodBegin和OnAsyncMethodEnd方法。这些方法在目标方法开始和结束时被调用。OnMethodBegin方法创建一个新的Tracing Scope其中包含了与执行的 Redis 命令相关的信息如hostname,port,command等。OnAsyncMethodEnd方法在命令执行结束后处理Scope在此过程中捕获可能的异常并返回结果。而这个CallTargetState state中其实包含了上下文信息有 Span Id 和 Trace Id 就可以将其收集发送到 APM 后端进行处理。但是仅仅只有声明了一个 AOP 切面类不够我们还需将这个 AOP 切面类应用到 Redis SDK 原有的方法中这又是如何做到的呢那么我们就需要了解一下 CLR Profiler API 实现方法注入的原理了。方法注入底层实现原理#在不考虑 AOT 编译和分层编译特性一个 .NET 方法一开始的目标地址都会指向 JIT 编译器当方法开始执行时先调用 JIT 编译器将 CIL 代码转换为本机代码然后缓存起来运行本机代码后面再次访问这个方法时都会走缓存以后得本机代码流程如下所示拦截JIT编译#由于方法一般情况下只会被编译一次一种方法注入的方案就是在 JIT 编译前替换掉对应方法的 MethodBody 这个在 CLR Profile API 中提供的一个关键的回调。JITCompilationStarted:通知探查器即时编译器已经开始编译方法。我们只需要订阅这个事件就可以在方法编译开始时将对应的 MethodBody 修改成我们想要的样子在里面进行 AOP 埋点即可。在JITCompilationStarted事件中重写方法IL的流程大致如下捕获JITCompilationStarted事件当一个方法被即时编译JIT时CLRCommon Language Runtime会触发JITCompilationStarted事件。通过使用 Profiler API 分析器可以订阅这个事件并得到一个回调。确定要修改的方法在收到JITCompilationStarted事件回调时分析器需要检查目标方法元数据例如方法名称、参数类型和返回值类型等来确定是否需要对该方法进行修改。获取方法的原始 IL 代码如果确定要对目标方法进行修改分析器需要首先获取该方法的原始 IL 代码。这可以通过使用Profiler API 提供的GetILFunctionBody方法来实现。分析和修改 IL 代码接下来分析器需要解析原始 IL 代码找到适当的位置以插入新的跟踪逻辑。这通常包括方法的入口点开始执行时和退出点返回或抛出异常。分析器会生成一段新的 IL 代码用于记录性能指标、捕获异常等。替换方法的 IL 代码将新生成的 IL 代码插入到原始 IL 代码中并使用SetILFunctionBody方法替换目标方法的IL代码。这样在方法被JIT编译成本地代码时新的跟踪逻辑也会被包含进去。继续JIT编译完成IL代码重写后分析器需要通知CLR继续JIT编译过程。编译后的本地代码将包含插入的跟踪逻辑并在应用程序运行期间执行。我们来看看源码是如何实现的打开 dd-trace-dotnet 开源仓库回退到较早的发布版本有一个 integrations.json 文件在 dd-trace-dotnet 编译时会自动生成这个文件当然也可以手动维护在这个文件里配置了需要 AOP 切面的程序集名称、类和方法在分析器启动时就会加载 json 配置告诉分析器应该注入那些方法。接下来我们找到cor_profiler.cpp文件并打开这是实现 CLR 事件回调的代码转到关于JITCompilationStarted事件的通知的处理的源码。由于代码较长简单的说一下这个函数它做了什么函数主要用于在 .NET JITJust-In-Time编译过程中执行一系列操作例如插入启动钩子、修改 IL中间语言代码以及替换方法等以下是它的功能函数检查is_attached_和is_safe_to_block变量如果不满足条件则直接返回。使用互斥锁保护模块信息防止在使用过程中卸载模块。通过给定的function_id获取模块 ID 和函数 token。根据模块 ID 查找模块元数据。检查是否已在CallTarget模式下注入加载器。如果符合条件且加载器尚未注入则在AppDomain中的第一个 JIT 编译方法中插入启动钩子。在最低程度上必须添加AssemblyResolve事件以便从磁盘找到Datadog.Trace.ClrProfiler.Managed.dll及其依赖项因为它不再被提供为 NuGet 包。在桌面版 IIS 环境下调用AddIISPreStartInitFlags()方法来设置预启动初始化标志。如果未启用CallTarget模式将对integrations.json配置的方法进行插入和替换并处理插入和替换调用。返回S_OK表示成功完成操作。其中有两个关键函数可以对 .NET 方法进行插入和替换分别是ProcessInsertionCalls和ProcessReplacementCalls。其中ProcessInsertionCalls用于那些只需要在方法前部插入埋点的场景假设我们有以下原始 C# 类public class TargetClass { public void TargetMethod() { Console.WriteLine(This is the original method.); } }现在我们希望在TargetMethod的开头插入一个新的方法调用。让我们创建一个示例方法并在WrapperClass中定义它修改后插入InsertedMethod调用的TargetMethod将如下所示public class TargetClass { public void TargetMethod() { WrapperClass.InsertedMethod(); // 这是新插入的方法调用 Console.WriteLine(This is the original method.); } } public class WrapperClass { public static void InsertedMethod() { Console.WriteLine(This is the inserted method.); } }请注意上述示例是为了解释目的而手动修改的实际上这种修改是通过操作IL代码来完成的。在CorProfiler::ProcessInsertionCalls方法中这些更改是在IL指令级别上进行的不会直接影响源代码。修改方法的 IL 代码.NET官方提供了一个帮助类 ILRewriter ILRewriter 是一个用于操作C#程序中方法的中间语言Intermediate LanguageIL代码的工具类。它会将方法的IL代码以链表的形式组织让我们可以方便的修改IL代码它通常用于以下场景代码注入在方法体中插入、删除或修改 IL 指令。代码优化优化 IL 代码以提高性能。执行 AOP面向切面编程通过动态操纵字节码实现横切关注点如日志记录、性能度量等。ILRewriter 类提供了一系列方法用于读取、修改和写回IL指令序列。例如在上述CorProfiler::ProcessInsertionCalls方法中我们使用 ILRewriter 对象导入IL代码执行所需的更改如插入新方法调用然后将修改后的 IL 代码导出并应用到目标方法上。这样可以实现对程序行为的运行时修改而无需直接更改源代码。另一个ProcessReplacementCalls方法就是将原有的方法调用实现一个 Proxy 适用于那些需要捕获异常获取方法返回值的场景这块代码比较复杂假设我们有以下 C# 代码其中我们想要替换OriginalMethod()的调用public class TargetClass { public int OriginalMethod(int a, int b) { return a * b; } } public class CallerClass { public void CallerMethod() { TargetClass target new TargetClass(); int result target.OriginalMethod(3, 4); Console.WriteLine(result); } }在应用方法调用替换后CallerMethod()将调用自定义的替换方法WrapperMethod()而不是OriginalMethod()。例如我们可以使用以下替换方法public class WrapperClass { public static int WrapperMethod(TargetClass instance, int opCode, int mdToken, long moduleVersionId, int a, int b) { Console.WriteLine(Method call replaced.); return instance.OriginalMethod(a, b); } }经过IL修改后CallerMethod()看起来大致如下public void CallerMethod() { TargetClass target new TargetClass(); int opCode /* Original CALL or CALLVIRT OpCode */; int mdToken /* Metadata token for OriginalMethod */; long moduleVersionId /* Module version ID pointer */; // Call the wrapper method instead of the original method int result WrapperClass.WrapperMethod(target, opCode, mdToken, moduleVersionId, 3, 4); Console.WriteLine(result); }现在CallerMethod()将调用WrapperMethod()在这个例子中我们记录了一条替换消息然后继续调用OriginalMethod()。正如所述通过捕获JITCompilationStarted事件并对中间语言IL进行改写我们修改方法行为的基本原理。在 .NET Framework 4.5 之前的版本中这种方式广泛应用于方法改写和植入埋点从而实现 APM 的自动化探针。然而此方法也存在以下一些不足之处不支持动态更新JITCompilationStarted在方法被 JIT 编译之前触发这意味着它只能在初次编译过程中修改 IL。更大的性能影响由于JITCompilationStarted是一个全局事件它会在每个需要 JIT 编译的方法被调用时触发。因此如果在此事件中进行 IL 修改可能会对整个应用程序产生更大的性能影响。无法控制执行时机在JITCompilationStarted中重写 IL 时您不能精确控制何时对某个方法应用更改。某些情况下运行时可能选择跳过JIT编译过程例如对于 NGENNative Image Generator俗称AOT编译生成的本地映像此时无法捕获到JITCompilationStarted事件。在多线程环境下可能会出现竞争条件导致一些方法执行的是未更新的代码。但是我们也无法再其它时间进行重写因为JIT一般情况下只会编译一次JIT 已经完成编译以后修改方法 IL 不会再次 JIT 修改也不会生效。在 .NET Framework 4.5 诞生之前我们并未拥有更为优美的途径来实现 APM 自动化探测。然而随着 .NET Framework 4.5 的降临一条全新的路径终于展现在我们面前。重新JIT编译#上文中提到了捕获JITCompilationStarted事件时进行方法重写的种种缺点于是在.NET 4.5中新增了一个名为RequestReJIT的方法它允许运行时动态地重新编译方法。RequestReJIT主要用于性能分析和诊断工具在程序运行过程中可以为指定的方法替换新的即时编译JIT代码以便优化性能或修复bug。RequestReJIT提供了一种强大的机制使开发人员能够在不重启应用程序的情况下热更新代码逻辑。这在分析、监视及优化应用程序性能方面非常有用。它可以在程序运行时动态地替换指定方法的 JIT 代码而无需关心方法是否已经被编译过。RequestReJIT减轻了多线程环境下的竞争风险并且可以处理 NGEN 映像中的方法。通过提供这个强大的机制RequestReJIT使得性能分析和诊断工具能够更有效地优化应用程序性能及修复bug。使用RequestReJIT重写方法IL的流程如下Profiler 初始化当.NET应用程序启动时分析器profiler会利用Profiler API向CLRCommon Language Runtime注册。这允许分析器在整个应用程序生命周期内监听和操纵代码执行流程。确定要修改的方法分析器需要识别哪些方法需要进行修改。这通常是通过分析方法元数据如方法名称、参数类型和返回值类型等来判断的。为目标方法替换 IL 代码首先分析器获取目标方法的原始 IL 代码并在适当位置插入新的跟踪逻辑。接着使用 SetILFunctionBody 方法将修改后的 IL 代码设置为目标方法的新 IL 代码。请求重新 JIT 编译使用RequestReJIT方法通知 CLR 重新编译目标方法。此时CLR 会触发ReJITCompilationStarted事件。捕获ReJITCompilationStarted事件分析器订阅ReJITCompilationStarted事件在事件回调中获取到修改后的 IL 代码订阅结束事件分析器可以获取本次重新编译是否成功。生成新的本地代码CLR 会根据修改后的 IL 代码重新进行 JIT 编译生成新的本地代码。这样新的 JIT 代码便包含了插入的跟踪逻辑。执行新的本地代码之后当目标方法被调用时将执行新生成的本地代码。这意味着插入的跟踪逻辑会在应用程序运行期间起作用从而收集性能数据和诊断信息。有了RequestJIT方法我们可以在任何时间修改方法 IL 然后进行重新编译无需拦截JIT执行事件在新版的 dd-trace 触发方法注入放到了托管代码中托管的 C# 代码直接调用非托管的分析器 C 代码进行方法注入所以不需要单独在 json 文件中配置。取而代之的是InstrumentationDefinitions.g.cs文件在编译时会扫描所有标记了InstrumentMethod特性的方法然后自动生成这个类。