使用 Express.js 路由進行基於 Promise 的錯誤處理

已發表: 2022-03-11

Express.js 的標語是正確的:它是“一個快速、獨立、極簡的 Node.js 網絡框架”。 儘管當前 JavaScript 最佳實踐規定了使用 Promise,但 Express.js 默認不支持基於 Promise 的路由處理程序,這太沒有意見了。

由於許多 Express.js 教程都忽略了這些細節,因此開發人員經常養成為每條路由複制和粘貼結果發送和錯誤處理代碼的習慣,從而在進行過程中產生技術債務。 我們可以使用我們今天將介紹的技術來避免這種反模式(及其後果)——我已經在具有數百條路由的應用程序中成功使用了這種技術。

Express.js 路由的典型架構

讓我們從一個 Express.js 教程應用程序開始,其中包含一些用於用戶模型的路由。

在實際項目中,我們會將相關數據存儲在 MongoDB 等數據庫中。 但就我們的目的而言,數據存儲細節並不重要,因此為了簡單起見,我們將對其進行模擬。 我們不會簡化的是良好的項目結構,這是任何項目成功的關鍵。

Yeoman 通常可以生成更好的項目骨架,但是對於我們需要的,我們將簡單地使用 express-generator 創建一個項目骨架並刪除不必要的部分,直到我們擁有:

 bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.json

我們已經減少了與我們的目標無關的剩余文件的行。

這是主要的 Express.js 應用程序文件./app.js

 const createError = require('http-errors'); const express = require('express'); const cookieParser = require('cookie-parser'); const usersRouter = require('./routes/users'); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use('/users', usersRouter); app.use(function(req, res, next) { next(createError(404)); }); app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); module.exports = app;

這裡我們創建一個 Express.js 應用程序並添加一些基本的中間件來支持 JSON 使用、URL 編碼和 cookie 解析。 然後我們為/users添加一個usersRouter 。 最後,我們指定如果沒有找到路由該怎麼做,以及如何處理錯誤,我們稍後會更改。

啟動服務器本身的腳本是/bin/start.js

 const app = require('../app'); const http = require('http'); const port = process.env.PORT || '3000'; const server = http.createServer(app); server.listen(port);

我們的/package.json也是準系統:

 { "name": "express-promises-example", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/start.js" }, "dependencies": { "cookie-parser": "~1.4.4", "express": "~4.16.1", "http-errors": "~1.6.3" } }

讓我們在/routes/users.js中使用典型的用戶路由器實現:

 const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { userService.getAll() .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); module.exports = router;

它有兩條路線: /獲取所有用戶和/:id通過 ID 獲取單個用戶。 它還使用/services/userService.js ,它具有基於承諾的方法來獲取此數據:

 const users = [ {id: '1', fullName: 'User The First'}, {id: '2', fullName: 'User The Second'} ]; const getAll = () => Promise.resolve(users); const getById = (id) => Promise.resolve(users.find(u => u.id == id)); module.exports = { getById, getAll };

在這裡,我們避免使用實際的 DB 連接器或 ORM(例如,Mongoose 或 Sequelize),只是模擬使用Promise.resolve(...)獲取數據。

Express.js 路由問題

查看我們的路由處理程序,我們看到每個服務調用都使用重複的 .then( .then(...).catch(...)回調將數據或錯誤發送回客戶端。

乍一看,這似乎並不嚴重。 讓我們添加一些基本的現實世界要求:我們只需要顯示某些錯誤並省略一般的 500 級錯誤; 另外,我們是否應用這個邏輯必須基於環境。 有了這個,當我們的示例項目從它的兩條路線發展成一個有 200 條路線的真實項目時,它會是什麼樣子?

方法 1:效用函數

也許我們會創建單獨的實用程序函數來處理resolvereject ,並將它們應用到我們的 Express.js 路由中的任何地方:

 // some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err) => res.status(500).send(err); // routes/users.js router.get('/', function(req, res) { userService.getAll() .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); });

看起來更好:我們不會重複我們發送數據和​​錯誤的實現。 但是我們仍然需要在每條路由中導入這些處理程序,並將它們添加到傳遞給then()catch()的每個 Promise 中。

方法二:中間件

另一種解決方案可能是圍繞 Promise 使用 Express.js 最佳實踐:將錯誤發送邏輯移動到 Express.js 錯誤中間件(添加到app.js中)並使用next回調將異步錯誤傳遞給它。 我們的基本錯誤中間件設置將使用一個簡單的匿名函數:

 app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); });

Express.js 知道這是為了錯誤,因為函數簽名有四個輸入參數。 (它利用了每個函數對像都有一個.length屬性來描述函數需要多少參數的事實。)

通過next傳遞錯誤如下所示:

 // some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); // routes/users.js router.get('/', function(req, res, next) { userService.getAll() .then(data => handleResponse(res, data)) .catch(next); }); router.get('/:id', function(req, res, next) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(next); });

即使使用官方的最佳實踐指南,我們仍然需要在每個路由處理程序中使用我們的 JS 承諾來使用handleResponse()函數解析並通過傳遞next函數來拒絕。

讓我們嘗試用更好的方法來簡化它。

方法 3:基於 Promise 的中間件

JavaScript 最大的特點之一是它的動態特性。 我們可以在運行時將任何字段添加到任何對象。 我們將使用它來擴展 Express.js 結果對象; Express.js 中間件函數是一個方便的地方。

我們的promiseMiddleware()函數

讓我們創建我們的 Promise 中間件,這將使我們能夠靈活地更優雅地構建 Express.js 路由。 我們需要一個新文件/middleware/promise.js

 const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err = {}) => res.status(err.status || 500).send({error: err.message}); module.exports = function promiseMiddleware() { return (req,res,next) => { res.promise = (p) => { let promiseToResolve; if (p.then && p.catch) { promiseToResolve = p; } else if (typeof p === 'function') { promiseToResolve = Promise.resolve().then(() => p()); } else { promiseToResolve = Promise.resolve(p); } return promiseToResolve .then((data) => handleResponse(res, data)) .catch((e) => handleError(res, e)); }; return next(); }; }

app.js中,讓我們將中間件應用到整個 Express.js app對象並更新默認錯誤行為:

 const promiseMiddleware = require('./middlewares/promise'); //... app.use(promiseMiddleware()); //... app.use(function(req, res, next) { res.promise(Promise.reject(createError(404))); }); app.use(function(err, req, res, next) { res.promise(Promise.reject(err)); });

請注意,我們沒有省略錯誤中間件。 對於我們代碼中可能存在的所有同步錯誤,它仍然是一個重要的錯誤處理程序。 但是,錯誤中間件現在不再重複錯誤發送邏輯,而是通過發送到res.promise()Promise.reject()調用將任何同步錯誤傳遞給同一個中央handleError()函數。

這有助於我們處理像這樣的同步錯誤:

 router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); });

最後,讓我們在/routes/users.js中使用我們的新res.promise()

 const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { res.promise(userService.getAll()); }); router.get('/:id', function(req, res) { res.promise(() => userService.getById(req.params.id)); }); module.exports = router;

請注意.promise()的不同用途:我們可以將其傳遞給函數或承諾。 傳遞函數可以幫助您處理沒有承諾的方法; .promise()看到它是一個函數並將其包裝在一個承諾中。

實際向客戶端發送錯誤在哪裡更好? 這是一個很好的代碼組織問題。 我們可以在我們的錯誤中間件(因為它應該與錯誤一起工作)或在我們的承諾中間件(因為它已經與我們的響應對象交互)中做到這一點。 我決定將所有響應操作都放在我們的 Promise 中間件中的一個位置,但是由每個開發人員來組織他們自己的代碼。

從技術上講, res.promise()是可選的

我們添加了res.promise() ,但我們並沒有被限制在使用它:我們可以在需要時直接使用響應對象進行操作。 讓我們看一下這將是有用的兩種情況:重定向和流管道。

特例一:重定向

假設我們想將用戶重定向到另一個 URL。 讓我們在userService.js中添加一個函數getUserProfilePicUrl()

 const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`);

現在讓我們在我們的用戶路由器中以async / await風格使用它並直接響應操作:

 router.get('/:id/profilePic', async function (req, res) { try { const url = await userService.getUserProfilePicUrl(req.params.id); res.redirect(url); } catch (e) { res.promise(Promise.reject(e)); } });

請注意我們如何使用async / await ,執行重定向,並且(最重要的是)仍然有一個中心位置來傳遞任何錯誤,因為我們使用res.promise()進行錯誤處理。

特例 2:流管道

就像我們的個人資料圖片路由一樣,管道流是我們需要直接操作響應對象的另一種情況。

為了處理對我們現在重定向到的 URL 的請求,讓我們添加一個返回一些通用圖片的路由。

首先,我們應該在新的/assets/img子文件夾中添加profilePic.jpg 。 (在實際項目中,我們會使用 AWS S3 之類的雲存儲,但管道機制是相同的。)

讓我們通過管道傳輸此圖像以響應/img/profilePic/:id請求。 我們需要在/routes/img.js中為此創建一個新路由器:

 const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); router.get('/:id', function(req, res) { /* Note that we create a path to the file based on the current working * directory, not the router file location. */ const fileStream = fs.createReadStream( path.join(process.cwd(), './assets/img/profilePic.png') ); fileStream.pipe(res); }); module.exports = router;

然後我們在app.js中添加我們的新/img路由器:

 app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img'));

與重定向案例相比,一個區別可能很突出:我們沒有在/img路由器中使用res.promise() ! 這是因為傳遞錯誤的已管道響應對象的行為與錯誤發生在流中間的行為不同。

Express.js 開發人員在處理 Express.js 應用程序中的流時需要注意,根據錯誤發生的時間以不同的方式處理錯誤。 我們需要在管道之前處理錯誤( res.promise()可以幫助我們)以及中游(基於.on('error')處理程序),但更多細節超出了本文的範圍。

增強res.promise()

調用res.promise() ,我們也沒有按照我們的方式來實現它。 promiseMiddleware.js可以擴展為接受res.promise()中的一些選項,以允許調用者指定響應狀態代碼、內容類型或項目可能需要的任何其他內容。 由開發人員決定他們的工具並組織他們的代碼,以使其最適合他們的需求。

Express.js 錯誤處理符合現代基於 Promise 的編碼

由於app.js中的錯誤處理,這裡介紹的方法允許比我們開始時更優雅的路由處理程序以及單點處理結果和錯誤——即使是那些在res.promise(...)之外觸發的錯誤。 儘管如此,我們並沒有被迫使用它,並且可以根據需要處理邊緣情況。

這些示例的完整代碼可在 GitHub 上找到。 從那裡,開發人員可以根據需要向handleResponse()函數添加自定義邏輯,例如如果沒有可用數據,則將響應狀態更改為 204 而不是 200。

但是,對錯誤的附加控制更有用。 這種方法幫助我在生產中簡潔地實現了這些功能:

  • 將所有錯誤統一格式化為{error: {message}}
  • 如果未提供任何狀態,則發送通用消息,否則傳遞給定消息
  • 如果環境是dev (或test等),填充error.stack字段
  • 處理數據庫索引錯誤(即,某些具有唯一索引字段的實體已經存在)並優雅地響應有意義的用戶錯誤

這個 Express.js 路由邏輯都在一個地方,不涉及任何服務——這種解耦使代碼更容易維護和擴展。 這就是簡單但優雅的解決方案可以顯著改善項目結構的方式。


進一步閱讀 Toptal 工程博客:

  • 如何構建 Node.js 錯誤處理系統