何为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看下效果。
这里我没有做分包处理,因此只有一个.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 || '';
效果如下图⬇️
这个路径就是报错文件的路径了,通过这个路径再加上报错上下文,就可以定位报错的具体位置了。
优化
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跨域。那为什么之前做错误分析推送的时候,并没有出现跨域问题呢?因为跨域是浏览器的一种安全保护机制,错误推送并没有直接在浏览器进行请求响应,而是以消息推送的形式发送给其他端(不是传统的浏览器环境下的请求响应),因此之前并没有遇到跨域的问题。
那么知道了问题的原因,那我们就需要针对这个域名进行跨域处理。
- 安装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。
- 在config/plugin.js中注册cors插件
cors: {
enable: true,
package: "egg-cors",
}
- 在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工程,还有很长的路要走,要做的系统、全面、易用,需要考虑各种实际场景,任重而道远。