此项目使用 java 静态代码分析(底层使用 javaparser 能力),分析 .java 文件,从指定方法入口开始,找到项目中方法的层层调用链路
项目若想要立即试试,来看看效果,可以执行下 demo 包下的 Main.java 即可,你可以结合着看 功能概述 和 Demo 快速先跑起来 来快速阅读
建议:对于 java 项目,如果只能拿到 .java 文件无法拿到 .class 文件,解析方法调用链可以使用 javaparser 来解析。而若能拿到 .class 字节码文件,那么可以使用 ASM 来解析方法调用链,因为 ASM 可能解析的更全(但处理也更复杂)。
项目是在做啥:使用 javaparser 的能力,从指定方法入口开始,找到项目中,该方法一直往下调用的所有方法调用链
【javaparser 和 ASM】
一般企业在做精准测试时,往往会使用 javaparser 结合 ASM 做方法调用链分析。由于 javaparser 做静态代码分析解析的是 .java 文件来构造 AST,而 ASM 则是解析 .class 文件来进行方法调用链分析,其实相比 javaparser 而言,ASM 要解析的更完整一些,且性能更好,但是 ASM 更偏低层能力,用户用起来相对更麻烦
【不好解析的地方】 相比 ASM,其实 javaparser 很多场景不太好解析:
- lambda 表达式的场景:javaparser 解析的很有限
- 多态场景:由于 javaparser 做静态代码分析,而多态在运行时确定,所以 javaparser 无法真正的解析多态场景,可能只能通过 coding 来定一些规则来猜测
- 第三方依赖:项目中往往有很多第三方依赖,如果引入真实的这些依赖的
.java文件的路径,那么这些依赖对应的类也无法解析的完整 - ...
【性能上】 ASM 其实性能要更好,不管是解析速度还是内存占用上
【易用性上】 相比 ASM,javaparser 无疑占优
src/main/java/org/example/demo 下的所有内容都是为了演示项目效果,即 src/main/java/org/example/demo 下的整个内容作删除也不会影响该项目
你可以执行 src/main/java/org/example/demo/Main.java 来看打印出的方法调用链路:
Level1#level1_func8(String, int)
├── Level2#Level2()
└── Level2#level2_func8()
├── Level3#Level3()
├── PrintStream#println(String)
└── Level3#level3_func8(boolean)
└── StringUtils#isBlank(null)
核心是创建解析器(指定项目路径、符号解析路径、自定义查找规则、以及是否允许多个Dag连通):
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, preciseRule, isConnected);分析某个方法往下层的调用链(传入全限定的类名,方法名,以及方法参数):
DagNode rootNode = resolver.resolveCallChain(startClass, startMethod, methodParams);当然最简单的方式,你可以直接这样写(但它会采用一些默认的规则):
// 默认项目路径,符号解析路径都是 sourceRoot,默认用 PreciseRule 是最常规模式,默认不允许多个 Dag 连通
CallChainResolver resolver = new CallChainResolver(sourceRoot);
// 查找指定方法调用链
DagNode rootNode = resolver.resolveCallChain(startClass, startMethod, methodParams);方法调用关系简单想可能是一个树形结构,比如一颗二叉树,方法 A1 -> B1, B2 然后 B1 -> C1, C2
但其实方法调用与其说像二叉树,其实更像一颗多叉树,因为方法内存在众多方法的调用关系
更进一步,方法调用仅仅是多叉树吗?不一定,因为多叉树要求任何节点有且仅有一个父亲节点,但其实方法调用可能出现如下(左)结构,除非同时被多个方法调用的 C1 方法你需要弄出新的对象,如下(右)结构,但这无疑增加了存储成本,所以此项目使用的如下(左)结构,一个 Dag 的结构
由于 Dag 要求是没有环,但是方法调用可能存在环,比如递归,因此 Dag 结构似乎也不满足,我们可以把 Dag 做下改造,依然将方法调用链构造成一个 Dag,但是区别是当遍历调用链时,若发现存在循环调用的方法后,给其打上一个"已出现循环"的标记,而"已出现循环"的标记的方法节点可作为 Dag 中的叶子节点,这样就终止了它的“环”,结构如下
我们通过如下代码来创建一个方法调用解析器,并查找 2 个方法的调用链:
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, preciseRule, isConnected);
DagNode A1 = resolver.resolveCallChain(startClass, startMethod, methodParams);
DagNode A2 = resolver.resolveCallChain(startClass2, startMethod2, methodParams2);其中 isConnected 表示是否连通,如果为 true 表示当使用 resolver 多次寻找不同方法调用链时,最终会自动把多个独立连通的 Dag 组合成一个大的连通 Dag(前提是多个独立 Dag 中有共同的方法)。如果为 false 则表示每个方法的调用链都是独立的,不会被组合成一个大的连通 Dag,如下:
若 isConnected = true,我们希望其最后连通,很多时候当我们遍历这个 Dag 找到某个中间节点时,我们希望能从中间节点快速往上查找,来找到父节点的内容,因此这个 Dag 可能需要拥有指向父节点的指针:
但它并不是环,因为当为了查找方法调用链时,只能往“下”,这一个方向去查找,且碰到重复出现的方法时,会将其打标,作为叶子节点来特殊处理
最终我们构造的方法调用 Dag 有类似如下的结构,即对应 src/main/java/org/example/node/DagNode.java 结构
src/main/java/org/example/node/DagNode.java 以方法为节点核心,每个节点其实就是表示一个方法,这样看来 DagNode 更应该叫 FuncNode
其中包含 6 大部分信息
- 方法所属声明类的包名
- 方法所属实现类的包名
注意这里的声明类和实现类,是在多态场景下,比如:
User user = new Student();
user.getName();getName() 方法在 User 类和 Student 中都含有,但实际执行时执行的是 Student 逻辑,那么 getName() 方法所属的声明类是 User 类,所属的实现类是 Student 类
- ClassOrigin:类来源,项目/jdk/依赖
- 声明类的简单类名:不带包名,不包含泛型,若是内部类则为
Aaa$Bbb的形式 - 实现类的简单类名:不带包名,不包含泛型,若是内部类则为
Aaa$Bbb的形式 Map<String, Map<String, Object>>:类的注解,数据如:{ "注解名1": { "参数名1": "String", "参数名2": "int" }, "注解名2": {} }List<Keyword>:类的修饰符,如 public, final, abstract 等,修饰符枚举使用 javaparser 中的com.github.javaparser.ast.Modifier.Keyword类- ClassDeclaration:类声明,如 class/interface/enum/annotation/record
- MethodBelongs2Class:推测的所找到的方法属于的实现类或者声明类,还是属于某个祖先类,亦或者无法判定
- 类的其他属性等
- 同类注解一样,也有方法注解
- FuncCate:方法的分类,如普通方法/构造方法/main方法
List<Keyword>和类修饰符类似- 方法名
- 方法参数类型以及方法参数所属包名:方法参数类型仅仅是简单类型,不包含泛型
- 方法参数返回值类型及返回值所属包名:方法参数返回值类型仅仅是简单类型,不包含泛型
- 方法等其他属性等
一般的节点循环调用都会标记为 false,当出现了循环调用,此字段为 true
List<DagNode> children
List<DagNode> parents
src/main/java/org/example/resolver/CallChainResolver.java 内:
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, preciseRule, isConnected);
DagNode A1 = resolver.resolveCallChain(startClass, startMethod, methodParams);
DagNode A2 = resolver.resolveCallChain(startClass2, startMethod2, methodParams2);当 isConnected == true 表示 A1,A2 如果有相同的节点,则二者能连通;当 isConnected == false 表示 A1,A2 是独立的,不会被组合成一个大的连通 Dag。
如果你使用默认的构造器,则默认不连通:
CallChainResolver resolver = new CallChainResolver(sourceRoot);在创建解析器 CallChainResolver resolver 时,你可以传入一个 IPreciseRule 实现类,来控制调用链查找规则。
IPreciseRule normalRule = new NormalRule();
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, normalRule, isConnected);其中 preciseRule 你需要自己创建,有如下 4 种方式
// 走默认规则,最简单直接的方式
IPreciseRule normalRule = new NormalRule();
// 走 warnMod 规则
IPreciseRule warnModRule = new WarnModRule();
// 走 dangerMod 规则
IPreciseRule dangerModRule = new DangerModRule();
// 走全部自定义规则,MyCustomRule 是你自己编写的,需要实现 CustomRule 类
IPreciseRule myCustomRule = new MyCustomRule();项目中已有的四种规则分别表示的限制:
interface IPreciseRule 规则接口
├── class NormalRule:限制最强,只允许项目中的类中的方法被构造到 Dag,最大遍历深度 20 层
├── class WarnModRule:限制减弱,允许项目中和第三方依赖类中的方法被构造到 Dag,最大遍历深度 20 层。内存可能出现风险
├── class DangerModRule:限制更弱,允许所有方法被构造到 Dag 中,包括项目,第三方依赖,jdk 中的类中的方法,无最大遍历深度。内存更可能出现风险
└── abstract class CustomRule:自定义规则,其中明确的更细致的规则标准,用户如果像用自定义规则,需要实现此类
如果需要自定义构造过滤节点的规则,你需要实现 CustomRule 类,并且 Override 其中的 setMaxLayer()/setPreciseModel()/setFilterClasses()/setThrownClasses() 方法,分别表示:
setMaxLayer():自行设置方法调用最大层数限制,必须赋值给super.maxLayersetPreciseModel():自行设置项目中的,第三方依赖,jdk 中的类中的方法是否被构造到 Dag 中,必须赋值给super.preciseModelsetFilterClasses():自行设置过滤的类白名单,即如果白名单存在某些类的全限定名,则这些类才允许被构造进 Dag,特殊场景,如果白名单为空则表示所有类都允许被构造进 Dag,最后必须赋值给super.filterClassessetThrownClasses():自行设置过滤的类黑名单,即如果黑名单存在某些类的全限定名,则这些类不会允许被构造进 Dag,黑名单比白名单优先级更高,最后必须赋值给super.thrownClasses
如下是自定义过滤规则,实现了 CustomRule 抽象类,其中自定义了各种细致规则
public class MyCustomRule extends CustomRule {
// 自定义规则:调用最大层数限制
@Override
public void setMaxLayer() {
super.maxLayer = 20;
}
// 自定义规则:项目中的,第三方依赖,jdk
@Override
public void setPreciseModel() {
super.preciseModel = PreciseModel.DANGER_MOD;
}
// 自定义规则:设置过滤的类白名单
@Override
public void setFilterClasses() {
super.filterClasses = new ArrayList<>();
}
// 自定义规则:设置过滤的类黑名单
@Override
public void setThrownClasses() {
super.thrownClasses = new ArrayList<>();
}
}IPreciseRule 中的过滤规则中
src/main/java/org/example/rule/PreciseModel.java 有 3 种枚举:
PreciseModel.NORMAL_MOD:只允许项目中的类中的方法被构造到 DagPreciseModel.WARN_MOD:允许项目中和第三方依赖类中的方法被构造到 DagPreciseModel.DANGER_MOD:允许所有方法被构造到 Dag 中,包括项目,第三方依赖,jdk 中的类中的方法
而过滤黑名单 thrownClasses 和白名单 filterClasses 的写法则比较丰富,支持尾部通配符的写法:
com.abc.*:表示 com.abc 包下的所有类com.abc.User:表示 User 类com.abc.Student*:表示 com.abc.Student* 这样的类
实际项目中,往往需要通过注解来定位到要进行方法调用链分析的入口方法处,可以使用 src/main/java/org/example/resolver/entrance/AnnotationEntrance.java 中的能力,如下所示:
String classAnn = "@RestController"; // 写成 "RestController" 亦可
String methodAnn = "@RequestMapping"; // 写成 "RequestMapping" 亦可
String path = "/user/xxx/yyy/project/src/main/java/com/example/name/controller" // 在指定路径下递归遍历其下所有包
AnnotationEntrance ann = new AnnotationEntrance();
List<MethodCallInfo> methodCallInfos = ann.findEntranceMethod(path, classAnn, methodAnn);目前 classAnn 和 methodAnn 必须要存在这样的注解,不能为 null 或 ""
返回的 methodCallInfos 含有找到的所有入口方法(其中 declClassName 和 realClassName 相同)
src/main/java/org/example/resolver/find/TraverseDag.java 中专门用来遍历 Dag
// 指定节点
DagNode root = ...; // 伪代码
// 一批目标节点
List<MethodCallInfo> methodCallInfos = ...; // 伪代码
TraverseDag traverseDag = new TraverseDag();
Set<DagNode> nodes = traverseDag.findDownSpecificNodes(root, methodCallInfos);查找方式是通过类名+方法名+参数完全匹配去寻找
// 指定节点
DagNode specificNode = ...; // 伪代码
TraverseDag traverseDag = new TraverseDag();
Set<DagNode> roots = traverseDag.findUpRootsFromSpecificNode(specificNode);// 指定节点
DagNode root = ...; // 伪代码
// 一批目标节点
List<MethodCallInfo> methodCallInfos = ...; // 伪代码
TraverseDag traverseDag = new TraverseDag();
Map<DagNode, Set<DagNode>> map = traverseDag.findDownSpecific2RootMap(root, methodCallInfos);返回的 map 中:
- map.key = 目标被查找的节点
- map.value = 目标被查找的节点的所有在 Dag 的最顶层的根父节点集合
// 指定节点
DagNode specificNode = ...; // 伪代码
TraverseDag traverseDag = new TraverseDag();
Set<DagNode> roots = traverseDag.findAllRoots(specificNode);本质上通过 specificNode 会向上和向下遍历,最终能遍历完所有的节点,来找到 Dag 中所有的根节点
当然 src/main/java/org/example/resolver/find/TraverseDag.java 中也提供了 preOrderRecursive()/postOrderRecursive() 先序/后序遍历的基础写法,完全也可以按照自己的方式来遍历 DagNode
src/main/java/org/example/resolver/print/PrintDag.java 中专门用来打印 Dag 图
// 指定节点
DagNode dagNode = ...; // 伪代码
// 从上到下打印整个 Dag 方法调用
PrintDag printDag = new PrintDag();
printDag.printSimpleCallChains(dagNode);打印出的每处方法的结构如 User#func2(String, Object, int),不带有包名
【jdk 中的方法场景】
入口方法 org.example.demo.callchain.Level1#level1_func1,打印结果:
Level1#level1_func1()
└── PrintStream#println(String)
【第三方依赖方法场景】
入口方法 org.example.demo.callchain.Level1#level1_func2,打印结果:
Level1#level1_func2()
└── StringUtils#isBlank(String)
【项目中的方法场景】
入口方法 org.example.demo.callchain.Level1#level1_func3,打印结果:
Level1#level1_func3()
└── Level2#Level2()
【循环调用场景】
入口方法 org.example.demo.callchain.Level1#level1_func4,打印结果:
Level1#level1_func4()
├── Level2#Level2()
└── Level2#level2_func4()
├── Level1#Level1()
└── Level1#level1_func4() [循环调用]
【检测泛型是否会含有】
入口方法 org.example.demo.callchain.Level1#level1_func5,打印结果:
Level1#level1_func5()
├── ArrayList#ArrayList()
└── List#add(Object)
【多态场景】
入口方法 org.example.demo.callchain.Level1#level1_func6,打印结果:
Level1#level1_func6()
├── Level2#Level2()
└── ILevel2#level2_func6()
└── PrintStream#println(String)
打印出的类名#方法名中类名其实对应是方法所属的声明类名,那如果是 Level2() 这样的构造方法呢,该命名的构造方法因为只有实现类有,因此即使这样声明 ILevel2 level2 = new Level2();,构造方法对应的声明类和实现类都是 Level2,于是上方展现 Level2#Level2()
【类/方法中各种信息内容展示场景】
可以断点查看 DagNode 中各种信息是否包含
入口方法 org.example.demo.callchain.Level1#level1_func7,打印结果:
Level1#level1_func7()
├── Level2#Level2()
└── Level2#level2_func7(String, int)
【混合场景】
入口方法 org.example.demo.callchain.Level1#level1_func8,打印结果:
Level1#level1_func8(String, int)
├── Level2#Level2()
└── Level2#level2_func8()
├── Level3#Level3()
├── PrintStream#println(String)
└── Level3#level3_func8(boolean)
└── StringUtils#isBlank(null)
创建类解析器时,指定的路径要求是绝对路径,而是末尾一般是 src/main/java 结尾的路径,因找类时候会从 sourceRoot 后拼接类的全限定名来查找类
String sourceRoot = "/aaa/bbb/ccc/src/main/java";
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, preciseRule, isConnected);这也意味着,如果你想要分析 test 测试包下某些方法的调用链目前是不支持的,比如填写 sourceRoot 是 /aaa/bbb/ccc/test/main/java
另外符号解析 symbolSolverPaths 路径一般也是到 src/main/java 结尾
单个 Dag 中会存在方法复用的情况,如果该方法循环出现了,那么该叶子节点为整个循环调用的方法,其被标记成“出现循环”的标志,如果不同方法往下调用,都有该方法发生循环调用,则被标记的“出现循环”标志的循环调用方法也无法复用。具体看下面结构
A1
├── B1
| └── C1
| └── C1 [出现循环调用]
└── B2
└── C1
└── C1.bak [出现循环调用]
这里的 2 个 C1 是复用的,这里的 C1 [出现循环调用] 和 C1 是不同节点,这里的 C1 [出现循环调用] 不是复用的,即 C1 [出现循环调用] 和 C1.bak [出现循环调用] 其实是不同节点
DagNode 结构中所有的类都是不带有泛型信息的
DagNode 中所有的简单类名,如果是内部类的情况,则为 Aaa$Bbb 类似的形式表示,以 $ 拼接
创建解析器时,尽量选择 NormalRule 或者 WarnModRule,因为他们限制了解析的范围和深度
IPreciseRule normalRule = new NormalRule();
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, normalRule, isConnected);IPreciseRule warnModRule = new WarnModRule();
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, warnModRule, isConnected);或者使用自定义的解析规则,注意设置最大深度,以及过滤范围,和具体 PreciseModel 模式,上方 [自定义规则细节] 已提到
如果解析复杂超大项目时,如果查找过多的方法时
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, preciseRule, isConnected);
DagNode rootNode1 = resolver.resolveCallChain(startClass1, startMethod1, methodParams1);
DagNode rootNode2 = resolver.resolveCallChain(startClass2, startMethod2, methodParams2);
DagNode rootNode3 = resolver.resolveCallChain(startClass3, startMethod3, methodParams3);
...
DagNode rootNodeN = resolver.resolveCallChain(startClassN, startMethodN, methodParamsN);- 如果这样的 rootNode1, rootNode2, rootNode3, ..., rootNodeN 是在同一作用域中,未来他们可能会同时释放,那么建议 isConnected 可设置为 true,增进节点的复用,减少内存占用
- 如果这样的 rootNode1, rootNode2, rootNode3, ..., rootNodeN 是在比如 for 循环中,每次循环拿到一个 rootNode,但每次循环后这个 rootNode 不会再被使用,它会失去引用,未来被 jvm 回收,那么建议 isConnected 可设置为 false,让 Dag 独立,使 Dag 和 Dag 之间节点不能复用,来调控好内存,若 isConnected 设置为 true,可能会导致 for 循环中历史迭代中的 rootNode 没有失去引用,jvm 无法回收,从而容易造成内容溢出
专门分析 .java 源代码文件的 javaparser 可以解析 AST 出来,但是由于纯 java 代码是静态代码,没有动态信息,纯文本内容很难猜测分析出多态场景,以及目前 lambda 表达式的识别能力有限,并且对于第三方依赖,是不能很好的分析的,除非第三方的依赖的 .java 文件都被下载下来,添加进入符号解析路径中去(但不可能把所有的第三方依赖的源码文件都下载下来)
相比,ASM 专门用来分析 .class 字节码文件,可以分析的更深,但是操作起来难度也更大
查找要做方法调用分析的入口方法:
String classAnn = "@RestController"; // 写成 "RestController" 亦可
String methodAnn = "@RequestMapping"; // 写成 "RequestMapping" 亦可
String path = "/user/xxx/yyy/project/src/main/java/com/example/name/controller" // 在指定路径下递归遍历其下所有包
AnnotationEntrance ann = new AnnotationEntrance();
List<MethodCallInfo> methodCallInfos = ann.findEntranceMethod(path, classAnn, methodAnn);找到后,解析 methodCallInfos 中每个方法,构造出 startClass/startMethod/methodParams
然后用自定义 MyCustomRule 类,其实现了 CustomRule 抽象类
IPreciseRule preciseRule = new MyCustomRule();构造解析器,并开始解析入口方法,来构造 Dag
CallChainResolver resolver = new CallChainResolver(sourceRoot, symbolSolverPaths, preciseRule, isConnected);
DagNode rootNode = resolver.resolveCallChain(startClass, startMethod, methodParams);打印 Dag
PrintDag printDag = new PrintDag();
printDag.printSimpleCallChains(rootNode);你也可以使用 src/main/java/org/example/resolver/find/TraverseDag.java 中的各种 public 方法来遍历 Dag,或者自行编写 Dag 遍历方







