干预java程序自动改写代码
时机
我们可以在编译时、程序启动时、程序启动后这三个阶段来干预程序自动改写或新增程序代码
方式
干预程序自动改写代码的三种方式
1、开发编译或程序部署编译时:通过apt+ast,类似lombok、Mapstruts
2、部署程序启动中:javaagent+Instrument+Javaassist
3、部署程序启动后:Attach+Instrument+Javaassist
方式一
需要了解APT和AST,在之前要先了解下Java编译过程:大概分为如下三个阶段
Parse and Enter: Java源文件被解析成抽象语法树(Abstract syntax tree,AST)。
Annotation Processing: 扫描注解,调用对应的编译式注解处理器处理注解。这个过程可能会修改已有的源文件或者产生新的源文件,这些源文件将再次进入Parse and Enter阶段进行处理。
Analyse and Generate: 分析AST并转化为class文件。
能确定地是:(1)Parse阶段会将源代码解析成AST,(2)注解处理阶段可能会产生新的源代码,注解处理是多轮的。
操作AST,可以达到修改源代码功能
代码实现层面 APT + AST
- 通过AnnotationProcessor的process方法, 拿到所有Elements对象
- 自定义 TreeTranslator,在visitMethodDef可对方法进行判断
- 如果是目标方法,通过AST框架有关API插入代码
APT即为Annotation Processing Tool,它是javac的一个工具。APT可以用来在编译时扫描和处理注解。通过APT可以获取到注解和被注解对象的相关信息,在拿到这些信息后我们可以根据需求来自动的生成一些代码,省去了手动编写。注意,获取注解及生成代码都是在代码编译时候完成的,相比反射在运行时处理注解大大提高了程序性能。
apt比较简单就是spi的一个实现,使用过程如下:
1、新建注解类:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.SOURCE)
public @interface AddHelloWorld {
}
2、新建注解处理类继承
javax.annotation.processing.AbstractProcessor
import com.google.auto.service.AutoService;
import com.gy.annotation.AddHelloWorld;
import com.sun.tools.javac.model.JavacElements;
import com.sun.tools.javac.processing.JavacProcessingEnvironment;
import com.sun.tools.javac.tree.JCTree;
import com.sun.tools.javac.tree.TreeMaker;
import com.sun.tools.javac.tree.TreeTranslator;
import com.sun.tools.javac.util.Context;
import com.sun.tools.javac.util.List;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import java.util.Set;
@SupportedAnnotationTypes("com.gy.annotation.AddHelloWorld")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor.class)
public class AddHelloWorldProcessor extends AbstractProcessor
{
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
{
final Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
final JavacElements elementUtils = (JavacElements) processingEnv.getElementUtils();
final TreeMaker treeMaker = TreeMaker.instance(context);
Set<? extends Element> elements = roundEnv.getRootElements();
Set<? extends Element> elementsHello = roundEnv.getElementsAnnotatedWith(AddHelloWorld.class);
……
}
3、最后一步在
resources/META-INF.services下新建
javax.annotation.processing.Processor文件,并把实现类写入其中,如下:
AST即Abstract Syntax Tree,抽象语法树是一种用编程语言编写的源代码的抽象语法结构的树表示。树的每个节点表示源代码中出现的一个构造。
AST常见api:
- 抽象内部类,内部定义了访问各种语法节点的方法,获取到对应的语法节点后我们可以对语法节点增加删除或者修改语句;
- Visitor派生子类有TreeScanner(扫描所有的语法节点)和 TreeTranslator(扫描节点且可以把语法节点转换成另一种语法节点)
代码例子
https://github.com/guoyang1982/lombok-gy
调试
1、java 提供了
javax.annotation.processing.AbstractProcessor 在编译时处理 注解 。即是插件的处理类。我们需要debug这些类及其相关联的类。所以debug的断点在这里。然后,需要在idea进行一些设置。先配置一个远端的debug配置。
这里的module 选择你要debug的模块。
2、在命令中进入引用该模块的应用根目录,使用命令listen需要调试的模块连接。
mvnDebug clean compile
它会 打印提示,并在命令行中等待连接
Listening for transport dt_socket at address: 8000
3、最后启动之前配置模块。
这样就可以进行调试了
方式二
Instrument
Instrument是JVM提供的一个可以修改已加载类的类库,专门为Java语言编写的插桩服务提供支持。它需要依赖JVMTI的Attach API机制实现。在JDK 1.6以前,Instrument只能在JVM刚启动开始加载类时生效,而在JDK 1.6之后,Instrument支持了在运行时对类定义的修改。要使用Instrument的类修改功能,我们需要实现它提供的ClassFileTransformer接口,定义一个类文件转换器。
import java.lang.instrument.ClassFileTransformer;
public class TestTransformer implements ClassFileTransformer
{
@Override
public byte[] transform(
ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,byte[] classfileBuffer)
{
System.out.println(“Transforming” + className);
return null;
}
}
现在有了Transformer,那么它要如何注入到正在运行的JVM呢?
还需要定义一个Agent,借助Agent的能力将Instrument注入到JVM中。
什么是javaagent呢?
它是JVM TI的一种实现,而JVM TI它是jvm提供的一套对JVM进行操作的工具接口,通过它可以实现对JVM的多种操作,如:jvm 事件触发时,定义各种勾子,以实现对JVM事件的响应,事件包括类文件加载,异常产生等。
Javaagent有两种方法,一个是premain,另一个是agentmain,前一个是在主程序启动的时候加载,通过在jvm参数配置-javaagent,
后一个是在程序启动后加载(这个也是在jdk1.6后支持的)。
import java.lang.instrument.Instrumentation;
public class TestAgent
{
public static void agentmain(String args,Instrumentation inst)
{
//指定我们自己定义的Transformer,在其中利用Javassist做字节码替换
inst.addTransformer(new TestTransformer(),true);
try {
//重定义类并载入新的字节码需要修改的类
inst.retransformClasses(Base.class);
System.out.println(AgentLoad Done.);
}catch (Exception e) {
System.out.println(agentload failed!);
}
}
}
MANIFEST.MF文件配置
Manifest-Version:1.0 //用来定义manifest文件的版本,例如:Manifest-Version:
Premain-Class:com.gy.woodpecker.agent.TestAgent //指定的那个类必须实现 agentmain()、premain()方法Can-Redefine-Classes:true
怎么启动Agent
随Java进程启动而启动,利用java -javaagent这种方式
再串起来回顾下
由于在MANIFEST.MF中指定了Agent-Class,所以在Attach后,目标JVM在运行时会走到TestAgent类中定义的agentmain()方法,而在这个方法中,我们利用Instrumentation,将指定类的字节码通过定义的类转化器TestTransformer做了Base类的字节码替换,并完成了类的重新加载。由此,我们达成了“在JVM运行时,改变类的字节码并重新载入类信息”的目的。
通过什么手段修改类字节码
1、Javaassist
2、ASM
工具:ASM Bytecode Outline
Javaassist
利用Javassist实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用Java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。其中最重要的是ClassPool、CtClass、CtMethod、CtField这四个类:
CtClass(compile-time class):编译时类信息,它是一个Class文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个CtClass对象,用来表示这个类文件。
ClassPool:从开发视角来看,ClassPool是一张保存CtClass信息的HashTable,Key为类名,Value为类名对应的CtClass对象。当我们需要对某个类进行修改时,就是通过pool.getCtClass(className)方法从pool中获取到相应的CtClass。
CtMethod、CtField:这两个比较好理解,对应的是类中的方法和属性。
了解这四个类后,我们可以写一个小Demo来展示Javassist简单、快速的特点。我们依然是对Base中的process()方法做增强,在方法调用前后分别输出start和end,实现代码如下。我们需要做的就是从Pool中获取到相应的CtClass对象和其中的方法,然后执行method.insertBefore和insertAfter方法,参数为要插入的Java代码,再以字符串的形式传入即可,实现起来也极为简单。
简单使用例子
import com.agent.javassist.*;
public class JavassistTest
{
public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException, IOException
{
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("gy.Base");
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{ System.out.println(\"start\");}");
m.insertAfter("{ System.out.println(\"end\");}");
Class c = cc.toClass();
cc.writeFile("/Users/zen/projects");
Base h = (Base)c.newInstance();
h.process();
}
}
将上面代码嵌入到TestTransformer类里进行插桩,如下:
import java.lang.instrument.ClassFileTransformer;
public class TestTransformer implements ClassFileTransformer{
@Override
public byte[] transform(ClassLoader loader, String className,Class<?> classBeingRedefined,ProtectionDomain protectionDomain,byte[] classfileBuffer)
{
try {
ClassPool cp = ClassPool.getDefault();
CtClass cc = null;
cp.insertClassPath(new LoaderClassPath(loader));
cc = cp.get(className);
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{System.out.println(\"start\");}");
m.insertAfter("{System.out.println(\"end\");}");
byteCode = cc.toBytecode();
} catch (Exception ex) {
this.command.setRes(false);
}
return byteCode;
}
}
方式三
是运行时载入,通过Attach API,将模块(jar包)动态地Attach到指定进程id的Java进程内。
import com.sun.tools.attach.VirtualMachine;
public class Attacher{
public static void main(String[] args) throws AttachNotSupportedException,
IOException,AgentLoadException,AgentInitializationException{
// 传入目标JVM pid
VirtualMachine vm = VirtualMachine.attach(“39333”);
vm.loadAgent(“/Users/gy/test.jar”);
}
}
由于agent main方式无法向premain方式那样在命令行指定代理jar,因此需要借助AttachTools API。使用com.sun.tools.attach包下的VirtualMachine类,使用attach pid 来得到相应的VirtumalMachine,使用loadAgent 方法指定agentmain所在类并加载。其中
com.sun.tools.attach.VirtualMachine的jar包是jdk下lib中的tools.jar
Attach API 的作用是提供JVM 进程间通信的能力,比如说我们为了让另外一 个 JVM 进程把线上服务的线程Dump 出来,会运行 jstack或 jmap的进程,并传递 pid 的参数,告诉它要对哪个进程进行线程Dump,这就是 Attach API 做的事情。
调试
本地debug和远程debug都可以