此项目使用 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 表达式,比如方法连续调用等内容。 但对于多态场景,其实 ASM 和 javaparser 都不太好解析,因为多态的类型是在运行时才确定的
【性能上】 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 类似的形式表示,以 $ 拼接
对于匿名内部类中的方法,进行解析时候,方法名和和类名可能都是 Anonymous 开头的:
new Thread(new Runnable() {
@Override
public void run() {
}
}).start();比如 run() 方法,方法名和对应的匿名类的名字可能会被解析成 Anonymous-xxx-xxx-xxx-xxx-xxx
创建解析器时,尽量选择 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 无法回收,从而容易造成内容溢出
【常见有局限的场景】
- 多态场景:多态场景的 javaparser 解析支持的不是很好(能识别一部分),这一点上 ASM 同样也很难做到,因为多态是运行时确定的,而不是编译时确定的
// 部分场景下可能无法识别 func() 所属的类 A a = new B(); a.func();
- 连续调用:连续调用的方法,目前解析能力有限,可能存在无法识别出该
func2()属于哪个类的情况// 可能无法识别 func2() 所属的类 a.func1().func2();
- lambda 表达式:目前解析能力有限,支持的不是特别好,可能存在无法识别出表达式中使用的方法所属哪一个类的情况,如下:
// 可能无法识别 isOk() 所属的类 a.stream().filter(x -> x.isOk());
- 类的 field 写法:方法的作用域是带有 "." 的写法,目前解析能力有限,可能存在无法识别出该方法所属哪一个类,如下方法作用域是
SomeConstant.name:// 可能无法识别 func() 所属的类 SomeConstant.name.func();
查找要做方法调用分析的入口方法:
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 遍历方
MIT LICENSE







