从分析一段 TS 代码开始
上一篇我们学习了 AST 相关的知识,了解到 AST 是开发代码分析工具的前提,同时也学会了通过 TypeScript 的 CompilerAPI 来获取 TS 代码的 AST。最后我们还得出一个结论:代码的含义可以通过 AST 对象来表述,通过程序对 AST 进行处理就可以实现分析代码的目的。
既然想要通过程序实现对全部子应用项目代码中真实 API 调用的分析,我们就需要先学会如何分析单个项目中的 API 调用信息,对单个项目进行分析的前提,是先学习如何对单个 TS 文件中的 API 调用进行分析,而学习一项技能最好的方式就是先学习它的最小可用性实现,所以本篇我们先从如何分析一小段 TS 代码中的 API 调用讲起,内容主要以代码实操为主,讲解过程中会穿插一些 TypeScript CompilerAPI 的介绍,旨在帮助大家亲自实现一个简单的分析脚本, 迈出开发代码分析工具的第一步。
下面是一段 TS 代码片段,直接看代码的话,它的 API 调用信息无非就是从依赖 framework 中引入了名为 app 的 API,并且在第 6 行和第 11 行中进行了调用。
import { app } from 'framework';
const dataLen = 3;
let name = 'iceman';
if(app){
console.log(name);
}
function getInfos (info: string) {
const result = app.get(info);
return result;
}但如果我们想通过程序的方式从代码片段中找出 app 这个 API 被导入后是否有调用,以及调用的次数,代码行分布等信息,该如何实现呢?
将待分析的代码解析为 AST
第一步,将需要分析的代码解析为 AST,因为 AST 是代码分析的前提,对如何生成 AST 对象还不清楚的同学可以回顾第 2 篇程的内容,实现代码如下:
const tsCompiler = require('typescript'); // TS编译器
// 待分析代码片段字符串
const tsCode = `import { app } from 'framework';
const dataLen = 3;
let name = 'iceman';
if(app){
console.log(name);
}
function getInfos (info: string) {
const result = app.get(info);
return result;
}`;
// 获取AST
const ast = tsCompiler.createSourceFile('xxx', tsCode, tsCompiler.ScriptTarget.Latest, true);
console.log(ast);因为分析目的很简单,所以这里我们通过 createSourceFile 这个 Compiler API 来获取 AST,完成第一步后,我们将 ast 对象用 console 的方式打印出来,发现它是一个很复杂的 JS 对象。那接下来我们要做什么呢?
观察代码的 AST 结构及特征
要分析代码,那肯定要先弄清楚代码对应的 AST 节点组成信息,直接通过第一步打印的结果来观察节点信息肯定是不行的,我们可以把要分析的代码片段放到 AST explorer 中,结合可视化工具来观察节点及相关特征:
我们发现要分析的代码片段总共包含 5 条语句,分别为 ImportDeclaration、VariableStatement、VariableStatement、IfStatement、FunctionDeclaration,然后这 5 个 AST 节点再继续派生出更详细的子节点,共同组成了映射这段 TS 代码的 AST 语法树结构,我们把所有的 AST 节点类型以树状图的形式展示,会看得更直观一些:
细心的同学可能已经发现了,我对所有的 Identifier 类型节点进行了标注,回顾一下第 2 篇中我们关于 AST 节点类型的相关知识,Identifier 节点通常为变量名、属性名、参数名等等一系列声明和引用的名字,既然 Identifier 代表的是各种名字,而我们要寻找的是 app 这个 API 在代码中的调用情况,那是不是可以通过遍历所有 Identifier 类型节点并判断它的名字是否为 app,以此来判定 app 这个 API 它有没有被调用呢?
遍历分析 AST 各级节点
既然有了思路,那我们接下来就需要学习如何遍历 AST 并找到所有的 Identifier 类型节点。这里我向大家介绍 TypeScript 一个非常重要的 CompilerAPI : forEachChild,它可以帮助我们实现对 AST 各层级节点的深度遍历,该方法第一个参数是指 AST 根节点或正在被遍历的子节点,第二个参数是一个回调函数,我们可以把对当前节点的判定逻辑写在里面,完善后的分析脚本如下:
const tsCompiler = require('typescript'); // TS编译器
// 待分析代码片段字符串
const tsCode = `import { app } from 'framework';
const dataLen = 3;
let name = 'iceman';
if(app){
console.log(name);
}
function getInfos (info: string) {
const result = app.get(info);
return result;
}`;
// 获取AST
const ast = tsCompiler.createSourceFile('xxx', tsCode, tsCompiler.ScriptTarget.Latest, true);
console.log(ast);
function walk (node) { // AST遍历函数
tsCompiler.forEachChild(node, walk); // 遍历AST节点
console.log(node); // 输出节点信息
}
walk(ast); // 执行遍历分析脚本会将遍历后的 AST node 节点信息依次打印出来,大家可以亲自在本地执行一下,并观察输出的节点信息有些什么内容。
我们遍历每个 AST 节点的目的并不是为了单纯地输出它的节点信息,而是为了判断它是否为 Identifier 类型节点,如果它是,那么接着获取该节点的名称并判断它是否为 app,如果不是就跳过该节点。
那么,该如何判定节点的类型呢?TypeScript 提供了一系列判断节点类型的 CompilerAPI:
const tsCompiler = require('typescript');
// 判断节点类型的函数,返回值类型为 boolean
tsCompiler.isFunctionDeclaration(node); // 判定是否为函数声明节点
tsCompiler.isArrowFunction(node); // 判定是否为箭头函数
tsCompiler.isTypeReferenceNode(node); // 判定是否为Type类型节点
tsCompiler.isVariableDeclaration(node); // 判定是否为变量声明节点
tsCompiler.isIdentifier(node); // 判定是否为Identifier节点了解完上面这些知识后,我们可以在 walk 函数中使用 tsCompiler.isIdentifier(node) 来判定当前节点是否为 Identifier 类型节点,进而判断它的名字是否为 app,如果两个条件都满足的话,我们把这个节点的信息记录到一个 Map 结构中,Map 结构非常适合需要统计信息的分析场景,我们以 API 名称作为 key,将 API 调用相关的信息以对象的形式写入 value,等遍历完 AST 的全部节点后,就可以得到 app 这个 API 在代码中真实的调用情况了。
可以通过 ast 对象上的 getLineAndCharacterOfPosition 方法获取当前遍历节点的代码行信息:
// 获取当前node节点所在代码行
ast.getLineAndCharacterOfPosition(node.getStart()).line + 1;基于上面这些知识,我们再次完善一下分析脚本:
const tsCompiler = require('typescript'); // TS编译器
// 待分析代码片段字符串
const tsCode = `import { app } from 'framework';
const dataLen = 3;
let name = 'iceman';
if(app){
console.log(name);
}
function getInfos (info: string) {
const result = app.get(info);
return result;
}`;
// 获取AST
const ast = tsCompiler.createSourceFile('xxx', tsCode, tsCompiler.ScriptTarget.Latest, true);
console.log(ast);
const apiMap = {}; // 记录API分析结果
function walk (node) { // AST遍历函数
tsCompiler.forEachChild(node, walk); // 遍历AST节点
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + 1; // 获取节点所在行
if(tsCompiler.isIdentifier(node) && node.escapedText ==='app') { // 判断isIdentifier节点名称是否为app
if(Object.keys(apiMap).includes(node.escapedText)){
apiMap[node.escapedText].callNum++;
apiMap[node.escapedText].callLines.push(line);
}else{
apiMap[node.escapedText] = {}
apiMap[node.escapedText].callNum =1;
apiMap[node.escapedText].callLines = [];
apiMap[node.escapedText].callLines.push(line);
}
}
}
walk(ast);
console.log(apiMap); // 输出分析结果
// {
// app: {
// callNum: 3,
// callLines: [1,6,11]
// }
// }然后执行分析脚本,会发现输出的结果并不符合预期,因为我们遍历了所有的节点,这些节点里面自然也包含 import 节点中的 app,应该排除这个干扰,所以加一个判断条件后,代码如下:
const tsCompiler = require('typescript'); // TS编译器
// 待分析代码片段字符串
const tsCode = `import { app } from 'framework';
const dataLen = 3;
let name = 'iceman';
if(app){
console.log(name);
}
function getInfos (info: string) {
const result = app.get(info);
return result;
}`;
// 获取AST
const ast = tsCompiler.createSourceFile('xxx', tsCode, tsCompiler.ScriptTarget.Latest, true);
console.log(ast);
const apiMap = {}; // 记录API分析结果
function walk (node) { // AST遍历函数
tsCompiler.forEachChild(node, walk); // 遍历AST节点
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + 1; // 获取节点所在行
if(tsCompiler.isIdentifier(node) && node.escapedText ==='app') { // 判断isIdentifier节点名称是否为app
if(line !=1){ // 排除import导入自身
if(Object.keys(apiMap).includes(node.escapedText)){
apiMap[node.escapedText].callNum++;
apiMap[node.escapedText].callLines.push(line);
}else{
apiMap[node.escapedText] = {}
apiMap[node.escapedText].callNum =1;
apiMap[node.escapedText].callLines = [];
apiMap[node.escapedText].callLines.push(line);
}
}
}
}
walk(ast); // 遍历AST
console.log(apiMap); // 输出分析结果
// {
// app: {
// callNum: 2,
// callLines: [6, 11]
// }
// }执行脚步,我们得到了符合预期的分析结果,即这段 TS 代码中有引入 app 这个 API,且有 2 次调用,分别在代码第 6 行,第 11 行。
分析脚本存在的问题
但是,但是,但是,
我们再仔细思考一下,我们实现分析脚本的思路有没有不妥?
首先,我们并没有先对 import 节点进行分析,如果代码中都不存在 import 导入语句,那么通过遍历所有 Identifier 类型节点名称是否为 app 这样的判定思路,它只能找到与 app 相同名称的 AST 节点而已,这并不能代表这些 app 都是从 framework 导入的,什么意思呢?
import { app } from 'framework'; // import app 定义
const dataLen = 3;
let name = 'iceman';
function doWell () {
const app =4; // 局部常量 app 定义
return app; // 局部常量 app 调用
}
function getInfos (info: string) {
const result = app.get(info); // import app 调用
return result;
}我们在要分析的代码片段中增加一个新的函数 doWell,并在它里面定义一个局部常量也叫 app,这个时候我们的分析脚本是无法区分 doWell 方法中的 app 与 getInfos 方法中的 app 的,即无法判定同名 Identifier 节点是否属于同一语义上下文。
其它的问题:
-
无法判断
app这个 API 的具体用途(类型、属性、方法、类...); -
无法区分
app.get链式调用与app直接调用这两种场景; -
排除 import 节点的判断方式不准确。
可见设计一个完整的代码分析工具还需要学习很多知识,这些我们都会在后面的文档中娓娓道来。在学习后续的文档前,我们先来明确一个事情:依赖调用分析到底要做些什么?
依赖调用分析到底要做些什么?
简单来说,首先针对每一个需要分析的 TS(JS) 文件:
- 遍历其所有
import节点(上图绿框区域),分析并记录从目标依赖引入的API信息,并排除非目标依赖项的干扰。 - 判定引入的
API在具体代码中(上图红框区域)是否有调用,过程中还需要排除局部同名变量等一系列干扰。 - 根据分析指标如用途识别(类型、属性、方法)等对该
API调用进行指标判定分析,命中则记录到指定 Map 中。
然后按照上面的步骤依次遍历所有项目中指定的 TS(JS) 文件,就可以得到全部应用对于特定依赖(如:framework)的 API 调用分析数据了,最后根据使用场景(告警、评分、代码报告、代码建议等)对分析数据进行标记,二次整理,即可输出最终的分析结果。
小结
这一小节我们学习了如何实现简易的 TS 代码分析脚本,旨在帮助大家迈出开发代码分析工具的第一步,通过分析脚本,我们找到了 API 在代码中的调用位置、调用频次,不过脚本还存在一些缺陷。想要设计完整的代码分析工具还需要学习很多知识。
下一节课我们会学习代码分析工具的 架构设计与分析范式,相关内容会贯穿第二阶段所有的章节,非常重要。