基于字节码的统一异常上报实践

语言: CN / TW / HK

基于字节码的统一异常上报实践

一、前言

在我们的日常工作中,总会出现各种各样的“错误”和突发的“异常”。无论我们做了多少准备,多少测试,这些异常总会在某个时间点出现,如果处理不当或是不及时,往往还会导致其他新的问题出现。所以我们要时刻注意这些陷阱以及需要一套“最佳实践”来建立起一个完善的异常处理机制。那么我们如何快速、准确地定位异常的发生的地方,和一些简易的异常信息方便研发定位问题?下面跟随我来看一下转转中台是如何优雅地对异常进行统一的监控和上报处理的。

二、异常介绍

2.1 认识异常

程序在运行时,发生了意料之外的事情,阻止了程序的正常执行,这种情况被称为异常。出现异常后,往往需要人工介入处理,否则会扩大异常的影响面。通常处理异常时,需要解决以下3个问题:

  1. 哪里发生异常?
  2. 谁来处理异常?
  3. 如何处理异常?

2.2 Java异常的分类

JDK中有一套完整的异常机制,所有异常都是Throwable的子类,分为Error(致命异常)和 Exception(非致命异常)。

Error是一种非常特殊的异常类型,他的出现标识着系统发生了不可控的错误,如:StackOverflowError、OutOfMemoryError,程序无法处理,只能人工介入处理

Exception又分为ckecked异常(受检异常)和 uncheck异常(非受检异常),checked异常是需要代码中显式处理,否则编译会报错。uncheck异常是运行时异常,他们都继承RuntimeException,不需要程序进行显示的捕捉和处理。

2.3 异常的处理流程

2.4 异常抛出与捕获的原则

  • 非必要不使用异常
  • 使用描述性消息抛出异常
  • 力所能及的异常一定要处理
  • 异常忽略要有理有据

2.5 认识try/catch/finally

说到异常处理,这里就不得不提try/catch/finally。try不可以单独存在,要么搭配catch,要么搭配finally,或者三者并存。 1、try代码块:监视代码块的执行,发现对应的的异常则跳转至catch,若无catch则直接到finally块。 2、catch代码块:发生对应的异常会执行里面的代码,要么处理,要么向上抛出。 3、finally代码块:不管是否有异常,都必执行,一般用来清理资源,释放连接等。然而有以下几种情况不会执行到这里的代码。

  • 代码执行流程未进入try代码块。
  • 代码在try代码块中发生死循环、死锁等状态。
  • 在try代码块中执行了System.exit()操作。

三、异常处理

3.1 处理异常的最佳实践

  • 当需要向上抛出异常的时候,需根据当前业务场景定义具有业务含义的异常,优先使用行业内定义的异常或者团队内部定义好的。例如在使用dubbo进行远程服务调用超时的时候会抛出DubboTimeoutException,而不是直接把RuntimeException抛出。
  • 请勿在finally代码块中使用return语句,避免返回值的判断变得复杂。
  • 捕获异常具体的子类,而不是Exception,更不是throwable。这样会捕获所有的错误,包括JVM抛出的无法处理的严重错误。
  • 切记更别忽视任何一个异常(catch住了不做任何处理),即使现在能确保不影响逻辑的正常运行,但是对于将来谁都无法保证代码会如何改动,别给自己挖坑。
  • 不要使用异常当作控制流程来使用,这是一个很奇葩也很影响性能的做法。
  • 清理资源,释放连接等操作一定要放在finally代码块中,防止内存泄漏,如果finally块处理的逻辑比较多且模块化,我们可以封装成工具方法调用,代码会比较简洁。

3.2 转转异常监控和上报的实践

先上一段伪代码看看大家在处理异常时一般都做了什么

java public String doSomething(String arg) { try { return method1(arg); } catch (Exception e) { log.error("call method1 error msg={}", e.getMessage()); // do 报警相关逻辑 } finally { // do close resource } return null; }

一般我们会在catch代码块中进行错误日志的打印,注册一些埋点信息,或者调用监控报警组件进行错误告警信息的上报,并利用告警系统通知到研发人员进行错误的排查和定位。

那我们分析一下上面这种写法有没有什么可以优化的地方,所有的catch代码块中我们进行的操作都是类似的,所以我们可以通过一种什么方式进行统一的处理呢?看到这里大家肯定会想,可以利用aop切面进行进行统一的封装和处理,这是一个方案,但是这个需要我们去编写切面,

我们还可以采用一种非侵入的统一的方式进行处理,这就是字节码增强技术。在字节码增强方面主流的有三个框架;ASMJavassistByteCode,各有优缺点按需选择。本文不再展开讲各自的优缺点,我们选择的是ASM+javaAgent来实现 .

ASM,是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。说白了asm是直接通过字节码来修改class文件。另外除了 asm 可以操作字节码,还有javassist和Byte-code等,他们比 asm 要简单,但是执行效率还是 asm 高。因为 asm 是直接使用指令来控制字节码。

JavaAgent,是一种探针技术可以通过 premain 方法,在类加载的过程中给指定的方法进行字节码增强。其实你的每一个类最终都是字节码指令的执行,而这种增强后的方法就可以输出我们想要的信息。这就相当于你硬编码时候输出了一些方法的耗时,日志等信息。

3.3 ASM+javaAgent实现异常信息的统一上报

编写javaAgent

java public static void premain(String arg, Instrumentation inst) { LOG.info("******** AgentApplication.premain executing, String Param: {}********", arg); inst.addTransformer(new CustomClassFileTransformer(), true); LOG.info("******** AgentApplication premain executed ********"); }

编写CustomClassFileTransformer实现ClassFileTransformer重写transform方法,判断需要进行增强的类

java @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { // 判断当前类是否需要增强 if (!needEnhance(className)) { return classfileBuffer; } try { ClassReader cr = new ClassReader(className); ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); CustomClassVisitor classVisitor = new CustomClassVisitor(cw); cr.accept(classVisitor, ClassReader.EXPAND_FRAMES); return cw.toByteArray(); } catch (IOException e) { LOG.warn("desc=CustomClassFileTransformer.transform, className:{} Exception:{}", className, e); } return classfileBuffer; }

编写CustomClassVisitor继承 ClassVisitor对字节码方法进行增强

java @Override public MethodVisitor visitMethod(int access, String methodName, String desc, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, methodName, desc, signature, exceptions); // 跳过忽略的方法,如构造方法 <init>, toString等 if (!SKIP_METHODS.contains(methodName) && mv != null) { mv = new CustomMethodVisitor(mv, className, methodName); } return mv; }

编写CustomMethodVisitor继承MethodVisitor重新 visitTryCatchBlock和visitLineNumber进行try-catch的问题定位和处理增强。

```java @Override public void visitTryCatchBlock(Label start, Label end, Label handler, String type) { exceptionHandlers.add(handler); super.visitTryCatchBlock(start, end, handler, type); }

    @Override
    public void visitLineNumber(int line, Label start) {
        if (exceptionHandlers.contains(start)) {
            ExceptionProcessor.injectHandleLogic(this, className, methodName, line);
        }
        super.visitLineNumber(line, start);
    }
/**
 * 注入处理逻辑
 * 调用异常处理方法 {@link ExceptionProcessor#process(Throwable, String, String, int)}
 *
 * @param visitor    方法visitor
 * @param className  类名
 * @param methodName 方法名
 * @param lineNumber catch块开始的 行号(一个方法中可能由多个catch块,索引引入lineNumber,精准标识别异常位置)
 */
public static void injectHandleLogic(MethodVisitor visitor, String className, String methodName, int lineNumber) {
    visitor.visitInsn(DUP);
    visitor.visitLdcInsn(className);
    visitor.visitLdcInsn(methodName);
    visitor.visitLdcInsn(lineNumber);
    visitor.visitMethodInsn(INVOKESTATIC, EXCEPTION_HANDLE_CLASS, EXCEPTION_HANDLE_METHOD, EXCEPTION_HANDLE_PARAM, false);
}

/* * 处理异常的逻辑 * 这个方法里我们可以明确知道抛异常的类、方法、行号等异常信息,方便组装上报信息方便研发定位 * 方法签名请勿修改 {@link ExceptionProcessor#injectHandleLogic(MethodVisitor, String, String, int)}中调用 * * @param exception 要处理的异常 * @param className 类名 * @param methodName 方法名 * @param lineNumber catch块开始的行号 * @param 异常范型 / public static void process(T exception, String className, String methodName, int lineNumber) { try { className = className.replace(File.separator, "."); String itemName = generateItemNameAfterFilter(exception.getClass().getSimpleName(), className, methodName); if (StringUtil.isEmpty(itemName)) { return; } // 告警逻辑的处理 ZzMonitor.sumWithAlarm(itemName, 1, String.valueOf(lineNumber), true); } catch (RuntimeException e) { LOG.error("desc=ExceptionProcessor.process error", e); } } ```

至此编码基本结束,那么接下来我们看如何通过maven将我们写的代码打包成可供别人引用的javaAgent

xml <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.0.0</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <manifestEntries> <!--修改为你自己的类名全路径--> <Premain-Class>com.****.AgentApplication</Premain-Class> <Can-Retransform-Classes>true</Can-Retransform-Classes> <Can-Redefine-Classes>true</Can-Redefine-Classes> </manifestEntries> </transformer> </transformers> </configuration> </execution> </executions> </plugin>

使用javaAgent

bash -javaagent:./lib/auto-monitor-alarm-1.0.0.jar

至此我们利用ASM+javaAgent实现了try-catch的自动监控,上报,且对代码0侵入。

看一下我司实现的效果

四、总结

综上所述,使用了 JavaAgent 结合 ASM 对监控方法做了字节码增强,可以在方法执行的捕捉抛出异常的代码,并上报信息方便研发人员对错误信息的识别和关注,提高项目的预警机制,驱动研发人员主动发现和处理线上的问题,提高代码质量,维护系统的稳定,研发的任务不仅仅是代码编写,也要对自己编写的代码,进行异常的关注,有效且主动识别运行中的异常,也是我们工作中的一部分,

参考

https://www.infoq.cn/article/javaagent-illustrated

https://www.baeldung.com/java-asm​


作者简介

顾文昌,转转中台支付中心研发工程师