{T}

从分析一段 TS 代码开始

上一篇我们学习了 AST 相关的知识,了解到 AST 是开发代码分析工具的前提,同时也学会了通过 TypeScript 的 CompilerAPI 来获取 TS 代码的 AST。最后我们还得出一个结论:代码的含义可以通过 AST 对象来表述,通过程序对 AST 进行处理就可以实现分析代码的目的。

既然想要通过程序实现对全部子应用项目代码中真实 API 调用的分析,我们就需要先学会如何分析单个项目中的 API 调用信息,对单个项目进行分析的前提,是先学习如何对单个 TS 文件中的 API 调用进行分析,而学习一项技能最好的方式就是先学习它的最小可用性实现,所以本篇我们先从如何分析一小段 TS 代码中的 API 调用讲起,内容主要以代码实操为主,讲解过程中会穿插一些 TypeScript CompilerAPI 的介绍,旨在帮助大家亲自实现一个简单的分析脚本, 迈出开发代码分析工具的第一步。

下面是一段 TS 代码片段,直接看代码的话,它的 API 调用信息无非就是从依赖 framework 中引入了名为 app 的 API,并且在第 6 行和第 11 行中进行了调用。

typescript
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 篇程的内容,实现代码如下:

typescript
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 条语句,分别为 ImportDeclarationVariableStatementVariableStatementIfStatementFunctionDeclaration,然后这 5 个 AST 节点再继续派生出更详细的子节点,共同组成了映射这段 TS 代码的 AST 语法树结构,我们把所有的 AST 节点类型以树状图的形式展示,会看得更直观一些:

细心的同学可能已经发现了,我对所有的 Identifier 类型节点进行了标注,回顾一下第 2 篇中我们关于 AST 节点类型的相关知识,Identifier 节点通常为变量名、属性名、参数名等等一系列声明和引用的名字,既然 Identifier 代表的是各种名字,而我们要寻找的是 app 这个 API 在代码中的调用情况,那是不是可以通过遍历所有 Identifier 类型节点并判断它的名字是否为 app,以此来判定 app 这个 API 它有没有被调用呢?

遍历分析 AST 各级节点

既然有了思路,那我们接下来就需要学习如何遍历 AST 并找到所有的 Identifier 类型节点。这里我向大家介绍 TypeScript 一个非常重要的 CompilerAPI : forEachChild,它可以帮助我们实现对 AST 各层级节点的深度遍历,该方法第一个参数是指 AST 根节点或正在被遍历的子节点,第二个参数是一个回调函数,我们可以把对当前节点的判定逻辑写在里面,完善后的分析脚本如下:

typescript
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:

typescript
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 方法获取当前遍历节点的代码行信息:

typescript
// 获取当前node节点所在代码行
ast.getLineAndCharacterOfPosition(node.getStart()).line + 1;

基于上面这些知识,我们再次完善一下分析脚本:

typescript
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,应该排除这个干扰,所以加一个判断条件后,代码如下:

typescript
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 导入的,什么意思呢?

typescript
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 方法中的 appgetInfos 方法中的 app 的,即无法判定同名 Identifier 节点是否属于同一语义上下文。

其它的问题:

  1. 无法判断 app 这个 API 的具体用途(类型、属性、方法、类...);

  2. 无法区分 app.get 链式调用与 app 直接调用这两种场景;

  3. 排除 import 节点的判断方式不准确。

可见设计一个完整的代码分析工具还需要学习很多知识,这些我们都会在后面的文档中娓娓道来。在学习后续的文档前,我们先来明确一个事情:依赖调用分析到底要做些什么?

依赖调用分析到底要做些什么?

简单来说,首先针对每一个需要分析的 TS(JS) 文件:

  1. 遍历其所有 import 节点(上图绿框区域),分析并记录从目标依赖引入的 API 信息,并排除非目标依赖项的干扰。
  2. 判定引入的 API 在具体代码中(上图红框区域)是否有调用,过程中还需要排除局部同名变量等一系列干扰。
  3. 根据分析指标如用途识别(类型、属性、方法)等对该 API 调用进行指标判定分析,命中则记录到指定 Map 中。

然后按照上面的步骤依次遍历所有项目中指定的 TS(JS) 文件,就可以得到全部应用对于特定依赖(如:framework)的 API 调用分析数据了,最后根据使用场景(告警、评分、代码报告、代码建议等)对分析数据进行标记,二次整理,即可输出最终的分析结果。

小结

这一小节我们学习了如何实现简易的 TS 代码分析脚本,旨在帮助大家迈出开发代码分析工具的第一步,通过分析脚本,我们找到了 API 在代码中的调用位置、调用频次,不过脚本还存在一些缺陷。想要设计完整的代码分析工具还需要学习很多知识。

下一节课我们会学习代码分析工具的 架构设计分析范式,相关内容会贯穿第二阶段所有的章节,非常重要。