36 KiB
二十、Node.js
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,并按照用于你的操作系统的安装说明进行操作。 您也可以在那里找到 Node.js 的更多文档。
20.1 背景
编写通过网络通信的系统时,一个更困难的问题是管理输入输出,即向/从网络和硬盘读写数据。到处移动数据会耗费时间,而调度这些任务的技巧会使得系统在相应用户或网络请求时产生巨大的性能差异。
在这样的程序中,异步编程通常是有帮助的。 它允许程序同时向/从多个设备发送和接收数据,而无需复杂的线程管理和同步。
Node最初是为了使异步编程简单方便而设计的。 JavaScript 很好地适应了像 Node 这样的系统。 它是少数几种没有内置输入和输出方式的编程语言之一。 因此,JavaScript 可以适应 Node 的相当古怪的输入和输出方法,而不会产生两个不一致的接口。 在 2009 年设计 Node 时,人们已经在浏览器中进行基于回调的编程,所以该语言的社区用于异步编程风格。
20.3 node命令
在系统中安装完Node.js后,Node.js会提供一个名为node的程序,该程序用于执行JavaScript文件。假设你有一个文件hello.js,该文件会包含以下代码。
let message = "Hello world";
console.log(message);
读者可以仿照下面这种方式通过命令行执行程序。
$ node hello.js
Hello world
Node中的console.log方法与浏览器中所做的类似,都用于打印文本片段。但在Node中,该方法不会将文本显示在浏览器的JavaScript控制台中,而显示在标准输出流中。从命令行运行node
时,这意味着您会在终端中看到记录的值。
若你执行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),当前程序是成功完成(代码为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则不存在。
20.4 模块
除了前文提到的一些绑定,比如console和process,Node在全局作用域中添加了很少绑定。如果你需要访问其他的内建功能,可以通过system模块获取。
第10章中描述了基于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
,并定义了一个脚本,可以从命令行调用来反转字符串。
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
20.5 使用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
文件,如下所示:
{
"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 来获取更多文档和搜索软件包的方法。
20.6 文件系统模块
在Node中最常用的内建模块就是“fs”(表示Filesystem,文件系统)模块。该模块提供了处理文件和目录的函数。
例如,有个函数名为readFile,该函数读取文件并调用回调,并将文件内容传递给回调。
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 位的块)对应的数字。
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对象,则writeFile会使用默认编码(即UTF-8)来输出文本。
“fs”模块也包含了其他实用函数,其中readdir函数用于将目录中的文件以字符串数组的方式返回,stat函数用于获取文件信息,rename函数用于重命名文件,unlink用于删除文件等。
而且其中大多数都将回调作为最后一个参数,它们会以错误(第一个参数)或成功结果(第二个参数)来调用。 我们在第十一章中看到,这种编程风格存在缺点 - 最大的缺点是,错误处理变得冗长且容易出错。
相关细节请参见http://nodejs.org/中的文档。
虽然Promise
已经成为 JavaScript 的一部分,但是,将它们与 Node.js 的集成的工作仍然还在进行中。 从 v10 开始,标准库中有一个名为fs/promises
的包,它导出的函数与fs
大部分相同,但使用Promise
而不是回调。
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"));
请注意,在执行这样的同步操作时,程序完全停止。 如果它应该响应用户或网络中的其他计算机,那么可在同步操作中可能会产生令人讨厌的延迟。
20.7 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);
若你在自己的机器上执行该脚本,你可以打开网页浏览器,并访问http://localhost:8000/hello,就会向你的服务器发出一个请求。服务器会响应一个简单的HTML页面。
每次客户端尝试连接服务器时,服务器都会调用传递给createServer函数的参数。request和response绑定都是对象,分别表示输入数据和输出数据。request包含请求信息,例如该对象的url属性表示请求的URL。
因此,当您在浏览器中打开该页面时,它会向您自己的计算机发送请求。 这会导致服务器函数运行并返回一个响应,您可以在浏览器中看到该响应。
你需要调用response对象的方法以将一些数据发回客户端。第一个函数调用(writeHead)会输出响应头(参见第17章)。你需要向该函数传递状态码(本例中200表示成功)和一个对象,该对象包含头部信息的值。该示例设置了“Content-Type”头,通知客户端我们将发送一个HTML文档。
接下来使用response.write来发送响应体(文档自身)。若你想一段一段地发送相应信息,可以多次调用该方法,例如将数据发送到客户端。最后调用response.end发送相应结束信号。
调用server.listen会使服务器在8000端口上开始等待请求。这就是你需要连接localhost:8000和服务器通信,而不是localhost(这样将会使用默认端口,即80)的原因。
当你运行这个脚本时,这个进程就在那里等着。 当一个脚本正在监听事件时 - 这里是网络连接 - Node 不会在到达脚本末尾时自动退出。为了关闭它,请按 Ctrl-C。
一个真实的Web服务器需要做的事情比示例多得多。其差别在于我们需要根据请求的方法(method属性),来判断客户端尝试执行的动作,并根据请求的URL来找出动作处理的资源。本章随后会介绍更高级的服务器。
我们可以使用http模块的request函数来充当一个HTTP客户端。
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方法结束发送。本例中并没有使用wirte方法,因为GET请求的请求正文中无法包含数据。
https
模块中有类似的request
函数,可以用来向https:
URL 发送请求。
但是使用 Node 的原始功能发送请求相当麻烦。 NPM 上有更多方便的包装包。 例如,node-fetch
提供了我们从浏览器得知的,基于Promise
的fetch
接口。
20.8 流
我们在HTTP中看过两个可写流的例子,即服务器可以向response对象中写入数据,而request返回的请求对象也可以写入数据。
可写流是Node中广泛使用的概念。这种对象有一个write方法,你可以传递字符串或Buffer对象,来向流写入一些数据。它们end方法用于关闭流,并且还可以接受一个可选值,在流关闭之前将其写入流。 这两个方法也可以接受回调作为附加参数,当写入或关闭完成时它们将被调用。
我们也可以使用来自fs模块的createWriteStream,建立一个指向本地文件的输出流。你可以调用该方法返回的结果对象的write方法,每次向文件中写入一段数据,而不是像fs.writeFile那样一次性写入所有数据。
可读流则略为复杂。传递给HTTP服务器回调的request绑定,以及传递给HTTP客户端回调的response对象都是可读流(服务器读取请求并写入响应,而客户端则先写入请求,然后读取响应)。读取流需要使用事件处理器,而不是方法。
Node中发出的事件都有一个on方法,类似浏览器中的addEventListener方法。该方法接受一个事件名和一个函数,并将函数注册到事件上,接下来每当指定事件发生时,都会调用注册的函数。
可读流有data事件和end事件。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);
传递给data处理函数的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服务允许客户远程访问文件系统。这个服务有许多用处,它允许网络应用程序存储并共享数据或使得一组人可以共享访问一批文件。
当我们将文件当作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
,解析为描述响应的对象。
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错误响应,该代码表示服务器拒绝处理特定的方法。
函数respond被传递给处理不同方法的函数,作为请求结束时的回调。该方法接受三个参数,第一个是状态码,第二个是响应体,第三个是可选的内容类型。若传递的响应体是一个可读流,该对象有一个pipe方法,用于将可读流转发到可写流中。若不是可读流,我们假定它是null(没有响应体)或一个字符串,我们将字符串直接传递给响应的end方法。
为了从请求的URL中获取路径,urlToPath函数使用了Node内建的url模块来解析URL。我们取出parse函数返回值中的pathname属性(结果类似于/file.txt),并进行解码,去除%20这类转义代码,并加上一个句号作为前缀,以产生相对于当前目录的路径。
function urlToPath(url) {
var path = require("url").parse(url).pathname;
return "." + decodeURIComponent(path);
}
若你担心urlToPath函数的安全性,那么你是正确的。我们将会在习题中回过头来看这个问题。
我们来创建GET方法,在读取目录时返回文件列表,在读取普通文件时返回文件内容。
一个棘手的问题是我们返回文件内容时添加的Content-Type头应该是什么类型。因为这些文件可以是任何内容,我们的服务器无法简单地对所有文件返回相同的类型。但NPM可以帮助我们完成该任务。mime包(以text/plain这种方式表示的内容类型名为MIME类型)可以获取大量文件扩展名的正确类型。
若你在服务器脚本所在目录中执行以下npm命令,你可以使用require(“mime”)来获取库。
$ npm install mime@1.4.0
npm http GET https://registry.npmjs.org/mime
npm http 304 https://registry.npmjs.org/mime
mime@1.4.0 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创建一个可读流,并将其传递给respond对象,同时使用mime模块根据文件名获取内容类型并传递给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响应向客户端报告任务成功完成。
完整的服务器脚本可以从http://eloquentjavascript.net/code/file_server.js获取。读者可以下载该脚本并使用Node启动你自己的文件服务器。当然你可以修改并扩展该脚本,以完成本章的习题或进行实验。
命令行工具curl在类Unix系统中得到广泛使用,可用于产生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请求再次找不到该文件。
20.10 错误处理
在文件服务器代码中,有6个位置我们显式传递异常,我们不知道如何处理异常。因为异常不会直接传播到回调中而是使用参数传递给回调,因此我们不得不每次都显式处理这些异常。这完全抵消了异常处理的优势,换言之,我们无法将错误处理集中到一起。
当一些代码在系统中抛出一个异常会发生什么?由于我们并未使用任何的try块,因此exception会直接传播到调用栈顶部。在Node中,这会导致程序停止运行并输出异常信息(包括堆栈轨迹)到程序的标准错误流中。
这就意味着我们的服务器在服务器代码内遇到任何问题都会崩溃,而相对的,异步问题会通过参数传递给回调。若我们想要处理所有在请求处理中引发的异常,为了确保我们可以发送响应,我们需要在每个回调中添加try/catch块。
这是无法工作的。许多Node程序都尽可能少使用异常,如果没有这种假设,当引发异常时,由于程序无法处理这些异常,正常的结果就是程序崩溃。
另一种方法是使用Promise,第17章中已经有过介绍。Promise可以捕捉由回调引发的异常,并将其作为错误向外层传播。我们可以在Node中加载promise库,并使用其管理异步控制。几乎没有Node库集成了promise,但包裹这些异常对象太烦琐了。而NPM提供了优秀的promise模块,包含一个名为denodeify的函数,用于将诸如fs.readFile之类的异步函数转换成返回promise的函数。
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);
});
为了进行比较,我基于promise编写了另一个版本的文件服务器,该文件服务器可以从http://eloquentjavascript.net/code/file_server_promises.js获取。由于现在函数可以直接返回其结果,而不必要调用回调,因此代码变得稍微优雅一点,而异常处理路径也变成隐式处理,而非显式处理。
这里列举出几行基于promise的文件服务器代码,展示两种程序设计风格的差别。
代码中使用的fsp对象使用Promise.denodeify包裹fs中的一系列函数,提供了promise风格的变体。方法处理器返回的对象包含code和body属性,是promise链中的最终结果,我们用其判断应该向客户端发送何种响应。
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),其他的错误依然继续传播。当这些处理器中返回的promise失败,HTTP服务器会响应500错误状态码。
20.11 本章小结
Node是一种优雅直接的系统,可以让我们在非浏览器环境中执行JavaScript。Node最初的设计意图是完成网络任务,扮演网络中的节点。但同时也能用来执行任何脚本任务,如果你觉得编写JavaScript代码是一件惬意的事情,那么使用Node来自动完成每天的任务是非常不错的。
NPM为你所能想到的功能(当然还有相当多你想不到的)提供了库,你可以通过执行简单的命令,获取并安装这些库。Node也附带了许多内建模块,包括fs模块(处理文件系统)、http模块(执行HTTP服务器并生成HTTP请求)。
Node中的所有输入输出都是异步的,除非你明确使用函数的同步变体,比如fs.readFileSync。使用者提供回调,Node会在适当的时候调用回调(比如I/O请求结束时)。
20.12 习题
20.12.1 再次设定内容属性
在第17章中,第一个习题是向http://eloquentjavascript.net/author/发送一些请求,通过传递不同的Accept头来请求不同类型的内容。
请使用Node的http.request函数再次完成该习题。至少需要请求以下几类媒体类型:text/plain、text/html和application/json。你可以直接给定一个对象表示请求头部,该对象通过http.request的第一个参数的headers属性传递。
输出每个请求的响应内容。
20.12.2 修复漏洞
我非常喜欢在自己的机器上运行本章定义的文件服务器,这样我可以很容易地远程访问某些在/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来解决该问题。请考虑Node在Windows上的运行情况,而Windows同时允许以斜杠和反斜杠作为目录分隔符。
同时也考虑一下,如果你在互联网上执行了没有充分考虑安全问题的系统而别人可以利用系统漏洞在你的机器上做一些危险的事情。
20.12.3 创建目录
尽管DELETE方法可以删除目录(使用了fs.rmdir),但文件服务器现在没有提供创建目录的方式。
为了添加对MKCOL方法的支持,读者可以调用fs.mkdir来创建目录。MKCOL并不是基本的HTTP方法,但其确实存在,且与我们的意图相同。该方法定义在WebDAV标准中,WebDAV制定了一系列的HTTP扩展,以适用于修改资源,而不仅仅是读取资源。
20.12.4 网络上的公共空间
由于文件服务器提供了任何类型的文件服务,甚至只要包含正确的Content-Type头部,你可以使用其提供网站服务。由于该服务允许每个人删除或替换文件,因此这是一类非常有趣的网站:任何人只要使用正确的HTTP请求,都可以修改、截取并破坏文件。但这仍然是一个网站。
请编写一个基础的HTML页面,包含一个简单的JavaScript文件。将该文件放在文件服务器的数据目录下,并在你的浏览器中打开这些文件。
接下来,作为进阶练习或是周末作业,将你迄今为止在本书中学习到的内容整合起来,构建一个对用户友好的界面,在网站内部修改网站。
使用一个HTML表单(第18章)编辑网站内部资源,运行用户通过第17章中描述的HTTP请求更新服务器软件。
刚开始的时候,该页面仅允许用户编辑单个文件,然后进行修改,允许选择想要编辑的文件。向文件服务器发送请求时,若URL是一个目录,服务器会返回该目录下的文件列表,你可以利用该特性实现你的网页。
不要直接编辑文件服务器的代码,如果你犯了什么错误,很有可能就破坏了你的代码。相反,将你的代码保存在公共访问目录之外,测试时再将其拷贝到公共目录中。
若你可以使用因特网直接连接你的计算机,而且没有防火墙、路由器或其他任何会干涉两台机器之间连接的设备,你可以邀请你的朋友使用你的网站。为了检查一下,你可以访问http://whatismyip.com/,复制网站显示的IP地址到你浏览器的地址栏中,并在其后添加:8000以选择正确端口。若这样可以访问你的网站,说明网络上的任何人都可以访问你的网站。