基于字节码的统一异常上报实践
[toc]
一、前言
在我们的日常工作中,总会出现各种各样的“错误”和突发的“异常”。无论我们做了多少准备,多少测试,这些异常总会在某个时间点出现,如果处理不当或是不及时,往往还会导致其他新的问题出现。所以我们要时刻注意这些陷阱以及需要一套“最佳实践”来建立起一个完善的异常处理机制。那么我们如何快速、准确地定位异常的发生的地方,和一些简易的异常信息方便研发定位问题?下面跟随我来看一下转转中台是如何优雅地对异常进行统一的监控和上报处理的。
二、异常介绍
2.1 认识异常
程序在运行时,发生了意料之外的事情,阻止了程序的正常执行,这种情况被称为异常。出现异常后,往往需要人工介入处理,否则会扩大异常的影响面。通常处理异常时,需要解决以下 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 代码块:不管是否有异常,都必执行,一般用来清理资源,释放连接等。然而有以下几种情况不会执行到这里的代码。
三、异常处理
3.1 处理异常的最佳实践
当需要向上抛出异常的时候,需根据当前业务场景定义具有业务含义的异常,优先使用行业内定义的异常或者团队内部定义好的。例如在使用 dubbo 进行远程服务调用超时的时候会抛出 DubboTimeoutException,而不是直接把 RuntimeException 抛出。
请勿在 finally 代码块中使用 return 语句,避免返回值的判断变得复杂。
捕获异常具体的子类,而不是 Exception,更不是 throwable。这样会捕获所有的错误,包括 JVM 抛出的无法处理的严重错误。
切记更别忽视任何一个异常(catch 住了不做任何处理),即使现在能确保不影响逻辑的正常运行,但是对于将来谁都无法保证代码会如何改动,别给自己挖坑。
不要使用异常当作控制流程来使用,这是一个很奇葩也很影响性能的做法。
清理资源,释放连接等操作一定要放在 finally 代码块中,防止内存泄漏,如果 finally 块处理的逻辑比较多且模块化,我们可以封装成工具方法调用,代码会比较简洁。
3.2 转转异常监控和上报的实践
先上一段伪代码看看大家在处理异常时一般都做了什么
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 切面进行进行统一的封装和处理,这是一个方案,但是这个需要我们去编写切面,
我们还可以采用一种非侵入的统一的方式进行处理,这就是字节码增强技术。在字节码增强方面主流的有三个框架;ASM
、Javassist
、ByteCode
,各有优缺点按需选择。本文不再展开讲各自的优缺点,我们选择的是 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
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 方法,判断需要进行增强的类
@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 对字节码方法进行增强
@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 的问题定位和处理增强。
@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 <T> 异常范型
*/
public static <T extends Throwable> 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
<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
-javaagent:./lib/auto-monitor-alarm-1.0.0.jar
复制代码
至此我们利用 ASM+javaAgent 实现了 try-catch 的自动监控,上报,且对代码 0 侵入。
看一下我司实现的效果
四、总结
综上所述,使用了 JavaAgent
结合 ASM
对监控方法做了字节码增强,可以在方法执行的捕捉抛出异常的代码,并上报信息方便研发人员对错误信息的识别和关注,提高项目的预警机制,驱动研发人员主动发现和处理线上的问题,提高代码质量,维护系统的稳定,研发的任务不仅仅是代码编写,也要对自己编写的代码,进行异常的关注,有效且主动识别运行中的异常,也是我们工作中的一部分,
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。关注公众号「转转技术」(综合性)、「大转转 FE」(专注于 FE)、「转转 QA」(专注于 QA),更多干货实践,欢迎交流分享~
参考
https://www.infoq.cn/article/javaagent-illustrated
https://www.baeldung.com/java-asm
作者简介
顾文昌,转转中台支付中心研发工程师
评论