使用 Byte Buddy 检测 运行 SpringBoot 应用程序时出现 IncompatibleClassChangeError

IncompatibleClassChangeError when instrumenting running SpringBoot application with Byte Buddy

我想向我的公司介绍字节好友,我已经为我的同事准备了一个演示。由于我们经常使用 Spring,我认为最好的例子是 SpringBoot 应用程序的检测。我已决定将日志添加到 RestController 方法。

检测应用程序是一个简单的 SpringBoot Hello World 示例:

@RestController
public class HelloController {
  private static final String template = "Hello, %s!";

  @RequestMapping("/hello")
  public String greeting(
        @RequestParam(value = "name", defaultValue = "World") String name) {
      return String.format(template, name);
  }

  @RequestMapping("/browser")
  public String showUserAgent(HttpServletRequest request) {
      return request.getHeader("user-agent");
  }
}

这是我的字节好友代理:

public class LoggingAgent {
    public static void premain(String agentArguments,
            Instrumentation instrumentation) {
        install(instrumentation);
    }

    public static void agentmain(String agentArguments,
            Instrumentation instrumentation) {
        install(instrumentation);
    }

    private static void install(Instrumentation instrumentation) {
        createAgent(RestController.class, "greeting")
                .installOn(instrumentation);
    }

    private static AgentBuilder createAgent(
            Class<? extends Annotation> annotationType, String methodName) {
        return new AgentBuilder.Default().type(
                ElementMatchers.isAnnotatedWith(annotationType)).transform(
                new AgentBuilder.Transformer() {
                    @Override
                    public DynamicType.Builder<?> transform(
                            DynamicType.Builder<?> builder,
                            TypeDescription typeDescription,
                            ClassLoader classLoader) {
                        return builder
                                .method(ElementMatchers.named(methodName))
                                .intercept(
                                        MethodDelegation
                                                .to(LoggingInterceptor.class)
                                                .andThen(
                                                        SuperMethodCall.INSTANCE));
                    }
                });
    }
}

拦截器记录方法执行:

public static void intercept(@AllArguments Object[] allArguments,
        @Origin Method method) {
    Logger logger = LoggerFactory.getLogger(method.getDeclaringClass());
    logger.info("Method {} of class {} called", method.getName(), method
            .getDeclaringClass().getSimpleName());

    for (Object argument : allArguments) {
        logger.info("Method {}, parameter type {}, value={}",
                method.getName(), argument.getClass().getSimpleName(),
                argument.toString());
    }
}

当使用 -javaagent 参数执行时,此示例运行良好。但是,当我尝试使用 Attach API:

在 运行ning JVM 上加载代理时
VirtualMachine vm = VirtualMachine.attach(args[0]);
vm.loadAgent(args[1]);
vm.detach();

我在第一次尝试记录时遇到以下异常:

Exception in thread "ContainerBackgroundProcessor[StandardEngine[Tomcat]]" java.lang.IncompatibleClassChangeError: Class ch.qos.logback.classic.spi.ThrowableProxy does not implement the requested interface ch.qos.logback.classic.spi.IThrowableProxy
    at ch.qos.logback.classic.pattern.ThrowableProxyConverter.subjoinExceptionMessage(ThrowableProxyConverter.java:180)
    at ch.qos.logback.classic.pattern.ThrowableProxyConverter.subjoinFirstLine(ThrowableProxyConverter.java:176)
    at ch.qos.logback.classic.pattern.ThrowableProxyConverter.recursiveAppend(ThrowableProxyConverter.java:159)
    at ch.qos.logback.classic.pattern.ThrowableProxyConverter.throwableProxyToString(ThrowableProxyConverter.java:151)
    at org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter.throwableProxyToString(ExtendedWhitespaceThrowableProxyConverter.java:35)
    at ch.qos.logback.classic.pattern.ThrowableProxyConverter.convert(ThrowableProxyConverter.java:145)
    at ch.qos.logback.classic.pattern.ThrowableProxyConverter.convert(ThrowableProxyConverter.java:1)
    at ch.qos.logback.core.pattern.FormattingConverter.write(FormattingConverter.java:36)
    at ch.qos.logback.core.pattern.PatternLayoutBase.writeLoopOnConverters(PatternLayoutBase.java:114)
    at ch.qos.logback.classic.PatternLayout.doLayout(PatternLayout.java:141)
    at ch.qos.logback.classic.PatternLayout.doLayout(PatternLayout.java:1)
    at ch.qos.logback.core.encoder.LayoutWrappingEncoder.doEncode(LayoutWrappingEncoder.java:130)
    at ch.qos.logback.core.OutputStreamAppender.writeOut(OutputStreamAppender.java:187)
    at ch.qos.logback.core.OutputStreamAppender.subAppend(OutputStreamAppender.java:212)
    at ch.qos.logback.core.OutputStreamAppender.append(OutputStreamAppender.java:100)
    at ch.qos.logback.core.UnsynchronizedAppenderBase.doAppend(UnsynchronizedAppenderBase.java:84)
    at ch.qos.logback.core.spi.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:48)
    at ch.qos.logback.classic.Logger.appendLoopOnAppenders(Logger.java:270)
    at ch.qos.logback.classic.Logger.callAppenders(Logger.java:257)
    at ch.qos.logback.classic.Logger.buildLoggingEventAndAppend(Logger.java:421)
    at ch.qos.logback.classic.Logger.filterAndLog_0_Or3Plus(Logger.java:383)
    at ch.qos.logback.classic.Logger.log(Logger.java:765)
    at org.slf4j.bridge.SLF4JBridgeHandler.callLocationAwareLogger(SLF4JBridgeHandler.java:221)
    at org.slf4j.bridge.SLF4JBridgeHandler.publish(SLF4JBridgeHandler.java:303)
    at java.util.logging.Logger.log(Unknown Source)
    at java.util.logging.Logger.doLog(Unknown Source)
    at java.util.logging.Logger.logp(Unknown Source)
    at org.apache.juli.logging.DirectJDKLog.log(DirectJDKLog.java:181)
    at org.apache.juli.logging.DirectJDKLog.error(DirectJDKLog.java:147)
    at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.run(ContainerBase.java:1352)
    at java.lang.Thread.run(Unknown Source)

I 运行 64 位 HotSpot 上的示例 Java8:

java version "1.8.0_112"
Java(TM) SE Runtime Environment (build 1.8.0_112-b15)
Java HotSpot(TM) 64-Bit Server VM (build 25.112-b15, mixed mode)

字节好友版本为1.4.32。这是代理 maven 配置:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>pl.halun.demo.bytebuddy</groupId>
<artifactId>byte-buddy-agent-demo</artifactId>
<version>1.0</version>

<properties>
    <jdk.version>1.8</jdk.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.4.1.RELEASE</version>
</parent>

<dependencies>
    <dependency>
        <groupId>net.bytebuddy</groupId>
        <artifactId>byte-buddy</artifactId>
        <version>1.4.32</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <source>${jdk.version}</source>
                <target>${jdk.version}</target>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-assembly-plugin</artifactId>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
                <finalName>${project.artifactId}-${project.version}-full</finalName>
                <appendAssemblyId>false</appendAssemblyId>
                <archive>
                    <manifestEntries>
                        <Premain-Class>pl.halun.demo.bytebuddy.logging.LoggingAgent</Premain-Class>
                        <Agent-Class>pl.halun.demo.bytebuddy.logging.LoggingAgent</Agent-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
                    </manifestEntries>
                </archive>
            </configuration>
            <executions>
                <execution>
                    <id>assemble-all</id>
                    <phase>package</phase>
                    <goals>
                        <goal>single</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

这是检测应用程序的 pom 文件:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>pl.halun.demo.bytebuddy.instrumented.app</groupId>
<artifactId>byte-buddy-agent-demo-instrumented-app</artifactId>
<version>1.0</version>
<packaging>jar</packaging>

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.4.1.RELEASE</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

<properties>
    <java.version>1.8</java.version>
</properties>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

<repositories>
    <repository>
        <id>spring-releases</id>
        <url>https://repo.spring.io/libs-release</url>
    </repository>
</repositories>
<pluginRepositories>
    <pluginRepository>
        <id>spring-releases</id>
        <url>https://repo.spring.io/libs-release</url>
    </pluginRepository>
</pluginRepositories>

从我的角度来看,在 运行ning 服务器上添加日志是非常有价值的选择,我讨厌放弃这部分演示。我尝试尝试不同的重新定义策略,但直到现在似乎都没有效果。

我认为您观察到的是 class 版本冲突。 Spring 引导很可能附带 ThrowableProxy 版本,该版本与 Java 代理添加的版本不兼容。在运行时加载 Java 代理时,Spring 的版本已经加载,而启动附件会在加载代理版本的 class 路径上添加代理捆绑版本。

Java 代理通常添加到 class 路径。这也是您的 Spring 启动应用程序所在的位置。您需要确保 Java 代理不包含与您的应用程序依赖项不兼容的依赖项,或者您需要隐藏所有依赖项以避免此类冲突。

然而还有另一个问题:当编写一个在运行时附加的 Java 代理时,您会遇到大多数 JVM 的额外限制,在 HotSpot 上,您不允许更改 class 文件已加载的任何 class 的格式。您的 class 也有可能已经加载到当前位置,因为您没有启用重新转换,所以看不到任何效果。

具有运行时能力的代理需要使用 Advice 组件将代码内联到目标代码中,而不是使用 classical 委托模型:

class MyAdvice {
  @Advice.OnMethodEnter
  static void intercept(@Advice.BoxedArguments Object[] allArguments,
                        @Advice.Origin Method method) {
    Logger logger = LoggerFactory.getLogger(method.getDeclaringClass());
    logger.info("Method {} of class {} called", method.getName(), method
                  .getDeclaringClass().getSimpleName());

    for (Object argument : allArguments) {
      logger.info("Method {}, parameter type {}, value={}",
               method.getName(), argument.getClass().getSimpleName(),
               argument.toString());
    }
  }
}

您可以通过注册为访客使用上述建议class。此类访问者仅适用于声明的方法,即不适用于继承的方法并将其代码内联到现有方法中。这样,日志记录将不会在调用堆栈上可见,并且重新转换已加载的内容也变得合法 classes:

new AgentBuilder.Default()
  .disableClassFormatChanges()
  .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
  .type(isAnnotatedWith(annotationType))
  .transform(new AgentBuilder.Transformer() {
      @Override
      public DynamicType.Builder<?> transform(
          DynamicType.Builder<?> builder,
          TypeDescription typeDescription,
          ClassLoader classLoader) {
        return builder.visit(Advice.to(MyAdvice.class).on(named(methodName)));
      }
   });

关于附件,请查看byte-buddy-agent项目,它允许您调用:

ByteBuddyAgent.attach(agentJar, processId);

上述助手支持其他 VM,其中附件 API 通常位于不同的命名空间中。

更新:这是 Spring 引导的问题。 Spring 引导创建自定义 class 加载程序,这些加载程序将系统 class 加载程序(class 路径)作为其父级。这些 class 加载器首先考虑来自系统 class 加载器的 classes。添加代理时,整个 Spring 启动应用程序都在 class 加载程序和这些子 class 加载程序中。像 IThrowableProxy 这样的 class 现在在两个 class 加载程序中存在两次,但 JVM 认为它们不相等。根据 VM 的状态,一些 classes 可能已经链接到原始 IThrowableProxy,而其他 classes 在附加代理后加载并链接到新的 IThrowableProxy 来自代理。两个 class 不相等,您看到的错误是在 VM 抱怨 class 没有实现正确的 IThrowableProxy(但前一个)的地方抛出的。如果代理在启动时附加,则不存在此问题,因为 class 路径的 IThrowableProxy 始终加载。

这不是一个容易修复的错误,最后,Byte Buddy 无法帮助您解决此类 class 路径问题,并且 Spring Boot 在解释 class 装载机合同。最简单的方法是不在代理中使用 Spring 引导类型。您仍然可以将注释与例如

匹配
isAnnotatedWith(named("org.springframework.web.bind.annotation.RestController"))

问题是如何与 Spring Boot 通信。一种解决方法是在启动时将所有共享 classes 添加到 class 路径。通常,我确实完全避免使用共享 classes,但只在 Advice classes 中使用它们,其中代码内联在目标应用程序的 class 加载器中.只需在提供的范围内设置 Spring 启动依赖项,建议代码本身就不会被执行。