从零搭建 Node.js 企业级 Web 服务器(四):异常处理

字节追梦者
• 阅读 4188

异常类型与处理方法

Node.js 中的异常根据发生方式分为同步异常与异步异常,后者又进一步分为 Thunk 异常与 Promise 异常,共 3 类异常:

  • 同步异常 就是同步执行过程中抛出的异常,比如 throw new Error();
  • Thunk 异常 是指发生在异步回调中的异常,比如 fs.readFile 读不存在的文件,以回调第一个参数返回。
  • Promise 异常 是指 reject 引起的或 async 方法中抛出的异常,可以通过 Promise 的 catch 方法捕获。

在本文的 Node.js 版本 v12.8.2 中,未处理的同步异常会直接引起进程异常关闭,未处理的 Thunk 异常会被无视但如果在回调抛出就会引起进程异常关闭,未处理的 Promise 异常会引起进程警告事件但不会导致进程异常关闭。

在一个 7 x 24 小时运行的企业级 Web 服务器集群中,通常需要多层措施保障高可用性,针对程序异常至少在以下 3 层做好处理:

  • 代码级别异常处理:使用编程语句及运行时机制对发生的异常进行处理。
  • 进程级别异常处理:根据进程状态与重启策略对异常进程进行管理。
  • 节点级别异常处理:通过负载均衡和容器编排等运维手段将访问调离异常的节点。

本章将基于上一章已完成的工程 licg9999/nodejs-server-examples - 03-middleware 结合上述 3 方面的思考对代码进行调整。

加上异常处理机制

现在先写入用于注入异常的接口以提供初级的混沌工程入口:

// src/controllers/chaos.js
const { Router } = require('express');

const ASYNC_MS = 800;

class ChaosController {
  async init() {
    const router = Router();
    router.get('/sync-error-handle', this.getSyncErrorHandle);
    router.get('/sync-error-throw', this.getSyncErrorThrow);
    router.get('/thunk-error-handle', this.getThunkErrorHandle);
    router.get('/thunk-error-throw', this.getThunkErrorThrow);
    router.get('/promise-error-handle', this.getPromiseErrorHandle);
    router.get('/promise-error-throw', this.getPromiseErrorThrow);
    return router;
  }

  getSyncErrorHandle = (req, res, next) => {
    next(new Error('Chaos test - sync error handle'));
  };

  getSyncErrorThrow = () => {
    throw new Error('Chaos test - sync error throw');
  };

  getThunkErrorHandle = (req, res, next) => {
    setTimeout(() => {
      next(new Error('Chaos test - thunk error handle'));
    }, ASYNC_MS);
  };

  getThunkErrorThrow = () => {
    setTimeout(() => {
      throw new Error('Chaos test - thunk error throw');
    }, ASYNC_MS);
  };

  getPromiseErrorHandle = async (req, res, next) => {
    await new Promise((r) => setTimeout(r, ASYNC_MS));
    next(new Error('Chaos test - promise error handle'));
  };

  getPromiseErrorThrow = async (req, res, next) => {
    await new Promise((r) => setTimeout(r, ASYNC_MS));
    throw new Error('Chaos test - promise error throw');
  };
}

module.exports = async () => {
  const c = new ChaosController();
  return await c.init();
};
// src/controllers/index.js
const { Router } = require('express');
const shopController = require('./shop');
+const chaosController = require('./chaos');

module.exports = async function initControllers() {
  const router = Router();
  router.use('/api/shop', await shopController());
+  router.use('/api/chaos', await chaosController());
  return router;
};

Express 提供了默认的异常处理兜底逻辑,会将自动捕获的异常并交给 finalhandler 处理(直接输出异常信息)。Express 可以自动捕获同步异常并通过 next 回调捕获异步异常,但是无法捕获在异步方法中直接抛出的异常。因此访问上述接口会出现以下效果:

URL效果
http://localhost:9000/api/chaos/sync-error-handle异常被捕获并处理
http://localhost:9000/api/chaos/sync-error-throw异常被捕获并处理
http://localhost:9000/api/chaos/thunk-error-handle异常被捕获并处理
http://localhost:9000/api/chaos/thunk-error-throw引起进程异常关闭
http://localhost:9000/api/chaos/promise-error-handle异常被捕获并处理
http://localhost:9000/api/chaos/promise-error-throw引起进程警告事件

需要注意 promise-error-throw 注入的异常并没有被捕获也没有引起进程异常关闭,这会让程序进入十分模糊的状态,给整个 Web 服务埋下高度的不确定性,有必要对此类异常加强处理:

$ mkdir src/utils             # 新建 src/utils 目录存放帮助工具

$ tree -L 2 -I node_modules   # 展示除了 node_modules 之外的目录内容结构
.
├── Dockerfile
├── package.json
├── public
│   ├── glue.js
│   ├── index.css
│   ├── index.html
│   └── index.js
├── src
│   ├── controllers
│   ├── middlewares
│   ├── moulds
│   ├── server.js
│   ├── services
│   └── utils
└── yarn.lock
// src/utils/cc.js
module.exports = function callbackCatch(callback) {
  return async (req, res, next) => {
    try {
      await callback(req, res, next);
    } catch (e) {
      next(e);
    }
  };
};
// src/server.js
// ...
async function bootstrap() {
  // ...
}

+// 监听未捕获的 Promise 异常,
+// 直接退出进程
+process.on('unhandledRejection', (err) => {
+  console.error(err);
+  process.exit(1);
+});
+
bootstrap();
// src/controllers/chaos.js
const { Router } = require('express');
+const cc = require('../utils/cc');

const ASYNC_MS = 800;

class ChaosController {
  async init() {
    const router = Router();
    router.get('/sync-error-handle', this.getSyncErrorHandle);
    router.get('/sync-error-throw', this.getSyncErrorThrow);
    router.get('/thunk-error-handle', this.getThunkErrorHandle);
    router.get('/thunk-error-throw', this.getThunkErrorThrow);
    router.get('/promise-error-handle', this.getPromiseErrorHandle);
    router.get('/promise-error-throw', this.getPromiseErrorThrow);
+    router.get(
+      '/promise-error-throw-with-catch',
+      this.getPromiseErrorThrowWithCatch
+    );
    return router;
  }

  // ...

  getPromiseErrorThrow = async (req, res, next) => {
    await new Promise((r) => setTimeout(r, ASYNC_MS));
    throw new Error('Chaos test - promise error throw');
  };
+
+  getPromiseErrorThrowWithCatch = cc(async (req, res, next) => {
+    await new Promise((r) => setTimeout(r, ASYNC_MS));
+    throw new Error('Chaos test - promise error throw with catch');
+  });
}

module.exports = async () => {
  const c = new ChaosController();
  return await c.init();
};

再打开异常注入接口看一下效果:

URL效果
http://localhost:9000/api/chaos/promise-error-throw引起进程异常关闭
http://localhost:9000/api/chaos/promise-error-throw-with-catch异常被捕获并处理

现在程序的状态变得非常可控了,接下来构建镜像并结合重启策略启动容器:

$ # 构建容器镜像,命名为 04-exception,标签为 1.0.0
$ docker build -t 04-exception:1.0.0 .
# ...
Successfully tagged 04-exception:1.0.0

$ # 以镜像 04-exception:1.0.0 运行容器,命名为 04-exception,重启策略为无条件重启
$ docker run -p 9090:9000 -d --restart always --name 04-exception 04-exception:1.0.0 

访问 http://localhost:9090 的各个 chaos 接口即可看到当服务进程异常关闭后会自动重启并以期望的状态持续运行下去。

健康状态检测

服务进程在重启时会有短暂一段时间的不可用,在实际生产环境会使用负载均衡将访问分发到多个应用节点提高可用性。需要提供健康状态检测来帮助负载均衡判断流量去向。由于当前的异常处理机制会保持程序的合理状态,因此只要提供一个可访问的接口就能够代表健康状态:

// src/controllers/health.js
const { Router } = require('express');

class HealthController {
  async init() {
    const router = Router();
    router.get('/', this.get);
    return router;
  }

  get = (req, res) => {
    res.send({});
  };
}

module.exports = async () => {
  const c = new HealthController();
  return await c.init();
};
// src/controllers/index.js
const { Router } = require('express');
const shopController = require('./shop');
const chaosController = require('./chaos');
+const healthController = require('./health');

module.exports = async function initControllers() {
  const router = Router();
  router.use('/api/shop', await shopController());
  router.use('/api/chaos', await chaosController());
+  router.use('/api/health', await healthController());
  return router;
};

在后续生产环境部署时根据 /api/health 的状态码配置负载均衡检测应用节点健康状态即可。

补充更多异常处理

接下来用异常页面重定向替换 Express 默认异常兜底逻辑,并为店铺管理相关接口也加上 Promise 异常捕获:

<!-- public/500.html -->
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <h1>系统繁忙,请您稍后再试</h1>
    <a href="/">返回首页</a>
  </body>
</html>
// src/server.js
// ...
async function bootstrap() {
  server.use(express.static(publicDir));
  server.use('/moulds', express.static(mouldsDir));
  server.use(await initMiddlewares());
  server.use(await initControllers());
+  server.use(errorHandler);
  await promisify(server.listen.bind(server, port))();
  console.log(`> Started on port ${port}`);
}

// ...

+function errorHandler(err, req, res, next) {
+  if (res.headersSent) {
+    // 如果是在返回响应结果时发生了异常,
+    // 那么交给 express 内置的 finalhandler 关闭链接
+    return next(err);
+  }
+
+  // 打印异常
+  console.error(err);
+  // 重定向到异常指引页面
+  res.redirect('/500.html');
+}
+
bootstrap();
// src/controllers/shop.js
const { Router } = require('express');
const bodyParser = require('body-parser');
const shopService = require('../services/shop');
const { createShopFormSchema } = require('../moulds/ShopForm');
+const cc = require('../utils/cc');

class ShopController {
  shopService;

  async init() {
    this.shopService = await shopService();

    const router = Router();
    router.get('/', this.getAll);
    router.get('/:shopId', this.getOne);
    router.put('/:shopId', this.put);
    router.delete('/:shopId', this.delete);
    router.post('/', bodyParser.urlencoded({ extended: false }), this.post);
    return router;
  }

-  getAll = async (req, res) => {
+  getAll = cc(async (req, res) => {
    // ...
-  }
+  });

-  getOne = async (req, res) => {
+  getOne = cc(async (req, res) => {
    // ...
-  };
+  });

-  put = async (req, res) => {
+  put = cc(async (req, res) => {
    // ...
-  };
+  });

-  delete = async (req, res) => {
+  delete = cc(async (req, res) => {
    // ...
-  };
+  });

-  post = async (req, res) => {
+  post = cc(async (req, res) => {
    // ...
-  };
+  });
}

module.exports = async () => {
  const c = new ShopController();
  return await c.init();
};

这样一来,完整的异常处理就做好了。

本章源码

licg9999/nodejs-server-examples - 04-exception

更多阅读

从零搭建 Node.js 企业级 Web 服务器(零):静态服务
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
从零搭建 Node.js 企业级 Web 服务器(二):校验
从零搭建 Node.js 企业级 Web 服务器(三):中间件
从零搭建 Node.js 企业级 Web 服务器(四):异常处理
从零搭建 Node.js 企业级 Web 服务器(五):数据库访问
从零搭建 Node.js 企业级 Web 服务器(六):会话
从零搭建 Node.js 企业级 Web 服务器(七):认证登录
从零搭建 Node.js 企业级 Web 服务器(八):网络安全
从零搭建 Node.js 企业级 Web 服务器(九):配置项
从零搭建 Node.js 企业级 Web 服务器(十):日志
从零搭建 Node.js 企业级 Web 服务器(十一):定时任务
从零搭建 Node.js 企业级 Web 服务器(十二):远程调用
从零搭建 Node.js 企业级 Web 服务器(十三):断点调试与性能分析
从零搭建 Node.js 企业级 Web 服务器(十四):自动化测试
从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
7个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
3年前
java Exception和Error的区别
Exception子类下面的另一部分子类对应于Java程序中的非运行时异常的处理,这些异常也称为显式异常。它们都是在程序中用语句抛出、并且也是用语句进行捕获的,比如,文件没找到引起的异常、类没找到引起的异常等。常见的异常有:ArithmeticException——由于除数为0引起的异常;ArrayStoreExcept
梦
4年前
微信小程序new Date()转换时间异常问题
微信小程序苹果手机页面上显示时间异常,安卓机正常问题image(https://imghelloworld.osscnbeijing.aliyuncs.com/imgs/b691e1230e2f15efbd81fe11ef734d4f.png)错误代码vardate'2021030617:00:00'vardateT
Wesley13 Wesley13
3年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
03.Android崩溃Crash库之ExceptionHandler分析
目录总结00.异常处理几个常用api01.UncaughtExceptionHandler02.Java线程处理异常分析03.Android中线程处理异常分析04.为何使用setDefaultUncaughtExceptionHandler前沿上一篇整体介绍了crash崩溃
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
3年前
初探 Objective
作者:Cyandev,iOS和MacOS开发者,目前就职于字节跳动0x00前言异常处理是许多高级语言都具有的特性,它可以直接中断当前函数并将控制权转交给能够处理异常的函数。不同语言在异常处理的实现上各不相同,本文主要来分析一下ObjectiveC和C这两个语言。为什么要把ObjectiveC和
Wesley13 Wesley13
3年前
Java异常
异常分为两种:Exception、ErrorException:异常,可以捕捉到,进行处理以后可以让程序继续正常执行Error:错误,不能捕捉,只能修改代码,重新执行ThrowableException(RuntimeException非运行时异常)throw:抛出指定的异常throws:用在方法声明处,声明该方法可能发生