跳到主要内容

AREX Agent 技术实现细节分享

· 阅读需 13 分钟

AREX 是一款利用 Java Agent 和字节码增强技术实现流量录制回放的自动化回归测试平台,本文将分享 AREX Agent 具体实现细节。

背景

在携程内部,随着公司业务规模和复杂度不断提高,研发测试团队面临着各种效能困境,尤其是在需要构造大量测试数据、写场景验证、发布频繁的场景下,业务的质量保障更是是重中之重。

为了满足公司持续交付的需求、并有效保障质量,我们基于流量录制回放的概念,在合法合规以及安全的前提下,开发了一款“用真实的生产流量和数据进行回归测试“自动化测试平台 AREX。利用 Java Agent 和字节码增强技术,在生产环境中记录真实请求链路的入口和依赖的请求和响应数据。然后在测试环境中进行模拟请求回放,并逐一验证整个调用链路的逻辑正确性。

通过自动录制、回放和比对功能,AREX 有效解决了回归测试的难题,使得测试过程更加高效和准确。携程机票 BU 在接入 AREX 平台后,单次发布回归测试效率提升 75%。目前机票 85% 的核心应用都已接入 AREX 并投入回归测试中,其他 BU 如酒店、商旅、用车、火车票、平台研发中心等也都在逐步接入,累计接入 App 达到 2000+,接口数量达到 18000+。

简单来说就是 AREX 可以用线上真实流量在测试环境进行回放,它的特点如下:

  1. 无代码侵入的数据采集和自动化 Mock,支持常用的开源组件:Dubbo、Http、Redis、持久层框架、配置中心的录制和回放;
  2. 支持多种复杂业务场景的验证,包括多线程并发、异步回调、写操作等;
  3. 可直接使用生产录制数据在本地回放,快速复现生产 bug。

本文将分享 AREX Agent 具体实现细节,希望对开发人员带来一些启发和帮助。项目开源地址:https://github.com/arextest/arex-agent-java

启动过程

考虑到应用的接入成本,AREX Agent 的接入过程必须要做到无侵入式,对用户透明。

AREX Agent 的接入和启动过程, 如下图所示:

  1. 用户通过 CI Pipeline 选择 Flight AREX Agent 服务后,重新打包镜像时会把 AREX 启动脚本 arex-agent.sh 打进发布包。

  2. 发布时会执行启动脚本会拉取最新的 arex-agent.jar,通过修改应用 JVM Options 挂载 AREX Agent。

  3. JVM 初始化后,将调用 premain 方法启动 AREX Agent,通过配置服务拉取应用对应配置,根据配置按需加载 AREX Agent 插件,等应用启动类加载时进行字节码增强。

  4. 目前启动默认策略为单集群只允许一台机器启动 AREX-Agent。

录制回放流程

如下图所示,通常来讲一次请求的调用链路是包含入口和各个依赖同步或异步的调用。录制过程就是把入口和各个依赖的调用通过一个 RecordId 串联起来,这样才能形成一个完整的测试用例。AREX-Agent 通过对入口调用和各个依赖调用的代码进行字节码增强,当代码被执行到时拦截调用过程,将调用的入参、返回值和异常录制下来,发送到存储服务。

在测试环境回放时会使用生产环境录制的真实数据模拟请求,AREX Agent 通过识别回放标识决定是否需要回放。如果需要回放,则不进行方法的真实调用,去拉取存储服务保存的调用响应数据进行返回。

下图是 SOA Client 同步调用被 AREX Agent 增强后的字节码示例,其他组件类似。

技术挑战

录制和回放的过程是非常复杂的,面对各种应用不同的实现方式,我们遇到了许多问题和挑战。接下来分享 AREX Agent 解决这些问题的技术细节。

类加载器隔离、互通

为了保证 AREX Agent 的代码及依赖和应用的代码不会产生冲突或干扰,AREX Agent 和应用代码是通过不同的类加载器实现的隔离。如下图所示,AREX Agent 通过自定义实现 AgentClassLoader 去重写 findClass 方法,保证 AREX Agent 使用的类只会被 AgentClassLoader 加载,避免和应用的 ClassLoader 产生冲突。

同时为了让应用 ClassLoader 能识别 AREX Agent 的录制和回放代码,AREX Agent 通过 ByteBuddy ClassInjector 将录制和回放需要的代码字节码注入到应用 ClassLoader,保证录制和回放时不会出现 ClassNotFoundException/NoClassDefFoundError。

调用链路 Trace 传递

AREX Agent 进行数据录制和回放时,会将一次请求的入口和各个依赖的调用通过一个 RecordId 串联起来。在面对多线程和各种异步框架时,对数据的串联带来很大的挑战。AREX Agent 通过对线程的增强一一解决跨线程 RecordId 传递的问题。目前支持的线程和线程池如下:

  • Thread
  • ThreadPoolExecutor
  • ForkJoinTask
  • FutureTask
  • FutureCallback
  • Reactor Framework
  • ……

我们通过一个简单的代码示例来看一种实现,其他解决思路是类似的,可以举一反三。

在调用 java.util.concurrent.ThreadPoolExecutor#execute(Runnable runnable) 时,通过使用 AgentRunnableWrapper 将参数 AgentRunnableWrapper runnable 进行 wrap,构造 AgentRunnableWrapper 时将当前线程上下文捕获,在 run 方法时替换子线程上下文,执行完后再还原子线程上下文。代码示例如下:

executors.execute(Runnable runnable)
executors.submit(Callable callable)

public void execute(Runnable var1) {
var1 =RunnableWrapper.wrap(var1);
}

public class RunnableWrapper implements Runnable {
private final Runnable runnable;
private final TraceTransmitter traceTransmitter;

private RunnableWrapper(Runnable runnable){
this.runnable = runnable;
//捕获当前线程上下文
this.traceTransmitter = TraceTransmitter.create();
}

@Override
public void run(){
//替换子线程上下文
try (TraceTransmitter tm = traceTransmitter.transmit()){
(runnable.run();
}
//还原子线程上下文
}
}

...

组件版本兼容

应用引入的组件可能存在多个版本,同一组件不同版本之间也有可能会出现不兼容的情况,比如: package 变更、方法新增或移除等。为了兼容支持组件多个版本,AREX Agent 需要识别正确的组件版本进行字节码增强,避免重复增强或者增强了错误的版本。

AREX Agent 通过识别组件 Jar 包里面 META-INF/MANIFEST.MF 的 Name 和 Version,在类加载时进行版本匹配,确保对正确的版本进行代码增强。

本地缓存 MOCK

Object value = localCache.get(key)
// 录制时存在缓存,回放时不存在缓存去查询DB
if (value != null) {
return value;
} else {
return db.query();
}

上面是一个常用的缓存使用场景,回放时经常会碰到因为和录制时本地缓存数据不一致,导致回放请求时执行的流程与录制时不同,与预期不符,降低了回放成功率。若要解决整个问题,需要考虑几点:

  • 生产和测试缓存数据隔离,很难做到实时同步
  • 本地内存实现方式各式各样,无法一一感知
  • 本地内存数据通常为基础数据,数据量非常大,录制可能会引起过高的性能损耗

现阶段 AREX Agent 采用的解决方案是每次只录制当前请求链路上用到的缓存数据,通过让应用配置动态类的方式去识别录制,测试环境回放时自动替换,保证录制和回放内存数据的一致性。大缓存数据录制的方案我们还在研究中,欢迎有此方面经验的同学与我们一起讨论。

时间回放

很多业务系统的场景是对时间敏感的,不同的时间访问往往会返回不同的结果,如果录制和回放时间不一致就会导致接口回放失败,另外由于回放请求是并发的,修改测试机器的机器时间是不合适的,而且很多服务器也不能修改当前时间,因此我们需要在代码层面上实现当前时间的 Mock。目前支持的时间类型如下:

  • java.time.Instant
  • java.time.LocalDate
  • java.time.LocalTime
  • java.time.LocalDateTime
  • java.util.Date
  • java.util.Calendar
  • org.joda.time.DateTimeUtils
  • java.time.ZonedDateTime

public static native long currentTimeMillis() 因为是一个固有函数 (intrinsic),当 JVM 对固有函数进行内联优化后,将会采用内部的代码完成现有字节码的替换(JIT),导致 AREX Agent 增强的代码失效。JDK 对 System.currentTimeMillis() and System.nanoTime() 内联操作如下:

// https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/dae2d83e0ec2/src/share/vm/classfile/vmSymbols.hpp#l631
//------------------------inline_native_time_funcs--------------
// inline code for System.currentTimeMillis() and System.nanoTime()
// these have the same type and signature
bool LibraryCallKit::inline_native_time_funcs(address funcAddr, const char* funcName) {
const TypeFunc* tf = OptoRuntime::void_long_Type();
const TypePtr* no_memory_effects = NULL;
Node* time = make_runtime_call(RC_LEAF, tf, funcAddr, funcName, no_memory_effects);
Node* value = _gvn.transform(new ProjNode(time, TypeFunc::Parms+0));
#ifdef ASSERT
Node* value_top = _gvn.transform(new ProjNode(time, TypeFunc::Parms+1));
assert(value_top == top(), "second value must be top");
#endif
set_result(value);
return true;
}

针对这个问题 AREX Agent 进行了特殊处理,通过应用配置直接对方法使用到 System.currentTimeMillis() 的代码替换为 AREX Agent 的获取时间的方法,避免产生内联优化。

规划展望

后续我们的重点会集中在以下几个方面进行优化:

  1. 提升回放效率,降低回放终止率
  2. 提升回放平台体验,降低用户使用成本
  3. 提高精准化测试,减少回放 CASE 量
  4. 静态/动态代码综合分析,实现线上 100% 业务场景覆盖

目前 AREX 整个平台已经开源,地址:https://github.com/arextest。 目前社区收获近两百位的社区用户,包括数十家企业用户,涵盖金融、互联网、制造业、电商等等。我们希望有更多的伙伴为社区贡献力量,提升公司的技术品牌影响力。

参考

  1. https://docs.oracle.com/en/java/javase/11/docs/api/java.instrument/java/lang/instrument/package-summary.html
  2. https://github.com/raphw/byte-buddy
  3. https://github.com/open-telemetry/opentelemetry-java-instrumentation