Java字节码增强技术Bytebuddy探路篇二(NoClassDefFoundError问题)

图片来自pixabay.com的NickRivers会员

最近为了实现Java应用RPC调用的录制和Mock回放,需要以无侵入方式获取到RPC方法的出入参数和返回响应消息等数据,于是踏上了Java字节码增强技术的道路摸索。技术路径上选择了Java Agent + Bytebuddy框架,但是在应用实践过程中,对tomcat/dubbo/rocketmq进行类切面增强时出现了NoClassDefFoundError的问题,本文针对这个关键问题的出现原因进行分析讨论,提供相应的解决思路。

1. 演示代码工程

为了方便讨论,请先下载演示代码工程,整个工程结构如下,

+ phantom-core 演示基础类。
+ phantom-demo 演示web应用,这是一个简单的sprint boot应用,运行在端口18080。
+ phantom-agent 一个Java agent,里面通过ByteBuddy对指定类以无侵入方式切面增强,在演示工程中主要对Tomcat web的请求进行切面处理。
+ phantom-agent-plugin 一个增强类插件,这个是为了解决NoClassDefFoundError问题而提供的一个插件解决方案,应用于phantom-agent的TransformerV3.tranform()中,项目构建后需要复制构建包到路径/tmp/phantom-agent-plugin.jar上。

下载后,分别对项目进行构建、启动和测试,命令如下,

# 使用mvn工具编译构建
mvn clean package

# 启动演示web应用
java -jar ./phantom-demo/target/phantom-demo.jar

# 测试web请求命令
curl -X POST http://127.0.0.1:18080/api/hello

若上面的web应用启动成功,则测试web请求将会收到“hello,world”的消息响应,这个简单的测试请求已经走过web服务的完整路径。工程中的Transformer将通过java agent方式对tomcat web请求进行切面处理,获取所有http请求的执行前、执行后、执行异常相关情况并打印到日志中,tomcat切点定义为,

# 切点:tomcat核心类StandardHostValve的invoke方法
org.apache.catalina.core.StandardHostValve.invoke(Request request, Response response);

演示工程中Transformer类有三个,

  1. com.phantom.agent.trace.TransformerV1 :用于演示问题的复现和定位
  2. com.phantom.agent.trace.TransformerV2 :用于演示问题的解决思路1
  3. com.phantom.agent.trace.TransformerV3 :用于演示问题的解决思路2

这三个类都各自继承自ByteBuddy的AgentBuilder.Transformer接口类,实现了tranform()方法。

2. 问题复现和定位

我们先复现一下NoClassDefFoundError的问题,通过设置agent启动参数agent.transformer.version=v1,执行TransformerV1版本的类增强变换,启动命令如下,

# 启动命令(加载agent)
java \
-javaagent:./phantom-agent/target/phantom-agent.jar \
-Dagent.transformer.version=v1 \
-jar ./phantom-demo/target/phantom-demo.jar

这个时候对hello接口发起请求,该请求可以成功“hello,world”的响应消息,翻开web应用后台,可以观察如下日志,

[20220808 16:22:37-453][http-nio-18080-exec-2][INFO] [trace]beforeMethod(), method = org.apache.catalina.core.StandardHostValve.invoke
[20220808 16:22:37-453][http-nio-18080-exec-2][ERROR] EnhancerProxy failure - beforeMethod, [class org.apache.catalina.core.StandardHostValve].[invoke], msg = java.lang.NoClassDefFoundError: org/apache/catalina/connector/Request
[20220808 16:22:37-455][http-nio-18080-exec-2][INFO] [trace]afterMethod(), method = org.apache.catalina.core.StandardHostValve.invoke

可以看到,增强切面在后台报java.lang.NoClassDefFoundError的错误,即找不到org.apache.catalina.connector.Request类,若通过调试的话,可以看到这个异常是从TomcatEnhancer报出来,增强切面出现异常,找不到相应的Request类。

这个Request类定义在tomcat-embedd-core类包中,这个tomcat核心类包其实已经被web应用正常加载,tomcat web处于正常工作状态,hello接口的请求可以得到hello响应消息证明了这点。另外,ByteBuddy也正常在指定invoke切点织入了切面,如下的两条来自TomcatEnhancer日志证明了这点:

[20220808 16:22:37-453][http-nio-18080-exec-2][INFO] [trace]beforeMethod(), method = org.apache.catalina.core.StandardHostValve.invoke
[20220808 16:22:37-455][http-nio-18080-exec-2][INFO] [trace]afterMethod(), method = org.apache.catalina.core.StandardHostValve.invoke

现在的问题是,为什么在增强切面类TomcatEnhancer执行时会报找不到tomcat核心类包的Request类?

打开应用的远程调试,在执行到TomcatEnhancer时,检查一下是否能够直接通过loadClass找到Request类,

this.getClass().classLoader.loadClass("org.apache.catalina.connector.Request")

没有出意外,确实没有找到这个类。查看了一下TomcatEnhancer的类加载器,是AppClassLoader,这是系统类加载器,负责加载来自Java classpath指定路径下的JAR类包。难道Request类不在AppClassLoader的搜索路径?

于是通过arthas工具查看当前应用的类加载树,

# 查看类加载器树
[arthas@78212]$ classloader -t
+-BootstrapClassLoader
+-sun.misc.Launcher$ExtClassLoader@2503dbd3
  +-com.taobao.arthas.agent.ArthasClassloader@463899fd
  +-sun.misc.Launcher$AppClassLoader@18b4aac2
    +-org.springframework.boot.loader.LaunchedURLClassLoader@6fdbe764
Affect(row-cnt:5) cost in 10 ms.

# 查看类文件信息,通过AppClassLoader,未找到
[arthas@78212]$ classloader -c 18b4aac2 -r org/apache/catalina/connector/Request.class
Affect(row-cnt:0) cost in 0 ms.

# 查看类文件信息,通过LaunchedURLClassLoader,找到
[arthas@78212]$ classloader -c 6fdbe764 -r org/apache/catalina/connector/Request.class
 jar:file:/Users/huangyinhuang/hyh/gitee/simple-demo/demo-bytebuddy/phantom-demo/target/phantom-demo.jar!/BOOT-INF/lib/tomcat-embed-core-9.0.65.jar!/org/apache/catalina/connector/Request.class
Affect(row-cnt:1) cost in 1 ms.

通过arthas的探针查看证实了Request类由LaunchedURLClassLoader加载,确实无法通过AppClassLoader进行加载。

3. 解决思路1

知道了这个问题的原因后,一个首先想到的解决方案就是让LaunchedURLClassLoader来加载TomcatEnhancer类,即延迟TomcatEnhancer类加载到类增强时刻,而不是在agent启动时刻,这样TomcatEnhancer类和Request类都由Spring的LaunchedURLClassLoader进行加载。

我们可以在transform方法中通过传入的LaunchedURLClassLoader来动态创建TomcatEnhancer实例,于是就有了TransformerV2版本的类增强变换方案,打开其transform()方法,可以看到其如下代码实现,

@Override
public DynamicType.Builder transform(DynamicType.Builder builder, TypeDescription typeDescription, ClassLoader loader, JavaModule javaModule) {

        // enhanceClass = "com.phantom.agent.enhancer.impl.TomcatEnhancer"
        // loader = org.springframework.boot.loader.LaunchedURLClassLoader

        EnhancerProxy proxy = new EnhancerProxy();
        try {
            AbstractEnhancer enhancer = (AbstractEnhancer) Class.forName(this.enhanceClass, true, loader).newInstance();
            proxy.setEnhancer(enhancer);
        } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
            Logger.error("failed to initialized the proxy %s", e.toString());
        }

        // return ...
    }

通过设置agent启动参数agent.transformer.version=v2,执行上面TransformerV2版本的类增强变换,

# 启动命令(加载agent)
java \
-javaagent:./phantom-agent/target/phantom-agent.jar \
-Dagent.transformer.version=v2 \
-jar ./phantom-demo/target/phantom-demo.jar

发一个hello接口的测试请求,测试请求可以成功收到“hello,world”的响应消息,翻开web应用后台,却仍然收到如下NoClassDefFoundError日志,

[20220808 18:29:44-174][http-nio-18080-exec-2][INFO] [trace]beforeMethod(), method = org.apache.catalina.core.StandardHostValve.invoke
[20220808 18:29:48-173][http-nio-18080-exec-2][ERROR] EnhancerProxy failure - beforeMethod, [class org.apache.catalina.core.StandardHostValve].[invoke], msg = java.lang.NoClassDefFoundError: org/apache/catalina/connector/Request
[20220808 18:29:48-176][http-nio-18080-exec-2][INFO] [trace]afterMethod(), method = org.apache.catalina.core.StandardHostValve.invoke

问题并没有得到解决,继续跟踪调试,发现如下Class.forName方法参数重虽然指定了LaunchedURLClassLoader,但实际上返回TomcatEnhancer实例仍然由AppClassLoader加载,并没有被指定的LaunchedURLClassLoader所加载。

// enhanceClass = "com.phantom.agent.enhancer.impl.TomcatEnhancer"
// loader = org.springframework.boot.loader.LaunchedURLClassLoader
Class.forName(this.enhanceClass, true, loader).newInstance()

见调试图片,

这个时候突然想到类的双亲委派机制,恍然大悟,这里即使指定了LaunchedURLClassLoader来创建,但是由于AppClassLoader是LaunchedURLClassLoader父加载器,基于Java的双亲委派机制,最终还是会被AppClassLoader进行创建。

4. 解决思路2

这个时候,就不能再依赖LaunchedURLClassLoader了,得另谋思路。要改变Java的双亲委派机制,就得自定义类加载器,这也是TransformerV3版本采用的技术方案。

@Override
public DynamicType.Builder transform(DynamicType.Builder builder, TypeDescription typeDescription, ClassLoader loader, JavaModule javaModule) {

    // enhancerClz = "com.phantom.agent.enhancer.impl.TomcatEnhancer"
    // loader = org.springframework.boot.loader.LaunchedURLClassLoader

    EnhancerProxy proxy = new EnhancerProxy();
    try {
        IAspectEnhancer enhancer = EnhancerInstanceLoader.load(enhancerClz, loader);
        proxy.setEnhancer(enhancer);
    } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
        Logger.error("failed to initialized the proxy %s", e.toString());
    }

    // return ...
}

上面实现代码中的EnhancerInstanceLoader就是一个自定义类加载器,其将加载指定在路径"/tmp/phantom-agent-plugin.jar"的Jar包。

通过设置agent启动参数agent.transformer.version=v3,执行上面TransformerV3版本的类增强变换,

# 复制文件到/tmp/phantom-agent-plugin.jar
cp ./phantom-agent-plugin/target/phantom-agent-plugin.jar /tmp/

# 启动命令(加载agent)
java \
-javaagent:./phantom-agent/target/phantom-agent.jar \
-Dagent.transformer.version=v3 \
-jar ./phantom-demo/target/phantom-demo.jar

发一个hello接口的测试请求,测试请求可以成功收到“hello,world”的响应消息,这时打开web应用后台日志,NoClassDefFoundError的错误信息不再出现, hello接口的Span详细请求信息也通过Span顺利地打印出来了。

[20220808 18:54:18-878][http-nio-18080-exec-2][INFO] [trace]beforeMethod(), method = org.apache.catalina.core.StandardHostValve.invoke
[20220808 18:54:18-881][http-nio-18080-exec-2][INFO] [trace]afterMethod(), method = org.apache.catalina.core.StandardHostValve.invoke
[20220808 18:54:23-880][BatchSpanProcessor_WorkerThread-1][INFO] demo 1.0.0 - SpanData{spanContext=ImmutableSpanContext{traceId=d23da1005580022dd42e92a3fc4c0754, spanId=cfdd64665bdb7270, traceFlags=01, traceState=ArrayBasedTraceState{entries=[]}, remote=false, valid=true}, parentSpanContext=ImmutableSpanContext{traceId=00000000000000000000000000000000, spanId=0000000000000000, traceFlags=00, traceState=ArrayBasedTraceState{entries=[]}, remote=false, valid=false}, resource=Resource{schemaUrl=null, attributes={service.name="unknown_service:java", telemetry.sdk.language="java", telemetry.sdk.name="opentelemetry", telemetry.sdk.version="1.12.0"}}, instrumentationLibraryInfo=InstrumentationLibraryInfo{name=demo, version=1.0.0, schemaUrl=null}, name=POST /api/hello, kind=PRODUCER, startEpochNanos=1659956058879000000, endEpochNanos=1659956058881771573, attributes=AttributesMap{data={METHOD=POST, URL=http://127.0.0.1:18080/api/hello, Component=Tomcat, URI=/api/hello, RemoteAddr=127.0.0.1, RemoteHost=127.0.0.1, RemotePort=64352}, capacity=128, totalAddedValues=7}, totalAttributeCount=7, events=[], totalRecordedEvents=0, links=[], totalRecordedLinks=0, status=ImmutableStatusData{statusCode=UNSET, description=}, hasEnded=true}
ta={METHOD=POST, URL=http://127.0.0.1:18080/api/hello, Component=Tomcat, URI=/api/hello, RemoteAddr=127.0.0.1, RemoteHost=127.0.0.1, RemotePort=62654}, capacity=128, totalAddedValues=7}, totalAttributeCount=7, events=[], totalRecordedEvents=0, links=[], totalRecordedLinks=0, status=ImmutableStatusData{statusCode=UNSET, description=}, hasEnded=true}

5. 执行时序图

为了更好地理解相关代码的执行路径,下面提供相关的执行时序图,

前端页面在运行过程中出现ElementPlusError错误,报[ElOnlyChild] no valid child node found错误消息

本文记录一个前端页面开发出现的问题,涉及的前端框架版本为,
- "vue": "^3.2.22"
- "element-plus": "^2.0.4"

1. 问题

近期在使用vue 3 + element plus进行前端页面开发,在前端页面运行过程浏览器后台中出现如下错误消息,

error.ts:12 ElementPlusError: [ElOnlyChild] no valid child node found
    at debugWarn (error.ts:12:18)
    at Proxy.<anonymous> (only-child.ts:35:9)
    at renderComponentRoot (runtime-core.esm-bundler.js:893:44)
    at ReactiveEffect.componentUpdateFn [as fn] (runtime-core.esm-bundler.js:5098:34)
    at ReactiveEffect.run (reactivity.esm-bundler.js:167:25)
    at updateComponent (runtime-core.esm-bundler.js:4968:26)
    at processComponent (runtime-core.esm-bundler.js:4901:13)
    at patch (runtime-core.esm-bundler.js:4489:21)
    at ReactiveEffect.componentUpdateFn [as fn] (runtime-core.esm-bundler.js:5107:17)
    at ReactiveEffect.run (reactivity.esm-bundler.js:167:25)

出现这个错误消息概率比较高,但不是每次都必现。页面上各个组件按钮都运行正常,出现这个问题感觉有些纳闷,上面的错误消息也没有直接给出哪里不对,一时不知道问题在哪里。

2. 定位

从错误消息[ElOnlyChild] no valid child node found来看,报的是子组件无效,估计是什么组件使用的不当,没有映射到相应的模型数据,是不是哪里变量命名有拼写错误,使用Ctrl+F检查了一番代码,没有发现任何变量拼写错误。

为了发现问题,开始对页面组件进行二分法排查,把页面组件逐一过滤,定位问题出现在如下一个气泡确认框组件上,

<el-popconfirm title="确定要删除吗?" @confirm="deleteConfig(row)">
   <template #reference>
       <el-button type="text" v-if="row.version != 'new'">删除</el-button>
   </template>
</el-popconfirm>

这个组件没有什么特别,之前已经在很多地方用的很好,唯一不同的是添加了v-if控制,稍微判断了一下,应该就是这个v-if导致。

若把上面的el-button去掉,则直接可以复现这个问题,

<el-popconfirm title="确定要删除吗?" @confirm="deleteConfig(row)">
   <template #reference>
   </template>
</el-popconfirm>

气泡确认框组件的用途,主要是在点击某个元素弹出一个简单的气泡确认框,其需要绑定在一个子组件上,若子组件为空,则会报出如下的错误信息,

[ElOnlyChild] no valid child node found...

在了解到如上的问题后,将v-if移到el-popconfirm组件,正确的书写方法为,

<el-popconfirm title="确定要删除吗?" v-if="row.version != 'new'" @confirm="deleteConfig(row)">
   <template #reference>
       <el-button type="text">删除</el-button>
   </template>
</el-popconfirm>

至此问题解决。