二十、Node.js
原文: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》
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/20-0.jpg
到目前为止,我们已经使用了 JavaScript 语言,并将其运用于单一的浏览器环境中。本章和下一章将会大致介绍 Node.js,该程序可以让读者将你的 JavaScirpt 技能运用于浏览器之外。读者可以运用 Node.js 构建应用程序,实现简单的命令行工具和复杂动态 HTTP 服务器。
这些章节旨在告诉你建立 Node.js 的主要概念,并向你提供信息,使你可以采用 Nodejs 编写一些实用程序。它们并不是这个平台的完整的介绍。
如果你想要运行本章中的代码,需要安装 Node.js 10 或更高版本。 为此,请访问 nodejs.org,并按照用于你的操作系统的安装说明进行操作。 你也可以在那里找到 Node.js 的更多文档。
背景
编写通过网络通信的系统时,一个更困难的问题是管理输入输出,即向/从网络和硬盘读写数据。到处移动数据会耗费时间,而调度这些任务的技巧会使得系统在相应用户或网络请求时产生巨大的性能差异。
在这样的程序中,异步编程通常是有帮助的。 它允许程序同时向/从多个设备发送和接收数据,而无需复杂的线程管理和同步。
Node最初是为了使异步编程简单方便而设计的。 JavaScript 很好地适应了像 Node 这样的系统。 它是少数几种没有内置输入和输出方式的编程语言之一。 因此,JavaScript 可以适应 Node 的相当古怪的输入和输出方法,而不会产生两个不一致的接口。 在 2009 年设计 Node 时,人们已经在浏览器中进行基于回调的编程,所以该语言的社区用于异步编程风格。
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
则不存在。
模块
除了前文提到的一些绑定,比如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
,并定义了一个脚本,可以从命令行调用来反转字符串。
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,是一个 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
来获取更多文档和搜索包的方法。
文件系统模块
在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"));
请注意,在执行这样的同步操作时,程序完全停止。 如果它应该响应用户或网络中的其他计算机,那么可在同步操作中可能会产生令人讨厌的延迟。
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
)会输出响应头(参见第十七章)。你需要向该函数传递状态码(本例中 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
方法结束发送。本例中并没有使用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
函数创建一个可读流,来读取本地文件。
这段代码创建了一个服务器并读取请求正文,然后将读取到的数据全部转换成大写,并使用流写回客户端。
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 错误响应,该代码表示服务器拒绝处理特定的方法。
当请求处理程序的Promise
受到拒绝时,catch
调用会将错误转换为响应对象(如果它还不是),以便服务器可以发回错误响应,来通知客户端它未能处理请求。
响应描述的status
字段可以省略,这种情况下,默认为 200(OK)。 type
属性中的内容类型也可以被省略,这种情况下,假定响应为纯文本。
当body
的值是可读流时,它将有pipe
方法,用于将所有内容从可读流转发到可写流。 如果不是,则假定它是null
(无正文),字符串或缓冲区,并直接传递给响应的end
方法。
为了弄清哪个文件路径对应于请求URL,urlPath
函数使用 Node 的url
内置模块来解析 URL。 它接受路径名,类似"/file.txt"
,将其解码来去掉%20
风格的转义代码,并相对于程序的工作目录来解析它。
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
函数,来找出特定文件是否存在以及是否是一个目录。
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
请求的代码就稍显简单了。
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
请求的处理器。
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
。读者可以下载该脚本,并且在安装依赖项之后,使用 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是一个目录,服务器会返回该目录下的文件列表,你可以利用该特性实现你的网页。
不要直接编辑文件服务器开放的代码,如果你犯了什么错误,很有可能就破坏了你的代码。相反,将你的代码保存在公共访问目录之外,测试时再将其拷贝到公共目录中。