Express 对异常的统一处理

3,732 阅读7分钟

(代码见:github.com/Laishuxin/c…

Express 对异常的统一处理

项目开发过程中,优雅地处理异常是一个好的工程师必备的素养。最近在项目开发过程中, 经常捕获到一些奇怪的异常(如下图)。显然,这是没有对异常进行处理的结果。

2022-06-27-23-31-07.png

下面 👇,我们将采用 Express + Typescript 来演示如何对异常进行处理。

通过本文,你可以学到以下知识 👏:

  • 捕获所有类型的异常。
  • 用一个处理器对所有的异常进行统一处理。
  • 自定义异常处理中间件。
  • 自定义 Error 类。

预备条件

环境配置

yarn init --yes

yarn add express
yarn add -D @types/express @types/node ts-node-dev typescript cross-en

# Windows 注意 shell 环境的差异
mkdir src
touch src/index.ts

启动:

// src/index.ts
import express from 'express'
import { router } from './routes'

const app = express()
const PORT = 3002

app.use(router)

app.listen(PORT, () => {
  console.log(`server is listening on ${PORT}`)
})

同步异常捕获

通常最好的做法是捕获所有的异常,并根据不同的异常做出处理。有时候我们会忘记对异常进行处理,好在 Express 会默认将同步异常交由中间件进行处理。

可以看以下示例:

// src/routes/index.ts
import express from 'express'
export const router = express()

router.get('/', (req, res) => {
  throw new Error('this is an error')
  res.json({ message: 'ok' })
})

当我们访问 / 时,会看到如下异常:

Error: this is a error
...

在生产环境(NODE_ENV='production')时,Express 会对同步异常进行加工处理, 于是我们的请求结果为

Internal Server Error

然而,对于异步异常 Express 是不会进行处理的。

异步异常捕获

Express 4 及以下版本中,异步异常会被跳过。

来看下面基于 Express4 的示例,我们在 Promise 中 抛出一个异常,这种异常 有可能出现在我们访问数据库过程中出现:

// src/routes/index.ts
router.get('/async-error', async (req, res) => {
  function _getUserFromDb() {
    return new Promise(() => {
      throw new Error('this is an error')
    })
  }

  const data = await _getUserFromDb()
  return res.json({ user: data }).end()
})

当我们访问 /async-error 时,可以看到以下异常信息:

(node:21168) UnhandledPromiseRejectionWarning: Error: this is an error

这是我们对于 Promise Rejection 异常没有进行处理。正确的做法是在外面套一层 try/catch,同时使用 next(error) 将异常交由 Express 异常中间件进行处理:

// src/routes/index.ts
router.get('/async-error', async (req, res, next) => {
  function _getUserFromDb() {
    return new Promise(() => {
      throw new Error('this is an error')
    })
  }

  try {
    const data = await _getUserFromDb()
    return res.json({ user: data }).end()
  } catch (err) {
    next(err)
  }
})

但是,这种异常处理方式非常繁琐,对于当个请求还好处理,一旦请求多了,对于每一个请求都 写 try/catch 显然太不实际。

当然,我们可以借助第三方异常处理中间件,帮我们 catch 住异步异常,这里采用 express-async-errors

我们还是先安装依赖:

yarn add express-async-errors

改造以下我们的异步请求:

// src/routes/index.ts
import express from 'express'
import 'express-async-errors'
export const router = express()

router.get('/async-error', async (req, res, next) => {
  function _getUserFromDb() {
    return new Promise(() => {
      throw new Error('this is an error')
    })
  }

  // 👇:不再使用 try catch
  const data = await _getUserFromDb()
  return res.json({ user: data }).end()
})

再次访问可以看到的异常信息就已经发生改变了,不再是 UnhandledPromiseRejectionWarning

Error: this is an error

其实,express-async-errors 是带有副作用的函数,它帮我们对 Express Router 进行加工,也就是对 Promise 异常进行封装处理, 可以简要看一下它的源码:

// express-async-errors

function wrap(fn) {
  const newFn = function newFn(...args) {
    // 获取到 handler 函数。
    const ret = fn.apply(this, args)
    const next = (args.length === 5 ? args[2] : last(args)) || noop
    // 检查执行结果是否是一个 Promise,并对 Promise 异常进行处理。
    if (ret && ret.catch) ret.catch((err) => next(err))
    return ret
  }
  Object.defineProperty(newFn, 'length', {
    value: fn.length,
    writable: false,
  })
  return copyFnProps(fn, newFn)
}

到目前以为,我们已经掌握 Express 对于同步异常和异步异常的处理。 但是还有一些异常是超出 Express 的管理范围的,下面我们继续展开讨论。

处理未捕获异常

在讨论这部分内容之前,我们先注释之前导入的 express-async-errors

// src/routes/index.ts
import express from 'express'
// import 'express-async-errors'

考虑如下情景:

// src/routes/index.ts
router.get('/rejection-error', (req, res) => {
  function _getUserFromDb() {
    return new Promise(() => {
      throw new Error('this is an error')
    })
  }

  return _getUserFromDb().then((user) => {
    res.json({
      user,
    })
  })
})

和之前的结果一样,当我们访问 /rejection-error 时,控制台会抛出 UnhandledPromiseRejectionWarning。 我们可以在 Node 注册全局的处理函数来捕获此类异常:

// src/process.ts
process.on('unhandledRejection', (reason: Error) => {
  console.log('on unhandledRejection: ', reason)
  throw new Error(reason?.message ?? reason)
})

// src/index.ts
import express from 'express'
import { router } from './routes'
import './process'

我们可以在监听函数中捕获 Rejection 异常,并进行适当的处理(这里直接往下抛)。 当我们访问 /rejection-error 我们可以在控制台看下如下信息 👇;

on unhandledRejection:  Error: this is an error
...

同样的道理,我们也可以捕获 uncaughtException

// src/process.ts
process.on('uncaughtException', (error) => {
  console.log('on uncaughtException: ', error)
  errorHandler.handleError(error)
})

// src/exceptions/error-handler.ts
export const errorHandler = {
  handleError(error: unknown) {
    console.log('errorHandler handleError: ', error)
  },
}

我们将异常交由一个错误处理器处理,后续我们会完善这一部分的代码。

自定义异常处理中间件

为了覆盖 Express 提供的默认错误处理中间件,我们需要自行提供错误处理中间件。不同于其他中间件,错误处理中间件 需要提供 4 个参数 err, req, res, next,参考下面的示例:

// src/routes/index.ts
import express, { NextFunction, Request, Response } from 'express'
// import 'express-async-errors'
export const router = express()

// routes...

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.log('handler error...')
})

访问之前会出错的路由,我们可以在终端看到如下的输出,说明错误处理中间件生效:

handler error...

事实上,我们还可以通过 next(err) 将错误继续往下抛,交由后面的中间件进行处理:

const logger = {
  log(err: Error) {
    console.log(`logger: `, err)
  },
}

const messageLogger = {
  sendErrorMessage(err: Error) {
    console.log('sendErrorMessage: ', err)
  },
}

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 日志记录
  logger.log(err)
  next(err)
})

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 发送通知信息
  messageLogger.sendErrorMessage(err)
  next(err)
})

router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 异常处理
  errorHandler.handleError(err)
})

我们可以在最后再对异常进行处理,在传递的过程中我们可以记录日志,又或者发送通知。

自定义异常处理

为了实现对不同区分对待,我们自定义异常处理对象。对于网络请求我们通常会携带 HTTP 状态码在创建自定义异常类之前 我们先安装所需要的依赖:

yarn add http-status-codes
yarn add @types/http-status-codes -D

接下来我们创建自定义异常类:

// src/exceptions/app-error.ts
import { StatusCodes } from 'http-status-codes'

interface AppErrorArgs {
  name?: string
  httpCode: StatusCodes
  message: string
  isOperational?: boolean
}

export class AppError extends Error {
  public readonly name: string
  public readonly httpCode: StatusCodes
  // 是否为严重错误
  public readonly isOperational: boolean = true

  constructor(args: AppErrorArgs) {
    super(args.message)

    Object.setPrototypeOf(this, new.target.prototype)
    this.name = args.name || this.constructor.name
    this.httpCode = args.httpCode
    if (args.isOperational !== undefined) {
      this.isOperational = args.isOperational
    }

    Error.captureStackTrace(this)
  }
}

异常处理

接下来我们就可以对异常进行处理:

// src/exceptions/error-handler.ts

import { Response } from 'express'
import { AppError } from './app-error'

class ErrorHandler {
  private isTrustedError(error: Error): boolean {
    if (error instanceof AppError) {
      return error.isOperational
    }

    return false
  }

  public handleError(err: Error | AppError, res?: Response) {}
}

export const errorHandler = new ErrorHandler()

我们用 isOperational 作为可信赖异常的判断依据,对于可信赖的异常我们 额外进行处理,其他异常我们视为服务器异常处理,于是 handleError 实现如下:

class ErrorHandler {
  // ...
  public handleError(err: Error | AppError, res?: Response) {
    if (err instanceof AppError && res) {
      this.handleTrustedError(err, res)
    } else {
      this.handleCriticalError(err, res)
    }
  }

  private handleTrustedError(err: AppError, res: Response) {
    res.status(err.httpCode).json({
      message: err.message,
    })
  }

  private handleCriticalError(err: Error | AppError, res?: Response) {
    if (res) {
      res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
        message: 'Internal server error',
      })
    }
    console.log('Server error with: ', err)
    process.exit(1)
  }
}

可信赖的异常是由我们通过 Expression 抛出来的,所以我们可以用 Response 进行统一处理。而不可信赖的异常可能出现在其他不被 Express 所捕获的异常,对此我们需要 判断传入的 Response 是否有值。

总结

以上就是如何在 Express 中对于异常的统一处理。

  • 同步异常 Express 会帮我们捕获。
  • 异步异常可以借助像 express-async-errors 捕获。
  • 我们可以自定义异常类,针对不同异常进行处理。
  • 我们可以通过覆盖 Express 处理中间件,自定义异常处理。
  • 监听全局未捕获的异常,确保程序的健壮。

参考文章