gogocode踩坑并实现函数插桩

2,317 阅读7分钟

gogocode是什么

贴一个官方的介绍在这边:

  1. 全网最简单易上手,可读性最强的AST处理工具!
  2. 如果你需要对代码升级、改造、分析,任何可以通过AST进行的处理,都可以用GOGOCODE快速解决问题。
  3. 不需要traverse,像剥洋葱一样一层一层的对比操作、构造ast节点,甚至不需要理解什么是CallExpression、Identifier、ImportDeclaration

函数插桩是什么

插桩:目标程序代码中某些位置插入或修改成一些代码,从而在目标程序运行过程中获取某些程序状态并加以分析。简单来说就是在代码中插入代码。 那么函数插桩,便是在函数中插入或修改代码。在实现的过程中,我们先进行引入,然后在各种类型的函数体内进行运行。

今天我们要通过gogocode去尝试在一个简易情境下实现插桩。主要是展示gogocode相比babeljscodeshift的一些区别,与比较有特点的api。

实现步骤

  1. 首先准备一个js文件作为目标,使用gogocode引入。
  2. 遍历import块,查询是否引入过函数包。如果没有引入,则生成代码块进行引入。
  3. 确定要调用插桩函数的函数体类型,并根据类型进行调用插桩函数

前置内容

  1. 所有内容将使用typescript
  2. 应当有ast基础。但gogocode的介绍说不认识相关类型也可以使用,大家可以试试。

准备目标文件并引入

目标文件:

// source.js

import ff from 'aa';
import * as bb from 'bb';
import {cc} from 'cc';
import 'dd';

const x = 1;

function a () {
    console.log('aaa');
}

class B {
    bb() {
        return 'bbb';
    }
}

const c = () => x;

const d = function () {
    console.log('ddd');
}

这里使用的是神光大大在babel小册中的例子,小册写的很良心,内容丰富,希望大家多多支持~

引入文件:

//index.ts

import $ from "gogocode"

$.loadFile("./source.js")

loadFile是对fs.readFileSync的一层包装。使用这种方式引入文件直接生成gogoAST,方便我们接下来操作。

获取ast类型

接下来我们开始面对代码AST操作。

由于gogocode本身并没有暴露可使用的类型集,所以我们需要使用returnType方法拿到gogoAST的类型:

import $ from "gogocode"
export type AstType = ReturnType<typeof $>

之后我们会使用很多次这个类型规范函数的入参。

查询是否引入过插桩函数

这里我们模拟插桩函数为track,使用gogocode进行查询:

/**
 * 判断是否有import过track包
 */
export function haveImport(code: AstType): boolean { 
  let flag = false
  code
    .find(`import $_$1 from "$_$2"`)
    .each(item => {
      if(item.match[2][0].value === "track") {
        flag = true
      } 
    })

  return flag
}

这里我们使用了find方法,find方法接收一个字符串,字符串内可以使用_或者$$$占位符对不确定的部分进行占位。这里我要查询import部分,不确定代码内引入了什么并命名了什么,所以分别用_1和_2进行占位。

find方法会返回一个结果数组,可以使用方法each对结果进行遍历。由于我们使用了占位符,所以结果会带有match属性,属性下是占位符部分的内容。_后的内容就是match内的key。

比如这里我们要确定文件有没有引入track,所以需要判断_2 所匹配的内容是否等于track。

item.match[2][0].value === "track"

这里存在一个问题,数字的语义化会影响代码的可读性。使用数字作为key可能不满足你的需求,这时也可以使用字符

find(`import $_$name from "$_$source"`)

这样我们使用的时候用字符作为key即可

item.match["source"][0].value === "track"

如果这时你使用的是typescript,你会遇到新的问题。类型规定了match的key必须为number。目前的替代方案是在前面加上ts忽略标签。等待官方修复吧~

在我们的例子代码中是没有引入track包的,接下来我们进行添加引用。

对没有引入的目标文件进行引入

引入应当是在所有内容之前,也就是置顶的位置,我们可以通过这种方式直接插入:

/**
 * 引入track包
 */
export function insertTrack(code: AstType) :AstType {
  return code
    .before(`import track from "track"; \n`) // 可爱的换行符不要忘记
}

但这也不太能满足我们的要求,一般插桩函数的命名都会给一个独一无二的,方便检索。这里我们使用一个函数生成递增的uid。当然这是很不严格的,生产环境下应当使用其他方式生成。

/**
 * 返回uid
 */
export const generateUid = function (){
  let uid = 0
  function add() {
    uid++
    return uid
  }

  function get() {
    return uid
  }

  return {
    add,
    get
  }
}()

接着我们使用的uid和track进行拼接:

export function insertTrack(code: AstType) :AstType {
  const trackName = `track_${generateUid.add()}`
  return code
    .before(`import ${trackName} from "track"; \n`)
}

这样我们生成出的置顶代码差不多是这个样子:

import track_1 from "track"

函数已经引入,但在插桩之前,我们还需要解决一个小问题。

转化箭头函数为具有return的函数

我们的例子中有一个这样的函数:

const c = () => x;

我们要在里面添加这样的代码

track();

那么必须先转化为这样

const c = () => {
    // 要在这里插入代码
    return x;
}

所以我们写一个转化函数的方法

/**
 * 箭头函数转为命名函数
 */
export function turnArrowToAnonymous(code: AstType): AstType {
  return code
    .find(`const $_$1 = () => "$_$2"`) // 这里必须加"",不然会解析错误
    .each(node => {
      if(node.has(`() => $_$`)) { // 做二次判断,筛掉命名函数
        node.replace(
          `const $_$1 = () => "$_$2"`, 
          `const $_$1 = function() { return "$_$2"; } \n`
        )
      }
    })
    .root()
}

细心的朋友已经发现了,这个地方我们使用了一个新的api, has。这个api帮助我们判断find找出的函数中是否具有标识类型的部分。

那么问题在于,为什么要进行第二次的判断呢?const $_$1 = () => "$_$2"部分应该已经筛选出箭头函数了呀?

但其实这种方式进行筛选,会把这类也筛进来

const d = function () {
    console.log('ddd');
}

这里就要说一下find方法实现,虽然我们输入的是一个模板字符串,但gogocode不会用它直接去做正则的匹配(当然不会)。gogocode会把这些转化为具体输入的类型,再进行匹配,我猜可能是这个地方出现了某种重叠。

经历了两次筛选,我们终于找到了要找的箭头函数。接着我们进行替换,这里使用了replace方法,使用对应的模板,并改一下结构就可以,这里还是很方便的。

但要注意,格式务必正确。相对于ast不合法的代码将在编译过程中抛错。

那么插播一下,什么叫“相对于ast不合法”呢?很简单,把你的代码贴到https://astexplorer.net/上面去看能不能识别出ast树,如果识别不出,那相对于ast就是不合法的。

现在我们的函数都转化为可以被插桩的函数了,下面我们开始插桩。

插桩

在插桩之前,我们需要先确定函数类型。虽然函数在我们眼里都是函数,但是在ast树内就是完全不同的一些东西。

// FunctionDeclaration
function a () {
    console.log('aaa');
}

// FunctionExpression
const d = function () {
    console.log('ddd');
}

class B {
    bb() {
        return 'bbb';
    }
}

FunctionDeclaration

这种是最规范的函数,我们也最喜欢。

export function insertFunc(code: AstType):AstType {
  return code
    .find(`function $_$ (){}`).each(node => {
      node.prepend("body",`track_${generateUid.get()}(); \n`)
    })
    .root()
}

这里我们使用了prepend,这个方法要求我们传入一个数组形式的ast节点,然后它会把代码添加到这个数组的最顶端。

FunctionExpression

这种相对于Declaration类型,它没有名字,它的命名在函数之外。但名字和我们关系不大,我们对函数体内的部分进行操作即可

export function insertAnonymousFunc(code: AstType) {
  return code
    .find(`
      const $_$1 = $_$body
    `)
    .each(funcNode => {
      // @ts-ignore
      const bodyAst = $(funcNode.match["body"][0].node).find(`{}`) // 找到函数体
      bodyAst.prepend("body",`track_${generateUid.get()}(); \n`)
    })
    .root()
}

至此我们就对目标文件转换完成啦。

小结

总的来说是一个想法很好的工具,针对于很多codeMod工具进行了封装。对于简单的代码转化需求已经是绰绰有余,如果你有需要,看10分钟的文档即可上手。但这里还是有一些小小问题,希望有能力的大大佬抽空支援一下。

  1. ts支持度的问题。首先没有像jscodeshift一样开箱即用的类型声明,需要用户自己推导。并且在match方面的类型处理是有些问题的。

  2. 使用模板字符串的方式进行查询,方向是好的。极大方便了操作,但又出现了另一种问题,字符串的书写并不是完全自由,刚上手的小伙伴可能会这样使用

    find(`$_$1 $_$2 = () => {}`)
    

    因为你不确定变量是用什么方式声明的,并且被声明成了什么。所以你选择用两个占位符。但这样会报错。因为它不符合ast的直觉书写。所以想熟练使用此类工具的小伙伴还是要打好ast基础哦。