{T}

架构设计 & 分析范式

上一篇我们实现了一个分析 TS 代码的简易脚本,虽然存在一些缺陷,但让大家对代码依赖调用分析到底要做些什么有了一个最基础的了解,在文档最后我提到想要开发完整的分析工具,还需要学习很多知识。那从这一小节开始,我们的学习旅程就进入了第二阶段,即代码分析工具的具体实现阶段。

这一小节我们会介绍依赖调用分析模型,代码分析基础类设计,以及分析工具的分析范式、架构设计等概念,这些内容都比较抽象,甚至有一些概念大家会比较陌生,至于为什么把这些内容放在具体实现阶段的第一节,是因为我想让大家在学习具体的代码实现前,可以先在脑海中建立起通用方法论 & 学习框架,明确之后每个章节的学习目的。

对于学习新概念,我推荐的学习方式是先学习它的最小可用实现,在了解完代码依赖调用分析到底是要做什么后,我们不妨思考一下,如何最小化依赖调用分析这件事。

依赖调用分析模型

依赖调用分析这个概念中有两种角色,也就是我们在第 1 篇程中反复提到的 依赖调用方依赖提供方。如果我们把依赖调用方比作 A, 依赖提供方比作 B,那不论多复杂的项目,总共多少个项目团队,巨型应用中的依赖关系都可以用 A 与 B 来表述,也就是依赖与被依赖。在微前端架构中,B 代表的就是基础项目(主应用、基础框架、组件库、工具库),A 代表的是子应用项目。

依赖调用方 就是我们要分析的代码源,依赖提供方 是提供 API 的代码源。针对 依赖调用方 仓库中每个 TS(JS) 代码文件,首先分析其所有 import 节点来获取 API 导入情况,然后在代码体中判定这些导入的 API 是否被调用,根据分析指标分析并记录相关信息,然后汇总统计 A 中所有 TS 文件的 API 调用信息并加以整理,生成依赖调用分析报告,便可以了解 A 与 B 真实的依赖调用关系。

基于微前端架构的大型 Web 应用中依赖调用方肯定不止有1个,那么依次分析所有依赖调用方的代码便可以了解依赖提供方导出的 API 在所有项目中真实的调用情况,进而生成代码分析报告,帮助开发者了解并管控依赖关系。

上一篇在实现分析脚本时,我们采用的是面向过程的编程思想。也就是说,我们设计的分析程序是盯着某个代码示例本身要表达的意思去设计的,之所以要将依赖分析这个事情抽象成分析模型,是希望大家可以站在更高的维度去思考如何设计解决某一类问题的通用方法论,而不是局限在解决具体某一个问题的方法里面。那我们应该以什么样的编程思想去设计分析程序呢?

面向对象编程

面向对象编程(Object Oriented Programming),简称 OOP ,与面向过程编程一样,也是一种编程思想。 它旨在将真实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。面向对象是过程式代码的一种高度封装,目的在于提高代码的开发效率和可维护性。

在面向对象程序开发思想中,每一个对象都是功能中心,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。因此,面向对象编程具有灵活、代码可复用、高度模块化等特点,容易维护和开发,比起由一系列函数组成的面对过程编程(procedural programming),更适合多人合作的大型软件项目。

举个例子: Webpack 在每次处理进程中时会创建一个名为Compiler 的对象,它是一个编译器实例,与打包构建相关的属性、方法和上下文都封装在这个对象中。一次完整的 Webpack 打包构建过程是由这个 Compiler 对象来驱动完成的,它很好的诠释了面向对象的编程思想。既然我们开发的也是代码处理工具,那不妨借鉴一下优秀工具的设计理念,设计一个依赖调用分析的基础抽象类。

基础类设计

基于面向对象的设计思想,我们把依赖调用分析过程中涉及的方法,属性都封装在一个名为 codeAnalysis 的基础类中,可以把它理解成依赖调用分析流程的抽象,后续文档都会围绕它展开,它是代码分析工具的核心,通过 new 生成分析实例后,我们可以通过分析实例来驱动整个分析流程。

下面是 codeAnalysis 基础类的代码框架简化版演示(完整源码参照 analysis.js):

typescript
......
const { parseTs } = require(path.join(__dirname, './parse'));  // 解析模块
......

class CodeAnalysis {
  constructor(options) {
    ......
    // 私有属性
    this._scanSource = options.scanSource;                     // 扫描源配置信息
    this._analysisTarget = options.analysisTarget;             // 要分析的目标依赖配置
    this._blackList = options.blackList || [];                 // 需要标记的黑名单API配置
    this._scorePlugin = options.scorePlugin || null;           // 代码评分插件配置
    this._analysisPlugins = options.analysisPlugins || [];     // 代码分析插件配置
    // 公共属性
    this.pluginsQueue = [];                                    // Targer分析插件队列
    this.browserQueue = [];                                    // Browser分析插件队列
    this.importItemMap = {};                                   // importItem统计Map
    // this.apiMap = {};                                       // 未分类API统计Map(插件挂载)
    // this.typeMap = {};                                      // 类型API统计Map(插件挂载)
    // this.methodMap = {};                                    // 方法API统计Map(插件挂载)
    // this.browserMap = {};                                   // BrowserAPI统计Map(插件挂载)
    this.parseErrorInfos = [];                                 // 解析异常信息数组
    this.diagnosisInfos = [];                                  // 诊断日志信息数组
    this.scoreMap = {};                                        // 代码评分及建议Map
    ......
  }
  // API黑名单标记
  _blackTag() {
    ......
  }
  // 注册插件
  _installPlugins() {
    ......
  }
  // 链式调用检查,找出链路顶点node
  _checkPropertyAccess() {
    ......
  }
  // 执行分析插件队列中的checkFun函数
  _runAnalysisPlugins() {
    ......
  }
  // 执行分析插件队列中的afterHook函数
  _runAnalysisPluginsHook() {
    ......
  }
  // 分析import节点
  _findImportItems(ast, ...) {
    let importItems = {};
    // 处理imports相关map
    function dealImports(){
      // 处理importItems
    }
    // 遍历AST寻找import节点
    function walk(node) {
      ......
      tsCompiler.forEachChild(node, walk);
      // 分析引入情况
      dealImports();
      ......
    }
    walk(ast);
    // 返回 API 导入情况
    return importItems;
  }
  // API调用分析
  _dealAST(importItems, ast, ...) {
    ......
    const that = this;
    // 遍历AST
    function walk(node) {
        ......
        tsCompiler.forEachChild(node, walk);
        // 获取基础分析节点信息
        const { baseNode, depth, apiName } = that._checkPropertyAccess();
        // 执行分析插件
        that._runAnalysisPlugins();
        ......
    }
    walk(ast);
    // AST遍历结束,执行afterhook
    this._runAnalysisPluginsHook();
    ......
  }
  // 扫描代码文件
  _scanFiles() {
    let entrys = [];
    ......
    return entrys;
  }
  // 扫描代码文件 & 分析代码
  _scanCode() {
    ......
    const entrys = this._scanFiles();  // 扫描所有需要分析的代码文件
    // 遍历每个文件,依次(解析AST,分析import,分析API调用)
    entrys.forEach(()=>{
      ......
      const { ast, ...} = parseTs(...)                      // 将TS代码文件解析为 AST
      const importItems = this._findImportItems(ast, ...)   // 遍历 AST 分析 import 节点
      if(Object.keys(importItems).length >0){
          this._dealAST(importItems, ast, ...)              // 遍历 AST 分析 API 调用
      }
      ......
    })
    ......
  }
  // 记录诊断日志
  addDiagnosisInfo() {
    ......
  }
  // 入口函数
  analysis() {
    ......
    // 注册插件
    this._installPlugins();
    // 扫描分析代码
    this._scanCode();
    // 黑名单标记
    this._blackTag();
    // 代码评分
    this._scorePlugin();
    ......
  }
}

基础类中包括扫描代码文件解析 AST遍历 AST统计信息安装插件执行插件链式检查黑名单标记代码评分等在内的一系列方法,重要属性。大家先不用着急弄清楚它们的含义,因为这些方法和属性我们在后面的章节中都会详细介绍的,目前阶段大家只需要对其中关键函数的执行时序有了解即可。

我们根据 codeAnalysis 入口函数 analysis 的详细调用链路,将依赖调用分析的执行时序梳理后,可以得到分析工具的执行生命周期(分析范式)

分析范式

分析工具的执行生命周期(分析范式)可以归纳为 7 个 step,codeAnalysis 实例会贯穿 step2-step7,也就是整个代码分析流程,其中 step4-step6 带有循环标志,表示该步骤会遍历每个代码文件,step1 是分析工具入口,负责校验配置文件参数,预处理分析环境,相关逻辑在 CLI/API 两个入口文件中。

分析范式非常重要,后续的文档就是讲解这 7 个步骤是如何实现的,讲解顺序如下:

  1. 《扫描 & 解析TS文件》:step3step4 实现原理,相关方法 _scanCode,_scanFiles,parseTs。
  2. 《分析Import节点》:step5 实现原理,相关方法 _scanCode,_findImportItems。
  3. 《判定API调用》:step5 实现原理,相关方法 _scanCode,_dealAST。
  4. 《API用途分析》:step5 实现原理,相关方法 _scanCode,_dealAST。
  5. 《分析插件方案设计》:step2step5step6 实现原理,相关方法 _installPlugins,_scanCode,_dealAST,_runAnalysisPlugins,_runAnalysisPluginsHook。
  6. 《Browser API调用分析》:step5 实现原理,相关方法 _scanCode,_dealAST。
  7. 《提取Vue文件中TS代码》:step3step4 实现原理,相关方法 _scanCode,_scanFiles。
  8. 《整理分析数据》:step7 实现原理,相关方法 _blackTag,_scorePlugin。
  9. 《入口设计 & 发布NPM包》:step1 实现原理(相关处理在 CLI / API 入口文件)。

大家肯定会有疑问,step1step2 作为最开始的步骤,为什么会放在靠后甚至最后来讲呢?

step1 放最后是想让大家先搞清楚分析工具全部的实现原理后,再来设计 npm 包的使用入口。

step2 不放在前面讲是因为引入插件模式是为了解决 step5 中的代码结构问题,也就是要讲完 step5 再讲 step2 更符合正常的讲解顺序,另外不引入插件模式其实并不影响工具实现,意味着 step2step6 甚至可以省略。

step3-5 虽然不是开始步骤,但却是整个分析范式最核心的几个步骤,所以从 step3 开始讲解更合理。

分析范式体现的是分析程序的执行时序,而文档的讲解顺序更贴合设计工具时的思考时序。

架构设计

仅仅靠一个 codeAnalysis 基础类是无法完成依赖调用分析整个流程的,它是分析工具的核心,但不是全部,我们还需要其它模块来辅助它完成分析任务,代码分析工具的架构设计如下图:

推荐大家对照 code-analysis-ts 源码lib目录下的文件来理解分析工具的代码组织模式。

  1. 分析工具提供 cliapi 两种使用模式,cli 为命令行模式,依赖 analysis.config.js 配置文件。
  2. 配置文件 analysis.config.js 用于提供实例化 codeAnalysis 的配置项,是使用者与工具交互的窗口。
  3. index 用于初始化 codeAnalysis 实例执行代码分析,并且为两种不同入口返回统一格式的代码分析数据。
  4. analysis 是代码分析工具的核心,parse、file、score、report、constant 等模块提供了代码分析之外需要用到的辅助能力,由 analysis 通过组合模式按需引入,将辅助逻辑抽离是为了更好的解耦。
  5. plugins 配合 analysis 用于拓展分析指标,与主程序解耦,不影响主程序执行。
  6. file 提供了扫描代码文件,读写文件等基础能力。
  7. parse 提供了解析 TS 代码文件,解析 Vue 代码文件等基础能力。
  8. score 提供了代码评分默认处理逻辑。
  9. report 提供了生成代码分析报告等基础能力。
  10. constant 提供了工具的基础常量配置。

配置文件

最后,我们来介绍一下分析工具的配置文件 analysis.config.js,配置文件是使用者与工具进行交流的窗口,合理的配置项设计可以提升工具的可用性,拓展性。我们来看下具体的配置项:

  • scanSource: 扫描源配置信息,也就是依赖调用方配置,它是一个数组,可以一次分析 1 个或者多个项目。其中,name 属性表示项目名称,path 属性是一个数组,用于声明每个项目需要被扫描的文件目录。(<mark>必填</mark>

  • analysisTarget:对应 依赖提供方 配置,是一个字符串类型。(<mark>必填</mark>

  • analysisPlugins:配置代码分析插件,可按需配置、动态加载。

  • blackList:配置需要标记的黑名单 API。

  • browserApis:配置需要分析的 Browser API。

  • reportDir:配置生成代码分析报告的目录。

  • reportTitle:配置代码分析报告的标题。

  • isScanVue:配置是否扫描 Vue 文件中的 TS 代码。

  • scorePlugin:配置代码评分插件。

  • alarmThreshold:配置是否开启代码告警及告警阈值。

根据依赖调用分析模型,scanSource 表示 A,analysisTarget 表示 B,所以这两项是配置文件中的必填项,下面是配置文件的示例:

typescript
const { execSync } = require('child_process');                        // 子进程操作
const DefaultBranch = 'master';                                       // 默认分支常量
function getGitBranch() {                                             // 获取当前分支
    try{
        const branchName = execSync('git symbolic-ref --short -q HEAD', {
            encoding: 'utf8'
        }).trim();
        // console.log(branchName);
        return branchName;
    }catch(e){
        return DefaultBranch;
    }
}

module.exports = {
    scanSource: [{                                                    // 必须,待扫描源码的配置信息
        name: 'Market',                                                    // 必填,项目名称
        path: ['src'],                                                     // 必填,需要扫描的文件路径(基准路径为配置文件所在路径)
        packageFile: 'package.json',                                       // 可选,package.json 文件路径配置,用于收集依赖的版本信息
        format: null,                                                      // 可选, 文件路径格式化函数,默认为null,一般不需要配置
        httpRepo: `https://gitlab.xxx.com/xxx/-/blob/${getGitBranch()}/`   // 可选,项目gitlab/github url的访问前缀,用于点击行信息跳转,不填则不跳转
    }],
    analysisTarget: 'framework',                                      // 必须,要分析的目标依赖名
    analysisPlugins: [],                                              // 可选,自定义分析插件,默认为空数组,一般不需要配置
    blackList: ['app.localStorage.set'],                              // 可选,需要标记的黑名单api,默认为空数组
    browserApis: ['window','document','history','location'],          // 可选,要分析的BrowserApi,默认为空数组
    reportDir: 'report',                                              // 可选,生成代码分析报告的目录,默认为'report',不支持多级目录配置
    reportTitle: 'Market依赖调用分析报告',                              // 可选,分析报告标题,默认为'依赖调用分析报告'
    isScanVue: true,                                                  // 可选,是否要扫描分析vue中的ts代码,默认为false
    scorePlugin: 'default',                                           // 可选,评分插件: Function|'default'|null, default表示运行默认插件,默认为null表示不评分
    alarmThreshold: 90                                                // 可选,开启代码告警的阈值分数(0-100),默认为null表示关闭告警逻辑 (CLI模式生效)
}

可以看到,analysis.config.js 配置文件本身具有动态特性,因为它返回的是一个 JS 对象,比如 httpRepo 的配置是根据运行时动态获取,动态特性可以帮助使用者定制化分析配置。

小结

本篇的内容有点多,且非常抽象,虽然没有讲解具体的代码实现,但却是非常重要的一节,因为它会贯穿第二阶段所有的章节,值得大家反复回顾学习,需要大家掌握以下知识点:

  1. 不要把思维局限在解决具体问题的方法实现上,先将要做的事情抽象成分析模型,站在更高维度去思考如何设计解决问题的通用方法论。
  2. 借鉴 Webpack 思路,把依赖调用分析过程中涉及的方法,属性都封装在名为 codeAnalysis 的基础类中,可以把它理解成依赖调用分析流程的抽象,在生成分析实例后,通过实例来驱动整个分析流程。
  3. 根据 codeAnalysis 基础类的关键函数,将执行时序梳理后,得到了分析工具的执行生命周期(分析范式),根据分析范式步骤完善基础类的过程就是实现代码分析工具的过程。
  4. 完整的工具不仅需要 codeAnalysis 基础类,还需要其它模块来辅助,因此合理的架构设计需要考虑解耦性、拓展性、可用性。

从下一节课开始,我们将根据分析范式逐步讲解代码分析工具各个模块的具体实现。