Express.js 作為 Node.js 生態中最流行的框架,以其簡潔、靈活的特點受到了廣大開發者的青睞。然而,要將 Express 伺服器提升到企業級水平,還需要考慮許多關鍵因素。
本文將帶你深入瞭解如何從零開始構建一個企業級的 Express 伺服器。我們將探討以下幾個核心方面:
程式碼分層:合理組織路由和中介軟體,提高程式碼的可讀性和可維護性。
中介軟體:構建高效可擴充套件的 Express 核心例如:日誌中介軟體、內容轉義中介軟體。
資料庫整合:使用 Prisma ORM 連線 Mysql 資料庫為例。
身份驗證與授權:JWT 使用者認證管理機制。
錯誤捕獲:統一的錯誤捕獲處理。
安全性:使用
helmet
等中介軟體增強伺服器的安全性,防範常見的網路攻擊。日誌記錄與錯誤處理:實現詳細的日誌記錄和優雅的錯誤處理機制。
程式碼分層
/express-best-practice # 專案根目錄 ├── node_modules/ # Node.js 依賴包 ├── prisma/ # Prisma 相關檔案 │ ├── schema.prisma # Prisma 資料庫模型定義檔案 ├── src/ # 原始碼目錄 │ ├── controllers/ # 控制器層,處理HTTP請求和響應 │ │ └── User.js # 使用者相關路由的控制器 │ │ └── Login.js # 登入相關路由的控制器 │ │ └── Other.. # 其他相關路由的控制器 │ ├── services/ # 服務層,包含業務邏輯 │ │ └── userService.js # 使用者相關的業務邏輯 │ │ └── Login.js # 登入相關路由的業務邏輯 │ ├── middlewares/ # 中介軟體層,用於處理請求前後的邏輯 │ │ └── escapeHtmlMiddle.js # 轉義中介軟體 │ │ └── logMiddle.js # 日誌中介軟體 │ ├── routes/ # 路由層,定義路由和控制器的對映 │ │ ├── index.js # 應用的入口路由檔案 │ └── utils/ # 工具函式和幫助程式 │ └── logs/ # 日誌檔案
程式碼分層參考 Nest.js 框架
Controller
路由管理:據請求的URL來決定呼叫哪個方法處理請求
業務邏輯呼叫:Controller會呼叫 Service 層的程式碼來執行具體的業務邏輯
依賴注入:可以呼叫注入的 Service
const postServiceInstance = require("../services/PostService"); const userServiceInstance = require("../services/UserService"); class PostController { // 依賴注入 postService = postServiceInstance; userService = userServiceInstance; controller = "/posts"; routes = [ { path: "", handler: this.getAll, method: "get", }, { path: "", handler: this.create, method: "post", }, ]; async getAll(req, res) { const posts = await this.postService.find(); res.json(posts); } async create(req, res) { const authorId = req.body.authorId; const user = await this.userService.findById(authorId); const dto = { title: req.body.title, content: req.body.content, authorId: parseInt(req.body.authorId), author: user, }; await this.postService.create(dto); res.status(200).json({ message: "成功", }); } } module.exports = new PostController();
Service
業務邏輯封裝:Service層包含了應用的業務邏輯,它封裝了與業務相關的操作。
可複用性:Service層的程式碼通常設計為可複用的,這意味著它可以被不同的Controller呼叫,甚至在不同的應用中使用。
資料訪問抽象:Service層通常透過 ORM 對資料庫進行訪問。
const { PrismaClient } = require('@prisma/client') const prisma = new PrismaClient(); class PostService { async find() { return await prisma.post.findMany({ include: { author: true } }); } async create(postDto) { return await prisma.post.create({ data: { title: postDto.title, content: postDto.content, authorId: postDto.authorId } }) } } module.exports = new PostService();
路由註冊
我們在 Controller 中定義了 routes,維護了路由,在 routes/index.js 是統一註冊路由的位置。
並且透過 asyncHandler 給路由新增非同步異常捕獲。當發生異常捕獲的時候,asyncHandler 會透過 next(error) 將非同步異常再拋出去,這樣可以在全域性的異常中捕獲。
這裏我們是透過 router.use("/api", routers); 新增全域性的路由字首。透過 const routerPath = prefix + path; 新增每個 Controller 路由字首,減少了路由共通字首部分的重複定義。
const asyncHandler = require("express-async-handler"); const { Router } = require('express'); const { userController, postController, loginController, } = require("../controllers/index"); const router = Router(); const allController = [userController, postController, loginController]; const routers = allController.map((item) => { const router = Router(); const prefix = item.controller; item.routes.forEach((route) => { // 將 Controller 中定義的路由實際註冊到 router 上 const { method, path, handler } = route; const routerPath = prefix + path; router[method.toLowerCase()](routerPath, asyncHandler(handler.bind(item))); }); return router; }); router.use("/api", routers); module.exports = router;
中介軟體
Express是一個路由和中介軟體web框架,它自己的功能很少:Express應用程式本質上是一系列中介軟體函式呼叫。
那什麼是中介軟體呢?
在Express框架中,中介軟體是一個函式,它處理HTTP請求和響應物件,並且可以向請求-響應迴圈中的下一個中介軟體傳遞執行許可權。一箇中間件可以執行以下任務:
執行任何程式碼。如進行一些邏輯驗證,邏輯計算,日誌輸出等。
對請求和響應物件進行更改。
結束請求-響應週期。也就是對應 http 中 res.end() 返回 http 請求響應。
呼叫堆疊中的下一個中介軟體。也就是呼叫 next() 函式。
如果當前中介軟體沒有結束請求-響應迴圈,它必須呼叫 next()
方法將控制權傳遞給下一個中介軟體。否則,請求將被掛起
轉義中介軟體
對請求中的某些資料進行清理,以防止例如跨站指令碼攻擊(XSS)等安全問題。轉義中介軟體可能會對請求體、查詢引數、路徑引數等進行HTML實體編碼,以確保任何特殊字元都被正確處理。
中介軟體模板函式就是 (req, res, next) => {}
在每次請求中介軟體都會被呼叫,所以你可以對請求中的引數進行轉義。
const escapeHtml = require('escape-html') module.exports = (req, res, next) => { if (req.body) { req.body = JSON.parse(JSON.stringify(req.body), (key, value) => { if (typeof value === "string") { return escapeHtml(value); } return value; }); } if (req.query) { req.query = JSON.parse(JSON.stringify(req.query), (key, value) => { if (typeof value === "string") { return escapeHtml(value); } return value; }); } if (req.params) { req.params = JSON.parse(JSON.stringify(req.params), (key, value) => { if (typeof value === "string") { return escapeHtml(value); } return value; }); } next(); };
日誌記錄中介軟體
Prisma 連線資料庫
安裝 Prisma CLI:
在專案中安裝 Prisma CLI 是第一步。通常,我們會將其作為開發依賴安裝到專案中,以避免不同專案間的版本衝突。
npm install prisma --save-dev
初始化 Prisma 專案:
使用 Prisma CLI 提供的init
命令來初始化專案,這會建立一個prisma
目錄,其中包含schema.prisma
檔案,以及一個.env
檔案用於儲存環境變數。
npx prisma init
配置環境變數,連線 mysql 資料庫:
在.env
檔案中配置資料庫連線字串。這個字串包含了資料庫的型別、使用者名稱、密碼、主機地址、埠和資料庫名稱。
root使用者名稱
123456 密碼
ip和埠號
資料庫名稱
DATABASE_URL="mysql://root:123456@localhost:3306/primadb"
定義資料模型:
在schema.prisma
檔案中定義你的資料模型。這些模型將對映到資料庫中的表。
model User { id Int @id @default(autoincrement()) email String @unique name String? }
生成 Prisma 客戶端,透過客戶端來運算元據庫:
使用generate
命令來生成 Prisma 客戶端,這是與資料庫互動的 JavaScript/TypeScript 客戶端。
npx prisma generate
遷移資料庫,生成遷移資料 sql ,記錄變更歷史:
使用migrate
命令來建立資料庫遷移,這將根據你的模型定義來更新資料庫架構。
npx prisma migrate dev --name init
遷移記錄如下
使用 Prisma 客戶端:
現在你可以在程式碼中使用 Prisma 客戶端來進行資料庫操作了。例如,建立一個新的使用者:
PrismaClient
例項在建立時會自動連線到資料庫。當例項不再使用時,Prisma 會管理連線的關閉。
const { PrismaClient } = require('@prisma/client') const prisma = new PrismaClient() async function main() { const newUser = await prisma.user.create({ data: { name: 'Alice', email: 'alice@prisma.io', }, }) console.log(newUser) } main() .catch(e => { throw e }) .finally(async () => { await prisma.$disconnect() })
JWT JSON Web Tokens
傳統模式
1、使用者向伺服器傳送使用者名稱和密碼。
2、伺服器驗證透過後,在當前對話(session)裡面儲存相關資料,比如使用者角色、登入時間等等。
3、伺服器向用戶返回一個 session_id,寫入使用者的 Cookie。
4、使用者隨後的每一次請求,都會透過 Cookie,將 session_id 傳回伺服器。
5、伺服器收到 session_id,找到前期儲存的資料,由此得知使用者的身份。
這種模式的問題在於,擴充套件性(scaling)不好。單機當然沒有問題,如果是伺服器叢集
,或者是跨域的服務導向架構,就要求 session 資料共享,需要依賴 Redis 伺服器統一管理 session 資料。
舉例來說,A 網站和 B 網站是同一家公司的關聯服務。現在要求,使用者只要在其中一個網站登入,再訪問另一個網站就會自動登入,請問怎麼實現?
一種解決方案是 session 資料持久化,寫入資料庫或別的持久層。各種服務收到請求後,都向持久層請求資料。這種方案的優點是架構清晰,缺點是工程量比較大。另外,持久層萬一掛了,就會單點失敗。
另一種方案是伺服器索性不儲存 session 資料了,所有資料都儲存在客戶端,每次請求都發回伺服器。JWT 就是這種方案的一個代表
JWT
Header(頭部) 表示演算法和token型別
Payload(負載) 實際存放資料的地方
Signature(簽名)金鑰
jsonwebtoken 生成 token
const jwt = require('jsonwebtoken'); const secretKey = 'shhhhh'; const token = jwt.sign( { username: user.username }, secretKey, { expiresIn: '1m' // 設定過期時間為1分鐘 } );
username 、expiresIn 對應 payload
secretKey 用於建立和驗證JWT的簽名的金鑰。
預設情況下,如果你不指定演算法,它將使用
HS256
{ "alg": "HS256", "typ": "JWT" }
express-jwt JWT驗證
const {expressjwt: jwt} = require("express-jwt"); const server = express(); server.use( jwt({ secret: secretKey, algorithms: ["HS256"] }).unless({ path: [/^/api/login/], }) );
server.use()
:將express-jwt
中介軟體新增到伺服器的中介軟體鏈中。這意味著,對於進入伺服器的每個HTTP請求,express-jwt
中介軟體都會嘗試驗證請求中的JWT。jwt({ secret: secretKey, algorithms: ["HS256"] })
:配置express-jwt
中介軟體,指定使用secretKey
作為簽名金鑰,並且僅接受HS256
演算法簽名的JWT。.unless({ path: [/^/api/login/] })
:這是一個條件中介軟體,它告訴express-jwt
中介軟體在某些路徑上跳過JWT驗證。在這個例子中,任何以/api/login
開頭的路徑都不會進行JWT驗證。
特點
JWT 的最大缺點是,由於伺服器不儲存 session 狀態,因此無法在使用過程中廢止某個 token,或者更改 token 的許可權。也就是說,一旦 JWT 簽發了,在到期之前就會始終有效,除非伺服器部署額外的邏輯。
JWT 本身包含了認證資訊,一旦洩露,任何人都可以獲得該令牌的所有許可權。爲了減少盜用,JWT 的有效期應該設定得比較短。對於一些比較重要的許可權,使用時應該再次對使用者進行認證。
JWT 本身由於傳輸的內容包含三部分,會導致每次請求傳輸到內容增大。
錯誤捕獲
在 Express 應用中,你可能會遇到以下幾種異常情況:
資料庫錯誤: 如連線失敗、唯一性約束錯誤、型別約束錯誤等。
無效的使用者輸入: 使用者提交的資料不符合預期,如型別錯誤、格式錯誤等。
檔案系統錯誤: 如檔案不存在、許可權問題等。
程式碼錯誤: 如邏輯錯誤、未處理的異常、手動丟擲錯誤等。
資源限制: 如記憶體不足、資料庫連線數超限等。
自定義的錯誤處理函式
同步錯誤捕獲
舉個簡單的例子
const express = require('express'); const app = express(); let port = 3001; app.use((error,req,res,next)=> { console.log('error :', error) }) // 開始監聽埠 app.listen(port, () => { console.log(`Express server is running on port ${port}`); });
非同步錯誤捕獲
但是錯誤中介軟體有個缺陷只能捕獲同步錯誤,如下幾個路由中,如果在非同步中丟擲錯誤,則錯誤中介軟體是無法捕獲錯誤
const express = require('express'); const app = express(); let port = 3001; // 同步路由處理器 app.get('/sync-error', (req, res, next) => { throw new Error('同步錯誤'); // 這個錯誤可以被錯誤中介軟體捕獲 }); app.get('/async-callback-error', (req, res, next) => { setTimeout(() => { throw new Error('非同步回撥錯誤'); // 這個錯誤無法被錯誤中介軟體捕獲 }, 1000); }); // 未處理的 Promise 拒絕 app.get('/unhandled-rejection', (req, res, next) => { Promise.reject(new Error('未處理的 Promise 拒絕')); // 這個拒絕無法被錯誤中介軟體捕獲 }); // 非同步路由處理器 app.get('/async-error', async (req, res, next) => { await new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('非同步錯誤')); // 這個拒絕無法被錯誤中介軟體捕獲 }) }, 1000); }); }); app.use((error,req,res,next)=> { console.log('error :', error) }) // 開始監聽埠 app.listen(port, () => { console.log(`Express server is running on port ${port}`); });
這個時候我們可以使用 express-async-handler 來包裹回撥函式。
並且函式改為 async 函式,在 async 函式中丟擲的錯誤會被錯誤中介軟體捕獲。
express-async-handler 本質就是說一個高階函式,返回一箇中間件函式。
在中介軟體函式中執行了原本的路由函式,由於路由函式改寫為 async 函式,它返回的是一個 Promise 物件。
這裏對 promise 新增了 catch 錯誤捕獲,如果有錯誤就回呼叫 next(error) 函式,next() 中如果有引數就會最終呼叫錯誤中介軟體。
const asyncHandler = require('express-async-handler') express.get('/', asyncHandler(async (req, res, next) => { await new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('非同步錯誤')); // 這個拒絕無法被錯誤中介軟體捕獲 }) }, 1000); }); }))
unhandledRejection 事件
如果我們透過 express-async-handler 包裹了路由回撥函式,基本場景的 Promise 拒絕都會被捕獲。如有遺漏的場景可以使用,unhandledRejection 事件作為兜底。
對於未處理的Promise拒絕,Node.js提供了 unhandledRejection
事件。當Promise被拒絕且在事件迴圈的一個輪詢內沒有錯誤控制代碼附加時,會觸發此事件。一般作為兜底處理未處理的Promise拒絕。
process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); // 記錄錯誤到日誌檔案 logErrorToDatabase(reason); });
uncaughtException 事件
'uncaughtException'
是用於異常處理的粗略機制,僅用作最後的手段。 事件_不應該_用作 On Error Resume Next
的等價物。 未處理的異常本質上意味著應用程式處於未定義狀態。 在沒有從異常中正確恢復的情況下嘗試恢復應用程式程式碼可能會導致其他不可預見和不可預測的問題。
'uncaughtException'
的正確用法是在關閉程序之前對分配的資源(例如檔案描述符、控制代碼等)執行同步清理。 在 'uncaughtException'
之後恢復正常操作是不安全的。
process.on('uncaughtException', (error, origin) => { console.error('Uncaught Exception:', error); console.error('Origin:', origin); // 記錄錯誤到日誌檔案 logErrorToDatabase(error); // 執行必要的清理操作 cleanupResources(); // 優雅地關閉應用程式 process.exit(1); // 非零狀態碼錶示異常退出 }); function logErrorToDatabase(error) { // 實現記錄錯誤到資料庫的邏輯 console.error('Logging error to database:', error.message); } function cleanupResources() { // 實現資源清理邏輯 console.log('Cleaning up resources...'); }
網路安全
常見的網路安全問題
跨站指令碼攻擊 (XSS) | 使用者透過表單或其他方式提交惡意指令碼,這些指令碼可以在其他使用者的瀏覽器中執行,從而竊取資訊或進行其他惡意活動。 | 使用輸入驗證和輸出編碼,使用 helmet 設定 X-XSS-Protection 頭部。 |
---|---|---|
跨站請求偽造 (CSRF) | 攻擊者利用已認證使用者的瀏覽器傳送惡意請求,這些請求看起來像是合法的使用者操作。 | 使用 csurf 中介軟體生成和驗證 CSRF 令牌。 |
SQL 注入 | 攻擊者透過在輸入欄位中插入惡意 SQL 程式碼,嘗試操控資料庫查詢。 | 使用引數化查詢或 ORM(如 Sequelize、Mongoose)來避免直接拼接 SQL 語句。 |
HTTP 頭部注入 | 攻擊者可能透過注入 HTTP 頭部來操縱客戶端的行為。 | 使用 helmet 設定安全的 HTTP 頭部。 |
點選劫持 (Clickjacking) | 攻擊者透過嵌入 iframe 的方式誘騙使用者點選某個按鈕或連結。 | 使用 helmet 設定 X-Frame-Options 頭部,防止頁面被嵌入到 iframe 中。 |
內容安全策略 (CSP) | CSP 是一種 HTTP 頭部,它允許網站開發者定義一個白名單,指定哪些來源的內容是可信的。瀏覽器會根據這個白名單來決定是否載入和執行頁面中的資源,如指令碼、樣式表、影象等。 | 使用 helmet 設定 Content-Security-Policy 頭部,限制頁面可以載入的資源。 |
使用 helmet
透過設定HTTP響應頭來保護Express應用,常見的響應頭如下,能解決如下問題:
跨站指令碼攻擊 (XSS) | 使用者輸入惡意指令碼,可能在其他使用者的瀏覽器中執行。 | xssFilter() |
X-XSS-Protection |
X-XSS-Protection: 1; mode=block |
---|---|---|---|---|
點選劫持 (Clickjacking) | 攻擊者透過嵌入 iframe 的方式誘騙使用者點選某個按鈕或連結。 | frameguard() |
X-Frame-Options |
X-Frame-Options: DENY 或 X-Frame-Options: SAMEORIGIN |
內容安全策略 (CSP) | 控制頁面可以載入的資源,防止 XSS 和其他注入攻擊。 | contentSecurityPolicy() |
Content-Security-Policy |
Content-Security-Policy: default-src 'self'; script-src 'self' https://code.jquery.com https://stackpath.bootstrapcdn.com; style-src 'self' https://stackpath.bootstrapcdn.com; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; media-src 'self'; frame-src 'none'; report-uri /csp-report-endpoint/ |
介面測試
在 client/client.html 檔案中寫了一個簡單的介面測試頁面,測試步驟如下。
打卡測試 html 檔案
對介面許可權、介面錯誤處理、介面響應進行測試
總結
透過完成一個完整的 Express 專案,我們配置了一個企業級專案通用的一些基礎功能。