基于 JavaAgent 的无侵入 Mock:让联调和链路测试不再等下游

17 阅读11分钟

目标就一句话:不改业务代码,把下游的返回值临时换掉。用它做联调、走通异常分支、让链路测试有个稳定的下游。

实现方式是在业务 JVM 里挂一个 javaagent,拦住框架出口的调用(Dubbo、Feign、RestTemplate、HttpClient、Spring MVC、RocketMQ)。命中配置就返回平台给的数据,没命中就原样放行。

这套东西跑了两年多:8 类框架、20 个拦截点、116 个 Java 文件。下面讲它怎么搭起来的、坑在哪。只想看原理的话,代码块都可以跳过。


1. 先把问题说清楚

假设调用链是 A → B → C。C 的某个分支还没写完,你要验 A 的异常处理。

能想到的办法都不太顺:

  • 起一个 Mock Server:它只能挡"前端打到网关"这一段。B 调用 C 走的是 Dubbo,根本不经过它。
  • 在 B 里写死返回值:改代码、发版、还得记得删。上线前忘删就是事故。
  • 单元测试里 mock:测不到真实链路上的序列化、超时、异常包装。
  • 改注册中心,把 C 的地址指到一个假服务:影响面太大,协议和版本也未必对得上。

所以需求其实是:在 C 的进程里,把它"出站调用"的那一刻接管掉,其它什么都不动。

实际用下来,主要是三件事:

  • 联调:下游还没写好,但接口约定定了,先按约定返回,把自己这边验完;
  • 分支验证:构造异常、超时、空数据,看上游的 catch、重试、降级写得对不对;
  • 链路测试:把下游行为固定住,避免你测的时候别人的服务正在变。

三条硬要求随之而来,后面所有设计都是被它们逼出来的:

  • 不能改业务代码。改了就要发版,联调期天天发版没人受得了。
  • 配置要能实时改。改一条 mock 数据等 5 分钟发版,工具就废了。
  • 它自己不能成为故障源。一个帮忙的组件如果把业务拖垮,第二天就会被全公司卸掉。

2. 原理一句话:站在框架的门口

不拦业务方法,拦框架。

业务代码调下游,最后一定会经过框架的某个固定方法:Dubbo 的 MockClusterInvoker.invoke、Feign 的 InvocationHandler.invoke、HttpClient 的 execute、Spring MVC 的 DispatcherServlet.doDispatch。

这些方法的名字十年不变,参数里还天然带着"调的是哪个接口、哪个方法、什么参数"。我们只要在它们执行前插一脚,就能决定这次调用是放过去,还是直接返回假数据。

业务代码 ──► 框架出口方法 ──► 真实下游
                  │
                  └── 被我们拦住:查配置,命中就返回假数据

怎么"插一脚"

用 javaagent。这是 JVM 自带的能力:启动时(或运行中被 attach)加载一个 jar,JVM 会给它一个 Instrumentation 对象,允许它修改还没加载的类的字节码。

改字节码用 ByteBuddy,它提供的是"描述式"写法:告诉它"我想改哪个类、哪个方法、改成调用谁",它负责生成新的 class 文件。

new AgentBuilder.Default()
    .type(named("org.apache.dubbo.rpc.cluster.support.wrapper.MockClusterInvoker"))
    .transform((builder, type, loader, module) ->
        builder.method(named("invoke"))
               .intercept(MethodDelegation.to(new CommonInterceptor(...))))
    .installOn(instrumentation);

这段的意思是:找到 MockClusterInvoker,把它的 invoke 方法改成"先调用我的拦截器,由拦截器决定要不要继续"。


3. 拦哪儿:8 类框架,20 个点

每个框架有"启动"和"调用"两类位置,用途不同:

  • 启动时拦(start):把"我依赖哪些接口、暴露哪些端点、订阅哪些 topic"上报给平台。只读不写,出问题也不影响业务。
  • 调用时拦(invoke):在真实调用链上,先查本地配置,没命中就直接放过去。

我们实际拦的点(节选,同类合并):

拦谁拦哪个方法干什么
Dubbo 消费端MockClusterInvoker.invoke命中就返回假数据
Dubbo 消费端启动ReferenceBean.afterPropertiesSet上报依赖的接口和方法
Dubbo 提供端ServiceBean.export上报暴露的接口和字段结构
Dubbo 提供端AbstractProxyInvoker.invoke记录真实入参
FeignReflectiveFeign$FeignInvocationHandler.invoke命中就返回假数据
RestTemplateAbstractBufferingClientHttpRequest.executeInternal覆盖两种底层 HTTP 实现
Apache HttpClientCloseableHttpClient.execute按 URL + method 匹配
Spring MVCDispatcherServlet.doDispatch / checkMultipart记录 URI、Header、Body
Spring MVC 启动AbstractHandlerMethodMapping.afterPropertiesSet反射导出所有接口
RocketMQDefaultMQPushConsumer.start / onMessage记录订阅关系和消息体
任意类(兜底)平台下发的类名 + 方法名非标准框架也能 mock

两个容易写错的地方:

匹配用 named 还是 hasSuperType。 DispatcherServlet 是确定类,named 就行;CloseableHttpClient 是抽象类,业务实际用的是它的子类,必须用 hasSuperType。这一个词写错,现象是"agent 明明挂了,但就是没生效",很难查。

一定要排除自己。 java.、javax.、sun.、net.bytebuddy.、org.slf4j. 这些包必须 ignore,否则 agent 会试图增强 JDK 和它自己,轻则启动变慢,重则死循环。


4. 所有框架适配,收敛成一个接口

如果每个框架写一套逻辑,20 个点会变成 20 份重复代码。所以只抽象出四个方法:

public interface MockTransformer {
    ElementMatcher<TypeDescription> classMatcher();          // 改哪个类
    ElementMatcher<? super MethodDescription> methodMatcher(); // 改哪个方法
    String interceptorClassName(boolean withMetrics);        // 交给谁处理
    void before(CommonInterceptor interceptor, boolean withMetrics); // 转换前的准备
}

再维护一张表,按顺序注册进去:

PluginFinder finder = new PluginFinder();
finder.addTransformer(new FeignInvokeTransformer());
finder.addTransformer(new Dubbo27InvokeTransformer());
// ... 一共 20 个,顺序就是优先级

agentBuilder.type(finder.buildMatch()).transform((builder, type, loader, module) -> {
    MockTransformer t = finder.find(type);        // 第一个匹配的生效
    CommonInterceptor interceptor =
            new CommonInterceptor(loader, t.interceptorClassName(false));
    t.before(interceptor, false);
    return builder.method(t.methodMatcher())
                  .intercept(MethodDelegation.to(interceptor));
});

为什么一个类只让第一个匹配的 transformer 生效? 因为可预期。出问题时你能立刻说出"这个类是被谁改的",而不是猜哪几个拦截器叠在一起。代价是没法组合,真要组合就自己写一个组合拦截器。

另外留了一个逃生舱:平台可以下发"给 com.example.order.OrderService.createOrder 打 mock",agent 启动时按类名和方法名生成一个临时 transformer。不是标准框架也能用。注意它只在启动时构建一次,新增这类配置需要重启或重新 attach。


5. 最麻烦的地方:类加载器

前面都很顺,真正让自研 agent 翻车的是这里。如果只想记一件事,记这个:agent 和业务在同一个 JVM 里,但它们的类不是"同一个世界"的。

5.1 拦截器为什么不能直接用 agent 的加载器

拦截器里要引用业务侧的类型,比如 CloseableHttpResponse、AbstractProxyInvoker。这些类在 Spring Boot fat jar 里,只有业务的类加载器找得到。

所以每个拦截器实例都配一个自己的类加载器,父亲是"被增强的那个类的加载器":

public class CommonInterceptor {
    private final Interceptor delegate;

    public CommonInterceptor(ClassLoader targetClassLoader, String interceptorClass) {
        InterceptorClassLoader loader = new InterceptorClassLoader(targetClassLoader);
        this.delegate = InterceptorInstanceLoader.load(interceptorClass, loader);
    }

    public Object intercept(@Origin Method method, @AllArguments Object[] args,
                            @This Object self, @SuperCall Callable<?> supercall) throws Exception {
        if (delegate == null) return supercall.call();   // 加载失败也不拦,保业务
        return delegate.intercept(method, args, self, supercall);
    }
}

加载时有个细节:用 findClass,不要用 loadClass。

loadClass 会先问父加载器。agent jar 通常挂在 system classpath 上,AppClassLoader 自己就能搜到拦截器类,于是父加载器抢先定义了它。之后解析拦截器里引用的业务类型时,用的是父加载器的视野——看不到 fat jar 里的 httpclient,直接 NoClassDefFoundError。

5.2 child-first 不能无脑开

有些类必须"子加载器优先",否则拦截器内部类和它的外部类会分处两个加载器,访问私有成员直接报错。但无脑 child-first 会炸得更惨:

做法现象原因
无脑 child-firstSecurityException: Prohibited package name: java.util判断"是不是我的类"时向上搜索,把已经由 bootstrap 加载的 java.util.HashMap 认成自己的,然后试图定义一个 java.* 类
只走双亲委派NoClassDefFoundError: CloseableHttpResponse内部类引用了应用侧类型,却被 AppClassLoader 定义,链接时用错了加载器

最后的规则只有三条:

  1. 只认自己 defineClass 过的类,单独记一个集合,绝不向上搜;
  2. child-first 只对 Outer$Inner 这种内部类开,顶层类型照旧走双亲委派;
  3. 共享的顶层接口(比如 Interceptor)绝不能重复定义,否则跨加载器赋值时 checkcast 失败。
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    if (!isInnerClassOfLocalClass(name)) return super.loadClass(name, resolve);
    // 只有"外部类已由本加载器定义"的内部类,才走 child-first
    ...
}

5.3 一个注解引发的 NPE

MethodDelegation 会把委托目标上所有 public 方法都当成候选。我们在 CommonInterceptor 上加了个辅助方法 loadClass(String),本意是预加载类,结果它被误绑到了被拦截方法的第一个 String 参数上——运行时把业务入参当类名传进去,返回 null,紧接着 NPE。

修法是加 @IgnoreForBinding。这类问题单测永远复现不了,只在真实 agent 环境里出现,所以现在的约定是:委托目标类上除了 intercept,任何 public 方法都必须标 @IgnoreForBinding。


6. 一次调用完整走一遍

以 Dubbo 消费端为例:

  1. 业务发起调用,进入 MockClusterInvoker.invoke,被我们的拦截器接管;
  2. 从 self 里取出接口名,从入参里取出方法名、group、version,拼成查询条件;
  3. 查本地配置表(一个 ConcurrentHashMap)。没查到就直接放行,这条路径没有任何额外开销;
  4. 查到了,把参数序列化后发给平台,由平台决定返回什么(规则匹配、模板渲染、脚本执行都在服务端做);
  5. 拿到结果反序列化成方法的返回类型,包成 AsyncRpcResult 返回给业务。

第 3 步是关键:绝大多数调用是没配置的,所以"未命中"这条路径必须便宜到可以忽略。

返回值怎么处理

平台不只返回数据,还会返回一个状态码,agent 按码行事:

状态码含义agent 怎么办
-1 / -2 / -3没配置 / 规则没匹配上 / 数据为空老老实实走真实调用
-4脚本执行出错把错误信息当结果返回(返回类型是 String 时直接返回文本)
正常拿到了 mock 数据反序列化后返回

这里的取舍是:配了 mock 但参数不匹配时,应该走真实调用。mock 的语义是"覆盖一部分流量",不是"劫持整个接口"。要是参数对不上就返回空,联调现场会以为下游真的挂了。

两件容易忽略的事

反序列化要用 getGenericReturnType()。 getReturnType() 会把 Result<List<OrderVO>> 擦成 Result,反序列化出来的 data 是 LinkedHashMap,调用方一取就 ClassCastException。

异常要还原成真实的异常类型。 直接 throw new RuntimeException("mock error") 是没用的:调用方的 catch (BizException e)、重试逻辑、事务回滚规则全部依赖真实类型。所以平台返回的是 {"type":"com.example.BizException","code":"E1001","message":"余额不足"},agent 按类名加载并实例化:

Class<?> clazz = Class.forName(type, true, Thread.currentThread().getContextClassLoader());
// 按 (String message) / (String code, String message) 等形态逐个尝试构造器,失败再降级

注意必须用线程上下文类加载器,用 agent 自己的加载器找不到业务异常类。


7. 配置怎么实时生效

没有用长连接推送,用的是"定时拉 + 本地缓存":每 5 秒拉一次全量配置,服务端把结果算个 MD5,变了才替换本地表。

替换时按 key 增删,不 clear() 再 putAll()——后者在替换瞬间会有个"配置全空"的窗口,这期间的流量会突然打到真实服务上。

上报方向同理,也算指纹:把配置 key 排序后取 MD5,没变就不发请求。失败时不更新指纹,下一轮自动重试,不需要额外的重试队列。

为什么不用推送?因为 agent 是无状态的:断网、重启、扩容都不用恢复连接。代价是最长 5 秒延迟,对"联调工具"这个定位完全够用。真要做到亚秒级,再换推送不迟。

还有一个细节:首次同步必须异步。拉配置的入口挂在 Dubbo 启动拦截器上,如果在这里同步发 HTTP,后端抖一下就会拖慢甚至阻断应用启动。所以入口只做一次标志位判断,实际工作全部丢给后台线程。

超时也要分档:配置拉取和注册给 15 秒,其它上报给 5 秒。原因很实在——配置几千条时响应体很大,3 秒容易在服务端还在写响应的时候被客户端断开,日志里就是 Broken pipe。


8. 顺手把真实请求录下来

只用不采,接入成本会高到没人愿意用:新人得自己手写接口名、方法名、参数结构、返回值。所以 agent 启动和运行时顺带做几件事。

Dubbo 提供端:ServiceBean.export() 执行完再采集,这时 group/version 已经确定。参数名优先用反射(要求编译带 -parameters),拿不到就用 ASM 读 class 文件的 LocalVariableTable——不可能要求业务为了一个 mock 工具改编译参数。字段结构递归生成,前端直接渲染成表单。

Spring MVC:在 afterPropertiesSet() 之后反射调用 getHandlerMethods(),把所有 @RequestMapping 导出来。整个类不 import 任何 Spring WebMVC 类型,好处是同一份代码能同时兼容 Boot 2(javax.servlet)和 Boot 3(jakarta.servlet)。要跳过 /actuator/**,否则列表里全是框架内部端点。

请求体:body 是流,读一次就没了,handler 还要读。做法是在 checkMultipart 的返回值上包一层 ContentCachingRequestWrapper,用 ThreadLocal 传给 doDispatch,finally 里再把缓存读出来。只包非 multipart 请求(multipart 返回的是另一个对象,包装会干扰 Spring 的临时文件清理),缓存上限 100KB。

RocketMQ:这个坑查了很久。DefaultMQPushConsumer.subscribe() 写入的其实是 rebalanceImpl.subscriptionInner,而 getSubscription() 返回的本地字段从来不填,所以必须反射走 defaultMQPushConsumerImpl.getSubscriptionInner()。

采集是开关控制的:服务端按方法/端点/topic 下发采集清单,每个端点最多留 3 条。默认关闭,否则 agent 自己就成了内存泄漏源。


9. 怎么挂到业务进程上

三种方式,按侵入程度排:

启动参数,最常规:

java -javaagent:/opt/mock-agent.jar -DMOCK_SERVER_URL=http://mock-server:8080 -jar app.jar

K8s 注入:initContainer 下载 jar,主容器用 JAVA_TOOL_OPTIONS 挂上去。业务仓库和 CI 都不用改。注意 JAVA_TOOL_OPTIONS 要和业务已有的值合并,不能覆盖。

运行时 attach,适合排障:

VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(agentPath, "MOCK_SERVER_URL=http://mock-server:8080");
vm.detach();

attach 有几点要注意:参数用 key=value;key=value 传;全局初始化要双检锁保证只跑一次;但 doInstall() 每次 attach 都会重新安装一遍 transformer,重复 attach 等于重复增强,生产上要保证只执行一次。另外 JDK 9 以后要求同 UID,容器里通常靠 shareProcessNamespace 或者直接进容器执行。

失败时把 PID、类加载器、系统属性、完整堆栈全打到 stdout——attach 失败没有别的信号可看。


10. 上线前必须做的几件事

这些不是最佳实践,是准入条件:

  • 最外层 catch (Throwable),任何异常都不许漏到业务;
  • 失败策略可配:mandatory=false 时 mock 出错就走原方法;
  • 插件加载失败也要放行:delegate == null 直接 supercall.call();
  • 启动路径禁止阻塞 IO,拦截路径禁止网络 IO;
  • 配置替换要原子,别出现"空窗口";
  • 启动时打印版本、jar 路径、配置和 JDK 版本。线上"mock 没生效",第一个问题永远是"Pod 里到底是哪个版本、有没有真的挂上",有这行日志就不用猜。

11. 它现在做不到什么

  • 最长 5 秒延迟:改完配置立刻验证,可能还没同步;接口上得能手动触发一次同步。
  • 一个类只能被一个 transformer 接管:优先级靠注册顺序,不能叠加。
  • 动态插件要重启:AgentBuilder 装完就不再接受新插件了。
  • 匹配在服务端做:agent 只负责"拦住"和"还原"。好处是规则、模板、脚本只有一份实现;代价是每次命中多一次 HTTP。
  • 只支持 Servlet 栈:端点和 body 采集都挂在 DispatcherServlet 上,WebFlux 不在范围内。
  • 多 agent 叠加要灰度:JVM 里已经有 APM 或覆盖率 agent 时,后装的 transformer 看到的是被改写过的类。我们的 ignore 列表只保证不增强自己和 JDK,组合使用还是要先验。
  • 拦不到传输层:连接池、超时这些行为属于底层,mock 覆盖不了;序列化框架自身的 bug 也覆盖不了。

写到这里,回头看最难的三件事其实都不是"改字节码":

一是选对位置——框架接缝稳定、语义完整,业务方法是流沙; 二是隔离类加载器——边界一模糊,就是随机 NoClassDefFoundError; 三是把失败设计进去——状态码降级、mandatory 开关、原子替换、指纹重试,少一个都活不过一次线上抖动。

代码写得再漂亮,只要它可能让业务更糟,就不该上线。