ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ASM 字节码增强实战:CodeGuide 手把手教你给所有方法加 TryCatch,非入侵采集异常与出参

ASM 字节码增强实战:CodeGuide 手把手教你给所有方法加 TryCatch,非入侵采集异常与出参 文档教程后端【免费下载链接】CodeGuide:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总旨在为大家提供一个清晰详细的学习教程侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助请给予支持(关注、点赞、分享)项目地址https://gitcode.com/gh_mirrors/code/CodeGuide点击查看免费下载本文基于 CodeGuide 开源仓库《用字节码增强技术给所有方法加上TryCatch捕获异常并输出》整理成文。当业务系统出现问题时你是否希望在不改动一行业务代码的前提下自动抓取每个方法的入参、出参与异常信息本指南以strToNumber方法为靶点逐步演示如何用 ASM 指令码将一个普通方法改造成被TryCatch包裹、可输出监控信息的增强方法。读完你将掌握 ASM 字节码增强的核心骨架ClassReader/ClassWriter/AdviceAdapter理解onMethodEnter、visitMaxs、onMethodExit三个注入点的配合方式以及ASTORE、ALOAD、LDC、INVOKESTATIC、ATHROW等关键指令的实战含义从而能自己搭建一套非入侵的监控组件。一、为什么需要给方法织上监控一套系统是否稳定运行取决于它的运行健康度这包括调用量、可用率、影响时长以及服务器性能等各项指标的综合值。并且在系统出现异常问题时最好能抓取整个业务方法执行链路并输出当时的入参、出参、异常信息以及一些 JVM、Redis、MySQL 的各项性能指标用于快速定位并解决问题。要做到这件事常见的监控方案有三种方案做法缺点硬编码直接在方法中写代码收取执行耗时、出入参与异常信息成本太大且有不可预估的风险切面 AOP做一套统一监控组件仍需要硬编码维护成本不低探针非入侵基于探针技术采用字节码增强方式监控如 Google Dapper、Zipkin 的思路需要掌握字节码编程能力在字节码增强方面有三个常用框架ASM、Javassist、ByteCode各有优缺点按需选择。本文聚焦 ASM 方式通过指令码来改写方法给方法添加TryCatch块用于采集异常信息以及正常的出参结果。从仓库目录可以看到CodeGuide 的字节码编程专题覆盖了 ASM、Javassist、Byte-Buddy 三大框架README.md 的7. 字节码编程章节对整套学习路线做了归档其中 ASM 部分还包括完整的 ASM 官方文档译文是本文深入原理的权威依据。二、系统环境与依赖jdk1.8.0asm-commons 6.2.1本文用到的AdviceAdapter就位于org.objectweb.asm.commons包三、技术目标把方法改造成什么样通过 ASM 字节码增强技术使用指令码将方法修改为我们想要的效果。生产落地时通常需要配合JavaAgent技术在工程启动加载类时修改字节码本文为了把字节码核心内容完整展示出来采用加载类名称获取字节码进行修改的方式更完整的JavaAgent ASM落地方案可参考仓库姊妹篇 《JavaAgentASM字节码插桩采集方法名称以及入参和出参结果并记录方法耗时》。修改之前的方法public Integer strToNumber(String str) { return Integer.parseInt(str); }修改之后的方法public Integer strToNumber(String str) { try { Integer var2 Integer.parseInt(str); MethodTest.point(org.itstack.test.MethodTest$Test.strToNumber, var2); return var2; } catch (Exception var3) { MethodTest.point(org.itstack.test.MethodTest$Test.strToNumber, var3); throw var3; } }对比修改前后主要有两点变化返回值赋值给新的局部变量并做了输出原本return的表达式结果被保存到var2先调用监控方法point输出再return var2整个方法被包裹在TryCatch中捕获到异常后把异常信息也交给point输出随后throw var3原样抛出保证原有异常语义不变。想通这一点你就能意识到把它用到自己的业务中就可以做一套非入侵的监控系统了。四、实现过程字节码增强的过程乍一看比较麻烦如果没有阅读过 JVM 虚拟机规范等相关书籍确实不好理解。但正是这部分不那么容易理解的知识才是后续价值的体现。仓库中恰好有完整的 JVM 指令表 和 字节代码指令附录 可供随时查阅。1. 搭建字节码处理框架/** * 字节码增强获取新的字节码 */ private byte[] getBytes(String className) throws IOException { ClassReader cr new ClassReader(className); ClassWriter cw new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); cr.accept(new ClassVisitor(ASM5, cw) { public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { // 方法过滤 if (!strToNumber.equals(name)) return super.visitMethod(access, name, descriptor, signature, exceptions); MethodVisitor mv super.visitMethod(access, name, descriptor, signature, exceptions); return new AdviceAdapter(ASM5, mv, access, name, descriptor) { // 方法进入时修改字节码 protected void onMethodEnter() {} // 访问局部变量和操作数栈 public void visitMaxs(int maxStack, int maxLocals) {} // 方法退出时修改字节码 protected void onMethodExit(int opcode) {} }; } }, ClassReader.EXPAND_FRAMES); return cw.toByteArray(); }这段代码就是 ASM 用于处理字节码增强的模板代码块ClassReader/ClassWriter分别负责类的读取和写入。加载方式在构造方法中提供得比较丰富可以通过类名、字节码或流的方式处理。姊妹篇 2020-04-05 插桩篇 中就是通过new ClassReader(classfileBuffer)以字节数组方式加载两种入口对应不同的落地场景。ClassWriter.COMPUTE_MAXS让 ASM 自动计算方法的最大栈深maxStack与局部变量数maxLocals省去手工维护的负担。ASM 文档 3.3 工具 中也指出实践中通过COMPUTE_MAXS计算最优maxStack的效率远高于使用AnalyzerAdapter手工推导。ClassReader.EXPAND_FRAMES展开栈映射帧配合visitFrame时更新帧信息保证改写后的方法能被 JVM 验证器接受。方法过滤visitMethod中通过name只处理目标方法strToNumber其余方法原样透传。这也是所有 ASM 监控系统的通用做法——在MethodVisitor层做字节码注入。方法访问层用到了AdviceAdapter的三个钩子基本所有 ASM 字节码增强操作都离不开它们方法时机用途onMethodEnter方法进入时设置基本内容如记录起始纳秒、标记Try块开始visitMaxs方法结束前添加Catch块将整个方法包裹起来onMethodExit方法退出时、RETURN之前注入结尾字节码如调用外部方法输出监控信息关于AdviceAdapter的定位ASM 文档 3.3.4 AdviceAdapter 有权威说明它是一个抽象类可用于在方法的开头以及恰在任意RETURN或ATHROW指令之前插入代码。它的最大优势是对构造器同样有效——在构造器中不能把代码恰好插入到开头而必须插在对超构造器super(...)的调用之后AdviceAdapter内部专门检测了这个调用同时它继承自LocalVariablesSorter可以方便地使用newLocal申请新的局部变量槽位本文第 4、5 节正是这么用的。2. 获取方法返回值被测试的方法public Integer strToNumber(String str) { return Integer.parseInt(str); }编写指令onMethodExit方法Override protected void onMethodExit(int opcode) { if ((IRETURN opcode opcode RETURN) || opcode ATHROW) { int nextLocal this.nextLocal; mv.visitVarInsn(ASTORE, nextLocal); // 将栈顶引用类型值保存到局部变量indexbyte中。 mv.visitVarInsn(ALOAD, nextLocal); // 从局部变量indexbyte中装载引用类型值入栈。 } }逐条说明this.nextLocal获取局部变量的索引值即当前局部变量表的末尾位置用于存放ARETURN的值ARETURN返回对象类型如果是返回int则需要使用IRETURN。ASTORE将栈顶引用类型值保存到局部变量。可以在脑中想象方法里有局部变量区和操作数栈两块区域指令在执行中不断压栈、弹栈。ALOAD从局部变量装载引用类型值入栈将刚才保存的值放回操作数栈供后续输出使用。被初次增强后的方法public Integer strToNumber(String str) { Integer var2 Integer.parseInt(str); return var2; }原本的返回值被赋值到一个参数上之后再由return将参数返回。这样我们就拿到了方法出参var2可以对它进行输出操作了。3. 输出方法返回值定义输出结果方法public static void point(String methodName, Object response) { System.out.println(系统监控 :: [方法名称 methodName 输出信息 JSON.toJSONString(response) ]\r\n); }在onMethodExit中使用字节码增强调用这个静态方法Override protected void onMethodExit(int opcode) { if ((IRETURN opcode opcode RETURN) || opcode ATHROW) { ... mv.visitLdcInsn(className . name); // 类名.方法名 mv.visitVarInsn(ALOAD, nextLocal); mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(MethodTest.class), point, (Ljava/lang/String;Ljava/lang/Object;)V, false); } }mv.visitLdcInsn(className . name)把常量池中的常量值int、float、string reference、object reference入栈即把类名.方法名字符串写入常量池。mv.visitVarInsn(ALOAD, nextLocal)把上面保存的返回值加载到操作数栈。mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(MethodTest.class), point, (Ljava/lang/String;Ljava/lang/Object;)V, false)调用静态方法。INVOKESTATIC是调用指令后面是方法所属类的内部名、方法名、方法描述。(Ljava/lang/String;Ljava/lang/Object;)V表示String和Object两个入参V表示返回空即方法void point(String methodName, Object response)。这里用到的Type是 ASM 提供的运行时工具类ASM 文档 2.3.1 Type 说明getInternalName()返回类的内部名如java/lang/StringgetDescriptor()返回类型描述符getArgumentTypes/getReturnType可以解析方法描述符是编写字节码时的常用辅助。再次被增强后的方法public Integer strToNumber(String str) { Integer var2 Integer.parseInt(str); point(org.itstack.test.MethodTest.strToNumber, var2); return var2; }增强后每次调用这个方法都会输出方法名称和出参结果。但还遗留一个问题如果抛异常了就监控不到了4. 给方法加上 TryCatch添加TryCatch开始onMethodEnterprivate Label from new Label(), to new Label(), target new Label(); Override protected void onMethodEnter() { //标志try块开始位置 visitLabel(from); visitTryCatchBlock(from, to, target, java/lang/Exception); }在onMethodEnter()中加入TryCatch开始块这是 ASM 中的固定模式按需添加即可。添加TryCatch结尾visitMaxsOverride public void visitMaxs(int maxStack, int maxLocals) { //标志try块结束 mv.visitLabel(to); //标志catch块开始位置 mv.visitLabel(target); mv.visitFrame(Opcodes.F_SAME1, 0, null, 1, new Object[]{java/lang/Exception}); // 异常信息保存到局部变量 int local newLocal(Type.LONG_TYPE); mv.visitVarInsn(ASTORE, local); // 抛出异常 mv.visitVarInsn(ALOAD, local); mv.visitInsn(ATHROW); super.visitMaxs(maxStack, maxLocals); }在visitMaxs方法中完成TryCatch的结尾捕获异常后请原样抛出。mv.visitFrame(Opcodes.F_SAME1, 0, null, 1, new Object[]{java/lang/Exception})在指定方法操作数栈中将TryCatch处理完成。这几个参数也可以动态拼装局部变量、参数、栈、异常。之所以要显式visitFrame是因为字节码增强改变了控制流后原本的栈映射帧已经失效——这正是 ASM 文档第 8 章方法分析 所讲的数据流分析问题新增的异常处理器入口target处操作数栈上会多出一个Exception引用必须用F_SAME1帧声明否则类加载时会被 JVM 验证器拒绝。ASTORE将异常信息保存到局部变量再用ALOAD放到操作数栈用于抛出。ATHROW抛出异常的指令即throw var。这次增强后的方法public Integer strToNumber(String str) { try { Integer var2 Integer.parseInt(str); point(org.itstack.test.MethodTest.strToNumber, var2); return var2; } catch (Exception var3) { throw var3; } }此时整个方法已经被包装到TryCatch中下一步就是把异常信息也打印出来。5. 输出异常信息定义输出异常方法public static void point(String methodName, Throwable throwable) { System.out.println(系统监控 :: [方法名称 methodName 异常信息 throwable.getMessage() ]\r\n); }在visitMaxs中把调用外部方法的内容集成进抛出异常的逻辑Override public void visitMaxs(int maxStack, int maxLocals) { ... // 输出信息 mv.visitLdcInsn(className . name); // 类名.方法名 mv.visitVarInsn(ALOAD, local); mv.visitMethodInsn(INVOKESTATIC, Type.getInternalName(MethodTest.class), point, (Ljava/lang/String;Ljava/lang/Throwable;)V, false); ... }mv.visitLdcInsn加载常量即类名和方法名。ALOAD将异常信息加载到操作数栈用于输出。INVOKESTATIC调用静态方法。Java 中的方法调用指令除了INVOKESTATIC外还有invokespecial调用私有/构造/超类方法、invokevirtual普通实例方法分派、invokeinterface接口方法分派可以参考 JVM 指令表 加深印象。现在再看字节码增强后的方法public Integer strToNumber(String str) { try { Integer var2 Integer.parseInt(str); point(org.itstack.test.MethodTest.strToNumber, (Object)var2); return var2; } catch (Exception var3) { point(org.itstack.test.MethodTest.strToNumber, (Throwable)var3); throw var3; } }至此这个方法已通过字节码彻底改造完成正常路径输出出参异常路径输出异常信息后原样抛出。对外输出的方法后续可以通过 MQ 等机制推送出去用于图表展示监控信息。6. 核心指令速查把上面用到的指令汇总如下方便对照 JVM 指令表 学习指令作用本文用途ASTORE将栈顶引用类型值保存到局部变量保存返回值 / 异常对象ALOAD从局部变量装载引用类型值入栈把返回值 / 异常重新压栈供输出LDC常量池常量值入栈把类名.方法名压栈作为入参INVOKESTATIC调用静态方法调用point(...)输出监控信息ATHROW抛出异常增强后原样抛出捕获到的异常visitTryCatchBlock声明异常处理器范围圈定 try 块起止与 catch 入口visitFrame写栈映射帧修正异常处理入口的帧状态五、测试验证测试方法是一个字符串转数字的方法通过传入不同参数验证数字字符串正常返回非数字字符串触发异常分支。另外要注意通过字节码增强改造后方法反馈给我们的仍然是字节码需要使用ClassLoader重新加载后执行。测试方法public static void main(String[] args) throws Exception { // 方法字节码增强 byte[] bytes new MethodTest().getBytes(MethodTest.class.getName()); // 输出方法新字节码 outputClazz(bytes, MethodTest.class.getSimpleName()); // 测试方法 Class? clazz new MethodTest().defineClass(org.itstack.test.MethodTest, bytes, 0, bytes.length); Method queryUserInfo clazz.getMethod(strToNumber, String.class); // 正确入参测试验证结果输出 Object obj01 queryUserInfo.invoke(clazz.newInstance(), 123); System.out.println(01 测试结果 obj01); // 异常入参测试验证打印异常信息 Object obj02 queryUserInfo.invoke(clazz.newInstance(), abc); System.out.println(02 测试结果 obj02); }输出结果ASM字节码增强后类输出路径/User/itstack/git/github.com/WormholePistachio/SQM/target/test-classes/MethodTestSQM.class 系统监控 :: [方法名称org.itstack.test.MethodTest.strToNumber 输出信息123] 01 测试结果123 系统监控 :: [方法名称org.itstack.test.MethodTest.strToNumber 异常信息For input string: abc] Process finished with exit code 1从输出可以看到正常入参123时监控输出出参123方法正常返回异常入参abc时Integer.parseInt抛出NumberFormatException被我们织入的 catch 块捕获并输出For input string: abc后原样抛出程序以 exit code 1 结束——异常语义没有被破坏但异常信息已经完整落到了监控输出里。六、从单方法到全方法如何扩展成真正的监控组件本文为了突出字节码核心内容只对strToNumber一个方法做增强。生产级的非入侵监控需要回答两个问题给哪些方法增强增强后输出什么1. 方法过滤与描述解析在visitMethod中把!strToNumber.equals(name)换成自己的过滤规则如排除init、clinit只处理业务包路径即可批量织入。更进一步的实现可以参考姊妹篇 2020-04-05 插桩篇方法的入参和出参在字节码中被描述成一段指定码如(II)Ljava/lang/String;表示两个int入参、一个String出参复杂描述如(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;IJ[I[[Ljava/lang/Object;Lorg/itstack/test/Req;)Ljava/lang/String;需要使用正则(L.*?;|\\[{0,2}L.*?;|[ZCBSIFJD]|\\[{0,2}[ZCBSIFJD]{1})逐块拆解8 个基本类型Z C B S I F J D需要先装箱Integer.valueOf等才能放进Object[]数组。2. 入参采集与全局方法 ID在onMethodEnter中先用INVOKESTATIC System.nanoTime()LSTORE记录开始时间再通过BIPUSH/ICONST_xANEWARRAY创建参数数组、AASTORE逐项赋值在onMethodExit中把耗时、方法 ID、入参数组、出参一起交给ProfilingAspect.point(...)输出。为了不在每次调用时重复传递方法信息可以为每个方法预生成一个全局唯一methodIdAtomicInteger自增 AtomicReferenceArrayMethodTag存放增强时只压入一个int即可。这套onMethodEnter采集入参、onMethodExit输出出参的模式与 ASM 文档 3.3.4 节的 AddTimerMethodAdapter 示例 中进入记时、退出结算的写法一脉相承——包括(opcode IRETURN opcode RETURN) || opcode ATHROW这个返回值类型判断正是源自该示例的经典写法。字节码增强的骨架一旦掌握换成记耗时采集入参织入 TryCatch都只是在这三个钩子里填不同的指令而已。3. 从案例工程到全链路监控CodeGuide 仓库还提供了完整的进阶路径基于JavaAgent的 全链路监控系列ThreadLocal 链路追踪、JVM 内存与 GC 信息、开发应用级监控以及 字节码插桩技术调研用于系统监控设计和实现。把这些与本文的 TryCatch 织入技术组合就能拼出一套完整的非入侵服务质量监控方案。七、总结通过字节码指令控制代码的编写注入完成功能的同时也加深了对 JVM 虚拟机的理解——不再只是硬背理论而是彻底实践。可以配合仓库的 用 Java 实现 JVM 系列 从解释器视角再看一遍ASTORE、ATHROW等指令是如何被执行的。掌握字节码编程后可以在很多场景中建设高级玩法非入侵监控系统、全链路监控、反射框架等底层其实都用到了 ASM。翻看 Spring、MyBatis 等框架源码也会更容易读懂。深入研究可阅读JVM 虚拟机规范与仓库内的 ASM 文档译文涵盖类、方法、工具、方法分析、后向兼容等章节以及 字节码编程入门篇《如果你只写CRUD那这种技术你永远碰不到》系统补齐 ASM 的完整知识版图。赞分享文档教程后端【免费下载链接】CodeGuide:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总旨在为大家提供一个清晰详细的学习教程侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助请给予支持(关注、点赞、分享)项目地址https://gitcode.com/gh_mirrors/code/CodeGuide点击查看免费下载相关推荐CodeGuide 中间件实战非入侵监控设计之 ASM 字节码插桩CodeGuide 中间件实战非入侵监控设计之 ASM 字节码插桩 本文源自小傅哥《SpringBoot 中间件设计和开发》专栏第 16 章。在大型互联网架构文档教程后端基于 Javassist 字节码插桩的方法级监控采集运行时入参、出参与异常信息CodeGuide 实战基于 Javassist 字节码插桩的方法级监控采集运行时入参、出参与异常信息CodeGuide 实战 字节码插桩技术常与 JavaAgent 探针结合文档教程后端JavaAgent ASM 字节码探针实战以 IDEA 破解 Jar 包为例拆解非入侵式类增强原理JavaAgent ASM 字节码探针实战以 IDEA 破解 Jar 包为例拆解非入侵式类增强原理 本文以开源仓库 CodeGuide https://文档教程后端创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表