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

异常类型与处理方法

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 层做好处理:

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

本章将基于上一章已完成的工程 host1-tech/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();
};

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

本章源码

host1-tech/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 服务器(十三):断点调试与性能分析

阅读 547

推荐阅读