美文网首页
java进阶之agent代理系列(三)——使用javassist

java进阶之agent代理系列(三)——使用javassist

作者: moutory | 来源:发表于2023-12-14 15:05 被阅读0次

    前言

    基于前面两篇文章,我们已经掌握了agent两种模式的代理开发,但实现的功能都是打印输出比较多,还没能体现代理的强大之处。本篇文章我们将引入javassist来完成一个接口统计耗时的功能,加深对agent技术的实际使用。

    本系列更多相关文章:

    java进阶之agent代理系列(一)——使用premain模式进行代理
    java进阶之agent代理系列(二)——使用agentmain模式进行代理

    本篇文章将基于常用的springboot项目进行演示,对springboot项目中的Service实现类接口执行耗时进行统计

    一、代码实现和思路分析

    (一)思路分析
    • 找到代理类对象
      当前的场景需求是找到项目中所有的自定义Service实现类,对其方法进行拦截并在方法原逻辑的基础上新增统计执行耗时的逻辑。我们很容易就能联想到在代理premain入口方法中,根据className来判断对象是否属于需要被拦截的类对象

    • 通过动态编译来进行代理
      找到类对象之后,接下来的动作就是对原方法进行增强,此处考虑借助javassist库来帮助我们动态地修改类的字节码数据。当然了,如果是明确知道自己要替换的类对象且准备好了新的代理class文件的话,也可以用原生的IO来做字节码的替换。只是原生的写法比较麻烦,不太推荐。

    (二)代码演示
    1、主程序端开发

    主程序端是标准的springboot项目,这里我们只对比较重要的代码进行展示,有需要源码的话可以从文章最后的地址上面拿。

    • Controller代码
    @RestController
    @RequestMapping("order")
    public class OrderController {
    
        @Resource
        private OrderService orderService;
    
        @GetMapping("getOrder")
        public QiQvResult getOrder(){
            System.out.println("进入了OrderController的getOrder方法");
            List<OrderDto> allOrders = orderService.getAllOrders();
            return QiQvResult.OK(allOrders);
        }
    
    }
    
    • Service代码
      为了模拟真实的业务场景,这里我们手动让线程睡眠1秒,为了简化代码我们也暂时不写dao层
    public interface OrderService {
    
        List<OrderDto> getAllOrders();
    }
    
    public class OrderServiceImpl implements OrderService {
    
    
        @Override
        public List<OrderDto> getAllOrders() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            List<OrderDto> list = new ArrayList<>();
            for (int i = 0; i < 5; i++) {
                list.add(generatorOrder());
            }
            return list;
        }
    
        private OrderDto generatorOrder() {
            OrderDto dto = new OrderDto();
            dto.setOrderId(UUID.randomUUID().toString());
            dto.setPayTime(LocalDateTime.now());
            dto.setComment("测试备注");
            return dto;
        }
    }
    
    2、代理端代码开发
    步骤1:配置pom文件

    引入javassist依赖

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

    配置maven打包插件,让依赖可以正常的打入jar包中

            <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-shade-plugin</artifactId>
                    <version>3.2.4</version>
                    <executions>
                        <execution>
                            <phase>package</phase>
                            <goals>
                                <goal>shade</goal>
                            </goals>
                            <configuration>
                                <createDependencyReducedPom>false</createDependencyReducedPom>
                                <transformers>
                                    <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                        <mainClass>com.qiqv.demo.JavaAgentTest</mainClass>
                                    </transformer>
                                </transformers>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
    
    步骤二:开发自定义的转换器
    1. 首先我们通过className判断类对象是否属于是我们要代理的对象,如果是的话则进入agentForRunTimeStatistical方法
    2. 在这个方法中,我们会尝试在ClassPool中找我们要代理的对象,如果找不到则借助当前的类加载器进行二次查找
    3. 若javassist找到了需要被代理的类对象,我们先通过getDeclaredMethods方法拿到类中的所有方法(不包含父类方法)
    4. 对每个方法进行拷贝后,重命名为${oldMethodName}$agent
    5. 在方法副本中加入时间统计的代码,然后手动回调一次原方法
    6. 把最新的字节码数据返回回去,生成最终的代理对象
    public class LogTransformer  implements ClassFileTransformer {
    
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,ProtectionDomain protectionDomain, byte[] classfileBuffer) {
            if(!className.contains("com/qiqv") || !className.contains("ServiceImpl")){
                return classfileBuffer;
             }
            try {
                return agentForRunTimeStatistical(loader,className);
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        }
    
    
        private byte[] agentForRunTimeStatistical(ClassLoader loader,String className) throws Exception {
            String classPath = className.replaceAll("/",".");
            CtClass ctClass = this.findClassInClassPool(loader,classPath,true);
            if(ctClass == null){
                System.out.println("在类加载器中找不到:"+classPath);
                return null;
            }
            System.out.println("在类加载器中找到了:"+classPath);
            // 只对本类方法进行拦截,不处理父类方法
            CtMethod[] methods = ctClass.getDeclaredMethods();
            
            for (CtMethod method : methods) {
                CtMethod methodCopy = CtNewMethod.copy(method, ctClass, new ClassMap());
                String agentMethodName = method.getName() + "$agent";
                method.setName(agentMethodName);
                StringBuffer body = new StringBuffer("{\n")
                        .append("System.out.println(\"start statistical method...\");\n")
                        .append("long begin = System.currentTimeMillis();\n")
                        .append("try{\n")
                        .append("return ($r)" + agentMethodName + "($$);\n")
                        .append("}finally {\n")
                        .append("long executeTime = System.currentTimeMillis() - begin;\n")
                        .append("System.out.println(\"" + methodCopy.getName() + " method total execute time is \" + executeTime);\n")
                        .append("}\n")
                        .append("}");
                methodCopy.setBody(body.toString());
                ctClass.addMethod(methodCopy);
            }
            return ctClass.toBytecode();
        }
    
        private CtClass findClassInClassPool(ClassLoader loader, String classPath,boolean firstTry) throws NotFoundException {
            ClassPool pool = ClassPool.getDefault();
            CtClass ctClass = null;
            try {
                ctClass = pool.get(classPath);
            }catch (NotFoundException e){
                if(firstTry){
                    System.out.println("can not find "+ classPath +" in ClassPool first time");
                    pool.appendClassPath(new LoaderClassPath(loader));
                    ctClass = findClassInClassPool(loader,classPath,false);
                }
            }
            return ctClass;
        }
    }
    

    这里还可以更细化一点,根据实际需要只对public方法进行日志统计,减少不必要的日志收集。然后代码里面使用了javassist的一些特殊的符号,也在这里解释一下。

    • ($$) 表示方法入参
    • ($r) 表示方法返回值
    步骤三:开发agent入口类

    这里其实没太多逻辑,主要就是把我们自定义的转换器给引入进去。需要注意的是agent入口类需要配置在步骤1的pom文件中

    public class JavaAgentTest {
        public static void premain(String agentArgs, Instrumentation inst) {
            inst.addTransformer(new LogTransformer());
        }
    }
    
    3、启动项目进行测试

    在IDEA中配置项目启动VM参数-javaagent:具体路径\java-agent-mock.jar

    启动项目后调用Controller接口,我们可以发现日志统计确实正常生效了。


    主程序的控制台日志

    二、代理方法可能遇到的问题

    (一)加载器的问题

    我们知道,类加载器用来动态加载Java类到Java虚拟机的内存空间中,分为Bootstrap(引导类加载器)、Extension(拓展类加载器)和System以及User-Defined。其中Bootstrap负责加载<JAVA_HOME>/lib路径下的核心类库或-Xbootclasspath参数指定的路径下的类;Extension负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类;System负责加载ClassPath下的类。
    默认情况下,javassist所使用的类加载器是AppClassLoader,而SpringBoot项目的jar是有着自己的类加载器的(SpringBoot把所有的依赖和代码都放在了一个jar包中,需要加载依赖的类时通过自己的类加载器进行加载,也就是说无法直接通过classpath找到springboot的类对象),所以正常情况下无法通过ClassPool来直接获取到SpringBoot项目中的类对象,但是问题也不大。因为javassist已经帮我们提前准备好了解决方案,如果默认的类加载器无法加载到我们想要的对象时,我们可以通过指定其他类加载器帮助我们找到类对象。

    ClassPool pool = ClassPool.getDefault();
    pool.appendClassPath(new LoaderClassPath(loader));
    

    解压最终得到的jar包,我们可以发现MANIFEST.MF文件里面记录的Main方法并不是我们的XXXApplication中的main方法,而是springboot又单独封装了一层main方法,并且通过自己的类加载器来读取应用下所使用到的依赖(感觉应该是为了实现应用隔离)

    解压最终得到的jar包,MANIFEST.MF文件内容
    (二)如何对agent代理的jar包进行debug

    java agent放在和我们开发项目的同级目录下,在idea中打开java agent的项目(作为一个模块引入进来)

    image.png
    引入完成后,在agent项目中打上断点,启动我们的主程序就可以看到agent项目的断点已经可以正常使用了。

    本文的源码已经上传码云,有需要自取:https://gitee.com/moutory/java-agent
    (可以的话顺便帮忙点个赞)

    小结

    文章针对方法执行耗时统计这一场景,提供了使用java agent技术的解决方案,当然了个人觉得这种场景下更好的解决方案是通过spring aop来做,这样更方便一点。java agent技术更适合用于做应用的性能检测(包括检测当前应用JVM使用情况等信息),和业务无关的场景。事实上,java agent能实现的功能十分强大而且灵活,事实上一些外挂就是基于此来进行实现的。
    对于本篇文章的案例,其实这里也可以针对Controller来进行拦截统计,不过需要额外考虑一个问题,就是我们进行Controller层方法的拷贝时需要注意,需要同时将方法副本上面的@RequestMapping注解去掉,否则即使生成了副本,也有可能因为路径转发问题,导致没有执行我们最新生成的代理方法。

    相关文章

      网友评论

          本文标题:java进阶之agent代理系列(三)——使用javassist

          本文链接:https://www.haomeiwen.com/subject/yaymgdtx.html