NodeJS-开发者高级教程-二-

82 阅读41分钟

NodeJS 开发者高级教程(二)

原文:Pro Node.js for Developers

协议:CC BY-NC-SA 4.0

五、命令行界面

前四章向您展示了 Node 开发的基础。从这一章开始,这本书改变了方向,开始关注用于创建 Node 应用的各种 API 和模块。本章重点介绍如何创建命令行界面(CLI)来与用户进行交互。首先,您将学习 Node 内置 API 的命令行基础。从那里,你可以使用commander模块扩展基础,你可能记得在第二章中的几个npm例子。

命令行参数

命令行参数是向计算机程序提供输入的最基本的方式之一。在 Node 应用中,命令行参数可以通过全局process对象的argv数组属性 来访问。清单 5-1 展示了如何使用forEach()方法迭代argv,就像任何其他数组一样。

清单 5-1 。一个迭代argv数组的例子

process.argv.forEach(function(arg, index) {
  console.log("argv[" + index + "] = " + arg);
});

为了检查保存在argv中的实际值,将来自清单 5-1 的代码保存在一个名为argv-test.js 的新 JavaScript 源文件中。接下来,运行代码,观察输出(参见清单 5-2 )。注意,有四个参数被传递给了我们的 Node 程序:-foo3--bar=4-baz。然而,基于程序的输出,在argv中有六个元素。无论您提供什么样的命令行参数组合,argv总是在数组的开头包含额外的两个元素。这是因为argv的前两个元素总是node(可执行文件的名称)和 JavaScript 源文件的路径。argv数组的其余部分由实际的命令行参数组成。

清单 5-2 。运行清单 5-1 中代码的输出

$ node argv-test.js -foo 3 --bar=4 -baz
argv[0] = node
argv[1] = /home/colin/argv-test.js
argv[2] = -foo
argv[3] = 3
argv[4] = --bar=4
argv[5] = -baz

解析参数值

基于清单 5-2 中的命令行,我们似乎试图传入三个参数:foobarbaz。然而,这三个论点的作用各不相同。foo的值来自它后面的自变量(我们假设它是一个整数)。在这种情况下,foo的值是3。与foo不同的是,bar4的值被编码在同一个参数中,后面跟一个等号。同时,baz是一个布尔自变量。如果提供了参数,则其值为true,否则为false。不幸的是,通过简单地检查argv中的值,这些语义都没有被捕获。

为了提取正确的命令行参数值,我们可以开发一个定制的解析器(见清单 5-3 )。在示例中,parseArgs()函数 负责解析命令行、提取值并返回一个对象,该对象将每个参数映射到其正确的值。这个函数的工作方式是循环遍历argv中的每个元素,检查可识别的参数名。如果参数是foo,那么从下面的参数中解析出一个整数。循环变量i也被递增以节省时间,因为没有必要为foo的值执行循环体。如果自变量被确定为baz,我们简单的赋值true。为了提取bar的值,使用了一个正则表达式。如果字符串--bar=后跟一系列一个或多个数字,那么这些数字将被解析为一个整数值。最后,所有的参数都通过args对象返回,并打印到控制台。

清单 5-3 。清单 5-2 中示例的命令行解析器

function parseArgs() {
  var argv = process.argv;
  var args = {
    baz: false
  };

  for (var i = 0, len = argv.length; i < len; i++) {
    var arg = argv[i];
    var match;

    if (arg === "-foo") {
      args.foo = parseInt(argv[++i]);
    } else if (arg === "-baz") {
      args.baz = true;
    } else if (match = arg.match(/--bar=(\d+)/)) {
      args.bar = parseInt(match[1]);
    }
  }

  return args;
}

var args = parseArgs();

console.log(args);

清单 5-4 显示了运行清单 5-3 中代码的输出。如你所见,所有的论点都被恰当地提取出来了。但是当用户输入格式错误时会发生什么呢?清单 5-5 显示了使用不同参数运行相同程序的输出。在这种情况下,baz被拼错为az,用户忘记为foo提供一个值。

清单 5-4 。运行清单 5-3 中代码的结果

$ node argv-parser.js -foo 3 --bar=4 -baz
{ foo: 3, bar: 4, baz: true }

清单 5-5 。由畸形的用户输入产生的输出

$ node argv-parser.js -foo -az --bar=4
{ foo: NaN, bar: 4 }

在清单 5-5 的输出中,请注意baz完全缺失,而foo的值为NaN(非数字),因为解析器试图将-az转换为整数。由于baz没有从命令行传入,理想情况下它的值是false。类似地,foobar应该有一些默认值,以便处理这样的情况。在这种情况下,预填充parseArgs()中的args对象不会阻止foo被设置为NaN

相反,我们可以使用一个sanitize()函数对args进行后处理(参见清单 5-6 )。这个函数检查每个参数的值,如果它还没有值,就给它分配一个合适的值。在这个例子中,JavaScript 内置的isFinite()方法用于确保foobar是有效的整数。由于baz是一个布尔值,代码简单地检查它是否不等于true,如果是,就将其设置为false。这确保了baz实际上被设置为布尔值false——而不是保留为undefined,这是一个不同的 falsy 值。注意parseArgs()代码不包括在本例中,因为它没有改变。

清单 5-6 。为参数分配默认值的sanitize()函数

function sanitize(args) {
  if (!isFinite(args.foo)) {
    args.foo = 0;
  }

  if (!isFinite(args.bar)) {
    args.bar = 0;
  }

  if (args.baz !== true) {
    args.baz = false;
  }

  return args;
}

var args = sanitize(parseArgs());

console.log(args);

commander 中的命令行参数

如果实现简单的命令行解析所需的工作量对您来说似乎有点多,请放心,您并不孤单。幸运的是,像commander这样的模块使得命令行解析变得简单。第三方模块commander用于简化常见的 CLI 任务,如参数解析和读取用户输入。要安装commander,使用命令npm install commander。为了适应命令行参数解析,commander 提供了option()parse()方法。对option()的每次调用都向commander注册一个有效的命令行参数。一旦使用option()注册了所有可能的参数,就可以使用parse()方法从命令行提取参数值。

用一个例子来说明commander的命令行参数系统是如何工作的可能是最简单的。在清单 5-7 中,commander被配置为接受三个参数:--foo--bar--baz。也可以使用-f来指定--foo参数。这被认为是论点的简短版本。所有的commander参数必须有一个短名称和一个长名称。短名称应该是一个破折号后跟一个字母,长名称应该在名称前有两个破折号。

清单 5-7 。使用commander的命令行解析器示例

var commander = require("commander");

commander
  .option("-f, --foo <i>", "Integer value for foo", parseInt, 0)
  .option("-b, --bar [j]", "Integer value for bar", parseInt, 0)
  .option("-z, --baz", "Boolean argument baz")
  .parse(process.argv);

console.log(commander.foo);
console.log(commander.bar);
console.log(commander.baz);

注意--foo--bar后面的<i>[j]。这些是应该跟在参数后面的值。当使用尖括号时,就像使用--foo一样,必须指定附加值,否则会抛出一个错误。与--bar一起使用的方括号表示附加值是可选的。--baz被视为布尔参数,因为它不接受任何附加参数。参数字符串之后是描述字符串。这些字符串是人类可读的,用于显示帮助,这将被暂时覆盖。

接下来要指出的是,--foo--bar选项也指parseInt()和数字 0(零)。parseInt()作为可选参数传递,用于解析附加参数。在这种情况下,--foo--bar的值被评估为整数。最后,如果没有为--foo--bar提供值,它们将被设置为 0。

一旦注册了所有选项,就调用parse()来处理命令行。从技术上讲,任何数组都可以传递给parse(),但是传入process.argv最有意义。解析后,参数值根据它们的长名称可用,如三个 print 语句所示。

自动生成的帮助

commander根据选项配置自动生成一个--help(或-h)自变量。清单 5-8 显示了前一个例子中自动生成的帮助。

清单 5-8 。为清单 5-7 中的代码自动生成帮助

$ node commander-test.js --help

  Usage: commander-test.js [options]

  Options:

    -h, --help         output usage information
    -f, --foo <i>      Integer value for foo
    -b, --bar [j]      Integer value for bar
    -z, --baz          Boolean argument baz

还有两种方法可以用来显示帮助:help()outputHelp()。它们之间唯一的区别是help()会导致程序退出,而outputHelp()不会。通常,如果提供了无效的参数,您可以调用help(),然后退出。但是,如果你想显示帮助菜单并出于某种原因继续执行,你可以调用outputHelp()。这两种方法的使用如清单 5-9 所示。

清单 5-9 。使用commander帮助方法

commander.help()
commander.outputHelp()

标准流

默认情况下,Node 应用连接到提供输入和输出功能的三个数据流— stdinstdoutstderr。如果您熟悉 C/C++、Java 或任何一种其他语言,您肯定以前遇到过这些标准流。本节将详细探讨每一个问题。

标准输入

stdin流(标准输入的缩写)是一个可读的流,为程序提供输入。默认情况下,stdin从用于启动应用的终端窗口接收数据,and通常用于在运行时接受用户的输入。然而,stdin也可以从一个文件或另一个程序接收它的数据。

在 Node 应用中,stdin是全局process对象的属性。但是,当应用启动时,stdin处于暂停状态,也就是说,不能从中读取任何数据。对于要读取的数据,必须使用resume()方法对数据流进行解析(见清单 5-10 ),该方法不带参数,也不提供返回值。

清单 5-10stdin.resume() 的用法

process.stdin.resume()

除了解除对stdin流的暂停,resume()还防止应用终止,因为它将处于等待输入的状态。然而,stdin可以再次暂停,使用pause()方法,允许程序退出。清单 5-11 显示了pause()的用法。

清单 5-11stdin.pause() 的用法

process.stdin.pause()

调用resume()后,你的程序可以从stdin读取数据。但是,您需要设置一个data事件处理程序来自己读取数据。stdin上新数据的到达触发了一个data事件。data事件处理程序接受一个参数,即接收到的数据。在清单 5-12 中,显示了如何使用data事件从stdin读取数据,提示用户输入他/她的名字。然后调用resume()以激活stdin流。一旦输入了名字,用户按下Return,就会调用data事件处理程序——使用once()方法添加的(在第四章中介绍)。然后事件处理器确认用户并暂停stdin。注意,在事件处理程序中,data参数被转换成一个字符串。这样做是因为data是作为Buffer对象传入的。用于处理 Node 应用中的原始二进制数据。(该主题在第八章的中有更详细的介绍。)

清单 5-12 。从stdin读取数据的示例

process.stdin.once("data", function(data) {
  var response = data.toString();

  console.log("You said your name is " + response);
  process.stdin.pause();
});

console.log("What is your name?");
process.stdin.resume();

通过预先指定stdin流的字符编码,可以避免每次读取数据时都必须将数据转换成字符串。为此,请使用stdinsetEncoding()方法。如表 5-1 所示,Node 支持许多不同的字符编码。处理字符串数据时,建议将编码设置为utf8 (UTF-8)。清单 5-13 展示了如何使用setEncoding()重写清单 5-12 。

表 5-1 。Node 支持的各种字符串编码类型

|

编码类型

|

描述

| | --- | --- | | utf8 | 多字节编码的 Unicode 字符。UTF-8 编码被许多网页使用,并用于表示 Node 中的字符串数据。 | | ascii | 七位美国信息交换标准码(ASCII)编码。 | | utf16le | 小端编码的 Unicode 字符。每个字符是两个或四个字节。 | | ucs2 | 这只是utf16le编码的别名。 | | base64 | Base64 字符串编码。Base64 通常用于 URL 编码、电子邮件和类似的应用。 | | binary | 允许仅使用每个字符的前八位将二进制数据编码为字符串。这种编码现在已被弃用,取而代之的是Buffer对象,并将在 Node 的未来版本中删除。 | | hex | 将每个字节编码为两个十六进制字符。 |

清单 5-13 。设置字符编码类型后从stdin读取

process.stdin.once("data", function(data) {
  console.log("You said your name is " + data);
  process.stdin.pause();
});

console.log("What is your name?");
process.stdin.setEncoding("utf8");
process.stdin.resume();

使用commanderstdin读取

commander模块还提供了几种从stdin读取数据的有用方法。其中最基本的是prompt(),它向用户显示一些消息或问题,然后读入响应。然后将响应作为字符串传递给回调函数进行处理。清单 5-14 展示了如何使用prompt()重写来自清单 5-13 的例子。

清单 5-14 。使用commanderprompt()方法从stdin读取

var commander = require("commander");

commander.prompt("What is your name? ", function(name) {
  console.log("You said your name is " + name);
  process.stdin.pause();
});

confirm()

confirm()方法与prompt()相似,但用于解析布尔响应。如果用户输入yyestrue或 ok,回调将被调用,其参数设置为true。否则,回调将被调用,其参数设置为false。清单 5-15 中显示了confirm()方法的一个使用示例,清单 5-16 显示了该示例的示例输出。

清单 5-15 。使用commanderconfirm()方法解析布尔响应

var commander = require("commander");

commander.confirm("Continue? ", function(proceed) {
  console.log("Your response was " + proceed);
  process.stdin.pause();
});

清单 5-16 。运行清单 5-15 中代码的输出示例

$ node confirm-example.js
Continue? yes
Your response was true

password()

prompt()的另一个特例是password()方法,它用于获取敏感的用户输入,而不在终端窗口中显示。顾名思义,它最大的用例是提示用户输入密码。清单 5-17 中的显示了一个使用password()的例子。

清单 5-17 。使用password()方法提示输入密码

var commander = require("commander");

commander.password("Password: ", function(password) {
  console.log("I know your password!  It's " + password);
  process.stdin.pause();
});

默认情况下,password()不会将信息回显到终端。但是,可以提供一个可选的掩码字符串,它会为用户输入的每个字符回显。清单 5-18 显示了一个例子。其中,掩码字符串只是星号字符(*)。

清单 5-18 。使用掩码字符提示输入密码

var commander = require("commander");

commander.password("Password: ", "*", function(password) {
  console.log("I know your password!  It's " + password);
  process.stdin.pause();
});

choose()

choose()功能对于创建基于文本的菜单很有用。以一组选项作为第一个参数,choose()允许用户从列表中选择一个选项。第二个参数是用所选选项的数组索引调用的回调。清单 5-19 显示了一个使用choose()的例子。

清单 5-19 。使用choose()显示文本菜单

var commander = require("commander");
var list = ["foo", "bar", "baz"];

commander.choose(list, function(index) {
  console.log("You selected " + list[index]);
  process.stdin.pause();
});

清单 5-20 显示了运行前一个例子的样本输出。需要注意的一点是,菜单项计数从 1 开始,而数组从 0 开始索引。考虑到这一点,choose()将正确的从零开始的数组索引传递给回调函数。

清单 5-20 。清单 5-19 的输出示例

$ node choose-example.js
  1) foo
  2) bar
  3) baz
  : 2
You selected bar

标准输出

标准输出,或stdout ,是一个可写的流,程序应该将它们的输出指向这个流。默认情况下,Node 应用直接输出到启动应用的终端窗口。向stdout写入数据的最直接方式是通过process.stdout.write()方法。write()的用法如清单 5-21 所示。write()的第一个参数是要写入的数据字符串。第二个参数是可选的;用于指定数据的字符编码,默认为utf8 (UTF-8)编码。write()支持表 5-1 中指定的所有编码类型。write()的最后一个参数是可选的回调函数。一旦数据成功写入stdout,就会执行该命令。没有参数传递给回调函数。

清单 5-21stdout的使用。write()方法

process.stdout.write(data, [encoding], [callback])

image process.stdout.write()也可以接受一个Buffer作为它的第一个自变量。

console.log()

阅读完stdout.write()之后,你可能会好奇它与已经讨论过的console.log()方法有什么关系。实际上,console.log()只是一个在引擎盖下调用stdout.write()的包装器。清单 5-22 显示了console.log()的源代码。这段代码直接取自 Node 官方 GitHub repo 中的文件https://github.com/joyent/node/blob/master/lib/console.js。如您所见,log()调用了_stdout.write()。检查整个源文件会发现_stdout只是对stdout的引用。

清单 5-22console.log()的源代码

Console.prototype.log = function() { this._stdout.write(util.format.apply(this, arguments) + '\n');
};

另外,注意对write()的调用调用了util.format()方法。util对象是对核心util模块的引用。format()方法用于根据传递给它的参数创建格式化字符串。作为第一个参数,format()接受一个包含零个或多个占位符的格式字符串。占位符是格式字符串中的一个字符序列,预计将被返回的字符串中的不同值替换。在格式字符串之后,format()期望每个占位符都有一个额外的参数。format()支持四种占位符,如表 5-2 所述。

表 5-2 。util.format()支持的各种占位符。

|

占位符

|

更换

| | --- | --- | | %s | 字符串数据。一个参数被使用并传递给String()构造函数。 | | %d | 整数或浮点数字数据。一个参数被使用并传递给Number()构造函数。 | | %j | JSON 数据。一个参数被消费并传递给JSON.stringify()。 | | %% | 一个百分号(%)字符。这不会消耗任何参数。 |

清单 5-23 中显示了util.format()的几个例子,清单 5-24 中显示了的结果输出。这些示例显示了如何使用各种占位符替换数据。前三个示例使用字符串、数字和 JSON 占位符来替换字符串。请注意,数字占位符被替换为NaN。这是因为保存在name变量中的字符串不能被转换成实际数字。在第四个例子中,使用了 JSON 占位符,但是没有相应的参数传递给format()。结果就是没有替换发生,并且%j包含在结果中。在第五个例子中,format()比它能处理的多传递了一个参数。format()通过将附加参数转换为字符串并附加到结果字符串中来处理附加参数,使用空格字符作为分隔符。在第六个示例中,按照预期使用了多个占位符。最后,在第七个示例中,根本没有提供任何格式字符串。在这种情况下,参数被转换为字符串,并用空格字符分隔符连接起来。

清单 5-23 。使用util.format()的几个例子

var util = require("util");
var name = "Colin";
var age = 100;
var format1 = util.format("Hi, my name is %s", name);
var format2 = util.format("Hi, my name is %d", name);
var format3 = util.format("Hi, my name is %j", name);
var format4 = util.format("Hi, my name is %j");
var format5 = util.format("Hi, my name is %j", name, name);
var format6 = util.format("I'm %s, and I'm %d years old", name, age);
var format7 = util.format(name, age);

console.log(format1);
console.log(format2);
console.log(format3);
console.log(format4);
console.log(format5);
console.log(format6);
console.log(format7);

清单 5-24 。运行清单 5-23 中的代码的输出

$ node format.js
Hi, my name is Colin
Hi, my name is NaN
Hi, my name is "Colin"
Hi, my name is %j
Hi, my name is "Colin" Colin
I'm Colin, and I'm 100 years old
Colin 100

image 注意任何熟悉 C/C++、PHP 或其他语言的人都会认识到util.format()的行为,因为它提供了类似于printf()函数的格式。

其他打印功能

Node 还提供了几个不太流行的函数来打印到stdout。例如,util模块定义了log()方法。log()方法接受一个单独的字符串作为参数,并把它和时间戳一起打印给stdout。清单 5-25 显示了log()的一个实例。结果输出如清单 5-26 所示。

清单 5-25util.log()的一个例子

var util = require("util");

util.log("baz");

清单 5-26 。运行清单 5-25 中的代码的输出

$ node util-log-method.js
17 Mar 15:08:29 - baz

console对象还提供了两种额外的打印方法,info()dir()info()方法只是console.log()的别名。console.dir()将一个对象作为其唯一参数。使用util.inspect()方法将对象字符串化,然后打印到stdoututil.inspect()是用于将多余的参数字符串化到没有相应占位符的util.format()的相同方法。inspect(),一个强大的字符串化数据的方法,将在下面介绍。

util.inspect()

util.inspect()用于将对象转换成格式良好的字符串。虽然它真正的强大之处在于它的定制能力,但我们首先来看看它的默认行为。清单 5-27 显示了一个使用inspect()字符串化一个对象obj的例子。结果字符串如清单 5-28 中的所示。

清单 5-27 。一个使用util.inspect()方法的例子

var util = require("util");
var obj = {
  foo: {
    bar: {
      baz: {
        baff: false,
        beff: "string value",
        biff: null
      },
      boff: []
    }
  }
};

console.log(util.inspect(obj));

清单 5-28 。清单 5-27 中的util.inspect()创建的字符串

{ foo: { bar: { baz: [Object], boff: [] } } }

注意foobar是完全字符串化的,但是baz只显示字符串[Object]。这是因为,默认情况下,inspect()在格式化对象时只通过两级递归。不过,这种行为可以通过使用可选的第二个参数inspect()来改变。该参数是一个指定inspect()配置选项的对象。如果你对增加递归的深度感兴趣,设置depth选项。它可以设置为null来强制inspect()在整个对象上递归。清单 5-29 和清单 5-30 中显示了这样的例子和结果字符串。

清单 5-29 。在启用完全递归的情况下调用util.inspect()

var util = require("util");
var obj = {
  foo: {
    bar: {
      baz: {
        baff: false,
        beff: "string value",
        biff: null
      },
      boff: []
    }
  }
};

console.log(util.inspect(obj, {
  depth: null
}));

清单 5-30 。运行清单 5-29 中的代码的输出

$ node inspect-recursion.js
{ foo:
   { bar:
      { baz: { baff: false, beff: 'string value', biff: null },
        boff: [] } } }

options参数支持其他几个选项— showHiddencolorscustomInspectshowHiddencolors默认为false,而customInspect默认为true。当showHidden设置为true时,inspect()打印对象的所有属性,包括不可枚举的属性。将colors设置为true会导致结果字符串采用 ANSI 颜色代码。当customInspect设置为true时,对象可以定义自己的inspect()方法,调用这些方法可以返回字符串化过程中使用的字符串。在这个例子中,如清单 5-31 所示,一个自定义的inspect()方法被添加到顶层对象中。此自定义方法返回隐藏所有子对象的字符串。结果输出如清单 5-32 所示。

image 注意并不是所有的方法属性都是相同的。在 JavaScript 中,可以创建不可枚举的属性,当一个对象在for...in循环中迭代时,这些属性不会显示出来。通过设置showHidden选项,inspect()将在其输出中包含不可枚举的属性。

清单 5-31 。使用自定义的inspect()方法调用util.inspect()

var util = require("util");
var obj = {
  foo: {
    bar: {
      baz: {
        baff: false,
        beff: "string value",
        biff: null
      },
      boff: []
    }
  },
  inspect: function() {
    return "{Where'd everything go?}";
  }
};

console.log(util.inspect(obj));

清单 5-32 。清单 5-31 中自定义inspect()方法的结果

$ node inspect-custom.js
{Where'd everything go?}

标准误差

标准误差流stderr是类似于stdout的输出流。然而,stderr用于显示错误和警告信息。虽然stderrstdout是相似的,stderr是一个独立的实体,所以你不能像console.log()一样使用stdout函数来访问它。幸运的是,Node 提供了许多专门用于访问stderr的函数。对stderr最直接的访问路径是通过它的write()方法。write()的用法如清单 5-33 所示,与stdoutwrite()方法相同。

清单 5-33 。使用stderr write()方法

process.stderr.write(data, [encoding], [callback])

console对象还提供了两个方法error()warn(),用于写入stderrconsole.warn()的行为与console.log()完全一样,只是充当了process.stderr.write()的包装器。error()方法只是warn()的别名。清单 5-34 显示了warn()error()的源代码。

清单 5-34console.warn()console.error() 的源代码

Console.prototype.warn = function() {
  this._stderr.write(util.format.apply(this, arguments) + '\n');
};

Console.prototype.error = Console.prototype.warn;

console.trace()

console对象还提供了一个有用的调试方法,名为trace(),它创建并打印一个堆栈跟踪到stderr,而不会使程序崩溃。如果您曾经遇到过错误(我相信您现在已经遇到过了),那么您就会看到程序崩溃时打印的堆栈跟踪。trace()完成同样的事情,没有错误和崩溃。清单 5-35 显示了一个使用trace()的例子,其输出显示在清单 5-36 中。在示例中,名为test-trace的堆栈跟踪是在函数baz()中创建的,该函数从bar()中调用,而后者又从foo()中调用。请注意,这些函数是堆栈跟踪中的前三项。堆栈跟踪中的其余函数是由 Node 框架进行的调用。

清单 5-35 。使用console.trace() 生成示例堆栈跟踪

(function foo() {
  (function bar() {
    (function baz() {
      console.trace("test-trace");
    })();
  })();
})();

清单 5-36 。运行清单 5-35 中的示例的输出

$ node stack-trace.js
Trace: test-trace
    at baz (/home/colin/stack-trace.js:4:15)
    at bar (/home/colin/stack-trace.js:5:7)
    at foo (/home/colin/stack-trace.js:6:5)
    at Object.<anonymous> (/home/colin/stack-trace.js:7:3)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)

image 传递给console.trace()的自变量被转发给util.format()。因此,可以使用格式字符串创建堆栈跟踪名称。

分离stderrstdout

stderr定向到与stdout相同的目的地是常见的,但不是必需的。默认情况下,Node 的stdoutstderr都指向运行流程的终端窗口。但是,可以重定向一个流或两个流。清单 5-37 中的代码可以用来简单地演示这个概念。示例代码使用console.log()stdout输出一条消息,使用console.error()stderr输出第二条消息。

清单 5-37 。打印到stdoutstderr的示例应用

console.log("foo");
console.error("bar");

当清单 5-37 中的代码正常运行时,两条消息都被打印到终端窗口。输出如清单 5-38 所示。

清单 5-38 。运行清单 5-37 中的代码时的控制台输出

$ node stdout-and-stderr.js
foo
bar

同样的代码在清单 5-39 中再次执行。然而,这次使用>操作符将stdout重定向到文件output.txt。注意重定向对stderr流没有影响。结果是发送到stderrbar打印在终端窗口,而foo没有。

清单 5-39 。当stdout被重定向时清单 5-39 中代码的控制台输出

$ node stdout-and-stderr.js > output.txt
bar

image 注意您可能已经注意到了,console方法是同步的。这种行为(当底层流的目的地是文件或终端窗口时的默认行为)避免了由于程序崩溃或退出而丢失消息。在第七章中有更多关于流和它们如何被管道化的内容,但是现在,只需要知道当底层流被管道化时console方法的行为是异步的。

TTY 界面

正如您已经看到的,默认情况下,标准流被配置为使用终端窗口。为了适应这种配置,Node 提供了一个 API 来检查终端窗口的状态。因为流可以被重定向,所以所有标准流都提供了一个isTTY属性,如果流与终端窗口相关联,那么这个属性就是true。清单 5-40 显示了如何为每个流访问这些属性。默认情况下,isTTYstdinstdoutstderrtrue,如清单 5-41 所示。

清单 5-40 。检查每个标准流是否连接到终端的示例

console.warn("stdin  = " + process.stdin.isTTY);
console.warn("stdout = " + process.stdout.isTTY);
console.warn("stderr = " + process.stderr.isTTY);

清单 5-41 。默认条件下清单 5-40 的输出

$ node is-tty.js
stdin  = true
stdout = true
stderr = true

清单 5-42 展示了当stdout被重定向到一个文件时,这些值是如何变化的。注意源代码使用了console.warn()而不是console.log()。这是有意这样做的,以便stdout可以被重定向,同时仍然提供控制台输出。如你所料,isTTY的值不再是stdouttrue。然而,请注意isTTY不是false,而是简单的undefined,这意味着isTTY不是所有流的属性,只是那些与终端相关的流的属性。

清单 5-42 。来自清单 5-40 的输出,带有重定向的stdout

$ node is-tty.js > output.txt
stdin  = true
stdout = undefined
stderr = true

确定终端尺寸

终端窗口的大小,尤其是列数,会极大地影响程序输出的可读性。因此,一些应用可能需要根据终端大小定制输出。假设stdoutstderr或两者都与终端窗口相关联,则可以确定终端中的行数和列数。这些信息可以分别通过流的rowscolumns属性获得。您还可以使用流的getWindowSize()方法以数组的形式检索终端维度。列表 5-43 显示了如何确定端子尺寸,而列表 5-44 显示了最终输出。

清单 5-43 。以编程方式确定终端窗口的大小

var columns = process.stdout.columns;
var rows = process.stdout.rows;

console.log("Size:  " + columns + "x" + rows);

清单 5-44 。运行清单 5-43 中的代码的输出

$ node tty-size.js
Size:  80x24

image 注意使用stdin无法确定终端大小,因为终端尺寸仅与可写 TTY 流相关。

如果你的程序的输出依赖于终端的大小,那么当用户在运行时调整窗口大小时会发生什么?幸运的是,可写 TTY 流提供了一个resize事件,该事件在终端窗口调整大小时触发。清单 5-45 中的例子定义了一个函数size(),它打印出当前的端子尺寸。启动时,程序首先检查stdout是否连接到终端窗口。如果不是,将显示一条错误消息,并且程序通过调用process.exit()方法以一个错误代码终止。如果程序在终端窗口中运行,它会通过调用size()来显示窗口的当前大小。相同的函数随后被用作resize事件处理程序。最后,调用process.stdin.resume()来防止程序在测试时终止。

清单 5-45 。监控终端大小的示例

function size() {
  var columns = process.stdout.columns;
  var rows = process.stdout.rows;

  console.log("Size:  " + columns + "x" + rows);
}

if (!process.stdout.isTTY) {
  console.error("Not using a terminal window!");
  process.exit(-1);
}

size();
process.stdout.on("resize", size);
process.stdin.resume();

信号事件

信号是发送给特定进程或线程的异步事件通知。它们用于在符合 POSIX 的操作系统上提供有限形式的进程间通信。(如果您正在为 Windows 开发,您可能希望跳过这一部分。)所有信号及其含义的完整列表超出了本书的范围,但这些信息在互联网上很容易找到。

例如,如果您在终端程序运行时按下Ctrl+C,一个中断信号SIGINT将被发送到该程序。在 Node 应用中,除非提供了自定义处理程序,否则信号由默认处理程序处理。当默认处理程序接收到一个SIGINT信号时,它会导致程序终止。要覆盖这种行为,向process对象添加一个SIGINT事件处理程序,如清单 5-46 中的所示。

清单 5-46 。添加一个SIGINT信号事件处理器

process.on("SIGINT", function() {
  console.log("Got a SIGINT signal");
});

image 注意如果你在你的应用中包含了来自清单 5-46 的事件处理程序,你将无法使用Ctrl+C终止程序。但是,您仍然可以使用Ctrl+D停止程序。

用户环境变量

环境变量是操作系统级别的变量,可由系统上执行的进程访问。例如,许多操作系统定义了一个TEMPTMP环境变量,它指定了用于保存临时文件的目录。在 Node 中访问环境变量非常简单。process对象有一个包含用户环境的对象属性envenv对象可以像任何其他对象一样进行交互。清单 5-47 显示了如何引用env对象。在本例中,显示了PATH变量。然后在PATH的开头添加一个额外的 Unix 风格的目录。最后显示刚更新的PATH。清单 5-48 显示了这个例子的输出。但是,请注意,根据您当前的系统配置,您自己的输出可能会有很大的不同。

清单 5-47 。使用用户环境变量的示例

console.log("Original: " + process.env.PATH);
process.env.PATH = "/some/path:" + process.env.PATH;
console.log("Updated:   " + process.env.PATH);

清单 5-48 。运行清单 5-47 中代码的输出示例

$ node env-example.js
Original:  /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
Updated:   /some/path:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

环境变量通常用于配置应用中不同的执行模式。例如,一个程序可能支持两种执行模式,开发和生产。在开发模式下,调试信息可能会打印到控制台,而在生产模式下,调试信息可能会记录到文件中或被完全禁用。要启用开发模式,只需设置一个环境变量,该变量可以从应用内部访问。清单 5-49 展示了这个概念是如何工作的。在这个例子中,DEVELOPMENT环境变量被用来定义布尔变量devMode,然后控制if语句的条件。注意,!! (bang bang)符号用于强制将任何值转换为布尔值。

清单 5-49 。使用环境变量实现开发模式的一个例子

var devMode = !!process.env.DEVELOPMENT;

if (devMode) {
  console.log("Some useful debugging information");
}

清单 5-50 显示了在开发模式下执行前面例子的一种方法。请注意,如何在启动 Node 的同一个命令提示符下定义环境变量,从而实现快速的一次性测试,避免了实际定义环境变量的麻烦。(不过,那也可以。)

清单 5-50 。在开发模式下运行清单 5-51 中的例子

$ DEVELOPMENT=1 node dev-mode.js
Some useful debugging information

摘要

本章介绍了 Node 中命令行界面编程的基础知识。一些例子甚至展示了来自 Node 核心的实际代码。现在,您应该已经掌握了命令行参数、标准流、信号处理程序和环境变量等基本概念。这些概念集合了一些已经介绍过的内容(比如事件处理程序)和一些将在本书后面介绍的内容(比如流)。

本章还向您展示了commander模块的基础知识。在撰写本文时,commandernpm注册表中第六大依赖模块。但是,您可能有兴趣探索其他类似的 CLI 模块。其中最突出的是optimist模块(optimist由 James Halliday——又名 substack——Node 社区的杰出成员创建)。我们鼓励您浏览npm存储库并尝试其他模块,以找到最适合您需求的模块。

六、文件系统

对于许多 JavaScript 开发人员来说,访问文件系统很难实现。理由一直是——正确的——让 Web 脚本访问文件系统存在太大的安全风险。然而,Node 通常不会从互联网的黑暗角落执行任意脚本。作为一种成熟的服务器端语言,Node 拥有与 PHP、Python 和 Java 等语言相同的权利和责任。因此,对于 JavaScript 开发人员来说,文件系统是一个不依赖于特定于供应商的实现或黑客的现实。本章展示了文件系统如何成为 Node 开发人员工具箱中的另一个工具。

相关路径

每个 Node 应用都包含许多变量,这些变量提供了关于 Node 在文件系统中的哪个位置工作的洞察力。这些变量中最简单的是__filename__dirname。第一个变量__filename,是当前执行文件的绝对路径。类似地,__dirname是包含当前执行文件的目录的绝对路径。清单 6-1 中的例子显示了__filename__dirname的用法。请注意,这两者都可以在不导入任何模块的情况下访问。当这个例子从目录/home/colin中执行时,结果输出显示在清单 6-2 中。

清单 6-1 。使用__filename__dirname变量

console.log("This file is " + __filename);
console.log("It's located in " + __dirname);

清单 6-2 。运行清单 6-1 中代码的输出

$ node file-paths.js
This file is /home/colin/file-paths.js
It's located in /home/colin

image 注意__filename__dirname的值取决于引用它们的文件。因此,即使在单个 Node 应用中,它们的值也可能不同——例如,当从应用中的两个不同模块引用__filename时,就可能发生这种情况。

当前工作目录

应用的当前工作目录是应用在创建相对路径时引用的文件系统目录。这方面的一个例子是pwd命令,它返回一个 shell 的当前工作目录。在 Node 应用中,当前工作目录可通过process对象的cwd()方法获得。使用cwd()方法的例子如清单 6-3 所示。结果输出如清单 6-4 所示。

清单 6-3 。使用process.cwd()方法

console.log("The current working directory is " + process.cwd());

清单 6-4 。运行清单 6-3 中代码的输出

$ node cwd-example.js
The current working directory is /home/colin

更改当前工作目录

在执行过程中,应用可以改变其当前的工作目录。在 shell 中,这是通过cd命令完成的。process对象提供了一个名为chdir()的方法,通过接受一个表示要更改的目录名的字符串参数来完成相同的任务。该方法同步执行,如果目录更改由于任何原因失败(比如,如果目标目录不存在),该方法将引发异常。

清单 6-5 中的显示了一个例子,它使用chdir()方法显示当前工作目录,然后试图切换到根目录/。如果出现错误,它会被捕获,然后打印到stderr。最后,显示更新的工作目录。

清单 6-5 。使用process.chdir()改变当前工作目录

console.log("The current working directory is " + process.cwd());

try {
  process.chdir("/");
} catch (exception) {
  console.error("chdir error:  " + exception.message);
}

console.log("The current working directory is now " + process.cwd());

清单 6-6 显示了成功执行清单 6-5 中的代码。接下来,尝试将chdir()中的路径更改为某个不存在的路径,并再次运行该示例。清单 6-7 显示了一个失败的例子,它试图将chdir()改为/foo。请注意当前工作目录在失败后是如何保持不变的。

清单 6-6 。清单 6-5 中流程的成功运行

$ node chdir-example.js
The current working directory is /home/colin
The current working directory is now /

清单 6-7 。清单 6-5 中的流程运行失败

$ node chdir-example.js
The current working directory is /home/colin
chdir error:  ENOENT, no such file or directory
The current working directory is now /home/colin

定位node可执行文件

node可执行文件的路径也可以通过process对象获得。具体来说,可执行路径在process.execPath属性中。清单 6-8 显示了一个显示node可执行路径的例子,相应的输出显示在清单 6-9 中。请注意,您自己的路径可能会因操作系统或 Node 安装路径的不同而不同。

清单 6-8 。显示process.execPath的值

console.log(process.execPath);

清单 6-9 。清单 6-8 中的输出

$ node exec-path-example.js
/usr/local/bin/node

path模块

path模块是一个核心模块,它提供了许多使用文件路径的实用方法。虽然path模块使用文件路径,但是它的许多方法只执行简单的字符串转换,而不实际访问文件系统。清单 6-10 展示了path模块是如何包含在一个 Node 应用中的。

清单 6-10 。将path模块导入到 Node 应用中

var path = require("path");

跨平台差异

处理跨多个操作系统的路径可能有点痛苦。这主要是因为 Windows 使用反斜杠(\)来分隔文件路径的各个部分,而其他操作系统使用正斜杠(/)。Node 的 Windows 版本可以有效处理正斜杠,但大多数原生 Windows 应用不能。幸运的是,这个细节可以使用path.sep属性抽象出来。该属性保存当前操作系统的文件分隔符。这在 Windows 中是\\(记住,反斜杠必须被转义),但在其他地方是/。清单 6-11 展示了如何将path.sep与数组join()方法结合使用,来创建特定于平台的文件路径。

清单 6-11 。使用path.sepjoin() 创建跨平台目录

var path = require("path");
var directories = ["foo", "bar", "baz"];
var directory = directories.join(path.sep);

console.log(directory);

image 注意 Windows 使用一个反斜杠作为它的路径分隔符。然而,反斜线必须在 JavaScript 字符串中转义。这就是为什么在 Windows 中path.sep返回\\

非 Windows 系统的结果输出如清单 6-12 中的所示。在本章的后面,我们将解释如何在目录上执行文件系统操作,但是现在我们只显示目录路径。

清单 6-12 。运行清单 6-11 中代码的输出

$ node sep-join-example.js
foo/bar/baz

Windows 和其他平台的另一个主要区别是在PATH环境变量中分隔目录的字符。Windows 使用分号(;),但其他所有系统都使用冒号(:)。path模块的delimiter属性用于将其抽象出来。清单 6-13 使用delimiter属性分割PATH环境变量并打印每个单独的目录。

清单 6-13 。拆分PATH环境变量的跨平台示例

var path = require("path");

process.env.PATH.split(path.delimiter).forEach(function(dir) {
  console.log(dir);
});

提取路径组件

path模块还提供了对几个关键路径组件的简单访问。具体来说,pathextname()basename()dirname()方法分别返回路径的文件扩展名、文件名和目录名。extname()方法 查找路径中的最后一个句点(.),并将其和所有后续字符作为扩展名返回。如果路径不包含句点,则返回空字符串。清单 6-14 显示了如何使用extname()

清单 6-14 。使用path.extname()方法

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var extension = path.extname(fileName);

console.log(extension);
// extension is .txt

basename()方法 返回路径的最后一个非空部分。如果路径对应于一个文件,basename()返回完整的文件名,包括扩展名。清单 6-15 中显示了一个这样的例子。您还可以通过将extname()的结果作为第二个参数传递给basename()来检索不带扩展名的文件名。清单 6-16 显示了一个这样的例子。

清单 6-15 。使用path.basename()从路径中提取完整文件名

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var file = path.basename(fileName);

console.log(file);
// file is baz.txt

清单 6-16 。使用path.basename()从路径中提取文件名减去扩展名

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var extension = path.extname(fileName);
var file = path.basename(fileName, extension);

console.log(file);
// file is baz

dirname()方法 返回路径的目录部分。清单 6-17 展示了dirname()的用法。

清单 6-17 。使用path.dirname()从路径中提取目录名

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var dirName = path.dirname(fileName);

console.log(dirName);
// dirName is /foo/bar

路径标准化

如果混合了"."".."部分,路径会变得过于复杂和混乱。如果用户将路径作为命令行参数传入,很可能会发生这种情况。例如,用户发出cd命令来改变目录,通常会提供相对路径。反过来,path模块提供了一个normalize()方法来简化这些路径。在清单 6-18 的例子中,一个相当复杂的路径被规范化了。在跟随几个父目录和当前目录引用之后,结果路径就是/baz

清单 6-18 。使用path.normalize()实现路径标准化

var path = require("path");
var dirName = "/foo/bar/.././bar/../../baz";
var normalized = path.normalize(dirName);

console.log(normalized);
// normalized is /baz

path模块还有一个join()方法。对任意数量的字符串进行操作,join()获取这些字符串并创建一个单一的规范化路径。在清单 6-19 的例子中,展示了如何使用join()来规范化来自清单 6-18 的路径,输入路径被分成几个字符串。注意,如果传入一个字符串,join()的工作方式与normalize()完全一样。

清单 6-19 。使用path.join()实现路径标准化

var path = require("path");
var normalized = path.join("/foo/bar", ".././bar", "../..", "/baz");

console.log(normalized);
// normalized is /baz

解析目录之间的相对路径

path.relative()方法可用于确定从一个目录到另一个目录的相对路径,它采用两个字符串作为参数。第一个参数表示计算的起点,而第二个参数对应于终点。在清单 6-20 的例子中,显示了relative()的用法,计算了从/foo/bar/baz/biff的相对路径。基于这个目录结构,在遍历/baz/biff之前,相对路径向上移动两级到根目录。

清单 6-20 。使用path.relative()确定相对路径

var path = require("path");
var from = "/foo/bar";
var to = "/baz/biff";
var relative = path.relative(from, to);

console.log(relative);
// relative is ../../baz/biff

fs模块

Node 应用通过fs模块执行文件 I/O,这个核心模块的方法提供了标准文件系统操作的包装器。清单 6-21 展示了文件系统模块是如何导入到一个 Node 应用中的。你可能还记得第三章中的这个模块,其中实现了一个文件阅读器程序。

清单 6-21 。将模块fs导入到 Node 应用中

var fs = require("fs");

关于fs模块特别值得注意的一点是它的同步方法的扩散。更具体地说,几乎所有的文件系统方法都有异步和同步版本。同步的可以通过使用Sync后缀来识别。每个方法的异步版本都将回调函数作为其最终参数。在 Node 的早期版本中,许多异步fs方法允许您省略回调函数。但是根据官方文档,从 Node 0.12 开始,省略回调函数会导致异常。

如您所见,异步方法是 Node 编程模型的核心。使用异步编程使 Node 看起来高度并行,而实际上它是单线程的。即使是一个同步方法的粗心使用也有可能使整个应用停止(如果你需要复习,请参见第三章)。那么为什么将近一半的文件系统方法是同步的呢?

碰巧的是,许多应用访问文件系统来获取配置数据。这通常在启动时的配置过程中完成。在这种情况下,同步读取配置文件通常要简单得多,无需担心性能的最大化。此外,Node 可用于创建简单的实用程序,类似于 shell 脚本。这些脚本可能会逃脱同步行为。一般来说,可以同时调用多次的代码应该是异步的。虽然作为开发人员,您可以随意使用同步方法,但是使用时要非常小心。

确定文件是否存在

exists()existsSync()方法用于确定给定路径是否存在。这两种方法都将路径字符串作为参数。如果使用同步版本,则返回一个表示路径存在的布尔值。如果使用异步版本,相同的布尔值将作为参数传递给回调函数。

清单 6-22 使用existsSync()exists()检查根目录是否存在。当调用exists()回调函数时,比较两种方法的结果。当然,这两种方法应该返回相同的值。假设等价,路径被打印出来,后面跟着表示它存在的布尔值。

清单 6-22 。使用exists()existsSync() 检查文件是否存在

var fs = require("fs");
var path = "/";
var existsSync = fs.existsSync(path);

fs.exists(path, function(exists) {
  if (exists !== existsSync) {
    console.error("Something is wrong!");
  } else {
    console.log(path + " exists:  " + exists);
  }
});

正在检索文件统计信息

fs模块提供了一组用于读取文件统计数据的函数。这些功能是stat()lstat()fstat()。当然,这些方法也有同步的对等物— statSync()lstatSync()fstatSync()。这些方法最基本的形式是stat(),它将路径字符串和回调函数作为参数。回调函数也是用两个参数调用的。第一个表示发生的任何错误。第二个是包含实际文件统计信息的fs.Stats对象。在探索fs.Stats对象之前,让我们看一个使用stat()方法的例子。在清单 6-23 中,stat()用于收集我们假设存在的文件foo.js的信息。如果出现异常(比如文件不存在),错误信息会打印到stderr。否则,打印Stats对象。

清单 6-23 。正在使用的fs.stat()方法

var fs = require("fs");
var path = "foo.js";

fs.stat(path, function(error, stats) {
  if (error) {
    console.error("stat error:  " + error.message);
  } else {
    console.log(stats);
  }
});

清单 6-24 显示了一次成功运行的输出样本。表 6-1 包含了列表中显示的各种fs.Stats对象属性的解释。请注意,您的输出可能会有所不同,尤其是在使用 Windows 的情况下。事实上,在 Windows 中,有些属性根本不会出现。

清单 6-24 。清单 6-23 中代码的输出示例

$ node stat-example.js
{ dev: 16777218,
  mode: 33188,
  nlink: 1,
  uid: 501,
  gid: 20,
  rdev: 0,
  blksize: 4096,
  ino: 2935040,
  size: 75,
  blocks: 8,
  atime: Sun Apr 28 2013 12:55:17 GMT-0400 (EDT),
  mtime: Sun Apr 28 2013 12:55:17 GMT-0400 (EDT),
  ctime: Sun Apr 28 2013 12:55:17 GMT-0400 (EDT) }

表 6-1 。各种 fs 的解释。统计对象属性

|

财产

|

描述

| | --- | --- | | dev | 包含文件的设备的 ID。 | | mode | 文件的保护。 | | nlink | 指向文件的硬链接的数量。 | | uid | 文件所有者的用户 ID。 | | gid | 文件所有者的组 ID。 | | rdev | 如果文件是特殊文件,则为设备 ID。 | | blksize | 文件系统 I/O 的块大小。 | | ino | 文件的索引 Node 号。inode 是存储文件信息的文件系统数据结构。 | | size | 文件的总大小,以字节为单位。 | | blocks | 为文件分配的块数。 | | atime | 代表文件上次访问时间的对象。 | | mtime | Date代表文件最后修改时间的对象。 | | ctime | Date表示文件的信息 Node 最后一次被更改的对象。 |

fs.Stats对象也有几个帮助识别文件类型的方法(见表 6-2 )。这些方法是同步的,它们没有参数,并且返回一个布尔值。例如,isFile()方法为普通文件返回true,但是isDirectory()为目录返回true

表 6-2 。各种 fs 的解释。统计方法

|

方法

|

描述

| | --- | --- | | isFile() | 指示文件是否是正常文件。 | | isDirectory() | 指示文件是否是目录。 | | isBlockDevice() | 指示文件是否是块设备文件。这包括硬盘、光盘和闪存驱动器等设备。 | | isCharacterDevice() | 指示文件是否是字符设备文件。这包括像键盘这样的设备。 | | isSymbolicLink() | 指示文件是否是符号链接。这仅在使用lstat()lstatSync()时有效。 | | isFIFO() | 指示文件是否是 FIFO 特殊文件。 | | isSocket() | 指示文件是否是套接字。 |

其他stats()变化

lstat()fstat()的变化几乎与stat()相同。与lstat()的唯一区别是,如果路径参数是一个符号链接,那么fs.Stats对象对应的是链接本身,而不是它所引用的文件。对于fstat(),唯一的区别是第一个参数是文件描述符而不是字符串。文件描述符用于与打开的文件进行通信(稍后会有更详细的描述)。当然,statSync()lstatSync()fstatSync()的行为就像它们的异步对应物一样。因为同步方法没有回调函数,所以直接返回fs.Stats对象。

打开文件

使用open()openSync()方法打开文件。这两个方法的第一个参数是一个字符串,表示要打开的文件名。第二个是一个flags字符串,表示文件应该如何打开(读、写等)。).表 6-3 总结了 Node 让你打开文件的各种方式。

表 6-3 。open()和 openSync()可用的各种标志的分类

|

旗帜

|

描述

| | --- | --- | | r | 打开阅读。如果文件不存在,则会发生异常。 | | r+ | 为阅读和写作而打开。如果文件不存在,则会发生异常。 | | rs | 以同步模式打开进行读取。这指示操作系统绕过系统缓存。这主要用于打开 NFS 挂载上的文件。这并没有使而不是成为同步方法。 | | rs+ | 以同步模式打开进行读写。 | | w | 打开写。如果文件不存在,则创建该文件。如果文件已经存在,它将被截断。 | | wx | 类似于w标志,但是文件是以独占模式打开的。独占模式确保文件是新创建的。 | | w+ | 为阅读和写作而打开。如果文件不存在,则创建该文件。如果文件已经存在,它将被截断。 | | wx+ | 类似于w+标志,但是文件是以独占模式打开的。 | | a | 打开以追加。如果文件不存在,则创建该文件。 | | ax | 类似于a标志,但是文件是以独占模式打开的。 | | a+ | 打开以供阅读和追加。如果文件不存在,则创建该文件。 | | ax+ | 类似于a+标志,但是文件是以独占模式打开的。 |

第三个参数是可选的,给open()openSync()指定了modemode默认为"0666"。异步open()方法将回调函数作为第四个参数。作为一个参数,回调函数接受一个错误和打开文件的文件描述符。文件描述符是一种用于与打开的文件交互的结构。文件描述符,无论是传递给回调函数还是由openSync()返回,都可以传递给其他函数来执行诸如读写之类的文件操作。选择清单 6-25 中的例子,使用open()打开文件/dev/null,是因为对它的任何写入都会被简单地丢弃。请注意,该文件在 Windows 中不存在。但是,您可以更改第二行的path的值,以指向一个不同的文件。建议使用当前不存在的文件路径,因为现有文件的内容将被覆盖,如本例所示。

清单 6-25 。使用open()打开/dev/null

var fs = require("fs");
var path = "/dev/null";

fs.open(path, "w+", function(error, fd) {
  if (error) {
    console.error("open error:  " + error.message);
  } else {
    console.log("Successfully opened " + path);
  }
});

从文件中读取数据

read()readSync()方法用于从打开的文件中读取数据。这些方法有许多参数,所以使用一个例子可能会使研究它们变得更容易(见清单 6-26 )。该示例从应用目录中的文件foo.txt读取数据(为了简单起见,省略了错误处理代码),从调用stat()开始。它必须这样做,因为稍后将需要该文件的大小。接下来,使用open()打开文件。获取文件描述符需要这一步。文件打开后,初始化一个数据缓冲区,这个缓冲区足够容纳整个文件。

清单 6-26 。使用read()从文件中读取

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.stat(path, function(error, stats) {
  fs.open(path, "r", function(error, fd) {
    var buffer = new Buffer(stats.size);

    fs.read(fd, buffer, 0, buffer.length, null, function(error, bytesRead, buffer) {
      var data = buffer.toString("utf8");

      console.log(data);
    });
  });
});

接下来是对read() 的实际调用。第一个参数是由open()提供的文件描述符。第二个是用来保存从文件中读取的数据的缓冲区。第三个是缓冲区内放置数据的偏移量(在本例中,偏移量为零,对应于缓冲区的开始)。第四个参数是要读取的字节数(在本例中,读取了文件的全部内容)。第五个是一个整数,指定文件中开始读取的位置。如果该值为null,则从当前文件位置开始读取,该位置被设置为文件最初打开时的开头,并在每次读取时更新。

如果这是对readSync()、的调用,它将返回从文件中成功读取的字节数。异步read()函数将一个回调函数作为它的最终参数,这个回调函数又将一个错误对象、读取的字节数和缓冲区作为参数。在回调函数中,原始数据缓冲区被转换为 UTF-8 字符串,然后打印到控制台。

image 注意这个例子在对read()的一次调用中读取整个文件。如果文件非常大,内存消耗可能是个问题。在这种情况下,您的应用应该初始化一个较小的缓冲区,并使用循环以较小的块读取文件。

readFile()readFileSync()方法

readFile()readFileSync()方法 提供了一种更简洁的从文件中读取数据的方法。以文件名作为参数,它们自动读取文件的全部内容,不需要文件描述符、缓冲区或其他麻烦。清单 6-27 显示了使用readFile()重写的来自清单 6-26 的代码。注意,readFile()的第二个参数指定数据应该作为 UTF-8 字符串返回。如果省略该参数或null,则返回原始缓冲区。

清单 6-27 。使用readFile()读取整个文件

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.readFile(path, "utf8", function(error, data) {
  if (error) {
    console.error("read error:  " + error.message);
  } else {
    console.log(data);
  }
});

将数据写入文件

将数据写入文件类似于读取数据。用于写入文件的方法有write()writeSync()。在清单 6-28 的例子中,使用write()方法 ,打开一个名为foo.txt的文件进行写操作。还创建了一个缓冲区来保存要写入文件的数据。接下来,write()用于将数据实际写入文件。write()的第一个参数是由open()提供的文件描述符。第二个是包含要写入的数据的缓冲区。第三和第四个参数对应于开始写入的缓冲区偏移量和要写入的字节数。第五个是一个整数,表示文件中开始写入的位置。如果该参数为null,则数据被写入当前文件位置,writeFileSync()返回成功写入文件的字节数。另一方面,write()接受一个带有三个参数的回调函数:异常对象、写入的字节数和缓冲区对象。

清单 6-28 。使用write()将数据写入文件

var fs = require("fs");
var path = __dirname + "/foo.txt";
var data = "Lorem ipsum dolor sit amet";

fs.open(path, "w", function(error, fd) {
  var buffer = new Buffer(data);

  fs.write(fd, buffer, 0, buffer.length, null, function(error, written, buffer) {
    if (error) {
      console.error("write error:  " + error.message);
    } else {
      console.log("Successfully wrote " + written + " bytes.");
    }
  });
});

writeFile()writeFileSync()方法

方法writeFile()writeFileSync()write()writeSync()提供快捷方式。清单 6-29 中的例子显示了writeFile()的用法,它将文件路径和要写入的数据作为它的前两个参数。通过可选的第三个参数,您可以指定编码(默认为 UTF-8)和其他选项。对writeFile() 的回调函数将一个错误对象作为其唯一的参数。

清单 6-29 。使用writeFile()写入文件

var fs = require("fs");
var path = __dirname + "/foo.txt";
var data = "Lorem ipsum dolor sit amet";

fs.writeFile(path, data, function(error) {
  if (error) {
    console.error("write error:  " + error.message);
  } else {
    console.log("Successfully wrote " + path);
  }
});

另外两种方法,appendFile()appendFileSync(),用于在不覆盖现有数据的情况下向现有文件追加数据。如果该文件尚不存在,则创建该文件。这些方法的用法和writeFile()writeFileSync()一模一样。

关闭文件

作为一个通用的编程经验,总是关闭你打开的任何东西。在 Node 应用中,使用close()closeSync()方法关闭文件。两者都将文件描述符作为参数。在异步版本中,回调函数应该作为第二个参数。回调函数的唯一参数用于指示可能的错误。在清单 6-30 的例子中,使用open()打开一个文件,然后使用close()立即关闭。

清单 6-30 。用open()close()打开然后关闭文件

var fs = require("fs");
var path = "/dev/null";

fs.open(path, "w+", function(error, fd) {
  if (error) {
    console.error("open error:  " + error.message);
  } else {
    fs.close(fd, function(error) {
      if (error) {
        console.error("close error:  " + error.message);
      }
    });
  }
});

image 注意没有必要关闭使用readFile()writeFile()等方法打开的文件。这些方法在内部处理一切。此外,它们没有提供文件描述符来传递给close()

重命名文件

要重命名文件,使用rename()renameSync()方法。这些方法的第一个参数是要重命名的文件的当前名称。正如您可能猜到的,第二个是文件的新名称。rename() 的回调函数只有一个参数,代表一个可能的异常。清单 6-31 中的例子将一个名为foo.txt的文件重命名为bar.txt

清单 6-31 。使用rename() 重命名文件

var fs = require("fs");
var oldPath = __dirname + "/foo.txt";
var newPath = __dirname + "/bar.txt";

fs.rename(oldPath, newPath, function(error) {
  if (error) {
    console.error("rename error:  " + error.message);
  } else {
    console.log("Successfully renamed the file!");
  }
});

删除文件

使用unlink()unlinkSync()方法删除文件,这两种方法将文件路径作为参数。异步版本也接受回调函数作为参数。回调函数只接受一个表示可能异常的参数。在清单 6-32 的示例中,展示了unlink()方法的使用,应用试图删除位于同一目录中的一个名为foo.txt的文件。

清单 6-32 。使用fs.unlink()方法删除文件

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.unlink(path, function(error) {
  if (error) {
    console.error("unlink error:  " + error.message);
  }
});

创建目录

使用mkdir()mkdirSync()方法创建新目录。mkdir()的第一个参数是要创建的目录路径。由于mkdir()只创建最后一级目录,mkdir()不能用于在一次调用中构建整个目录层次结构。这个方法还带有一个可选的第二个参数,它指定了目录的权限,默认为"0777"。异步版本还采用回调函数,该函数的唯一参数是一个可能的异常。清单 6-33 提供了一个使用mkdir()在应用的目录中创建目录树foo/bar的例子。

清单 6-33 。使用mkdir()创建几个目录

var fs = require("fs");
var path = __dirname + "/foo";

fs.mkdir(path, function(error) {
  if (error) {
    console.error("mkdir error:  " + error.message);
  } else {
    path += "/bar";
    fs.mkdir(path, function(error) {
      if (error) {
        console.error("mkdir error:  " + error.message);
      } else {
        console.log("Successfully built " + path);
      }
    });
  }
});

读取目录的内容

readdir()readdirSync()方法用于获取给定目录的内容。要读取的目录路径作为参数传入。readdirSync()方法返回包含目录中的文件和子目录的字符串数组,而readdir()将错误和相同的文件数组传递给回调函数。清单 6-34 显示了使用readdir()来读取进程当前工作目录的内容。注意readdir()readdirSync()提供的数组不包含目录"."".."

清单 6-34 。使用readdir() 读取目录的内容

var fs = require("fs");
var path = process.cwd();

fs.readdir(path, function(error, files) {
  files.forEach(function(file) {
    console.log(file);
  });
});

删除目录

您也可以使用rmdir()rmdirSync()方法删除目录。要移除的目录路径作为第一个参数传递给每个方法。rmdir()的第二个参数是一个回调函数,它将一个潜在的异常作为唯一的参数。清单 6-35 中的例子使用了rmdir()

清单 6-35 。使用rmdir() 删除目录

var fs = require("fs");
var path = __dirname + "/foo";

fs.rmdir(path, function(error) {
  if (error) {
    console.error("rmdir error:  " + error.message);
  }
});

如果试图删除非空目录,将会出现错误。删除这样一个目录需要更多的工作。清单 6-36 中的代码展示了一种实现在非空目录下工作的rmdir()函数的方法。在删除一个非空目录之前,我们首先要清空它。为此,删除目录中的所有文件,并递归删除所有子目录。

清单 6-36 。实现递归rmdir()功能

var fs = require("fs");
var path = __dirname + "/foo";

function rmdir(path) {
  if (fs.existsSync(path)) {
    fs.readdirSync(path).forEach(function(file) {
      var f = path + "/" + file;
      var stats = fs.statSync(f);

      if (stats.isDirectory()) {
        rmdir(f);
      } else {
        fs.unlinkSync(f);
      }
    });

    fs.rmdirSync(path);
  }
}

// now call the recursive rmdir() function
rmdir(path);

清单 6-36 中所有的函数调用都是同步的,这极大地简化了代码,使算法更容易理解。然而,同步函数不是 Node 方式。清单 6-37 展示了使用异步调用实现的相同功能。关于这个例子,首先要注意的是已经包含了async模块。因此,我们可以专注于实际的算法,因为async负责驯服异步函数调用。

清单 6-37 。递归的异步实现rmdir()

var async = require("async");
var fs = require("fs");
var path = __dirname + "/foo";

function rmdir(path, callback) {
  // first check if the path exists
  fs.exists(path, function(exists) {
    if (!exists) {
      return callback(new Error(path + " does not exist"));
    }

    fs.readdir(path, function(error, files) {
      if (error) {
        return callback(error);
      }

      // loop over the files returned by readdir()
      async.each(files, function(file, cb) {
        var f = path + "/" + file;

        fs.stat(f, function(error, stats) {
          if (error) {
            return cb(error);
          }

          if (stats.isDirectory()) {
            // recursively call rmdir() on the directory
            rmdir(f, cb);
          } else {
            // delete the file
            fs.unlink(f, cb);
          }
        });
      }, function(error) {
        if (error) {
          return callback(error);
        }

        // the directory is now empty, so delete it
        fs.rmdir(path, callback);
      });
    });
  });
}

// now call the recursive rmdir() function
rmdir(path, function(error) {
  if (error) {
    console.error("rmdir error:  " + error.message);
  } else {
    console.log("Successfully removed " + path);
  }
});

观看文件

fs模块让您的应用监视特定文件的修改。这是使用watch()方法完成的。watch()的第一个参数是要查看的文件的路径。可选的第二个参数是一个对象。如果存在的话,这个对象应该包含一个名为persistent的布尔属性。如果persistenttrue(默认),只要至少有一个文件被查看,应用就会继续运行。watch()的第三个参数是一个可选的回调函数,每次修改目标文件时都会触发这个函数。

如果存在,回调函数接受两个参数。第一个,观察事件的类型,将是changerename。回调函数的第二个参数是被监视文件的名称。

在清单 6-38 的例子中,显示了watch()方法 的运行,一个名为foo.txt的文件被持久地监视。也就是说,除非程序被终止或被监视的文件被删除,否则应用不会终止。每当foo.txt被修改时,回调函数就会触发并处理一个事件。如果文件被删除,将触发并处理一个rename事件,然后程序退出。

清单 6-38 。使用watch()方法观看文件

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.watch(path, {
  persistent: true
}, function(event, filename) {
  if (event === "rename") {
    console.log("The file was renamed/deleted.");
  } else if (event === "change") {
    console.log("The file was changed.");
  }
});

watch()方法也返回一个类型为fs.FSWatcher的对象。如果省略可选的回调函数,FSWatcher可以用来处理事件(通过第四章中介绍的熟悉的事件处理语法)。清单 6-39 显示了一个使用FSWatcher来处理文件监视事件的例子。另外,请注意close()方法,它用于指示FSWatcher停止查看有问题的文件。因此,此示例只处理一个文件更改事件。

清单 6-39 。使用可选的watch()语法查看文件

var fs = require("fs");
var path = __dirname + "/foo.txt";
var watcher;

watcher = fs.watch(path);
watcher.on("change", function(event, filename) {
  if (event === "rename") {
    console.log("The file was renamed/deleted.");
  } else if (event === "change") {
    console.log("The file was changed.");
  }

  watcher.close();
});

image 注意 Node 的官方文档将watch()列为不稳定,因为它依赖于底层的文件系统,并且没有跨平台实现 100%的一致性。例如,watch()回调函数的filename参数并非在所有系统中都可用。

摘要

本章介绍了 Node 的文件系统 API。在任何合法的应用中,有效地使用文件系统是一个关键因素。如果不能访问文件系统,应用就无法完成读取配置文件、创建输出文件和写入错误日志等任务。Node 中的许多文件系统任务都是使用fs模块来处理的,因此本章涵盖了fs提供的最重要的方法。但是,本章还没有介绍许多其他方法,这些方法允许您完成诸如更改文件所有权和权限之类的任务。读者可以参考完整的文档(http://nodejs.org/api/fs.html)以获得所有可能方法的列表。

七、流

Node 广泛使用流作为数据传输机制,例如,用于读写文件和通过网络套接字传输数据。第五章已经向你展示了标准流——stdinstdoutstderr。本章更详细地探讨了 Node 的 streams API,介绍了不同类型的流,它们是如何工作的,以及它们的各种应用。但是在开始之前,您应该知道,streams API 虽然是 Node 核心的重要部分,但在官方文档中被列为不稳定的。

什么是流?

流是一种在两点之间传输数据的机制。在行为方面,一个简单的花园软管提供了一个很好的类比。当你需要给你的草坪浇水时,你用一根软管把水源连接到洒水器上。当你打开水时,水通过软管流到喷水器。然后由洒水器来分配水。

流在概念上非常相似。例如,把给草坪浇水比作呼唤console.log()。在这种情况下,Node 应用充当水源。当调用console.log()时,水被打开,信息流经标准输出流。此时,Node 不再关心数据会发生什么。stdout流将数据传送到目的地。在这种情况下,目的地(喷洒器)几乎可以是任何东西——终端窗口、文件、另一个程序。

使用流

Node 支持几种类型的流,它们都继承自EventEmitter。每种类型的流行为略有不同。为了处理各种类型的流,首先导入stream核心模块(参见清单 7-1 )。

清单 7-1 。正在导入到stream模块

var Stream = require("stream");

导入stream模块会返回对Stream构造函数的引用。然后构造函数可以用来实例化新的流,如清单 7-2 所示。

清单 7-2 。使用stream模块创建新流

var Stream = require("stream");
var stream = new Stream();

可读流

可读流是数据的来源。一个典型的可读流是一个已经打开进行读取的文件。创建可读流的最简单方法是将流的readable属性分配给true,然后发出dataendcloseerror事件。以下部分探讨了如何使用这些事件。

data事件

您使用一个data事件来表示一个新的流数据片段(称为块)是可用的。对于发出的每个data事件,处理程序都被传递实际的数据块。许多应用将数据块作为二进制文件发出Buffer。这是官方文档规定的,尽管从技术上讲,任何数据都可以被发出。为了保持一致,建议data事件使用一个Buffer。清单 7-3 中的例子发出一个data事件,块被指定为Buffer

清单 7-3 。创建一个可读的流并发出一个data事件

var Stream = require("stream");
var stream = new Stream();

stream.readable = true;
stream.emit("data", new Buffer("foo"));

end事件

一旦一个流发送了它所有的数据,它应该发出一个单独的end事件。一旦发出了end事件,就不会再发出data事件。end事件不包括任何伴随数据。清单 7-4 中的例子创建了一个可读的流,它使用一个时间间隔在五秒钟内每秒发送一次数据。Date比较用来确定五秒钟过去了。此时,会发出一个end事件,间隔被清除。

清单 7-4 。一个可读的流,发出几个data事件,后跟一个end事件

var Stream = require("stream");
var stream = new Stream();
var duration = 5 * 1000; // 5 seconds
var end = Date.now() + duration;
var interval;

stream.readable = true;
interval = setInterval(function() {
  var now = Date.now();

  console.log("Emitting a data event");
  stream.emit("data", new Buffer("foo"));

  if (now >= end) {
    console.log("Emitting an end event");
    stream.emit("end");
    clearInterval(interval);
  }
}, 1000);

image 注意Date.now()方法返回当前日期和时间,指定为自 1970 年 1 月 1 日 00:00:00 UTC 以来经过的毫秒数。

close事件

close事件用于指示流数据的底层源已经关闭。例如,当文件描述符关闭时,从文件中读取数据的流会发出一个close事件。并非所有可读的流都会发出一个close事件。因此,如果您实现自己的可读流,则不需要发出此事件。如果存在的话,close事件不包含额外的参数。清单 7-5 中的显示了一个close事件的例子。

清单 7-5 。发出一个close事件

var Stream = require("stream");
var stream = new Stream();

stream.readable = true;
stream.emit("close");

error事件

error事件用于指示数据流出现问题。例如,如果后备文件不存在,从文件中读取的流会发出一个error事件。向error事件处理程序传递一个Error对象,该对象详细解释了问题。清单 7-6 中的例子发出了一个error事件。

清单 7-6 。发出一个error事件

var Stream = require("stream");
var stream = new Stream();

stream.readable = true;
stream.emit("error", new Error("Something went wrong!"));

控制可读流

要暂停可读流,请使用pause()方法。当处于暂停状态时,可读流停止发出data事件(第五章在stdin)的上下文中涉及pause())。清单 7-7 中显示了pause()的一个使用示例。

清单 7-7 。在stdin上调用pause()

process.stdin.pause();

默认情况下,stdin处于暂停状态(参见第五章)。为了从stdin或任何其他暂停的流中读取数据,首先使用resume()方法解除暂停。清单 7-8 中的例子显示了resume()的用法。调用resume()后,通过stdin到达的数据将导致data事件被发出。

清单 7-8 。在stdin上调用resume()

process.stdin.resume();

可写流

正如可读流是数据源一样,可写流是数据的目的地。要创建一个可写的流,将流的writable属性设置为true,并定义名为write()end()的方法。以下部分描述了这些方法,以及可写流的其他特性。

write()

方法负责将一大块数据写入数据流。数据块作为一个Buffer或字符串传递给write()。如果块是一个字符串,可选的第二个参数可用于指定编码。如果没有指定编码,默认情况下将使用 UTF-8。作为可选的最后一个参数,write()也接受一个回调函数。如果存在回调函数,则在成功写入数据块后调用该函数。

write()方法还返回一个布尔值,指示块是否被刷新到底层资源。如果返回true,则数据已经被刷新,流可以接受更多。如果返回了false,数据仍然在队列中等待写入。返回false还通知数据源停止发送数据,直到可写流发出一个drain事件。

清单 7-9 中的例子显示了对stdoutwrite()方法的调用。对write()的调用以字符串形式传递。因为文本是 UTF-8,所以省略了编码参数。回调函数因此成为第二个参数。

清单 7-9 。对stdoutwrite()方法的调用

var success = process.stdout.write("foo\n", function() {
  console.log("Data was successfully written!");
});
  console.log("success = " + success); 

在结果输出中(见清单 7-10 ,注意打印语句的执行顺序。对write()的调用完成,导致回调函数在事件循环中被调度。然而,执行从write()返回,然后继续,打印出success的值。此时,由于回调函数是事件循环中唯一剩下的项,因此它被执行,导致最终的打印语句运行。

清单 7-10 。运行清单 7-9 中代码的结果输出

$ node write.js
foo
success = true
Data was successfully written!

end()

用于表示数据流结束的end()方法可以在没有任何参数的情况下调用。但是,也可以使用与write()相同的参数调用它。对于只需要调用一次write(),然后再调用end()的情况,这是一个方便的快捷方式。

drain事件

write()返回false时,流的数据源应该不再发送数据。drain事件用于提醒源,处理完所有数据的可写流可以再次开始接收数据。drain事件不包括任何伴随数据。

finish事件

end()被调用并且不再有数据被写入时,流发出一个finish事件。它也没有提供额外的数据。与可能被多次发射的drain不同,finish可用于检测流的结束。

closeerror事件

像可读流一样,可写流也有行为方式相同的closeerror事件。

一个可写流的例子

现在让我们看一个非常简单的自定义可写流。当您希望在 Node 不支持的情况下使用流 API 时,自定义流非常有用。在清单 7-11 中的代码,改编自 James Halliday 的例子(https://github.com/substack/stream-handbook),流计算它处理的字节数。每次调用write()方法,总字节数都会增加缓冲区中的字节数。当调用end()时,它检查是否有缓冲区被传入。如果有,缓冲区被传递到write()。然后通过将writable属性设置为false并发出一个finish事件来关闭该流。最后,显示流处理的总字节数。

清单 7-11 。一个自定义的可写流,计算它处理的字节数

var Stream = require("stream");
var stream = new Stream();
var bytes = 0;

stream.writable = true;

stream.write = function(buffer) {
  bytes += buffer.length;
};

stream.end = function(buffer) {
  if (buffer) {
    stream.write(buffer);
  }

  stream.writable = false;
  stream.emit("finish");
  console.log(bytes + " bytes written");
};

管道

让我们回到花园软管的比喻。如果你的水管不够长,无法从水源到达你的草坪,那该怎么办?你可以用多根软管把它们连接起来。以类似的方式,数据流也可以链接在一起,以完成更大的任务。例如,假设我们有两个程序,程序 A 和程序 b。程序 A 的代码如清单 7-12 中的所示,它每秒钟生成一个随机的一位数整数(0–9)并将其输出到stdout。如清单 7-13 中的所示,程序 B 从stdin中读取任意数量的整数,并向stdout输出一个运行总和。现在你只需要一根软管来连接这两个程序。

清单 7-12 。一个随机的一位数整数生成器

setInterval(function() {
  var random = Math.floor(Math.random() * 10);

  console.log(random);
}, 1000);

清单 7-13 。对从stdin中读取的数字求和的应用

var sum = 0;

process.stdin.on("data", function(data) {
  var number = parseInt(data.toString(), 10);

  if (isFinite(number)) {
    sum += number;
  }

  console.log(sum);
});

process.stdin.resume();

image Math.random()返回一个介于 0(含)和 1(不含)之间的伪随机浮点数。将这个值乘以 10,如清单 7-12 所示,得到一个介于 0(含)和 10(不含)之间的随机浮点数。Math.floor()返回小于传入参数的最大整数。因此,清单 7-12 生成一个介于 0(含)和 9(含)之间的随机整数。

这些隐喻的软管被称为管道。如果您做过任何 shell 编程,您无疑会遇到管道。它们允许一个流程的输出流直接进入另一个流程的输入流。在 shell 编程中,管道操作符|实现管道。清单 7-14 显示了如何使用管道从命令行连接两个示例程序。在示例中,程序 A 的输出通过管道传输到程序 B 的输入。当您运行该命令时,您将看到一串数字,代表程序 B 中的sum变量的值,以每秒一个的速度打印到控制台。

清单 7-14 。从一个程序到另一个程序的管道输出

$ node Program-A.js | node Program-B.js

pipe()

在 Node 应用中,可以使用pipe()方法将流连接在一起,该方法有两个参数:一个作为数据目的地的必需的可写流和一个用于传入选项的可选对象。在清单 7-15 中的简单例子中,从stdinstdout创建了一个管道。当这个程序运行时,它监听用户的输入。当按下Enter键时,用户输入的任何数据都会回显到stdout

清单 7-15 。使用pipe()方法将stdin连接到stdout

process.stdin.pipe(process.stdout);

pipe()可选的第二个参数是一个可以保存单个布尔属性的对象,end。如果endtrue(默认行为),当源流发出其end事件时,目标流关闭。然而,如果end被设置为false,则目标流保持打开,因此可以将额外的数据写入目标流,而无需重新打开它。

image 注意当与文件或终端窗口相关联时,标准流的行为是同步的。例如,对stdout的写操作会阻塞程序的其余部分。然而,当它们通过管道传输时,它们的行为是异步的,就像任何其他流一样。此外,可写的标准流stdoutstderr不能被关闭,直到进程终止,不管end选项的值是多少。

回到可写流的例子

当清单 7-11 引入一个定制的可写流时,你看不到它做任何事情。既然您已经了解了管道,那么可以向这个示例流提供一些数据。清单 7-16 展示了这是如何做到的。最后三行特别值得注意。首先,创建一个具有相同源和目的地的管道。接下来,流发出一个data事件,随后是一个end事件。

清单 7-16 。从清单 7-11 中的向自定义可写流传输数据

var Stream = require("stream");
var stream = new Stream();
var bytes = 0;

stream.writable = true;

stream.write = function(buffer) {
  bytes += buffer.length;
};

stream.end = function(buffer) {
  if (buffer) {
    stream.write(buffer);
  }

  stream.writable = false;
  stream.emit("finish");
  console.log(bytes + " bytes written");
};

stream.pipe(stream);
stream.emit("data", new Buffer("foo"));
stream.emit("end");

这些事件触发可写流的write()end()方法。结果输出如清单 7-17 所示。

清单 7-17 。运行清单 7-16 中的代码得到的输出

$ node custom-stream.js
3 bytes written

文件流

在第六章的中,你看到了如何使用fs模块的readFile()writeFile()方法,以及它们的同步对应物来读写文件。这些方法非常方便,但是有可能导致应用中的内存问题。作为复习,以清单 7-18 中的readFile()为例,其中一个名为foo.txt的文件被异步读取。一旦读取完成,回调函数被调用,文件的内容被打印到控制台。

清单 7-18 。使用fs.readFile() 读取文件

var fs = require("fs");

fs.readFile(__dirname + "/foo.txt", function(error, data) {
  console.log(data);
});

为了理解这个问题,假设您的应用是一个每秒接收成百上千个连接的 web 服务器。还假设所有被服务的文件,不管什么原因,都非常大,并且在将数据返回给客户机之前,每次请求都使用readFile()将文件从磁盘读入内存。当调用readFile()时,它在调用其回调函数之前缓冲文件的全部内容。由于繁忙的服务器正在同时缓冲许多大文件,内存消耗可能会激增。

那么,如何避免所有这些肮脏的事情呢?事实证明,文件系统模块提供了以流的形式读写文件的方法。然而,这些方法createReadStream()createWriteStream()与大多数其他的fs方法不同,它们没有同步等价物。因此,第六章有意跳过了它们,直到读者对 streams 有了更彻底的介绍。

createReadStream()

顾名思义,createReadStream()用于将文件作为可读流打开。最简单的形式是,createReadStream()接受一个文件名作为参数,并返回一个类型为ReadStream的可读流。因为在fs模块中定义的ReadStream类型继承自标准可读流,所以它可以以同样的方式使用。

清单 7-19 中的例子显示createReadStream()正在读取一个文件的内容。data事件处理程序用于在数据通过流时打印出数据块。由于一个文件可以包含多个块,process.stdout.write()用于显示这些块。如果使用了console.log(),并且文件比一个块大,那么输出将包含原始文件中没有的额外的换行符。当end事件被接收时,console.log() 被用来简单地打印一个尾随的新行到输出。

清单 7-19 。使用fs.createReadStream()读取文件

var fs = require("fs");
var stream;

stream = fs.createReadStream(__dirname + "/foo.txt");

stream.on("data", function(data) {
  var chunk = data.toString();

  process.stdout.write(chunk);
});

stream.on("end", function() {
  console.log();
});()

ReadStreamopen事件

如前所述,ReadStream类型继承自基本可读流。这意味着ReadStream可以增强基本流的行为。?? 事件是一个很好的例子。当传递给createReadStream()的文件名被成功打开时,流发出一个open事件。用单个参数调用open事件的处理函数,该参数是流使用的文件描述符。通过获得文件描述符的句柄,createReadStream()可以与其他文件系统方法结合使用,这些文件系统方法使用诸如fstat()read()write()close()这样的文件描述符。在清单 7-20 的例子中,当调用open事件处理程序时,文件描述符被传递给fstat()以显示文件的统计数据。

清单 7-20 。使用来自open事件处理程序的文件描述符调用fstat()

var fs = require("fs");
var stream;

stream = fs.createReadStream(__dirname + "/foo.txt");

stream.on("open", function(fd) {
  fs.fstat(fd, function(error, stats) {
    if (error) {
      console.error("fstat error:  " + error.message);
    } else {
      console.log(stats);
    }
  });
});

options论证

createReadStream()接受的可选的第二个参数被命名为options。如果存在,这个参数是一个对象,它的属性允许你修改createReadStream()的行为。options参数支持的各种属性在表 7-1 中描述。

表 7-1。选项参数支持的属性描述

|

属性名称

|

描述

| | --- | --- | | fd | 现有的文件描述符。这默认为null。如果提供了一个值,就没有必要指定一个文件名作为createReadStream()的第一个参数。 | | encoding | 指定流的字符编码。默认为null。表 5-1 描述了支持的编码类型。 | | autoClose | 如果为true,当发出errorend事件时,文件自动关闭。如果false,文件不关闭。默认为true。 | | flags | flags参数传递给open()。可用值列表见表 6-3。默认为"r"。 | | mode | mode参数传递给了open()。默认为"0666"。 | | start | 文件中开始读取的字节索引。默认值为零(文件的开头)。 | | end | 文件中要停止读取的字节索引。只有在同时指定了start的情况下才能使用。默认为Infinity(文件的结尾)。 |

在清单 7-21 的例子中,利用了createReadStream()options参数,由open()返回的文件描述符被传递给createReadStream()。因为使用了一个现有的文件描述符,所以将null而不是文件名作为第一个参数传递给createReadStream()。该示例还使用了startend选项来跳过文件的第一个和最后一个字节。fstat()方法用于确定文件大小,以便适当设置end。该示例还包括许多错误检查。例如,如果使用目录而不是普通文件,代码将无法正常工作。

清单 7-21 。利用createReadStream()options自变量

var fs = require("fs");

fs.open(__dirname + "/foo.txt", "r", function(error, fd) {
  if (error) {
    return console.error("open error:  " + error.message);
  }

  fs.fstat(fd, function(error, stats) {
    var stream;
    var size;

    if (error) {
      return console.error("fstat error:  " + error.message);
    } else if (!stats.isFile()) {
      return console.error("files only please");
    } else if ((size = stats.size) < 3) {
      return console.error("file must be at least three bytes long");
    }

    stream = fs.createReadStream(null, {
      fd: fd,
      start: 1,
      end: size - 2
    });

    stream.on("data", function(data) {
      var chunk = data.toString();

      process.stdout.write(chunk);
    });

    stream.on("end", function() {
      console.log();
    });
  });
});

createWriteStream()

要创建与文件相关联的可写流,请使用createWriteStream()。与createReadStream()非常相似,createWriteStream()将一个文件路径作为其第一个参数,将一个可选的options对象作为其第二个参数,并返回一个WriteStream的实例,这是在fs模块中定义的一种数据类型,从基本的可写流类型继承而来。

清单 7-22 中的例子展示了数据如何通过管道传输到用createWriteStream()创建的可写文件流。在本例中,创建了一个可读的文件流,它从foo.txt中提取数据。然后,数据通过可写流传输到一个名为bar.txt的文件中。

清单 7-22 。将可读文件流管道传输到可写文件流

var fs = require("fs");
var readStream = fs.createReadStream(__dirname + "/foo.txt");
var writeStream = fs.createWriteStream(__dirname + "/bar.txt");

readStream.pipe(writeStream);

createWriteStream()options参数与createReadStream()使用的略有不同。表 7-2 描述了传递给createWriteStream()options对象可以包含的各种属性。

表 7-2 。createWriteStream()的 options 参数支持的属性

|

属性名称

|

描述

| | --- | --- | | fd | 现有的文件描述符。这默认为null。如果提供了一个值,就没有必要指定一个文件名作为createWriteStream()的第一个参数。 | | flags | flags参数传递给open()。可用值列表见表 6-3。默认为"w"。 | | encoding | 指定流的字符编码。默认为null。 | | mode | mode参数传递给了open()。默认为"0666"。 | | start | 文件中开始写入的字节索引。默认值为零(文件的开头)。 |

WriteStreamopen事件

WriteStream类型也实现了它自己的open事件,当目标文件被成功打开时,该事件被发出。open事件的处理程序接受文件描述符作为唯一的参数。清单 7-23 中显示了一个可写文件流的示例open事件处理程序。这个例子只是打印出代表打开文件的文件描述符的整数。

清单 7-23 。可写文件流的open事件处理程序

var fs = require("fs");
var stream = fs.createWriteStream(__dirname + "/foo.txt");

stream.on("open", function(fd) {
  console.log("File descriptor:  " + fd);
});

bytesWritten属性

WriteStream类型跟踪写入底层流的字节数。这个计数可以通过流的bytesWritten属性获得。清单 7-24 显示了如何使用bytesWritten。回到清单 7-22 中的例子,一个文件的内容使用一个可读的流读取,然后使用一个可写的流传输到另一个文件。然而,清单 7-24 包含了一个可写流的finish事件的处理程序。当发出finish事件时,这个处理程序被调用,并显示已经写入文件的字节数。

清单 7-24 。使用WriteStreambytesWritten属性

var fs = require("fs");
var readStream = fs.createReadStream(__dirname + "/foo.txt");
var writeStream = fs.createWriteStream(__dirname + "/bar.txt");

readStream.pipe(writeStream);

writeStream.on("finish", function() {
  console.log(writeStream.bytesWritten);
});

使用 zlib模块压缩

压缩是使用比原始表示更少的比特对信息进行编码的过程。压缩很有用,因为它允许使用更少的字节来存储或传输数据。当需要检索数据时,只需将其解压缩到原始状态。压缩广泛用于 web 服务器,通过减少网络上发送的字节数来缩短响应时间。但是,应该注意,压缩不是免费的,并且会增加响应时间。在归档数据时,压缩也通常用于减小文件大小。

Node 的核心zlib模块提供了使用流实现的压缩和解压缩 API。因为zlib模块是基于流的,所以它允许使用管道轻松压缩和解压缩数据。具体来说,zlib提供了使用 Gzip、Deflate 和通缩箭头进行压缩的绑定,以及使用 Gunzip、Inflate 和 Inflate 箭头进行解压缩的绑定。由于所有这三种方案都提供了相同的接口,因此在它们之间切换只是改变方法名的问题。

清单 7-25 中的例子使用 Gzip 压缩一个文件,从导入fszlib模块开始。接下来,zlib.creatGzip()方法用于创建 Gzip 压缩流。数据源input.txt用于创建可读的文件流。同样,创建一个可写文件流,将压缩数据输出到input.txt.gz。清单的最后一行通过读取未压缩的数据并将其通过 Gzip 压缩器来执行实际的压缩。然后,压缩数据通过管道传输到输出文件。

清单 7-25 。使用 Gzip 压缩来压缩文件

var fs = require("fs");
var zlib = require("zlib");
var gzip = zlib.createGzip();
var input = fs.createReadStream("input.txt");
var output = fs.createWriteStream("input.txt.gz");

input.pipe(gzip).pipe(output);

要测试压缩应用,只需创建input.txt,并在其中存储 100 个A字符(文件大小应为 100 字节)。接下来,运行 Gzip 压缩器。文件input.txt.gz应该以 24 字节的文件大小创建。当然,压缩文件的大小取决于几个因素。第一个因素是未压缩数据的大小。然而,压缩的有效性还取决于原始数据中重复模式的数量。我们的示例实现了出色的压缩,因为文件中的所有字符都是相同的。通过用一个B替换一个A,压缩文件的大小从 24 字节跳到 28 字节,即使源数据的大小相同。

压缩后的数据可能更小,但不是特别有用。为了处理压缩的数据,我们需要对其进行解压缩。清单 7-26 中的显示了一个 Gzip 解压缩应用的例子。zlib.createGunzip()方法创建一个执行解压缩的流。来自清单 7-25 的input.txt.gz文件被用作可读流,它通过管道传输到 Gunzip 流。解压缩后的数据通过管道传输到一个新的输出文件output.txt

清单 7-26 。使用 Gunzip 解压缩 Gzip 压缩文件

var fs = require("fs");
var zlib = require("zlib");
var gunzip = zlib.createGunzip();
var input = fs.createReadStream("input.txt.gz");
var output = fs.createWriteStream("output.txt");

input.pipe(gunzip).pipe(output);

放气/充气和放气箭头/充气箭头

Deflate 压缩方案可以用作 Gzip 的替代方案。DeflateRaw 方案类似于 Deflate,但是省略了 Deflate 中的 zlib 头。如前所述,这些方案的用法与 Gzip 相同。用于创建 Deflate 和 DeflateRaw 流的方法是zlib.createDeflate()zlib.createDeflateRaw()。类似地,zlib.createInflate()zlib.createInflateRaw()用于创建相应的解压缩流。一个额外的方法,zlib.createUnzip(),以同样的方式使用,它可以通过自动检测压缩方案来解压缩 Gzip 和 Deflate 压缩数据。

便利方法

前面提到的所有流类型都有相应的一步压缩/解压缩字符串或Buffer的便利方法。这些方法是gzip()gunzip()deflate()inflate()deflateRaw()inflateRaw()unzip()。它们都将一个Buffer或字符串作为第一个参数,将一个回调函数作为第二个参数。回调函数将错误条件作为第一个参数,将压缩/解压缩的结果(作为Buffer)作为第二个参数。清单 7-27 显示了如何使用deflate()unzip()来压缩和解压缩一个字符串。压缩和解压缩后,数据被打印到控制台。如果一切正常,存储在data变量中的相同字符串会显示出来。

清单 7-27 。使用方便的方法进行压缩和解压缩

var zlib = require("zlib");
var data = "This is some data to compress!";

zlib.deflate(data, function(error, compressed) {
  if (error) {
    return console.error("Could not compress data!");
  }

  zlib.unzip(compressed, function(error, decompressed) {
    if (error) {
      return console.error("Could not decompress data!");
    }

    console.log(decompressed.toString());
  });
});

摘要

本章介绍了数据流的概念。您已经看到了如何创建自己的流,以及如何使用现有的流 API,比如文件流。接下来的章节将展示网络编程环境中的流。您还将学习如何生成和控制子进程,这些子进程公开它们自己的标准流。