目标就一句话:不改业务代码,把下游的返回值临时换掉。用它做联调、走通异常分支、让链路测试有个稳定的下游。
实现方式是在业务 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 | 记录真实入参 |
| Feign | ReflectiveFeign$FeignInvocationHandler.invoke | 命中就返回假数据 |
| RestTemplate | AbstractBufferingClientHttpRequest.executeInternal | 覆盖两种底层 HTTP 实现 |
| Apache HttpClient | CloseableHttpClient.execute | 按 URL + method 匹配 |
| Spring MVC | DispatcherServlet.doDispatch / checkMultipart | 记录 URI、Header、Body |
| Spring MVC 启动 | AbstractHandlerMethodMapping.afterPropertiesSet | 反射导出所有接口 |
| RocketMQ | DefaultMQPushConsumer.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-first | SecurityException: Prohibited package name: java.util | 判断"是不是我的类"时向上搜索,把已经由 bootstrap 加载的 java.util.HashMap 认成自己的,然后试图定义一个 java.* 类 |
| 只走双亲委派 | NoClassDefFoundError: CloseableHttpResponse | 内部类引用了应用侧类型,却被 AppClassLoader 定义,链接时用错了加载器 |
最后的规则只有三条:
- 只认自己
defineClass过的类,单独记一个集合,绝不向上搜; - child-first 只对
Outer$Inner这种内部类开,顶层类型照旧走双亲委派; - 共享的顶层接口(比如
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 消费端为例:
- 业务发起调用,进入
MockClusterInvoker.invoke,被我们的拦截器接管; - 从
self里取出接口名,从入参里取出方法名、group、version,拼成查询条件; - 查本地配置表(一个
ConcurrentHashMap)。没查到就直接放行,这条路径没有任何额外开销; - 查到了,把参数序列化后发给平台,由平台决定返回什么(规则匹配、模板渲染、脚本执行都在服务端做);
- 拿到结果反序列化成方法的返回类型,包成
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 开关、原子替换、指纹重试,少一个都活不过一次线上抖动。
代码写得再漂亮,只要它可能让业务更糟,就不该上线。