• Node.js中如何优雅的处理异常?
  • 发布于 2个月前
  • 294 热度
    0 评论
前端出错没处理白屏,后端出错不处理程序崩了,对异常处理的处理是我们日常写代码中需要足够关注的部分。也是提升开发者对自己程序信心的有效方式之一。本文主要讲一下用 Node.js 写应用时的要注意什么异常?怎么抛出这些异常。
在写后端时通常会遇到这些异常:
1.数据库挂了,连接服务器时异常了
2.客户端请求有问题,服务器该返回 400
3.请求超时
4.资源未找到比如 404;
5.程序逻辑异常,服务器返回 500

上面提的的这些异常需要我们足够了解自己的程序,对这些可能会出问题的地方做异常捕,当这些异常发生时能返回给客户端一个“可控”的结果,也能让我们对自己的程序更可控。下面先来看看常见的异常。


数据结构和类型处理不严谨导致的异常
xxx of undefined 或者 null of xxx  作为 JS 错误率之首一定要高度警惕,新老手都容易翻车,这要求我们对数据的结构要有清晰的理解
 const user = {
   name: 'max'
 }

console.log(user.hello) 
没定义的东西不要乱取,取的时候也可以考虑用更安全的方式比如 lodash 的 get。
const data = getFromServer()
data.user = 'abc'
不要相信 server 的数据结构,先判断有没有 user,后端数据结构给错了是他的问题,但是程序因为这个崩了就是我们异常没处理的问题。
console.log(typeof null); // 输出 object 
console.log(typeof undefined); // 输出 undefined
null 和 undefined 的不要傻傻分不清楚, ?? 操作符是用来做什么的?
console.log("a" / 2); // 输出 NaN
看到一些网站上经常出现 NaN,这也是计算杀手,必须判断数据是数字后再计算,不然 NaN 会给用户脑袋中出现一个大大的问号,包括 parseInt("abc") 输出是什么?
var arr = [1, 2, 3]; 
console.log(arr[3]); // 输出 undefined
越界应该不能吧?
处理异步不得当导致的异常
处理回调
const fs = require('fs');
const write = function () { 
    fs.mkdir('./writeFolder'); 
    fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); 
} 
    
write();
这代码一定报错,fs.mkdir和 fs.writeFile 都是异步方法,第3行代码没执行完,第四条命令就开始运行了即目录都没有创建好,就开始往这个目录下的文件里写内容导致程序崩溃。怎么解?改成顺序调用?即创建目录后再调用写文件?
const fs = require('fs');
const write = function () { 
    fs.mkdir('./writeFolder', ()=>{
        fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); 
    }); 
} 
    
write();
这代码还是有问题,因为你也不确定这个目录有没有创建成功,我们需要记住一点 Node.js 的异步函数都是遵从 Error irst 原则,即如果这个异步操作失败, 第一个参数就会返回 error 值。
const fs = require('fs');
const write = function (callback) { 
        fs.mkdir('./writeFolder', (err, data) => { 
            if (data) 
                fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); 
            else 
                callback(err) });
} 
 // 堆代码 duidaima.com  
write(console.log);
这样就没问题了吗? 这个 write 函数还是有问题,我们只处理了 fs.mkdir 的错误,当 fs.writeFile 发生错误的话,我们的错误就被“吞掉了”。当然,我们的目标不是通过 if else 来处理 callback 的错误,而是把 callback 的异步方法封装成 promise,来使用更加语义和现代的 catch, 封装一个promise人人都会吧?不会的话参考这个例子封装个异步 sleep 函数练练手?
function doesWillNotAlwaysSettle(arg) {
    return new Promise((resolve, reject) => {
       doATask(foo, (err) => {
           if (err) {
                return reject(err);
            }

            if (arg === true) {
                resolve('I am Done')
            }
        });
    });
}
这里要提醒的是,千万别在第五行画蛇添足返回一个 字符串 类型,最佳实践是返回 error 对象 或者再 wrap 一个 error 对象,否则会 say goodbye to stack trace。
function readTemplate() {
    return new Promise(() => {
      databaseGet('query', function(err, data) {
          if (err) {
           reject('Template not found. Error: ', + err);
          } else {
            resolve(data);
          }
        });
    });
}

readTemplate();
Promise Hell?
听过写 Node.js 异步写出 callback 回调地狱的,那其实写出 promise 地狱也是"正常操作"。
'use strict';
const fs = require('fs').promises;
const write = function () {
    return fs.mkdir('./writeFolder').then(() => {
        fs.writeFile('./writeFolder/foobar.txt', 'Hello world!').then(() => {
            // do something
        }).catch((err) => {
            console.error(err);
        })
    }).catch((err) => {
        // catch all potential errors
        console.error(err)
    })
}
看着可怕不?怎么解决呢?用 async/await , 除了有这个好处外,还有一个好处!
爱看八股文的(本人不爱看)对 "Promise 只能有一个状态" 应该理解的很深,那这会导致 promise 中可能存在 dead zones? (翻译成死区会不会很奇怪?)
const p = new Promise((resolve, reject) => { throw new Error('同步错误'); });
p.catch(error => console.log(error)); 
p.then(() => console.log('已解决'));

观察上面代码,那是不是第3行是 dead zone? resolve 后这个 promise的状态已经变了,无法再改变。所以写代码时候不要搞这种心智负担,直接 try catch,通过将同步代码包装在 try/catch 块中,我们可以在出现任何错误的情况下正确拒绝 promise。这确保了所有错误(同步和异步)都得到正确处理。


忽略错误
写前端每次 try catch 住 error 不处理在有些场景下是可以被接受的,但是后端不行,后端要把为什么错了返回给调用方,具体调用方拿这个结果怎么展示那是他自己的事情。
router.get('/:id', function (req, res, next) {
    database.getData(req.params.userId)
    .then(function (data) {
        if (data.length) {
            res.status(200).json(data);
        } else {
            res.status(404).end();
        }
    })
    .catch(() => {
        log.error('db.rest/get: could not get data: ', req.params.userId);
        res.status(500).json({error: 'Internal server error'});
    })
});
像这个代码就问题很大,直接返回 500 服务器异常,太不友好了!你返回 400,给出错误原因,客户端知道是他自己的问题,500 会给所有人带来“沟通的痛苦”
unhandled rejections
unhandled rejections 这个错误大家应该非常熟悉,感觉上没处理 promise 的 rejection 状态影响不大,但是后果很严重。
async function foobar() {
    throw new Error('foobar');
}

async function baz() {
    throw new Error('baz')
}


(async function doThings() {
    const a = foobar();
    const b = baz();

    try {
        await a;
        await b;
    } catch (error) {
        // ignore all errors!
    }
})();
咋办?上Promise all呗
(async function doThings() {
    const a = foobar();
    const b = baz();

    try {
        await Promise.all([a, b]);
    } catch (error) {
        // ignore all errors!
    }
})();
还有一种情况非常容易出现  unhandled rejections
async function foobar() {
    throw new Error('foobar');
}

async function doThings() {
    try {
        return foobar()
    } catch {
        // ignoring errors again !
    }
}

doThings();

第七行明显没有处理错误,因为没 await,没明白? 看这篇 你知道吗?在try/catch块之外,return await是多余的。


专业一点点
作为一个专业的 codebase, 我们随便 throw new Error 对工程来讲有点草率了, 通常我们需要自定义 Error Class。
举个例子,先写一个baseError, 定义3个类
ApplicationError 这是所有其他错误类的祖先,即所有其他错误类都继承自它。
DatabaseError 任何与数据库操作有关的错误都将继承自此类。
UserFacingError 任何由用户与应用程序交互产生的错误都将继承自此类。
class ApplicationError extends Error {
    get name() {
        return this.constructor.name;
    }
}

class DatabaseError extends ApplicationError { }

class UserFacingError extends ApplicationError { }

module.exports = {
    ApplicationError,
    DatabaseError,
    UserFacingError
}
有了这 3 个类以后,我们就可以开始处理具体异常了,举一个用户查询不到的异常吧,其他异常大家可以自己发散,(比如连接数据库挂了,call第三方接口挂了,代码runtime挂了,各种)。
先定义具体的异常: 用户找不到
class NotFoundError extends UserFacingError {
    constructor(message, options = {}) {
        super(message);

        // You can attach relevant information to the error instance
        // (e.g.. the username)

        for (const [key, value] of Object.entries(options)) {
            this[key] = value;
        }
    }
    get statusCode() {
        return 404
    }
}
接下来就把我们定义的错误 用起来
app.get('/:id', async function (req, res, next) {
    let data

    try {
        data = await database.getData(req.params.userId)
    } catch (err) {
        return next(err); 
    }

    if (!data.length) { 
        // 我们用刚才定义的类来处理 用户没查到异常
        return next(new NotFoundError('Dataset not found'));
    }

    res.status(200).json(data)
})

app.use(function (err, req, res, next) {
   // 堆代码 duidaima.com
    // 判断是哪一类错误
    if (err instanceof UserFacingError) {
        res.sendStatus(err.statusCode);

        res.status(err.statusCode).send(err.errorCode)
    } else {
        res.sendStatus(500)
    }
 
    // 必须 log 异常
    logger.error(err, 'Parameters: ', req.params, 'User data: ', req.user)
});

这里有2个建议,demo 这么写 ok,但是工程里要分层处理错误。如果在任何地方处理错误,那么对错误处理的方法就是不一致的,代码又乱又难以追踪,其次就确保你抛出的错误类是你唯一异常原因来源,并且包含了调试应用程序所需的所有信息,不然你的错抛出来也无法定位到到问题,由于你的异常原因不准确不唯一。


小结
写服务端时候一定要对错误处理“上心“。
1.为应用统一编写错误类,或者根据自己使用的 Node.js 框架内置的错误处理扩展一下
2.一定要捕获错误,不能漏掉一个异常,无论是通过全局 catch 还是局部 catch
3.始终使用Async/Await,处理错误更优雅
4.将所有可能存在异常的地方 "表达" 出来(显示返回 or Log whatever),黑盒程序上线只能拜佛
5.使用 promisify ,有些老的 Node.js 还是 callback,可以用 promisify 包一下,就可以愉快的 try catch 了
6.返回有意义的状态码和错误值,符合 Web 标准最好,那考考你 创建数据成功该返回状态码多少?
7.使用 Promise Hooks,Promise Hooks可用于处理异步操作期间发生的错误。它们可用于捕获和处理异常,以及清理资源。

用户评论