开源测试工具 自动化回归测试平台 AREX Agent 源码阅读

lijing-22 · 2023年05月23日 · 2883 次阅读

AREX 启动过程

通用 Java Agent 的启动过程

Java Agent 是一种 Java 应用程序,它可以在 Java 应用程序启动时动态地注入到 JVM 中,并在应用程序运行时监视和修改应用程序的行为。Java Agent 通常用于性能分析、代码覆盖率、安全检查等方面。

以下是 Java Agent 的启动过程:

  1. 编写 Java Agent 程序,实现 premain方法。premain方法是 Java Agent 的入口方法,它会在 Java 应用程序启动时被调用。在 premain方法中,可以进行一些初始化操作,如设置代理、加载配置文件等。

  2. 将 Java Agent 打包成 jar 文件,并在 MANIFEST.MF 文件中指定 Premain-Class属性,该属性指定了 Java Agent 的入口类。

  3. 在启动 Java 应用程序时,通过 -javaagent 参数指定 Java Agent 的 jar 文件路径。例如:

java -javaagent:/path/to/agent.jar -jar myapp.jar

在上面的命令中,/path/to/agent.jar 是 Java Agent 的 jar 文件路径,myapp.jar 是 Java 应用程序的 jar 文件路径。

  1. 当 Java 应用程序启动时,JVM 会加载 Java Agent 的 jar 文件,并调用 premain方法。在 premain方法中,Java Agent 可以使用 Java Instrumentation API 来修改 Java 应用程序的字节码,实现对应用程序的监视和修改。

AREX 源码视角的启动过程

步骤一

arex-agent modulepom.xml文件中,通过配置manifestEntries,将Premain-Class属性设置为io.arex.agent.ArexJavaAgent。这意味着在构建arex-agent.jar时,将在manifest文件中指定ArexJavaAgent类作为 Agent 的入口点。

步骤二

ArexJavaAgent类中,实现了premain方法作为 Agent 的入口方法。在premain方法中,它调用了agentmain方法。在agentmain方法中,进一步调用了init(Instrumentation inst, String agentArgs)函数。这个函数接受一个Instrumentation对象和一个字符串参数agentArgs

步骤三

init函数中,有两个重要的操作:installBootstrapJar()AgentInitializer.initialize()

installBootstrapJar()

installBootstrapJar()函数根据AgentInitializer.class找到其所在的 jar 包,并通过调用inst.appendToBootstrapClassLoaderSearch(jar)将其添加到 Bootstrap ClassLoader 的搜索路径中。Bootstrap ClassLoader 是 Java 虚拟机中负责加载核心类库(如java.langjava.util等)的特殊类加载器。通过调用appendToBootstrapClassLoaderSearch方法,可以将自定义的类库添加到 Bootstrap ClassLoader 的搜索路径中,从而使得 Java 应用程序能够使用这些自定义的类库。

如要根据 class 对象或者 jar 包的实现,获取一个类所在的 jar 包,可以按照以下步骤进行:

  1. 获取该类的Class对象。
  2. 调用Class对象的getProtectionDomain()方法获取该类的ProtectionDomain对象。
  3. 调用ProtectionDomain对象的getCodeSource()方法获取该类的CodeSource对象。
  4. 调用CodeSource对象的getLocation()方法获取该类所在的 jar 包的 URL。
  5. 通过 URL 对象的getFile()方法获取该 jar 包的路径。

AgentInitializer.initialize()

AgentInitializer.initialize()函数是根据ArexJavaAgent.class找到其所在的 jar 包(AgentInitializer.java文件),然后设置arex.agent.jar.file.path变量,即代理 jar 包所在的目录。

接下来,它会在该目录下查找/extensions/子目录,并读取该目录下的所有 jar 包文件,这些文件是扩展包所在的位置。

然后,调用createAgentClassLoader(agent jar, 扩展包.jar)函数,创建一个AgentClassLoader对象,它是 AREX 自定义的类加载器。使用自定义的类加载器是为了隔离,防止应用程序能够访问 AREX Agent 的代码。

接着,调用createAgentInstaller()函数,该函数使用前面生成的AgentClassLoader加载类io.arex.agent.instrumentation.InstrumentationInstaller,获取其构造函数并创建实例,然后返回指向AgentInstaller接口的对象。

AdviceClassesCollector收集代理 jar 文件和扩展 jar 文件。

使用指向AgentInstaller接口的installer(前面返回的对象)调用install()函数,实际上会调用BaseAgentInstaller类的install()函数,该函数调用init(String agentArgs)进行初始化。

BaseAgentInstaller类的install()函数中,调用init()函数执行以下操作:

  1. 初始化TraceContextManager,生成IDGenerator用于生成TransactionID
  2. 初始化installSerializer
  3. 初始化RecordLimiter,设置录制频率限制。
  4. ConfigService加载代理配置,包括设置调试模式、动态类配置、排除操作配置、Dubbo 回放阈值、记录速率配置等。
  5. 初始化数据收集器,根据运行模式进行判断,并启动数据收集器。
  6. 再次从服务器获取代理配置,进行三次重试,然后解析配置并进行再次设置和更新(注意:存在一个 BUG,第二次从服务器获取配置后,并没有看到 Dubbo 的回放阈值得到更新)。

BaseAgentInstaller类的install()函数中,调用了名为transform()的抽象函数。实际上,这个抽象函数的具体实现在InstrumentationInstaller类的transform()函数中。

通过这些配置和操作,ArexJavaAgent类将被作为 Agent 的入口点,在 Java 应用程序启动时被加载,并对 Bootstrap ClassLoader 进行扩展,使得应用程序能够使用自定义的类库。

步骤四

在 InstrumentationInstaller 类的 transform() 函数中实现了对目标应用程序的代码注入操作。

  1. 通过 getAgentBuilder() 获取了 ByteBuddy 的 AgentBuilder。
  2. 通过 SPI 函数获取了所有标识为 ModuleInstrumentation.class 的类的列表,这些类使用了 com.google.auto.service 注解 @AutoService(ModuleInstrumentation.class)。
  3. 根据上文获取的 List, 逐个调用 InstallModule(),即使用步骤 6.a 中获取的 AgentBuilder 和 ModuleInstrumentation 注册模块。
  4. 在 ModuleInstrumentation 类中获取了 TypeInstrumentation 的列表,针对每个 TypeInstrumentation,找到其对应的 MethodInstrumentation 列表。
  5. 对于每个 MethodInstrumentation,调用 AgentBuilder.Identified 的 transform() 函数进行代码注入。

简而言之,这一步过程是实现了模块化的插桩功能。通过实现 ModuleInstrumentation 接口,可以定义需要进行代码注入的模块。在每个模块中,通过实现 TypeInstrumentation 接口可以定义需要注入代码的具体类型。而在每个类型中,通过实现 MethodInstrumentation 接口可以定义需要注入代码的具体方法。这样,AREX Agent 就可以根据这些定义,将录制和回放的代码注入到相应的方法中,实现了对应功能的记录和重放。

步骤五

完成所有类的注入,完成 AREX 的启动后 AREX 开始运行。

AREX 录制回放

录制回放概述

AREX 的录制功能不仅仅是单独录制请求报文,而是将请求、应答报文以及内部调用的请求和应答一并保存下来。核心目标是将请求、应答和内部调用的请求应答一一关联起来保存。AREX 采用类似 OpenTelemetry 的 Tracing 技术,实现全链路跟踪并保存关联的 Tracing ID。

录制

录制分为入口录制和内部调用录制两部分。入口请求中没有 Tracing ID,需要生成唯一的 Tracing ID,并记录下来。入口录制保存请求和 Tracing ID。内部调用录制保存 Tracing ID 和内部调用的请求、应答。

入口请求的响应报文也需要记录,即入口调用的应答和 Tracing ID(这里提到的 Tracing ID 在后文中称为 AREX Record ID)。

回放

回放过程中,入口请求中包含 AREX-Replay-ID 和 Record ID 的报文。根据 Record ID 从数据库中获取相应的应答,并返回给调用方。同时,关联 Replay ID 记录回放过程中的数据并保存到数据库。

在内部调用过程中,如果检测到当前处于回放状态,则根据 Record ID 从数据库中获取数据返回(模拟应答),并记录内部调用的请求,关联 Replay ID 并保存到数据库。

根据 Replay ID,找到入口调用的应答报文以及内部调用的请求报文,并进行录制场景和回放场景的差异比对。

最后输出差异结果,结束回放过程。

AREX Servlet 的入口录制和回放

代码所在目录 arex-agent-java\arex-instrumentation\servlet

AREX 注入代码三要素

  1. ModuleInstrumentation: FilterModuleInstrumentationV3
  2. TypeInstrumentation: FilterInstrumentationV3
  3. MethodInstrumentation:
 @Override
public List<MethodInstrumentation> methodAdvices() {
    ElementMatcher<MethodDescription> matcher = named("doFilter")
            .and(takesArgument(0, named("javax.servlet.ServletRequest")))
            .and(takesArgument(1, named("javax.servlet.ServletResponse")));

    return Collections.singletonList(new MethodInstrumentation(matcher, FilterAdvice.class.getName()));
} 

录制回放的步骤

  1. 改写 javax.servlet.Filter 类的 doFilter(request, response) 函数。
  2. 在函数入口处(OnMethodEnter)进行改写,并获取两个参数,0 位是 request,1 位是 response。

    a. 调用 ServletAdviceHelper.onServiceEnter(),传入请求和应答。

    b. 调用 CaseEventDispatcher.onEvent(CaseEvent.ofEnterEvent()),其中包括调用了 TimeCache.remove()、TraceContextManager.remove() 和 ContextManager.overdueCleanUp()。

    c. 调用 CaseEventDispatcher.onEvent(CaseEvent.ofCreateEvent()),其中包括调用了 initContext(source) 和 initClock()。

    initContext() 函数调用设置 ArexContext,入口处会生成 TraceID。ContextManager.currentContext(true, source.getCaseId()) 中的 createIfAbsent 参数传入 True,会调用 TRACE_CONTEXT.set(messageId)。

    initClock() 函数判断当前是否处于回放状态,如果是则解析时间并调用 TimeCache.put(millis)。如果当前是录制状态(即 ArexContext 不为空且不处于回放状态 ContextManager.needRecord()),则调用 RecordMocker。

  3. 在函数出口处(OnMethodExit)进行改写,调用 ServletAdviceHelper.onServiceExit()。

    调用 new ServletExtractor<>(adapter, httpServletRequest, httpServletResponse).execute() 函数。

    然后调用 doExecute(),构建 Mocker 对象,并为 Mocker 对象设置请求头、Body 和属性。同时为 Mocker 对象设置响应对象、Body 和 Type。

    如果当前处于回放状态,则回放 Mocker 数据。如果当前处于录制状态,则保存 Mocker 数据。

类似的实现方式也适用于入口录制和回放,原理类似,不再赘述。

  • 对于 Dubbo,可以在 DubboProviderExtractor 类的 onServiceEnter() 中实现。
  • 对于 Netty,可以在 io.netty.channel.DefaultChannelPipeline 类中的 add 前缀函数和 replace 函数中实现。

AREX 内部调用的录制回放

代码所在目录 arex-agent-java\arex-instrumentation\netty\arex-netty-v4

AREX 注入代码三要素:

  1. ModuleInstrumentation: NettyModuleInstrumentation
  2. TypeInstrumentation: ChannelPipelineInstrumentation
  3. MethodInstrumentation:
 @Override
public List<MethodInstrumentation> methodAdvices() {
    return singletonList(new MethodInstrumentation(
            isMethod().and(nameStartsWith("add").or(named("replace")))
                    .and(takesArgument(1, String.class))
                    .and(takesArgument(2, named("io.netty.channel.ChannelHandler"))),
            AddHandlerAdvice.class.getName()));
} 

录制回放的步骤

在 Java Netty 中,ChannelPipeline 是一个事件处理机制,用于处理入站和出站事件。它是 Netty 的核心组件之一,用于管理 ChannelHandler 的处理流程。当一个事件被触发时,它会被传递给 ChannelPipeline,然后由 Pipeline 中的每个 ChannelHandler 依次处理。每个 ChannelHandler 都可以对事件进行处理或者转发给下一个 ChannelHandler。addAfter 方法是用于向 ChannelPipeline 中添加一个新的 ChannelHandler,并将其插入到指定的 ChannelHandler 之后。这个方法可以用于动态地修改 ChannelPipeline 中的处理流程,以便在运行时根据需要添加或删除处理器。

在改写 io.netty.channel.DefaultChannelPipeline 类的 add 前缀函数或者 replace 函数时,我们可以在函数 OnMethodExit 时获取当前对象的 ChannelPipeline,以及参数 1 handleNamer 和参数 2 handler。

我们可以进行以下判断和处理:

  • 如果 handler 是 HttpRequestDecoder 实例,则调用 RequestTracingHandler() 来处理回放的数据。

  • 如果 handler 是 HttpResponseEncoder 实例,则调用 ResponseTracingHandler() 来处理录制的数据。

  • 如果 handler 是 HttpServerCodec 实例,则调用 ServerCodecTracingHandler() 来处理。HttpServerCodec 是 Java Netty 中的一个 ChannelHandler,用于将 HTTP 请求和响应消息编码和解码为 HTTP 消息。它实现了 HTTP 协议的编解码,可以将 HTTP 请求和响应消息转换为字节流,以便在网络中传输。

异步访问的处理

在 Java 生态系统中存在多种异步框架和类库,如 Reactor、RxJava 等,同时还有一些类库提供了异步访问的实现,例如 lettuce 提供了同步和异步访问 Redis 的方式。不同的场景通常需要不同的解决方案。

以 ApacheAsyncClient 为例,它是通过在固定运行的线程中监听响应并发起回调(Callback)来实现异步处理。在整个调用、监听和回调的过程中,需要确保多个跨线程的 Trace 传递。

在注入代码中,需要使用 FutureCallbackWrapper 中的 TraceTransmitter 来传递 Trace。具体的注入位置如下:

  • ModuleInstrumentation: SyncClientModuleInstrumentation

  • TypeInstrumentation: InternalHttpAsyncClientInstrumentation(用于异步情况)、InternalHttpClientInstrumentation

  • MethodInstrumentation: 注入到 org.apache.http.impl.nio.client.InternalHttpAsyncClient 类的 execute 函数,使用 named("execute") 方法进行识别。

录制回放的步骤

在注入函数中,我们针对 org.apache.http.impl.nio.client.InternalHttpAsyncClient 类的 execute 函数进行操作,使用函数名 named("execute") 来标识该函数。

首先,我们获取 execute 函数的第三个参数 FutureCallback,并将其赋值给 AREX 实现的封装类 FutureCallbackWrapper 的 callback 参数。FutureCallback 接口定义了两个方法:onSuccess 和 onFailure。当异步操作成功完成时,onSuccess 方法将被调用,并传递异步操作的结果作为参数。当异步操作失败时,onFailure 方法将被调用,并传递异常作为参数。

然后,我们进行以下判断:

  • 如果需要进行录制,则 FutureCallbackWrapper 的封装类重写了 completed(T) 函数,在 completed 函数中保存响应数据,然后调用原始的 FutureCallback 的 completed 方法。同样地,FutureCallbackWrapper 的封装类也重写了 failed() 函数,在 failed 函数中记录响应数据,并调用原始的 FutureCallback 的 failed 方法。

  • 如果需要进行回放,则获取回放数据并将其保存在本地的 mockResult 变量中。

最后,在注入函数的出口处,如果 mockResult 变量的数据不为空,并且 callback 是 AREX 封装类的实例,那么调用封装类的 replay 函数进行回放操作。

通过以上操作,我们在 execute 函数的入口和出口处对跨线程的 Trace 传递进行了处理,包括录制和回放功能的实现。

AREX 录制频率设置

在 ServletAdviceHelper 类的 onServiceEnter 函数中(就是 Servlet 进入的函数中),实现了 AREX 的录制频率设置。

CaseEventDispatcher.onEvent(CaseEvent.ofEnterEvent());
if (shouldSkip(adapter, httpServletRequest)) {
            return null;
}

首先,根据报文头和配置进行录制判断:

  • 如果请求报文头中有 caseID 字段,查询配置项 arex.disable.replay,如果该配置项的值为 true,则直接跳过录制。
  • 如果请求报文头中存在 arex-force-record 字段,并且该字段的值为 true,则不能跳过录制。
  • 如果请求报文头中存在 arex-replay-warm-up 字段,并且该字段的值为 true,则跳过录制。

接下来,会解析请求报文:

  • 如果请求 URL 为空,则跳过录制。
  • 如果请求 URL 在配置中的录制忽略列表中,则跳过录制。

接着,会调用 Config 类的 invalidRecord 方法进行录制有效性的检查:

  • 如果配置处于 debug 状态,则不能跳过录制,直接返回 false。
  • 如果配置的录制速率小于 0,则跳过录制。

最后,根据请求的路径和录制速率判断是否需要跳过录制,这里使用 com.google.common.util.concurrent.RateLimiter 类的 acquire 函数。RateLimiter 是 Google Guava 库中的一个类,用于限制操作的频率。它可以用于控制某个操作在一定时间内最多能执行多少次,或者在一定时间内最多能执行多少次操作。使用RateLimiter类,需要先创建一个RateLimiter对象,并指定它的速率限制。然后,我们可以使用 acquire()方法获取一个许可证,表示可以执行一个操作。

  • 如果当前速率已经达到了限制,acquire 函数会阻塞等待,直到能够获取到许可证为止。
  • 如果能够获取到许可证,则不跳过录制。

AREX 代码隔离

在 Java 虚拟机中,判断两个类是否相等时,不仅会比较它们的全限定名,还会比较它们的加载器。如果两个类的全限定名相同,但加载它们的 ClassLoader 不同,Java 虚拟机会认为这是两个不同的类。

这种设计有助于保证 Java 虚拟机的安全性和隔离性。不同的 ClassLoader 可以加载同一个类,但它们加载的类是相互独立的,互相不可见的。这样可以避免不同应用程序或模块之间的类冲突和干扰。

在 AREX 中,涉及的 ClassLoader 有以下几种:

  • arex-agent:由 AppClassLoader 加载,用于加载 AREX Agent 的核心组件。
  • arex-agent-bootstrap:由引导类加载器(Bootstrap ClassLoader)加载,用于加载 AREX Agent 的引导类。
  • arex-agent-core:由 AgentClassLoader 加载,这是 AREX 自定义的 ClassLoader,负责加载 arex-agent-core 等 jar。
  • arex-instrumentation:由 UserClassLoader 加载,用于加载 AREX 的 Instrumentation、Module 和 Advice 等组件。
    • XXX Instrumentation & Module & Advice:由 AgentClassLoader 加载,用于加载具体的 Instrumentation、Module 和 Advice 等实现。
  • arex-instrumentation-api:由 AgentClassLoader 加载,包括 API 和 Runtime 两部分。
    • api:由 AgentClassLoader 加载,提供给用户使用的 API。
    • runtime:由 AppClassLoader 加载,用于 AREX 运行时的一些功能。
  • arex-instrumentation-foundation`:由 AgentClassLoader 加载,用于加载 AREX 的基础功能,如后端实现等。

这些不同的 ClassLoader 之间具有隔离性,确保了各个组件的独立性和安全性。

其中:

  • AgentClassLoader:AREX 自定义的 ClassLoader。

  • Bootstrap ClassLoader: Java Instrumentation API 是 Java SE 5 中引入的一个功能强大的工具,它允许在运行时修改 Java 类的行为。

其中,Instrumentation 类是 Java Instrumentation API 的核心类之一,它提供了一些方法来监测和修改 Java 应用程序的运行状态。 其中,appendToBootstrapClassLoaderSearch 方法是 Instrumentation 类中的一个方法,它的作用是将指定的 jar 文件添加到 Bootstrap ClassLoader 的搜索路径中。

Bootstrap ClassLoader 是 Java 虚拟机中的一个特殊的类加载器,它负责加载 Java 运行时环境中的核心类库,如 java.lang 和 java.util 等。

通过调用 appendToBootstrapClassLoaderSearch 方法,可以将自定义的类库添加到 Bootstrap ClassLoader 的搜索路径中,从而使得 Java 应用程序可以使用这些自定义的类库。 需要注意的是,由于 appendToBootstrapClassLoaderSearch 方法会修改 Java 虚拟机的运行状态,因此只有具有足够权限的用户才能调用该方法。

  • AppClassLoader 是 Java 应用程序默认的 ClassLoader,它负责加载应用程序的类。AppClassLoader 会从 CLASSPATH 环境变量或者系统属性 java.class.path 指定的路径中加载类文件。

如果需要加载的类不在 AppClassLoader 的搜索路径中,它会委托给父 ClassLoader 进行加载,直到 BootstrapClassLoader 为止。

  • UserClassLoader 用户自定义 ClassLoader,如 SPIUtil 类中 Load 方法如下获取 ClassLoader 加载 ClassLoader cl = Thread.currentThread().getContextClassLoader();
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册