第四版可用。点击此处阅读!

第 20 章Node.js

一名学生问道:“古代的程序员只使用简单的机器,没有编程语言,却能做出精美的程序。为什么我们现在要使用复杂的机器和编程语言呢?” 傅子回答道:“古代的建筑师只用木棍和泥土,却能造出美丽的房屋。”

元马大师,编程之书
Picture of a telephone pole

到目前为止,我们只在一种环境中使用 JavaScript 语言:浏览器。本章和下一章将简要介绍 Node.js,这是一个允许你在浏览器之外应用 JavaScript 技能的程序。借助它,你可以构建从小型命令行工具到为动态网站提供支持的 HTTP 服务器的任何东西。

这些章节旨在教你 Node.js 使用的主要概念,并为你提供足够的信息,让你能够为它编写有用的程序。它们不是试图成为一个完整的,甚至是一个彻底的,对该平台的处理。

虽然你可以直接在这些页面上运行前几章的代码,因为它们要么是原始的 JavaScript,要么是为浏览器编写的,但本章的代码示例是为 Node 编写的,通常不会在浏览器中运行。

如果你想跟随并运行本章的代码,你需要安装 Node.js 版本 10.1 或更高版本。为此,请访问 https://node.org.cn并按照你的操作系统的安装说明进行操作。你也可以在那里找到 Node.js 的进一步文档。

背景

编写通过网络通信的系统的最难问题之一是管理输入和输出 - 也就是对网络和硬盘的读写操作。移动数据需要时间,巧妙地调度它可以显著影响系统响应用户或网络请求的速度。

在这样的程序中,异步编程通常是有帮助的。它允许程序同时从多个设备发送和接收数据,而无需复杂的线程管理和同步。

Node 最初是为了方便地进行异步编程而设计的。JavaScript 很适合 Node 这样的系统。它是少数几种没有内置方式进行输入输出的编程语言之一。因此,JavaScript 可以适应 Node 相当独特的方法,而不会导致两个不一致的接口。2009 年,当 Node 正在设计时,人们已经在浏览器中进行基于回调的编程,因此围绕该语言的社区已经习惯了异步编程风格。

node 命令

当 Node.js 安装在一个系统上时,它提供了一个名为 node 的程序,用于运行 JavaScript 文件。假设你有一个名为 hello.js 的文件,包含以下代码

let message = "Hello world";
console.log(message);

然后,你可以从命令行运行 node,如下所示,以执行该程序

$ node hello.js
Hello world

Node 中的 console.log 方法与它在浏览器中的作用类似。它打印出一段文本。但在 Node 中,文本将发送到进程的标准输出流,而不是浏览器的 JavaScript 控制台。当从命令行运行 node 时,这意味着你在终端中看到已记录的值。

如果你在不提供文件的情况下运行 node,它将为你提供一个提示,你可以在其中键入 JavaScript 代码并立即看到结果。

$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$

process 绑定,就像 console 绑定一样,在 Node 中是全局可用的。它提供了各种方法来检查和操作当前程序。exit 方法结束进程,并可以指定一个退出状态代码,该代码告诉启动 node 的程序(在本例中为命令行 shell)程序是否成功完成(代码为零)或遇到错误(任何其他代码)。

要找到传递给脚本的命令行参数,你可以读取 process.argv,它是一个字符串数组。请注意,它还包括 node 命令的名称和你的脚本名称,因此实际的参数从索引 2 开始。如果 showargv.js 包含语句 console.log(process.argv),你可以像这样运行它

$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]

所有标准的 JavaScript 全局绑定,例如 ArrayMathJSON,也存在于 Node 的环境中。与浏览器相关的功能,例如 documentprompt,则不存在。

模块

除了我提到的绑定,例如 consoleprocess 之外,Node 在全局范围内几乎没有添加其他绑定。如果你想访问内置功能,你必须向模块系统请求它。

基于 require 函数的 CommonJS 模块系统在第 10 章中进行了描述。此系统内置于 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,它定义了一个可以从命令行调用的脚本,用于反转字符串。

const {reverse} = require("./reverse");

// Index 2 holds the first actual command line argument
let argument = process.argv[2];

console.log(reverse(argument));

文件 reverse.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,在第 10 章中介绍过,是一个 JavaScript 模块的在线存储库,其中许多专门为 Node 编写。当你将 Node 安装到你的电脑上时,你也会获得 npm 命令,你可以用它来与这个存储库交互。

NPM 的主要用途是下载包。我们在第 10 章中看到了 ini 包。我们可以使用 NPM 将该包获取并安装到我们的电脑上。

$ npm install ini
npm WARN enoent ENOENT: no such file or directory,
         open '/tmp/package.json'
+ [email protected]
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 创建。它包含一些关于项目的信息,例如它的名称和版本,并列出了它的依赖项。

第 7 章中的机器人模拟,如第 10 章的练习中模块化的那样,可能会有一个 package.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 要求其程序包遵循一种称为语义版本控制的模式,该模式将有关哪些版本兼容(不破坏旧接口)的信息编码在版本号中。语义版本由三个数字组成,用点号分隔,例如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 用法的细节。请参考 https://npmjs.net.cn 以获取更多文档和程序包搜索方法。

文件系统模块

Node 中最常用的内置模块之一是fs模块,它代表文件系统。它导出用于处理文件和目录的函数。

例如,名为readFile的函数读取文件,然后调用一个回调函数,该回调函数包含文件的内容。

let {readFile} = require("fs");
readFile("file.txt", "utf8", (error, text) => {
  if (error) throw error;
  console.log("The file contains:", text);
});

readFile的第二个参数表示用于将文件解码为字符串的字符编码。文本可以有几种方式编码为二进制数据,但大多数现代系统使用 UTF-8。因此,除非您有理由相信使用了其他编码,否则在读取文本文件时,请传递"utf8"。如果您没有传递编码,Node 将假设您对二进制数据感兴趣,并将为您提供一个Buffer对象而不是字符串。这是一个类似于数组的对象,它包含表示文件中的字节(8 位数据块)的数字。

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用于将文件写入磁盘。

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对象来写入时,它应该使用其默认字符编码(即 UTF-8)将它作为文本写入。

fs模块包含许多其他有用的函数:readdir将以字符串数组的形式返回目录中的文件,stat将检索有关文件的 信息,rename将重命名文件,unlink将删除一个文件,等等。请查看 https://node.org.cn 上的文档以获取详细信息。

这些函数中的大多数都将回调函数作为最后一个参数,它们要么使用错误(第一个参数)调用该函数,要么使用成功的结果(第二个参数)调用该函数。正如我们在 第 11 章 中看到的,这种编程风格有一些缺点,最主要的是错误处理变得冗长且容易出错。

虽然承诺已经成为 JavaScript 的一部分有一段时间了,但在撰写本文时,它们在 Node.js 中的集成仍在进行中。从版本 10.1 开始,fs包中导出了一个对象promises,它包含与fs大多数相同的函数,但使用承诺而不是回调函数。

const {readFile} = require("fs").promises;
readFile("file.txt", "utf8")
  .then(text => console.log("The file contains:", text));

有时您不需要异步性,它只会妨碍。fs中的许多函数也有同步变体,其名称相同,只是在末尾添加了Sync。例如,readFile的同步版本称为readFileSync

const {readFileSync} = require("fs");
console.log("The file contains:",
            readFileSync("file.txt", "utf8"));

请注意,在执行这样的同步操作时,您的程序将完全停止。如果它应该响应用户或网络上的其他机器,那么被卡在同步操作上可能会导致令人讨厌的延迟。

HTTP 模块

另一个核心模块称为http。它提供了运行 HTTP 服务器和发出 HTTP 请求的功能。

这足以启动一个 HTTP 服务器

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);
console.log("Listening! (port 8000)");

如果您在自己的机器上运行此脚本,您可以将您的网络浏览器指向 https://127.0.0.1:8000/hello 向您的服务器发出请求。它将以一个小的 HTML 页面响应。

传递给createServer的函数在每次客户端连接到服务器时都会被调用。requestresponse绑定是表示传入和传出数据的对象。第一个包含有关请求的信息,例如其url属性,它告诉我们对哪个 URL 发出了请求。

因此,当您在浏览器中打开该页面时,它会向您自己的计算机发送请求。这会导致服务器函数运行并发送回响应,您可以在浏览器中看到该响应。

要发送回内容,您可以调用response对象上的方法。第一个writeHead将写入响应头(请参见 第 18 章)。您将状态代码(在本例中为“OK”的 200)和一个包含头值的对象传递给它。该示例设置了Content-Type头,以告知客户端我们将发送回一个 HTML 文档。

接下来,使用response.write发送实际的响应正文(文档本身)。如果您想分段发送响应,例如在数据可用时将数据流式传输到客户端,则可以多次调用此方法。最后,response.end表示响应的结束。

server.listen的调用会导致服务器开始在端口 8000 上等待连接。这就是为什么您必须连接到localhost:8000才能与该服务器通信,而不是只连接到localhost,后者将使用默认端口 80。

当您运行此脚本时,该进程只是停在那里等待。当一个脚本在监听事件时,在本例中是网络连接,node在到达脚本末尾时不会自动退出。要关闭它,请按 control-C。

一个真正的网络服务器通常比示例中的服务器做更多的事情,它查看请求的方法(method属性)以了解客户端试图执行的操作,并查看请求的 URL 以了解该操作正在对哪个资源执行。我们将在 本章后面看到一个更高级的服务器。

要充当 HTTP 客户端,我们可以使用http模块中的request函数。

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提供了我们从浏览器中知道的基于承诺的fetch接口。

我们已经看到了 HTTP 示例中的两个可写流实例,即服务器可以写入的响应对象和从request返回的请求对象。

可写流是 Node 中广泛使用的概念。此类对象具有一个write方法,可以传递一个字符串或一个Buffer对象来向流中写入内容。它们的end方法关闭流,并可选地接受一个值来在关闭流之前写入流。这两种方法也可以接受回调函数作为附加参数,它们会在写入或关闭完成时调用该函数。

可以使用fs模块中的createWriteStream函数创建一个指向文件的可写流。然后,您可以使用结果对象上的write方法来分段写入文件,而不是像writeFile那样一次性写入。

可读流稍微复杂一些。传递给 HTTP 服务器回调的 `request` 绑定和传递给 HTTP 客户端回调的 `response` 绑定都是可读流——服务器读取请求然后写入响应,而客户端先写入请求然后读取响应。从流中读取使用事件处理程序而不是方法进行。

在 Node 中发出事件的对象有一个名为 `on` 的方法,它类似于浏览器中的 `addEventListener` 方法。你给它一个事件名,然后给它一个函数,它将注册该函数,以便在给定事件发生时调用该函数。

可读流有 `"data"` 和 `"end"` 事件。第一个事件在每次有数据传入时触发,第二个事件在流到达其末尾时调用。这种模型最适合处理可以立即处理的*流*数据,即使整个文档尚未可用。可以使用 `fs` 中的 `createReadStream` 函数将文件作为可读流读取。

此代码创建了一个服务器,该服务器读取请求主体并将它们作为全大写文本流回客户端

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);

传递给数据处理程序的 `chunk` 值将是一个二进制 `Buffer`。我们可以通过使用其 `toString` 方法将其解码为 UTF-8 编码的字符来将其转换为字符串。

以下代码段在与大写服务器处于活动状态的情况下运行时,将向该服务器发送请求并写出它收到的响应

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 服务器。这样的服务器有各种用途——它允许 Web 应用程序存储和共享数据,或者它可以为一组人提供对一堆文件的共享访问。

当我们将文件视为 HTTP 资源时,HTTP 方法 `GET`、`PUT` 和 `DELETE` 可分别用于读取、写入和删除文件。我们将解释请求中的路径作为请求引用的文件的路径。

我们可能不想共享整个文件系统,因此我们将解释这些路径从服务器的工作目录开始,即启动服务器的目录。如果我从 `/tmp/public/`(或 Windows 上的 `C:\tmp\public\`)运行服务器,则对 `/file.txt` 的请求应该引用 `/tmp/public/file.txt`(或 `C:\tmp\public\file.txt`)。

我们将逐步构建该程序,使用名为 `methods` 的对象来存储处理各种 HTTP 方法的函数。方法处理程序是 `async` 函数,它们以请求对象作为参数,并返回一个解析为描述响应的对象的 Promise。

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(正常)。`type` 属性中的内容类型也可以省略,在这种情况下,响应被认为是纯文本。

当 `body` 的值为可读流时,它将有一个 `pipe` 方法,该方法用于将所有内容从可读流转发到可写流。如果不是,则假设它为 `null`(无正文)、字符串或缓冲区,并直接传递给响应的 `end` 方法。

为了弄清楚哪个文件路径对应于请求 URL,`urlPath` 函数使用 Node 内置的 `url` 模块来解析 URL。它获取其路径名,它将类似于 `/file.txt`,对其进行解码以去除 `%20` 样式的转义代码,并将其相对于程序的工作目录进行解析。

const {parse} = require("url");
const {resolve, sep} = 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 + sep)) {
    throw {status: 403, body: "Forbidden"};
  }
  return path;
}

一旦你设置了一个程序来接受网络请求,你就必须开始担心安全性。在这种情况下,如果我们不小心,我们很可能会意外地将整个文件系统暴露给网络。

文件路径是 Node 中的字符串。要将这样的字符串映射到实际文件,需要进行大量的解释。例如,路径可能包含 `../` 来引用父目录。因此,一个明显的问题来源是像 `/../secret_file` 这样的路径的请求。

为了避免此类问题,`urlPath` 使用 `path` 模块中的 `resolve` 函数,该函数解析相对路径。然后它验证结果是否位于工作目录*之下*。`process.cwd` 函数(其中 `cwd` 代表“当前工作目录”)可用于查找此工作目录。`path` 包中的 `sep` 绑定是系统的路径分隔符——Windows 上的反斜杠和大多数其他系统上的正斜杠。当路径没有以基本目录开头时,该函数会抛出一个错误响应对象,使用指示禁止访问资源的 HTTP 状态代码。

我们将设置 `GET` 方法,以便在读取目录时返回文件列表,并在读取普通文件时返回文件的内容。

一个棘手的问题是我们在返回文件内容时应该设置什么类型的 `Content-Type` 头。由于这些文件可能是任何东西,因此我们的服务器不能简单地为所有文件返回相同的内容类型。NPM 再次可以帮助我们。`mime` 包(像 `text/plain` 这样的内容类型指示符也称为 MIME 类型)知道大量文件扩展名的正确类型。

以下 `npm` 命令(在服务器脚本所在的目录中)安装特定版本的 `mime`

$ npm install [email protected]

当请求的文件不存在时,要返回的正确 HTTP 状态代码是 404。我们将使用 `stat` 函数,该函数查找有关文件的信息,以找出文件是否存在以及它是否是目录。

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 而不是回调样式,因此它必须从 `promises` 而不是直接从 `fs` 中导入。

当文件不存在时,`stat` 会抛出一个带有 `code` 属性为 `"ENOENT"` 的错误对象。这些有点模糊的、受 Unix 启发的代码是你如何在 Node 中识别错误类型的。

`stat` 返回的 `stats` 对象告诉我们有关文件的一些信息,例如其大小(`size` 属性)和其修改日期(`mtime` 属性)。在这里,我们感兴趣的是它是否是目录还是普通文件,`isDirectory` 方法告诉我们这一点。

我们使用 `readdir` 来读取目录中的文件数组并将其返回给客户端。对于普通文件,我们使用 `createReadStream` 创建一个可读流并将其作为主体返回,以及 `mime` 包为文件名提供的 content type。

处理 `DELETE` 请求的代码稍微简单一些。

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(“无内容”)来指示这一点。由于对删除的响应不需要传输除操作是否成功以外的任何信息,因此这是这里的一个明智的选择。

你可能想知道为什么尝试删除一个不存在的文件会返回成功状态代码,而不是错误。当要删除的文件不存在时,你可以说请求的目标已经实现。HTTP 标准鼓励我们使请求*幂等*,这意味着多次发出相同的请求会产生与发出一次请求相同的结果。从某种意义上说,如果你试图删除已经不存在的东西,那么你想要做的效果已经实现——东西不再存在了。

这是 `PUT` 请求的处理程序

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(不返回任何东西)的地方。

服务器的完整脚本可以在 https://eloquent.javascript.ac.cn/code/file_server.js 中找到。您可以下载该脚本,并在安装其依赖项后,使用 Node 运行它来启动您自己的文件服务器。当然,您也可以修改和扩展它来解决本章的练习或进行实验。

命令行工具 curl 在类 Unix 系统(如 macOS 和 Linux)上广泛可用,可用于发送 HTTP 请求。以下会话简要测试了我们的服务器。-X 选项用于设置请求的方法,-d 用于包含请求体。

$ curl https://127.0.0.1:8000/file.txt
File not found
$ curl -X PUT -d hello https://127.0.0.1:8000/file.txt
$ curl https://127.0.0.1:8000/file.txt
hello
$ curl -X DELETE https://127.0.0.1:8000/file.txt
$ curl https://127.0.0.1:8000/file.txt
File not found

第一个对 file.txt 的请求失败,因为该文件尚不存在。PUT 请求创建了该文件,瞧,下一个请求成功地检索了它。在使用 DELETE 请求将其删除后,该文件再次丢失。

摘要

Node 是一个很棒的小系统,它让我们可以在非浏览器环境中运行 JavaScript。它最初是为网络任务而设计的,在网络中充当节点的角色。但它适合各种脚本任务,如果您喜欢编写 JavaScript,那么使用 Node 自动化任务效果很好。

NPM 提供了您能想到的(以及许多您可能永远想不到的)所有东西的软件包,它允许您使用 npm 程序获取和安装这些软件包。Node 附带了许多内置模块,包括用于处理文件系统的 fs 模块和用于运行 HTTP 服务器和发送 HTTP 请求的 http 模块。

除非您明确使用函数的同步变体,例如 readFileSync,否则 Node 中的所有输入和输出都是异步执行的。当调用此类异步函数时,您会提供回调函数,Node 会在准备就绪时使用错误值和(如果有)结果调用这些回调函数。

练习

搜索工具

在 Unix 系统上,有一个名为 grep 的命令行工具,可用于快速搜索文件中是否包含正则表达式。

编写一个 Node 脚本,该脚本可以从命令行运行,其功能类似于 grep。它将第一个命令行参数视为正则表达式,并将任何其他参数视为要搜索的文件。它应该输出任何内容匹配正则表达式的文件的文件名。

当该脚本正常工作后,将其扩展,以便当其中一个参数是目录时,它会搜索该目录及其子目录中的所有文件。

根据需要使用异步或同步文件系统函数。将多个异步操作同时请求可能会稍微提高速度,但不会提高太多,因为大多数文件系统一次只能读取一个内容。

第一个命令行参数(即正则表达式)可以在 process.argv[2] 中找到。输入文件位于其后。您可以使用 RegExp 构造函数将字符串转换为正则表达式对象。

使用 readFileSync 以同步方式执行操作更加直接,但如果您再次使用 fs.promises 来获取返回 Promise 的函数并编写一个 async 函数,则代码看起来类似。

要确定某个内容是否为目录,您可以再次使用 stat(或 statSync)以及 stats 对象的 isDirectory 方法。

浏览目录是一个分支过程。您可以通过使用递归函数或通过保留一个工作数组(仍需探索的文件)来完成它。要查找目录中的文件,您可以调用 readdirreaddirSync。奇怪的大写 - Node 的文件系统函数命名松散地基于标准的 Unix 函数,例如 readdir,它们都是小写,但随后添加了 Sync(大写字母)。

要从使用 readdir 读取的文件名转换为完整路径名,您必须将其与目录名称组合在一起,并在它们之间放置一个斜杠字符(/)。

创建目录

虽然我们文件服务器中的 DELETE 方法能够删除目录(使用 rmdir),但服务器当前没有提供任何方法来创建目录。

添加对 MKCOL 方法(“创建集合”)的支持,该方法应通过调用 fs 模块中的 mkdir 来创建目录。MKCOL 不是一个广泛使用的 HTTP 方法,但它确实存在于WebDAV 标准中,用于相同目的,该标准在 HTTP 之上指定了一组约定,使其适合创建文档。

您可以使用实现 DELETE 方法的函数作为 MKCOL 方法的蓝图。当没有找到文件时,尝试使用 mkdir 创建目录。当该路径下存在目录时,您可以返回 204 响应,以便目录创建请求是幂等的。如果此处存在非目录文件,则返回错误代码。代码 400(“错误请求”)将是合适的。

网络上的公共空间

由于文件服务器提供任何类型的文件,甚至包含正确的 Content-Type 标头,因此您可以使用它来提供网站。由于它允许每个人删除和替换文件,因此它将是一种有趣的网站:一个可以由任何有时间创建正确 HTTP 请求的人修改、改进和破坏的网站。

编写一个基本 HTML 页面,其中包含一个简单的 JavaScript 文件。将这些文件放在文件服务器提供的目录中,并在您的浏览器中打开它们。

接下来,作为高级练习或甚至周末项目,将您从本书中获得的所有知识结合起来,构建一个更用户友好的界面,用于从网站内部修改网站。

使用 HTML 表单来编辑构成网站的文件的内容,允许用户使用 HTTP 请求(如 第 18 章 中所述)在服务器上更新它们。

首先,只使一个文件可编辑。然后,让用户可以选择要编辑的文件。使用我们的文件服务器在读取目录时返回文件列表这一事实。

不要直接在文件服务器公开的代码中工作,因为如果您犯了错误,您很可能会损坏那里的文件。相反,将您的工作保存在公开访问目录之外,并在测试时将其复制到该目录。

您可以创建一个 <textarea> 元素来保存正在编辑的文件的内容。一个使用 fetchGET 请求可以检索文件的当前内容。您可以使用像index.html这样的相对 URL,而不是 https://127.0.0.1:8000/index.html,来引用与正在运行的脚本位于同一服务器上的文件。

然后,当用户点击按钮时(您可以使用 <form> 元素和 "submit" 事件),向同一个 URL 发送一个 PUT 请求,并将 <textarea> 的内容作为请求体,以保存文件。

然后,您可以添加一个 <select> 元素,该元素包含服务器顶层目录中的所有文件,方法是添加包含 GET 请求到 URL / 返回的行作为 <option> 元素。当用户选择另一个文件时(字段上的 "change" 事件),脚本必须获取并显示该文件。当保存文件时,使用当前选择的文件名。