一、背景
由于团队里有用到eggjs做后端服务,之前一直用egg-static
作为图片的静态资源服务,用户上传图片到eggjs的静态资源目录后,就可以通过url访问,非常方便。
最近接到一个新需求,需要上传音频,然后前端访问,所以也用egg-static
作为静态资源服务,但是却发现前端访问音频的时候eggjs会报错,错误信息如下:
[-/undefined/-/88ms GET /public/audio/test.mp3] nodejs.ECONNRESETError: read ECONNRESET
at TCP.onStreamRead (internal/stream_base_commons.js:111:27)
errno: "ECONNRESET"
code: "ECONNRESET"
syscall: "read"
headerSent: true
name: "ECONNRESETError"
pid: 6221
hostname: bogon
或者
[-/undefined/-/88ms GET /public/audio/test.mp3] nodejs.EPIPEError: write EPIPE
at WriteWrap.afterWrite (net.js:779:14)
errno: "EPIPE"
code: "EPIPE"
syscall: "write"
headerSent: true
name: "EPIPEError"
pid: 70068
hostname: bogon
egg版本“egg”: “^2.15.1”
egg-static配置
config.static = {
prefix: ‘/public/’,
dir: path.join(appInfo.baseDir, ‘./app/public’),
};
二、问题
发现问题后,先是网上找了一圈,大概知道是什么原因了,但是依然没有找到有效的解决方案,有兴趣的可以通过下面链接了解:
大概意思就是egg-static
依赖了koa-range
,但是koa-range
本身有bug,对Range
请求头处理的有问题(官方未修复),图片不会报错是因为图片显示浏览器要一次性加载完,而浏览器默认会对音视频资源发起Range分段请求,只加载当前播放需要的内容,剩下的边播放边加载,所以egg就报错了。
egg-static
的依赖:(github.com/eggjs/egg-s…)
"dependencies": {
"is-type-of": "^1.2.1",
"koa-compose": "^4.1.0",
"koa-range": "^0.3.0",
"koa-static-cache": "^5.1.2",
"mkdirp": "^0.5.1",
"ylru": "^1.2.1"
}
虽然eggjs控制台会报错,但是不影响服务稳定性,原则上忽略它还是可以接受的。
但是由于我们egg服务都接入了错误监控上报(sentry + 实时告警通知),所以如果用户一直访问,eggjs一直报错的话,我们会收到大量的告警信息,这并不是我们能接受的。
三、解决
1、前提:错误日志监控
我们的错误监控是通过egg提供的日志上报功能实现的,并且通过错误处理中间件捕获context上下文异常,然后再打印错误日志,最终触发错误日志上报。
这里可能会有人问,既然可以通过错误处理中间件捕获错误,那为什么不直接在错误处理中间件直接做错误日志上报,不需要通过打印错误日志再上报,步骤还少了一步。
这是因为错误日志输出的不只是请求context上下文的异常,它还可以包括框架本身、其他插件、中间件的错误输出,而错误处理中间件只能捕获请求context上下文带来的异常,能力有限,所以用日志上报功能实现可以监控到所有错误输出。
错误监控代码
// app/utils/RemoteErrorTransport.ts
import * as util from 'util';
import { Transport } from 'egg-logger';
class RemoteErrorTransport extends Transport {
// 定义 log 方法,在此方法中把日志上报给远端服务
log(level: string, args: any, meta: any): void {
let log: string;
if (args[0] instanceof Error) {
const err = args[0];
log = util.format('%s: %s\n%s\npid: %s\n', err.name, err.message, err.stack, process.pid);
} else {
log = util.format(...args);
}
// 这里进行上报处理
}
// app.ts 中给 errorLogger 添加 transport,这样每条日志就会同时打印到这个 transport 了async serverDidReady() {
app.getLogger('errorLogger').set('remote', new RemoteErrorTransport({ level: 'ERROR', app }));
}
错误处理中间件代码
// app/middleware/errorHandler.ts
import { Context } from 'egg';
export default function errorHandlerMiddleWare(): any {
return async (ctx: Context, next: () => Promise<any>) => {
try {
await next();
} catch (err) {
// 将错误日志完整堆栈信息记录下来,并且输出到 errorLog 中
ctx.logger.error(err);
const errorCode = err.code || err.errorCode || 500;
const errorMessage = (err.message || err.errorMessage || 'Internal Server Error').toUpperCase();
ctx.body = {
errorCode,
data: {},
errorMessage,
};
}
};
}
2、尝试中间件
思路:尝试通过错误处理中间件捕获错误,再将来自静态资源路径前缀的请求导致的特定错误忽略掉,不输出到errorLog中,避开错误监控上报。
修改后的错误处理中间件代码
// app/middleware/errorHandler.ts
import { Context } from 'egg';
export default function errorHandlerMiddleWare(): any {
return async (ctx: Context, next: () => Promise<any>) => {
try {
await next();
} catch (err) {
// 将错误日志完整堆栈信息记录下来,并且输出到 errorLog 中
if (ctx.request.path.startsWith(ctx.app.config.static.prefix) && (err.name === 'ECONNRESETError' || err.name === 'EPIPEError')) {
ctx.logger.info(err); // info输出,不会被上报
} else {
ctx.logger.error(err); // error输出,会被上报
}
const errorCode = err.code || err.errorCode || 500;
const errorMessage = (err.message || err.errorMessage || 'Internal Server Error').toUpperCase();
ctx.body = {
errorCode,
data: {},
errorMessage,
};
}
};
}
看起来这种方式可以解决,但实际上并不能,因为上面两个报错是在context上下文以外抛出的,不能被中间件的try catch
捕获,所以这种方式尝试失败,不可行。
3、拦截Transport
既然中间库不行,那就想到了修改ErrorTransport
组件,在ErrorTransport
组件里拦截错误日志上报,但是由于我们的ErrorTransport
是很多项目共用的公共插件,通过私有npm包进行管理,修改的话会影响到很多项目。同时,这个问题也是共性问题,其他项目暂时没遇到这种问题,但是不代表以后不会面临,所以还是要从长远去考虑,尽量做到既能复用,又不会影响其他项目。
思路:通过提供插件配置项,配置项启用后,才对特定错误进行处理
实现:
由于我们的ErrorTransport
组件是放在团队自己开发的Egg插件里的,所以要先在插件里增加一个配置项,如下
// @teamName/myPluginName/config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';
export default () => {
const config = {} as PowerPartial<EggAppConfig>;
// ...
config.myPluginName = {
errorTransport: {
ignoreStaticRangeError: false // 默认false,项目配置里设置为true
},
};
return config;
};
项目配置:
// myProjectName/config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';
export default () => {
const config = {} as PowerPartial<EggAppConfig>;
// ...
config.myPluginName = {
errorTransport: {
ignoreStaticRangeError: true // 启用
},
};
};
修改插件里的ErrorTransport
组件,如下
// @teamName/myPluginName/app/utils/RemoteErrorTransport.ts
import * as util from 'util';
import { Transport } from 'egg-logger';
class RemoteErrorTransport extends Transport {
// 定义 log 方法,在此方法中把日志上报给远端服务
log(level: string, args: any, meta: any): void {
let log: string;
if (args[0] instanceof Error) {
const err = args[0];
const { ctx } = meta;
if (ctx) {
// 如果配置ignoreStaticRangeError开启了,并且请求路径是静态资源,错误类型匹配的话,则进行拦截,不上报
if (ctx.app.config.pluginMyTeamName.errorTransport.ignoreStaticRangeError &&
ctx.url.startsWith(ctx.app.config.static.prefix) &&
(err.name === 'ECONNRESETError' || err.name === 'EPIPEError')) {
return;
}
}
log = util.format('%s: %s\n%s\npid: %s\n', err.name, err.message, err.stack, process.pid);
} else {
log = util.format(...args);
}
// 这里进行上报处理
}
}
改造完,npm link
一下,本地测试访问音频资源,发现控制台打印了报错,但是不会再触发告警了,验证通过!
通过配置化的方式,即做到了隋开随用,又做到了不会影响其他项目现有逻辑。
4、改用其他静态资源服务
这里笔者不展开讨论,留给大家去发现。