开发中,有些开发者会积极寻求处理错误,力求减少开发时间,但也有些人完全忽略了错误的存在。正确处理错误不仅意味着能够轻松发现和纠正错误,而且还意味着能够为大型应用程序开发出稳健的代码库。
特别是对于 Node.js 开发人员,他们有时会也发现自己使用了不那么整洁的代码来处理各种错误,例如会在所有地方都用相同的逻辑来处理错误。那么,难道 Node.js 在处理错误方面不太友好 ?
不。本文里,我想告诉的是 Node.js 一点问题也没有。
首先,我们有必要对 Node.js 中的错误有一个清晰的认识。一般来说,Node.js错误分为两大类: 操作错误 和 开发者错误。
“undefined”
的属性。要解决这个问题,必须更改代码。因为这是开发者制造的错误,而不是操作错误。接下来的一个问题是:“为什么我们要把它们分成两类来处理?”
原因是,如果你没有对错误有一个清晰的认识,那么每当出现错误时,你可能会想重启服务。而当成千上万的用户正在使用你的程序时,他们可能看到的是“Not Found”。那这样的重启是否有意义?
同样,如果你的代码逻辑发生错误的时候,给应用带来了意想不到的问题,影响到了用户体验,这是否有意义?
假设你有一些使用异步 Js 的经验,那么在使用回调处理错误时可能会遇到一些挑战。例如在回调函数中你不断地进行错误检查,可能会导致嵌套过深,从而引发“回调地狱”的问题。这种情况会使代码流变得难以跟踪和理解。
那么,你可以使用 promise或async/await
替代回调。例如下面这段代码:
const doAsyncJobs = async () => {
try {
const result1 = await job1();
const result2 = await job2(result1);
const result3 = await job3(result2);
return await job4(result3);
} catch (error) {
console.error(error);
} finally {
await anywayDoThisJob();
}
}
在 Node.js 中有一个内置的 Error
对象,也是一个很好的处理办法,因为它包含了直观而清晰的错误信息,比如 StackTrace
,大多数开发者都依赖它来跟踪错误的根源。除此之外,还有一些其他有意义的属性,如 HTTP 状态码和通过扩展 Error 类的描述,将使其错误描述的更加具体。
class BaseError extends Error {
public readonly name: string;
public readonly httpCode: HttpStatusCode;
public readonly isOperational: boolean;
constructor(name: string, httpCode: HttpStatusCode, description: string, isOperational: boolean) {
super(description);
Object.setPrototypeOf(this, new.target.prototype);
this.name = name;
this.httpCode = httpCode;
this.isOperational = isOperational;
Error.captureStackTrace(this);
}
}
//继承 BaseError
class APIError extends BaseError {
constructor(name, httpCode = HttpStatusCode.INTERNAL_SERVER, isOperational = true, description = 'internal server error') {
super(name, httpCode, isOperational, description);
}
}
为了简单起见,我只实现了一些 HTTP 状态码,你可以尝试添加更多状态码:
export enum HttpStatusCode {
OK = 200,
BAD_REQUEST = 400,
NOT_FOUND = 404,
INTERNAL_SERVER = 500,
}
同时,你可以根据你的需要和个人偏好对常见错误进行扩展:
class HTTP400Error extends BaseError {
constructor(description = 'bad request') {
super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description);
}
}
那么如何使用它呢? 很简单,就是抛出这种错误类型:
const user = await User.getUserById(1);
if (user === null)
throw new APIError(
'NOT FOUND',
HttpStatusCode.NOT_FOUND,
true,
'detailed explanation'
);
现在,我们准备构建 Node.js 错误处理系统的主要组件: 集中式错误处理组件。
构建集中式的错误处理组件通常是一个好主意,以便在处理错误时避免可能的代码重复。错误处理组件负责使捕获的错误变得可以理解,例如,通过向系统管理员发送通知、将事件传输到监视服务器中(如 Sentry)、打日志记录错误。
下图中我给出了处理错误的基本工作流程:
在代码的某些部分,错误会被捕获并传递给错误处理中间件:
try {
userService.addNewUser(req.body).then((newUser: User) => {
res.status(200).json(newUser);
}).catch((error: Error) => {
next(error)
});
} catch (error) {
next(error);
}
错误处理中间件是区分错误类型并将它们发送到集中式错误处理组件的好地方:
app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => {
if (!errorHandler.isTrustedError(err)) {
next(err);
}
await errorHandler.handleError(err);
});
到目前为止,你应该可以想象到集中式组件应该是什么样子。不过请记住,这完全取决于你如何实现它。例如,它可能看起来像以下这样:
class ErrorHandler {
public async handleError(err: Error): Promise<void> {
await logger.error(
'Error message from the centralized error-handling component',
err,
);
await sendMailToAdminIfCritical();
await sendEventsToSentry();
}
public isTrustedError(error: Error) {
if (error instanceof BaseError) {
return error.isOperational;
}
return false;
}
}
export const errorHandler = new ErrorHandler();
不过,有时候你会发现默认的 “console.error”
输出错误信息不是很好阅读。相反,以格式化的方式输出错误可能会更好,这样开发者可以更快速理解问题并确保它们得到修复。
这里,我向你推荐 winston
或 morgan
这样的可定制记录器。
例如,下面是一个定制的 winston
记录器:
const customLevels = {
levels: {
trace: 5,
debug: 4,
info: 3,
warn: 2,
error: 1,
fatal: 0,
},
colors: {
trace: 'white',
debug: 'green',
info: 'green',
warn: 'yellow',
error: 'red',
fatal: 'red',
},
};
const formatter = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.splat(),
winston.format.printf((info) => {
const { timestamp, level, message, ...meta } = info;
return `${timestamp} [${level}]: ${message} ${
Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
}`;
}),
);
class Logger {
private logger: winston.Logger;
constructor() {
const prodTransport = new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
});
const transport = new winston.transports.Console({
format: formatter,
});
this.logger = winston.createLogger({
level: isDevEnvironment() ? 'trace' : 'error',
levels: customLevels.levels,
transports: [isDevEnvironment() ? transport : prodTransport],
});
winston.addColors(customLevels.colors);
}
trace(msg: any, meta?: any) {
this.logger.log('trace', msg, meta);
}
debug(msg: any, meta?: any) {
this.logger.debug(msg, meta);
}
info(msg: any, meta?: any) {
this.logger.info(msg, meta);
}
warn(msg: any, meta?: any) {
this.logger.warn(msg, meta);
}
error(msg: any, meta?: any) {
this.logger.error(msg, meta);
}
fatal(msg: any, meta?: any) {
this.logger.log('fatal', msg, meta);
}
}
export const logger = new Logger();
它主要提供的是以格式化的方式在多个不同级别进行日志记录,颜色清晰,并根据运行时环境记录到错误日志文件中。这样做的好处是,你可以使用 winston
的内置 api
来监视和查询日志。此外,你可以使用日志分析工具来分析格式化的日志文件,以获得有关应用程序的更多有用信息。
到目前为止,我们主要讨论了如何处理操作错误,那开发者的代码逻辑造成的错误呢?
由于开发者的错误是意料之外的,它们是实际的 bug,可能导致应用程序最终处于错误的状态,并以意想不到的方式运行。那么,处理这些错误的最佳方法是“立即崩溃”,然后使用像 PM2
这样的自动重启器优雅地重新启动:
process.on('uncaughtException', (error: Error) => {
errorHandler.handleError(error);
if (!errorHandler.isTrustedError(error)) {
process.exit(1);
}
});
最后我想要提到的是处理未处理的 promise.reject
和 异常。
在开发 Node.js/Express
应用程序时,你可能会发现自己花了很多时间处理承诺。当你忘记处理 reject
时,会看到有关未处理 promise.reject
的警告信息。
除了日志记录之外,警告消息不会做太多事情,但是使用适当的回退和订阅 process.on('unhandledRejection',callback)
是一个不错的做法。你可以将其视为Node.js 的一种全局的错误处理程序。
典型的错误处理流程如下所示:
User.getUserById(1).then((firstUser) => {
if (firstUser.isSleeping === false) throw new Error('He is not sleeping!');
});
...
// 获取未处理的 reject 并将其扔给我们已有的另一个回退处理程序
process.on('unhandledRejection', (reason: Error, promise: Promise<any>) => {
throw reason;
});
process.on('uncaughtException', (error: Error) => {
errorHandler.handleError(error);
if (!errorHandler.isTrustedError(error)) {
process.exit(1);
}
});
现在,你是否意识到无论是在开发阶段还是在生产阶段错误处理可不是一个可选的功能,而是应用程序的一个必要部分。
在 Node.js 中的单个组件中处理错误的策略将确保开发人员节省宝贵的时间,并通过避免代码重复和丢失错误上下文来编写干净且可维护的代码。不得不说,它已经成为 Node.js 应用程序的必备保健品。