书接上文。此前讲到了通过常量池结合正则表达式获取所有的错误码。下面主要是判断该如何确定各个类调用的是哪个 Logger,以及如果调用的是 error 和 warn 方法的话,是否正确的使用了带错误码的参数。
判断是否使用正确的日志方法
Javap 的输出
此文仍以上篇文章的org.apache.dubbo.common.DeprecatedMethodInvocationCounter.onDeprecatedMethodCalled(String)
的输出的节选为例。该类在最近在 Dubbo 的主线被删除,请参考参考链接 [1] 中的文件):
1 | public final class org.apache.dubbo.common.DeprecatedMethodInvocationCounter |
以常量池为切入点查找
考虑到错误码 Logger 的接入是以整个类为单位的,我们可以简化成只扫描这个类是否使用了错误码 Logger 类。
据 Java 虚拟机规范 [2]:
Java Virtual Machine instructions do not rely on the run-time layout of classes, interfaces, class instances, or arrays. Instead, instructions refer to symbolic information in the
constant_pool
table.
可知常量池盛装了 .class 文件中所有的符号(比如类的引用)等等。
因此我们只需要查询 .class 文件中间的常量池里头是否存在不符合要求的 Logger 类调用即可。
具体思路
鉴于 Logger 的方法是接口方法,在 JVM 中是使用 invokeinterface
调用的。其接受的常量池的结构体是 InterfaceMethodref
。从 JVM 规范可知 InterfaceMethodref
的结构如下 [3]:
1 | CONSTANT_InterfaceMethodref_info { |
tag
是固定值,代表该项常量池项目是接口方法的引用。class_index
是对应着常量池中接口信息的索引,它对应着我们要调用方法所在的接口。name_and_type_index
对应着常量池中的CONSTANT_NameAndType_info
结构的索引,代表方法签名。
因此我们只需要确认 .class 文件里头的所有的 CONSTANT_InterfaceMethodref_info
的内容即可。
Javassist 的实现
我们可以仿照上篇文章的 Javassist 的用法(以下均为org.apache.dubbo.errorcode.extractor.JavassistConstantPoolErrorCodeExtractor#getIllegalLoggerMethodInvocations
这一方法的讲述)[4]:
首先找到
CONSTANT_InterfaceMethodref_info
在 Javassist 中对应的类javassist.bytecode.InterfaceMethodrefInfo
通过反射调用
ConstPool.getItem(int)
获得所有的常量池的内容(详见前一篇文章的JavassistUtils.getConstPoolItems
),并通过 Stream 筛选和 map 出所有的InterfaceMethodrefInfo
的实例所对应的常量池索引:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22private static final Class INTERFACE_METHOD_INFO;
static {
try {
INTERFACE_METHOD_INFO = Class.forName("javassist.bytecode.InterfaceMethodrefInfo");
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
// ...
public List<MethodDefinition> getIllegalLoggerMethodInvocations(String classFilePath) {
List<Object> constPoolItems = JavassistUtils.getConstPoolItems(classFile.getConstPool());
List<Integer> interfaceMethodRefIndices = constPoolItems.stream()
.filter(x -> x.getClass() == INTERFACE_METHOD_INFO)
.map(this::getIndexFieldInConstPoolItems)
.collect(Collectors.toList());
// ...
}另附
getIndexFieldInConstPoolItems
和ReflectUtils.getDeclaredFieldRecursively
[5] 的代码:1
2
3
4
5
6
7
8
9
10
11
12
13// 为了查找出 Javassist 对应的常量池类实例的 index 的 Field,以确定其在常量池的索引。
private int getIndexFieldInConstPoolItems(Object item) {
// 鉴于 index 这个 Field 是在 javassist.bytecode.ConstInfo 中定义的。
// 此处其实可以使用 javassist.bytecode.ConstInfo 所对应的 Class 对象直接获取。
// 但是原本的写法是从该类一直向父类找 index 这个 Field。
Field indexField = ReflectUtils.getDeclaredFieldRecursively(item.getClass(), "index");
try {
return (int) indexField.get(item);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
1 | public static Field getDeclaredFieldRecursively(Class cls, String name) { |
遍历第 2 步所得出的索引,通过 Javassist 的常量池 API 回表查找,同时记录该类所有的方法调用信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35// 接上文 getIllegalLoggerMethodInvocations
List<MethodDefinition> methodDefinitions = new ArrayList<>();
for (int index : interfaceMethodRefIndices) {
ConstPool cp = classFile.getConstPool();
MethodDefinition methodDefinition = new MethodDefinition();
methodDefinition.setClassName(
// 确定 invokeinterface 的接口名
cp.getInterfaceMethodrefClassName(index)
);
methodDefinition.setMethodName(
// 通过常量池索引确定参数名
cp.getUtf8Info(
// 获取方法签名名的常量池索引
cp.getNameAndTypeName(
// 获取方法签名所在的常量池索引
cp.getInterfaceMethodrefNameAndType(index)
)
)
);
methodDefinition.setArguments(
// 通过常量池索引确定方法名
cp.getUtf8Info(
cp.getNameAndTypeDescriptor(
cp.getInterfaceMethodrefNameAndType(index)
)
)
);
methodDefinitions.add(methodDefinition);
}另提供 MethodDefinition 类供参考(部分内容通过注解省略。源代码并没用 Lombok。)[6]:
1
2
3
4
5
6
7
8
public class MethodDefinition {
private String className;
private String methodName;
private String arguments;
}通过比对调用的方法的类和方法签名来确定是否满足需求。鉴于合符要求的错误码 Logger 的 warn 和 error 调用至少要四个参数,所以只需要确定调用那两个方法的参数个数即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 接上文 getIllegalLoggerMethodInvocations
// 确定是否是日志类的方法调用
Predicate<MethodDefinition> legacyLoggerClass = x -> x.getClassName().equals("org.apache.dubbo.common.logger.Logger");
Predicate<MethodDefinition> errorTypeAwareLoggerClass = x -> x.getClassName().equals("org.apache.dubbo.common.logger.ErrorTypeAwareLogger");
Predicate<MethodDefinition> loggerClass = legacyLoggerClass.or(errorTypeAwareLoggerClass);
return methodDefinitions.stream()
.filter(loggerClass)
// 确定是否 warn, error
.filter(x -> x.getMethodName().equals("warn") || x.getMethodName().equals("error"))
// 若是 warn 和 error 级别则确定参数是否小于四个,如果是则代表没有挂上错误码。
.filter(x -> x.getArguments().split(";").length < 4)
.collect(Collectors.toList());通过返回的值确定哪些类里头调用了没有错误码的 Logger 调用。
以方法调用为切入点查找
问题
上述通过判定类常量池的做法虽然可以确定哪个类调用了哪些不符合要求的 Logger 方法调用,但是维护者也需要定位到具体是是哪个方法没有调用到合符要求的 Logger 方法。因此在这里需要以方法调用为切入点查找 Logger 调用。
具体思路
遍历方法
首先我们需要遍历所有方法(通过 ClassFile.getMethods()
),并确定 .class 文件中每个方法的具体代码的位置。据 JVM 规范 [7],.class
文件中,方法的具体实现是存放到每个方法的属性表中的 Code 属性,所以在 Javassist 中应使用 Class File API 获取每个方法的 Code 属性(即 getCodeAttribute()
),因此有:
1 | ClassFile classFile = JavassistUtils.openClassFile("..."); |
拿到 Code 属性之后,遍历每条指令,直到 invokeinterface
出现就开始比对。
那么怎么遍历每条指令呢?
Javassist 的方法的字节码指令的遍历 API
这个时候我们可以使用 CodeIterator
来遍历每一条字节码,而这个对象可以通过 CodeAttribute.iterator()
获取。
CodeIterator 的用法与迭代器 Iterator 相似(但不是 Iterator 的实现类),都是使用 hasNext
方法确定是否还有字节码,next
方法拿到下一个字节码指令的字节相对于 Code 属性表最开始的指令的偏移量。byteAt
方法可以拿到偏移量所在位置对应的字节。
题外话:为什么是 Code 表的偏移量?
- 在
.class
文件中,Code 是方法的属性。- 在获取 CodeIterator 的
CodeAttribute.iterator()
中,调用了 CodeIterator 的构造方法,这个构造方法获取了 CodeAttribute 的info
这一 Field (即 Code 属性表的原始字节码),并赋值给CodeIterator.byteCode
属性。- 通过 byteAt 方法可知它读取了 byteCode 数组,下标是给定的 index,因此可以看出 index 是相对于 Code 表的偏移量,而非相对于字节码文件的偏移量。
在 Javassist 中有一个数组可以用来对应指令名称和指令的字节码的表示,为 Mnemonic.OPCODE
。我们可以用它比对指令的名称。[8] (此处也可以通过直接比对具体指令的字节以提高效率。)
鉴于抽象方法没有 Code 属性表 [7],因此需要通过判断排除这类方法以防 NPE。
整合上述思路并用代码表示,如下:
1 | ClassFile classFile = JavassistUtils.openClassFile("..."); |
Invokeinterface 的具体参数的获得
为了进一步确定接下来的行为,我们不妨参考下 JVM 规范中 invokeinterface
指令的参数 [9]:
不难看出调用的接口方法的方法签名(即 CONSTANT_InterfaceMethodref_info
)的常量池索引是 (indexbyte1 << 8) | indexbyte2
这一表达式的结果。且 indexbyte1 就在 invokeinterface 这一指令的字节码的下一个字节。故有:
1 | // 前略。 |
再依照“以常量池为切入点查找”一节的办法拿到具体方法签名,并通过 MethodInfo.toString()
(或者 MethodInfo.getName()
和 MethodInfo.getDescriptor()
)获取发起调用的方法的签名,再做好记录,做好记录全部实现如下:
1 | ClassFile classFile = JavassistUtils.openClassFile("..."); |
若要筛选出不合格的 Logger 方法调用,只需要筛选 methodDefinitions 这一结果,或在遍历方法调用指令之时完成筛选即可。
以上解决了如何获取所有的 Logger 方法的调用的问题。但是,一个新需求到来 —— 确定不合格的 Logger 调用所在的行号。该怎么办呢?还请看下回分解。
备注:
有关环境:
(a) 命令行 Maven 运行于 OpenJDK
19(本文初稿时)22(重新整理时)环境下。(b) 对于 Dubbo 项目 IDEA JDK 配置为基于 OpenJDK 8 的 GraalVM 21.3.1 的 JDK。
(c) 在编写错误码 Logger 调用自动检测程序时,使用的是 OpenJDK 19 版本,但调节了兼容性设置到 JDK 8。
(d) 在 Dubbo CI 运行时使用 Azul OpenJDK 17。
本文写作时的 JDK 的最新版本为
21(本文初稿时)23(重新整理时),本文所有的有关 JDK 的参考文献均以该版本为参考。
引用和参考:
[1] Apache Dubbo Source Code - org.apache.dubbo.common.DeprecatedMethodInvocationCounter
[2] Java Virtual Machine Specification - Chap. 4 - Constant Pool section
https://docs.oracle.com/javase/specs/jvms/se23/html/jvms-4.html#jvms-4.4
[3] Java Virtual Machine Specification - Chap. 4 - The ‘CONSTANT_Fieldref_info’, ‘CONSTANT_Methodref_info’, and ‘CONSTANT_InterfaceMethodref_info’ Structures and Static Constraints section
https://docs.oracle.com/javase/specs/jvms/se23/html/jvms-4.html#jvms-4.4.2
[4] Apache Dubbo Error Code Inspector Source Code (in dubbo-test-tools) - org.apache.dubbo.errorcode.extractor.JavassistConstantPoolErrorCodeExtractor
[5] Apache Dubbo Error Code Inspector Source Code (in dubbo-test-tools) - org.apache.dubbo.errorcode.util.ReflectUtils
[6] Apache Dubbo Error Code Inspector Source Code (in dubbo-test-tools) - org.apache.dubbo.errorcode.model.MethodDefinition
[7] Java Virtual Machine Specification - Chap. 4 - The Code Attribute section
https://docs.oracle.com/javase/specs/jvms/se23/html/jvms-4.html#jvms-4.7.3
[8] Javassist API Docs - javassist.bytecode.Mnemonic
https://www.javassist.org/html/javassist/bytecode/Mnemonic.html
[9] Java Virtual Machine Specification - Chap. 4 - invokeinterface section
https://docs.oracle.com/javase/specs/jvms/se23/html/jvms-4.html#jvms-4.10.1.9.invokeinterface (Page 525 in the PDF version.)
[ TART - Dubbo (ECI) - T3 - R5,6,7 ] (Mainly @FB (M))
(SNa - ECI, SNu - 2)