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

异常类型与处理方法

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

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

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

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

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

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

加上异常处理机制

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

// 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 回调捕获异步异常,可是没法捕获在异步方法中直接抛出的异常。所以访问上述接口会出现如下效果:docker

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 服务埋下高度的不肯定性,有必要对此类异常增强处理:express

$ 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 异常被捕获并处理

如今程序的状态变得很是可控了,接下来构建镜像并结合重启策略启动容器:json

$ # 构建容器镜像,命名为 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 接口便可看到当服务进程异常关闭后会自动重启并以指望的状态持续运行下去。bootstrap

健康状态检测

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

// 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 服务器(四):异常处理