從零搭建 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 服務器(四):異常處理

相關文章
相關標籤/搜索