之前在es聚合导出(不是),Map#merge方法使用一文里提到过调用jar包里的代码,但是jar包里的代码有问题,需要修改才行的问题。另外之前也跟朋友聊天的时候提到过他调用的jar包里的源码有bug,但是没法修改的事情。今天摸会儿<・)))><<,试用下Javassist(http://www.javassist.org/)的强大功能,可以动态的直接修改编译后的.class文件,这样的好处是在不修改源码的情况下即可修改现有逻辑。不光是修改,他还能动态创建新的类,并编写相应逻辑,实现对现有代码的零侵入。
同时结合上文Redis结合AOP实现限流注解的开发中提到的Aspect切面编程相关技术实现更加强大的方法增强效果。
下面就来做一个简单实现试试看,目标仍然是修改jar包里的代码逻辑,首先第一步自己先写个类,并打成jar包。
打包jar包
取一段现有项目里的代码,打成一个包。新建一个项目,按如下结构填入代码,并进行package打包操作
public class Mod12 {
public static int mod12(String number){
return modBy(number,12);
}
public static int modBy(String number, int modNum){
BigInteger numBig = new BigInteger(number);
return numBig.mod(BigInteger.valueOf(modNum)).intValue();
}
}
pom.xml文件内容也很干净,如下,分别定义groupId、artifactId、version即可
<?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>com.mymod</groupId>
<artifactId>mod12</artifactId>
<version>1.0-SNAPSHOT</version>
</project>
执行package后在项目的target目录下得到一个mod12-1.0-SNAPSHOT.jar文件。
题外话,这样的操作其实也是在日常工作中常用的一种方式。将一些通用的工具类,接口类等独立一个项目,并打包成jar包,再在其他各业务系统中引入这个对应的包调用即可。打一个比方,A系统在集群内提供了一系列rpc服务接口,此时A系统的开发人员将接口单独定义单独抽出来打成一个jar包,其他系统在需要调用A系统的接口的时候引入这个jar包,根据jar包内定义的方法调用A系统的接口。如果A系统某天新增了某个接口,而某个调用方需要用这个新增的接口,则更新下jar包的版本号即可。
导入本地jar包
要引用一个新的jar包的方法有很多种,这里就简单的直接选择本地引用jar包的方式,不过也因此后面项目打包的时候要包含进本地jar包会有点麻烦。当然你如果有本地maven仓库也可以把jar包发布到仓库,直接在项目的pom文件中添加依赖即可使用,不过我这边没有这个条件就没这么做了。在现有项目中新建一个lib目录,并将生成的jar包粘贴到这里
修改项目pom文件,添加对新增的jar包的依赖
<dependency>
<groupId>com.mymod</groupId>
<artifactId>mod12</artifactId>
<version>1.0-SNAPSHOT</version>
<type>jar</type>
<scope>system</scope>
<systemPath>${basedir}/lib/mod12-1.0-SNAPSHOT.jar</systemPath>
</dependency>
同时修改打包参数,<includeSystemScope>true</includeSystemScope>
选项会将本地jar文件在项目最终打包的时候一起打包进来
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<includeSystemScope>true</includeSystemScope>
</configuration>
</plugin>
再新建一个简单的controller类,用来调用刚刚加进来的方法
@RestController
public class ModTestController {
@RequestMapping(value = "mod")
public String modTest(@RequestParam String num) throws Exception {
return String.valueOf(Mod12.mod12(num));
}
}
执行跑一下看下效果
正确,没有问题,准备工作结束,开始试试javassist
javassist引用
添加pom依赖
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.29.2-GA</version>
</dependency>
由于是在Spring Boot中使用,我需要在项目启动的时候将相关代码执行一遍,要实现Spring Boot启动的时候执行指定代码逻辑的常用方法有这么几种
- 实现ApplicationRunner接口
- 实现CommandLineRunner接口
- 使用注解修饰你要执行的方法
@Component
public class MyApplicationRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.println("MyApplicationRunner...");
}
}
@Component
public class MyCommandLineRunner implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
System.out.println("MyCommandLineRunner...");
}
}
@Component
public class MyPostConstruct {
@PostConstruct
public void init(){
System.out.println("MyPostConstruct.....");
}
}
启动项目的时候可以看到执行情况,ApplicationRunner和CommandLineRunner只是参数类型的区别,而@PostConstruct注解则是Java自身提供的一个注解,被修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。这里不做过多展开了
那么我就直接选择了实现CommandLineRunner接口的方式来实现了,实现编码如下
/**
* 修改Jar包中Mod12类的mod12方法
*
* @author CheungQ
* @date 2023/3/14 14:40
*/
@Component
public class Mod12Hack implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
ClassPool pool = ClassPool.getDefault();
//指定要修改class的完整类名
CtClass ctClass = pool.get("com.mymod.Mod12");
//设置要修改的方法需要的参数,没有参数可以不设置
//指定要修改的类方法
CtMethod methodMod12 = ctClass.getDeclaredMethod("mod12");
//重新设置方法体
methodMod12.setBody("{ return modBy($1,5);}");
//插入新的代码到最前面
methodMod12.insertBefore("{ System.out.println(\"当前mod12方法已经被javassist修改了\"); }");
ctClass.toClass();
}
}
基本实现逻辑也很简单,这里只是做一个示例。获取到对应的com.mymod.Mod12
类,再根据类获取到mod12
方法,将该方法的方法体修改为return modBy($1,5);
也就是从原来的模12修改为模5了。并再方法体最前面再加一句输出语句“方法已经被修改了”。最后执行toClass()
方法,CtClass
对象并不是实际的java类,调用 toClass()
方法将CtClass
实例转换为对应的java类并实际加载该类。需要注意的是如果调用了writeFile()
,toClass()
或toBytecode()
方法之后,对应CtClass就会被冻结(预期类已经加载到jvm了,用户应该不会有其它修改),无法被再次编辑。需要时可以通过defrost()
方法取消冻结。
启动项目,请求下之前的controller接口看下效果
可以看到javassist对mod12方法的字节码的修改确实已经生效了,使原来的模12变成了模5的操作。如果我们在jar包的对应方法里打上断点,则可以发现在jar包中的mod12方法中打的断点无法生效。这样的方式也给调试造成了不少麻烦。不过可以通过CtClass.debugDump设置指定目录来开启调试模式,指定一个目录,所有编译过的类文件会在那个目录下。
一切看起来很完美,下一步作为在运行项目,总是要打包发布的。执行打包在本地java -jar执行一下之后出现问题了。很明显,在jar包里执行没有找到这个类
javassist.NotFoundException: com.mymod.Mod12
一开始一度以为是本地jar包没有打包进去的原因,但是我在解压打包后的jar包之后进去看了下本地的mod12-1.0-SNAPSHOT.jar是有被正确打包进来的,那么问题可能要转向另一个方向,可能是类加载相关方面的问题
索性这个问题之前有人提问过
https://stackoverflow.com/questions/48437113/javassist-not-working-with-spring-boot-jar
原因也很简单,因为Spring Boot的使用的自己的ClassLoader产生的问题,关于Spring Boot的类加载机制可以另外再看,这里我们需要把原来的代码修改一下即可
ClassPool pool = ClassPool.getDefault();
//指定class所在的路径
pool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
详细区别,稍后debug了来分析下<待补充……>
补充
一些局限
- 不支持内部类
- 不支持枚举、泛型(源码层面)
- 针对方法重载,会产生混淆(错误调用)
- 不支持continue和break标签
- 不支持数组初始化方式
- javassist的编译器不支持自动拆装箱(即类似,”Integer i = 1″这种语句是非法的)
以及一些字符含义
符号 | 含义 |
---|---|
$0 , $1 , $2 , … | $0 = this, $1 , $2 , …按顺序对应方法的参数,静态方法没有$0 |
$args | 方法参数数组.它的类型为 Object[] |
$$ | 全部实参。例如, m($$) 等价于 m($1,$2, …) |
$cflow( …) | cflow 变量 |
$r | 返回结果的类型,用于强制类型转换 |
$w | 包装器类型,用于强制类型转换 |
$_ | 返回值 |
$sig | 类型为 java.lang.Class 的参数类型数组 |
$type | 一个 java.lang.Class 对象,表示返回值类型 |
$class | 一个 java.lang.Class 对象,表示当前正在修改的类 |
参考:https://www.cnblogs.com/rickiyang/p/11336268.html
发表评论