之前在es聚合导出(不是),Map#merge方法使用一文里提到过调用jar包里的代码,但是jar包里的代码有问题,需要修改才行的问题。另外之前也跟朋友聊天的时候提到过他调用的jar包里的源码有bug,但是没法修改的事情。今天摸会儿<・)))><<,试用下Javassist(http://www.javassist.org/)的强大功能,可以动态的直接修改编译后的.class文件,这样的好处是在不修改源码的情况下即可修改现有逻辑。不光是修改,他还能动态创建新的类,并编写相应逻辑,实现对现有代码的零侵入。

同时结合上文Redis结合AOP实现限流注解的开发中提到的Aspect切面编程相关技术实现更加强大的方法增强效果。

下面就来做一个简单实现试试看,目标仍然是修改jar包里的代码逻辑,首先第一步自己先写个类,并打成jar包。

打包jar包

取一段现有项目里的代码,打成一个包。新建一个项目,按如下结构填入代码,并进行package打包操作

现有分库操作中常用的一个方法,根据订单号模12之后的值进行分库存取操作
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));
    }
}

执行跑一下看下效果

16模12余4

正确,没有问题,准备工作结束,开始试试javassist

javassist引用

添加pom依赖

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.29.2-GA</version>
</dependency>

由于是在Spring Boot中使用,我需要在项目启动的时候将相关代码执行一遍,要实现Spring Boot启动的时候执行指定代码逻辑的常用方法有这么几种

  1. 实现ApplicationRunner接口
  2. 实现CommandLineRunner接口
  3. 使用注解修饰你要执行的方法
@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接口看下效果

16模5余1

可以看到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了来分析下<待补充……>

补充

一些局限

  1. 不支持内部类
  2. 不支持枚举、泛型(源码层面)
  3. 针对方法重载,会产生混淆(错误调用)
  4. 不支持continue和break标签
  5. 不支持数组初始化方式
  6. 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 对象,表示当前正在修改的类
更多内容详见http://www.javassist.org/tutorial/tutorial2.html

参考:https://www.cnblogs.com/rickiyang/p/11336268.html