第三版已发布。点击此处阅读

第 20 章
Node.js

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

元马大师,编程之书

到目前为止,你已经学习了 JavaScript 语言,并在单一环境中使用它:浏览器。本章和下一章将简要介绍 Node.js,它允许你在浏览器之外应用你的 JavaScript 技能。有了它,你可以构建任何东西,从简单的命令行工具到动态的 HTTP 服务器。

这些章节旨在教你 Node.js 构建的重要理念,并为你提供足够的信息来编写一些有用的程序。它们并非试图成为 Node 的完整介绍,甚至不是彻底的介绍。

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

如果你想跟着学习并运行本章中的代码,请先访问nodejs.org并按照适用于你的操作系统的安装说明操作。此外,请参考该网站了解更多关于 Node 及其内置模块的文档。

背景

编写通过网络通信的系统最困难的问题之一是管理输入和输出,即读写网络、硬盘和其他设备的数据。数据传输需要时间,巧妙地调度它可以极大地影响系统响应用户或网络请求的速度。

处理输入和输出的传统方式是使用一个函数,例如 readFile,开始读取文件,并只在文件完全读取后返回。这被称为同步 I/O(I/O 代表输入/输出)。

Node 最初的目的是简化异步 I/O。我们之前已经见过异步接口,例如浏览器的 XMLHttpRequest 对象,在第 17 章中讨论过。异步接口允许脚本在完成工作时继续运行,并在完成后调用回调函数。这是 Node 执行所有 I/O 的方式。

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

异步性

我将尝试用一个简单的例子来说明同步 I/O 和异步 I/O 的区别,其中一个程序需要从互联网上获取两个资源,然后对结果进行一些简单的处理。

在同步环境中,执行此任务的明显方法是按顺序发出请求。这种方法的缺点是,只有在第一个请求完成之后才会开始第二个请求。总共花费的时间至少是两个响应时间的总和。这不是对机器的有效利用,机器在通过网络传输和接收数据时大部分时间处于空闲状态。

在同步系统中,解决这个问题的方法是启动额外的控制线程。(请参阅第 14 章,了解之前关于线程的讨论。)第二个线程可以启动第二个请求,然后这两个线程都等待结果返回,然后同步起来组合他们的结果。

在下图中,粗线代表程序正常运行的时间,细线代表等待 I/O 的时间。在同步模型中,I/O 所花费的时间是给定控制线程的时间线的一部分。在异步模型中,启动 I/O 操作在概念上会导致时间线分裂。启动 I/O 的线程继续运行,I/O 本身与其并行完成,最后在完成时调用回调函数。

Control flow for synchronous and asynchronous I/O

另一种表达这种差异的方法是,在同步模型中,等待 I/O 完成是隐式的,而在异步模型中,它是显式的,直接在我们控制之下。但异步性是双向的。它使表达不适合控制的直线模型的程序变得更容易,但也使表达遵循直线的程序变得更加笨拙。

第 17 章中,我已经提到了所有这些回调给程序增加了相当多的噪声和间接性。这种异步风格是否总体上是一个好主意是可以争论的。无论如何,它需要一些时间来适应。

但对于基于 JavaScript 的系统,我认为基于回调的异步性是一个明智的选择。JavaScript 的优势之一是它的简单性,试图向其中添加多个控制线程会增加很多复杂性。虽然回调不会导致简单的代码,但作为一种概念,它们既简单又足够强大,可以编写高性能的 Web 服务器。

node 命令

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

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

然后,你可以在命令行中像这样运行 node 来执行程序

$ node hello.js
Hello world

Node 中的 console.log 方法与它在浏览器中的行为类似。它会打印出一段文本。但在 Node 中,文本将发送到进程的标准输出流,而不是浏览器的 JavaScript 控制台。

如果你在没有提供文件的情况下运行 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", "/home/marijn/showargv.js", "one", "--and", "two"]

所有标准 JavaScript 全局变量,例如 ArrayMathJSON,也存在于 Node 的环境中。与浏览器相关的功能,例如 documentalert,是不存在的。

全局作用域对象在浏览器中被称为 window,在 Node 中则有更合理的名称 global

模块

除了我提到的几个变量,例如 consoleprocess 之外,Node 在全局作用域中几乎没有提供什么功能。如果你想访问其他内置功能,你必须向模块系统请求它。

基于 require 函数的 CommonJS 模块系统在第 10 章中进行了描述。此系统内置于 Node,用于加载任何内容,从内置模块到下载的库,再到程序本身的一部分文件。

当调用 require 时,Node 必须将给定的字符串解析为要加载的实际文件。以 "/""./""../" 开头的路径名将相对于当前模块的路径解析,其中 "./" 代表当前目录,"../" 代表上一级目录,"/" 代表文件系统的根目录。因此,如果你从文件 /home/marijn/elife/run.js 中请求 "./world/world",Node 将尝试加载文件 /home/marijn/elife/world/world.js。可以省略 .js 扩展名。

当给 require 提供一个看起来不像相对路径或绝对路径的字符串时,假设它指的是内置模块或安装在 node_modules 目录中的模块。例如,require("fs") 将返回 Node 的内置文件系统模块,require("elife") 将尝试加载在 node_modules/elife/ 中找到的库。使用 NPM 是安装此类库的一种常见方法,我将在稍后讨论它。

为了说明 require 的用法,让我们设置一个包含两个文件的简单项目。第一个文件名为 main.js,它定义了一个可以从命令行调用来混淆字符串的脚本。

var garble = require("./garble");

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

console.log(garble(argument));

文件 garble.js 定义了一个用于混淆字符串的库,该库既可以被前面定义的命令行工具使用,也可以被需要直接访问混淆函数的其他脚本使用。

module.exports = function(string) {
  return string.split("").map(function(ch) {
    return String.fromCharCode(ch.charCodeAt(0) + 5);
  }).join("");
};

请记住,替换 module.exports,而不是向其添加属性,可以让我们从模块中导出特定值。在本例中,我们使 require 我们 garble 文件的结果成为混淆函数本身。

该函数通过在空字符串上拆分来将给定的字符串拆分为单个字符,然后用代码高 5 位的字符替换每个字符。最后,它将结果重新组合成一个字符串。

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

$ node main.js JavaScript
Of{fXhwnuy

使用 NPM 安装

NPM 在第 10 章中简要介绍过,它是一个在线 JavaScript 模块库,其中许多模块专门为 Node 编写。当你将 Node 安装在你的计算机上时,你还会获得一个名为 npm 的程序,它提供了一个方便的接口来访问这个库。

例如,您在 NPM 上会找到一个名为 figlet 的模块,它可以将文本转换为ASCII 艺术——用文本字符绘制的图形。以下脚本展示了如何安装和使用它

$ npm install figlet
npm GET https://registry.npmjs.org/figlet
npm 200 https://registry.npmjs.org/figlet
npm GET https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz
npm 200 https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz
[email protected] node_modules/figlet
$ node
> var figlet = require("figlet");
> figlet.text("Hello world!", function(error, data) {
    if (error)
      console.error(error);
    else
      console.log(data);
  });
  _   _      _ _                            _     _ _
 | | | | ___| | | ___   __      _____  _ __| | __| | |
 | |_| |/ _ \ | |/ _ \  \ \ /\ / / _ \| '__| |/ _` | |
 |  _  |  __/ | | (_) |  \ V  V / (_) | |  | | (_| |_|
 |_| |_|\___|_|_|\___/    \_/\_/ \___/|_|  |_|\__,_(_)

运行 npm install 后,NPM 会创建一个名为 node_modules 的目录。在该目录内将有一个 figlet 目录,其中包含该库。当我们运行 node 并调用 require("figlet") 时,该库就会被加载,我们可以调用它的 text 方法来绘制一些大写字母。

也许有点出乎意料的是,figlet.text 并没有简单地返回构成大写字母的字符串,而是接受一个回调函数,并将结果传递给它。它还将另一个参数 error 传递给回调函数,该参数在出现错误时将保存一个错误对象,在一切正常时将保存 null。

这是 Node 代码中的一种常见模式。使用 figlet 渲染某些内容需要库读取一个包含字母形状的文件。从磁盘读取该文件是 Node 中的一个异步操作,因此 figlet.text 无法立即返回其结果。异步性具有传染性,在某种程度上,每个调用异步函数的函数本身也必须变成异步的。

NPM 不仅仅是 npm install。它读取 package.json 文件,这些文件包含关于程序或库的 JSON 编码信息,例如它依赖于哪些其他库。在包含此类文件的目录中执行 npm install 将自动安装所有依赖项,以及它们的依赖项。npm 工具还用于将库发布到 NPM 的在线软件包存储库中,以便其他人可以找到、下载和使用它们。

本书不会深入探讨 NPM 使用的细节。有关更多文档和轻松搜索库的方法,请参阅 npmjs.org

文件系统模块

Node 附带的最常用的内置模块之一是 "fs" 模块,它代表文件系统。该模块提供了用于处理文件和目录的功能。

例如,有一个名为 readFile 的函数,它读取文件,然后使用文件的内容调用回调函数。

var fs = require("fs");
fs.readFile("file.txt", "utf8", function(error, text) {
  if (error)
    throw error;
  console.log("The file contained:", text);
});

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

var fs = require("fs");
fs.readFile("file.txt", function(error, buffer) {
  if (error)
    throw error;
  console.log("The file contained", buffer.length, "bytes.",
              "The first byte is:", buffer[0]);
});

一个类似的函数 writeFile 用于将文件写入磁盘。

var fs = require("fs");
fs.writeFile("graffiti.txt", "Node was here", function(err) {
  if (err)
    console.log("Failed to write file:", err);
  else
    console.log("File written.");
});

在这里,无需指定编码,因为 writeFile 会假设如果它被赋予一个字符串而不是一个 Buffer 对象来写入,它应该使用其默认字符编码(UTF-8)将其作为文本写入。

"fs" 模块包含许多其他有用的函数:readdir 将返回目录中的文件,这些文件是字符串数组,stat 将检索有关文件的详细信息,rename 将重命名文件,unlink 将删除一个文件,等等。有关详细信息,请参阅 nodejs.org 中的文档。

"fs" 中的许多函数都有同步和异步变体。例如,readFile 有一个同步版本,名为 readFileSync

var fs = require("fs");
console.log(fs.readFileSync("file.txt", "utf8"));

同步函数使用起来更简单,在简单的脚本中很有用,因为异步 I/O 提供的额外速度无关紧要。但是请注意,在执行此类同步操作时,您的程序将完全停止。如果它应该对用户或网络上的其他机器做出响应,那么卡在同步 I/O 上可能会导致令人讨厌的延迟。

HTTP 模块

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

这就是启动简单 HTTP 服务器所需的全部代码

var http = require("http");
var server = http.createServer(function(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);

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

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

要发送回内容,您可以调用 response 对象上的方法。第一个是 writeHead,它将输出响应头(见 第 17 章)。您向它提供状态代码(在本例中为 200,表示“正常”)以及包含头值的对象。在这里,我们告诉客户端我们将返回一个 HTML 文档。

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

调用 server.listen 会导致服务器开始在端口 8000 上等待连接。这就是您必须连接到 localhost:8000 而不是仅仅连接到 localhost(它将使用默认端口 80)才能与该服务器通信的原因。

要停止运行像这样的 Node 脚本(它不会自动完成,因为它正在等待进一步的事件,在本例中是网络连接),请按 Ctrl-C。

真正的 Web 服务器通常比前面的示例中所示的要复杂——它查看请求的方法(method 属性)以查看客户端试图执行什么操作,并查看请求的 URL 以找出该操作正在对哪个资源执行。您将在 本章后面看到一个更高级的服务器。

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

var http = require("http");
var request = http.request({
  hostname: "eloquentjavascript.net",
  path: "/20_node.html",
  method: "GET",
  headers: {Accept: "text/html"}
}, function(response) {
  console.log("Server responded with status code",
              response.statusCode);
});
request.end();

request 的第一个参数配置请求,告诉 Node 与哪个服务器通信,从该服务器请求哪个路径,使用哪种方法,等等。第二个参数是接收响应时应调用的函数。它被赋予一个对象,允许我们检查响应,例如找出它的状态代码。

就像我们在服务器中看到的 response 对象一样,request 返回的对象允许我们使用 write 方法将数据流式传输到请求中,并使用 end 方法完成请求。示例未使用 write,因为 GET 请求不应在其请求主体中包含数据。

要向安全的 HTTP(HTTPS)URL 发出请求,Node 提供了一个名为 https 的包,它包含自己的 request 函数,类似于 http.request

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

可写流是 Node 接口中广泛使用的概念。所有可写流都有一个 write 方法,该方法可以传递一个字符串或一个 Buffer 对象。它们的 end 方法关闭流,如果给定一个参数,它也会在关闭流之前写出一个数据块。这两种方法还可以接受一个回调函数作为附加参数,它们会在流写入或关闭完成后调用该函数。

可以使用 fs.createWriteStream 函数创建一个指向文件的可写流。然后,您可以使用结果对象上的 write 方法一次写入一个文件,而不是像使用 fs.writeFile 那样一次写入全部内容。

可读流稍微复杂一些。传递给 HTTP 服务器回调函数的 request 变量和传递给 HTTP 客户端的 response 变量都是可读流。(服务器读取请求,然后写入响应,而客户端首先写入请求,然后读取响应。)从流中读取是使用事件处理程序完成的,而不是使用方法。

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

可读流具有 "data""end" 事件。第一个事件在每次有数据传入时触发,第二个事件在流结束时调用。此模型最适合“流式传输”数据,即使整个文档不可用,也可以立即处理数据。可以使用 fs.createReadStream 函数将文件读取为可读流。

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

var http = require("http");
http.createServer(function(request, response) {
  response.writeHead(200, {"Content-Type": "text/plain"});
  request.on("data", function(chunk) {
    response.write(chunk.toString().toUpperCase());
  });
  request.on("end", function() {
    response.end();
  });
}).listen(8000);

传递给数据处理程序的 chunk 变量将是一个二进制 Buffer,我们可以通过在它上面调用 toString 来将其转换为字符串,这将使用默认编码(UTF-8)对其进行解码。

以下代码片段(如果在运行大写服务器时运行)将向该服务器发送一个请求并写出它收到的响应

var http = require("http");
var request = http.request({
  hostname: "localhost",
  port: 8000,
  method: "POST"
}, function(response) {
  response.on("data", function(chunk) {
    process.stdout.write(chunk.toString());
  });
});
request.end("Hello server");

该示例写入 process.stdout(进程的标准输出,作为可写流),而不是使用 console.log。我们不能使用 console.log,因为它在写入的每个文本块之后添加一个额外的换行符,这在这里不合适。

一个简单的文件服务器

让我们将我们新学到的关于 HTTP 服务器和与文件系统交互的知识结合起来,在它们之间建立一座桥梁:一个允许远程访问文件系统的 HTTP 服务器。这样的服务器有很多用途。它允许 Web 应用程序存储和共享数据,或为一组人提供对一组文件的共享访问权限。

当我们将文件视为 HTTP 资源时,HTTP 方法 GETPUTDELETE 分别可以用于读取、写入和删除文件。我们将请求中的路径解释为请求所指文件的路径。

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

我们将逐步构建程序,使用一个名为 methods 的对象来存储处理各种 HTTP 方法的函数。

var http = require("http"), fs = require("fs");

var methods = Object.create(null);

http.createServer(function(request, response) {
  function respond(code, body, type) {
    if (!type) type = "text/plain";
    response.writeHead(code, {"Content-Type": type});
    if (body && body.pipe)
      body.pipe(response);
    else
      response.end(body);
  }
  if (request.method in methods)
    methods[request.method](urlToPath(request.url),
                            respond, request);
  else
    respond(405, "Method " + request.method +
            " not allowed.");
}).listen(8000);

这启动了一个仅返回 405 错误响应的服务器,这是用于指示服务器未处理给定方法的代码。

respond 函数传递给处理各种方法的函数,并充当完成请求的回调。它接收一个 HTTP 状态代码、一个主体,以及可选的 content type 作为参数。如果作为主体传递的值是可读流,它将具有一个 pipe 方法,该方法用于将可读流转发到可写流。如果不是,则假定它为 null(无主体)或字符串,并直接传递给响应的 end 方法。

要从请求中的 URL 获取路径,urlToPath 函数使用 Node 的内置 "url" 模块来解析 URL。它获取其路径名,它将类似于 /file.txt,对该路径进行解码以去除 %20 风格的转义代码,并添加一个点以生成相对于当前目录的路径。

function urlToPath(url) {
  var path = require("url").parse(url).pathname;
  return "." + decodeURIComponent(path);
}

如果您担心 urlToPath 函数的安全性,您是对的。我们将在练习中回到这个问题。

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

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

如果您在服务器脚本所在的目录中运行以下 npm 命令,您将能够使用 require("mime") 访问库

$ npm install [email protected]
npm http GET https://registry.npmjs.org/mime
npm http 304 https://registry.npmjs.org/mime
[email protected] node_modules/mime

当请求的文件不存在时,要返回的正确 HTTP 错误代码是 404。我们将使用 fs.stat,它查找文件的相关信息,以找出文件是否存在以及它是否是目录。

methods.GET = function(path, respond) {
  fs.stat(path, function(error, stats) {
    if (error && error.code == "ENOENT")
      respond(404, "File not found");
    else if (error)
      respond(500, error.toString());
    else if (stats.isDirectory())
      fs.readdir(path, function(error, files) {
        if (error)
          respond(500, error.toString());
        else
          respond(200, files.join("\n"));
      });
    else
      respond(200, fs.createReadStream(path),
              require("mime").lookup(path));
  });
};

由于它必须访问磁盘,因此可能需要一段时间,fs.stat 是异步的。当文件不存在时,fs.stat 将将一个错误对象(其 code 属性为 "ENOENT")传递给其回调。如果 Node 为不同类型的错误定义了不同的 Error 子类型就好了,但它没有。相反,它只是将一些模糊的、Unix 风格的代码放在那里。

我们将使用状态代码 500 报告任何我们没有预期的错误,这表示问题存在于服务器中,而不是以 4 开头的代码(例如 404),这些代码是指错误的请求。在某些情况下,这并不完全准确,但对于像这样的小型示例程序,它就足够好了。

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

我们使用 fs.readdir 读取目录中的文件列表,并在另一个回调中将它返回给用户。对于普通文件,我们使用 fs.createReadStream 创建一个可读流,并将它与 "mime" 模块为文件名提供的 content type 一起传递给 respond

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

methods.DELETE = function(path, respond) {
  fs.stat(path, function(error, stats) {
    if (error && error.code == "ENOENT")
      respond(204);
    else if (error)
      respond(500, error.toString());
    else if (stats.isDirectory())
      fs.rmdir(path, respondErrorOrNothing(respond));
    else
      fs.unlink(path, respondErrorOrNothing(respond));
  });
};

您可能想知道为什么尝试删除不存在的文件会返回 204 状态,而不是错误。当要删除的文件不存在时,您可以说请求的目标已经实现。HTTP 标准鼓励人们使请求 幂等,这意味着多次应用它们不会产生不同的结果。

function respondErrorOrNothing(respond) {
  return function(error) {
    if (error)
      respond(500, error.toString());
    else
      respond(204);
  };
}

当 HTTP 响应不包含任何数据时,状态代码 204(“无内容”)可以用来表示这一点。由于我们需要在一些不同的情况下提供回调,这些回调要么报告错误,要么返回 204 响应,因此我编写了一个 respondErrorOrNothing 函数来创建这样的回调。

这是 PUT 请求的处理程序

methods.PUT = function(path, respond, request) {
  var outStream = fs.createWriteStream(path);
  outStream.on("error", function(error) {
    respond(500, error.toString());
  });
  outStream.on("finish", function() {
    respond(204);
  });
  request.pipe(outStream);
};

这里,我们不需要检查文件是否存在——如果存在,我们将覆盖它。我们再次使用 pipe 将数据从可读流移动到可写流,在本例中是从请求到文件。如果创建流失败,它将引发一个 "error" 事件,我们会在我们的响应中报告该事件。当数据成功传输时,pipe 将关闭两个流,这将导致可写流上触发一个 "finish" 事件。发生这种情况时,我们可以使用 204 响应向客户端报告成功。

服务器的完整脚本可在 eloquentjavascript.net/2nd_edition/code/file_server.js 找到。您可以下载它并使用 Node 运行它来启动自己的文件服务器。当然,您可以修改和扩展它来解决本章的练习或进行实验。

命令行工具 curl 在类 Unix 系统上广泛可用,可以用来发送 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 请求将其删除后,该文件再次丢失。

错误处理

在文件服务器的代码中,有 个地方我们显式地将我们不知道如何处理的异常路由到错误响应。由于异常不会自动传播到回调,而是作为参数传递给它们,因此必须在每次出现时显式地处理它们。这完全破坏了异常处理的优势,即集中处理故障条件的能力。

当系统中出现 抛出 异常的情况时会发生什么?由于我们没有使用任何 try 块,因此异常将传播到调用栈的顶部。在 Node 中,这将中止程序并将有关异常的信息(包括堆栈跟踪)写入程序的标准错误流。

这意味着,每当在服务器代码本身中遇到问题时,我们的服务器就会崩溃,而不是异步问题,这些问题将作为参数传递给回调。如果我们想处理在处理请求期间引发的所有异常,以确保我们发送响应,我们必须在 每个 回调中添加 try/catch 块。

这是不可行的。许多 Node 程序被编写为尽可能少地使用异常,并假设如果引发异常,则程序无法处理它,崩溃是正确的响应。

另一种方法是使用承诺,承诺在 第 17 章 中介绍。它们会捕获由回调函数引发的异常并将它们传播为失败。可以在 Node 中加载承诺库并使用它来管理您的异步控制。很少有 Node 库集成承诺,但通常将它们包装起来微不足道。NPM 中优秀的 "promise" 模块包含一个名为 denodeify 的函数,它接收一个像 fs.readFile 这样的异步函数,并将其转换为一个返回承诺的函数。

var Promise = require("promise");
var fs = require("fs");

var readFile = Promise.denodeify(fs.readFile);
readFile("file.txt", "utf8").then(function(content) {
  console.log("The file contained: " + content);
}, function(error) {
  console.log("Failed to read file: " + error);
});

为了比较,我编写了另一个基于承诺的文件服务器版本,您可以在 eloquentjavascript.net/2nd_edition/code/file_server_promises.js 找到它。它稍微简洁一些,因为函数现在可以 返回 它们的结果,而不是必须调用回调,并且异常的路由是隐式的,而不是显式的。

我将列出基于承诺的文件服务器中的一些代码行,以说明编程风格的差异。

此代码使用的 fsp 对象包含许多 fs 函数的承诺风格变体,这些变体由 Promise.denodeify 包装。方法处理程序返回的对象(具有 codebody 属性)将成为承诺链的最终结果,它将用于确定发送给客户端的响应类型。

methods.GET = function(path) {
  return inspectPath(path).then(function(stats) {
    if (!stats) // Does not exist
      return {code: 404, body: "File not found"};
    else if (stats.isDirectory())
      return fsp.readdir(path).then(function(files) {
        return {code: 200, body: files.join("\n")};
      });
    else
      return {code: 200,
              type: require("mime").lookup(path),
              body: fs.createReadStream(path)};
  });
};

function inspectPath(path) {
  return fsp.stat(path).then(null, function(error) {
    if (error.code == "ENOENT") return null;
    else throw error;
  });
}

inspectPath 函数是围绕 fs.stat 的一个简单包装器,它处理文件未找到的情况。在这种情况下,我们将失败替换为产生 null 的成功。所有其他错误都允许传播。当从这些处理程序返回的承诺失败时,HTTP 服务器将以 500 状态代码响应。

总结

Node 是一个不错、直接的系统,它让我们在非浏览器环境中运行 JavaScript。它最初是为网络任务设计的,在网络中充当一个 节点 的角色。但它适用于各种脚本任务,如果您喜欢编写 JavaScript,那么用 Node 自动化日常任务非常棒。

NPM 提供了你能想到的(以及你可能永远想不到的)所有内容的库,并且它允许你通过运行简单的命令来获取和安装这些库。Node 还附带了一些内置模块,包括用于处理文件系统的 "fs" 模块,以及用于运行 HTTP 服务器和发出 HTTP 请求的 "http" 模块。

除非你明确使用函数的同步变体(例如 fs.readFileSync),否则 Node 中的所有输入和输出都是异步执行的。你可以提供回调函数,Node 会在适当的时候调用它们,即当你请求的 I/O 完成时。

练习

内容协商,再次

第 17 章 中,第一个练习是向 eloquentjavascript.net/author 发出多个请求,通过传递不同的 Accept 标头来请求不同类型的内容。

使用 Node 的 http.request 函数再次执行此操作。至少请求 text/plaintext/htmlapplication/json 媒体类型。请记住,请求的标头可以作为对象给出,在 http.request 的第一个参数的 headers 属性中。

写出对每个请求的响应内容。

不要忘记在 http.request 返回的对象上调用 end 方法,以便真正发出请求。

传递给 http.request 回调的响应对象是一个可读流。这意味着从它获取整个响应主体并非微不足道。以下实用程序函数读取整个流并使用通常的模式(将遇到的任何错误作为第一个参数传递给回调)调用回调函数,并将结果作为第二个参数传递给回调函数

function readStreamAsString(stream, callback) {
  var data = "";
  stream.on("data", function(chunk) {
    data += chunk.toString();
  });
  stream.on("end", function() {
    callback(null, data);
  });
  stream.on("error", function(error) {
    callback(error);
  });
}

修复漏洞

为了方便远程访问某些文件,我可能会养成一个习惯,即让本章中定义的 文件服务器在我的机器上的 /home/marijn/public 目录中运行。然后,有一天,我发现有人获得了访问我存储在浏览器中的所有密码的权限。

发生了什么事?

如果你还没有明白,请回想一下 urlToPath 函数,定义如下

function urlToPath(url) {
  var path = require("url").parse(url).pathname;
  return "." + decodeURIComponent(path);
}

现在考虑这样一个事实,传递给 "fs" 函数的路径可以是相对的——它们可能包含 "../" 来向上移动一个目录。当客户端发送请求到像这里显示的 URL 时会发生什么?

http://myhostname:8000/../.config/config/google-chrome/Default/Web%20Data
http://myhostname:8000/../.ssh/id_dsa
http://myhostname:8000/../../../etc/passwd

更改 urlToPath 以修复此问题。考虑到 Windows 上的 Node 允许使用正斜杠和反斜杠来分隔目录。

此外,思考这样一个事实,一旦你在互联网上暴露了一些半生不熟的系统,该系统中的错误可能会被用来对你机器做坏事。

删除所有两侧都有斜杠、反斜杠或字符串结尾的两个点的出现就足够了。使用带正则表达式的 replace 方法是实现这一点的最简单方法。但由于此类实例可能重叠(如 "/../../f" 中所示),你可能需要多次应用 replace,直到字符串不再更改。还要确保你在解码字符串之后进行替换,否则可以通过编码点或斜杠来破坏检查。

另一个可能令人担忧的情况是路径以斜杠开头,这些路径被解释为绝对路径。但是,因为 urlToPath 在路径前面放了一个点字符,所以不可能创建导致这种路径的请求。路径内部的多个连续斜杠很奇怪,但文件系统会将其视为单个斜杠。

创建目录

虽然 DELETE 方法已连接到删除目录(使用 fs.rmdir),但文件服务器目前没有提供任何创建目录的方法。

添加对 MKCOL 方法的支持,该方法应通过调用 fs.mkdir 来创建目录。MKCOL 不是基本 HTTP 方法之一,但它确实存在于 WebDAV 标准中,该标准指定了一组对 HTTP 的扩展,使其适用于写入资源,而不仅仅是读取资源。

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

网络上的公共空间

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

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

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

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

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

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

如果你的计算机直接连接到互联网,没有防火墙、路由器或其他干扰设备介于两者之间,你可能可以邀请朋友使用你的网站。要检查,请访问 whatismyip.com,将它提供的 IP 地址复制到浏览器地址栏中,并在其后添加 :8000 以选择正确的端口。如果这带你到你的网站,那么它就对每个人可见。

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

然后,当用户点击按钮(你可以使用 <form> 元素和 "submit" 事件,或者简单地使用 "click" 处理程序)时,对同一个 URL 发出 PUT 请求,并将 <textarea> 的内容作为请求主体,以保存文件。

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

不幸的是,该服务器过于简单,无法可靠地从子目录中读取文件,因为它没有告诉我们我们使用 GET 请求获取的东西是普通文件还是目录。你能想到一种扩展服务器以解决此问题的方法吗?