利用Node服务实现SourceMap解析

552 阅读8分钟

何为SourceMap?

简单来说,SourceMap是一个打包文件的映射,通过map文件,可以将打包后的内容对应的源内容映射出来,以方便通过报错信息找到源代码的位置。

我们在生产环境调试bug的时候,常常会发现某些错误信息被混淆了。其实该错误信息对应的是打包后的文件代码,开发者一般是不理解这段内容的含义的,那我们如何找到对应的报错的源码位置呢?这里就需要用到SourceMap了。

接前文,当时我开发了一款智能报错的服务,不过我在使用过程中发现,这个服务报错信息就是打包后的代码所映射的报错信息,这肯定是不行的。于是,我们必须手动实现一个SourceMap解析的逻辑。

实现

由于文件的处理和报错的逻辑是两个独立的模块,因此需要针对文件上传处理新开一个Route

其实这个流程串联很简单,就是上传文件->解析文件->将解析后的错误信息合并到源报错信息->交给AI解析->推送服务

(ps:以上流程可以参考我之前写的文章《利用AI大模型,增强你的DevOps!》)

上传文件
客户端

首先这部分需要客户端的配合,手写一个Vite插件,在closeBundle钩子函数中写上传逻辑,表示等Vite打包结束后将产物进行上传。

closeBundle: error => {
  if (!error) {
    const outputPath = path.resolve(__dirname, '../dist')
    const formData = new FormData()
    deepSearchFile(outputPath, formData)
    axios.post(上传目标服务的url, formData).then(res => {
      console.log(`SourceMap文件上传成功!`);
    }).catch(err => {
      console.error(err);
    })
  }
},

这里需要对文件进行一个深度搜索,以确保dist目录下的所有文件都完成上传,这里实现一个deepSearch函数

function deepSearchFile(dir: string, formData: FormData) {
  fs.readdirSync(dir).forEach((file: unknown | string) => {
    if ((file as string).indexOf('.map') > -1) {
      try {
        const filePath = path.join(dir, `./${file as string}`);
        const buffer = fs.readFileSync(filePath)
        const blob = new Blob([buffer], { type: 'text/plain' })
        const newFile = new File([blob], `${file}`, { type: 'text/plain' })
        formData.append(file as string, newFile)
      } catch (error) {
        console.error(error);
      }
    }
    if ((file as string).indexOf('.') === -1) {
      deepSearchFile(path.join(dir, `./${file as string}`), formData)
    }
  })
}

通过创建Blob对象,生成File,然后将File append到formData中,最后将formData通过axios进行上传。这里要注意的是,我们只上传dist目录下对应的.map文件,这是SourceMap的映射文件集合。对于.map文件的生成,在Vite.config.ts中,需要在build配置项中配置sourcemap: true(生产环境) sourcemap: isProd ? true : false

完成以上操作,客户端部分的逻辑就实现完成了,下一步就是实现服务端对文件的接收了。

服务端

Egg.js创建接收上传文件路由,这里上传文件必须是post请求

router.post('/sourcemapUpload', controller.upload.sourcemapUpload)

定义一个Controller,将其命名为upload.js,这个文件用于接收上传文件的请求。因为.map文件一般都是多个(分包),因此这里需要区分单文件上传和多文件上传。

'use strict'

const { Controller } = require('egg')

class UploadSrouceMapFile extends Controller {

	/**
	 * sourcemap文件上传
	 */
	async sourcemapUpload() {
	 const { ctx } = this
    const files = ctx.request.files
    const { type } = ctx.query
    // 单文件模式
    if (files && files.length === 1) {
      try {
        const file = files[0]
        await ctx.service.sourcemap.saveFile(file, type || 'public')
      } catch (error) {
        console.log('单文件模式解析错误: ', error);
      }
    }
    // 多文件模式
    else if (files && files.length > 1) {
      const result = files.filter(async file => {
        try {
          return await ctx.service.sourcemap.saveFile(file, type || 'public')
        } catch (error) {
          console.log('多文件模式解析错误: ', error);
          return false
        }
      })
        if (files.length === result.length) {
            ctx.status = 200
            ctx.body = {
              message: '文件解析成功',
              code: 0
            }
        } else {
            ctx.status = 500
            ctx.body = {
              message: '文件解析失败',
              code: 500
            }
        }
    }
	}
}

module.exports = UploadSrouceMapFile

将file对象从请求对象中提取出来,并传递给Service层,Service接收到file对象后进行文件保存。多文件模式接收到的是一个多文件数组,遍历数组处理即可。

Service层通过fs.readFileSync读取到文件内容,最后经过文件路径解析,将文件保存到到服务端的public文件夹下。

'use strict'
const { Service } = require('egg')
const fs = require('fs')
const path = require('path')

class SourceMapService extends Service {
    async saveFile(file, type) {
        const { filename, filepath } = file
        const fileData = fs.readFileSync(filepath)
        // 设置文件保存路径
        const tempDir = path.join(__dirname, `../public/sourcemap/${type}`, filename)
        fs.writeFileSync(tempDir, fileData)
        return 'upload success'
    }
}

module.exports = SourceMapService

此时文件就被成功保存到public文件夹下了,打开public看下效果。

截屏2024-11-01 13.45.59.png

这里我没有做分包处理,因此只有一个.map文件。文件上传/保存逻辑到这里就基本写好了,然后就是如何利用上传好的.map做报错源码指向和解析。

SourceMap解析

这一步比较灵活,你可以将其包装成一个Service,也可以放在中间件层,因为属于针对请求过来的Error进行加工,所以我这里选择将其放在中间件中,将这部分逻辑和错误处理结合。

上一片文章,针对错误处理做了介绍,当时这部分逻辑是写在一个叫做middleware/errorToGpt.js的中间件中的。通过解析错误对象里的错误信息、错误栈、错误文件、url等信息,最后将这些参数传递给gpt方法,返回最终的错误智能解析结果。

引入Sourcemap的逻辑后,需要在获取错误栈等信息后,对这些内容先进行一层预处理,然后再传递给gpt。这里需要使用到两个库——source-map和stacktracey,大家自行下载引入即可。

stacktracey 用于解析错误栈信息,参数是错误栈字符串,解析的结果是一个错误栈数组,一般而言,只需要取数组第一个元素即可,因为单个报错的错误栈一般对应的也是单一的错误内容,这里我们为了简单处理,默认获取处理后的数组的第一个元素。代码如下:

const tracey = new Stacktracey(esk ?? {});
const errorInfo = tracey.items?.[0] ?? {}

后续处理都围绕errorInfo展开的,这个对象包含了我们需要的出现错误的文件名,出错定位行和出错定位列等信息。我们需要根据这些内容去做解析。

定义一个文件的读方法

const readFile = (filePath) => new Promise((resolve, reject) => {
    // 以读取模式打开文件
    fs.readFile(filePath, { flag: 'r' }, (err, data) => {
        if (err) reject(err)
        else resolve(data.toString())
    })
})

通过源文件errorInfo.fileName定位到对应的.map文件,获取.map文件内容,并将其解析成一个对象

const sourceMapFileContent = await readFile(path.resolve(__dirname, `../public/sourcemap/${type}/${errorInfo.fileName}.map`))
const sourceMapContent = JSON.parse(sourceMapFileContent)

然后,再通过SourceMap类(通过source-map引入)根据sourceMapContent文件创建一个consumer实例。

const consumer = await new sourceMap.SourceMapConsumer(sourceMapContent)

然后根据打包后的代码错误位置解析出源代码的错误位置对象,然后通过originalPosition的source属性,解析出报错文件的位置,这个出错位置是.map映射出来的,可以直接拿到。

const originalPosition = consumer.originalPositionFor({
   line: errorInfo.line,
   column: errorInfo.column
})

sourcePosition = originalPosition?.source || '';

效果如下图⬇️

截屏2024-11-01 14.13.17.png

这个路径就是报错文件的路径了,通过这个路径再加上报错上下文,就可以定位报错的具体位置了。

优化

Egg.js的中间件可以扩展很多功能,比如一些请求的全局过滤和预解析,我们可以写很多上层逻辑。

全局报错处理

常规的报错捕获都是try...catch,但是这个会破坏你的代码的正常执行流,它会形成一个单独的块级作用域。因此大量的try...catch会使得你的代码难以理解和维护,于是可以考虑将错误捕获提升到中间件中去做处理。

'use strict'

/**
 * 统一异常处理
 */
module.exports = () => {
    return async (ctx, next) => {
        try {
            await next()
        } catch (error) {
            ctx.app.emit('error', error, ctx)
            ctx.resp.error(error.message, 500);
        }
    }
}

因为所有的请求和响应都会走一遍中间件(基于Koa的洋葱圈模型),所以我们可以直接对next方法包一层try...catch。根据错误的冒泡原理,未被捕获的错误会冒泡到外层的作用域,直到全局作用域,所以这里next()中的错误都会在这里被捕获掉。

通过ctx.app.emit('error', error, ctx)给app实例发布一个error事件,egg内置的订阅逻辑会将这个error事件捕获,并打印一条错误日志。

全局请求加工

通过中间件我们也可以实现ctx对象的响应体结构的预构建,从而省去繁冗的ctx.body构建。注意书写顺序,这里需要在请求发送的时候进行ctx.resp的挂载,因此赋值代码必须写到await next()之前,而不是之后(之后是响应时走的逻辑)。

/**
 * 提供统一的返回数据处理方法
 * 成功:ctx.resp.success([], '');
 * 失败:ctx.resp.error('暂无权限', 401);
 */
module.exports = () => {
    return async (ctx, next) => {
      ctx.resp = {
        success: (data, msg = '') => {
          ctx.status = 200;
          ctx.body = {
            status: 'success',
            data,
            msg,
          };
        },
        error: (msg, status = 200) => {
          ctx.status = status;
          ctx.body = {
            status: 'fail',
            data: null,
            msg,
          };
        },
      };
      await next();
    };
  };

2024/11/07更新

最近需求提测时,客户端测试环境遇到了跨域问题,原因是我的服务没有针对该域名设置白名单,配置cors跨域。那为什么之前做错误分析推送的时候,并没有出现跨域问题呢?因为跨域是浏览器的一种安全保护机制,错误推送并没有直接在浏览器进行请求响应,而是以消息推送的形式发送给其他端(不是传统的浏览器环境下的请求响应),因此之前并没有遇到跨域的问题。

那么知道了问题的原因,那我们就需要针对这个域名进行跨域处理。

  1. 安装egg-cors,这个插件是专门用来处理跨域的,它本质上就是一个中间件,用于给响应头添加自定义属性。
const requestOrigin = ctx.get('Origin');

    // Always set Vary header
    // https://github.com/rs/cors/issues/10
    ctx.vary('Origin');

    let origin;
    if (typeof options.origin === 'function') {
      origin = await options.origin(ctx);
      if (!origin) {
        return await next();
      }
    } else {
      origin = options.origin || '*';
    }

    let credentials;
    if (typeof options.credentials === 'function') {
      credentials = await options.credentials(ctx);
    } else {
      credentials = !!options.credentials;
    }

    if (credentials && origin === '*') {
      origin = requestOrigin;
    }

以上是egg-cors中间件的部分源码,从这里不难看出,它首先针对请求Origin和credentials做了相应处理,然后将处理后的origin和credentials进行响应头的赋值。

set('Access-Control-Allow-Origin', origin);

if (credentials === true) {
    set('Access-Control-Allow-Credentials', 'true');
}

if (options.exposeHeaders) {
    set('Access-Control-Expose-Headers', options.exposeHeaders);
}

if (options.secureContext) {
    set('Cross-Origin-Opener-Policy', 'same-origin');
    set('Cross-Origin-Embedder-Policy', 'require-corp');
}

因此,我们配置的CORS,说白了,其实是将响应头的Access-Control-Allow-Origin字段,设置为对应的Origin,而Origin正是我们的客户端URL。

  1. 在config/plugin.js中注册cors插件
cors: {
    enable: true,
    package: "egg-cors",
}
  1. 在config.default.js中配置自定义origin、allowMethods和credentials
/**
   * 配置跨域
   */
  config.cors = {
    origin: function(ctx) {
      const whiteOrigin = '.xx.com' // 这里表示任何以该字符串结尾的域名后缀都会被匹配上
      const url = ctx.request.headers.origin
      if (url.endsWith(whiteOrigin)) {
        return url
      }
      return 'http://localhost' // 默认允许本地请求可跨域
    },
    allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH', // 允许的HTTP请求方法
    credentials: true // cookie跨域配置
  }

这里最好不要把origin配置为*,*意味着任何Origin都可以拿到返回数据,并不安全,最好要进行域名路径的匹配,圈定一个合理的范围。

以上工作做完后,重新发布服务,经过验证,跨域问题就不存在了~

总结

本文继上文的内容,扩展介绍了SourceMap在服务端进行错误内容的解析和源文件定位,增强了上文的错误处理逻辑。

告警系统的建设是一项复杂的工程,需要不断打磨,不仅要考虑报错内容的可读性,还要考虑报错的频率、阈值配置、报错类型区分、多类型报错处理、性能监控、报错日志记录、报错推送、数据看板搭建、责任处理人圈选等等......作为一项前端DevOps工程,还有很长的路要走,要做的系统、全面、易用,需要考虑各种实际场景,任重而道远。