跳到主要内容

关于 AREX

Arex IconAREX

基于真实请求与数据的流量回放测试平台。

AREX 介绍

背景

对于一个初上线的简单服务,只需通过常规的自动化测试加上人工即可解决,但我们线上核心的业务系统往往比较复杂,通常也会频繁的需求迭代,如何保证被修改后的系统原有业务的正确性就比较重要。常规的自动化测试需要投入大量的人力资源,准备测试数据、脚本等,并且覆盖率通常也不高,难以满足要求。
为了保证一个线上系统的稳定性,开发和测试人员都面临不少的挑战:

  • 开发完成后难以快速本地验证,发现初步的问题,容易陷入提测->发现 bug->fix->提测的循环
  • 准备测试数据、自动化脚本编写和维护需要大量的人力成本,而且难以保证覆盖率
  • 写服务难于验证,而且测试会产生脏数据,例如我们的核心交易系统,可能会往数据库、消息队列、Redis 等写入数据,这部分数据往往比较难以验证,测试产生的数据也难于清理
  • 线上问题难以本地复现,排查困难

什么是 AREX

AREX 通过复制线上真实流量到测试环境进行自动化回归测试,解决回归测试的难题。

AREX 采用 Java 的 Instrument 实现了无代码侵入的数据采集和自动化 Mock,智能的 Mock 机制使测试运行代码集中在待测应用,不会产生真正的外部交互(DB 的写入、其它服务的调用),也完美支持了写接口的测试(如核心交易系统、库存系统等)。

AREX 支持的 API 测试包括

  • 常规测试, 类 Postman 的接口测试,用例设置、执行、结果断言(ASSERT)等
  • 回放测试, 用真实的生产数据进行回放测试,并比对返回结果存在的差异

AREX 组成

AREX 由 Front(前端)、Schedule Service(调度服务)、Storage Service(存储服务)、Report Service(报告分析服务)及 Mongodb、Redis 数据存储等多模块组成。

我们假定生产环境应用会正常的响应用户的请求,通过 aop 的方式将请求入参及返回结果以及执行过程中的一些快照数据例如访问数据库的入参和返回结果、访问远程服务器的入参及结果保存下来。然后将快照数据发送给测试机器(代码发生变化的机器)完成一次回放过程。通过将落库数据、调用后台请求的数据以及返回结果和线上真实请求发生时的数据进行对比,发现其中的差异,从而识别被测试系统的问题。

组成模块

在使用 AREX 流量录制功能时,AREX Java Agent 会记录生产环境中 Java 应用的数据流量和请求信息,并将这些信息发送给 AREX 数据存取服务(Storage Service),由数据存取服务导入 Mongodb 数据库中进行存储。当需要进行回放测试时,AREX 调度服务(Schedule Service)将会根据用户的配置和需求,通过数据存取服务从数据库中提取被测应用的录制数据(请求),然后向目标验证服务发送接口请求。同时,Java Agent 会将录制的外部依赖(外部请求/DB)的响应返回给被测应用,目标服务处理完成请求逻辑后返回响应报文。随后调度服务会将录制的响应报文与回放的响应报文进行比对,验证系统逻辑正确性,并将比对结果推送给分析服务(Report Service),由其生成回放报告,供测试人员检查。在整个过程中,AREX 的缓存服务 Redis 负责缓存回放过程中的 Mock 数据和比对结果,以提高比对效率。

前端

AREX 前端是 AREX 工具的前端操作界面。

前端界面

调度服务

调度服务负责向被测试服务发送用例回放请求,并在服务响应后触发结果比对及依赖比对。

存储服务

存储服务负责接收由 Agent 捕获的请求、应答及依赖的真实数据的存储,同时负责在回放期间,按照 Agent 要求返回已存储的数据。

报告分析服务

报告分析服务负责在执行回放测试时收集测试结果及展示问题。

数据存储

Redis 存储服务负责在回放过程中缓存数据;

MongoDB 存储服务负责存储录制的数据和回放结果。

原理示例

我们假定生产环境应用会正常的响应用户的请求,通过 aop 的方式将请求入参及返回结果以及执行过程中的一些快照数据例如访问数据库的入参和返回结果、访问远程服务器的入参及结果保存下来。然后将快照数据发送给测试机器(代码发生变化的机器)完成一次回放过程。通过将落库数据、调用后台请求的数据以及返回结果和线上真实请求发生时的数据进行对比,发现其中的差异,从而识别被测试系统的问题。

  • xxxTestCase: 采集下来的数据在回放时做为测试 CASE
  • xxxMock:在回放时会使用采集的数据进行 Mock,代替真正的数据访问
  • xxxExpect 和 xxxReal:在测试结束后会验证对应的数据,发现代码中潜藏的隐患

技术原理

在 JDK1.5 中,Java 引入了 java.lang.Instrument 包,该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class,以此实现对原类的功能增强。现在有很多工具都是基于此技术实现的,例如阿里开源的 arthas、监控工具 SkyWalking 等, AREX 的数据采集和自动 Mock 也是基于此技术实现。

平台优势

低成本

无代码侵入,基本无接入成本
无需编写测试用例,海量的线上请求也能保证高覆盖率
插桩代码足够简单,性能损耗低

多样性支持

支持写验证,支持数据库、消息队列、Redis 数据的验证,甚至支持验证运行时的内存数据,并且测试时不会产生脏数据。

测试用例运行稳定

支持各种主流技术框架的自动数据采集和 Mock,参见:arex_java ,并且支持了本地时间、缓存,在回放时精准还原生产执行时的数据环境。

快速线上问题复现

支持一键本地调试,可以快速本地调试线上问题

安全稳定

代码隔离,也实现了健康管理,在系统繁忙时会智能降低或关闭数据采集频率

良好的功能测试支持

支持测试脚本,也可对采集的数据进行简单的编辑实现固定测试观点的测试,避免大量的测试数据准备

技术实现

我们采用了ByteBuddy 库实现的字节码修改,在实现过程中也遇到了各式的挑战。

类加载器隔离、互通

为了保证 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 的获取时间的方法,避免产生内联优化。