HTTP 交易解剖

本指南的目的是傳授對於 Node.js HTTP 處理過程的堅實理解。我們假設您對於 HTTP 請求的工作原理有一般的了解,無論是使用哪種語言或編程環境。我們還假設您稍微熟悉 Node.js 的 EventEmittersStreams。如果您對這些不太熟悉,值得快速閱讀一下它們的 API 文件。

建立伺服器

任何 Node 網頁伺服器應用程式在某個時候都必須建立一個網頁伺服器物件。這是通過使用 createServer 來完成的。

const http = require('node:http');

const server = http.createServer((request, response) => {
  // magic happens here!
});

傳遞給 createServer 的函數會在針對該伺服器進行的每個 HTTP 請求上被呼叫一次,因此它被稱為請求處理器。事實上,createServer 返回的 Server 物件是一個 EventEmitter,而我們這裡所做的只是為了創建一個 server 物件,然後稍後添加監聽器。

const server = http.createServer();
server.on('request', (request, response) => {
  // the same kind of magic happens here!
});

當 HTTP 請求到達伺服器時,Node.js 會使用一些方便的對象來處理交易,requestresponse,我們很快就會介紹它們。

為了實際處理請求,需要在 server 物件上調用 listen 方法。在大多數情況下,您只需要將要監聽的端口號傳遞給 listen。還有一些其他選項,所以請查閱API 參考

方法、URL 和標頭

在處理請求時,您可能首先要做的事情是查看方法和 URL,以便採取適當的操作。Node.js 通過將方便的屬性放在 request 對象上,使這個過程相對輕鬆。

const { method, url } = request;

request 對象是 IncomingMessage 的實例。

這裡的 method 總是一個正常的 HTTP 方法/動詞。 url 是完整的 URL,不包括伺服器、協議或端口。對於典型的 URL,這意味著第三個斜槓及其後的所有內容。

標頭也不遠了。它們在 request 上有自己的對象,稱為 headers

const { headers } = request;
const userAgent = headers['user-agent'];

這裡重要的一點是所有標頭都只用小寫表示,不管客戶端實際上如何發送它們。這簡化了解析標頭以及用於任何目的的任務。

如果某些標頭重複出現,則它們的值將被覆蓋或連接在一起,取決於該標頭。在某些情況下,這可能會帶來問題,因此也提供了 rawHeaders

請求主體

當接收到 POSTPUT 請求時,請求主體可能對您的應用程序很重要。獲取主體數據比訪問請求標頭更加複雜。傳遞給處理程序的 request 物件實現了 ReadableStream 介面。這個流可以像其他流一樣被監聽或導向其他地方。我們可以通過監聽流的 'data''end' 事件來直接從流中獲取數據。

在每個 'data' 事件中發出的块是一個 Buffer。如果您知道它將是字符串數據,最好的做法是在一個數組中收集數據,然後在 'end' 時將其連接並轉換為字符串。

let body = [];
request
  .on('data', chunk => {
    body.push(chunk);
  })
  .on('end', () => {
    body = Buffer.concat(body).toString();
    // at this point, `body` has the entire request body stored in it as a string
  });

這可能看起來有點繁瑣,在許多情況下確實如此。幸運的是,有像 concat-streambody 這樣的模塊可以幫助隱藏一些這樣的邏輯。在採取這樣的方法之前,了解正在發生的情況是很重要的,這也是您在這裡的原因!

關於錯誤的一點提示

由於 request 物件是一個 ReadableStream,它也是一個 EventEmitter,在發生錯誤時的行為與事件發生器相同。

request 流中出現錯誤將通過在流上發出一個 'error' 事件來表現自己。如果您沒有對該事件添加監聽器,錯誤將被 拋出,這可能會使您的 Node.js 程序崩潰。因此,您應該在您的請求流上添加一個 'error' 監聽器,即使您只是記錄它並繼續進行。 (儘管最好發送某種類型的 HTTP 錯誤響應。後面會更多介紹。)

request.on('error', err => {
  // This prints the error message and stack trace to `stderr`.
  console.error(err.stack);
});

處理這些錯誤的其他方式有其他抽象和工具,但請始終注意錯誤可能會發生,您將不得不處理它們。

到目前為止我們所獲得的

目前為止,我們已經涵蓋了創建伺服器以及從請求中提取方法、URL、標頭和主體的部分。當我們把這些放在一起時,它可能看起來像這樣

const http = require('node:http');

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // At this point, we have the headers, method, url and body, and can now
        // do whatever we need to in order to respond to this request.
      });
  })
  .listen(8080); // Activates this server, listening on port 8080.

如果我們運行此示例,我們將能夠接收請求,但無法對其做出響應。實際上,如果您在網絡瀏覽器中訪問此示例,您的請求將超時,因為未向客戶端發送任何數據。

到目前為止,我們尚未觸及response對象,該對象是ServerResponse的一個實例,它是一個WritableStream。它包含許多有用的方法,用於將數據發送回客戶端。我們將在下一步進行介紹。

HTTP狀態碼

如果您不關心設置它,響應的HTTP狀態碼將始終為200。當然,並非每個HTTP響應都應該是這樣,而且在某些時候,您肯定會想要發送不同的狀態碼。要做到這一點,您可以設置statusCode屬性。

response.statusCode = 404; // Tell the client that the resource wasn't found.

我們很快將看到一些其他快捷方式。

設置響應標頭

通過一個方便的方法setHeader來設置標頭。

response.setHeader('Content-Type', 'application/json');
response.setHeader('X-Powered-By', 'bacon');

在設置響應的標頭時,它們的名稱是不區分大小寫的。如果重複設置標頭,則最後設置的值將被發送。

明確發送標頭數據

我們已經討論過的設置標頭和狀態碼的方法假設您正在使用“隱式標頭”。這意味著您指望節點在開始發送主體數據之前在正確的時間向您發送標頭。

如果您希望,您可以明確地將標頭寫入回應串流。要做到這一點,有一個名為writeHead的方法,它將狀態碼和標頭寫入串流。

response.writeHead(200, {
  'Content-Type': 'application/json',
  'X-Powered-By': 'bacon',
});

一旦您設置了標頭(無論是隱含還是明確),您就準備好開始發送回應數據了。

發送回應主體

由於response對象是一個WritableStream,將回應主體寫出到客戶端只是使用通常的串流方法。

response.write('<html>');
response.write('<body>');
response.write('<h1>Hello, World!</h1>');
response.write('</body>');
response.write('</html>');
response.end();

串流上的end函數還可以接受一些可選數據作為串流上的最後一部分數據來發送,因此我們可以將上面的示例簡化如下。

response.end('<html><body><h1>Hello, World!</h1></body></html>');

在開始寫入主體的數據塊之前設置狀態和標頭非常重要。這是有道理的,因為在HTTP回應中,標頭位於主體之前。

關於錯誤的另一個快速說明

response串流也可以發出'error'事件,在某個時候您也需要處理這個。所有關於request串流錯誤的建議在這裡仍然適用。

將所有內容結合起來

現在我們已經了解了如何製作HTTP回應,讓我們將它們全部結合起來。基於之前的示例,我們將建立一個服務器,將用戶發送給我們的所有數據都發送回來。我們將使用JSON.stringify將該數據格式化為JSON。

const http = require('node:http');

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // BEGINNING OF NEW STUFF

        response.on('error', err => {
          console.error(err);
        });

        response.statusCode = 200;
        response.setHeader('Content-Type', 'application/json');
        // Note: the 2 lines above could be replaced with this next one:
        // response.writeHead(200, {'Content-Type': 'application/json'})

        const responseBody = { headers, method, url, body };

        response.write(JSON.stringify(responseBody));
        response.end();
        // Note: the 2 lines above could be replaced with this next one:
        // response.end(JSON.stringify(responseBody))

        // END OF NEW STUFF
      });
  })
  .listen(8080);

回聲服務器示例

讓我們簡化前面的例子,製作一個簡單的回聲伺服器,它只是將收到的請求中的任何資料直接發送回響應。我們只需要從請求串流中抓取資料,然後將該資料寫入響應串流,與之前的操作類似。

const http = require('node:http');

http
  .createServer((request, response) => {
    let body = [];
    request
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        response.end(body);
      });
  })
  .listen(8080);

現在讓我們進行微調。我們只想在以下條件下發送回聲

  • 請求方法是 POST。
  • URL 為 /echo

在任何其他情況下,我們只想回應 404。

const http = require('node:http');

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      let body = [];
      request
        .on('data', chunk => {
          body.push(chunk);
        })
        .on('end', () => {
          body = Buffer.concat(body).toString();
          response.end(body);
        });
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

通過這種方式檢查 URL,我們正在進行一種形式的“路由”。其他形式的路由可以像 switch 語句一樣簡單,也可以像整個框架 express 一樣複雜。如果你正在尋找僅執行路由而不執行其他操作的工具,請嘗試 router

太棒了!現在讓我們試著簡化這個。記住,request 物件是一個 ReadableStream,而 response 物件是一個 WritableStream。這意味著我們可以使用 pipe 將資料從一個串流導向另一個串流。這正是我們對於一個回聲伺服器所需的!

const http = require('node:http');

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

串流太棒了!

但我們還沒有完成。正如本指南中多次提到的,錯誤是可能且確實會發生的,我們需要處理它們。

為了處理請求串流上的錯誤,我們將錯誤記錄到 stderr 並發送 400 狀態碼來表示 Bad Request。然而,在真實的應用程式中,我們需要檢查錯誤以找出正確的狀態碼和訊息應該是什麼。就像處理其他錯誤一樣,您應該參考 Error 文件

在回應中,我們將錯誤僅記錄到 stderr

const http = require('node:http');

http
  .createServer((request, response) => {
    request.on('error', err => {
      console.error(err);
      response.statusCode = 400;
      response.end();
    });
    response.on('error', err => {
      console.error(err);
    });
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

我們現在已經涵蓋了大部分處理 HTTP 請求的基礎知識。此時,您應該能夠

  • 使用具有請求處理程序功能的 HTTP 伺服器實例化,並使其監聽端口。
  • request 物件中獲取標頭、URL、方法和主體資料。
  • 根據 URL 和/或 request 物件中的其他資料做出路由決策。
  • 透過 response 物件發送標頭、HTTP 狀態碼和主體資料。
  • 將資料從 request 物件導向和導出到 response 物件。
  • requestresponse 串流中處理串流錯誤。

從這些基礎知識中,可以建構出許多典型用例的 Node.js HTTP 伺服器。這些 API 還提供了許多其他功能,請務必仔細閱讀關於 EventEmittersStreamsHTTP 的 API 文件。