如何使用 HTTP 模組在 Node.js 中建立網頁伺服器
介紹
當你在瀏覽器中查看網頁時,你是在向互聯網上的另一台電腦發出請求,而該電腦則作為回應向你提供網頁。你透過互聯網與之通訊的這台電腦就是一個網頁伺服器。網頁伺服器接收來自客戶端的HTTP請求,例如你的瀏覽器,並提供HTTP回應,例如HTML頁面或來自API的JSON。
Node.js 的 http 模組提供了建立網頁伺服器所需的一切,無需外部依賴。它可以處理傳入的請求、解析 HTTP 標頭、管理連接以及發送回應——所有這些都通過簡單的 JavaScript API 來完成。
http 模組是 Node.js 內建的用於建立 HTTP 伺服器和客戶端的解決方案。與 Express.js 等框架不同,它是 Node.js 核心的一部分,意味著不需要安裝或依賴。這使它非常適合輕量級應用程式、學習 HTTP 基礎知識,或當你想要完整控制請求處理時使用。
一個伺服器要返回網頁需要涉及許多軟體。這些軟體通常分為兩類:前端和後端。前端程式碼關注內容的呈現方式,例如導覽列的顏色以及文字樣式。後端程式碼則關注資料的交換、處理與儲存。處理來自瀏覽器的網路請求或與資料庫通訊的程式碼主要由後端程式碼管理。
Node.js 允許開發者使用 JavaScript 撰寫後端程式碼,雖然傳統上它是在瀏覽器中用來撰寫前端程式碼的。前端和後端可以一起使用這樣的方式,減少了建立網頁伺服器所需的工作量,這也是 Node.js 成為撰寫後端程式碼熱門選擇的主要原因之一。
本教程示範如何使用 Node.js 內建的 http 模組來建立可投入生產的網頁伺服器。你將會建立能夠處理路由、解析請求主體、提供靜態檔案、實作正確的錯誤處理,並且同時支援 CommonJS 與 ES 模組語法的伺服器。
使用 Linux-Console.net 應用平台簡化 Node.js 應用的部署。可在幾分鐘內直接從 GitHub 部署 Node。
重點摘要
在開始之前,這裡是你將會達成的目標:
使用 Node.js 核心
http模組建立 HTTP 伺服器,無需框架:
你將學會如何僅使用 Node.js 的核心http模組啟動一個完整功能的 HTTP 網頁伺服器。這意味著不依賴外部套件或框架,讓你能更深入理解伺服器在底層的運作方式,並且在你的應用場景中實現更輕量且靈活的部署。處理不同的內容類型(JSON、HTML、CSV、靜態檔案)並設定適當的標頭:
您將學習如何處理多種內容類型,對網頁回應 HTML,對 API 回應 JSON,對 CSV 檔案進行處理,或提供如圖片和腳本等靜態資源。您將了解如何正確設定 HTTP 標頭,例如Content-Type,確保瀏覽器和客戶端正確解析您的回應,提供順暢的使用者體驗。使用請求 URL 解析和 switch 陳述式實作路由:
路由是網頁伺服器的基本功能,允許你針對不同的 URL 和端點提供不同的邏輯。你將看到解析傳入請求 URL 的技巧,以及如何結構化你的伺服器邏輯(例如使用 switch 陳述式),以根據路徑和 HTTP 方法提供自訂回應,模擬大型框架在幕後所執行的基本功能。透過解析請求主體並處理不同內容類型來處理 POST 請求:
處理使用者提交的資料是任何互動式應用程式的關鍵。你將學到如何處理 POST 請求,包括以原始資料、JSON 或其他格式讀取和解析請求主體。本節涵蓋緩衝傳入資料、安全解析資料,以及根據接收到的有效載荷做出回應,提供用於使用者互動或 API 開發的基礎。有效地從檔案系統提供靜態檔案:
許多網路伺服器需要將靜態資源——圖片、樣式表、JavaScript 檔案——直接提供給使用者。你將學習如何從磁碟讀取檔案並透過 HTTP 高效地傳送它們,妥善管理檔案路徑以避免安全問題,並探索如檔案串流等優化方法,以提升大型檔案的效能。實作錯誤處理並使用適當的 HTTP 狀態碼及日誌:
一個穩健的伺服器必須能夠優雅地處理錯誤。你將學到如何使用標準化的 HTTP 狀態碼(如 400、404 和 500)來偵測、記錄和回應錯誤,例如錯誤的請求或缺少檔案。你還會學習基本的日誌紀錄方法,讓問題可追蹤,並讓使用者收到具有資訊性的錯誤訊息,而不洩露敏感資料。-
使用現代 JavaScript,同時支援 CommonJS (
require) 與 ES Modules (import) 語法:
Node.js 支援兩種模組系統:CommonJS 與 ES Modules。你將練習使用require()與import語句,並了解何時以及如何使用每種語法。這不僅能確保在不同 Node.js 專案中的相容性,也有助於你撰寫現代、乾淨且易於維護的 JavaScript 代碼。 應用生產環境最佳做法,包括文件快取、錯誤恢復及安全性考量:
最後,你將會了解在現實環境中部署 Node.js 網頁伺服器的基本最佳做法。這包括利用文件快取來提升效能、實施錯誤恢復策略以保持伺服器的穩定性,以及應用基本的安全措施(例如輸入消毒和適當的 HTTP 標頭)來保護你的應用程式和使用者。
了解 HTTP 模組
http 模組是 Node.js 提供的用於 HTTP 通訊的低階介面。它提供了:
- 伺服器建立:
http.createServer()建立一個 HTTP 伺服器實例 - 請求處理:傳入的請求由
IncomingMessage物件表示 - 回應管理:發送的回應使用
ServerResponse物件 - 串流支援:請求和回應皆為串流,從而實現高效的資料傳輸
為什麼要使用 HTTP 模組而不是 Express? 雖然 Express.js 提供了方便的抽象,但原生的 http 模組可以讓你完全掌控、減少打包大小,並深刻理解 HTTP 的基本原理。它是框架建立的基礎。對於簡單的 API、微服務或學習用途,它通常已經足夠了。
這個模組是事件驅動的:當有請求到達時,伺服器會發出一個 'request' 事件,並呼叫你的請求監聽函數。這種設計讓 Node.js 能夠透過其事件迴圈,在單一線程上高效地處理許多並發連線。
核心概念:
- 非阻塞 I/O:
http模組使用 Node.js 的事件循環來處理多個連接,而不需要為每個請求創建獨立的線程。這使它在 I/O 密集型操作中非常高效。 - 串流:請求和回應物件繼承自 Node.js 串流。這意味著大型有效負載可以分批處理,而不需要將所有內容載入記憶體——這對處理檔案上傳或提供大型檔案至關重要。
- 標頭與狀態碼:此模組讓您可以直接控制 HTTP 標頭(
res.setHeader())和狀態碼(res.writeHead()),從而實現精確的 HTTP 語意。 - 沒有魔法路由:與框架不同,沒有內建的路由功能。你需要手動解析
req.url和req.method來決定如何回應——這讓你能完全了解路由的實際運作方式。
當你呼叫 http.createServer() 時,Node.js 會在幕後建立一個 TCP 伺服器,監聽傳入連線、解析 HTTP 協議訊息,並觸發你的程式碼可以處理的事件。了解這種底層運作有助於你除錯問題並做出明智的架構決策。
先決條件
確保你的開發機器已安裝 Node.js。本教程適用於 Node.js 20.x 或 22.x LTS(推薦)。要在 macOS 或 Ubuntu 上安裝 Node.js,請按照《在 macOS 上安裝 Node.js 並建立本地開發環境》或《如何在 Ubuntu 上安裝 Node.js》中的步驟操作。
Node.js 平台原生支持創建網頁伺服器。熟悉 Node.js 的基本概念會很有幫助。請查看我們的指南《如何在 Node.js 中編寫和運行你的第一個程式》。
在某些部分使用了非同步程式設計。如果你對非同步程式碼或
fs模組不熟悉,請參閱在 Node.js 中如何撰寫非同步程式碼。-
對於以生產為重點的部分,熟悉 Linux 的進程管理(systemd/PM2)和反向代理(如 Nginx)將有助於應用部署指導。
步驟 1 — 建立基本的 HTTP 伺服器
建立一個最簡單的 HTTP 伺服器,對任何請求都回應純文字。無論你使用 Express 還是從零開始建立,這都是基礎方法。當 Node.js 呼叫 http.createServer() 時,它會建立一個監聽指定端口的 TCP 伺服器。每個進來的連線都會觸發你的請求監聽函數。
建立您的專案資料夾:
mkdir first-servers
cd first-servers現在,建立將存放程式碼的檔案:
touch hello.js在文字編輯器中打開該檔案。我們將使用 nano,因為它可以在終端機中使用:
nano hello.js我們首先載入所有 Node.js 安裝中都標準提供的 http 模組。在 hello.js 中加入以下這行程式碼:
const http = require("http");http 模組包含用於創建伺服器的函數,我們稍後會看到。如果你想了解更多關於 Node.js 模組的資訊,可以參考我們的《如何創建 Node.js 模組》文章。
我們的下一步將是定義兩個常數,即我們的伺服器將綁定的主機和端口:
...
const host = 'localhost';
const port = 8000;如前所述,網頁伺服器會接收來自瀏覽器及其他客戶端的請求。我們可以透過輸入網域名稱來與網頁伺服器互動,而網域名稱會被 DNS 伺服器轉換成 IP 位址。IP 位址是一組獨特的數字序列,用來識別網路上的機器,例如互聯網上的設備。如需了解更多網域名稱的概念,請參考我們的《DNS 術語、組件與概念介紹》文章。
值 localhost 是一個特殊的私人地址,電腦用它來指代自己。它通常等同於內部 IP 地址 127.0.0.1,並且僅能在本地電腦上使用,無法被我們加入的任何本地網路或互聯網訪問。
端口是一個數字,伺服器用它作為連接我們 IP 位址的端點或「門」。在我們的範例中,我們將為網頁伺服器使用端口 8000。端口 8080 和 8000 通常在開發中用作預設端口,在大多數情況下,開發者會使用它們來取代其他 HTTP 伺服器端口。
當我們將伺服器綁定到這個主機和端口時,我們將能夠透過在本地瀏覽器中訪問 http://localhost:8000 來連接我們的伺服器。
讓我們添加一個特殊的函數,在 Node.js 中我們稱之為 請求監聽器。這個函數用來處理進來的 HTTP 請求並返回 HTTP 回應。這個函數必須有兩個參數:一個請求對象和一個回應對象。請求對象捕捉所有進來的 HTTP 請求資料。回應對象則用於為伺服器返回 HTTP 回應。
我們希望當有人訪問我們的第一個伺服器時,它能返回這條訊息:「我的第一個伺服器!」。
接下來我們來新增那個功能:
...
const requestListener = function (req, res) {
res.writeHead(200);
res.end("My first server!");
};函數通常會根據它的功能來命名。例如,如果我們創建了一個請求監聽函數來返回書籍列表,我們很可能會將它命名為 listBooks()。由於這是一個範例情況,我們將使用通用名稱 requestListener。
在 Node.js 中,所有的請求監聽函數都接受兩個參數:req 和 res(如果我們願意,可以給它們取不同的名字)。使用者發送的 HTTP 請求會被捕獲在一個 Request 物件中,這個物件對應第一個參數 req。我們返回給使用者的 HTTP 回應則是通過操作第二個參數 res 中的 Response 物件來形成的。
第一行 res.writeHead(200); 設置了回應的 HTTP 狀態碼。HTTP 狀態碼表示伺服器對 HTTP 請求的處理情況。在這個例子中,狀態碼 200 對應於 "OK"。如果你有興趣了解你的網頁伺服器可能返回的各種 HTTP 狀態碼及其含義,我們的指南《如何排解常見 HTTP 錯誤碼》是個不錯的起點。
函式的下一行,res.end("My first server!");,會將 HTTP 回應寫回給提出請求的客戶端。這個函式會回傳伺服器需回傳的任何資料。在這個案例中,它回傳的是文字資料。
最後,我們現在可以建立伺服器並使用我們的請求監聽器了:
...
const server = http.createServer(requestListener);
server.listen(port, host, () => {
console.log(`Server is running on http://${host}:${port}`);
});按下 CTRL+X 來儲存並退出 nano。
在第一行,我們透過 http 模組的 createServer() 函數創建一個新的 server 物件。這個伺服器接受 HTTP 請求並將它們傳遞給我們的 requestListener() 函數。
在我們建立伺服器後,必須將它綁定到一個網路地址。我們可以使用 server.listen() 方法來完成。它接受三個參數:port、host,以及在伺服器開始監聽時觸發的回呼函數。
所有這些參數都是可選的,但明確指定我們希望網路伺服器使用的埠和主機是個好主意。將網路伺服器部署到不同環境時,知道其運行的埠和主機是設定負載平衡或 DNS 別名所必需的。
回呼函數會將訊息記錄到我們的控制台,以便我們知道伺服器何時開始監聽連接。
這是完整的 hello.js 檔案,並附有解釋每個部分的內嵌註解:
// Import the built-in http module - no installation needed
const http = require("http");
// Define where the server will listen
const host = 'localhost'; // Only accessible from this machine
const port = 8000; // Common development port
// Request listener: handles every incoming HTTP request
const requestListener = function (req, res) {
// Set HTTP status code to 200 (OK)
res.writeHead(200);
// Send the response body and close the connection
res.end("My first server!");
};
// Create the server with our request listener
const server = http.createServer(requestListener);
// Start listening for connections
server.listen(port, host, () => {
console.log(`Server is running on http://${host}:${port}`);
});這段程式碼在不到20行中就建立了一個可運作的HTTP伺服器。每當有客戶端連線時——不論URL或HTTP方法為何——伺服器都會回應「我的第一個伺服器!」以及200狀態碼。
注意:即使 requestListener() 並未使用 req 物件,它仍然必須是函式的第一個參數。
只需不到十五行程式碼,我們就有了一個網頁伺服器。讓我們看它如何運作,並通過執行程式進行端到端測試:
node hello.js在控制台中,我們將看到這個輸出:
Server is running on http://localhost:8000注意提示消失了。這是因為 Node.js 伺服器是一個長時間運行的過程。它只有在遇到導致崩潰退出的錯誤時才會停止,或者當我們停止運行伺服器的 Node.js 進程時才會停止。
在另一個終端機視窗中,我們將使用 cURL 與伺服器進行通訊,cURL 是一個用於在網路上傳輸資料的 CLI 工具。輸入指令以對我們正在運行的伺服器發送 HTTP GET 請求:
curl http://localhost:8000當我們按下 ENTER 鍵時,我們的終端機將顯示以下輸出:
My first server!我們現在已經設置了一個伺服器,並收到了第一個伺服器回應。
讓我們解析一下當我們測試伺服器時發生的事情。使用 cURL,我們對伺服器發送了一個 GET 請求,目標地址是 http://localhost:8000。我們的 Node.js 伺服器監聽來自該地址的連線。伺服器將該請求傳遞給 requestListener() 函數。該函數返回狀態碼為 200 的文字資料。隨後,伺服器將這個回應發送回 cURL,cURL 在我們的終端機中顯示了這個訊息。
在生產環境中,你通常不會綁定到 localhost——你會綁定到 0.0.0.0 以接受來自任何網路介面的連接。然而,在本地開發中,localhost 更安全,並能防止外部訪問你的開發伺服器。此外,如果端口 8000 已經被使用,你會遇到 EADDRINUSE 錯誤——只需選擇另一個端口或使用 lsof -i :8000(macOS/Linux)停止占用該端口的進程即可。
在我們繼續之前,先按下 CTRL+C 結束正在運行的伺服器。這會中斷伺服器的執行,並將我們帶回命令列提示符。
在我們訪問的大多數網站或使用的 API 中,伺服器回應很少是純文字。我們通常會得到 HTML 頁面和 JSON 資料作為常見回應格式。在下一步,我們將學習如何以網路中常見的資料格式返回 HTTP 回應。
如果你想了解如何從 Node.js(客戶端)發送 HTTP 請求,請查看我們的指南《如何在 Node.js 中使用核心 HTTP 創建 HTTP 客戶端》。
步驟 2 — 返回不同類型的內容
我們從網頁伺服器返回的回應可以採用多種格式。之前提到過 JSON 和 HTML,我們還可以返回其他文本格式,例如 XML 和 CSV。最後,網頁伺服器還可以返回非文本資料,例如 PDF、壓縮檔案、音訊和影片。
在本文中,除了我們剛剛返回的純文字外,您還將學習如何返回以下類型的數據:
- JSON
- CSV
- HTML
這三種資料類型都是基於文字的,並且是網路上傳遞內容的熱門格式。許多伺服器端開發語言和工具都支援返回這些不同的資料類型。在 Node.js 的環境中,我們需要做兩件事情:
- 在我們的 HTTP 回應中使用適當的值設置
Content-Type標頭。 - 確保
res.end()以正確的格式接收資料。
讓我們通過一些例子來看看這個是如何運作的。我們在本節及後續章節中將要編寫的程式碼與之前編寫的程式碼有很多相似之處。大多數變化都出現在 requestListener() 函數中。讓我們使用這段“範本程式碼”來創建文件,以便未來的章節更容易跟隨。
建立一個名為 html.js 的新檔案。這個檔案稍後將用來在 HTTP 回應中返回 HTML 文字。我們會把模板程式碼放在這裡,並複製到其他返回不同類型的伺服器上。
在終端機中,輸入以下指令:
touch html.js現在在文字編輯器中打開這個文件:
nano html.js讓我們複製「範本程式碼」。在 nano 中輸入這個:
const http = require("http");
const host = 'localhost';
const port = 8000;
const requestListener = function (req, res) {};
const server = http.createServer(requestListener);
server.listen(port, host, () => {
console.log(`Server is running on http://${host}:${port}`);
});使用 CTRL+X 儲存並退出 html.js,然後返回終端機。
現在讓我們將這個檔案複製到兩個新檔案中。第一個檔案將用於在 HTTP 回應中返回 CSV 資料:
cp html.js csv.js第二個檔案將在伺服器中返回 JSON 回應:
cp html.js json.js剩下的檔案將用於後續練習:
cp html.js htmlFile.js
cp html.js routes.js我們現在已經準備好繼續練習了。讓我們從回傳 JSON 開始。
提供 JSON
JavaScript 物件表示法,通常稱為 JSON,是一種基於文本的資料交換格式。顧名思義,它源自 JavaScript 物件,但它與語言無關,這意味著任何能解析其語法的程式語言都可以使用它。
JSON 通常被 API 用來接收和返回資料。它的受歡迎程度源於相比於像 XML 這樣的舊有資料交換標準,其傳輸資料的大小更小,以及現有的工具可以讓程式在不費太多力氣的情況下解析它們。如果你想了解更多關於 JSON 的資訊,可以閱讀我們的指南《在 JavaScript 中如何使用 JSON》。
使用 nano 打開 json.js 文件:
nano json.js當提供 JSON 時,您必須將 Content-Type 標頭設置為 application/json。如果沒有設置,瀏覽器和 API 客戶端會將回應當作純文字處理,導致 fetch().json() 失敗。請在呼叫 writeHead() 之前設置標頭——一旦標頭發送後,就無法修改。
常見錯誤:在 res.setHeader() 之前呼叫 res.writeHead(200) 會鎖定標頭。如果你在 writeHead() 之後嘗試設置標頭,它們將被忽略。應該總是先呼叫 setHeader(),然後再呼叫 writeHead()。
修改 requestListener() 函數:
...
const requestListener = function (req, res) {
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(`{"message": "This is a JSON response"}`);
};
...狀態碼 200 表示成功。回應主體包含有效的 JSON。
為什麼要使用 JSON.stringify() 而不是手動寫字串? 手動編寫 JSON 很容易出錯。如果你的訊息包含引號、換行符號或特殊字元,你必須手動轉義它們。JSON.stringify() 會自動處理這些問題:
const data = {
message: "This is a JSON response",
timestamp: Date.now(),
note: 'He said "hello"' // Quotes automatically escaped
};
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(JSON.stringify(data));注意循環參考: 如果你的物件自我參考,JSON.stringify() 會拋出:TypeError: Converting circular structure to JSON。這種情況經常發生在資料庫查詢結果中。務必驗證你的資料結構。
如果你嘗試直接傳送一個物件會發生什麼? http 模組只接受字串或 Buffer。如果你嘗試 res.end({ message: "test" }),Node.js 會拋出:TypeError [ERR_INVALID_ARG_TYPE]: 第一個參數必須是字串或 Buffer 類型之一。你必須先將物件轉換為 JSON 字串。
確認標頭是否正確設置: 使用 -i 參數與 curl 查看回應標頭:
curl -i http://localhost:8000你應該在標頭中看到 Content-Type: application/json。如果你看到 text/plain 或沒有 Content-Type,你要麼是在 writeHead() 之後才調用了 setHeader(),要麼完全忘了設置它。
常見的除錯情境:如果你的前端 JavaScript 在呼叫 fetch('/api').then(r => r.json()) 時得到 undefined,請使用 curl -i 檢查標頭。Content-Type 必須完全是 application/json(對於某些嚴格的解析器來說大小寫敏感)。
按下 CTRL+X 來保存並退出 json.js。現在,讓我們使用 node 命令來啟動伺服器:
node json.js在另一個終端機中,讓我們使用 cURL 連接到伺服器:
curl http://localhost:8000當我們按下 ENTER 鍵時,我們將會看到以下結果:
{"message": "This is a JSON response"}我們現在已成功返回了一個 JSON 回應,就像我們使用的許多熱門 API 一樣。請務必使用 CTRL+C 結束正在運行的伺服器,這樣我們才能回到標準的終端提示符。接下來,讓我們看看另一種流行的資料返回格式:CSV。
提供 CSV
逗號分隔值(CSV)文件格式是一種常用於提供表格數據的文本標準。在大多數情況下,每一行以換行符分隔,而行中的每個項目則以逗號分隔。
在我們的工作區中,使用文字編輯器打開 csv.js 檔案:
nano csv.js讓我們在 requestListener() 函數中添加以下幾行:
...
const requestListener = function (req, res) {
res.setHeader("Content-Type", "text/csv");
res.setHeader("Content-Disposition", "attachment;filename=oceanpals.csv");
};
...這一次,我們的 Content-Type 表示返回的是 CSV 文件,因為其值是 text/csv。我們添加的第二個標頭是 Content-Disposition。這個標頭告訴瀏覽器如何顯示數據,特別是在瀏覽器中顯示還是作為單獨的文件。
當我們返回 CSV 回應時,大多數現代瀏覽器即使未設置 Content-Disposition 標頭,也會自動下載該文件。然而,在返回 CSV 文件時,我們仍應添加此標頭,因為它允許我們設置 CSV 文件的名稱。在這種情況下,我們向瀏覽器表示這個 CSV 文件是一個附件,應該被下載。然後我們告訴瀏覽器該文件的名稱是 oceanpals.csv。
讓我們在 HTTP 回應中寫入 CSV 資料:
...
const requestListener = function (req, res) {
res.setHeader("Content-Type", "text/csv");
res.setHeader("Content-Disposition", "attachment;filename=oceanpals.csv");
res.writeHead(200);
res.end(`id,name,email\n1,Sammy Shark,shark@ocean.com`);
};
...返回一個 200/OK 狀態以及 CSV 資料。逗號用來分隔每個欄位的值,而換行字元 (\n) 用來分隔列。我們有兩列:標題列和一列資料。
CSV 格式陷阱: 上面的簡單範例可以運作,但在生產環境中生成 CSV 則需要轉義。如果你的資料包含逗號(例如 "Smith, John")或引號,會破壞 CSV 格式。正確的 CSV 轉義方式是用引號包住值,並將內部的引號加倍:"Smith, John" 在 CSV 中會變成 "Smith, ""John"""。在生產環境中,建議使用像 csv-stringify 這樣的庫,或正確轉義值以防止解析錯誤。
如果沒有 Content-Disposition 會發生什麼? 大多數瀏覽器在設定了 Content-Type: text/csv 時會下載檔案,但檔名可能會是 URL 路徑或其他通用名稱。attachment;filename=oceanpals.csv 標頭可確保瀏覽器以指定名稱儲存檔案。若沒有 attachment,某些瀏覽器會嘗試直接在頁面中顯示 CSV,而這通常看起來很亂。
我們將在瀏覽器中測試這個伺服器。保存 csv.js 並使用 CTRL+X 退出編輯器。
使用 Node.js 命令啟動伺服器:
node csv.js在另一個終端機中,讓我們使用 cURL 連接到伺服器:
curl http://localhost:8000控制台將顯示這個:
id,name,email
1,Sammy Shark,shark@ocean.com如果我們在瀏覽器中打開 http://localhost:8000,將會下載一個 CSV 檔案。其檔案名稱將是 oceanpals.csv。
使用 CTRL+C 退出正在運行的伺服器,以返回標準終端提示符。
在返回了 JSON 和 CSV 之後,我們已經涵蓋了 API 常用的兩種情況。接下來讓我們看看如何返回用戶在瀏覽器中查看的網站數據。
提供 HTML
HTML(超文本標記語言)是當我們希望用戶透過網頁瀏覽器與伺服器互動時最常使用的格式。它被創建來結構化網頁內容。網頁瀏覽器被設計用來顯示 HTML 內容,以及我們使用另一種前端網頁技術 CSS 添加的任何樣式,CSS 可以讓我們改變網站的美觀效果。
讓我們用文字編輯器重新打開 html.js:
nano html.js修改 requestListener() 函數,以回傳適用於 HTML 回應的 Content-Type 標頭:
...
const requestListener = function (req, res) {
res.setHeader("Content-Type", "text/html");
};
...現在,讓我們將 HTML 內容返回給使用者。將高亮的行添加到 html.js,使其看起來像這樣:
...
const requestListener = function (req, res) {
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(`<html><body><h1>This is HTML</h1></body></html>`);
};
...我們首先添加 HTTP 狀態碼。然後我們使用一個包含有效 HTML 的字串作為參數調用 response.end()。當我們在瀏覽器中訪問伺服器時,我們將看到一個 HTML 頁面,其中有一個標題標籤,內容為 This is HTML。
讓我們按 CTRL+X 來儲存並退出。現在,讓我們使用 node 指令來啟動伺服器:
node html.js當我們的程式啟動時,我們將看到 Server is running on http://localhost:8000。
現在打開瀏覽器並訪問 http://localhost:8000。我們的頁面將會長這樣:
讓我們使用 CTRL+C 停止正在運行的伺服器,並返回標準終端提示符。
通常 HTML 會被寫在檔案中,與我們的 Node.js 程式等伺服器端程式碼分開。接下來,我們來看看如何從檔案返回 HTML 回應。
步驟 3 — 從檔案提供 HTML 頁面
我們可以在 Node.js 中將 HTML 當作字串提供給使用者,但更理想的做法是載入 HTML 檔案並提供其內容。這樣,隨著 HTML 檔案變大,我們就不必在 Node.js 代碼中維護冗長的字串,能保持代碼更簡潔,並讓我們可以獨立處理網站的各個部分。這種“關注點分離”在許多網頁開發環境中都很常見,因此學會如何在 Node.js 中載入 HTML 檔案並加以支援是很有幫助的。
要提供 HTML 檔案,我們使用 fs 模組載入 HTML 檔案,並在撰寫 HTTP 回應時使用其資料。
首先,我們將建立一個 HTML 檔案,網頁伺服器會回傳它。建立一個新的 HTML 檔案:
touch index.html現在在文字編輯器中打開 index.html :
nano index.html我們的網頁將會是極簡風格。它會有一個橙色背景,並在中央顯示一些問候文字。將此程式碼加入檔案中:
<!DOCTYPE html>
<head>
<title>My Website</title>
<style>
*,
html {
margin: 0;
padding: 0;
border: 0;
}
html {
width: 100%;
height: 100%;
}
body {
width: 100%;
height: 100%;
position: relative;
background-color: rgb(236, 152, 42);
}
.center {
width: 100%;
height: 50%;
margin: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-family: "Trebuchet MS", Helvetica, sans-serif;
text-align: center;
}
h1 {
font-size: 144px;
}
p {
font-size: 64px;
}
</style>
</head>
<body>
<div class="center">
<h1>Hello Again!</h1>
<p>This is served from a file</p>
</div>
</body>
</html>這個單一的網頁顯示兩行文字:Hello Again! 和 This is served from a file。這些文字會出現在頁面中央,前後排列。第一行文字以標題顯示,因此會較大。第二行文字會稍微小一些。所有文字都是白色,網頁背景為橙色。
雖然這不是本文或系列文章的範疇,但如果你有興趣了解更多關於 HTML、CSS 以及其他前端網頁技術的內容,你可以參考 Mozilla 的《網頁入門指南》。
這就是我們對 HTML 所需的一切,所以用 CTRL+X 保存並退出檔案。我們現在可以進入伺服器程式碼了。
在這個練習中,我們將操作 htmlFile.js。用文字編輯器打開它:
nano htmlFile.js由於我們需要讀取一個檔案,讓我們先從導入 fs 模組開始:
const http = require("http");
const fs = require('fs').promises;
...這個模組包含一個 readFile() 函數,我們將用它來載入 HTML 檔案。我們匯入的是 promise 版本,以符合現代 JavaScript 的最佳實踐。我們使用 promise,因為它的語法比回呼函數更簡潔,如果我們僅將 fs 指定為 require('fs'),就必須使用回呼函數。若想了解更多非同步程式設計的最佳實踐,你可以閱讀我們的《如何在 Node.js 中撰寫非同步程式碼》指南。
我們希望當使用者請求我們的系統時,能夠讀取我們的 HTML 檔案。讓我們從修改 requestListener() 以讀取檔案開始:
...
const requestListener = function (req, res) {
fs.readFile(__dirname + "/index.html")
};
...我們使用 fs.readFile() 方法來載入檔案。它的參數是 __dirname + "/index.html"。特殊變數 __dirname 代表 Node.js 程式執行所在的絕對路徑。我們接著加上 /index.html,以便載入我們先前建立的 HTML 檔案。
現在讓我們在 HTML 頁面加載完成後返回它:
...
const requestListener = function (req, res) {
fs.readFile(__dirname + "/index.html")
.then(contents => {
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(contents);
})
};
...如果 fs.readFile() 的 Promise 成功解析,它將返回其資料。我們使用 then() 方法來處理這種情況。contents 參數包含 HTML 檔案的資料。
我們首先將 Content-Type 標頭設置為 text/html,以告訴客戶端我們正在返回 HTML 資料。然後,我們寫入狀態碼以表示請求成功。最後,我們將加載的 HTML 頁面(存放在 contents 變量中)發送給客戶端。
fs.readFile() 方法有時可能會失敗,因此當我們遇到錯誤時應該處理這種情況。將此加入 requestListener() 函數中:
...
const requestListener = function (req, res) {
fs.readFile(__dirname + "/index.html")
.then(contents => {
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(contents);
})
.catch(err => {
res.writeHead(500);
res.end('Internal Server Error');
return;
});
};
...儲存檔案並使用 CTRL+X 退出 nano。
當一個 Promise 遇到錯誤時,會被拒絕。我們使用 catch() 方法來處理這種情況。它會將狀態碼設置為 500,表示遇到內部錯誤,並傳送一條對用戶友好的錯誤訊息。在生產環境中,您絕不應向使用者暴露原始錯誤細節,因為這可能洩露敏感資訊。
使用 node 命令運行我們的伺服器:
node htmlFile.js在網頁瀏覽器中,訪問 http://localhost:8000。你會看到這個頁面:
你現在已經從伺服器將 HTML 頁面返回給使用者。你可以按 CTRL+C 停止正在運行的伺服器。當你這麼做時,會看到終端機提示符回來。
在生產中編寫這樣的代碼時,您可能不希望每次收到 HTTP 請求時都加載 HTML 頁面。雖然此 HTML 頁面大小約為 800 字節,但更複雜的網站大小可能達到兆字節。大文件可能需要一段時間才能加載。如果您的網站預計會有大量流量,那麼最好在啟動時加載 HTML 文件並保存其內容。加載它們後,您可以設置服務器並使其偵聽地址上的請求。
為了演示這個方法,讓我們看看如何重新設計我們的伺服器,使其更高效且可擴展。
高效地提供 HTML
在這一步中,我們將不會為每個請求加載 HTML,而是在開始時加載一次。請求將返回我們在啟動時加載的數據。
在終端機中,使用文字編輯器重新打開 Node.js 腳本:
nano htmlFile.js在我們創建 requestListener() 函數之前,先新增一個變數:
...
let indexFile;
const requestListener = function (req, res) {
...當我們執行這個程式時,這個變數將會保存 HTML 檔案的內容。
現在,讓我們重新調整 requestListener() 函數。它將不再加載文件,而是返回 indexFile 的內容:
...
const requestListener = function (req, res) {
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(indexFile);
};
...接下來,我們將檔案讀取邏輯從 requestListener() 函數移到伺服器啟動時。在建立伺服器時,進行以下更改:
...
const server = http.createServer(requestListener);
fs.readFile(__dirname + "/index.html")
.then(contents => {
indexFile = contents;
server.listen(port, host, () => {
console.log(`Server is running on http://${host}:${port}`);
});
})
.catch(err => {
console.error(`Could not read index.html file: ${err}`);
process.exit(1);
});儲存檔案並使用 CTRL+X 退出 nano。
讀取檔案的程式碼與我們第一次嘗試寫的類似。然而,當我們成功讀取檔案後,我們現在將內容儲存到全域變數 indexFile 中。接著,我們使用 listen() 方法啟動伺服器。關鍵在於檔案是在伺服器運行之前就已經載入。這樣,requestListener() 函式就能確定返回一個 HTML 頁面,因為 indexFile 已經不再是空的變數。
我們的錯誤處理程序也已更改。如果檔案無法加載,我們會捕捉錯誤並將其列印到控制台。然後,我們使用 exit() 函數退出 Node.js 程式,而不啟動伺服器。這樣我們就可以看到檔案讀取失敗的原因,解決問題,然後再次啟動伺服器。
我們現在已經創建了不同的網頁伺服器,這些伺服器會向使用者返回各種類型的資料。到目前為止,我們還沒有使用任何請求資料來決定應該返回什麼。在設定 Node.js 伺服器的不同路由或路徑時,我們將需要使用請求資料,所以接下來讓我們看看它們如何協同工作。
步驟 4 — 使用 HTTP 請求物件管理路由
我們訪問的大多數網站或使用的 API 通常都有多個端點,以便我們可以訪問各種資源。一個很好的例子是一個書籍管理系統,可能會在圖書館中使用。它不僅需要管理書籍資料,還需要管理作者資料,以方便編目和搜索。
即使書籍和作者的數據是相關的,它們仍然是兩個不同的對象。在這種情況下,軟體開發人員通常會在不同的端點上編寫每個對象,以向 API 使用者表明他們正在處理哪種類型的數據。
讓我們為一個小型圖書館創建一個新的伺服器,它將返回兩種不同類型的資料。如果使用者訪問我們伺服器的 /books 地址,他們將收到一個 JSON 格式的書籍列表。如果他們訪問 /authors,他們將收到一個 JSON 格式的作者資訊列表。
到目前為止,伺服器對每個請求都返回相同的回應。要支援多個資源,請將您的資料存儲在變數中,並使用條件路由。例如:
const books = JSON.stringify([
{ title: "The Alchemist", author: "Paulo Coelho", year: 1988 },
{ title: "The Prophet", author: "Kahlil Gibran", year: 1923 }
]);
const authors = JSON.stringify([
{ name: "Paulo Coelho", countryOfBirth: "Brazil", yearOfBirth: 1947 },
{ name: "Kahlil Gibran", countryOfBirth: "Lebanon", yearOfBirth: 1883 }
]);
const requestListener = function (req, res) {
res.setHeader("Content-Type", "application/json");
switch (req.url) {
case "/books":
res.writeHead(200);
res.end(books);
break;
case "/authors":
res.writeHead(200);
res.end(authors);
break;
default:
res.writeHead(404);
res.end(JSON.stringify({error:"Resource not found"}));
}
}使用 curl http://localhost:8000/books 或 /authors 進行測試,以查看各自的資料。其他任何 URL 都會返回 404 錯誤。
簡單字串比對的問題: req.url 是一個原始字串,包含域名之後的所有內容——路徑、查詢字串以及片段。如果有人訪問 /books?id=123,你的 case "/books" 不會匹配,因為 req.url 是 /books?id=123,而不是 /books。查詢參數會破壞路由匹配:/books?sort=name 不會匹配 /books。URL 編碼的字符也會破壞路由匹配:/books%20reviews(空格編碼為 %20)不會匹配 /books reviews。
改用 URL API: URL API 會自動將路徑名稱與查詢參數分開,解碼 URL 編碼的字符,並正確處理各種邊界情況。
const host = req.headers.host || 'localhost:8000';
const url = new URL(req.url, `http://${host}`);
switch (url.pathname) {
case '/books':
// Validate HTTP method - only GET allowed for this endpoint
if (req.method !== 'GET') {
res.writeHead(405, { 'Allow': 'GET' });
return res.end(JSON.stringify({ error: 'Method not allowed' }));
}
res.writeHead(200);
return res.end(books);
case '/authors':
if (req.method !== 'GET') {
res.writeHead(405, { 'Allow': 'GET' });
return res.end(JSON.stringify({ error: 'Method not allowed' }));
}
res.writeHead(200);
return res.end(authors);
default:
res.writeHead(404);
return res.end(JSON.stringify({ error: 'Resource not found' }));
}為什麼要檢查 HTTP 方法? 相同的路徑可能支援不同的 HTTP 方法(GET 用於取得,POST 用於創建)。如果不進行此檢查,對 /books 發送 POST 請求可能會返回書籍列表,而不是創建新書,這會違反 REST API 的規範。405 Method Not Allowed 狀態碼搭配 Allow 標頭可以告訴客戶端哪些方法對該端點是有效的。
查詢字串處理: URL API 會自動將路徑名(/books)與查詢參數(?id=123)分開,因此 /books?id=123 可以正確地導向到 books 端點,同時如果需要,你仍然可以使用 url.searchParams.get('id') 來存取。
測試各種情境:
curl http://localhost:8000/books
curl http://localhost:8000/authors
curl http://localhost:8000/books?id=123 # Query string doesn't break routing
curl -X POST http://localhost:8000/books # Returns 405 Method Not Allowed
curl http://localhost:8000/nonexistent # Returns 404步驟 5 — 處理 POST 請求及解析請求主體
到目前為止,我們只處理了 GET 請求。大多數 API 需要接受來自客戶端的資料,這通常是透過 POST 請求來完成的。http 模組需要手動解析請求主體,這些主體是以資料流的形式接收的。
關鍵見解:與資料出現在 URL 的 GET 請求不同,POST 請求的主體是以串流方式傳送的。這表示資料不會一次全部到達,而是分批傳送。如果你在所有資料塊到達之前就嘗試解析 JSON,將會得到不完整或格式錯誤的資料。你必須先收集所有資料塊,然後再進行解析。
如果你太早嘗試解析,會發生以下情況:
// WRONG - Body isn't complete yet
req.on('data', chunk => {
const data = JSON.parse(chunk); // Will fail - chunk is only part of the body
});
// CORRECT - Collect all chunks first
let body = '';
req.on('data', chunk => body += chunk.toString());
req.on('end', () => {
const data = JSON.parse(body); // Now body is complete
});建立一個伺服器,能夠接收帶有 JSON 負載的 POST 請求。建立一個新檔案:
touch post.js
cp routes.js post.js打開 post.js:
nano post.js這是一個可用於生產環境的 JSON 主體解析器,能夠處理安全性問題:
const MAX_BODY = 1 * 1024 * 1024; // 1MB limit prevents memory exhaustion
function parseJSONBody(req, res) {
return new Promise((resolve, reject) => {
let body = '';
let received = 0;
// Validate Content-Type before processing
const contentType = req.headers['content-type'] || '';
if (!contentType.startsWith('application/json')) {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsupported Content-Type' }));
return reject(new Error('Unsupported Content-Type'));
}
// Collect data chunks
req.on('data', chunk => {
received += chunk.length;
// Prevent memory exhaustion from huge payloads
if (received > MAX_BODY) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy(); // Stop receiving more data
return reject(new Error('Payload too large'));
}
body += chunk.toString(); // Convert Buffer to string
});
// All chunks received, now parse
req.on('end', () => {
try {
if (!body) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Empty request body' }));
return reject(new Error('Empty request body'));
}
const data = JSON.parse(body);
// Validate it's an object (not a string, number, etc.)
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON object' }));
return reject(new Error('Invalid JSON object'));
}
resolve(data);
} catch (parseError) {
// JSON.parse() throws for malformed JSON
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Malformed JSON' }));
console.error('JSON parse error:', parseError.message);
}
});
// Handle network errors
req.on('error', (error) => {
console.error('Request error:', error.message);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Request error' }));
});
});
}為什麼要檢查 Content-Type? 如果客戶端以 Content-Type: application/x-www-form-urlencoded 發送表單資料,試圖將其解析為 JSON 將會失敗。415 Unsupported Media Type 狀態碼告訴客戶端,他們的 Content-Type 不被接受。
為什麼要限制請求體大小? 如果沒有限制,惡意客戶端可能會傳送數 GB 的資料,耗盡伺服器記憶體。當超過限制時,413 Payload Too Large 響應和 req.destroy() 會立即停止連線。
為什麼要驗證解析結果? JSON.parse('"hello"') 會成功,但返回的是字串,而不是物件。你的程式碼可能期望 data.property 能正常運作,但字串並沒有屬性。這個檢查可以確保你得到真正的物件。
在你的 POST 處理函式中使用這個函式:
const requestListener = async function (req, res) {
if (req.method === 'POST' && req.url === '/books') {
const bookData = await parseJSONBody(req, res);
// Now use bookData safely
// ...
}
};第六步 — 提供靜態檔案
許多網頁應用程式需要提供靜態資源,例如圖片、CSS 檔案、JavaScript 檔案或其他資源。雖然 http 模組不像 Express 的 express.static 一樣內建靜態檔案服務功能,但你可以使用 fs 和 path 模組來實現它。
嚴重的安全問題:如果你天真地使用 path.join(__dirname, 'public', req.url) 而沒有進行驗證,攻擊者可以透過目錄遍歷攻擊存取伺服器上的任何檔案。像 GET /../../../etc/passwd 的請求可能會暴露系統檔案。使用 path.resolve() 的檢查可以確保解析後的路徑永遠不會逃出 public 目錄。
如果沒有適當的路徑驗證,可能會出現以下問題:
// VULNERABLE - Allows directory traversal
const filePath = path.join(__dirname, 'public', req.url);
// Request: /../../../etc/passwd
// Resolves to: /etc/passwd (outside public directory!)
// SECURE - Validates path stays within public directory
const safePath = path.resolve(publicDir, '.' + reqPath);
if (!safePath.startsWith(publicDir)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
// Request: /../../../etc/passwd
// safePath: /home/user/app/public/../../../etc/passwd
// Resolved: /etc/passwd
// Check fails because /etc/passwd doesn't start with /home/user/app/public建立一個安全的靜態文件伺服器:
touch static.js打開 static.js:
nano static.js透過使用 fs.createReadStream() 並安全解析路徑,有效且安全地提供靜態檔案:
const http = require("http");
const fs = require("fs");
const path = require("path");
const host = 'localhost';
const port = 8000;
const mimeTypes = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf'
};
const publicDir = path.resolve(__dirname, 'public');
const requestListener = (req, res) => {
// Normalize the request path
let reqPath = req.url === '/' ? '/index.html' : req.url;
// Strip query string - ?id=123 shouldn't affect file path
reqPath = reqPath.split('?')[0];
// Resolve path and check it stays within publicDir
// The '.' prefix prevents absolute paths from being resolved incorrectly
const safePath = path.resolve(publicDir, '.' + reqPath);
// Critical security check: ensure resolved path is within publicDir
if (!safePath.startsWith(publicDir)) {
res.writeHead(403, { 'X-Content-Type-Options': 'nosniff' });
res.end('Forbidden');
return;
}
// Check if file exists and is actually a file (not a directory)
fs.stat(safePath, (err, stat) => {
if (err || !stat.isFile()) {
res.writeHead(404, { 'X-Content-Type-Options': 'nosniff' });
res.end('File not found');
return;
}
// Determine MIME type from file extension
const ext = path.extname(safePath).toLowerCase();
const contentType = mimeTypes[ext] || 'application/octet-stream';
res.setHeader("Content-Type", contentType);
res.setHeader("X-Content-Type-Options", "nosniff"); // Prevents MIME sniffing attacks
res.writeHead(200);
// Stream file instead of loading into memory
// Critical for large files - won't consume all memory
const stream = fs.createReadStream(safePath);
stream.pipe(res);
stream.on('error', () => {
// File might be deleted between stat() and createReadStream()
res.writeHead(500, { 'X-Content-Type-Options': 'nosniff' });
res.end('Server error');
});
});
};
const server = http.createServer(requestListener);
server.listen(port, host, () => {
console.log(`Server is running on http://${host}:${port}`);
console.log(`Serving static files from: ${publicDir}`);
});建立一個 public 目錄並新增一個測試檔案:
mkdir public
echo "<h1>Static File Served!</h1>" > public/index.html啟動伺服器:
node static.js在瀏覽器中訪問 http://localhost:8000,以查看提供的靜態文件。
為什麼要使用串流而不是 fs.readFile()? 對於一個 100MB 的影片檔案,fs.readFile() 會在傳送前將整個檔案載入記憶體。而使用 fs.createReadStream(),資料會以小區塊直接從磁碟流向網路,不論檔案大小都能使用最少的記憶體。
為什麼要檢查 stat.isFile()? 如果有人請求一個目錄路徑,例如 /images/,fs.stat() 會成功,但 isFile() 會返回 false。如果沒有這個檢查,你可能會嘗試去串流一個目錄,這樣會失敗。這個檢查確保你只提供實際的檔案。
測試路徑遍歷保護:
curl http://localhost:8000/../../../etc/passwd
# Should return 403 Forbidden, not the system file第七步 — 錯誤處理與日誌記錄
穩健的錯誤處理對於生產伺服器至關重要。http 模組不提供自動錯誤處理,因此你必須自己實作。
適當的錯誤處理包括捕捉非同步錯誤、適當地記錄錯誤、發送用戶友好的錯誤回應,以及防止伺服器因未處理的錯誤而崩潰。讓我們透過全面的錯誤處理和記錄來提升我們的伺服器:
const http = require("http");
const fs = require('fs').promises;
const host = 'localhost';
const port = 8000;
// Simple logging utility
const log = {
info: (msg) => console.log(`[INFO] ${new Date().toISOString()} - ${msg}`),
error: (msg, err) => console.error(`[ERROR] ${new Date().toISOString()} - ${msg}`, err),
warn: (msg) => console.warn(`[WARN] ${new Date().toISOString()} - ${msg}`)
};
const requestListener = async function (req, res) {
try {
log.info(`${req.method} ${req.url} - ${req.headers['user-agent'] || 'Unknown'}`);
if (req.url === '/') {
try {
const data = await fs.readFile(__dirname + '/index.html');
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(data);
} catch (error) {
log.error('Failed to read index.html', error);
res.writeHead(500);
res.end('<h1>Internal Server Error</h1><p>Unable to load page.</p>');
}
} else if (req.url === '/error') {
throw new Error('Simulated error for testing');
} else {
res.writeHead(404);
res.end('<h1>Not Found</h1><p>The requested resource was not found.</p>');
}
} catch (error) {
log.error('Unhandled error in request handler', error);
const errorMessage = process.env.NODE_ENV !== 'production'
? error.message
: 'An unexpected error occurred';
res.writeHead(500);
res.end(`<h1>Internal Server Error</h1><p>${errorMessage}</p>`);
}
};
const server = http.createServer(requestListener);
// Handle server-level errors
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
log.error(`Port ${port} is already in use`);
} else {
log.error('Server error', error);
}
process.exit(1);
});
// Handle client connection errors
server.on('clientError', (err, socket) => {
log.warn('Client error: ' + err.message);
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
server.listen(port, host, () => {
log.info(`Server is running on http://${host}:${port}`);
});優雅關閉(SIGTERM/SIGINT)
在使用像 PM2 或 Docker 這樣的進程管理器運行時,處理終止信號以優雅地關閉伺服器:
const connections = new Set();
server.on('connection', socket => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
const shutdown = (signal) => {
log.warn(`${signal} received: shutting down gracefully...`);
server.close(() => {
log.info('Server closed.');
process.exit(0);
});
setTimeout(() => {
log.warn('Forcing socket close...');
connections.forEach(s => s.destroy());
process.exit(1);
}, 10000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));顯示的關鍵錯誤處理做法:
- 圍繞非同步操作的 try-catch 區塊
- 伺服器級錯誤處理程序 用於連線和埠問題
- 用戶端錯誤處理 用於格式錯誤的請求
- 記錄,包含時間戳記和嚴重性等級
- 環境感知的錯誤訊息(在生產環境中不要暴露堆疊追蹤)
- 優雅的錯誤回應,並使用適當的 HTTP 狀態碼
最佳做法:在生產環境中,建議使用像 Winston 或 Pino 這樣的結構化日誌庫,並將關鍵錯誤發送到像 Sentry 或 Rollbar 這樣的錯誤追蹤服務。本範例為教育目的,使用簡單的控制台日誌。
步驟 8 — 使用 ES 模組 (ESM) 語法
現代的 Node.js 支援使用 import/export 語法的 ES 模組 (ESM),而非 CommonJS 的 require/module.exports。要使用 ESM,可以將檔案命名為 .mjs 副檔名,或在 package.json 中設置 "type": "module"。
ESM 語法使用 import 取代 require,使用 export 取代 module.exports。以下是使用 ES 模組語法的相同基本伺服器範例:
建立 server.mjs:
touch server.mjs
nano server.mjsimport http from 'http';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
// ES Modules don't have __dirname, so we recreate it
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const host = 'localhost';
const port = 8000;
const requestListener = async (req, res) => {
try {
if (req.url === '/') {
const data = await fs.readFile(path.join(__dirname, 'index.html'));
res.setHeader("Content-Type", "text/html");
res.writeHead(200);
res.end(data);
} else {
res.writeHead(404);
res.end('Not found');
}
} catch (error) {
console.error('Error:', error);
res.writeHead(500);
res.end('Server error');
}
};
const server = http.createServer(requestListener);
server.listen(port, host, () => {
console.log(`Server is running on http://${host}:${port}`);
});執行它:
node server.mjs主要差異:在 ESM 中,__dirname 不可用——你必須從 import.meta.url 推導出來。此外,fs 操作中的檔案路徑可能需要明確解析。否則,HTTP 模組的 API 保持不變。
或者,建立 package.json 並使用 "type": "module":
{
"type": "module"
}然後使用具有 ESM 語法的 .js 檔案:
import http from 'http';
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('ESM Server');
});
server.listen(8000);生產考量
從開發環境轉移到生產環境時,需要考慮幾個重要因素:
支持 HTTPS
http 模塊僅提供 HTTP 服務。若要使用 HTTPS,可類似地使用 https 模塊:
const https = require('https');
const fs = require('fs');
const options = {
key: fs.readFileSync('path/to/private-key.pem'),
cert: fs.readFileSync('path/to/certificate.pem')
};
const server = https.createServer(options, requestListener);安全性:切勿將私鑰或證書提交至版本控制。在生產環境中請使用環境變數或安全的機密管理服務。
反向代理配置
在生產環境中,Node.js 伺服器通常運行在像 Nginx 或 Apache 這樣的反向代理後面。代理負責 SSL 終止、靜態文件和負載均衡。你的 Node.js 伺服器應該:
- 僅監聽
localhost或127.0.0.1 - 讀取
X-Forwarded-For標頭以獲取客戶端 IP - 僅信任來自您代理伺服器的代理標頭
請參閱我們的指南《如何在 Ubuntu 上將 Nginx 配置為反向代理》以了解設置詳情。
流程管理
在生產環境中,使用像 PM2 這樣的進程管理工具來處理:
- 當程式崩潰時自動重啟
- 零停機部署
- 日誌管理
- 用於利用多個 CPU 核心的集群模式
在我們的《如何在 Ubuntu 上為生產環境設置 Node.js 應用程式》指南中了解更多信息。
效能優化
檔案快取:在啟動時載入靜態檔案(如步驟 3 所示),而不是在每次請求時從磁碟讀取。這可以大幅降低 I/O 負擔。
串流大型檔案:對於超過 1MB 的檔案,使用
fs.createReadStream()並將其傳輸到回應中,而不是將整個檔案載入記憶體。連接池:對於資料庫連接,使用連接池可以避免每次請求都創建新連接所帶來的開銷。
壓縮:使用
zlib模組對文字回應進行 gzip 壓縮,以減少頻寬使用:const zlib = require('zlib'); const gzip = zlib.createGzip(); res.setHeader('Content-Encoding', 'gzip'); gzip.pipe(res); gzip.write(data); gzip.end();叢集模式:使用 Node.js
cluster模組生成與 CPU 核心數量相等的工作進程,以最大化 CPU 密集型操作的吞吐量。請求逾時:設定合理的逾時值以防止慢速客戶端消耗資源:
server.setTimeout(30000); // 30 seconds記憶體監控:追蹤記憶體使用情況以早期發現記憶體洩漏。在生產環境中,監控堆積大小,並在記憶體無限制增長時實施優雅重啟。
快取與壓縮最佳實務
為了進一步提高效能並減少回應時間,在適當的地方應用快取和壓縮。
為靜態文件啟用快取:
在靜態文件的回應中添加Cache-Control和ETag標頭。例如:const stat = fs.statSync(safePath); res.setHeader('Cache-Control', 'public, max-age=86400'); // cache for 1 day res.setHeader('ETag', `"${stat.size}-${stat.mtime.getTime()}"`);瀏覽器和內容傳遞網路(CDN)將重複使用快取的副本,直到 ETag 或快取期限到期。
使用 gzip 或 Brotli 壓縮:
基於文字的回應例如 HTML、CSS、JS 和 JSON 壓縮效果非常好。你可以使用 Node 的zlib模組來進行壓縮:const zlib = require('zlib'); const stream = fs.createReadStream(safePath); res.setHeader('Content-Encoding', 'gzip'); stream.pipe(zlib.createGzip()).pipe(res);這可以減少頻寬使用,並加快對網速較慢的客戶端的傳輸速度。
避免對動態端點過度快取:
對於 API 或經常更新的資料,設定較短的快取時間(例如max-age=60)或在必要時使用no-store。
這些優化與靜態檔案串流和優雅關閉互相補充,幫助你的 Node.js 伺服器在保持安全和可預測性的同時,提供高效能。
使用 Node.js http 模組來建立最基本的 API、自訂路由或學習 HTTP 的內部運作。
在需要中介軟體、模板或快速原型開發時,使用 Express/Fastify。
常見問題排解
埠已被使用 (EADDRINUSE)
錯誤: 錯誤:listen EADDRINUSE:地址已被使用 :::8000
解決方案:
- 使用以下命令查找佔用該端口的進程:
lsof -i :8000(macOS/Linux)或netstat -ano | findstr :8000(Windows) - 終止該進程或使用其他埠
- 添加錯誤處理(如步驟7所示)以優雅地捕捉此錯誤
未收到請求主體
問題:POST 請求的主體為空或未定義
解決方案:請求主體是串流——您必須收集資料塊。請始終使用第 5 步所示的模式:
let body = '';
req.on('data', chunk => body += chunk.toString());
req.on('end', () => {
// Now parse body
});瀏覽器中的 CORS 錯誤
問題:瀏覽器因 CORS 政策而阻擋請求
解決方案: 在你的回應中添加 CORS 標頭:
res.setHeader("Access-Control-Allow-Origin", "*"); // Or specific domain
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");安全性: 使用 "*" 作為 Access-Control-Allow-Origin 允許任何來源。在生產環境中,請明確指定您的前端網域。
靜態檔案返回 404
問題: 即使靜態文件存在,也找不到它們
解決方案:
- 檢查檔案路徑解析
- 確認
__dirname指向正確的目錄 - 確保檔案權限允許讀取
- 驗證路徑清理是否沒有刪除有效字符
常見問題
Node.js 中的 HTTP 模組是什麼?
http 模組是 Node.js 內建的模組,用於建立 HTTP 伺服器和客戶端。它提供處理 HTTP 請求和回應的低階 API,無需外部依賴。它是 Node.js 核心的一部分,因此在每個 Node.js 安裝中都可使用。
我如何在 Node.js 中創建一個簡單的網頁伺服器?
使用 http.createServer() 和一個請求監聽函數來建立伺服器:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('Hello World');
});
server.listen(8000);這會建立一個監聽在 8000 端口的伺服器,對所有請求回應「Hello World」。
HTTP 模組和 Express.js 之間有什麼不同?
http 模組是 Node.js 的核心功能,提供低階的 HTTP 處理。Express.js 是建立在 http 之上的框架,增加了路由、中介軟體、模板以及其他便利功能。http 模組讓你擁有完全的控制,但需要更多手動設置;而 Express.js 提供了抽象化功能,但需要額外的依賴。
在以下情況下使用 http 模組:
- 建立輕量級 API 或微型服務
- 學習 HTTP 基礎
- 你想要零依賴
- 全面控制請求/回應處理
使用 Express.js 的情況:
- 建立具有多條路由的複雜應用程式
- 你需要中介軟體生態系統
- 快速原型製作是優先事項
- 你想要符合社群標準的模式
欲全面瞭解 Express.js 以及它如何簡化網頁開發,請參閱我們的《Node.js Express 基礎指南》。
我可以使用 Node.js 而不使用 Express 建立 REST API 嗎?
是的,絕對可以。http 模組足以用來建立 REST API。你需要手動處理:
- 路由解析(使用
req.url和req.method) - 請求主體解析(收集資料流片段)
- 回應格式
- 錯誤處理
對於簡單的 API,這通常比增加框架依賴更可取。請參考本教程的步驟 4 和步驟 5,以獲取路由和 POST 請求處理的範例。
我該如何使用 Node.js 的 HTTP 模組來處理不同的路由?
解析 req.url 並使用條件邏輯或 switch 語句:
const requestListener = (req, res) => {
switch (req.url) {
case '/':
res.writeHead(200);
res.end('Home page');
break;
case '/about':
res.writeHead(200);
res.end('About page');
break;
default:
res.writeHead(404);
res.end('Not found');
}
};對於更複雜的路由,你可以使用 url 模組解析 URL 路徑和查詢字串,並處理不同的 HTTP 方法。
我該如何使用 HTTP 模組傳送 HTML 或 JSON 回應?
適當設定 Content-Type 標頭並傳送資料:
HTML:
res.setHeader('Content-Type', 'text/html');
res.writeHead(200);
res.end('<h1>Hello</h1>');JSON:
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({ message: 'Hello' }));Content-Type 標頭告訴客戶端如何解釋回應資料。
我應該為本地 Node.js 伺服器使用哪個端口?
常見的開發端口有:
- 8000 或 8080:一般 HTTP 開發
- 3000:像 Express 這樣的框架很受歡迎
- 5000:也常見
對於非 root 使用者,1024 以上的任何埠皆可使用。1024 以下的埠需要管理員權限。在使用前,先用 lsof -i :PORT(macOS/Linux)檢查是否可用。
我該如何在 Node.js HTTP 伺服器中處理 404 錯誤?
在你的路由邏輯中新增一個預設情況:
const requestListener = (req, res) => {
if (req.url === '/') {
res.writeHead(200);
res.end('Home');
} else {
res.writeHead(404);
res.end('Not Found');
}
};對於不存在的路由,始終將狀態碼設置為 404 以遵循 HTTP 標準。
我該如何在不使用 Express 的情況下提供靜態文件?
使用 fs 模組來讀取檔案,並使用 path 模組來安全解析路徑:
const fs = require('fs').promises;
const path = require('path');
const requestListener = async (req, res) => {
const filePath = path.join(__dirname, 'public', req.url);
try {
const data = await fs.readFile(filePath);
res.writeHead(200);
res.end(data);
} catch (error) {
res.writeHead(404);
res.end('File not found');
}
};始終驗證路徑以防止目錄遍歷攻擊(請參見第6步以了解安全實作)。
我可以在 Node.js 中將 ES 模組(import)與 HTTP 模組一起使用嗎?
是的,Node.js 支援 ES 模組。可以使用以下任一方法:
- 具有
.mjs擴展名的檔案:
import http from 'http';
// ... rest of code- 在
package.json中設置"type": "module",然後使用.js文件:
{
"type": "module"
}請注意,ES 模組沒有 __dirname——可以如第 8 步所示,從 import.meta.url 推導出來。
我該如何使用 HTTP 模組解析 POST 請求的內容?
POST 主體以流的形式到達。收集片段,然後解析:
const parseBody = (req) => {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => body += chunk.toString());
req.on('end', () => {
try {
resolve(JSON.parse(body)); // For JSON
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
};這個模式適用於 JSON、表單資料或任何基於文字的內容類型。
HTTP 模組適合用於生產環境嗎?
是的,http 模組已經可以用於生產環境,並被許多 Node.js 應用程式使用。不過,請考慮:
- 流程管理:使用 PM2 或類似工具自動重啟
- 反向代理:在 Nginx/Apache 後端運行以實現 SSL 和負載平衡
- 錯誤處理:實施全面的錯誤處理和日誌記錄
- 安全性:驗證輸入、清理路徑、設定請求大小限制
- 效能:快取靜態檔案,對 CPU 密集型操作使用群集
許多生產環境應用程式會直接使用 http 模組,尤其是微服務和輕量級 API。關於完整的生產部署指南,請參閱在 Ubuntu 22.04 上設置 Node.js 應用程式的方式。
結論
在本教學中,你已經從零開始建立了可投入生產的 Node.js HTTP 伺服器。你從基本的文字回應開始,然後進一步學習了如何提供多種內容類型(JSON、CSV 和 HTML)。你實作了路由以處理不同的 URL、解析 POST 請求的主體、安全地提供靜態檔案,並增加了完善的錯誤處理和日誌記錄。你也探討了 CommonJS 與 ES 模組語法,以及生產環境部署的相關考量。
你現在已經具備創建能處理各種需求且不依賴框架的網頁伺服器的能力。憑藉這些知識,你可以:
- 為微服務構建輕量級 REST API
- 為多頁網站提供正確的路由服務
- 處理檔案上傳和下載
- 實作自訂中介軟體邏輯
- 明智地判斷框架何時能帶來價值,何時又只是增加不必要的複雜性
下一步:
- 深入探討 Node.js 基礎:回到《如何在 Node.js 編程》系列,獲取全面內容
- 探索官方文件:Node.js HTTP 模組文件包含詳細的 API 參考和進階使用範例
- 當你需要框架功能時:考慮使用 Express.js 基礎知識,以便快速開發,並支持中介軟體和模板功能
理解這些 HTTP 基礎知識會讓你成為更有能力的 Node.js 開發者。無論你選擇使用框架還是直接使用 http 模組,你現在都了解驅動所有 Node.js 網頁應用程式的底層機制。這些知識有助於你排除問題、優化效能,並設計能有效擴展的系統。
感謝您與 Linux-Console.net 社群一起學習。

