# 二十、Node.js
> 原文:[Node.js](https://eloquentjavascript.net/20_node.html)
>
> 譯者:[飛龍](https://github.com/wizardforcel)
>
> 協議:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/)
>
> 自豪地采用[谷歌翻譯](https://translate.google.cn/)
>
> 部分參考了[《JavaScript 編程精解(第 2 版)》](https://book.douban.com/subject/26707144/)
> A student asked 'The programmers of old used only simple machines and no programming languages, yet they made beautiful programs. Why do we use complicated machines and programming languages?'. Fu-Tzu replied 'The builders of old used only sticks and clay, yet they made beautiful huts.'
>
> Master Yuan-Ma,《The Book of Programming》

到目前為止,我們已經使用了 JavaScript 語言,并將其運用于單一的瀏覽器環境中。本章和下一章將會大致介紹 Node.js,該程序可以讓讀者將你的 JavaScirpt 技能運用于瀏覽器之外。讀者可以運用 Node.js 構建應用程序,實現簡單的命令行工具和復雜動態 HTTP 服務器。
這些章節旨在告訴你建立 Node.js 的主要概念,并向你提供信息,使你可以采用 Nodejs 編寫一些實用程序。它們并不是這個平臺的完整的介紹。
如果你想要運行本章中的代碼,需要安裝 Node.js 10 或更高版本。 為此,請訪問 [nodejs.org](https://nodejs.org),并按照用于你的操作系統的安裝說明進行操作。 你也可以在那里找到 Node.js 的更多文檔。
## 背景
編寫通過網絡通信的系統時,一個更困難的問題是管理輸入輸出,即向/從網絡和硬盤讀寫數據。到處移動數據會耗費時間,而調度這些任務的技巧會使得系統在相應用戶或網絡請求時產生巨大的性能差異。
在這樣的程序中,異步編程通常是有幫助的。 它允許程序同時向/從多個設備發送和接收數據,而無需復雜的線程管理和同步。
Node最初是為了使異步編程簡單方便而設計的。 JavaScript 很好地適應了像 Node 這樣的系統。 它是少數幾種沒有內置輸入和輸出方式的編程語言之一。 因此,JavaScript 可以適應 Node 的相當古怪的輸入和輸出方法,而不會產生兩個不一致的接口。 在 2009 年設計 Node 時,人們已經在瀏覽器中進行基于回調的編程,所以該語言的社區用于異步編程風格。
## Node 命令
在系統中安裝完 Node.js 后,Node.js 會提供一個名為`node`的程序,該程序用于執行 JavaScript 文件。假設你有一個文件 hello.js,該文件會包含以下代碼。
```js
let message = "Hello world";
console.log(message);
```
讀者可以仿照下面這種方式通過命令行執行程序。
```
$ node hello.js
Hello world
```
Node 中的`console.log`方法與瀏覽器中所做的類似,都用于打印文本片段。但在 Node 中,該方法不會將文本顯示在瀏覽器的 JavaScript 控制臺中,而顯示在標準輸出流中。從命令行運行`node`時,這意味著你會在終端中看到記錄的值。
若你執行`node`時不附帶任何參數,`node`會給出提示符,讀者可以輸入 JavaScript 代碼并立即看到執行結果。
```js
$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$
```
`process`綁定類似于`console`綁定,是 Node 中的全局綁定。該綁定提供了多種方式來監聽并操作當前程序。該綁定中的`exit`方法可以結束進程并賦予一個退出狀態碼,告知啟動`node`的程序(在本例中時命令行 Shell),當前程序是成功完成(代碼為 0),還是遇到了錯誤(其他代碼)。
讀者可以讀取`process.argv`來獲取傳遞給腳本的命令行參數,該綁定是一個字符串數組。請注意該數組包括了`node`命令和腳本名稱,因此實際的參數從索引 2 處開始。若`showargv.js`只包含一條`console.log(process.argv)`語句,你可以這樣執行該腳本。
```
$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]
```
所有標準 JavaScript 全局綁定,比如`Array`、`Math`以及`JSON`也都存在于 Node 環境中。而與瀏覽器相關的功能,比如`document`與`alert`則不存在。
## 模塊
除了前文提到的一些綁定,比如`console`和`process`,Node 在全局作用域中添加了很少綁定。如果你需要訪問其他的內建功能,可以通過`system`模塊獲取。
第十章中描述了基于`require`函數的 CommonJS 模塊系統。該系統是 Node 的內建模塊,用于在程序中裝載任何東西,從內建模塊,到下載的包,再到普通文件都可以。
調用`require`時,Node 會將給定的字符串解析為可加載的實際文件。路徑名若以`"/"`、`"./"`或`"../"`開頭,則解析為相對于當前模塊的路徑,其中`"./"`表示當前路徑,`"../"`表示當前路徑的上一級路徑,而`"/"`則表示文件系統根路徑。因此若你訪問從文件`/tmp/robot/robot.js`訪問`"./graph"`,Node 會嘗試加載文件`/tmp/robot/graph.js`。
`.js`擴展名可能會被忽略,如果這樣的文件存在,Node 會添加它。 如果所需的路徑指向一個目錄,則 Node 將嘗試加載該目錄中名為`index.js`的文件。
當一個看起來不像是相對路徑或絕對路徑的字符串被賦給`require`時,按照假設,它引用了內置模塊,或者安裝在`node_modules`目錄中模塊。 例如,`require("fs")`會向你提供 Node 內置的文件系統模塊。 而`require("robot")`可能會嘗試加載`node_modules/robot/`中的庫。 安裝這種庫的一種常見方法是使用 NPM,我們稍后講講它。
我們來建立由兩個文件組成的小項目。 第一個稱為`main.js`,并定義了一個腳本,可以從命令行調用來反轉字符串。
```js
const {reverse} = require("./reverse");
// Index 2 holds the first actual command-line argument
let argument = process.argv[2];
console.log(reverse(argument));
```
文件`reverse.js`中定義了一個庫,用于截取字符串,這個命令行工具,以及其他需要直接訪問字符串反轉函數的腳本,都可以調用該庫。
```js
exports.reverse = function(string) {
return Array.from(string).reverse().join("");
};
```
請記住,將屬性添加到`exports`,會將它們添加到模塊的接口。 由于 Node.js 將文件視為 CommonJS 模塊,因此`main.js`可以從`reverse.js`獲取導出的`reverse`函數。
我們可以看到我們的工具執行結果如下所示。
```
$ node main.js JavaScript
tpircSavaJ
```
## 使用 NPM 安裝
第十章中介紹的 NPM,是一個 JavaScript 模塊的在線倉庫,其中大部分模塊是專門為 Node 編寫的。當你在計算機上安裝 Node 時,你就會獲得一個名為`npm`的程序,提供了訪問該倉庫的簡易界面。
它的主要用途是下載包。 我們在第十章中看到了`ini`包。 我們可以使用 NPM 在我們的計算機上獲取并安裝該包。
```
$ npm install ini
npm WARN enoent ENOENT: no such file or directory,
open '/tmp/package.json'
+ ini@1.3.5
added 1 package in 0.552s
$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }
```
運行`npm install`后,NPM 將創建一個名為`node_modules`的目錄。 該目錄內有一個包含庫的`ini`目錄。 你可以打開它并查看代碼。 當我們調用`require("ini")`時,加載這個庫,我們可以調用它的`parse`屬性來解析配置文件。
默認情況下,NPM 在當前目錄下安裝包,而不是在中央位置。 如果你習慣于其他包管理器,這可能看起來很不尋常,但它具有優勢 - 它使每個應用程序完全控制它所安裝的包,并且使其在刪除應用程序時,更易于管理版本和清理。
## 包文件
在`npm install`例子中,你可以看到`package.json`文件不存在的警告。 建議為每個項目創建一個文件,手動或通過運行`npm init`。 它包含該項目的一些信息,例如其名稱和版本,并列出其依賴項。
來自第七章的機器人模擬,在第十章中模塊化,它可能有一個`package.json`文件,如下所示:
```json
{
"author": "Marijn Haverbeke",
"name": "eloquent-javascript-robot",
"description": "Simulation of a package-delivery robot",
"version": "1.0.0",
"main": "run.js",
"dependencies": {
"dijkstrajs": "^1.0.1",
"random-item": "^1.0.0"
},
"license": "ISC"
}
```
當你運行`npm install`而沒有指定安裝包時,NPM 將安裝`package.json`中列出的依賴項。 當你安裝一個沒有列為依賴項的特定包時,NPM會將它添加到`package.json`中。
## 版本
`package.json`文件列出了程序自己的版本和它的依賴的版本。 版本是一種方式,用于處理包的單獨演變。為使用某個時候的包而編寫的代碼,可能不能使用包的更高版本。
NPM 要求其包遵循名為語義版本控制(semantic versioning)的綱要,它編碼了版本號中的哪些版本是兼容的(不破壞就接口)。 語義版本由三個數字組成,用點分隔,例如`2.3.0`。 每次添加新功能時,中間數字都必須遞增。 每當破壞兼容性時,使用該包的現有代碼可能不適用于新版本,因此必須增加第一個數字。
`package.json`中的依賴項版本號前面的脫字符(`^`),表示可以安裝兼容給定編號的任何版本。 例如`"^2.3.0"`意味著任何大于等于`2.3.0`且小于`3.0.0`的版本都是允許的。
`npm`命令也用于發布新的包或包的新版本。 如果你在一個包含`package.json`文件的目錄中執行`npm publish`,它將一個包發布到注冊處,帶有 JSON 文件中列出的名稱和版本。 任何人都可以將包發布到 NPM - 但只能用新名稱,因為任何人可以更新現有的包,會有點恐怖。
由于`npm`程序是與開放系統(包注冊處)進行對話的軟件,因此它沒有什么獨特之處。 另一個程序`yarn`,可以從 NPM 注冊處中安裝,使用一種不同的接口和安裝策略,與`npm`具有相同的作用。
本書不會深入探討 NPM 的使用細節。 請參閱[`npmjs.org`](https://npmjs.org)來獲取更多文檔和搜索包的方法。
## 文件系統模塊
在Node中最常用的內建模塊就是`fs`(表示 filesystem,文件系統)模塊。該模塊提供了處理文件和目錄的函數。
例如,有個函數名為`readFile`,該函數讀取文件并調用回調,并將文件內容傳遞給回調。
```js
let {readFile} = require("fs");
readFile("file.txt", "utf8", (error, text) => {
if (error) throw error;
console.log("The file contains:", text);
});
```
`readFile`的第二個參數表示字符編碼,用于將文件解碼成字符串。將文本編碼成二進制數據有許多方式,但大多數現代系統使用 UTF-8,因此除非有特殊原因確信文件使用了別的編碼,否則讀取文件時使用`"utf-8"`是一種較為安全的方式。若你不傳遞任何編碼,Node 會認為你需要解析二進制數據,因此會返回一個`Buffer`對象而非字符串。該對象類似于數組,每個元素是文件中字節(8 位的數據塊)對應的數字。
```js
const {readFile} = require("fs");
readFile("file.txt", (error, buffer) => {
if (error) throw error;
console.log("The file contained", buffer.length, "bytes.",
"The first byte is:", buffer[0]);
});
```
有一個名為`writeFile`的函數與其類似,用于將文件寫到磁盤上。
```js
const {writeFile} = require("fs");
writeFile("graffiti.txt", "Node was here", err => {
if (err) console.log(`Failed to write file: ${err}`);
else console.log("File written.");
});
```
這里我們不需要制定編碼,因為如果我們調用`writeFile`時傳遞的是字符串而非`Buffer`對象,則`writeFile`會使用默認編碼(即 UTF-8)來輸出文本。
`fs`模塊也包含了其他實用函數,其中`readdir`函數用于將目錄中的文件以字符串數組的方式返回,`stat`函數用于獲取文件信息,`rename`函數用于重命名文件,`unlink`用于刪除文件等。
而且其中大多數都將回調作為最后一個參數,它們會以錯誤(第一個參數)或成功結果(第二個參數)來調用。 我們在第十一章中看到,這種編程風格存在缺點 - 最大的缺點是,錯誤處理變得冗長且容易出錯。
相關細節請參見[http://nodejs.org/](http://nodejs.org/)中的文檔。
雖然`Promise`已經成為 JavaScript 的一部分,但是,將它們與 Node.js 的集成的工作仍然還在進行中。 從 v10 開始,標準庫中有一個名為`fs/promises`的包,它導出的函數與`fs`大部分相同,但使用`Promise`而不是回調。
```js
const {readFile} = require("fs/promises");
readFile("file.txt", "utf8")
.then(text => console.log("The file contains:", text));
```
有時候你不需要異步,而是需要阻塞。 `fs`中的許多函數也有同步的變體,它們的名稱相同,末尾加上`Sync`。 例如,`readFile`的同步版本稱為`readFileSync`。
```js
const {readFileSync} = require("fs");
console.log("The file contains:",
readFileSync("file.txt", "utf8"));
```
請注意,在執行這樣的同步操作時,程序完全停止。 如果它應該響應用戶或網絡中的其他計算機,那么可在同步操作中可能會產生令人討厭的延遲。
## HTTP 模塊
另一個主要模塊名為`"http"`。該模塊提供了執行 HTTP 服務和產生 HTTP 請求的函數。
啟動一個 HTTP 服務器只需要以下代碼。
```js
const {createServer} = require("http");
let server = createServer((request, response) => {
response.writeHead(200, {"Content-Type": "text/html"});
response.write(`
<h1>Hello!</h1>
<p>You asked for <code>${request.url}</code></p>`);
response.end();
});
server.listen(8000);
```
若你在自己的機器上執行該腳本,你可以打開網頁瀏覽器,并訪問 <http://localhost:8000/hello>,就會向你的服務器發出一個請求。服務器會響應一個簡單的 HTML 頁面。
每次客戶端嘗試連接服務器時,服務器都會調用傳遞給`createServer`函數的參數。`request`和`response`綁定都是對象,分別表示輸入數據和輸出數據。`request`包含請求信息,例如該對象的`url`屬性表示請求的 URL。
因此,當你在瀏覽器中打開該頁面時,它會向你自己的計算機發送請求。 這會導致服務器函數運行并返回一個響應,你可以在瀏覽器中看到該響應。
你需要調用`response`對象的方法以將一些數據發回客戶端。第一個函數調用(`writeHead`)會輸出響應頭(參見第十七章)。你需要向該函數傳遞狀態碼(本例中 200 表示成功)和一個對象,該對象包含協議頭信息的值。該示例設置了`"Content-Type"`頭,通知客戶端我們將發送一個 HTML 文檔。
接下來使用`response.write`來發送響應體(文檔自身)。若你想一段一段地發送相應信息,可以多次調用該方法,例如將數據發送到客戶端。最后調用`response.end`發送相應結束信號。
調用`server.listen`會使服務器在 8000 端口上開始等待請求。這就是你需要連接`localhost:8000`和服務器通信,而不是`localhost`(這樣將會使用默認端口,即 80)的原因。
當你運行這個腳本時,這個進程就在那里等著。 當一個腳本正在監聽事件時 - 這里是網絡連接 - Node 不會在到達腳本末尾時自動退出。為了關閉它,請按`Ctrl-C`。
一個真實的 Web 服務器需要做的事情比示例多得多。其差別在于我們需要根據請求的方法(`method`屬性),來判斷客戶端嘗試執行的動作,并根據請求的 URL 來找出動作處理的資源。本章隨后會介紹更高級的服務器。
我們可以使用`http`模塊的`request`函數來充當一個 HTTP 客戶端。
```js
const {request} = require("http");
let requestStream = request({
hostname: "eloquentjavascript.net",
path: "/20_node.html",
method: "GET",
headers: {Accept: "text/html"}
}, response => {
console.log("Server responded with status code",
response.statusCode);
});
requestStream.end();
```
`request`函數的第一個參數是請求配置,告知 Node 需要訪問的服務器、服務器請求地址、使用的方法等信息。第二個參數是響應開始時的回調。該回調會接受一個參數,用于檢查相應信息,例如獲取狀態碼。
和在服務器中看到的`response`對象一樣,`request`返回的對象允許我們使用`write`方法多次發送數據,并使用`end`方法結束發送。本例中并沒有使用`write`方法,因為 GET 請求的請求正文中無法包含數據。
`https`模塊中有類似的`request`函數,可以用來向`https:` URL 發送請求。
但是使用 Node 的原始功能發送請求相當麻煩。 NPM 上有更多方便的包裝包。 例如,`node-fetch`提供了我們從瀏覽器得知的,基于`Promise`的`fetch`接口。
## 流
我們在 HTTP 中看過兩個可寫流的例子,即服務器可以向`response`對象中寫入數據,而`request`返回的請求對象也可以寫入數據。
可寫流是 Node 中廣泛使用的概念。這種對象擁有`write`方法,你可以傳遞字符串或`Buffer`對象,來向流寫入一些數據。它們`end`方法用于關閉流,并且還可以接受一個可選值,在流關閉之前將其寫入流。 這兩個方法也可以接受回調作為附加參數,當寫入或關閉完成時它們將被調用。
我們也可以使用`fs`模塊的`createWriteStream`,建立一個指向本地文件的輸出流。你可以調用該方法返回的結果對象的`write`方法,每次向文件中寫入一段數據,而不是像`writeFile`那樣一次性寫入所有數據。
可讀流則略為復雜。傳遞給 HTTP 服務器回調的`request`綁定,以及傳遞給 HTTP 客戶端回調的`response`對象都是可讀流(服務器讀取請求并寫入響應,而客戶端則先寫入請求,然后讀取響應)。讀取流需要使用事件處理器,而不是方法。
Node 中發出的事件都有一個`on`方法,類似瀏覽器中的`addEventListener`方法。該方法接受一個事件名和一個函數,并將函數注冊到事件上,接下來每當指定事件發生時,都會調用注冊的函數。
可讀流有`data`事件和`end`事件。`data`事件在每次數據到來時觸發,`end`事件在流結束時觸發。該模型適用于“流”數據,這類數據可以立即處理,即使整個文檔的數據沒有到位。我們可以使用`createReadStream`函數創建一個可讀流,來讀取本地文件。
這段代碼創建了一個服務器并讀取請求正文,然后將讀取到的數據全部轉換成大寫,并使用流寫回客戶端。
```js
const {createServer} = require("http");
createServer((request, response) => {
response.writeHead(200, {"Content-Type": "text/plain"});
request.on("data", chunk =>
response.write(chunk.toString().toUpperCase()));
request.on("end", () => response.end());
});
}).listen(8000);
```
傳遞給`data`處理器的`chunk`值是一個二進制`Buffer`對象,我們可以使用它的`toString`方法,通過將其解碼為 UTF-8 編碼的字符,來將其轉換為字符串。
下面的一段代碼,和上面的服務(將字母轉換成大寫)一起運行時,它會向服務器發送一個請求并輸出獲取到的響應數據:
```js
const {request} = require("http");
request({
hostname: "localhost",
port: 8000,
method: "POST"
}, response => {
response.on("data", chunk =>
process.stdout.write(chunk.toString()));
}).end("Hello server");
// → HELLO SERVER
```
該示例代碼向`process.stdout`(進程的標準輸出流,是一個可寫流)中寫入數據,而不使用`console.log`,因為`console.log`函數會在輸出的每段文本后加上額外的換行符,在這里不太合適。
## 文件服務器
讓我們結合新學習的 HTTP 服務器和文件系統的知識,并建立起兩者之間的橋梁:使用 HTTP 服務允許客戶遠程訪問文件系統。這個服務有許多用處,它允許網絡應用程序存儲并共享數據或使得一組人可以共享訪問一批文件。
當我們將文件當作 HTTP 資源時,可以將 HTTP 的 GET、PUT 和 DELETE 方法分別看成讀取、寫入和刪除文件。我們將請求中的路徑解釋成請求指向的文件路徑。
我們可能不希望共享整個文件系統,因此我們將這些路徑解釋成以服務器工作路徑(即啟動服務器的路徑)為起點的相對路徑。若從`/home/marijn/public`(或 Windows 下的`C:\Users\marijn\public`)啟動服務器,那么對`/file.txt`的請求應該指向`/home/marijn/public/file.txt`(或`C:\Users\marijn\public\file.txt`)。
我們將一段段地構建程序,使用名為`methods`的對象來存儲處理多種 HTTP 方法的函數。方法處理器是`async`函數,它接受請求對象作為參數并返回一個`Promise`,解析為描述響應的對象。
```js
const {createServer} = require("http");
const methods = Object.create(null);
createServer((request, response) => {
let handler = methods[request.method] || notAllowed;
handler(request)
.catch(error => {
if (error.status != null) return error;
return {body: String(error), status: 500};
})
.then(({body, status = 200, type = "text/plain"}) => {
response.writeHead(status, {"Content-Type": type});
if (body && body.pipe) body.pipe(response);
else response.end(body);
});
}).listen(8000);
async function notAllowed(request) {
return {
status: 405,
body: `Method ${request.method} not allowed.`
};
}
```
這樣啟動服務器之后,服務器永遠只會產生 405 錯誤響應,該代碼表示服務器拒絕處理特定的方法。
當請求處理程序的`Promise`受到拒絕時,`catch`調用會將錯誤轉換為響應對象(如果它還不是),以便服務器可以發回錯誤響應,來通知客戶端它未能處理請求。
響應描述的`status`字段可以省略,這種情況下,默認為 200(OK)。 `type`屬性中的內容類型也可以被省略,這種情況下,假定響應為純文本。
當`body`的值是可讀流時,它將有`pipe`方法,用于將所有內容從可讀流轉發到可寫流。 如果不是,則假定它是`null`(無正文),字符串或緩沖區,并直接傳遞給響應的`end`方法。
為了弄清哪個文件路徑對應于請求URL,`urlPath`函數使用 Node 的`url`內置模塊來解析 URL。 它接受路徑名,類似`"/file.txt"`,將其解碼來去掉`%20`風格的轉義代碼,并相對于程序的工作目錄來解析它。
```js
const {parse} = require("url");
const {resolve} = require("path");
const baseDirectory = process.cwd();
function urlPath(url) {
let {pathname} = parse(url);
let path = resolve(decodeURIComponent(pathname).slice(1));
if (path != baseDirectory &&
!path.startsWith(baseDirectory + "/")) {
throw {status: 403, body: "Forbidden"};
}
return path;
}
```
只要你建立了一個接受網絡請求的程序,就必須開始關注安全問題。 在這種情況下,如果我們不小心,很可能會意外地將整個文件系統暴露給網絡。
文件路徑在 Node 中是字符串。 為了將這樣的字符串映射為實際的文件,需要大量有意義的解釋。 例如,路徑可能包含`"../"`來引用父目錄。 因此,一個顯而易見的問題來源是像`/../ secret_file`這樣的路徑請求。
為了避免這種問題,`urlPath`使用`path`模塊中的`resolve`函數來解析相對路徑。 然后驗證結果位于工作目錄下面。 `process.cwd`函數(其中`cwd`代表“當前工作目錄”)可用于查找此工作目錄。 當路徑不起始于基本目錄時,該函數將使用 HTTP 狀態碼來拋出錯誤響應對象,該狀態碼表明禁止訪問資源。
我們需要創建GET方法,在讀取目錄時返回文件列表,在讀取普通文件時返回文件內容。
一個棘手的問題是我們返回文件內容時添加的`Content-Type`頭應該是什么類型。因為這些文件可以是任何內容,我們的服務器無法簡單地對所有文件返回相同的內容類型。但 NPM 可以幫助我們完成該任務。`mime`包(以`text/plain`這種方式表示的內容類型,名為 MIME 類型)可以獲取大量文件擴展名的正確類型。
以下`npm`命令在服務器腳本所在的目錄中,安裝`mime`的特定版本。
```
$ npm install mime@2.2.0
```
當請求文件不存在時,應該返回的正確 HTTP 狀態碼是 404。我們使用`stat`函數,來找出特定文件是否存在以及是否是一個目錄。
```js
const {createReadStream} = require("fs");
const {stat, readdir} = require("fs/promises");
const mime = require("mime");
methods.GET = async function(request) {
let path = urlPath(request.url);
let stats;
try {
stats = await stat(path);
} catch (error) {
if (error.code != "ENOENT") throw error;
else return {status: 404, body: "File not found"};
}
if (stats.isDirectory()) {
return {body: (await readdir(path)).join("\n")};
} else {
return {body: createReadStream(path),
type: mime.getType(path)};
}
};
```
因為`stat`訪問磁盤需要耗費一些時間,因此該函數是異步的。由于我們使用`Promise`而不是回調風格,因此必須從`fs/promises`而不是`fs`導入。
當文件不存在時,`stat`會拋出一個錯誤對象,`code`屬性為`'ENOENT'`。 這些有些模糊的,受 Unix 啟發的代碼,是你識別 Node 中的錯誤類型的方式。
由`stat`返回的`stats`對象告訴了我們文件的一系列信息,比如文件大小(`size`屬性)和修改日期(`mtime`屬性)。這里我們想知道的是,該文件是一個目錄還是普通文件,`isDirectory`方法可以告訴我們答案。
我們使用`readdir`來讀取目錄中的文件列表,并將其返回給客戶端。對于普通文件,我們使用`createReadStream`創建一個可讀流,并將其傳遞給`respond`對象,同時使用`mime`模塊根據文件名獲取內容類型并傳遞給`respond`。
處理`DELETE`請求的代碼就稍顯簡單了。
```js
const {rmdir, unlink} = require("fs/promises");
methods.DELETE = async function(request) {
let path = urlPath(request.url);
let stats;
try {
stats = await stat(path);
} catch (error) {
if (error.code != "ENOENT") throw error;
else return {status: 204};
}
if (stats.isDirectory()) await rmdir(path);
else await unlink(path);
return {status: 204};
};
```
當 HTTP 響應不包含任何數據時,狀態碼 204(“No Content”,無內容)可用于表明這一點。 由于刪除的響應不需要傳輸任何信息,除了操作是否成功之外,在這里返回是明智的。
你可能想知道,為什么試圖刪除不存在的文件會返回成功狀態代碼,而不是錯誤。 當被刪除的文件不存在時,可以說該請求的目標已經完成。 HTTP 標準鼓勵我們使請求是冪等(idempotent)的,這意味著,多次發送相同請求的結果,會與一次相同。 從某種意義上說,如果你試圖刪除已經消失的東西,那么你試圖去做的效果已經實現 - 東西已經不存在了。
下面是`PUT`請求的處理器。
```js
const {createWriteStream} = require("fs");
function pipeStream(from, to) {
return new Promise((resolve, reject) => {
from.on("error", reject);
to.on("error", reject);
to.on("finish", resolve);
from.pipe(to);
});
}
methods.PUT = async function(request) {
let path = urlPath(request.url);
await pipeStream(request, createWriteStream(path));
return {status: 204};
};
```
我們不需要檢查文件是否存在,如果存在,只需覆蓋即可。我們再次使用`pipe`來將可讀流中的數據移動到可寫流中,在本例中是將請求的數據移動到文件中。但是由于`pipe`沒有為返回`Promise`而編寫,所以我們必須編寫包裝器`pipeStream`,它從調用`pipe`的結果中創建一個`Promise`。
當打開文件`createWriteStream`時出現問題時仍然會返回一個流,但是這個流會觸發`'error'`事件。 例如,如果網絡出現故障,請求的輸出流也可能失敗。 所以我們連接兩個流的`'error'`事件來拒絕`Promise`。 當`pipe`完成時,它會關閉輸出流,從而導致觸發`'finish'`事件。 這是我們可以成功解析`Promise`的地方(不返回任何內容)。
完整的服務器腳本請見[`eloquentjavascript.net/code/file_server.js`](http://eloquentjavascript.net/code/file_server.js)。讀者可以下載該腳本,并且在安裝依賴項之后,使用 Node 啟動你自己的文件服務器。當然你可以修改并擴展該腳本,來完成本章的習題或進行實驗。
命令行工具`curl`在類 Unix 系統(比如 Mac 或者 Linux)中得到廣泛使用,可用于產生 HTTP 請求。接下來的會話用于簡單測試我們的服務器。這里需要注意,`-x`用于設置請求方法,`-d`用于包含請求正文。
```
$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d hello http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
hello
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found
```
由于`file.txt`一開始不存在,因此第一請求失敗。而`PUT`請求則創建文件,因此我們看到下一個請求可以成功獲取該文件。在使用`DELETE`請求刪除該文件后,第三次`GET`請求再次找不到該文件。
## 本章小結
Node 是一個不錯的小型系統,可讓我們在非瀏覽器環境下運行 JavaScript。Node 最初的設計意圖是完成網絡任務,扮演網絡中的節點。但同時也能用來執行任何腳本任務,如果你覺得編寫 JavaScript 代碼是一件愜意的事情,那么使用 Node 來自動完成每天的任務是非常不錯的。
NPM 為你所能想到的功能(當然還有相當多你想不到的)提供了包,你可以通過使用`npm`程序,獲取并安裝這些包。Node 也附帶了許多內建模塊,包括`fs`模塊(處理文件系統)、`http`模塊(執行 HTTP 服務器并生成 HTTP 請求)。
Node 中的所有輸入輸出都是異步的,除非你明確使用函數的同步變體,比如`readFileSync`。當調用異步函數時,使用者提供回調,并且 Node 會在準備好的時候,使用錯誤值和結果(如果有的話)調用它們。
## 習題
### 搜索工具
在 Unix 系統上,有一個名為`grep`的命令行工具,可以用來在文件中快速搜索正則表達式。
編寫一個可以從命令行運行的 Node 腳本,其行為類似`grep`。 它將其第一個命令行參數視為正則表達式,并將任何其他參數視為要搜索的文件。 它應該輸出內容與正則表達式匹配的,任何文件的名稱。
當它有效時,將其擴展,以便當其中一個參數是目錄時,它將搜索該目錄及其子目錄中的所有文件。
按照你認為合適的方式,使用異步或同步文件系統函數。 配置一些東西,以便同時請求多個異步操作可能會加快速度,但不是很大,因為大多數文件系統一次只能讀取一個東西。
### 目錄創建
盡管我們的文件服務器中的`DELETE`方法可以刪除目錄(使用`rmdir`),但服務器目前不提供任何方法來創建目錄。
添加對`MKCOL`方法(“make column”)的支持,它應該通過調用`fs`模塊的`mkdir`創建一個目錄。 `MKCOL`并不是廣泛使用的 HTTP 方法,但是它在 WebDAV 標準中有相同的用途,這個標準在 HTTP 之上規定了一組適用于創建文檔的約定。
你可以使用實現`DELETE`方法的函數,作為`MKCOL`方法的藍圖。 當找不到文件時,嘗試用`mkdir`創建一個目錄。 當路徑中存在目錄時,可以返回 204 響應,以便目錄創建請求是冪等的。 如果這里存在非目錄文件,則返回錯誤代碼。 代碼 400(“Bad Request”,請求無效)是適當的。
### 網絡上的公共空間
由于文件服務器提供了任何類型的文件服務,甚至只要包含正確的`Content-Type`協議頭,你可以使用其提供網站服務。由于該服務允許每個人刪除或替換文件,因此這是一類非常有趣的網站:任何人只要使用正確的 HTTP 請求,都可以修改、改進并破壞文件。但這仍然是一個網站。
請編寫一個基礎的 HTML 頁面,包含一個簡單的 JavaScript 文件。將該文件放在文件服務器的數據目錄下,并在你的瀏覽器中打開這些文件。
接下來,作為進階練習或是周末作業,將你迄今為止在本書中學習到的內容整合起來,構建一個對用戶友好的界面,在網站內部修改網站。
使用 HTML 表單編輯組成網站的文件內容,允許用戶使用 HTTP 請求在服務器上更新它們,如第十八章所述。
剛開始的時候,該頁面僅允許用戶編輯單個文件,然后進行修改,允許選擇想要編輯的文件。向文件服務器發送請求時,若URL是一個目錄,服務器會返回該目錄下的文件列表,你可以利用該特性實現你的網頁。
不要直接編輯文件服務器開放的代碼,如果你犯了什么錯誤,很有可能就破壞了你的代碼。相反,將你的代碼保存在公共訪問目錄之外,測試時再將其拷貝到公共目錄中。