记一次 egg-static Range请求头报错的处理

1,846 阅读5分钟

一、背景

由于团队里有用到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’),
};

二、问题

发现问题后,先是网上找了一圈,大概知道是什么原因了,但是依然没有找到有效的解决方案,有兴趣的可以通过下面链接了解:

github.com/eggjs/egg/i…

github.com/eggjs/egg-s…

cnodejs.org/topic/5a531…

大概意思就是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、改用其他静态资源服务

这里笔者不展开讨论,留给大家去发现。