前置解析编译单元(javaparser,CompilationUnit)
前置解析编译单元(CompilationUnit)
AST(抽象语法树)介绍:
抽象语法树(Abstract Syntax Tree,AST)是一种用于表示源代码语法结构的树状数据结构。它将源代码转化成一个树,其中每个节点代表一个语法结构或构造。AST是编译器和解析器中的重要组成部分,用于实现词法分析、语法分析和语义分析等编译过程。AST的节点通常表示源代码的不同部分,例如变量声明、函数调用、循环结构、条件语句等。每个节点在树中的位置和连接方式反映了代码的层次结构和语法关系。节点可能具有不同的属性,例如名称、类型、值等。
在许多编程语言中,AST通常是通过解析器将源代码转换成的中间表示形式。许多编程语言工具和框架,如编译器、静态分析工具和IDE等,都使用AST作为中间表示形式来进行代码的处理和分析。
推荐引擎中抽象语法树工具使用的是JavaParser,它可以帮我们把一个Java文件解析成“编译单元”,也就是把一个Java文件变成一颗Java语法树,类似于使用工具把汽车进行拆解,并按一定规则进行摆放,拆出来的零件是什么,零件之间有什么关系,则还需要我们来进一步分析。
加载编译单元
主要使用AST将所有目标文件(.java文件)解析成编译单元。解析时会根据排除/保留策略筛选出目标文件。一个编译单元就是一个.java文件,将编译单元暂存到“编译单元解析任务原信息(CompilationUnitParserTask)” 中,用于后续分析任务。
PS: 必须先解析完所有代码文件,才能进行方法调用链分析,因为不同文件之间存在的引用关系,先分析会导致获取不到依赖的问题。
分析编译单元(CompilationUnit)
分析编译单元时,采用并发模式进行分析,由于所有编译单元已经完成了加载,也就是说所有的代码文件都已经变成语法树了,这个时候就可以使用并发模式来提高分析效率了。
一个编译单元就是一个.java文件,我们以Java中的一个类(类|接口|枚举|注解声明)当做分析的主体,一个.java文件中存在一个或多个类,所以需要遍历拿到每个“类型声明(TypeDeclaration)”进行逐一分析:
分析类型声明(TypeDeclaration)
提取类级别http path前缀
由于Spring项目可以在“类、抽象类、接口”中定义http接口,所有需要从对应代码中进行提取。接口的最终path为 = 项目级别paht + 类级别path + 方法级别path。
PS: 代码中path不填写规范时需要平台兜底补全“/”
图片
从 @RequestMapping 注解或 @FeignClient 注解中提取path,通过注解表达式AnnotationExpr获取注解名“an.getName().getIdentifier()”并判断是那种注解。
- 提取注解的参数值:
由于注解的入参写法的不同分为以下两种表达式,不同表达式的提取方式不同,具体如下:
SingleMemberAnnotationExpr:单值无参数名表达式【获取逻辑相对简单】
如:@RequestMapping("/dubbo")
NormalAnnotationExpr:多值有参参数名表达式【获取逻辑较为复杂,且 RestController 和 FeignClient注解的有差异】
如:@RequestMapping(value = "/dubbo", method = RequestMethod.POST)
如:@RequestMapping(value = "/dubbo")
如:@RequestMapping()
RequestMapping注解:值存在两种字段名“value”和“path”,两种都代表接口路径,需遵循Spring的优先级规则来取值(value > path)
FeignClient注解:只存在path一个字段名
- 整体逻辑:
首先提取到目标注解的值的“表达式(Expression)”:
Expression 表达式存多种子类,重点关注“字符串表达式(StringLiteralExpr)”和“数组表达(ArrayInitializerExpr)”,如:
@RequestMapping(value = "/dubbo")中的"/dubbo" 就是一个 StringLiteralExpr对象
@RequestMapping(value = {"/dubbo", "/dubbo2"})中的{"/dubbo", "/dubbo2"} 就是一个ArrayInitializerExpr对象
然后通过不同的处理逻辑来提取“StringLiteralExpr”或“ArrayInitializerExpr”表达式的具体值
最后按照接口路径前缀规则拼接完整类级别前缀。
分析类成员
类成员主要有:
- 方法声明:普通方法(MethodDeclaration)、构造方法(ConstructorDeclaration)
- 字段声明(FieldDeclaration)
- 代码块声明(InitializerDeclaration)
- 类型声明(TypeDeclaration):一般是内部类
方法声明-普通方法(MethodDeclaration)分析
提取方法的关键信息
从法语树的方法节点中提取到方法关键信息存储到MemberInfo对象中,关键信息如下:
方法签名(sign):方法的唯一标识
所属类(class):
入参(parTypes):
返回值(retType):
接口标识(api): true 是接口,false 不是接口
接口路径(aPath): htttp 路径 或 dubbo路径
接口协议(aPtl): http 或 dubbo
调用方法(calls): 当前方法调用的其他方法签名
被调方法(usages): 当前方法被哪些方法签名调用
资损标(zsScan):方法上添加注解,标记资损方法,通过@ZsScan注解进行打标
其他属性:...
PS:因调用链需要存储海量的数据,为减少网络传输开销和存储空间,传输和落库使用的是字段名简称,代码中使用的是全称
图片
提取方法签名
由于Java存在方法重载,即方法名相同入参类型和个数不同,则代表不同的方法,所以必须使用完整签名才能唯一确定一个方法。sign = 包名.类名.方法名(所有参数的完整完整签名),如方法:
定义方法------------------------
build***Cmd(Outbo***ntity ohe, Listlist, boolean b)
完整签名(sign)------------------------
com.poi***sService.bu***erCmd(com.poiz***eaderEntity,java.util.List, boolean)
- 正常情况:通过原生的 ResolvedMethodLikeDeclaration.getQualifiedSignature() 方法来获取方法的完整签名
- 异常情况:由于存在排除代码和不分析依赖jar包中的代码的情况,所以推荐引擎已经加载的 编译单元 并不完整,即有些代码只是体现了被使用但没有具体的实体,如下图:如下图方法 queryCountByScene 的入参是 QueryCountBySceneRequest 类型,当前文件中也导入了这个类,但如果该类来自一个依赖jar包或者该类被排除了,这个时候已经加载的编译单元中是没有这个类的语法树的,所以原生的方式无法获取到完整签名,报错后会导致当前方法信息也丢失。
- 解决方案:使用兜底策略提取方法签名,支持两种方案,当方案1异常是自动切到方案2。
- 方案1:从导包中路径中提取出完整签名重写 MyResolvedMethodDeclaration类代替原 ResolvedMethodLikeDeclaration(方法声明)重写 MyReferenceTypeImpl类 替代 ResolvedType(方法入参声明)核心逻辑“从导包中路径中提取出完整签名”
图片