構建 Node.js/TypeScript REST API,第 1 部分:Express.js
已發表: 2022-03-11如何在 Node.js 中編寫 REST API?
在為 REST API 構建後端時,Express.js 通常是 Node.js 框架中的首選。 雖然它還支持構建靜態 HTML 和模板,但在本系列中,我們將專注於使用 TypeScript 進行後端開發。 生成的 REST API 將是任何前端框架或外部後端服務都能夠查詢的 API。
你需要:
- JavaScript 和 TypeScript 的基礎知識
- Node.js 基礎知識
- REST 架構的基本知識(如果需要,請參閱我之前的 REST API 文章的這一部分)
- Node.js 的現成安裝(最好是 14+ 版本)
在終端(或命令提示符)中,我們將為項目創建一個文件夾。 從該文件夾中,運行npm init
。 這將創建我們需要的一些基本 Node.js 項目文件。
接下來,我們將添加 Express.js 框架和一些有用的庫:
npm i express debug winston express-winston cors
這些庫是 Node.js 開發人員的最愛有充分的理由:
-
debug
是一個模塊,我們將使用它來避免在開發應用程序時調用console.log()
。 這樣,我們可以在故障排除期間輕鬆過濾調試語句。 它們也可以在生產中完全關閉,而不必手動移除。 -
winston
負責記錄對我們 API 的請求以及返回的響應(和錯誤)。express-winston
直接與 Express.js 集成,因此所有標準 API 相關的winston
日誌記錄代碼都已經完成。 -
cors
是一個 Express.js 中間件,它允許我們啟用跨域資源共享。 如果沒有這個,我們的 API 將只能從與我們的後端完全相同的子域提供服務的前端使用。
我們的後端在運行時使用這些包。 但是我們還需要為我們的 TypeScript 配置安裝一些開發依賴項。 為此,我們將運行:
npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript
這些依賴項是為我們的應用程序自己的代碼以及 Express.js 和其他依賴項使用的類型啟用 TypeScript 所必需的。 當我們使用像 WebStorm 或 VSCode 這樣的 IDE 時,這可以讓我們在編碼時自動完成一些功能方法,從而節省大量時間。
package.json
中的最終依賴項應該是這樣的:
"dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }
現在我們已經安裝了所有必需的依賴項,讓我們開始構建我們自己的代碼!
TypeScript REST API 項目結構
對於本教程,我們將只創建三個文件:
-
./app.ts
-
./common/common.routes.config.ts
-
./users/users.routes.config.ts
項目結構的兩個文件夾( common
和users
)背後的想法是擁有各自負責的各個模塊。 從這個意義上說,我們最終將為每個模塊提供以下部分或全部內容:
- 路由配置來定義我們的 API 可以處理的請求
- 用於連接到我們的數據庫模型、進行查詢或連接到特定請求所需的外部服務等任務的服務
- 在路由的最終控制器處理其細節之前運行特定請求驗證的中間件
- 用於定義與給定數據庫模式匹配的數據模型的模型,以促進數據存儲和檢索
- 控制器,用於將路由配置與最終(在任何中間件之後)處理路由請求的代碼分離,必要時調用上述服務函數,並向客戶端提供響應
此文件夾結構提供了基本的 REST API 設計,是本教程系列其餘部分的早期起點,足以開始練習。
TypeScript 中的常用路由文件
在common
文件夾中,讓我們創建common.routes.config.ts
文件,如下所示:
import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }
我們在這裡創建路由的方式是可選的。 但是由於我們使用的是 TypeScript,所以我們的路由場景是一個練習使用帶有extends
關鍵字的繼承的機會,我們很快就會看到。 在這個項目中,所有路由文件都具有相同的行為:它們有一個名稱(我們將用於調試目的)並可以訪問主要的 Express.js Application
對象。
現在,我們可以開始創建用戶路由文件了。 在users
文件夾中,讓我們創建users.routes.config.ts
並開始編寫如下代碼:
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }
在這裡,我們導入CommonRoutesConfig
類並將其擴展到我們的新類,稱為UsersRoutes
。 使用構造函數,我們將應用程序(主要的express.Application
對象)和名稱 UsersRoutes 發送到CommonRoutesConfig
的構造函數。
這個例子很簡單,但是在擴展創建多個路由文件時,這將幫助我們避免重複代碼。
假設我們想在這個文件中添加新特性,比如日誌。 我們可以在CommonRoutesConfig
類中添加必要的字段,然後所有擴展CommonRoutesConfig
的路由都可以訪問它。
使用 TypeScript 抽象函數來實現跨類的類似功能
如果我們希望在這些類之間有一些相似的功能(比如配置 API 端點),但是每個類需要不同的實現怎麼辦? 一種選擇是使用稱為抽象的 TypeScript 功能。
讓我們創建一個非常簡單的抽象函數, UsersRoutes
類(以及未來的路由類)將從CommonRoutesConfig
繼承。 假設我們想強制所有路由都有一個名為configureRoutes()
。 這就是我們將聲明每個路由類資源的端點的地方。
為此,我們將在common.routes.config.ts
中添加三個快速的東西:
- 我們的
class
行的關鍵字abstract
,以啟用該類的抽象。 - 我們類末尾的新函數聲明,
abstract configureRoutes(): express.Application;
. 這會強制任何擴展CommonRoutesConfig
的類提供與該簽名匹配的實現——如果不匹配,TypeScript 編譯器將拋出錯誤。 - 調用
this.configureRoutes();
在構造函數的末尾,因為我們現在可以確定這個函數將存在。
結果:
import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }
這樣,任何擴展CommonRoutesConfig
的類都必須有一個名為configureRoutes()
的函數,該函數返回一個express.Application
對象。 這意味著users.routes.config.ts
需要更新:
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }
回顧一下我們所做的:
我們首先導入common.routes.config
文件,然後是express
模塊。 然後我們定義UserRoutes
類,說我們希望它擴展CommonRoutesConfig
基類,這意味著我們承諾它將實現configureRoutes()
。
為了將信息發送到CommonRoutesConfig
類,我們使用該類的constructor
。 它期望接收express.Application
對象,我們將在下一步中更深入地描述它。 使用super()
,我們將應用程序和路由的名稱傳遞給CommonRoutesConfig
的構造函數,在這種情況下是 UsersRoutes。 ( super()
反過來會調用我們的configureRoutes()
實現。)
配置用戶端點的 Express.js 路由
configureRoutes()
函數是我們將為 REST API 的用戶創建端點的地方。 在那裡,我們將使用 Express.js 中的應用程序及其路由功能。
使用app.route()
函數的想法是避免代碼重複,這很容易,因為我們正在創建具有明確定義的資源的 REST API。 本教程的主要資源是users 。 在這種情況下,我們有兩種情況:
- 當 API 調用者想要創建一個新用戶或列出所有現有用戶時,端點最初應該只在請求路徑的末尾有
users
。 (我們不會在本文中討論查詢過濾、分頁或其他此類查詢。) - 當調用者想要對特定用戶記錄執行特定操作時,請求的資源路徑將遵循模式
users/:userId
。
.route()
在 Express.js 中的工作方式讓我們可以通過一些優雅的鏈接來處理 HTTP 動詞。 這是因為.get()
、 .post()
等都返回與第一個.route()
調用相同的IRoute
實例。 最終的配置將是這樣的:
configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }
上面的代碼允許任何 REST API 客戶端通過POST
或GET
請求調用我們的users
端點。 同樣,它允許客戶端使用GET
、 PUT
、 PATCH
或DELETE
請求調用我們的/users/:userId
端點。

但是對於/users/:userId
,我們還使用all()
函數添加了通用中間件,它將在任何get()
、 put()
、 patch()
或delete()
函數之前運行。 當(在本系列的後面)我們創建僅供經過身份驗證的用戶訪問的路由時,此功能將很有用。
您可能已經註意到,在我們的.all()
函數中——與任何中間件一樣——我們有三種類型的字段: Request
、 Response
和NextFunction
。
- Request 是 Express.js 表示要處理的 HTTP 請求的方式。 此類型升級並擴展了本機 Node.js 請求類型。
- Response 同樣是 Express.js 表示 HTTP 響應的方式,再次擴展了原生 Node.js 響應類型。
- 同樣重要的是,
NextFunction
用作回調函數,允許控制通過任何其他中間件函數。 在此過程中,所有中間件將共享相同的請求和響應對象,然後控制器最終將響應發送回請求者。
我們的 Node.js 入口點文件app.ts
現在我們已經配置了一些基本的路由骨架,我們將開始配置應用程序的入口點。 讓我們在項目文件夾的根目錄下創建app.ts
文件,並從以下代碼開始:
import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';
在本文的這一點上,這些導入中只有兩個是新的:
-
http
是一個 Node.js 原生模塊。 它是啟動我們的 Express.js 應用程序所必需的。 -
body-parser
是 Express.js 附帶的中間件。 在控制權交給我們自己的請求處理程序之前,它會解析請求(在我們的例子中是 JSON)。
現在我們已經導入了文件,我們將開始聲明我們想要使用的變量:
const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');
express()
函數返回我們將在整個代碼中傳遞的主要 Express.js 應用程序對象,首先將其添加到http.Server
對象。 (我們需要在配置express.Application
後啟動http.Server
。)
我們將監聽端口 3000(TypeScript 會自動推斷為Number
)而不是標準端口 80 (HTTP) 或 443 (HTTPS),因為這些端口通常用於應用程序的前端。
為什麼選擇端口 3000?
沒有規定端口應該是 3000——如果未指定,將分配任意端口——但是在 Node.js 和 Express.js 的文檔示例中都使用 3000,所以我們在這裡繼續傳統。
Node.js 可以與前端共享端口嗎?
我們仍然可以在自定義端口上本地運行,即使我們希望後端響應標準端口上的請求。 這將需要反向代理來接收端口 80 或 443 上具有特定域或子域的請求。 然後它將它們重定向到我們的內部端口 3000。
routes
數組將跟踪我們的路由文件以進行調試,如下所示。
最後, debugLog
最終將作為一個類似於console.log
的函數,但更好:它更容易微調,因為它會自動限定到我們想要調用文件/模塊上下文的任何內容。 (在這種情況下,當我們將它以字符串形式傳遞給debug()
構造函數時,我們稱它為“app”。)
現在,我們準備好配置所有 Express.js 中間件模塊和 API 的路由:
// here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });
expressWinston.logger
與 Express.js 掛鉤,通過與debug
相同的基礎架構自動記錄每個已完成請求的詳細信息。 我們傳遞給它的選項將整齊地格式化和著色相應的終端輸出,當我們處於調試模式時,會使用更詳細的日誌記錄(默認)。
請注意,我們必須在設置expressWinston.logger
之後定義我們的路線。
最後也是最重要的:
server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });
這實際上啟動了我們的服務器。 一旦啟動,Node.js 將運行我們的回調函數,它在調試模式下報告我們配置的所有路由的名稱——到目前為止,只有UsersRoutes
。 之後,我們的回調通知我們後端已準備好接收請求,即使在生產模式下運行也是如此。
更新package.json
以將 TypeScript 轉換為 JavaScript 並運行應用程序
現在我們已經準備好運行我們的骨架,我們首先需要一些樣板配置來啟用 TypeScript 轉換。 讓我們在項目根目錄中添加文件tsconfig.json
:
{ "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }
然後我們只需要以以下腳本的形式將最後的潤色添加到package.json
中:
"scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },
test
腳本是一個佔位符,我們將在本系列的後面部分替換它。
start
腳本中的tsc屬於 TypeScript。 它負責將我們的 TypeScript 代碼轉換成 JavaScript,然後輸出到dist
文件夾。 然後,我們只需使用node ./dist/app.js
運行構建版本。
我們將--unhandled-rejections=strict
傳遞給 Node.js(即使使用 Node.js v16+),因為在實踐中,使用直接的“崩潰並顯示堆棧”方法進行調試比使用expressWinston.errorLogger
對象進行更高級的日誌記錄更直接。 即使在生產環境中也是如此,讓 Node.js 在未處理的拒絕情況下繼續運行可能會使服務器處於意外狀態,從而導致進一步(和更複雜)的錯誤發生。
debug
腳本調用start
腳本,但首先定義了一個DEBUG
環境變量。 這樣做的效果是啟用我們所有的debugLog()
語句(加上 Express.js 本身的類似語句,它使用與我們相同的debug
模塊)向終端輸出有用的細節——這些細節在運行時(方便地)以其他方式隱藏具有標準npm start
的生產模式下的服務器。
嘗試自己運行npm run debug
,然後將其與npm start
進行比較,以查看控制台輸出的變化。
提示:您可以使用DEBUG=app
而不是DEBUG=*
將調試輸出限制為我們的app.ts
文件自己的debugLog()
語句。 debug
模塊通常是相當靈活的,這個特性也不例外。
Windows 用戶可能需要將export
更改為SET
,因為export
是它在 Mac 和 Linux 上的工作方式。 如果你的項目需要支持多個開發環境,cross-env 包在這裡提供了一個簡單的解決方案。
測試 Live Express.js 後端
隨著npm run debug
或npm start
仍在運行,我們的 REST API 將準備好為端口 3000 上的請求提供服務。此時,我們可以使用 cURL、Postman、Insomnia 等來測試後端。
由於我們只為用戶資源創建了一個骨架,我們可以簡單地發送沒有正文的請求,以查看一切是否按預期工作。 例如:
curl --request GET 'localhost:3000/users/12345'
我們的後端應該發回GET requested for id 12345
的答案。
至於POST
ing:
curl --request POST 'localhost:3000/users' \ --data-raw ''
我們為之構建骨架的所有其他類型的請求看起來都非常相似。
準備使用 TypeScript 進行快速 Node.js REST API 開發
在本文中,我們開始通過從頭開始配置項目並深入了解 Express.js 框架的基礎知識來創建 REST API。 然後,我們通過使用擴展CommonRoutesConfig
的UsersRoutesConfig
構建一個模式,邁出了掌握 TypeScript 的第一步,我們將在本系列的下一篇文章中重用該模式。 我們完成了配置我們的app.ts
入口點以使用我們的新路由和package.json
和腳本來構建和運行我們的應用程序。
但即使是使用 Express.js 和 TypeScript 製作的 REST API 的基礎知識也相當重要。 在本系列的下一部分中,我們專注於為用戶資源創建適當的控制器,並深入研究一些用於服務、中間件、控制器和模型的有用模式。
完整的項目可以在 GitHub 上找到,本文末尾的代碼可以在toptal-article-01
分支中找到。