Published on

Handling and Logging Errors in Express

Authors
  • avatar
    Name
    Jack Fan

Handling and Logging Errors

Introduction

现实会出很多错误,例如数据库连接断开,要发送合适的消息给回 Client,并在 Server 记录日志。

Handing Rejected Promises

Handing-Rejected-Promises

出现无法请求的 Promise 错误,且没有妥善处理。回到 genres.js

// routes/genres.js
router.get('/', async (req, res) => {
  const genres = await Genre.find().sort('name')
  res.send(genres)
})

这里 await 了但是没有用 try-catch 去处理错误,类似于对 Promise 使用 then 以后没有使用 catch 一样。如果使用 Promise,要使用 catch、采用异步方法则使用 try-catch

router.get('/', async (req, res) => {
  try {
    const genres = await Genre.find().sort('name')
    res.send(genres)
  } catch (ex) {
    res.status(500).send('Something failed.')
  }
})

这个时候就会再请求,Client 会收到提示。

Express Error Middleware

如果是现实,我们要给每个句柄都套上 try-catch,而且要修改的话要到每一处都做好修改,所以要把处理错误的逻辑进行集中。回到 index.js 添加中间件。

需要一个 err 参数,为在应用中某处捕捉到的异常。

// index.js
app.use(express.json())
// ...
app.use('/api/auth', auth)

app.use(function (err, req, res, next) {
  // Log the exception
  res.status(500).send('Something failed.')
})

现在看到 genres.js 刚才的 try-catch 块,在 catch 块中,要将控制权转移到错误处理函数,所以在路由句柄加上一个 next,来转移控制权。

router.get('/', async (req, res, next) => {
  try {
    //...
  } catch (ex) {
    next()
  }
})

现实开发中,处理函数会很长,所以要做封装。新建 middleware/error.js

// middleware/error.js
module.exports = function (err, req, res, next) {
  // Log the exception
  res.status(500).send('Something failed.')
}
// index.js
const error = require('./middleware/error')
app.use(error)

**注意:此处不是没有调用函数,只是传来一了一个 error 的应用。**目前依然要以来 try-catch 来捕捉错误,所以要进一步优化。

Removing Try Catch Blocks

要将那些高级代码放到单独的函数,在 genres.js 先新建一个 async Middleware,这个函数的基本结构是一个 try-catch 块。

function asycMiddleware() {
  try {
    // ...
  } catch (ex) {
    next(ex)
  }
}

传入路由句柄函数,并在 try 当中调用。这里路由句柄函数是 async 的,所以要稍作修改。

async function asycMiddleware(handler) {
  try {
    await handler()
  } catch (ex) {
    next(ex)
  }
}
router.get("/", async asyncMiddleware((req, res) => {
  const genres = await Genre.find().sort("name");
  res.send(genres);
}));

把这给路由句柄作为参数传入。但又有新的问题,requestresponse 怎么传入。我们唯一的参数只有 handler。如何访问 requestresponseex 呢?

**我们在定义 Express 路由时,*我们没有调用中中间函数,只是传入了一个引用。**例如

routes.get('/another', (req, res, next) => {})

这里我们并没有调用这个 Arrow Function,只是传入了他的引用,是 Express 自己调用了它。这三个参数也是由 Express 传给它的。

在这里其实是调用了 async Middleware,我们没有传入一个参数有三个参数的函数,所以要对这个处理函数做修改,我们让它返回一个函数,这样 Express 会自己调用这个返回的函数,然后传入三个参数。这个函数就类似于一个 Factory Function。调用它,返回一个路由句柄函数。

function asycMiddleware(handler) {
  return async (req, res, next) => {
    try {
      await handler(req, res)
    } catch (ex) {
      next(ex)
    }
  }
}

这里返回的是 async 函数,所以其本身就不需要为 async 函数。现在新建 middleware/async.js,将这个函数放入导出即可。

Express Async Errors

刚才虽然封装好了错误处理函数,但依然需要在每个地方调用,现在可以利用一个模块,它会自动捕捉异常,当发送请求时,它会自动把路由句柄包裹。

npm i express-async-errors

index.js 调用

require('express-async-errors')

现在去掉 genres.js 刚才嵌套的错误处理函数即可。

router.get("/", (req, res) => {
  const genres = await Genre.find().sort("name");
  res.send(genres);
});

Logging Errors

使用 Winston 记录日志。index.js 调用,将 logger(记录器)对象储存。

// index.js
const winston = require('winston')

这个 logger 对象,有输运特性(Transport)。输运基本上就是日志记录的介质。

Winston 有很多记录介质,Console:发送到控制台、File/Http:发送给指定终端地址,也可搭配其他附加的特定 Winston 模块使用,例如 MongoDB。刚才的加载默认导出为 logger。

// index.js
winston.add(winston.transports.File, { filename: 'logfile.log' })

回到 error.js。当捕捉到错误就可以记录了。

// middleware/error.js
const winston = require('winston')

module.exports = function (err, req, res, next) {
  // Log the exception
  winston.log('error', err.message, err)
  // winston.error(err.message, err);

  // error
  // warn
  // info
  // verbose
  // debug
  // silly

  res.status(500).send('Something failed.')
}

使用 log,第一个参数为记录等级,从下往上等级越高。然后传入错误信息,也可以传入原始信息作为第三个参数。也可以直接使用 error 方法直接输出错误,这样少一个参数。

回到 genres.js,抛出一个错误并测试。

// routes/genres.js
router.get('/', async (req, res, next) => {
  throw new Error('Could not get the genres.')
  //...
})

测试以后会发现多一个文件:logfile.log。里面的 JSON 记录了详细的错误信息。

Logging to MongoDB

npm i winston-mongodb

回到 index.js,添加运输介质。

// index.js
require('winston-mongodb')
winston.add(winston.transports.MongoDB, { db: 'mongodb://localhost/vidly' })

这里直接加载整个模块即可,现实开发中,日志记录要单独建一个数据库,此处直接使用同一个 vidly 库。

现在测试一下,打开 MongoDB 就能看到多一个文档 log 用于记录日志。

可以在设定传输介质的时候只记录特定等级的日志。假设记录 info

winston.add(winston.transports.MongoDB, {
  db: 'mongodb://localhost/vidly',
  level: 'info',
})

这里 info 是第三级,意味着比它高级的 error 和 warn 等级的日志都会被记录。

Uncaught Exceptions

现在添加到错误捕捉逻辑只能捕捉请求流程中的错误,是特别针对 Express 的,发生在 Express 之外的错误是不被捕捉的。例如

// index.js
throw new Error('...')

程序会无法运行,但这个错误不会被记录在 logfile.log。使用 process 对象处理。

process 对象有一个事件发生器(Event Emitter),它可以产生并发起事件,其有个方法 on 用于监听事件,Node 中有个特定的时间 uncaught Exception,它在 Node 处理过程中出现错误时发起,但我们没有专门捕捉它的 try-catch

process.on('uncaughtException', (ex) => {
  console.log('WE GOT AN UNCAUGHT EXCEPTION.')
  winston.error(ex.message, ex)
  process.exit(1)
})

第二个参数为错误处理函数,在里面使用 Winston 记录日志即可。

Unhandled Promise Rejections

刚才的方法只能处理同步代码,不能处理异步 Promise

// index.js
const p = Promise.reject(new Error('Somethind failed miserably!'))
p.then(() => consol.log('Done.'))

这就是一个未被处理的被拒 Promise。

利用刚才类似的方法,事件换为 unhandled Rejection。

process.on('unhandledRejection', (ex) => {
  console.log('WE GOT AN UNHANDLED REJECTION.')
  winston.error(ex.message, ex)
  process.exit(1)
})

此处 process.exit(1)是为了重启引用,0 为成功值,除此之外都为失败值。

还有一种方法,不需要 process 而是利用 Winston 的辅助函数。

winston.handleExceptions(new winston.transports.File({ filename: 'uncaughtExceptions.log' }))
// With this, we can handle uncaughtExceptions as well, also unhandledRejection

还有另一种。我们直接抛出错误,让被拒 Promise 变为一个未处理的异常。

process.on('unhandledRejection', (ex) => {
  throw ex
})

// In this way, winston will catch the ex automaticly, so we can hadle both of them in less code.

Extracting the Routes

现在的 index.js 太冗杂了,很多功能都集中在一块,现在把功能做单独封装。

新建文件夹 startup,新建文件 route.js。里面导出一个函数,并放置所有的路由和中间件。

// startup/route.js
const genres = require('../routes/genres')
const customers = require('../routes/customers')
const movies = require('../routes/movies')
const rentals = require('../routes/rentals')
const users = require('../routes/users')
const auth = require('../routes/auth')
const error = require('../middleware/error')
const express = require('express')

module.exports = function (app) {
  app.use(express.json())
  app.use('/api/genres', genres)
  app.use('/api/customers', customers)
  app.use('/api/movies', movies)
  app.use('/api/rental', rentals)
  app.use('/api/users', users)
  app.use('/api/auth', auth)
  app.use(error)
}

这里我们接收一个 app 作为参数,整个应用程序应该只有一个单一的 app 实例,而不是在这另外创建一个

// index.js
const express = require('express')
const app = express()

require('./startup/routes')(app)

注意更改引用地址

Extracting the DB Logic

新建 startup/db.js

// startup/db.js
const winston = require('winston')
const mongoose = require('mongoose')

module.exports = function () {
  mongoose.connect('mongodb://localhost/vidly').then(() => winston.info('Connected to MongoDB...'))
}

这里改用 Winston 做记录,取代 Console,也不需要 catch 块了。因为已经做过处理了。

// index.js
require('./startup/db')()

Extracting the Logging Logic

新建 startup/logging.js

// startup/logging.js
const winston = require('winston')
require('winston-mongodb')

module.exports = function () {
  winston.handleExceptions(new winston.transports.File({ filename: 'uncaughtExceptions.log' }))

  process.on('unhandledRejection', (ex) => {
    throw ex
  })
  winston.add(winston.transports.File, { filename: 'logfile.log' })
  winston.add(winston.transports.MongoDB, {
    db: 'mongodb://localhost/vidly',
    level: 'info',
  }) // Only message level > info will be recored(error warn info),
}
// index.js
require('./startup/logging')()
require('./startup/routes')(app)
require('./startup/db')()

这里 logging 放在最上面,以确保所有出错都会被捕捉。

Extracting the Config Logic

新建 startup/config.js

// startup/config.js
const config = require('config')

module.exports = function () {
  if (!config.get('jwtPrivateKey')) {
    throw new Error('FATAL ERROR: jwtPrivateKey is not defined')
  }
}

取代 console,抛出 Error 异常以由 Winston 记录错误并结束进程。

// index.js
require('./startup/config')()

Extracting the Validation Logic

新建 startup/validation.js

// startup/validation.js
const Joi = require('joi')
// A MongoDB ObjectId validator for Joi.
module.exports = function () {
  Joi.objectId = require('joi-objectid')(Joi)
}
// index.js
const winston = require('winston')
const express = require('express')
const app = express()

require('./startup/logging')()
require('./startup/routes')(app)
require('./startup/db')()
require('./startup/config')()
require('./startup/validation')()

const port = process.env.PORT || 3000
app.listen(port, () => winston.info(`Listening on port ${port}...`))

现在 index.js 简短很多了。把 listen 的 console 改为 Winston 即可。

Showing Unhandled Exceptions on the Console

我们需要将报错显示到控制台,不然有可能换台电脑,不会正常显示进程终止的原因。

// startup/logging.js
module.exports = function () {
  winston.handleExceptions(
    new winston.transports.Console({ colorize: true, prettyPrint: true }),
    new winston.transports.File({ filename: 'uncaughtExceptions.log' })
  )
  // ...
}

加上 Console 即可。里面设置了一些选项让可读性变强。

Node.js: The Complete Guide to Build RESTful APIs (2018) | Udemy