基于Rust工具实现循环引用排查工具

1,384 阅读5分钟

前情提要

这个标题起得带标题党的性质了,在此声明下本篇文章分享的核心源码不是用Rust写的,工具的核心能力是基于ast-grep这个Rust工具实现的。

实际业务项目是基于express框架搭建的,使用的模块规范是commonjs,所以本篇文章不考虑esmodule的场景,esmodule和commonjs对循环引用的处理是不同的,读者可以自己根据自己的场景进行引申。

没了解过ast-grep的,推荐看一下我的另一篇文章:juejin.cn/post/734457…

什么是循环引用

循环引用是指两个或以上的模块彼此之间相互依赖和引用,形成一个闭环的调用链关系。比如:

a.js -> b.js -> c.js -> a.js

commonjs的循环引用会导致模块的加载关系变得复杂,导致某些值在模块加载过程中变为未定义,从而导致代码运行时发生错误的行为。

循环引用的bug是比较难排查的,为什么这么说呢?

  1. 因为啊,有循环引用的代码说明代码复杂度是比较高的,代码写得乱容易出现循环引用,不好从代码层面梳理模块关系来解决问题。
  2. 循环引用的问题现象一般是偶现的,笔者在纯前端项目和node中间层项目中都遇到过循环引用问题,纯前端项目的问题表现为某个页面有AB两个入口,A入口跳转正常,但是B入口跳转后某些值变为undefined,导致页面空白报错。node中间层项目的问题表现为有多条调用链路,某个AB实验开启后,触发中间层告警Accessing non-existent property 'xxx' of module exports inside circular dependency,不开启该AB实验则不会触发告警。

特别是在node中间层项目中,承担了聚合n个后端微服务逻辑的角色,各个后端微服务在公司里面归属于不同的部门,不同部门都是自己玩自己的,有自己的规范。node中间层去统一这些差异,对齐后端服务之间的依赖关系,nodejs代码就会变得十分复杂,容易出现像循环引用这种问题。

如何检测

通过静态代码分析的方式扫描项目所有js文件,构建它们的模块引用关系。

有一个npm包node-source-walk,用来遍历js源代码语法树(基于@babel/parser),遍历时判断require的语法。这种实现方案是可行的,但是在具体实施的时候,发现检测速度太慢了,跑了十分钟,都没有把项目所有的js文件检测完,可见js在ast解析这个领域的性能瓶颈还是很明显的。

于是诞生了用Rust解决运行速度问题的想法,无奈笔者太菜,不会Rust,但是发现了ast-grep这个Rust工具,刚好可以解决我们当前的痛点。

ast-grep是一个Rust工具,它提供了js的api调用方式,让我们可以通过编写js代码来调用它。

import { js } from '@ast-grep/napi'

await js.findInFiles({
    paths: [
      './routes',
      './common',
      './apis',
      './controllers',
      './middlewares',
      './services',
    ],
    matcher: {
      rule: { pattern: 'require($A)' }
    },
    languageGlobs: ['*.js'],
  }, (err, n) => {
    if (err) {
      console.error(err)
      return
    }
    // todo
  })

我们的是node中间层项目,假设要扫描的文件夹就是['./routes', './common', './apis', './controllers', './middlewares', './services']这几个

匹配的规则是require($A),这里假设项目里只有两种写法,(统一写法很重要啊,require的写法多种多样,给代码分析增加了难度)

require(`xxx`)
require('xxx')

// 假设不存在
require(A + B)
require(`xxx${A}`)

收集整个项目的文件引用关系

let currFile = '' // 当前扫描的文件
n.forEach(node => {
  currFile = getScaningFilePath(currFile, node)
  const subNode1 = node.children()[1]
  const subNode2 = subNode1.children()[1].children()

  if (subNode2.length > 3) {
    unknownMap.set(subNode1.text(), 1) // 简单化,不处理非预设的语法,比如require(A+B)
    return
  }
  const depPathText = subNode2[1].text() // 这里获取到的是require('xxx')里面的xxx
  const children = fileMap.get(currFile) || new Set()
  let absFilePath = getAbsFilePath(currFile, depPathText)
  if (absFilePath === '') {
    return
  }
  children.add(absFilePath)
  fileMap.set(currFile, children) // key是文件路径,value是记录该文件引用了哪些文件
})

n是一个node数组,假设当前正在扫描的js文件代码为

require('./a.js')
require('@/b.js')
require(`./c.js`)

那么n就是3个node组成的数组

代码实现:获取当前正在扫描的js文件路径

const __root__ = process.cwd()
const getScaningFilePath = (scaningFile, node) => {
  if (scaningFile) {
    return scaningFile
  }
  const currFileName = node.getRoot().filename() // 从node去反推当前扫描的文件
  return path.join(__root__, currFileName)
}

代码实现:获取node对应的文件路径

const getAbsFilePath = (scaningFilePath, depPathText) => {
  let result = ''
  if (depPathText.indexOf('@/') === 0) {
    result = depPathText.replace('@', __root__)
  } else if (depPathText.indexOf('.') === 0) {
    result = path.join(path.dirname(scaningFilePath), depPathText)
  }
  if (result === '') {
    // 来自node_modules
    return ''
  }
  if (result.endsWith('/')) {
    result += 'index.js'
  } else if (!result.endsWith('.js') && !result.endsWith('.json')) {
    const temp = result + '.js'
    if (fs.existsSync(temp)) {
      result = temp
    } else {
      result += '/index.js'
    }
  }
  return result
}

这样子,我们就拥有一整个项目的文件引用关系啦,我们可以使用深度优化搜索算法(DFS)来进行遍历,遍历过程若发现有重复访问的节点,那说明引用关系有环,即存在循环引用。

const visited = new Set()
const visiting = []
const result = []

const dfs = (file, fileMap) => {
    const idx = visiting.indexOf(file)
    if (idx !== -1) {
      const arr = visiting.slice(idx)
      arr.push(file)
      const traceStr = arr.join(' => ').replaceAll(__root__, '')
      if (!result.includes(traceStr)) {
        result.push(traceStr)
      }
      return
    }
    if (visited.has(file)) {
      return
    }

    visiting.push(file)
    const children = Array.from(fileMap.get(file) || [])
    children.forEach(child => {
      dfs(child, fileMap)
    })
    visiting.pop()
    visited.add(file)
}

for (const file of fileMap.keys()) {
    dfs(file, fileMap)
    visited.clear()
}

基于以上代码实现,就能很快速地检测出来项目里存在循环引用的文件引用链。不过会存在重复相似的文件引用链,比如 a.js => b.js => c.js => a.jsb.js => c.js => a.js => b.js可以看作是同一个文件引用链,小问题,不影响我们去判断和处理循环引用问题

如何解决

目前针对commonjs的循环引用问题,有两种方案来解决,推荐第二种。

第一种是动态引用,把原来的引用语句,加上if else判断来进行引用,改动小,改起来快,但是会增加后续维护成本,治标不治本,静态上循环引用还是存在的,只是在运行时规避了,笔者不推荐。

第二种是新增中间模块来解引用,首先要梳理清楚模块引用关系,把导致循环引用根因的js文件里导出的代码内容拆分出来新的文件模块: 比如a.js => b.js => c.js => a.js改造成a.js => b.js => c.js => d.js, a.js => d.jsd.js是从a.js代码里拆分出来的代码。

第二种方案解引用,采用了代码解耦的编程思路,改动比第一种方案大,需要梳理代码,改起来耗时,但是降低了代码的维护成本,也从根本上解决了循环引用问题

参考