[譯] Node.js 基礎知識:沒有依賴關係的 Web 伺服器

Mirosalva發表於2019-03-13

Node.js 是構建 web 應用服務端的一種非常流行的技術選擇,並且有許多成熟的網路框架,比如 express, koa, hapijs。儘管如此,在這篇教程中我們不用任何依賴,僅僅使用 Node 核心的 http 包搭建服務端,並一點點地探索所有的重要細節。這不是你能經常看到的一種狀況,它可以幫助你更好地理解上面提及的所有框架--現有的許多庫不僅在底層使用這個包,而且經常會將原始物件暴露出來,使得你可以在某些特殊任務中應用他們。

目錄表

Hello, world

首先,讓我們開始一個最簡單的程式--返回那句經典的響應『hello,world』。為了用 Node.js 構建一個服務程式,我們需要使用 http 內建模模組,尤其是 createServer 函式。

const { createServer } = require("http");

// 這是一種好的實現
// 允許執行在不同的埠
const PORT = process.env.PORT || 8080;

const server = createServer();

server.on("request", (request, response) => {
  response.end("Hello, world!");
});

server.listen(PORT, () => {
  console.log(`starting server at port ${PORT}`);
});
複製程式碼

讓我們列出這個簡短示例的所有內容:

  1. 使用 createServer 函式建立一個服務物件例項。
  2. 為我們的服務程式中 request 事件新增一個事件監聽器
  3. 在環境變數指定的埠執行我們的服務程式,預設時使用 8080 埠。

我們建立的服務程式是 http.Server 類的一個例項,繼承自物件 net.Server,而它又繼承自類 EventEmitter。有許多我們可以監聽的事件,但最重要的事件是 request,並且在建立服務時提供它的監聽,常見的實現方式如下:

const { createServer } = require("http");

// 這樣等同於 `server.on('request', fn);`
createServer((request, response) => {
  response.end("Hello, world!");
}).listen(8080);
複製程式碼

最後一步是啟動我們的服務。我通過呼叫 server.listen 方法來啟動,並且你可以指定埠和啟動後執行內容。有一點要注意的是:服務並不會立即開始,它接入來訪的請求時必須先和一個埠繫結,然而在實踐中這點並不是非常重要,因為這個過程幾乎是瞬間完成。你也可以通過 listening 事件方法來單獨監聽這個特殊事件。

響應細節

現在,在我們學會了如何例項化一個新服務應用後,讓我們看看如何實際回覆使用者的請求。在我們唯一的事件處理器中,我們使用 response.end 方法以常規經典響應 Hello, world! 來回復。你可以看出這個簽名與可寫流方法 writable.end 非常相似,這是因為請求和響應物件都是流物件 streams,同時請求只是可讀流,而且響應只是可寫流。為什麼它們必須是流物件呢?為什麼我們不能傳送整個回覆?

答案是在回覆前我們不是非得做完所有的事。想象這種情景,當我們從檔案系統中讀取一個檔案時,而這個檔案比較大。因此我們可以通過 fs.createReadStream 方法開啟了一個檔案流,這樣我們就可以立即寫入響應。此外我們還可以直接將輸入通過管道連線到輸出!

現在因為它是流物件,我們可以做下面的事:

const { createServer } = require("http");

createServer((request, response) => {
  response.write("Hello");
  response.write(", ");
  response.write("World!");
  response.end();
}).listen(8080);
複製程式碼

因此我們可以直接多次寫入我們流物件。在任何形式的迴圈中這麼做時要小心,因為你必須自己處理背壓問題,另外最好直接管道連線到流物件。同樣的,請注意在結尾時使用 response.end() 方法。這是強制的,如果沒有這個呼叫,Node 將保持此連線處於開啟狀態,造成記憶體洩漏和客戶端處於等待狀態。

最後,讓我們演示一下流的管道方法是如何為響應物件和其他流起作用的。為了這麼做,我們使用 __filename 變數來讀取原始檔:

const { createReadStream } = require("fs");
const { createServer } = require("http");

createServer((request, response) => {
  createReadStream(__filename).pipe(response);
}).listen(8080);
複製程式碼

我們不一定要手動呼叫 res.end 方法,因為在原始流結束時,它也會自動地關閉管道傳輸的流。

HTTP 報文

我們的服務程式實現了 HTTP 協議,它是一種文字集的規則,允許客戶端以自己首選格式請求特定資訊,也允許服務程式以資料和附加資訊來回復,例如格式、連線狀態、快取資訊等等。

讓我們看一個對 web 頁面的典型請求:

GET / HTTP/1.1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko)
Host: blog.bloomca.me
Accept-Language: en-us
Accept-Encoding: gzip, deflate
Connection: Keep-Alive
複製程式碼

這是當你請求頁面時,我們瀏覽器傳送的內容,除了上面這些它還傳送更多的 headers,傳輸 cookies(也是一種 header),還有其他資訊。對我們來說重要的是要理解:所有的請求有方法、路徑(路由)以及 headers 列表,這些都是鍵值對(如果你想了解 cookies,它們只是一種具有特殊含義的 header)。HTTP 是一種文字協議,正如你所看到的,你自己可以讀懂它。雖然它只是一組協議,實現此協議的瀏覽器和服務程式都試圖遵守這個協議規定,這就是整個網際網路的運轉方式。並非所有規則都被遵守,但主要規則 - HTTP 操作、路由、cookie 都足夠可靠,您應該始終追求可預測的行為。

HTTP Headers 報文頭

我可以通過 request.headers 屬性來訪問客戶端傳送的所有 header。例如為了識別客戶端選擇的語言型別,我們可以像下面這樣做:

const { createServer } = require("http");

createServer((request, response) => {  
  // 這個物件中所有的 header 都是小寫  
  const languages = request.headers["accept-language"];

  response.end(languages);  
}).listen(8080);
複製程式碼

我個人對語言的選擇,使用『en-US,en;q=0.9,ru;q=0.8,de;q=0.7』,也就是說我首選英語,其次俄語,最後是德語。一般情況下瀏覽器使用你的作業系統語言,但是它會被替換,不是最好的依賴,因為使用者不能直接控制它(並且不同瀏覽器對這行程式碼有不同的選擇)。

為了寫一個 header,你需要理解 HTTP 是一種協議,這個協議規定首先是後設資料,然後在一個分隔符(兩個換行符)之後才是真正的報文體。這意味著一旦你開始傳送內容,你就不能變更你的報文頭!如果這麼做會在 Node 中丟擲錯誤以及實際會中止你的程式。

有兩種設定 header 的方法: response.setHeader 方法和 response.writeHead 方法。 兩者的區別是前者更特殊,並且如果兩者都被使用的情況下,所有的 header 會被合併,且以 writeHead 方式設定的 header 取值具有更高的優先順序。writeHeadwrite 方法的作用相同,也就是說你不可以在後續修改 header。

const { createServer } = require("http");

createServer((req, res) => {
  res.setHeader("content-type", "application/json");

  // 我們需要傳送 Buffer 或者 String 型別資料,我們不能直接傳遞一個物件  
  res.end(JSON.stringify({ a: 2 }));
}).listen(8080);
複製程式碼

HTTP Status Codes 狀態碼

HTTP 定義了每個響應都必須要有的狀態碼,列表 中定義了各個狀態碼的含義。同樣,並非所有人都嚴格遵守這個列表

讓我們列出最重要的狀態碼:

2xx – 成功碼:

  • 200:最常見的狀態碼,在 Node.js 中預設表示『OK』。
  • 201:新實體被建立。
  • 204:成功碼,但是沒有響應返回。例如,在移除一個實體後的狀態碼。

3xx – 重定向碼

  • 301:永久遷移,返回資訊中有新的 URL。
  • 302:臨時遷移,但是有另一個新 URL。成功向重定向頁發起 POST 請求後,新建的實體頁可訪問。

注意 301/302 狀態碼。瀏覽器傾向於記住 301,如果你偶然地把一些 URL 標記上 301 狀態碼,瀏覽器在收到新響應後也許仍然會這麼做(它們甚至都不檢查)。

4xx - 客戶端錯誤碼

  • 400:錯誤請求,比如傳遞引數錯誤,或者缺少一些引數
  • 401:未授權,使用者未被認證,因此無法訪問。
  • 403:禁止訪問,使用者通常已被認證,但是這項操作未被授權,同樣,在某些服務端可能會與 401 狀態碼混淆。
  • 404:未找到,提供的 URL 找不到指定頁面或資料。

5xx – 伺服器錯誤碼

  • 500:伺服器內部錯誤,例如資料庫連線錯誤。

這些錯誤碼是最常見的型別,並且足夠讓你為請求匹配正確的狀態碼。在 Node.js 中,我們既可以使用 response.statusCode 方法,也可以使用 response.writeHead 方法。這次就讓我們使用 writeHead 方法來設定一個自定義 HTTP 訊息:

const { createServer } = require("http");

createServer((req, res) => {
  // 表明沒有內容
  res.writeHead(204, "My Custom Message");
  res.end();
}).listen(8080);
複製程式碼

如果你嘗試在瀏覽器中開啟這些程式碼,並且在『網路』標籤中瀏覽 HTML 請求,你將會看到『狀態碼:204 我的自定義訊息』。

路由

在 Node.js 服務程式中,所有的請求都由單個請求處理程式處理。我們可以通過執行我們的任何服務來測試這點,或者通過請求不同的 URL 地址,例如地址 http://localhost:8080/homehttp://localhost:8080/about。你可以看到測試將返回同樣的響應。然而,在請求物件中我們有一個屬性 request.url,我們可以使用它構建一個簡單的路由功能:

const { createServer } = require("http");

createServer((req, res) => {
  switch (req.url) {
    case "/":
      res.end("You are on the main page!");
      break;
    case "/about":
      res.end("You are on about page!");
      break;
    default:
      res.statusCode = 404;
      res.end("Page not found!");
  }
}).listen(8080);
複製程式碼

有很多警告(嘗試在 /about/ 頁面新增一個尾部斜槓),但是你有辦法。在所有的框架中,有一個主處理程式,它將所有請求導向已註冊的處理程式。

HTTP 方法

你可能熟悉 HTTP methods/verbs,例如 GETPOST。它們是 HTTP 協議本身的一部分,且含義很明顯。然而,它們也有許多我不想深挖的微妙細節,為了簡潔起見,我想說 GET 是為了獲取資料,而 POST 是為了建立新的實體物件。沒人不讓你拿它們另做他用,但是標準和慣例建議你不要這麼做。

上面已經說到,在 Node.js 中服務程式有 request.method 屬性,可以用於我們內部邏輯處理。同樣,Node.js 本身沒有任何內容可供我們使用,對不同方法抽象出處理方法。我們需要自己構建抽象處理方法:

const { createServer } = require("http");

createServer((req, res) => {
  if (req.method === "GET") {
    return res.end("List of data");
  } else if (req.method === "POST") {
    // 建立新實體
    return res.end("success");
  } else {
    res.statusCode(400);
    return res.end("Unsupported method");
  }
}).listen(8080);
複製程式碼

Cookies 快取

Cookies 值得單獨開一個文章來介紹,所以請隨時閱讀更多關於它們的內容 MDN guide

兩個關鍵詞,cookie 用於在請求過程中保留一些資料,因為 HTTP 是一種無狀態協議,從技術上講,如果沒有 cookies(或者本地儲存),我們必須在每次需要身份驗證的操作之前都得執行登入操作。我們在客戶端保留 cookie(通常在瀏覽器中),這樣瀏覽器可以給我們傳送一個名為 Cookie 且包含所有 cookie 物件的 header,我們可以通過一個 Set-Cookie header 來響應請求,告訴客戶端設定哪個 cookie(例如訪問 token);客戶端儲存它之後,就會在每次後續請求中將它發回服務端。

讓我們執行下面的程式碼:

const { createServer } = require("http");

createServer((req, res) => {
  res.setHeader(
    "Set-Cookie",
    ["myCookie=myValue"],
    ["mySecondCookie=mySecondValue"]
  );
  res.end(`Your cookies are: ${req.headers.cookie}`);
}).listen(8080);
複製程式碼

你第一次重新整理瀏覽器時,可能會看到一些舊快取 cookie,但是你看不到 myCookie 或者 mySecondCookie。然而,如果你再重新整理瀏覽器,你將會看到兩者的值!這個情況的原因是在響應客戶端會在 cookies 中設定它們的值,正是這個響應渲染了我們頁面。因此我們只會在下一次請求發生後才會從客戶端接收到這些返回的快取 cookies。

現在,如果我們想在程式碼中使用 cookie 值該怎麼辦呢?Cookie 在 HTTP 中只是一個 header,因此它是一個有著自己規則的字串--cookie 使用 key=value 的模式來編寫,包含引數,以 ; 符號分割。你可以編寫自己的解析器(類似這篇文章這樣this SO answer),但是我建議你使用與你的框架或庫相容的其他外部庫作選擇就行了。

同樣地,請注意你不能刪除 cookie,因為它屬於客戶端,但是你可以通過設定它為一個空值或一個過去的失效日期這種方式,使它變得無效

查詢引數

給特殊處理器設定引數很常見:例如,你希望顯示所有圖片,我們可以指定一個頁面,這通過可以通過查詢引數來實現。它們被新增到 URL,通過符號 ? 與路徑分隔開:http://localhost:8080/pictures?page=2,你可以看出,我們請求了圖片庫的第二個頁面。或者我們可以只需要把它嵌入到 URL 連結本身,但是這裡的問題是:如果有不止一個引數,URL 會很快變得混亂。查詢引數並不固定,因此我們可以新增任意數量的內容,也可以在將來刪除/新增新內容。

為了在我們的服務程式中獲取到它,我們使用 request.url 屬性,在 路由 小節中我們已經用到過。現在,我們需要將我們的 URL 與查詢引數分開,雖然我們可以手動這麼做,但是沒有必要,因為它已經在 Node.js 中實現了:

const { createServer } = require("http");

createServer((req, res) => {
  const { query } = require("url").parse(req.url, true);
  if (query.name) {
    res.end(`You requested parameter name with value ${query.name}`);
  } else {
    res.end("Hello!");
  }
}).listen(8080);
複製程式碼

現在,如果你新增查詢引數來請求任何頁面,你將會在響應中看到效果,例如這個 http://localhost:8080/about?name=Seva 的請求將會返回帶有我們標識名的字串:

 你的請求引數名帶有值 Seva
複製程式碼

請求體內容

我們最後要看的是請求體內容。之前我們已知道,你可以從 URL 本身獲取所有資訊(路由和查詢引數),但是我們如何從客戶端獲取到真實資料?你不用直接訪問它,但我們可以直接通過讀取流來獲得傳遞的資料,這也是為什麼請求物件是流物件的一個原因。讓我們寫一個簡單的服務程式,這個程式期望從 POST 請求中獲取一個 JSON 物件,並且當獲取的並非有效 JSON 時將返回 400 狀態碼。

const { createServer } = require("http");

createServer((req, res) => {
  if (req.method === "POST") {
    let data = "";
    req.on("data", chunk => {
      data += chunk;
    });

    req.on("end", () => {
      try {
        const requestData = JSON.parse(data);
        requestData.ourMessage = "success";
        res.setHeader("Content-Type", "application/json");
        res.end(JSON.stringify(requestData));
      } catch (e) {
        res.statusCode = 400;
        res.end("Invalid JSON");
      }
    });
  } else {
    res.statusCode = 400;
    res.end("Unsupported method, please POST a JSON object");
  }
}).listen(8080);
複製程式碼

最簡單的測試它的方法是使用 curl。首先,使用一個 GET 方法來查詢:

> curl http://localhost:8080
Unsupported method, please POST a JSON object
複製程式碼

現在,使用一個隨機字串作為我們的資料來發起一個 POST 請求

> curl -X POST -d "some random string" http://localhost:8080
Invalid JSON
複製程式碼

最後,產生一個正確的響應並檢視結果:

> curl -X POST -d '{"property": true}' http://localhost:8080
{"property":true,"ourMessage":"success"}
複製程式碼

結尾

你可以看出,有在僅使用內建模組來處理每個請求時有許多繁瑣工作 - 比如記住每次都要關閉響應流,或者每次你傳送物件時都要以字串化的 JSON 來設定一個 Content-Type: application/json 型別的 header,或者分析查詢引數,或者編寫你自己的路由系統.....所有這些都被完成,只需要記住在框架引擎下,它使用這些核心方法,你不用擔心它的內部實際如何執行。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章