Web 工程化:使用 Babel 实现前端日志无痕插桩

身为一名开发人员,日志对大家一定不陌生,不管是平时开发的错误警告,还是发现线上问题后的快速定位,都离不开日志的帮助,甚至我们可以通过日志来分析用户的行为,优化产品设计和业务流程.在这篇文章中,我将会介绍如何利用 Babel 来实现日志自动插桩,以及我们在海外 pc 直播端中的实践.

面临的问题

最初的项目中,一旦出现线上问题,没有线索能帮助我们快速定位或者复现问题,又或者测试老师临时发现了一个问题,但是又不知道之前的操作步骤,难以复现出来,一个问题可能就被大家忽略了.导致这种这些的问题原因包括:

1.代码中的日志少,出现问题之后很难定位和复现

由于最初的直播端需求紧急,开发人员混乱,开发人员对日志的忽视,导致代码中的日志很少,一但出现问题,日志并没有提供很多有用的信息,两条日志中间的代码逻辑可能有很多,并不能第一时间定位到问题所在,而且也很难为问题复现提供操作步骤参考.这时候就需要开发人员去代码中去寻找问题,这样的效率很低,而且很容易出错。

2. 业务逻辑中包含大量 if else 条件语句中直接 return 的逻辑,可能直接跳出执行的业务逻辑,排查问题的时候,难以确定是否是条件判断执行了 return

在一些涉及到逻辑判断的时候,大量if(条件) return的简写代码,这种代码看起来很简洁高效,但是在遇到问题的时候,我们很难判断到底执行了哪个逻辑分支,是不是执行了我们想要的分支.对不熟悉这些业务逻辑判断的开发人员来说,这样的代码难以理解,更不用说去定位问题了.

3.大量 async/await 代码,但是没有错误捕获

很多老师习惯使用 async/await 作为异步代码的终极解决方案,但是并不会对错误进行捕获.这样的代码在出现问题的时候往往并不会触发错误上报,如果在 await 后的方法里报错,对日志来说是无感知的,并且不能提供错误的堆栈信息,这样的代码也会让我们很难去定位问题.

4. 代码中的日志不规范

因为早期的人员变动和没有执行一个强力的代码规范,导致了代码中的风格五花八门,连带不同开发者对日志的描述,级别的定位也不统一,导致了日志描述信息难懂,日志级别不准确的问题,报错信息混在 info 级别里,导致日志无法通过级别过滤快速查看错误日志

种种问题,让我们意识到不解决日志的问题会让我们解决线上问题效率低下,占用我们更多时间去排查问题.其连锁反应会影响到我们的正常开发时间和进度,我们就开始思考如何去解决这个问题.

可怜又无助的人力填坑

根据我们过往的经验,我们立马着手在项目中加入 sentry + 神策 + electron-log 相结合的方式,来实现前端日志.

  1. 我们制定了日志上报方式,日志级别,日志格式等
  2. 我们在出现问题的地方,补上 log,以期望下次出现问题的时候可以通过日志来快速定位到问题
  3. 我们在全局引入了 sentry,用来捕获 error,预警线上问题
  4. 我们每天下班后聚集在一起,手动为每一个文件补充日志

但是一段时间后,心力交瘁的我们发现,凭我们手动增加的日志在大量历史代码中仅仅是杯水车薪,完全无法达到我们的预期,并且由于人为的添加日志,很容易出现日志错漏,日志描述不准的问题,这样的日志并不能提供我们想要的信息,反而会让我们更加迷惑.更为严重的情况是由于大家对老旧代码的不熟悉,这种侵入式的添加日志方式,很容易出现代码冲突,导致代码出错,进而影响到线上的正常运行.基于以上的问题,我们迫切的希望寻找到一个更好的解决方案.

自动化高效的 babel

为了能提高添加日志的效率,同时避免在添加日志的同时又引入新的问题. 我们一定是要找到一个既能遍历到所有文件,所有方法,并且能区分出代码中 if 条件语句,async/await 等不同类型的语句,并且能够在不影响原有代码的情况下,在代码中添加日志的方法.当我们按照我们的需求思考新方案的时候,发现网上有人使用 babel 对代码进行批量替换或者添加.既然 babel 可以实现代码的批量替换和新增,那就有可能帮助我们实现自动化日志,经过调研发现 babel 完美符合我们的需求.利用 babel 实现一个插件,用来添加日志成为我们的新方案.

babel 是什么?

babel 是一个 JavaScript 编译器,它可以将 ES6 代码转换成 ES5 代码,让我们可以在现有的环境中运行 ES6 代码,同时 babel 还可以作为一个平台,让我们可以在编译过程中对代码进行修改,这样的功能正好符合我们的需求.

让我们着手实现一个 babel 插件

其实对于 babel,前端工程师们并不陌生,我们在使用 webpack 的时候,都会配置 babel-loader,来将我们的代码转换成浏览器可以识别的代码,这样的过程就是 babel 在工作.我们可以通过配置 babel 插件,来实现我们想要的功能.

先罗列一下我们想要添加日志的地方

  1. 调动方法的时候,我们希望知道触发了方法,并能获取到方法的参数
  2. 在 if 条件语句中,我们希望知道条件是否成立,并执行了哪个分支
  3. 在 async/await 中,我们希望为没有 catch 的 await 语句添加 catch,并且能获取到错误信息

实现思路

我们都知道,babel 会经过 分析,转换,生成 三个阶段,在转换阶段,babel 把代码转换成 AST,我们可以通过 babel 插件,对 AST 完成判断类型,修改节点,插入子节点等操作,在生成阶段,将修改过的 AST 转换成我们想要的代码.这样就实现了我们预期的自动化添加日志的功能.

实现过程

1. 首先我们需要安装 babel,并且配置 babel 插件

/ .babelrc
{
  "plugins": ["@thinkacademy/babel-plugin-log"]
}

2. 实现babel组件

// index.js
module.exports = function ({ types: t }) {
  return {
    visitor: {
      // if 语句
      IfStatement(path) {},
      // await 语句
      AwaitExpression(path) {},
      // 箭头函数
      ArrowFunctionExpression(path) {},
      // 函数声明
      FunctionDeclaration(path) {},
      // 函数表达式的时候
      FunctionExpression(path) {},
      // 类函数
      ClassMethod(path) {},
      // 对象函数
      ObjectMethod(path) {},
    },
  };
};

解释一下visitor的作用,visitor是一个对象,对象的每一个属性都是一个方法,这些方法会在遍历 AST 的时候,根据节点的类型,执行相应的方法,这样我们就可以在相应的方法中,对节点进行修改,插入子节点等操作.

path是一个对象,它包含了当前节点的信息,比如当前节点的类型,当前节点的父节点,当前节点的子节点等信息.

babel 分析语句生成 AST 的时候,会根据语句的类型,生成不同的节点,在遍历 AST 的时候,每个节点都会触发 visitor 中对应类型的方法,我们就可以在这些方法中,对节点进行修改,插入子节点等操作.

举个例子,当我们遍历到一个if语句的 AST 节点的时候,就会进入visitor中执行IfStatement方法,我们就可以在这个方法中,对if语句通过path进行修改.更多类型的节点,可以参考babel-types

3.实现 IfStatement 方法添加日志

// index.js

// babel用来将AST转换成代码的方法
const generator = require("@babel/generator").default;

module.exports = function (babel) {
  let types = babel.types;
  let template = babel.template;
  const visitor = {
    IfStatement(path) {
      // 将if里的条件语句转换成字符串
      // path.node.test是if里的条件语句
      const ifName = generator(path.node.test).code.split('"').join("'");

      //   判断if语句是否直接return if语句的consequent属性是if语句的主体
      if (path.node.consequent.type === "ReturnStatement") {
        //  如果有return语句,在return语句前面添加日志
        const temp = template(`console.info("if(${ifName})为true触发return,path: ${filePath}")`);
        // 生成日志节点
        const logNode = temp();
        // 将日志节点和return语句放到一个数组中,并将数组转换成blockStatement
        const statements = [logNode];
        statements.push(path.node.consequent);
        path.node.consequent = types.blockStatement(statements);
        // 这样我们就在return语句前面添加了日志
        // if(a) return 变成了 if(a) {console.info("if(a)为true触发return,path: ${filePath}");return}
      } else {
        // 判断if语句函数体里是否有return 其余逻辑同上
        if (path.node.consequent.type === "BlockStatement") {
          path.node.consequent.body.forEach((element) => {
            if (element.type === "ReturnStatement") {
              const temp = template(`console.info("if(${ifName})为true触发return,path: ${filePath}")`);
              const logNode = temp();
              path.node.consequent.body.unshift(logNode);
            }
          });
        }
      }
      //   alternate属性是if语句的else语句,其余逻辑同上
      if (path.node.alternate && path.node.alternate.type === "BlockStatement") {
        path.node.alternate.body.forEach((element) => {
          if (element.type === "ReturnStatement") {
            const temp = template(`console.info("if(${ifName})为false,触发return,path: ${filePath}")`);
            const logNode = temp();
            path.node.alternate.body.unshift(logNode);
          }
        });
      }
    },
  };
  return {
    visitor,
  };
};

可以看到,我们通过对 if 语句的 AST 节点通过一些判断,添加子节点,就可以实现在 if 语句的函数体添加日志,这样我们就完成了在 if 语句添加日志的功能了.

4. 实现函数里添加日志,由于函数的类型比较多,下面是函数声明的添加日志


// index.js
module.exports = function (babel) {
  let template = babel.template;
  const visitor = {
    FunctionDeclaration(path) {
      // 获取函数所在的文件路径
      let filePath = this.filename || this.file.opts.filename || "unknown";
      // 获取函数的参数
      let paramsArr = path.node.params
        .map((param) => {
          if (param.name) {
            return param.name;
          }
          if (!param.name && param.left) {
            return param.left.name;
          }
          if (!param.name && param.properties) {
            return param.properties.map((node) => node.key.name);
          }
          if (!param.name && param.elements) {
            return param.elements.map((node) => node.name);
          }
          if (param.type === "RestElement") {
            return param.argument.name;
          }
        })
        .flat();
      let paramsStr = paramsArr.join(", ");
      let temp = null;
      // 判断函数是否有函数名
      if (path.node.id) {
        // 获取函数名
        const funcName = path.node.id.name || null;

        // 判断函数是否有参数
        if (paramsStr) {
          temp = template(`console.info('函数申明 ${funcName}(${paramsStr})',${paramsStr},'filePath:${filePath}')`);
        } else {
          temp = template(`console.info('函数申明 ${funcName}, filePath:${filePath}')`);
        }
      } else {
        if (paramsStr) {
          temp = template(`console.info('函数申明 (${paramsStr})',${paramsStr},'filePath:${filePath}')`);
        } else {
          temp = template(`console.info('函数申明 (), filePath:${filePath}')`);
        }
      }
      // 生成日志节点
      const logNode = temp();
      // 将日志节点添加到函数体的第一行
      path.node.body.body.unshift(logNode);
    },
  };
  return {
    visitor,
  };
};

通过上面的代码,我们就可以实现在声明函数里添加日志了,这里需要注意的是获取函数的参数类型比较多,我们需要对函数的参数进行判断,然后获取到函数的参数,这里我就不一一列举了,大家可以看代码

其余的函数类型,比如函数表达式,箭头函数,对象函数,类函数的实现思路是一致的,只是不同类型的 AST 结构不同,所以对应的获取函数名,获取参数的逻辑可能不一致,每个类型的 AST 节点结构可以在babel 官网查看

大家可以思考下如果想把 没有 catch 的 await 方法都添加一个 trycatch 的话,通过 babel 该如何实现呢?

console 代理,生成日志

我们通过 babel 给每一个方法第一行加入了console代码,但是console代码只会输出到浏览器控制台,并不会写入本地日志和上传服务器,那么最后一步,我们通过代理 console,实现日志的输出

printf("hello world!");const oldConsole = window.console;
window.console = new Proxy(oldConsole, {
  get(target, key) {
    if (key === "info") {
      return function (...args) {
        // 写入本地日志
        // 输出到控制台
        target[key](...args);
      };
    }
    if (key === "error" || key === "warn") {
      return function (...args) {
        // 上传服务器
        // 写入本地日志
        // 输出到控制台
        target[key](...args);
      };
    }
    return target[key];
  },
});

并且,我们可以根据 console 的不同方法,区分不同级别的日志,比如console.info输出的普通级别日志,不需要上传服务器,只需要写入本地即可,console.errorconsole.warn作为更高级别的日志,既需要写入本地,也需要上传到服务器,并且如果是error我们还可以通过 sentry 上报错误信息

到此为止,我们就完成了一个 babel 插件,这个 babal 插件通过在编译过程中给每个方法注入 console.info 来输出方法名和参数,并通过全局代理 console 方法,最终实现了日志写入本地或者上传

取得的效果

仅主讲端就有 975 条 if 语句 被自动加入了日志,如果手动添加这 975 条日志,想想都觉得头大,通过 babel 插件,我们可以很轻松的实现这个功能

不仅如此,主讲端还有声明函数 150 个, 803 个箭头函数,数不清的对象函数和类函数,都被我们的日志插件添加了日志

并且,由于我们是利用 AST 获取的函数参数,也避免了手动添加日志的时候函数参数写错的问题,避免引入了新的 bug

同时我们日志也打印了函数所在的文件路径,这样我们就可以很方便的定位到问题所在的文件

还有个好处,引入插件后不光历史代码中的函数被添加了日志,今后我们开发中新增的函数也会被添加日志,这样我们就不用担心新的函数没有添加日志的问题了

遇到的坑

  1. 由于 sentry 错误位置,我们的代码经过打包后 sentry 收集的报错位置是打包后的位置,这样我们就很难定位到问题所在的文件,所以我们需要在 sentry 上配置 sourceMap,这样就可以定位到源文件的位置了,解决方案:上传 sourceMap
npm install --save-dev @sentry/cli
 sentry-cli --url ${SENTRY_URL} --auth-token ${SENTRY_AUTH_TOKEN} releases --org ${SENTRY_ORG} --project ${SENTRY_PROJECT} files ${COMMIT_HASH} upload-sourcemaps --url-prefix ${SENTRY_URL_PREFIX} ${SENTRY_URL_DIST}
  1. 函数参数的类型比较多,需要对函数参数进行判断,例如函数的参数可能是 vue 对象,如果你直接序列化 vue 实例的话会报错,例如函数参数有可能是 null, undefined 等,需要针对性的处理
  2. 代码中我们有时候无法避免会使用到轮训,轮训中的函数也会被我们添加上日志,这个时候可能会不停的产生日志,需要我们做取舍
  3. 我们的日志是通过代理 console 实现的,所以在日志中我们不能再次调用 console,不然会造成栈溢出.最初我们在日志函数中调用了 console.log 为了把日志同步展示在控制台,但是我们实现了 console 代理后没有第一时间发现这个问题,导致了软件的闪退
  4. 大量的日志可能也会导致我们一些性能问题,并且大量日志也会干扰我们阅读日志,所以大家可以根据自己的需求,在插件中对日志进行过滤,包括忽略一些文件,忽略一下固定方法名的方法(mounted,updated 等),添加一些标识来表明哪些方法不需要被添加日志等等,这些都可以在插件配置中配置,并在插件中通过代码来过滤,具体代码可以参考 npm 包中的代码

总结

一个并不复杂的 babel 插件,就可以解决耗时耗力耗人的日志问题,上述的 babel 插件我已经打包成一个npm 包 发布到 公司 npm 库里@thinkacademy/babel-plugin-log ,欢迎大家 review 并提出建议,也希望能帮到大家解决一些问题.

日志真的很重要,今后项目初始化的时候,就应该建立一套完善的日志系统,这样才能更好地定位问题,提高开发效率

另外,希望大家能够多多关注 babel,掌握 babel 的使用,通过 babel 可以创建很多有趣的工具,大大提高我们的工作效率。

作者:郭世伟
来源:好未来技术
原文:https://mp.weixin.qq.com/s/6eXcFA3WweNL3yGTpnYniA

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论