介绍
这篇博客中,我想描述一种技术,它可以用来灵活地更改由Java应用服务器执行的应用程序逻辑—或者更准确地说,在其服务器节点的Java虚拟机(JVM)中执行的应用程序逻辑。JVM执行预先编译和部署的与平台无关的字节码(这是Java源代码编译的结果),下面描述的技术基于字节码操作的概念。使用这种技术,几乎可以通过在已经部署的Java应用程序的字节码级别(JVM在运行时对其进行解释)上操作而对其进行任何更改,而无需修改应用程序的源代码(因为后者意味着需要重新编译、重新组装和重新部署应用程序)。
本博客还将说明为什么从原始资源(如位于应用程序服务器上由Java类加载器加载的类文件)获得的反编译代码的静态分析有时会产生误导以及为什么Java应用程序静态逆向工程结果可能不同于其观察到的运行时行为。
这种技术可能有用,那为什么不简单地对Java应用程序的源代码进行必要的更改并将其部署到应用程序服务器呢?以下是一些例子:
我们没有相同的原始开发项目——例如,如果原始项目不可获得,并且反编译无法再现可成功构建和组装的完整项目结构和制品;
我们需要对已运行的应用程序生成临时的补丁/逻辑修改,以便在开发和组装完整的补丁之前进行快速测试;
我们需要收集关于已执行类(所有或仅选择的类)的特定运行时信息;
或者我们只是想侵入已经部署的应用程序并侵入其逻辑。
该博客主要包含说明字节码instrumentation和操作的示例。关于应用程序demo,有几点需要提前说明:
为了避免不相关的复杂性,示例基于一个独立的Java应用程序。由于所描述的功能是JVM特性的一部分,并不特定于应用服务器实现,因此可以在实际场景中与各种应用服务器一起使用它(SAP应用服务器就是其中之一);
所有的开发都被简化了,因此代码行数被减少到合理的最小值,让我们可以关注核心主题,虽然这会导致大量使用硬编码值和简单的类模型设计。在实际开发中,大部分硬编码的值应作为可配置参数;
在一个独立的程序和补充开发的类,当调用相应的对象及查看其状态时,控制台的输出被大量使用,以使信息充分方便地展示。在实际开发中,这种详细输出应该禁用,或使用具有相应日志级别/重要性的应用服务器日志框架实现。
出于可读性和清晰度,在控制台输出相应的日志条目插入以下值:
程序主类调用的输出前加“[Application - main]”;
来自负责在控制台显示文本的类的调用,以及来自程序main类的调用的输出前面有“[Application - text display]”;
来自后续instrumentation示例的调用的输出前面有“[instrumentation]”;
来自后续代理(agent)示例的调用的输出前面有“[agent]”。
为了使演示中使用的功能隔离更加明显,开发的类位于以下包中:
- 我们将要侵入的Java应用程序和工具,位于vadim.demo.jvm.app包中;
- Java agent位于包vadim.demo.jvm.agent中,该agent通过agent加载来演示instrumentation;
- Java agent loader应用程序位于包vadim.demo.jvm.agent.loader中,用于演示从外部应用程序连接运行中的JVM。
相应地,instrumented的Java应用程序、Java代理和Java代理加载程序位于三个不同的Java项目中,项目结构如下:
我将从一个基本的应用程序开始,逐步增强已实现的特性,以说明讨论的主题和技术的各个实际方面,所以项目和内容将在这个博客中将逐步变化。
应用程序Demo
让我们使用以下独立的小Java程序作为未来扩充和操作的起点。该程序由两个类组成:主类DemoApplication和从主类调用的类Text。
类DemoApplication实现方法main(),是被调用Java程序的入口点:
package vadim.demo.jvm.app;
public class DemoApplication {
public static void main(String[] args) {
System.out.println("[Application - Main] Start application");
String value = "Demonstration of Java bytecode manipulation capabilities";
Text text = new Text();
System.out.println("[Application - Main] Value passed to text display: " + value);
text.display(value);
System.out.println("[Application - Main] Complete application");
}
}
从主类调用Text类,等待一秒钟后将给定的文本发送到控制台输出:
package vadim.demo.jvm.app;
public class Text {
public void display(String text) {
long sleepTime = 1000;
long sleepStartTime;
long sleepEndTime;
System.out.println("[Application - Text display] Text display is going to sleep for " + sleepTime + " ms");
sleepStartTime = System.nanoTime();
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
sleepEndTime = System.nanoTime();
System.out.println("[Application - Text display] Text display wakes up");
System.out.println("[Application - Text display] Text display sleep time: "
+ ((sleepEndTime - sleepStartTime) / 1000000) + " ms");
System.out.println("[Application - Text display] Output: " + text);
}
}
程序执行后控制台打印如下输出:
[Application - Main] Start application
[Application - Main] Value passed to text display: Demonstration of Java bytecode manipulation capabilities
[Application - Text display] Text display is going to sleep for 1000 ms
[Application - Text display] Text display wakes up
[Application - Text display] Text display sleep time: 1000 ms
[Application - Text display] Output: Demonstration of Java bytecode manipulation capabilities
[Application - Main] Complete application
现在让我们讲讲什么是bytecode instrumentation。
字节码instrumentation和操作
JDK从Java 5开始为开发人员提供所谓的字节码instrumentation功能。这种技术的目标是修改加载到JVM并由其执行的字节码——例如,扩展附加指令或对原始字节码的其他更改。需要注意的是,字节码instrumentation不会对字节码的原始资源(类文件)造成任何更改。当类加载器试图访问并将相应的被查找的类的字节码装入JVM时,它会动态地操纵字节码,扩展或替换从原始资源中获得的字节码,并带有instrumented版本。用于instrumentation的主接口是java.lang.instrument.Instrumentation。Instrumentation接口提供了添加自定义转换器实现类的功能,该实现类将在类字节码加载到JVM时被触发,并且可以用动态提交的自定义字节码扩展或替换类的原始字节码。请注意,如果类已经加载,则对其字节码进行操作是无关紧要的。在技术上instrument所需的类仍然是可行,但这意味着必须为该类开发增加版的类加载器逻辑,并使用可回调的类进行扩展以实现重加载或卸载——这可能不是一项轻松任务,因为标准类加载器不提供类卸载功能。
操作字节码不同于编辑原始Java源代码,因为我们需要对编译后的JVM指令进行操作,而不是使用原始Java语句。对字节码的低干扰要求对包含字节码的class文件的结构有很好的了解。幸运的是,有几个库可以简化对字节码操作—下面是其中最常用的几个库,按生成的字节码的抽象级别分类:
字节码抽象层次 | 描述 | 示例 |
---|---|---|
低 | 库需要直接在字节码级别进行操作。它们通常提供最丰富的功能,但与其他字节码操作工具相比,它们的使用也最复杂。 | ASM (ASM – Home Page) BCEL (https://commons.apache.org/proper/commons-bcel/) |
中 | 库根据字节码提供了一定程度的抽象,并简化了对字节码的修改。例如,不必修改字节码,可以使用类似java的语法进行更改,然后将其编译为字节码,并由使用的库将其修改为原始字节码。通常,它们缺乏对被修改代码验证的功能——这意味着,错误可能在修改准备过程中被忽略,然后在运行时被观察到。 | Javassist (Javassist by jboss-javassist) |
高 | 库使用高级别的指令进行操作,通常配有用于语法验证的工具集。不幸的是,对被修改的字节码进行高级别的抽象通常会丢失一些特性,这些特性通过对字节码的直接修改才会获得。 | AspectJ (The AspectJ Project) |
在本博客的后面的示例中,我将在修改底层字节码的必要性和抽象之间作一个折衷,使用Javassist库
让我们增强演示程序的基本逻辑并对其instrument。所提供的示例合并了几个不同的instrumentation,并说明了我们如何实现以下修改:
在被instrumented的类的给定方法执行之前插入额外的代码;
在被instrumented的类的给定方法执行之后插入额外的代码;
在被instrumented的类的给定方法中间注入额外的代码;
修改被instrumented的类的给定方法的现有代码。
几个关键的地方需要考虑
Javassist提供了访问编译时类定义(它是字节码的呈现版本)的功能;
然后就可以迭代类方法,通过名称和描述符访问方法。请注意方法描述符的表示法——它对应的是兼容字节码的表示法,而不是Java语言规范中定义的表示法;
对于给定的方法,可以在方法之前或之后插入任意代码,或者在给定的代码行插入代码。请注意语法-注入的代码行是经过一些修改的类似java的字符串(如适当转义某些特殊字符、可能使用占位符等)。在调用System.output.println()之前,我们将另一个值的赋值注入到使用的变量中,这样控制台输出的值就与从程序主类传递的值不同;
也可以通过引入所谓的表达式编辑器实现类改变已经存在的字节码,它可以拦截和取代构造函数和方法调用,访问类字段,异常处理,等。在这个例子中,我们废止sleep()调用,所以这个程序不需要等待就输出文本。
有关库功能的完整文档及其使用示例,请参阅官方网站上的API参考资料。
类DemoApplication得到了相应的增强——字节码instrumentation是在方法enableInstrumentation()中实现的
package vadim.demo.jvm.app;
import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.NotFoundException;
import javassist.expr.ExprEditor;
public class DemoApplication {
public static void main(String[] args) {
System.out.println("[Application - Main] Start application");
String value = "Demonstration of Java bytecode manipulation capabilities";
enableInstrumentation();
Text text = new Text();
System.out.println("[Application - Main] Value passed to text display: " + value);
text.display(value);
System.out.println("[Application - Main] Complete application");
}
private static void enableInstrumentation() {
String instrumentedClassName = "vadim.demo.jvm.app.Text";
String instrumentedMethodName = "display";
String instrumentedMethodDescriptor = "(Ljava/lang/String;)V";
try {
ClassPool cPool = ClassPool.getDefault();
CtClass ctClass = cPool.get(instrumentedClassName);
CtMethod ctClassMethod = ctClass.getMethod(instrumentedMethodName, instrumentedMethodDescriptor);
ctClassMethod.insertBefore("System.out.println(\"[Instrumentation] Entering instrumented method\");");
ctClassMethod.insertAfter("System.out.println(\"[Instrumentation] Exiting instrumented method\");");
ctClassMethod.insertAt(24, true, "text = \"Original text was replaced by instrumentation from agent\";");
ExprEditor instrumentationExpressionEditor = new DemoExpressionEditor();
ctClassMethod.instrument(instrumentationExpressionEditor);
ctClass.toClass();
} catch (NotFoundException e) {
e.printStackTrace();
} catch (CannotCompileException e) {
e.printStackTrace();
}
}
}
(SCN中的语法高亮显示功能有时会废止空行,所以请记住,上面代码中提到的代码24行对应于System.out.println()调用之前的空行,System.out.println()输出给定的文本到控制台)
此外,类DemoExpressionEditor继承了Javassist的类Javassist.expr.ExprEditor。DemoExpressionEditor实现了instrument被调用方法的逻辑
package vadim.demo.jvm.app;
import javassist.CannotCompileException;
import javassist.expr.ExprEditor;
import javassist.expr.MethodCall;
public class DemoExpressionEditor extends ExprEditor {
@Override
public void edit(MethodCall method) throws CannotCompileException {
if (method.getMethodName().contains("sleep")) {
System.out.println("[Instrumentation] Suppressing sleep for " + method.getClassName() + "."
+ method.getMethodName() + " called from " + method.getEnclosingClass().getName());
method.replace("{}");
}
}
}
我们再次执行DemoApplication,比较和原来的版本有什么不同。
[Application - Main] Start application
[Instrumentation] Suppressing sleep for java.lang.Thread.sleep called from vadim.demo.jvm.app.Text
[Application - Main] Value passed to text display: Demonstration of Java bytecode manipulation capabilities
[Instrumentation] Entering instrumented method
[Application - Text display] Text display is going to sleep for 1000 ms
[Application - Text display] Text display wakes up
[Application - Text display] Text display sleep time: 0 ms
[Application - Text display] Output: Original text was replaced by instrumentation from agent
[Instrumentation] Exiting instrumented method
[Application - Main] Complete application
从这个输出,可以看到什么时候instrumentation实现逻辑被调用,以及它如何影响执行程序——特别是负责显示文本的类:自定义代码在被instrumented方法之前和之后执行,线程没有运行进入睡眠状态,控制台输出不是最初演示程序设计的。这一切演示了我们如何不对该类源代码进行更改,在运行时引入对某个应用程序类逻辑的较大的更改。通常我们并不局限于让调用instrumented类的应用程序触发instrumentation逻辑——它可以是运行在相同JVM中的任何其他应用程序。这里让这个程序触发instrumentation逻辑,目的是为了简化,避免过分复杂。
Java Agent 与 Attach API
到目前为止,我们已经熟悉了字节码instrumentation的一些基本原理,但是上面提供的示例仍然不够灵活——我们需要将额外的逻辑嵌入到应用程序中,或者需要部署其他应用程序instrument所需的类字节码。让我们更进一步,探索如何将instrumenting应用程序与instrumented应用程序(上面使用的Java应用程序)解耦。这种概念在JVM中已经存在了一段时间,称为Java代理。Java agent是一种以特定方式捆绑的应用程序,通常作为一个独立的JAR文件(它可能还需要额外的依赖项)交付,它包含instrumentation逻辑的实现,并且可以为了instrumentation而附加到Java应用程序。
后续翻译敬请期待......
java达人
ID:drjava
(长按或扫码识别)