Node.js

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

元玛大师,编程之书
Illustration showing a telephone pole with a tangle of wires going in all directions

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

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

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

如果你想跟随并运行本章中的代码,你需要安装 Node.js 版本 18 或更高版本。为此,请访问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"]

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

模块

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

Node 最初使用基于require函数的 CommonJS 模块系统,我们在第 10 章中见过。当你加载.js文件时,它默认情况下仍然会使用此系统。

但是今天,Node 也支持更现代的 ES 模块系统。当脚本的文件名以.mjs结尾时,它被认为是这种模块,你可以在其中使用importexport(但不能使用require)。本章将使用 ES 模块。

导入模块时——无论是使用require还是import——Node 都必须将给定的字符串解析为它可以加载的实际文件。以/./../开头的名称被解析为文件,相对于当前模块的路径。这里,.代表当前目录,../代表上一级目录,/代表文件系统的根目录。如果你从文件/tmp/robot/robot.mjs中请求"./graph.mjs",Node 将尝试加载文件/tmp/robot/graph.mjs

当导入看起来不像相对或绝对路径的字符串时,它被认为是指内置模块或安装在node_modules目录中的模块。例如,从"node:fs"导入将提供 Node 的内置文件系统模块。导入"robot"可能会尝试加载在node_modules/robot/中找到的库。使用 NPM 安装这样的库很常见,我们将在稍后回到它。

让我们建立一个由两个文件组成的小型项目。第一个文件名为main.mjs,它定义了一个可以从命令行调用的脚本,用于反转字符串。

import {reverse} from "./reverse.mjs";

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

console.log(reverse(argument));

文件reverse.mjs定义了一个用于反转字符串的库,该库既可以被这个命令行工具使用,也可以被需要直接访问字符串反转函数的其他脚本使用。

export function reverse(string) {
  return Array.from(string).reverse().join("");
}

请记住,export用于声明一个绑定是模块接口的一部分。这允许main.mjs导入和使用该函数。

现在,我们可以像这样调用我们的工具

$ node main.mjs JavaScript
tpircSavaJ

使用 NPM 安装

我们在第 10 章中介绍了 NPM,它是一个 JavaScript 模块的在线存储库,其中许多模块是专门为 Node 编写的。当你在电脑上安装 Node 时,你也会获得npm命令,你可以使用它来与这个存储库进行交互。

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

$ npm install ini
added 1 package in 723ms

$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }

在运行npm install之后,NPM 将创建一个名为node_modules的目录。在这个目录里面将会有一个ini目录,其中包含该库。你可以打开它并查看代码。当我们导入"ini"时,这个库会被加载,我们可以调用它的parse属性来解析配置文件。

默认情况下,NPM 将包安装在当前目录下,而不是在中心位置。如果你习惯使用其他包管理器,这可能看起来不寻常,但它有优点——它让每个应用程序完全控制它安装的包,并使管理版本和删除应用程序时清理更容易。

包文件

在运行npm install来安装一些包之后,你不仅会发现一个node_modules目录,而且还会在你的当前目录中找到一个名为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.mjs",
  "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 使用的细节。请参阅 https://npmjs.net.cn 获取更多文档和搜索包的方法。

文件系统模块

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

例如,名为 `readFile` 的函数读取文件,然后使用文件的原始内容调用回调函数。

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

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

import {readFile} from "node: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` 用于将文件写入磁盘。

import {writeFile} from "node: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)将其写出为文本。

`node:fs` 模块包含许多其他有用的函数:`readdir` 会将目录中的文件作为字符串数组提供给您,`stat` 将检索有关文件的信息,`rename` 将重命名文件,`unlink` 将删除一个文件,等等。请参阅 https://node.org.cn 上的文档以获取详细信息。

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

`node:fs/promises` 模块导出与旧的 `node:fs` 模块几乎相同的函数,但使用 Promise 而不是回调函数。

import {readFile} from "node:fs/promises";
readFile("file.txt", "utf8")
  .then(text => console.log("The file contains:", text));

有时您不需要异步操作,反而会妨碍操作。`node:fs` 中的许多函数也具有同步变体,其名称相同,并在末尾添加了 `Sync`。例如,`readFile` 的同步版本称为 `readFileSync`。

import {readFileSync} from "node:fs";
console.log("The file contains:",
            readFileSync("file.txt", "utf8"));

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

HTTP 模块

另一个核心模块称为 `node:http`。它提供了运行 HTTP 服务器的功能。

这就是启动 HTTP 服务器所需的全部内容

import {createServer} from "node: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)");

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

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

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

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

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

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

当您运行此脚本时,进程只会坐在那里等待。当脚本在侦听事件(在本例中为网络连接)时,`node` 不会在到达脚本末尾时自动退出。要关闭它,请按 ctrl-C。

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

`node:http` 模块还提供了一个 `request` 函数,可用于发出 HTTP 请求。但是,它的使用比我们在 第 18 章 中看到的 `fetch` 繁琐得多。幸运的是,`fetch` 也作为全局绑定在 Node 中可用。除非您想要执行一些非常具体的操作,例如在数据通过网络传入时逐段处理响应文档,否则我建议坚持使用 `fetch`。

HTTP 服务器可以写入的响应对象是可写流对象的示例,这是 Node 中一个广泛使用的概念。此类对象具有一个 `write` 方法,可以向其传递一个字符串或一个 `Buffer` 对象以将内容写入流。它们的 `end` 方法关闭流,并可选地采用一个值在关闭之前写入流。这两种方法还可以额外接受一个回调函数作为参数,它们会在写入或关闭完成后调用该回调函数。

可以使用 `node:fs` 模块中的 `createWriteStream` 函数创建一个指向文件的可写流。然后,您可以使用生成的 `write` 方法一次写入一部分文件,而不是像 `writeFile` 那样一次性写入整个文件。

可读流稍微复杂一些。HTTP 服务器回调函数中的 `request` 参数是一个可读流。使用事件处理程序而不是方法从流中读取数据。

在 Node 中发出事件的对象有一个名为 `on` 的方法,它类似于浏览器中的 `addEventListener` 方法。您需要提供一个事件名称和一个函数,它会注册该函数,以便在发生给定事件时调用该函数。

可读流具有 `"data"` 和 `"end"` 事件。第一个在每次数据传入时触发,第二个在流结束时触发。此模型最适合用于流式传输可以立即处理的数据,即使整个文档尚未可用。可以使用 `node:fs` 中的 `createReadStream` 函数将文件读取为可读流。

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

import {createServer} from "node: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 编码字符来将其转换为字符串。

以下代码段在运行带有大写服务器的代码时,将向该服务器发送请求并写出它收到的响应

fetch("https://127.0.0.1:8000/", {
  method: "POST",
  body: "Hello server"
}).then(resp => resp.text()).then(console.log);
// → HELLO SERVER

文件服务器

让我们结合我们新获得的有关 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 函数,它们以请求对象作为参数,并返回一个承诺,该承诺解析为描述响应的对象。

import {createServer} from "node: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?.pipe) body.pipe(response);
    else response.end(body);
  });
}).listen(8000);

async function notAllowed(request) {
  return {
    status: 405,
    body: `Method ${request.method} not allowed.`
  };
}

这将启动一个服务器,该服务器只返回 405 错误响应,这是用于指示服务器拒绝处理给定方法的代码。

当请求处理程序的承诺被拒绝时,catch 调用将错误转换为响应对象(如果它还不是响应对象),以便服务器可以发送回错误响应以通知客户端它未能处理请求。

响应描述的status 字段可以省略,在这种情况下,它默认为 200(OK)。type 属性中的内容类型也可以省略,在这种情况下,响应假定为纯文本。

body 的值为可读流时,它将具有一个pipe 方法,我们可以使用它将可读流中的所有内容转发到可写流。如果不是,则假定它为null(没有主体)、字符串或缓冲区,并直接传递给响应的end 方法。

为了找出哪个文件路径对应于请求 URL,urlPath 函数使用内置的URL 类(它也存在于浏览器中)来解析 URL。此构造函数期望一个完整的 URL,而不仅仅是从request.url 获取的以斜杠开头的部分,因此我们给它一个虚拟域名来填补。它提取其路径名,它将类似于"/file.txt",对其进行解码以消除%20 样式的转义代码,并将其相对于程序的工作目录解析。

import {resolve, sep} from "node:path";

const baseDirectory = process.cwd();

function urlPath(url) {
  let {pathname} = new URL(url, "http://d");
  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 使用node:path 模块中的resolve 函数,该函数解析相对路径。然后它验证结果是否位于工作目录下方process.cwd 函数(其中cwd 代表当前工作目录)可用于查找此工作目录。node:path 包中的sep 绑定是系统的路径分隔符——Windows 上的反斜杠和大多数其他系统上的正斜杠。当路径没有以基本目录开头时,该函数抛出错误响应对象,使用 HTTP 状态码指示禁止访问资源。

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

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

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

$ npm install [email protected]

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

import {createReadStream} from "node:fs";
import {stat, readdir} from "node:fs/promises";
import {lookup} from "mime-types";

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: lookup(path)};
  }
};

由于它必须接触磁盘,因此可能需要一段时间,因此stat 是异步的。由于我们使用的是承诺而不是回调样式,因此它必须从node:fs/promises 导入,而不是直接从node:fs 导入。

当文件不存在时,stat 将抛出包含"ENOENT" 代码属性的错误对象。这些有点晦涩难懂的 Unix 风格代码是你在 Node 中识别错误类型的方式。

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

我们使用readdir 来读取目录中的文件数组并将其返回给客户端。对于普通文件,我们使用createReadStream 创建可读流并将其作为正文返回,以及mime 包为文件名称提供的內容类型。

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

import {rmdir, unlink} from "node: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 请求的处理程序

import {createWriteStream} from "node: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 不是为了返回承诺而编写的,因此我们必须编写一个包装器pipeStream,它围绕调用pipe 的结果创建一个承诺。

当在打开文件时出现问题时,createWriteStream 仍然会返回一个流,但该流将触发一个"error" 事件。来自请求的流也可能失败——例如,如果网络断开连接。因此,我们将两个流的"error" 事件连接起来以拒绝承诺。当pipe 完成时,它将关闭输出流,这将导致它触发一个"finish" 事件。那是我们可以成功解析承诺(返回无)的时候。

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

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

$ curl https://127.0.0.1:8000/file.txt
File not found
$ curl -X PUT -d CONTENT https://127.0.0.1:8000/file.txt
$ curl https://127.0.0.1:8000/file.txt
CONTENT
$ 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 附带了许多内置模块,包括用于处理文件系统的node:fs 模块和用于运行 HTTP 服务器的node:http 模块。

Node 中的所有输入和输出都是异步的,除非你显式使用函数的同步变体,例如readFileSync。Node 最初使用回调来实现异步功能,但node:fs/promises 包为文件系统提供了一个基于承诺的接口。

练习

搜索工具

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

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

当这部分代码能正常工作后,请扩展它,以便当参数之一是目录时,它能够搜索该目录及其子目录中的所有文件。

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

显示提示...

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

使用 readFileSync 同步地执行此操作更直接,但如果您使用 node:fs/promises 获取返回 promise 的函数并编写 async 函数,代码看起来会类似。

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

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

要从使用 readdir 读取的文件名转换为完整路径名,您必须将其与目录名称组合,在它们之间添加 sep(来自 node:path)或使用 join 函数(来自同一个包)。

目录创建

尽管文件服务器中的 DELETE 方法能够删除目录(使用 rmdir),但服务器目前不提供任何创建目录的方法。

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

显示提示...

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

网络上的公共空间

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

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

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

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

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

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

显示提示...

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

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

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