查看原文
其他

得物精准测试平台设计与实现

千仞 得物技术
2024-12-05

目录

一、精准测试是什么

    1. 什么是精准测试

    2. 为什么要做精准测试

    3. 业内主要精准测试方案简介(思路)

二、得物精准测试平台介绍

三、服务端精准推荐实现

    1. 精准推荐的核心组件-推荐引擎

    2. 链路分析器:

    3. 方法调用链存储-图数据库

    4. 差异提取器

四、总结


精准测试是什么

什么是精准测试

目前业内尚未形成统一的实践方案,对精准测试也有多种定义,这里列举部分定义:


(1)精准测试是一套计算机测试辅助分析系统。精准测试的核心组件包含的软件测试示波器、用例和代码的双向追溯、智能回归测试用例选取、覆盖率分析、缺陷定位、测试用例聚类分析、测试用例自动生成系统,这些功能完整的构成了精准测试技术体系。


(2)精准测试是一种测试方法,旨在通过优化测试过程和测试结果,提高软件质量和可靠性。该测试方法结合了自动化和手动化测试技术,以尽可能地覆盖所有的功能和边缘情况,从而减少潜在的缺陷和错误。精准测试方法采用了一系列技术和流程,包括测试计划和用例的制定和管理、缺陷跟踪和管理、代码覆盖率分析、性能测试、安全测试等等。该方法通过持续集成和持续测试,将测试过程整合到开发流程中,提高软件开发的效率和质量。精准测试的最终目标是确保软件产品能够满足用户的需求和期望,并且达到高度的可靠性、安全性和稳定性。同时,该测试方法可以减少开发过程中的错误和重复工作,提高开发团队的效率和协作能力,促进软件开发的创新和发展。


为什么要做精准测试

精准测试是为了解决测试的质量和效率问题。传统测试主要依赖测试人员的自身经验结合具体需求分析出可能涉及的功能模块以和受影响的功能模块,从而评估出测试范围。如果想提高精准度,则需要沉浸到代码中去寻找答案,不仅提高了人力成本,更是对测试同学提出了更高的要求,更不适用于敏捷迭代中。另外上述过程每次都要重复一遍,也只能存在于人脑中,不能无损传承、沉淀和重复利用。这导致代码变更后还是无法精准的评估“测试范围”,处于盲测、漏测、冗余测试的状态,即增加了上线风险,也提升了人力成本,同时对测试人员的经验依赖度相对较大。


业内主要精准测试方案简介(思路)

精准测试核心有两方面:精准推荐(用例推荐、接口、方法等)、测试度量(度量代码被测情况即代码覆盖率)


测试度量:方式基本一致-过代码覆盖率来度量测试覆盖度-指导漏测点-准出卡点等。


得物精准测试平台介绍

精准测试平台主要负责推荐精准测试范围,减少盲测、漏测、冗测现象。并将 “精准推荐 + 用例执行 + 结果度量”结合形成有机整体,在测试前、测试中、测试后帮助质量团队保质量、提效率。目前平台已提供服务端Java/Golang应用和前端H5应用的精准推荐能力。


平台期望:

相比于上文提到的精准测试的定义,其实我们更关注它能给我们带来什么帮助,我们期望精准测试能帮助我们:


  • 推荐测试范围,减少盲测、漏测、冗测,降低上线风险,提高测试准度和效率

  • 测试前:评估影响面(增量影响面,风险影响面(如:资损、核心等),自动跑用例,前置发现问题

  • 测试中:暴露未测代码,降低漏测风险,引导测试方向

  • 测试后:度量测试覆盖度,准出卡点,保驾上线质量

平台输出:

  • 服务端支持输出:

    变更接口、方法

    接口自动化用例

    功能用例

    资损标、资损接口、资损用例,核心接口、核心用例

    风险代码覆盖率报告

  • 前端支持输出:

    变更页面,变更url,影响接口,功能用例、前端巡检用例等


服务端精准推荐实现

得物精准测试平台主要由“精准测试平台”和“推荐引擎(链路分析器、差异分析器、知识库、推荐引擎)”两部分构成。整体思路是建立由“代码行”反推“代码方法”并逆向追踪“API接口”,以“API接口”为枢纽推荐“自动化用例、功能用例、资损用例等内容”的精准推荐体系。


技术实现上借助AST(抽象语法树)生成应用完整方法调用链,由code diff提取到有效增量信息,结合调用链可追溯变更接口(新增&修改的接口),根据接口推荐出自动化用例和功能用例,并根据增量方法及其调用链准确推出链路上的资损标,根据资损标推出资损用例。同时打通覆盖率平台,度量代码变更范围测试完整度。


精准推荐的核心组件-推荐引擎

其中推荐引擎是精准推荐的核心之一,主要包含如下内容:

  • 代码分析器:

    链路分析器:根据最新提交生成方法调用链,标记出http、dubbo接口的入口实现方法并记录接口相关属性信息,存入知识库

    差异分析器:根据 code diff(最新提交 - 线上提交),结合抽象语法树提取出有效的变更方法,结合知识库找到 具体的变更 接口,变更接口 结合 接口调用链,定位到影响接口

  • 接口调用链提取器:打通trace2.0 提取上个迭代的接口调用链,存入知识库

  • 推荐引擎:(变更接口 + 影响接口)  + 知识库 => 自动化用例 + 功能用例

  • 知识库:存储应用内方法调用链和全域接口调用链

平台能力

部分使用场景流程图


链路分析器

链路分析器的主要任务是借助AST(抽象语法树)解析源代码,然后通过分析代码之间的关系将各个散点串成一张完整的关系网,同时关系网中会记录重要属性信息。大体流程是:根据最新代码提交生成方法调用链,标记出http、dubbo接口的入口实现类方法并记录接口相关属性信息,将调用链存储到知识库。


提取项目级别接口路径前缀

  • 提取项目级别的http接口路径前缀:提取优先级遵循 Spring 的优先级顺序

    优先从 *application*.properties 配置文件server.servlet.context-path字段中提取前缀(Spring 规则),如:

# application.propertiesspring.application.name=auto-j-proxyserver.port=8888# 项目级别前缀server.servlet.context-path=/api/v1
  • 其次从 *application*.yml 配置文件 server.servlet.context-path字段中提取,如:

# application.ymlserver: servlet: # 项目级别前缀 context-path: /api/v1
  • 提取dubbo接口配置:提取到所有dubbo接口配置,用于链路解析完成后,进行dubbo接口标记。

    从 dubbo-provider.xml 配置文件中解析出dubbo接口配置信息“dubbo:service”,节点信息如下:


    dubbo:service:dubbo服务节点

    interface:dubbo 服务,是Java中的接口类(interface)

    ref:指定接口类(interface)实现类的名称,准确名称需要将ref值首字母转成大写

    从 dubbo-provider.xml 配置文件中解析出dubbo接口配置信息“dubbo:service”,节点信息如下:

/** * 提取dubbo-provider.xml文件配置信息 * - "dubbo-provider.xml"文件,提取dubbo接口 */private static void extractDubboProviderXmy() { try { for (String filePath : dubboProviderXmlFilePaths) { SAXReader reader = new SAXReader(); Document doc = reader.read(new File(filePath)); // 获取doc对象的字节点 Element rootEle = doc.getRootElement();
Iterator<?> iterator = rootEle.elementIterator(); while(iterator.hasNext()) { // 获取子节点 Element subEle = (Element)iterator.next(); String qualifiedName = subEle.getQualifiedName();
// 处理dubbo接口 if (qualifiedName.equals("dubbo:service")) { String interfaceName = null, refName = null; for (Attribute atr : subEle.attributes()) { if (interfaceName != null && refName != null) break; if (atr.getName().equals("interface")) { interfaceName = atr.getValue(); } else if (atr.getName().equals("ref")) { refName = atr.getValue(); } } if (interfaceName != null && refName != null) { DubboInterfaceInfo dIInfo = new DubboInterfaceInfo(interfaceName, refName); DUBBO_INTERFACE_INFO_MAP.put(dIInfo.interfaceSign, dIInfo); DUBBO_INTERFACE_INFO_MAP.put(dIInfo.name, dIInfo); DUBBO_INTERFACE_INFO_MAP.put(dIInfo.ref, dIInfo); LOG.info("interfaceName:" + interfaceName); LOG.info("refName:" + refName); } } } } } catch (Exception e) {...}}


前置解析编译单元(CompilationUnit)

AST(抽象语法树)介绍:

抽象语法树(Abstract Syntax Tree,AST)是一种用于表示源代码语法结构的树状数据结构。它将源代码转化成一个树,其中每个节点代表一个语法结构或构造。AST是编译器和解析器中的重要组成部分,用于实现词法分析、语法分析和语义分析等编译过程。AST的节点通常表示源代码的不同部分,例如变量声明、函数调用、循环结构、条件语句等。每个节点在树中的位置和连接方式反映了代码的层次结构和语法关系。节点可能具有不同的属性,例如名称、类型、值等。


在许多编程语言中,AST通常是通过解析器将源代码转换成的中间表示形式。许多编程语言工具和框架,如编译器、静态分析工具和IDE等,都使用AST作为中间表示形式来进行代码的处理和分析。


推荐引擎中抽象语法树工具使用的是JavaParser,它可以帮我们把一个Java文件解析成“编译单元”,也就是把一个Java文件变成一颗Java语法树,类似于使用工具把汽车进行拆解,并按一定规则进行摆放,拆出来的零件是什么,零件之间有什么关系,则还需要我们来进一步分析。


加载编译单元

主要使用AST将所有目标文件(.java文件)解析成编译单元。解析时会根据排除/保留策略筛选出目标文件。一个编译单元就是一个.java文件,将编译单元暂存到“编译单元解析任务原信息(CompilationUnitParserTask)” 中,用于后续分析任务。


PS: 必须先解析完所有代码文件,才能进行方法调用链分析,因为不同文件之间存在的引用关系,先分析会导致获取不到依赖的问题。

...CompilationUnit cu = StaticJavaParser.parse(FileUtils.read(file));...compilationUnitParserTasks.add(new CompilationUnitParserTask(typeMap, javaParses, packNames, packComments, cu, file));


分析编译单元(CompilationUnit)

分析编译单元时,采用并发模式进行分析,由于所有编译单元已经完成了加载,也就是说所有的代码文件都已经变成语法树了,这个时候就可以使用并发模式来提高分析效率了。


一个编译单元就是一个.java文件,我们以Java中的一个类(类|接口|枚举|注解声明)当做分析的主体,一个.java文件中存在一个或多个类,所以需要遍历拿到每个“类型声明(TypeDeclaration)”进行逐一分析:

// 此处已经在并发模式下运行了 for (TypeDeclaration<?> type : parserTask.compilationUnit.getTypes()) { Step3Type.parseType(parserTask.typeMap, parserTask.javaParses, type, parserTask.packNames, parserTask.packComments, parserTask.compilationUnit);}


分析类型声明(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”表达式的具体值


最后按照接口路径前缀规则拼接完整类级别前缀

/** * 提取接口信息 * - 提取http接口信息 */static void extractClassApiInfo(TypeInfo info) { if (info.annotations != null) { for (AnnotationExpr an : info.annotations) { if (an.getName().getIdentifier().equals("RestController")) { info.hasRestControllerAnnotation = true; } // 只提取http接口 if (!httpMethodAnnotations.contains(an.getName().getIdentifier())) continue;
// 源代码注解只有单个入参的写法,如 @RequestMapping("/dubbo") if (an.isSingleMemberAnnotationExpr()) { if (an.getName().getIdentifier().equals("RequestMapping")) { SingleMemberAnnotationExpr san = an.asSingleMemberAnnotationExpr(); Expression valueExpr = san.getMemberValue(); String path = getHttpPathByValueExpression(valueExpr); setHttpPathPrefix(info, path); } } // 源代码注解有多个值的写法,如 @RequestMapping(value = "/dubbo", method = RequestMethod.POST) // 或 @RequestMapping(value = "/dubbo") 或 @RequestMapping() else if (an.isNormalAnnotationExpr()) { NormalAnnotationExpr nan = an.asNormalAnnotationExpr(); List<Node> childNodes = nan.getChildNodes(); // 第一个元素为注解描述跳过(注解名称等信息) if (childNodes.size() == 1) continue; boolean flag = true; for (Node cn : childNodes) { if (flag) { flag = false; continue; } MemberValuePair mvp = (MemberValuePair) cn; // RequestMapping注解的参数value的值就是接口路径前缀 if (an.getName().getIdentifier().equals("RequestMapping")) { String name = mvp.getName().getIdentifier(); if (name != null && (name.equals("value") || name.equals("path"))) { Expression valueExpr = mvp.getValue(); String path = getHttpPathByValueExpression(valueExpr); setHttpPathPrefix(info, path); break; } } // FeignClient(公司内部实现的注解)注解的参数path的值就是接口路径前缀 else if (an.getName().getIdentifier().equals("FeignClient")) { String name = mvp.getName().getIdentifier(); if (name != null && name.equals("path")) { Expression valueExpr = mvp.getValue(); String path = getHttpPathByValueExpression(valueExpr); setHttpPathPrefix(info, path); break; } } } } } }}
/** * 根据注解的值表达式获取接口路径 */static String getHttpPathByValueExpression(Expression valueExpr) { String path = null; if (valueExpr instanceof StringLiteralExpr) { path = ((StringLiteralExpr) valueExpr).getValue();
} else if (valueExpr instanceof ArrayInitializerExpr) { ArrayInitializerExpr _valueExpr = (ArrayInitializerExpr) valueExpr; List<Node> valueChildNodes = _valueExpr.getChildNodes(); // 存在多个path时只取第一个 path = valueChildNodes.size() > 0 ? ((StringLiteralExpr) valueChildNodes.get(0)).getValue() : ""; } return path != null ? path : "";}
/** * 设置http接口路径前缀 * - 确保路径开头和路径之间存在"/"字符 */static void setHttpPathPrefix(TypeInfo info, String path) { path = path != null ? path : ""; if (FileUtils.getHttpRootPathPrefix().isEmpty()) { info.httpPathPrefix = path; } else { if (!path.isEmpty() && !path.startsWith("/")) { path = "/" + path; } info.httpPathPrefix = FileUtils.getHttpRootPathPrefix() + path; }
if (!info.httpPathPrefix.isEmpty() && !info.httpPathPrefix.startsWith("/")) { info.httpPathPrefix = "/" + info.httpPathPrefix; }}

分析类成员

类成员主要有:

  • 方法声明:普通方法(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() 方法来获取方法的完整签名

/** * 解析方法签名 */static String methodSign(ResolvedMethodLikeDeclaration r, TypeInfo typeInfo) { try { return r.getQualifiedSignature().replace(" extends java.lang.Object", ""); }catch (UnsolvedSymbolException e) { // 方法签名使用兜底字段值类型 String sign = getPocketBottomQualifiedSignature(r, typeInfo); // LOG.warn("InfoUtils.methodSign(使用兜底类型-方法签名入参的类型)(无法提取jar包中的名字)newSign=" + sign); return sign.replace(" extends java.lang.Object", ""); }}
  • 异常情况:由于存在排除代码和不分析依赖jar包中的代码的情况,所以推荐引擎已经加载的 编译单元 并不完整,即有些代码只是体现了被使用但没有具体的实体,如下图:

    如下图方法 queryCountByScene 的入参是 QueryCountBySceneRequest 类型,当前文件中也导入了这个类,但如果该类来自一个依赖jar包或者该类被排除了,这个时候已经加载的编译单元中是没有这个类的语法树的,所以原生的方式无法获取到完整签名,报错后会导致当前方法信息也丢失。


  • 解决方案:使用兜底策略提取方法签名,支持两种方案,当方案1异常是自动切到方案2。


  • 方案1:从导包中路径中提取出完整签名

    重写 MyResolvedMethodDeclaration类代替原 ResolvedMethodLikeDeclaration(方法声明)

    重写 MyReferenceTypeImpl类 替代 ResolvedType(方法入参声明)

    核心逻辑“从导包中路径中提取出完整签名”

public class MyResolvedMethodDeclaration implements ResolvedMethodDeclaration { ... // 重写构造方法 public MyResolvedMethodDeclaration(ResolvedMethodDeclaration declaration, List<ResolvedType> paramTypes, ResolvedType returnType, TypeInfo typeInfo) { this.declaration = declaration; this.paramTypes = paramTypes; this.returnType = returnType; this.typeInfo = typeInfo; // 前置设置参数类型 if (paramTypes == null) { this.paramTypes = new ArrayList<>(); for (int i = 0; i < declaration.getNumberOfParams(); ++i) { ResolvedType paramType = getParamType(declaration.getParam(i)); this.paramTypes.add(paramType); } } }
// 重写获取签名方法 @Override public String getSignature() { if (shortSign != null) return shortSign; StringBuilder sb = new StringBuilder(); sb.append(this.getName()); sb.append("("); for(int i = 0; i < this.paramTypes.size(); ++i) { if (i != 0) { sb.append(", "); } if (this.paramTypes.get(i) instanceof MyReferenceTypeImpl) { MyReferenceTypeImpl myTypeImpl = (MyReferenceTypeImpl) this.paramTypes.get(i); sb.append(myTypeImpl.getSign()); } else { sb.append(this.getParam(i).describeType()); } } sb.append(")"); shortSign = sb.toString(); return shortSign; } // 新增兜底获取参数类型 public ResolvedType getParamType(ResolvedParameterDeclaration param) { try { // 正常获取 return param.getType(); } catch (Exception e) {...} if (param instanceof JavaParserParameterDeclaration) { JavaParserParameterDeclaration jPpd = (JavaParserParameterDeclaration) param; List<Node> nodes = jPpd.getWrappedNode().getChildNodes(); Node node = nodes.get(0); if (node instanceof ClassOrInterfaceType) { ClassOrInterfaceType cIType = (ClassOrInterfaceType) node; String paramTypeName = cIType.getName().toString(); String classSign = getClassSign(paramTypeName); MyReferenceTypeImpl paramType = new MyReferenceTypeImpl(param, paramTypeName, classSign); return paramType; } } return null; } // 重写获取类的完整类签名值 public String getClassSign(String paramTypeName) { String matchValue = "." + paramTypeName; // 倒序查询目标导入类 List<String> impSigns = typeInfo.getImportSignsByInit(); for (int i = impSigns.size() - 1; i >=0; i--) { String impSign = impSigns.get(i); if (impSign.endsWith(matchValue) || impSign.equals(paramTypeName)) { return impSign; } } return null; }}
  • 方案2:生成残缺签名

    如果包时没有导入具体的类,而是通过*号导入的,那这时候将无法准确匹配到具体的类签名,只能兜底生成残缺的签名(极端情况下可能会不准)


方案1-结果:

com.shizhu***erProvider.que***cene(com.shi***est.Que***eRequest)


方案2-结果:

com.shizhu***erProvider.que***cene(Que***eRequest)


可以看到,方案1是完整的签名,方案2则参数类型名称不完整,如果项目中存在两个同名的类,则方案2的推荐结果就可能有偏差了。


提取方法资损标

通过@ZsScan注解对方法进行打标,如图 invoke2方法 有两个资损标,提取时从方法注解中找到@ZsScan注解,提取注解的 zsCases 字段的值表达式(ArrayInitializerExpr),将数组表达式中的多个值通过“;”拼接成字符串进行存储


补全方法其他相关属性

标注方法是否是 静态、抽象、默认方法。由于Java8开始interface中允许有default方法,即interface不完全都是抽象方法,故需要根据default标识来设置interface中的方法类型。

static void addResolvedMethodInfo(MemberInfo info, ResolvedMethodLikeDeclaration r, TypeInfo typeInfo) { if (info.sign == null || info.sign.equals("")) { info.sign = methodSign(r, typeInfo); } info.name = r.getName(); info.genLowFirstName(); if (r instanceof JavaParserMethodDeclaration) { JavaParserMethodDeclaration md = (JavaParserMethodDeclaration) r; info.isStatic = md.isStatic(); info.isAbstract = md.isAbstract(); info.isDefault = md.isDefaultMethod(); } else if (r instanceof MyResolvedMethodDeclaration) { MyResolvedMethodDeclaration rmd = (MyResolvedMethodDeclaration) r; info.isStatic = rmd.isStatic(); info.isAbstract = rmd.isAbstract(); info.isDefault = rmd.isDefaultMethod(); } else if (r instanceof ReflectionMethodDeclaration) { ReflectionMethodDeclaration rmd = (ReflectionMethodDeclaration) r; info.isStatic = rmd.isStatic(); info.isAbstract = rmd.isAbstract(); info.isDefault = rmd.isDefaultMethod(); } // 修正接口方法默认共有 if (TypeEnum.INTERFACE.equals(info.typeInfo.type)) { info.access = AccessEnum.PUBLIC; info.isAbstract = !info.isDefault; // 如果是interface中的默认方法,则不是抽象方法 } else { info.access = AccessEnumUtils.toEnum(r.accessSpecifier()); }}

提取方法的API信息(http path)

Spring中一般通过注解来标记一个方法是http接口的入口方法,支持:


RequestMapping、PostMapping、GetMapping、PutMapping等注解,同时由于注解中参数形式写法多样,对应的表达式类型也不一样,所以需要针对每种写法使用对应的方式才能提取到接口路径值,如下图:


  • 提取路径的核心思路:

    先从方法注解中选出目标注解。


    根据“注解写法”区分出 SingleMemberAnnotationExpr(单值无参数名表达式) 和 NormalAnnotationExpr(多值有参数名表达式) 两种参数表达式,根据不同表达式使用不同逻辑提取到path值的表达式 Expression(StringLiteralExpr or ArrayInitializerExpr) ,根据值表达式提取到具体的接口路径。

// 目标注解名称private static final List<String> httpMethodAnnotations = Arrays.asList( "RequestMapping", "PostMapping", "GetMapping", "PutMapping", "PatchMapping", "DeleteMapping", "HeadMapping", "TraceMapping");/** * 提取方法的接口信息 */static void extractMethodApiInfo(MemberInfo info) { if (info.annotations != null && info.memberType != MemberEnum.FIELD && info.memberType != MemberEnum.STATIC) { for (AnnotationExpr an : info.annotations) { // 只提取http接口 if (!httpMethodAnnotations.contains(an.getName().getIdentifier())) continue;
// 源代码注解只有单个入参的写法,如 @RequestMapping("/dubbo") if (an.isSingleMemberAnnotationExpr()) { SingleMemberAnnotationExpr san = an.asSingleMemberAnnotationExpr(); Expression valueExpr = san.getMemberValue(); String path = InfoUtils.getHttpPathByValueExpression(valueExpr); setHttpPath(info, path); } // 源代码注解有多个值的写法,如 @RequestMapping(value = "/dubbo", method = RequestMethod.POST) // 或 @RequestMapping(value = "/dubbo") 或 @RequestMapping() else if (an.isNormalAnnotationExpr()) { NormalAnnotationExpr nan = an.asNormalAnnotationExpr();
List<Node> childNodes = nan.getChildNodes(); // 第一个元素为注解描述跳过(注解名称等信息) if (childNodes.size() == 1) continue; boolean flag = true; for (Node cn : childNodes) { if (flag) { flag = false; continue; } MemberValuePair mvp = (MemberValuePair) cn; String name = mvp.getName().getIdentifier(); if (name != null && (name.equals("value") || name.equals("path"))) { Expression valueExpr = mvp.getValue(); String path = InfoUtils.getHttpPathByValueExpression(valueExpr); setHttpPath(info, path); break; } } } } }}


提取字段关键信息

字段的定义方式有多种,单行一个变量和单行多个变量这两种写法对应的定义表达式也不一样,需要通过FieldDeclaration.getVariables().size() 来区分,size == 1的就是单行一个变量,提取方式比较简单,size > 1的就是单行定义多个变量,提取方式具体如下:

FieldDeclaration d = m.asFieldDeclaration();// 一行定义一个字段的场景if (d.getVariables().size() == 1) { ResolvedFieldDeclaration r = d.resolve(); String sign = typeInfo.sign + "." + r.getName(); memberInfoLock.lock(); MemberInfo info = typeInfo.memberInfo.computeIfAbsent(sign, s -> new MemberInfo()); memberInfoLock.unlock(); info.sign = sign; InfoUtils.addResolvedFieldInfo(info, r); InfoUtils.addFieldInfo(info, d); info.memberType = MemberEnum.FIELD; infos.add(info);}// 一行定义多个字段的场景else if (d.getVariables().size() > 1) { LOG.warn("Step4Members.parseMembers(兜底一行定义多个属性的情况)typeInfoSign=" + typeInfo.sign + " 一行属性个数=" + d.getVariables().size()); for (VariableDeclarator vd : d.getVariables()) { String sign = typeInfo.sign + "." + vd.getName().getIdentifier(); memberInfoLock.lock(); MemberInfo info = typeInfo.memberInfo.computeIfAbsent(sign, s -> new MemberInfo()); memberInfoLock.unlock(); info.sign = sign; info.name = vd.getName().getIdentifier(); info.genLowFirstName(); try { // 设置完整类型-(int 等基础类型不会命中下方进行兜底) for (Node nd : vd.getChildNodes()) { if (nd instanceof ClassOrInterfaceType) { info.returnType = ((ClassOrInterfaceType) nd).toDescriptor().substring(1).replace("/", ".").replace(";", ""); break; } } } catch (Exception e) { // 下方兜底时统一设置 } // 设置兜底类型 if (info.returnType == null) { info.returnType = vd.getType().toString(); } // 不设置 info.access 用处不到,且难以拿到 info.memberType = MemberEnum.FIELD; infos.add(info); }}


提取方法调用关系

通过解析方法体找到所有的MethodCallExpr(方法调用表达式),进而提取出当前方法都调用了哪些方法。


1、技术难点

  • 方法重载:即方法名相同但方法的参数个数和参数类型不同,则代表是不同的方法。所以不能只通过方法名来判断当前方法调用了哪些方法,需结合方法名和调用时每个位置上的参数类型,才能准确定位到具体调用的方法。

  • 参传方式多样:方法调用传参支持 直接传值、传变量、传方法调用、传链式调用、组合拼接值等,增加了解析入参类型的复杂度(需要拿到最终调用参数类型)。

  • lombok注解:如@Data 注解,Java支持通过注解实现编译时生成实体方法,也就是说源码中并不存在方法,编译之后字节码中才存在这些方法。我们做的是源码分析不是字节码分析,所以无法正常获取调用关系。

  • 反射调用:Java反射是通过字符串来动态指定调用的类和方法,源码中体现的调用关系并不是业务逻辑上的真实调用关系。


2、案例分析

案例1:

  • 图一 中调用 checkBrandOrCategoryNew 方法时,使用了链式调用,且链式调用中的方法源代码中并不存在,而是通过 图二 @Data 注解在编译时自动生成的方法字节码的。

  • 要提取到当前方法调用的到底是那个checkBrandOrCategoryNew方法,则需要获取到每个入参的最终类型,也就是逐步分析每个链式调用中每个节点的类型,以及这个节点下的属性或方法的最终返回值的类型,直到获取出最后一个节点的最终返回类型为止。


案例2:

  • MethodCallExpr(方法调用表达式)展示,不同调用方式对应的子表达式也不一样,分析难度也不一样,图三中可以看到,源代码的方法体中只有两行代码,第一行代码先调用String.substring方法,传参为 String.length()方法,最终会提取到两个MethodCallExpr(方法调用表达式),第一个是主调用,第二个是子调用,也就是说当前方法即调用了 java.lang.String.substring(int) 又调用了 java.lang.String.length() 方法



调用关系核心逻辑介绍

核心思路通过 MethodCallExpr.resolve() 获取到 ResolvedMethodDeclaration(方法调用声明),并建立当前方法和被调用方法的绑定关系。同时检查是否存在反射调用,单独记录反射调用语法树节点,待整体分析结束后补充解析反射调用关系。

/** * 解析成员中的方法调用 */static void parseMethodCall(LinkedHashMap<String, TypeInfo> typeMap, List<JavaParse> javaParses, BodyDeclaration<?> m, TypeInfo typeInfo, MemberInfo usageInfo) { // 反射调用解析器 ReflectionCallParser reflectionCallParser = null; // 判断是否是方法,不需要判断,属性赋初值时也存在方法调用 for (MethodCallExpr expr : m.findAll(MethodCallExpr.class)) { ResolvedMethodDeclaration r; try { // 代码调用次数越多,源码量越多,这里花的时间越多 r = expr.resolve(); } catch (Throwable e) { // FIXME 目前已知解析失败:静态引用,::调用 continue; } ... // 建立方法调用绑定关系 MemberInfo callInfo = InfoFactory.getOrCreateMethodInfo(callTypeInfo, r); usageInfo.callInfo.put(callInfo.sign, callInfo); usageInfo.typeInfo.callInfo.put(callInfo.typeInfo.sign, callInfo.typeInfo); callInfo.usageInfo.put(usageInfo.sign, usageInfo); if (!callInfo.typeInfo.sign.equals(usageInfo.typeInfo.sign)) { callInfo.typeInfo.usageInfo.put(usageInfo.typeInfo.sign, usageInfo.typeInfo); } ... // 提取反射调用 if (callInfo.sign.contains("java.lang.Class.forName(java.lang.String)")) { reflectionCallParser = ReflectionCallParser.addForNameCallExpr( callInfo, expr, reflectionCallParser, usageInfo); } else if (callInfo.sign.contains("java.lang.Class.getMethod(java.lang.String, java.lang.Class<?>...)")) { reflectionCallParser = ReflectionCallParser.addGetMethodCallExpr( callInfo, expr, reflectionCallParser, usageInfo); } else if (callInfo.sign.contains("java.lang.reflect.Method.invoke(")) { reflectionCallParser = ReflectionCallParser.addInvokeCallExpr( callInfo, expr, reflectionCallParser, usageInfo); } }
// 存储反射调用解析器 ReflectionCallParser.saveReflectionCallParser(reflectionCallParser, usageInfo);}


技术难点解决


方法重载和lombok注解

核心思路确保调方法时的所有入参都能够解析出最终的参数类型,同时确保所有因lombok注解而缺失的方法都能被找到,其实补全缺失方法后基本就能保证所有方法入参类型能正常解析出来了。


  • 补全lombok注解缺少代码:

目前只支持@Data注解自动生成的 get set 方法,其他自动生成的代码对调用链无影响。只补全未实现的get set方法,且排除static字段“@data注解只会生成对象属性的方法不会生成类属性的方法”


PS: 由于Java中存在内部类,所以也需要额外解析所有内部类和内部类的内部类,并生成缺失方法 

方法分析和最终结果

写入缺失方法

补全内部类缺失代码

写入新代码并重新加载编译单元(CompilationUnit)


反射调用

Java反射是通过字符串来动态指定调用的类和方法,源码中体现的调用关系并不是业务逻辑上的真实调用关系,如下图,想要知道具体调的是那个方法,则需要先从入参提取出是那个类、那个方法和入参类型。


(1)技术难点

  • 获取创建反射对象的方法入参:

    存在变量:变量无法解析出运行时的值【不支持】

    参数拼接:

    相同类型参数拼接

    不同类型参数拼接

    多个参数混合类型拼接

  • 组合难点:

    调用多个反射方法

    变量定义顺序

    重复给变量赋值

    存在条件判断【无法准确处理】

    等...


(2)提取反射调用方法:

自研提取逻辑的核心逻辑,使用调用栈是思路确保安装正常代码执行的逻辑来提取反射调用方法,即使存在“不按顺序、调用多个方法、重复赋值”的情况,最终提取到的调用方法也是准确的。


  • 解析反射表达式:

将“反射类、反射方法、反射调用”三种表达式提取出来并生成对应的拓展表达式对象,对象中维护出现顺序索引、空间变量名、使用空间变量名、... 等关键信息,具体如下:


反射调用特征:方法调用中同时存在以下3种方法签名,才存在放射调用。

  1. java.lang.Class.forName(java.lang.String)

  2. java.lang.Class.getMethod(java.lang.String, java.lang.Class...)

  3. java.lang.reflect.Method.invoke(

反射类:Class.forName("com.shizhuang.xx.A")  ==> ForNameCallExprInfo

反射方法:clazz.getMethod("test", Integer.class, String.class) ==> GetMethodCallExprInfo

反射调用:method.invoke(obj, 1, "xxx", 2) ==> InvokeCallExprInfo


PS: 需要找到当前表达式对应的上级依赖表达式对象,寻找策略根据依赖变量名在依赖列表中的倒序查找查找,第一个 出现顺序索引 小于自己的变量,就是目标依赖对象。


  • 提取反射调用方法:

以“反射调用”为入口,通过其中的“使用空间变量名” + “出现顺序索引” 在“反射方法-变量空间”中找到“出现顺序索引”小于“反射调用”的“索引顺序”的第一个变量“反射方法”,同理以“反射方法”为入口找到“反射类”。从而提取出最终的反射方法签名。


  • 代码片段:

由于反射调用提取整体流程过于复杂此处仅能体现部分逻辑,以下是已经解析和存储完所有信息之后,从对应变量空间中提取最终方法的案例:


(3)提取入参的值-获取创建反射对象时调用方法的入参值

“clazz = Class.forName("com.shizhuang.xx.C" + "" + 1 + "aa");” 最终获取到的入参值是“com.shizhuang.xx.C1aa”


这里只能处理拼接和直接传值的情况,变量需要运行时才知道值所以无法处理,主要处理一下几种参数拼接(相同类型参数拼接、不同类型参数拼接、多个参数混合类型拼接),部分路径如下:

/** * 获取节点表达式的值 * - 如: test("aa", "b" + "b", "c" + "c" + "c", String.class) 中每个入参都是一个节点, * - 第一个为 StringLiteralExpr 节点 * - 第二个为 BinaryExpr 节点,其中的两个元素都是 StringLiteralExpr 节点 * - 第三个为 BinaryExpr 节点,其中的两个元素分别是 BinaryExpr 节点和 StringLiteralExpr 节点 * - 第四个为 ClassExpr 节点 */public String getExprNodeValue(Node node) { String nodeValue = null; try { // 参数是普通字符串值 if (node instanceof StringLiteralExpr) { nodeValue = getStringLiteralExprNodeValue((StringLiteralExpr) node); } // 加法计算表达式 else if (node instanceof BinaryExpr) { nodeValue = getBinaryExprNodeValue((BinaryExpr) node); } // 类节点表达式 else if (node instanceof ClassExpr) { nodeValue = getClassExprNodeClassSignValue((ClassExpr) node); } else if (node instanceof IntegerLiteralExpr) { nodeValue = getIntegerLiteralExprNodeValue((IntegerLiteralExpr) node); } else if (node instanceof MethodCallExpr) { // TODO 短期内不解决 throw new GetExprNodeValueNoImplException("调用方法表达式(" + node.getClass().getTypeName() + ")"); } else if (node instanceof NameExpr) { // TODO 短期内不解决 throw new GetExprNodeValueNoImplException("变量表达式(" + node.getClass().getTypeName() + ")"); } else { throw new GetExprNodeValueNoImplException("表达式(" + node.getClass().getTypeName() + ")"); }
return nodeValue != null ? nodeValue : ""; } catch (GetExprNodeValueNoImplException e) { LOG.warn(e.toString()); return null; }}
/** * 获取计算节点表达式(BinaryExpr)的值 */public String getBinaryExprNodeValue(BinaryExpr be) throws GetExprNodeValueNoImplException { StringJoiner nodeValueSubs = new StringJoiner(""); for (Node node : be.getChildNodes()) { if (node instanceof StringLiteralExpr) { String paramValueSub = getStringLiteralExprNodeValue((StringLiteralExpr) node); nodeValueSubs.add(paramValueSub); continue; } else if (node instanceof BinaryExpr) { String paramValueSub = getBinaryExprNodeValue((BinaryExpr) node); nodeValueSubs.add(paramValueSub); continue; } else if (node instanceof IntegerLiteralExpr) { String paramValueSub = getIntegerLiteralExprNodeValue((IntegerLiteralExpr) node); nodeValueSubs.add(paramValueSub); continue; } else if (node instanceof MethodCallExpr) { // TODO 短期内不解决 throw new GetExprNodeValueNoImplException("调用方法表达式(" + node.getClass().getTypeName() + ")"); } else if (node instanceof NameExpr) { // TODO 短期内不解决 throw new GetExprNodeValueNoImplException("变量表达式(" + node.getClass().getTypeName() + ")"); } else { throw new GetExprNodeValueNoImplException("表达式(" + node.getClass().getTypeName() + ")"); } } return nodeValueSubs.toString();}
/** * 获取字符串节点表达式(StringLiteralExpr)的值 */public String getStringLiteralExprNodeValue(StringLiteralExpr sle) { String nodeValue = sle.toString(); // 处理字符串类型 if (nodeValue.startsWith("\"") && nodeValue.endsWith("\"") && nodeValue.length() >= 2) { nodeValue = nodeValue.substring(1, nodeValue.length() - 1); } return nodeValue;}
/** * 获取整数节点表达式(IntegerLiteralExpr)的值 * - 以及字符串形式返回 */public String getIntegerLiteralExprNodeValue(IntegerLiteralExpr ile) { String nodeValue = ile.toString(); // 处理整数类型 if (nodeValue.startsWith("\"") && nodeValue.endsWith("\"") && nodeValue.length() >= 2) { nodeValue = nodeValue.substring(1, nodeValue.length() - 1); } return nodeValue;}
/** * 获取类节点表达式(ClassExpr)的完整类签名值 * 如:传参为 test(String.class),将获取到 java.lang.String */public String getClassExprNodeClassSignValue(ClassExpr ce) { String classExprNameStr = ce.toString(); String value = classExprNameStr.substring(0, classExprNameStr.length() - 6); // 去除 .class 后缀 String matchValue = "." + value; // 倒序查询目标导入类 List<String> impSigns = reCallParser.myMemberInfo.typeInfo.getImportSignsByInit(); for (int i = impSigns.size() - 1; i >=0; i--) { String impSign = impSigns.get(i); if (impSign.endsWith(matchValue) || impSign.equals(value)) { return impSign; } } return paramClassNameMap.get(value);}


分析继承/实现关系

Java中允许存在“类继承类、类实现接口、接口继承接口”,这里主要是按照Java的继承规则和方法使用顺序给子类或者子接口添加方法重写和继承关系,给类或接口添加继承和实现关系。用于解决Http接口路径和抽象调用问题。

  • 抽象调用案例:

接口1:In1 { m1(),   m2(),   m3(),   default m4() }

接口2:In2 implements In1 { m1(),   m2(),   default m3(),   m4() }

类2:C2 implements In2 {m1(),abstract m2(),   m3(),   m4() } // 不存在m1()方法

类3:C3  extends C2 {  m1(),   m2(),   m3(),   m4() }

类4:C4 { m1() {  In1.m2();   In1.m2();   In1.m3();   In1.m3() } }

最终C4.m1()的调用链:[

In1.m1(),    In1.m2(),    In1.m3(),    In1.m4(),

In2.m1(),    In2.m2(),    In2.m3(),

C2.m2(),

C3.m1(),   C3.m2()

]


分析接口(interface)继承关系

接口可以继承多个接口,所以需要按照Java本身的顺序来处理继承关系,先通过原生方法获取到接口的所有继承接口,然后倒序遍历继承接口的方式来处理方法的重写关系,才能保证继承链上的重写方法被正确体现出来(如:C 继承 B 继承 A,A和B中有相同的方法,则最终C继承的是B中的方法)


PS: 解析继承和方法重写关系与类(calss)的逻辑是复用的,请看下方


分析类(class)继承和实现关系

类可只能继承一个类可以实现多个接口,也需要安装Java本身的顺序来处理继承和实现关系。1)先通过原生方法获取到类的所有继承接口,然后倒序遍历继承接口的方式来处理方法的重写关系,才能保证继承链上的重写方法被正确体现出来(如:C 继承 B 继承 A,A和B中有相同的方法,则最终C继承的是B中的方法)。2)通过原生方法获取到类的唯一继承类,并处理继承和方法重写关系。


  • 主要逻辑如下:

static void parseOver(LinkedHashMap<String, TypeInfo> typeMap, List<JavaParse> javaParses, TypeDeclaration<?> type, List<ResolvedReferenceType> parents, TypeInfo overTypeInfo, boolean isImpl) { // 优化提速,统一存储方法签名和方法对象的关系 LinkedHashMap<String, ResolvedMethodDeclaration> typeMethodSingMap = new LinkedHashMap<>(); ... // 使用倒序方式处理,优先解析顶层的类(接口) for (int i = parents.size() - 1; i >= 0; i--) { ... TypeInfo parentTypeInfo = typeMap.get(parentTypeSign); ... if (isImpl) { // 绑定接口(interface)的继承关系 overTypeInfo.faceInfo.put(parentTypeInfo.sign, parentTypeInfo); parentTypeInfo.implInfo.put(overTypeInfo.sign, overTypeInfo); } else { // 绑定类(class)的继承或实现关系 // 调整成 当前为类时存在父类和子类 if (overTypeInfo.type == TypeEnum.CLAZZ) { overTypeInfo.parentInfo = parentTypeInfo; parentTypeInfo.childInfo.put(overTypeInfo.sign, overTypeInfo); } // 调成成 当前为接口时 只存在我实现的接口和实现我的接口或类 else { overTypeInfo.faceInfo.put(parentTypeInfo.sign, parentTypeInfo); parentTypeInfo.implInfo.put(overTypeInfo.sign, overTypeInfo); } } ... for (MethodUsage usage : usages) { ResolvedMethodDeclaration parentMethod = usage.getDeclaration(); // 私有方法不会被重写 if (AccessSpecifier.PRIVATE == parentMethod.accessSpecifier()) { continue; } // 查找当前类是否有同签名的重写方法 ResolvedMethodDeclaration overMethod = typeMethodSingMap.get(parentMethod.getSignature()); if (overMethod == null) { continue; } // 重写的方法 MemberInfo overInfo = InfoFactory.getOrCreateMethodInfo(overTypeInfo, overMethod); // 父类或实现类方法 MemberInfo parentInfo = InfoFactory.getOrCreateMethodInfo(parentTypeInfo, parentMethod); ... if (isImpl) { ... // 绑定接口(interface)的方法重写关系 overInfo.faceInfo.put(parentInfo.sign, parentInfo); parentInfo.implInfo.put(overInfo.sign, overInfo); } else { ... // 绑定类(class)的方法重写关系 overInfo.parentInfo = parentInfo; parentInfo.childInfo.put(overInfo.sign, overInfo); } } }}
  • 异常兜底逻辑如下:获取父类或接口中定义的方法信息时,可能因为依赖代码在依赖jar包或者排查代码中,所以会导致使用原生方法无法正常获取方法信息,这时就需要平台来增加兜底分析逻辑了。

    同时平台新增了MyMethodUsage类来代替原生的MethodUsage类,用于兜底时使用。

Set<MethodUsage> usages = new HashSet();try { usages.addAll(parent.getDeclaredMethods());}catch (UnsolvedSymbolException e) { // 兜底解析父类的方法信息 List<Set<Object>> res = getPocketBottomDeclaredMethods(parent, parentTypeInfo.sign, overTypeInfo); Set<Object> methods1 = res.get(0); for (Object _usage : methods1) { MethodUsage usage = (MethodUsage)_usage; usages.add(usage); }
Set<Object> methods2 = res.get(1); for (Object _usage : methods2) { MyMethodUsage usage = (MyMethodUsage)_usage; ResolvedMethodDeclaration parentMethod = usage.getDeclaration(); // 私有方法不会被重写 if (AccessSpecifier.PRIVATE == parentMethod.accessSpecifier()) continue;
String parentMethodShortSign = parentMethod.getSignature(); // 查找当前类是否有同签名的重写方法 ResolvedMethodDeclaration overMethod = seekMethod(type, parentMethodShortSign, overTypeInfo); if (overMethod == null) continue;
// 重写的方法 MemberInfo overInfo = InfoFactory.getOrCreateMethodInfo(overTypeInfo, overMethod); // 父类或实现类方法 MemberInfo parentInfo = InfoFactory.getOrCreateMethodInfo(parentTypeInfo, parentMethod); ... if (isImpl) { javaParses.forEach(v -> v.impl(overInfo, parentInfo)); overInfo.faceInfo.put(parentInfo.sign, parentInfo); parentInfo.implInfo.put(overInfo.sign, overInfo); } else { javaParses.forEach(v -> v.extend(overInfo, parentInfo)); overInfo.parentInfo = parentInfo; parentInfo.childInfo.put(overInfo.sign, overInfo); } }}
/** * 兜底解析父类的方法信息 * - 兜底生成重写解析父类的类,不然很多类里面就没有方法了 */static List<Set<Object>> getPocketBottomDeclaredMethods(ResolvedReferenceType parent, String parentTypeInfo, TypeInfo typeInfo) { HashSet<Object> methods1 = new HashSet(); HashSet<Object> methods2 = new HashSet(); parent.getTypeDeclaration().ifPresent((referenceTypeDeclaration) -> { Iterator var2 = referenceTypeDeclaration.getDeclaredMethods().iterator(); while(var2.hasNext()) { ResolvedMethodDeclaration methodDeclaration = (ResolvedMethodDeclaration)var2.next(); try { MethodUsage methodUsage = new MethodUsage(methodDeclaration); methods1.add(methodUsage); }catch (UnsolvedSymbolException e) { - 解析失败则替换成MyMethodUsage类进行解析 MyMethodUsage methodUsage = new MyMethodUsage(methodDeclaration, typeInfo); methods2.add(methodUsage); LOG.warn("Step6Over.parseOver(使用兜底类型-方法返回值的类型-替换MyMethodUsage类进行解析)(依赖来自第三方jar包中,不解析)(parentTypeSing=" + parentTypeInfo +" methodName=" + methodUsage.getName() + ")"); } } }); List<Set<Object>> res = new ArrayList(); res.add(methods1); res.add(methods2); return res;}
/** * 寻找和父类相同的方法 */static ResolvedMethodDeclaration seekMethod(TypeDeclaration<?> type, String parentMethodShortSign, TypeInfo typeInfo) { for (ResolvedMethodDeclaration method : type.resolve().getDeclaredMethods()) { // 按短签名匹配,完整签名中包含类签名无法用于匹配【getSignature():获取短签名,getQualifiedSignature() 获取完整签名】 ResolvedMethodDeclaration newMethod = getMethod(method, typeInfo); if (parentMethodShortSign.equals(newMethod.getSignature())) { return method; } } return null;}

MyMethodUsage 类重点重写了构造方法和getParamType()、getClassSign()、getSignature()等四个方法

/** * 兜底获取参数类型 */public ResolvedType getParamType(ResolvedParameterDeclaration param) { try { // 正常获取 return param.getType(); } catch (Exception e) {} // 兜底逻辑 if (param instanceof JavaParserParameterDeclaration) { JavaParserParameterDeclaration jPpd = (JavaParserParameterDeclaration) param; List<Node> nodes = jPpd.getWrappedNode().getChildNodes(); Node node = nodes.get(0); if (node instanceof ClassOrInterfaceType) { ClassOrInterfaceType cIType = (ClassOrInterfaceType) node; String paramTypeName = cIType.getName().toString(); String classSign = getClassSign(paramTypeName); MyReferenceTypeImpl paramType = new MyReferenceTypeImpl(param, paramTypeName, classSign); return paramType; } } return null;
}
/** * 兜底获取类的完整类签名值 */public String getClassSign(String paramTypeName) { String matchValue = "." + paramTypeName; // 从导包列表中倒序查询目标导入类 List<String> impSigns = typeInfo.getImportSignsByInit(); for (int i = impSigns.size() - 1; i >=0; i--) { String impSign = impSigns.get(i); if (impSign.endsWith(matchValue) || impSign.equals(paramTypeName)) { return impSign; } } return paramTypeName;}
/** * 兜底获取方法段签名 */public String getSignature() { StringBuilder sb = new StringBuilder(); sb.append(this.getName()); sb.append("(");
for(int i = 0; i < this.getNoParams(); ++i) { if (i != 0) { sb.append(", "); }
ResolvedType type = this.getParamType(i); if (type.isArray() && this.getDeclaration().getParam(i).isVariadic()) { sb.append(type.asArrayType().getComponentType().describe()).append("..."); } else { sb.append(type.describe()); } }
sb.append(")"); return sb.toString();}


拓展抽象调用链

由于Java语言的特性,会存在编码时调用的是父类或父接口中的方法(父或父父...父),代码执行时则调用具体实现类的方法,这种情况下通过AST进行分析的时候,真实的方法调用关系是不存在的,我们需要把这种抽象调用映射到真实的调用关系上才能形成完整的调用链。


PS: 抽象类和接口中的抽象方法才需要向下拓展调用关系,抽象类的实体方法和接口的default方法则不需要向下拓展调用关系


  • 抽象调用案例:

接口1:In1 { m1(),   m2(),   m3(),   default m4() }

接口2:In2 implements In1 { m1(),   m2(),   default m3(),   m4() }

类2:C2 implements In2 { m1(),abstract m2(),   m3(),   m4() } // 不存在m1()方法

类3:C3  extends C2 {  m1(),   m2(),   m3(),   m4() }

类4:C4 { m1() {  In1.m2();   In1.m2();   In1.m3();   In1.m3() } }

最终C4.m1()的调用链:[

In1.m1(),    In1.m2(),    In1.m3(),    In1.m4(),

In2.m1(),    In2.m2(),    In2.m3(),

C2.m2(),

C3.m1(),   C3.m2()

]

/** * 拓展抽象调用链 * - Java中子类实现父类方法时,参数类型也必须保持一致 */private static void expandAbstractCalls(HashMap<String, MethodInfo> has, MemberInfo usage, MemberInfo rawUsage) { // 拓展父接口的调用链(针对当前方法所属类为接口的场景) if (!usage.faceInfo.isEmpty()) { String rawShortSign = rawUsage.sign.substring(rawUsage.typeInfo.sign.length() + 1); for (MemberInfo targetMemberInfo : usage.faceInfo.values()) { // 拓展父父接口调用链 if (targetMemberInfo.parentInfo != null) { expandAbstractCalls(has, targetMemberInfo.parentInfo, rawUsage); } // 确保抽象方法或者不是实体类的方法才拓展(interface, enum, annotation, unknown) if (!targetMemberInfo.isAbstract) continue;
// 匹配被重写的父(接口)方法(按短签名匹配,完整签名中包含类签名无法用于匹配) String targetShortSign = targetMemberInfo.sign.substring(targetMemberInfo.typeInfo.sign.length() + 1); if (!rawShortSign.equals(targetShortSign)) continue;
// 匹配被重写的父类(接口)被哪些方法调用了 for (MemberInfo mbInfo : targetMemberInfo.usageInfo.values()) { if (rawUsage.sign.equals(mbInfo.sign)) continue; // 防止出现环 rawUsage.usageInfo.put(mbInfo.sign, mbInfo); mbInfo.callInfo.put(rawUsage.sign, rawUsage); } break; } }
// 拓展父类的调用链(针对当前方法所属类为类的场景) if (usage.parentInfo != null) { // 拓展父父接口调用链 for (MemberInfo targetMemberInfo : usage.parentInfo.faceInfo.values()) { expandAbstractCalls(has, targetMemberInfo, rawUsage); }
// 拓展父父类调用链 if (usage.parentInfo.parentInfo != null) { expandAbstractCalls(has, usage.parentInfo.parentInfo, rawUsage); }
// 确保抽象方法或者不是实体类的方法才拓展(interface, enum, annotation, unknown) if (!usage.parentInfo.isAbstract) return;
String rawShortSign = rawUsage.sign.substring(rawUsage.typeInfo.sign.length() + 1); String targetShortSign = usage.parentInfo.sign.substring(usage.parentInfo.typeInfo.sign.length() + 1); if (!rawShortSign.equals(targetShortSign)) return;
// 匹配被重写的父类(类)被哪些方法调用了 for (MemberInfo mbInfo : usage.parentInfo.usageInfo.values()) { if (rawUsage.sign.equals(mbInfo.sign)) continue; // 防止出现环 rawUsage.usageInfo.put(mbInfo.sign, mbInfo); mbInfo.callInfo.put(rawUsage.sign, rawUsage); }
}
}

生成最终调用链

链路分析器生成的方法调用链最终是需要转化成JSON格式进程临时存储的(方便进行网络传输和数据处理),这里的主要两个目标:

  1. 把前序步骤准备好的原始数据进行转换,前序步骤中主要把数据以 TypeInfo和MemberInfo 类型进行存储,这两种类型中字典存在各种引用关系,无法序列化成JSON格式,所以会被转成 ClassInfo和MethodInfo 类型,这两个类型中存的都是具体的值而不是对象引用,可以被序列化成JSON格式的数据。

  2. 标记接口入口方法,并补全接口路径,包括dubbo接口和http接口。


标记dubbo接口类

dubbo接口的定义是通过上面提的的“dubbo-provider.xml”文件进行配置的,该文件中会指定那个接口(interface)以及对应的实现类是dubbo服务,同时这些实现类下面除了被private和protected修饰的方法都是dubbo接口,如下图:


主要逻辑是拿“当前类、实现的所有接口、继承的所有父类”到从前序步骤中提取到的dubbo配置信息中进行匹配,匹配成功则是当前类是dubbo接口类,匹配优先级:当前类 > 实现的所有接口 > 继承的所有父类,部分逻辑如下:

/** * 寻找当前类的dubbo定义 */public static void setDubboApi(ClassInfo classInfo, TypeInfo typeInfo) { if (typeInfo.type != TypeEnum.CLAZZ) return;
DubboInterfaceInfo dIInfo = FileUtils.getDubboInterfaceInfo(classInfo.sign); if (dIInfo != null) { classInfo.dApi = true; classInfo.dInterface = dIInfo.interfaceSign; return; }
// ====从实现接口或父类中寻找==== List<String> pocketBottomSigns = new ArrayList(); if (!classInfo.sign.contains(".")) { // 当sign使用了兜底类型时存储兜底名称,后续统一根据兜底名称查询类的dubbo定义,优先查当前类 pocketBottomSigns.add(classInfo.name); }
// 找我实现的接口 setDubboApiByFace(classInfo, typeInfo, pocketBottomSigns); if (classInfo.dApi) return; // 找我继承的父类 setDubboApiByParent(classInfo, typeInfo, pocketBottomSigns); if (classInfo.dApi) return;
// 根据兜底pocketBottomSigns进行标记dubboApi for (String pds : pocketBottomSigns) { dIInfo = FileUtils.getDubboInterfaceInfo(pds); if (dIInfo != null) { classInfo.dApi = true; classInfo.dInterface = dIInfo.interfaceSign; pocketBottomSigns.clear(); return; } }}
/** * 寻找我实现的接口的dubbo定义 * - 深度优先 * - 不进行查找父接口实现的接口,父接口父父接口已经被统一放到当前类上了 */private static void setDubboApiByFace(ClassInfo classInfo, TypeInfo typeInfo, List<String> pocketBottomSigns) { if (typeInfo.faceInfo.isEmpty()) return;
DubboInterfaceInfo dIInfo; for (Map.Entry pEntry : typeInfo.faceInfo.entrySet()) { TypeInfo pTypeInfo = (TypeInfo)pEntry.getValue(); dIInfo = FileUtils.getDubboInterfaceInfo(pTypeInfo.sign); if (dIInfo != null) { classInfo.dApi = true; classInfo.dInterface = dIInfo.interfaceSign; pocketBottomSigns.clear(); return; } if (!typeInfo.sign.contains(".") && !pocketBottomSigns.contains(classInfo.name)) { // 当sign使用了兜底类型时存储兜底名称,后续统一根据兜底名称查询类的dubbo定义 pocketBottomSigns.add(classInfo.name); } }}
/** * 寻找我继承的类的dubbo定义 * - 深度优先 * - 不进行查找父接口实现的接口,父接口父父接口已经被统一放到当前类上了 */private static void setDubboApiByParent(ClassInfo classInfo, TypeInfo typeInfo, List<String> pocketBottomSigns) { if (typeInfo.parentInfo == null) return; DubboInterfaceInfo dIInfo; // 找父类 dIInfo = FileUtils.getDubboInterfaceInfo(typeInfo.parentInfo.sign); if (dIInfo != null) { classInfo.dApi = true; classInfo.dInterface = dIInfo.interfaceSign; pocketBottomSigns.clear(); return; } if (!typeInfo.parentInfo.sign.contains(".") && !pocketBottomSigns.contains(classInfo.name)) { // 当sign使用了兜底类型时存储兜底名称,后续统一根据兜底名称查询类的dubbo定义 pocketBottomSigns.add(classInfo.name); }
// 找父类继承的父类 if (typeInfo.parentInfo.parentInfo != null) { setDubboApiByParent(classInfo, typeInfo.parentInfo.parentInfo, pocketBottomSigns); }}

提取接口路径&接口协议

主要是提取http接口和dubbo接口,若同一个方法同时是dubbo和http接口则只识别成dubbo接口。主要逻辑如下:


1、优先设置为dubbo接口 


2、寻找当前类的http接口定义  

- 父类为抽象类时:     

- 当前类方法存在http路由,则优先使用当类方法上的路由,且当前方法和父类抽象方法路由不能重复      

- 抽象类中的抽象方法加了http路由注解,当前类方法没有加,则会请求最终会调当前类的方法      

- 抽象类中的非抽象方法如果添加了http路由注解,当前类没有加路由注解,则最终会调到父类类的非抽象方法  

- 实现了接口时:     

- 当前类方法存在http路由,则优先使用当类方法上的路由,但当前方法和被实现接口方法路由可以重复      

- 当前类方法不存在http路由,父类和被实现接口的方法都存在路由时,优先使用接口中方法的路由  


- 类上的路由前缀:     

- 当前类 > 被实现接口 > 父类  


- 方法上的路由:    

- 当前类 > 被实现接口 > 父类

/** * 设置API接口信息 */private static void setApiInfo(ClassInfo classInfo, TypeInfo typeInfo, MethodInfo methodInfo, MemberInfo memberInfo) { if (typeInfo.type != TypeEnum.CLAZZ || memberInfo.isAbstract) return; if (memberInfo.memberType != MemberEnum.METHOD && memberInfo.memberType != MemberEnum.GET_SET) return; // 排除私有和受保护的方法 if (memberInfo.access == AccessEnum.PRIVATE || memberInfo.access == AccessEnum.PROTECTED) return;
// 设置dubbo接口-若有dubbo接口则覆盖http接口协议 if (classInfo.dApi) { methodInfo.isApi = true; methodInfo.apiProtocol = "dubbo"; methodInfo.apiPath = classInfo.dInterface + "::" + methodInfo.name; return; } // 设置http接口-有 hasRestControllerAnnotation 标记的类中的方法才能被http请求调用 if (!typeInfo.hasRestControllerAnnotation) return; if (memberInfo.httpPath != null && typeInfo.httpPathPrefix != null) { methodInfo.isApi = true; methodInfo.apiProtocol = "http"; methodInfo.apiPath = linkHttpPath(typeInfo.httpPathPrefix, memberInfo.httpPath); return; }
// 触发从父类中找寻接口============= String[] httpPaths = new String[]{null, null}; if (typeInfo.httpPathPrefix != null) { httpPaths[0] = typeInfo.httpPathPrefix; } if (memberInfo.httpPath != null) { httpPaths[1] = memberInfo.httpPath; } String shortNameKey = InfoFactory.getShortNameMemberInfoKey(methodInfo.sign, memberInfo); if (!typeInfo.faceInfo.isEmpty()) { setHttpApiByFace(typeInfo, httpPaths, shortNameKey); } if (httpPaths[0] != null && httpPaths[1] != null) { methodInfo.isApi = true; methodInfo.apiProtocol = "http"; methodInfo.apiPath = linkHttpPath(httpPaths[0], httpPaths[1]); return; }
setHttpApiByParent(typeInfo, httpPaths, shortNameKey);
methodInfo.apiPath = linkHttpPath(httpPaths[0], httpPaths[1]); if (methodInfo.apiPath != null) { methodInfo.isApi = true; methodInfo.apiProtocol = "http"; }}
/** * 寻找我实现的接口的http定义 * - 深度优先 * - 不进行查找父接口实现的接口,父接口父父接口已经被统一放到当前类上了 */private static void setHttpApiByFace(TypeInfo typeInfo, String[] httpPaths, String shortNameKey) { for (Map.Entry pEntry : typeInfo.faceInfo.entrySet()) { if (httpPaths[0] != null && httpPaths[1] != null) return;
TypeInfo pTypeInfo = (TypeInfo) pEntry.getValue(); // 找类的接口前缀 if (httpPaths[0] == null) { if (pTypeInfo.httpPathPrefix != null) { httpPaths[0] = pTypeInfo.httpPathPrefix; } if (httpPaths[0] != null && httpPaths[1] != null) return; }
// 开始找方法上的接口 if (httpPaths[1] == null && shortNameKey != null) { MemberInfo pMemberInfo = pTypeInfo.shortNameMemberInfo.get(shortNameKey); if (pMemberInfo != null && pMemberInfo.httpPath != null) { httpPaths[1] = pMemberInfo.httpPath; return; } } }}
/** * 寻找我继承的类的http定义 * - 深度优先 * - 递归查找父父类 * - 不进行查找父类实现的接口,父接口父父接口已经被统一放到当前类上了 */private static void setHttpApiByParent(TypeInfo typeInfo, String[] httpPaths, String shortNameKey) { if (typeInfo.parentInfo == null) return;
if (httpPaths[0] == null) { if (typeInfo.parentInfo.httpPathPrefix != null) { httpPaths[0] = typeInfo.parentInfo.httpPathPrefix; } if (httpPaths[0] != null && httpPaths[1] != null) return; }
// 开始找方法上的接口 if (shortNameKey != null) { if (httpPaths[1] == null) { MemberInfo pMemberInfo = typeInfo.parentInfo.shortNameMemberInfo.get(shortNameKey); if (pMemberInfo != null && pMemberInfo.httpPath != null) { httpPaths[1] = pMemberInfo.httpPath; } } if (httpPaths[0] != null && httpPaths[1] != null) return; setHttpApiByParent(typeInfo.parentInfo, httpPaths, shortNameKey); }}


链路分析器总结

到此链路分析器的工作就完成了,会输出完整的方法调用链原数据 和 API入口方法标记信息,即建立了方法与方法、方法与类、类与类、类与接口(interface)、接口(interface)与接口(interface)之间的第一层关系,如:当前方法调用了哪些方法以及当前方法被哪些方法调用了。


仅有原数据还不能进行变更接口推荐,因为原数据中只维护了一层调用关系,需要借助图数据库来把这些原数据串成一张调用链关系网。


方法调用链存储-图数据库

由于精准测试平台“服务端”推荐技术底层是依赖“方法调用链”的,且需要提供多版本推荐和实时查看能力。由于热点应用总方法量大、方法调用关系复杂、应用数量多、代码版本多、链路收集频率高,传统关系型数据库DB无法满足这种场景,所以需要寻找一款合适的数据库,经过调研论证最终选择使用图数据库(Nebula Graph)来作为底层存储库。Nebula擅长处理 千亿节点万亿条边 的超大规模数据集,并且提供毫秒级查询。


热点应用方法数统计:平均方法数 2万+,50%应用方法数 >  1万个,21%应用方法数 >  3万个,部分应用方法数 > 13.5万个。总数据量极大(方法数 + 类数 + 方法与方法关系数 + 类与类关系数 + 方法与类关系数 + 增量方法关系数),以下是部分应用的方法量级展示:


图数据库(Nebula Graph)简介

“图”在人们的日常生活中出现的频率非常高,开心时发个靓照到朋友圈、网购时看完卖家秀再去评论区看看买家秀,这些都涉及到“图”,也许你想象的图是这样的 ↓↓↓


Nebula Graph的“图”和日常生活中的“图”有些差异,日常说的图一般指的的图片(Image),这里所说的图( Graph)其实是一种“关系图”,更侧重体现事物之间的关系。一张图(Graph)由一些小圆点(称为顶点或节点,即 Vertex 或 Node)和连接这些圆点的直线或曲线(称为边,即 Edge 或 Relationship)组成,大致是这样的 ↓↓↓


Nebula Graph 是一款开源的分布式高性能图数据库,由Facebook开发。专门用于存储和处理巨大规模的图数据,擅长处理 千亿节点万亿条边 的超大规模数据集,并且提供毫秒级查询:


  1. 采用了分布式架构,将图数据分片存储在多个服务器上,以实现水平扩展和高可用性

  2. 提供了丰富的功能,包括顶点和边的属性和索引管理、多层次的图模型、高效的图遍历算法等

  3. 支持查询语言 Cypher、GQL、NGQL


Nebula中主要用“点类型”和“边类型”来定义数据,可以联想到关系型数据库中的“表”,“点”和“边”则对应为具体的数据实例,可以了联想到关系型数据库中的“行记录”。使用过程中“点”一般代表具体的事务并存储自身的属性数据,“边”则用于体现点与点之间的关系。同时点和边都支持写入属性值用于存储信息


自研轻量级图ORM库介绍

由于Nebula Graph 对于Python没有提供好用的ORM库,返回的结果是原生的数据格式,使用起来比较麻烦,所以平台自研了轻量级的图ORM(Nebula Graph ORM)库。


精准测试方法调用链中主要涉及到2种点类型和4种边类型,将方法和类(类或接口)设计成2种点类型,方法与方法、类与类、方法与类、变更方法与类这四种关系设计成“边类型”,具体模型定义如下:


class JMethodTag(Tag):Java方法-点类型,存储方法的相关属性信息

class JMethodTag(Tag): """ Java方法-点类型 """ __NAME_PREFIX__ = 'tjm_' __VID_PREFIX__ = 'tjm_' # vid前缀
vid = Vid('vid', String, nullable=False, comment='点ID') name = Field('name', String, nullable=False, comment='简称') sign = Field('sign', String, nullable=False, comment='签名(全称)', index_len=50) type = Field('type', String, default='', nullable=False, comment='方法类型(method,constructor,static,get_set,field)') pkName = Field('pkName', String, default='', nullable=False, comment='包名') abs = Field('abs', Bool, default=False, nullable=False, comment='是否是抽象类') api = Field('api', Bool, default=False, nullable=False, comment='是否是API接口', index_len=None) aPtl = Field('aPtl', String, default='', nullable=False, comment='接口协议(http,dubbo,grpc)', index_len=20) aPath = Field('aPath', String, default='', nullable=False, comment='API接口路径(http: /xx/xx, dubbo: com.xx.xx.Cxx::mxx, /grpc.CntCenterService/MGetTrendInfo)', index_len=100) cName = Field('cName', String, default='', nullable=False, comment='所属类简称') cSign = Field('cSign', String, default='', nullable=False, comment='所属类签名', index_len=50) cType = Field('cType', String, default='', nullable=False, comment='所属类类型(class,interface,annotation,enum,unknown)') parNames = Field('parNames', String, default='', nullable=False, comment='参数名(多个";"分割)') parTypes = Field('parTypes', String, default='', nullable=False, comment='参数类型名(多个";"分割)') retType = Field('retType', String, default='', nullable=False, comment='返回类型名') calls = Field('calls', String, default='', nullable=False, comment='调用哪些方法调用(多个";"分割)') usages = Field('usages', String, default='', nullable=False, comment='被哪些方法调用(多个";"分割)') sLine = Field('sLine', Int, default=0, nullable=False, comment='开始行(从1开始)') eLine = Field('eLine', Int, default=0, nullable=False, comment='结束行') zsScan = Field('zsScan', String, default='', nullable=False, comment='资损字段')

class JClassTag(Tag):Java类-点类型,存储类/接口的相关属性信息

class JClassTag(Tag): """ Java类-点类型 """ __NAME_PREFIX__ = 'tjc_' __VID_PREFIX__ = 'tjc' # vid前缀
vid = Vid('vid', String, nullable=False, comment='点ID') name = Field('name', String, nullable=False, comment='简称') sign = Field('sign', String, nullable=False, comment='签名(全称)', index_len=50) type = Field('type', String, default='', nullable=False, comment='类类型(class,interface,annotation,enum,unknown)') pkName = Field('pkName', String, default='', nullable=False, comment='包名') abs = Field('abs', Bool, default=False, nullable=False, comment='是否是抽象类') dApi = Field('dApi', Bool, default=False, nullable=False, comment='是否是dubbo接口类') dInf = Field('dInf', String, default='', nullable=False, comment='dubbo接口interface值') hPrefix = Field('hPrefix', String, default='', nullable=False, comment='http接口路径前') faces = Field('faces', String, default='', nullable=False, comment='被我实现的接口(多个";"分割)') impls = Field('impls', String, default='', nullable=False, comment='实现我的接口(多个";"分割)') p = Field('p', String, default='', nullable=False, comment='我继承的父类') subs = Field('subs', String, default='', nullable=False, comment='继承我的子类(多个";"分割)') sLine = Field('sLine', Int, default=0, nullable=False, comment='开始行(从1开始)') eLine = Field('eLine', Int, default=0, nullable=False, comment='结束行') zsScan = Field('zsScan', String, default='', nullable=False, comment='资损字段')

class JClassClassEdge(Edge):Java类->类-边类型

class JClassClassEdge(Edge): """ Java类->类-边类型 """ __NAME_PREFIX__ = 'ejcc_'
FACE_RELATION = 'face' PARENT_RELATION = 'parent'
src_vid = Vid('src_vid', String, nullable=False, comment='起始点ID') dst_vid = Vid('dst_vid', String, nullable=False, comment='目的点ID') relation = Field('relation', String, default='', nullable=False, comment='类->类关系(face: 我实现的接口, parent: 我的父类)')

class JMethodMethodEdge(Edge):Java方法->方法-边类型

class JMethodMethodEdge(Edge): """ Java方法->方法-边类型 """ __NAME_PREFIX__ = 'ejmm_'
CALL_RELATION = 'call' REWRITE_RELATION = 'rewrite' HEAVY_LOD_RELATION = 'heavy_load'
src_vid = Vid('src_vid', String, nullable=False, comment='起始点ID') dst_vid = Vid('dst_vid', String, nullable=False, comment='目的点ID') relation = Field('relation', String, default='', nullable=False, comment='方法->方法关系(call: 调用, rewrite: 重写,heavy_load:重载)') api = Field('api', Bool, default=False, nullable=False, comment='src(开始)点是否是接口')

class JMethodClassEdge(Edge):Java方法->类-边类型

class JMethodClassEdge(Edge): """ Java方法->类-边类型 """ __NAME_PREFIX__ = 'ejmc_'
MYC_RELATION = 'myc'
src_vid = Vid('src_vid', String, nullable=False, comment='起始点ID') dst_vid = Vid('dst_vid', String, nullable=False, comment='目的点ID') relation = Field('relation', String, default='', nullable=False, comment='方法->类关系(myc: 我的类)')

class JChangeClassMethodEdge(Edge):Java变更(类->方法)-边类型

class JChangeClassMethodEdge(Edge): """ Java变更(类->方法)-边类型 - 推荐接口任务产生的增量方法和类 """ __NAME_PREFIX__ = 'ejccm_'
src_vid = Vid('src_vid', String, nullable=False, comment='起始点ID') dst_vid = Vid('dst_vid', String, nullable=False, comment='目的点ID') contrast_v = Field('contrast_v', String, default='', nullable=False, comment='对比版本(commit)')


调用链展示


使用图数据库(Nebula Graph)获取调用链信息

  • 获取到变更类下面的方法(先获取目标点ID,再根据点ID查询点数据性能最佳),查询语句如下:

MATCH (v:{tag_name}) WHERE v.{tag_name}.cSign IN [{cSignsStr}] RETURN id(v);

MATCH (v:{tag_name}) WHERE id(v) IN [{tag_name_vid_str}] RETURN v;


  • 使用点ID根据“JMethodMethodEdge边”遍历找出调用链中是接口的入口方法:

    这里使用 GO 语句,并设置1到50跳来遍历(即从原始点开始找50层关系,如:a() -> b() -> c()则a()到b()是1跳b()到c()是1跳a()到c() 是2跳)

GO 1 TO 50 STEPS FROM {vid_str} OVER {edge_name} REVERSELY WHERE properties(edge).api == true YIELD DISTINCT src(edge);


  • 查找增量类和增量方法

    通过“JChangeClassMethodEdge边”的属性“contrast_v”的值来判断哪些是目标增量边,根据增量边的起始点ID和结束点ID来获取最终的增量方法和增量类

MATCH (v_c:{c_tag_name})-[e:{ccme_name}]->(v_m:{m_tag_name}) RETURN e, v_m;


如:获取增量变更方法

def get_add_java_method(self, **kwargs) -> ResponseResult: """获取增量变更方法""" app_name = kwargs.get('app_name') current_version = kwargs.get('current_version') contrast_version = kwargs.get('contrast_version') zs_scan = kwargs.get('zs_scan') ... tag_handler = TagHandler(app_name, current_version) c_tag_name = tag_handler.get_tag_name(JClassTag) m_tag_name = tag_handler.get_tag_name(JMethodTag) edge_handler = EdgeHandler(app_name, current_version) ccme_name = edge_handler.get_edge_name(JChangeClassMethodEdge)
# 模版 MATCH (v_c:tag_name1)-[e:edge_name]->(v_m:tag_name2) RETURN e, v_m; n_gql = f"MATCH (v_c:{c_tag_name})-[e:{ccme_name}]->(v_m:{m_tag_name}) RETURN e, v_m;" _res = edge_handler.session.execute_json(n_gql) if type(_res) is bytes: _res = _res.decode(encoding='utf-8') # Nebula Graph Studio前端客户端工具报【-1009:SemanticError: PlanNode(Start) not support to append an input.】错是客 # 户端版本不匹配,代码里可以支持 res: dict = json.loads(_res) if res['errors'][0]['code'] != 0: err = f"{res['errors'][0]['code']}:{res['errors'][0].get('message')}" return ResponseResult.fail_response(message='获取增量变更方法失败', err=err)
# 筛选&组装数据 raws = res['results'][0]['data'] res_class_zs = [] res_method_rows = [] class_zs_dict = {} for raw in raws: _inner_row = raw['row'] # 对比版本commitID只取前8位 if _inner_row[0]['contrast_v'][:8] == contrast_version[:8]: _new_row = { "meta": [{ "type": "vertex", "id": raw['meta'][1]['id'] }], "row": [_inner_row[1]] } res_method_rows.append(_new_row)
method_tags: List[JMethodTag] = tag_handler.convert_raw_to_tag_model(res_method_rows, JMethodTag)
# 获取类的资损标签 if zs_scan and method_tags: cSign_set = set() # 获取增量类 for method_tag in method_tags: _fsr_cSign = String.fsr(method_tag.cSign) if _fsr_cSign not in cSign_set: cSign_set.add(_fsr_cSign) n_gql = f'MATCH (v:{c_tag_name}) ' \ f'WHERE v.{c_tag_name}.zsScan != "" AND v.{c_tag_name}.sign IN [{",".join(cSign_set)}] ' \ f'RETURN v.{c_tag_name}.sign, v.{c_tag_name}.zsScan;'
_res = edge_handler.session.execute_json(n_gql) if type(_res) is bytes: _res = _res.decode(encoding='utf-8') # 户端版本不匹配,代码里可以支持 res: dict = json.loads(_res) if res['errors'][0]['code'] != 0: err = f"{res['errors'][0]['code']}:{res['errors'][0].get('message')}" return ResponseResult.fail_response(message='获取增量变更方法失败', err=err) raws = res['results'][0]['data'] for raw in raws: _row = raw['row'] class_zs_dict[_row[0]] = { 'class_sign': _row[0], 'zs': _row[1] }
class_infos = [] class_tag_info_dict = {} class_n = 0
for method_tag in method_tags: class_tag_info = class_tag_info_dict.get(method_tag.cSign) if class_tag_info is None: class_tag_info = class_tag_info_dict[method_tag.cSign] = { 'class_sign': method_tag.cSign, 'class_zs': '' if class_zs_dict.get(method_tag.cSign) is None else class_zs_dict.get(method_tag.cSign)['zs'], 'methods': [] } class_infos.append(class_tag_info) class_n += 1
# 转化成JSON _method_tag = method_tag.__dict__.copy() _calls = _method_tag['calls'] _method_tag['calls'] = _calls.split(';') if _calls else [] _usages = _method_tag['usages'] _method_tag['usages'] = _usages.split(';') if _usages else [] class_tag_info['methods'].append(_method_tag)
return ResponseResult.success_response(data={'class_n': class_n, 'method_n': len(method_tags), 'class_infos': class_infos})


差异提取器

根据 code diff(最新提交 - 线上提交),从code diff中提取变更的方法,结合知识库找到 具体的变更 接口,变更接口 结合 方法调用链,定位到影响接口。


其中根据 code diff 提取变更方法,是接口git 的diff工具和抽象语法树来实现的,这种方式可以获取到真正有效的改动代码,而不是diff里面有增量就认为是增量(如只是加了行注释),实现逻辑较为复杂由于篇幅原因,详细介绍请看下回分解。


总结

精准测试是一套有效提高软件测试质量和效率的技术体系,可以有效解决传统测试中的盲测、漏测、冗测等现象提升测试效率和准确性,前置暴露风险保障上线质量。本文主要介绍了得物精准测试平台推荐引擎中部分核心功能的相关实现方式。精准测试平台为质量保障提供了有效的拓展策略,也带来了不错的收益。


往期回顾


1.解析Go切片:为何按值传递时会发生改变?|得物技术

2.基于IM场景下的Wasm初探:提升Web应用性能|得物技术

3.实时特征框架的生产实践|得物技术

4.增长在流量规则巡检的探索实践|得物技术

5.自研E2E为稳定性保驾护航 | 得物技术


文 / 千仞


关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:


继续滑动看下一个
得物技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存