1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-28 23:42:22 +00:00
wizardforcel cfc37be281 20.
2018-06-01 11:24:15 +08:00

36 KiB
Raw Blame History

二十、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编写一些实用程序。

若读者想要运行本章中的代码,可以先访问http://nodejs.org/并遵从你使用的操作系统的安装指南。也可以浏览该网站获得Node及其内建模块的文档。

20.1 背景

编写通过网络通信的系统时,一个更困难的问题是管理输入输出,即通过网络、硬盘或其他设备读写数据。到处移动数据会耗费时间,而调度这些任务的技巧会使得系统在相应用户或网络请求时产生巨大的性能差异。

处理输入输出的传统方式是使用类似于readFile之类的函数开始读取文件并当文件读取完全结束时返回。这被称为同步I/OI/OInput/Output表示输入输出

Node最初是为简化异步I/O而设计的。我们前文中讨论过异步接口比如第17章中讨论的浏览器XMLHttpRequest对象。异步接口允许脚本在读写设备的同时继续执行并在任务完成时调用回调函数。Node中所有I/O都是以此种方式完成的。

JavaScript良好地借助了Node之类的系统。JavaScript是少数没有内建I/O处理方式的程序设计语言之一。因此JavaScript可以使用Node中古怪的I/O处理方式而不需要使用两种不一致的接口。2009年设计Node时人们常常在浏览器中进行基于回调的I/O处理因此JavaScript社区习惯了这种异步编程风格。

20.2 异步性

我将会以一个简单示例演示同步和异步I/O该程序需要从因特网中获取两个资源并对获取结果进行简单处理。

在同步环境中,执行该任务的一种显而易见的方式是逐个请求资源。该方法的缺点在于第二个请求必须在第一个请求结束后方能开始。其时间总计至少等于两个响应时间之和。这将不利于有效利用机器,因为通过网络发送接收数据时机器大部分时间是空闲的。

在同步系统中解决该问题的方案是启动一个额外的控制线程请参考第14章中讨论线程的部分。第二个线程可以启动第二个请求这两个线程都会等待请求返回结果随后两个线程再次同步以合并结果。

下图中的粗线表示程序正常运行花费时间细线表示等待I/O的时间。在同步模型中I/O消耗的实现是特定控制线程时间线的一部分。在异步模型中启动一个I/O动作会导致概念上的时间线分割。初始化I/O操作的线程可以继续运行I/O则会在一旁自行处理最后结束时调用回调函数。

若用另一种方式来描述这种差别可以说同步模式中隐含着等待I/O而在异步模型中则明确处于我们的控制管理之下。但异步性有其利弊。我们可以用其更轻松地表示非直线控制模型但在表现直线控制流程序时显得不太合适。

在第17章中我已经提及了一个事实即所有这类回调函数会使得程序变得混乱不直接。异步风格是否是一个好的通用方法是会引起争论的。但无论如何已经有许多程序员习惯了这个风格。

但对于基于JavaScript的系统而言我认为基于回调风格的异步处理是一个明智的选择。JavaScript的关键之一是其简单性而控制多线程会增加许多复杂性。虽然使用回调函数不一定能编写出简单的代码但对编写高性能网络服务器而言这已经足够惬意且强大了。

20.3 node命令

在系统中安装完Node.js后Node.js会提供一个名为node的程序该程序用于执行JavaScript文件。假设你有一个文件hello.js该文件会包含以下代码。

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

读者可以仿照下面这种方式通过命令行执行程序。

$ 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当前程序是成功完成代码为0还是遇到了错误其他代码

读者可以读取process.argv来获取传递给脚本的命令行参数该变量是一个字符串数组。请注意该数组包括了node命令和脚本名称因此实际的参数从索引2处开始。若showargv.js只包含一条console.logprocess.argv语句你可以这样执行该脚本。

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

所有标准JavaScript全局变量比如Array、Math以及JSON也都存在于Node环境中。而与浏览器相关的功能比如document与alert则不存在。

全局作用域对象在浏览器中名为window而在Node中则名为global。

20.4 模块

除了前文提到的一些变量比如console和processNode并没有在全局作用域中添加多少功能。如果你需要访问其他的内建功能可以通过system模块获取。

第10章中描述了基于require函数的CommonJS模块系统。该系统是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而不是向其中添加属性这允许我们从模块中导出特定的值。在本例中请求garble文件的结果是文本截取函数自身。

该函数会使用空字符串将给定字符串分割成单个字符并将每个字符的代码加5最后将所有字符合并成字符串。

我们可以看到我们的工具执行结果如下所示。

$ node main.js JavaScript
Of{fXhwnuy

20.5 使用NPM安装

第10章中简要地讨论过NPM该工具是一个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
figlet@1.0.9 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格式编码的程序或库的信息比如依赖于哪些库。在包含该文件的目录下执行nmp install会自动安装所有的依赖还包括依赖的依赖。NPM工具也可用来将库发布到NPM在线包仓库中这样其他人就可以查找、下载并使用它们。

本书不会继续深入探究NPM的使用细节。请读者自行参考http://npmjs.org/进一步阅读文档,也可以在该网站中轻松地搜索库。

20.6 文件系统模块

在Node中最常用的内建模块就是“fs”表示Filesystem文件系统模块。该模块提供了处理文件和目录的函数。

例如有个函数名为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编码文本因此除非有特殊原因确信文件使用了别的编码否则读取文件时使用“utf-8”是一种较为安全的方式。若你不传递任何编码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对象则writeFile会使用默认编码即UTF-8来输出文本。

“fs”模块也包含了其他实用函数其中readdir函数用于将目录中的文件以字符串数组的方式返回stat函数用于获取文件信息rename函数用于重命名文件unlink用于删除文件等。

相关细节请参见http://nodejs.org/中的文档。

fs模块中的许多函数都有异步与同步的两种变体。例如函数readFile的同步版本名为readFileSync。

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

使用同步函数较为节省代码在简单的脚本中非常实用不需要异步I/O提供的额外速度提升。但需要注意的是当执行同步操作时程序会完全停止。若程序需要对用户或网络上的其他机器做出响应可能因为同步I/O而产生令人厌烦的延迟。

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

若你在自己的机器上执行该脚本,你可以打开网页浏览器,并访问http://localhost8000/hello就会向你的服务器发出一个请求。服务器会响应一个简单的HTML页面。

每次客户端尝试连接服务器时服务器都会调用传递给createServer函数的参数。request和response变量都是对象分别表示输入数据和输出数据。request包含请求信息例如该对象的url属性表示请求的URL。

你需要调用response对象的方法以将一些数据发回客户端。第一个函数调用writeHead会输出响应头参见第17章。你需要向该函数传递状态码本例中200表示成功和一个对象该对象包含头部信息的值。本例中我们告诉客户端我们送回的是HTML文档。

接下来使用response.write来发送响应体文档自身。若你想一段一段地发送相应信息可以多次调用该方法在合适的时候会将数据发送到客户端。最后调用response.end发送相应结束信号。

调用server.listen会使服务器在8000端口上开始等待请求。这就是与服务器通信时需要连接localhost8000而不是localhost这样将会使用默认端口即80的原因。

这类Node脚本不会自动结束因为它会永远等待下一个事件在本例中是网络连接所以需要按Ctrl-C来停止脚本。

一个真实的Web服务器需要做的事情比上面的示例多得多。其差别在于我们需要根据请求的方法method属性来判断客户端尝试执行的动作并根据请求的URL来找出动作处理的资源。本章随后会介绍更高级的服务器。

你可以使用http模块的request函数来充当一个HTTP客户端。

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方法结束发送。本例中并没有使用wirte方法因为GET请求的请求体中无法包含数据。

Node提供了名为https的包用于发送安全的HTTPHTTPS请求其中包含了自己的request函数与http.request类似。

20.8 流

我们在HTTP中看过两个可写流的例子即服务器可以向response对象中写入数据而http.request返回的请求对象也可以写入数据。

可写流是Node接口中广泛使用的概念。所有的可写流都有一个write方法你可以传递字符串或Buffer对象。可写流的end方法用于关闭流如果给定一个参数该方法会在关闭流前输出指定的一段数据。这两个方法都可以使用一个回调函数作为额外参数当写入数据或关闭流完成后会调用用户指定的回调函数。

我们也可以使用fs.createWriteStream建立一个指向本地文件的输出流。你可以调用该方法返回的结果对象的write方法每次向文件中写入一段数据而不是像fs.writeFile那样一次性写入所有数据。

可读流则略为复杂。传递给HTTP服务器回调函数的request变量以及传递给HTTP客户端回调函数的response对象都是可读流服务器读取请求并写入响应而客户端则先写入请求然后读取响应。读取流需要使用事件处理器而不是方法。

Node中发出的事件都有一个on方法类似浏览器中的addEventListener方法。该方法接受一个事件名和一个函数并将函数注册到事件上接下来每当指定事件发生时都会调用注册的函数。

可读流有data事件和end事件。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);

传递给data处理函数的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函数会在输出的每段文本后加上额外的换行符在这里不太合适。

20.9 简单的文件服务器

让我们将新学习的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方法的函数。

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被传递给处理不同方法的函数作为请求结束时的回调函数。该方法接受三个参数第一个是状态码第二个是响应体第三个是可选的内容类型。若传递的响应体是一个可读流该对象有一个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以选择正确端口。若这样可以访问你的网站说明网络上的任何人都可以访问你的网站。