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

59 阅读1小时+

NodeJS 开发者高级教程(三)

原文:Pro Node.js for Developers

协议:CC BY-NC-SA 4.0

八、二进制数据

到目前为止,我们只研究了处理文本数据的应用。然而,为了节省空间和时间,应用通常必须处理二进制数据而不是文本。此外,一些应用数据,如图像和音频,本来就是二进制的。随着 web 应用越来越复杂,二进制数据的使用变得越来越普遍,甚至在浏览器中也是如此。因此,本章的重点转移到处理纯二进制数据的应用。它研究了什么是二进制数据,如何在 JavaScript 标准中处理二进制数据,以及 Node 特有的特性。

二进制数据概述

那么到底什么是二进制数据呢?如果你在想,“在计算机上,所有的数据都是二进制数据”,那你就对了。在最基本的层面上,计算机上的几乎所有数据都是以二进制形式存储的——由一系列 1 和 0 组成,代表二进制数和布尔逻辑值。然而,当术语“二进制数据”在编程语言的上下文中使用时,它指的是不包含附加抽象或结构的数据。例如,考虑清单 8-1 中显示的简单 JSON 对象。这个对象被认为是 JSON,因为它遵循特定的语法。为了使它成为有效的 JSON 对象,大括号、引号和冒号都是必需的。

清单 8-1 。一个简单的 JSON 对象

{"foo": "bar"}

您也可以将该示例简单地视为一系列字符。在这种情况下,大括号突然失去了语义上的重要性。大括号只是字符串中的两个字符,而不是标记 JSON 对象的开始和结束。用任何其他字符替换它们都没有区别。最终,您得到了一个包含 14 个字符的字符串,恰好符合 JSON 语法。但是,这些数据仍然被解释为文本,而不是真正的二进制数据。

在处理文本时,数据是用字符来定义的。例如,清单 8-1 中的字符串长度为 14 个字符。在处理二进制数据时,我们称之为字节,或八位字节。要将字节解释为文本,必须使用某种类型的字符编码。根据编码类型的不同,字符到字节可能有也可能没有一对一的映射。

image 一个八位字节是一段 8 位的数据。术语字节也常用来描述 8 位数据。然而,从历史上看,字节并不总是 8 位的。本书假定了字节的常见 8 位定义,并与八位字节互换使用。

Node 支持许多字符编码,但通常默认为 UTF-8。UTF-8 是一种可变宽度编码,与 ASCII 向后兼容,但它也可以表示所有 Unicode 字符。由于 UTF-8 编码是可变宽度的,一些字符用一个字节表示,但许多字符不是。更具体地说,单个 UTF-8 字符可能需要 1 到 4 个字节。

清单 8-2 显示了来自清单 8-1 的字符串,表示为二进制数据。由于二进制数据由一(1)和零(0)的长字符串组成,因此通常使用十六进制表示法显示,其中每个数字代表 4 位。因此,每对十六进制数字代表一个八位字节。在本例中,每个文本字符都被 UTF-8 编码为一个字节。因此,清单 8-2 包含 14 个字节。通过检查每个字节的值,您可以开始看到字符映射的模式。例如,字节值22出现了四次——引号位于清单 8-1 中。与"foo"中的"oo"相对应的值6f也连续出现两次。

清单 8-2 。清单 8-1 中的字符串表示为以十六进制编写的二进制数据

7b 22 66 6f 6f 22 3a 20 22 62 61 72 22 7d

在最后一个例子中,每个文本字符方便地映射到一个字节。然而,这可能并不总是发生。例如,考虑雪人 Unicode 字符(见清单 8-3 ),虽然很少使用,但它在 JavaScript 中是完全有效的字符串数据。清单 8-4 显示了雪人的二进制表示。请注意,在 UTF-8 编码中,需要 3 个字节来表示这一个字符。

清单 8-3 。雪人 Unicode 字符

unFig08-01.jpg

清单 8-4 。以二进制数据表示的雪人角色

e2 98 83

字节序

处理二进制数据时有时会出现的另一个问题是字节序。字节序是指给定机器在内存中存储数据的方式,在存储多字节数据(如整数和浮点数)时发挥作用。两种最常见的字符顺序是大端小端。大端机器首先存储数据项的最高有效字节。在这种情况下,“第一个”是指最低的内存地址。另一方面,小端机器将最低有效字节存储在最低内存地址中。为了说明大端存储和小端存储之间的区别,让我们来看看数字 1 在每种方案中是如何存储的。图 8-1 显示了编码为 32 位无符号整数的数字 1。为了方便起见,标记了最高有效字节和最低有效字节。由于数据长度为 32 位,因此需要 4 个字节来将数据存储在内存中。

9781430258605_Fig08-01.jpg

图 8-1 。数字 1,编码为 32 位无符号整数,以十六进制显示

图 8-2 显示了数据如何存储在大端机器上,而图 8-3 显示了以小端格式表示的相同数据。注意,包含01的字节从一种表示交换到另一种表示。标签0x000000000xFFFFFFFF表示存储空间的升序地址。

9781430258605_Fig08-02.jpg

图 8-2 。数字 1,因为它存储在大端机器的内存中

在研究了图 8-2 和图 8-3 之后,你就能明白为什么理解字符顺序很重要了。如果存储在一种字节序中的数字在另一种字节序中被解释,结果将是完全错误的。为了说明这一点,让我们回到数字 1 的例子。假设数据已经被写入使用小端存储的机器上的文件中。如果将文件移动到另一台机器上,并作为大端数据读取,会怎么样呢?事实证明,数字00 00 00 01会被解释为01 00 00 00。如果你算一下,结果是 2 24 ,或者 16,777,216——相差将近 1700 万!

9781430258605_Fig08-03.jpg

图 8-3 。数字 1,因为它存储在小端机器的内存中

确定字节顺序

os核心模块提供了一个方法endianness(),顾名思义,用于确定当前机器的字节序。endianness()方法不带参数,返回一个表示机器字节顺序的字符串。如果机器使用大端存储,endianness()返回字符串"BE"。相反,如果使用 little-endian,则返回"LE"。清单 8-5 中的例子调用endianness()并将结果打印到控制台。

清单 8-5 。使用os.endianness()方法确定机器的字节顺序

var os = require("os");

console.log(os.endianness());

类型化数组规范

在查看处理二进制数据的特定于 Node 的方式之前,让我们先看看 JavaScript 的标准二进制数据处理程序,称为类型化数组规范。这个名字来源于这样一个事实:与普通的 JavaScript 变量不同,二进制数据数组有一个特定的数据类型,它不会改变。因为类型化数组规范是 JavaScript 语言的一部分,所以本节中的内容适用于浏览器(如果支持的话)和 Node。大多数现代浏览器至少部分支持二进制数据,但哪些浏览器支持哪些特性是细节,不在本书讨论范围之内。

ArrayBuffer年代

JavaScript 的二进制数据 API 由两部分组成,一个缓冲区和一个视图。使用ArrayBuffer数据类型实现的缓冲区是一个保存字节数组的通用容器。因为ArrayBuffer是固定长度的结构,一旦创建,它们就不能调整大小。建议不要直接处理ArrayBuffer的内容。相反,创建一个视图来操作ArrayBuffer的内容(稍后将再次讨论视图的主题)。

通过调用ArrayBuffer()构造函数创建一个ArrayBuffer。构造函数接受一个参数,一个代表ArrayBuffer中字节数的整数。清单 8-6 中的例子创建了一个新的ArrayBuffer,它总共可以容纳 1024 个字节。

清单 8-6 。创建一个 1024 字节的ArrayBuffer

var buffer = new ArrayBuffer(1024);

使用现有的ArrayBuffer与使用普通数组非常相似。使用数组下标符号读写单个字节。然而,由于不能调整ArrayBuffer的大小,写入不存在的索引不会改变底层数据结构。相反,写操作不会发生,会无声地失败。在清单 8-7 的例子中,显示了一个超过ArrayBuffer结尾的写尝试,一个空的 4 字节ArrayBuffer被初始化。接下来,向每个字节写入一个值,包括超过ArrayBuffer结尾的写入。最后,将ArrayBuffer打印到控制台。

清单 8-7 。将值写入ArrayBuffer并打印结果

var foo = new ArrayBuffer(4);

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;
// this assignment will fail silently
foo[4] = 4;
console.log(foo);

清单 8-8 显示了来自清单 8-7 的输出。请注意,虽然代码已经超出了缓冲区的末尾,但是写入的值并没有出现在输出中。失败的写入也没有生成任何异常。

清单 8-8 。运行清单 8-7 中代码的结果

$ node array-buffer-write.js
{ '0': 0,
  '1': 1,
  '2': 2,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }

在前面的输出中,您可能已经注意到了byteLength属性,它以字节表示ArrayBuffer的大小。该值在ArrayBuffer创建时分配,不能更改。像普通数组的length属性一样,byteLength对于循环ArrayBuffer的内容很有用。清单 8-9 显示了byteLength属性如何在for循环中显示ArrayBuffer的内容。

清单 8-9 。使用byteLength属性在ArrayBuffer上循环

var foo = new ArrayBuffer(4);

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;

for (var i = 0, len = foo.byteLength; i < len; i++) {
  console.log(foo[i]);
}

slice()

您可以使用slice()方法从现有文件中提取一个新的ArrayBufferslice()方法有两个参数,它们指定了要复制的范围的起始位置(包含)和结束位置(不包含)。结尾索引可以省略。如果未指定,切片跨度从起始索引到ArrayBuffer的结尾。这两个指数也可以是负数。负指数用于计算从ArrayBuffer末端而非开始的位置。清单 8-10 显示了几个从ArrayBuffer中截取相同的两个字节的例子。前两个示例使用显式的开始和结束索引,而第三个示例省略了结束索引。最后,第四个示例使用负起始索引创建一个切片。

清单 8-10 。使用slice()方法创建新的ArrayBuffer

var foo = new ArrayBuffer(4);

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;

console.log(foo.slice(2, 4));
console.log(foo.slice(2, foo.byteLength));
console.log(foo.slice(2));
console.log(foo.slice(-2));
// returns [2, 3]

需要注意的是,slice()返回的新的ArrayBuffer只是原始数据的副本。因此,如果slice()返回的缓冲区被修改,原始数据不会改变(见清单 8-11 中的例子)。

清单 8-11 。使用slice()方法创建新的ArrayBuffer

var foo = new ArrayBuffer(4);
var bar;

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;

// Create a copy of foo and modify it
bar = foo.slice(0);
bar[0] = 0xc;

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

在清单 8-11 中,一个名为fooArrayBuffer被创建并填充了数据。接下来,使用slice()foo的全部内容复制到bar中。然后将十六进制值0xc(二进制 12)写入bar中的第一个位置。最后,foobar都打印到控制台。清单 8-12 显示了结果输出。注意,除了第一个字节,这两个ArrayBuffer是相同的。写入bar的值0xc没有传播到foo

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

$ node array-buffer-slice.js
{ '0': 0,
  '1': 1,
  '2': 2,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }
{ '0': 12,
  '1': 1,
  '2': 2,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }

ArrayBuffer观点

直接处理字节数组既乏味又容易出错。通过给一个ArrayBuffer增加一个抽象层,视图给人一种更传统的数据类型的错觉。例如,您可以使用一个视图将数据显示为两个 4 字节整数的数组,每个都是 32 位长,总共是 64 位或 8 个字节,而不是使用 8 字节的ArrayBuffer。表 8-1 列出了各种类型的视图以及每个数组元素的字节大小。因此,在我们的示例场景中,我们需要一个Int32ArrayUint32Array视图,这取决于我们的应用需要有符号还是无符号数字。

表 8-1 。对 JavaScript 的各种数组缓冲视图的描述

|

视图类型

|

元素大小(字节)

|

描述

| | --- | --- | --- | | Int8Array | one | 8 位有符号整数数组。 | | Uint8Array | one | 8 位无符号整数数组。 | | Uint8ClampedArray | one | 8 位无符号整数数组。值被限制在 0–255 的范围内。 | | Int16Array | Two | 16 位有符号整数数组。 | | Uint16Array | Two | 16 位无符号整数数组。 | | Int32Array | four | 32 位有符号整数数组。 | | Uint32Array | four | 32 位无符号整数数组。 | | Float32Array | four | 32 位 IEEE 浮点数数组。 | | Float64Array | eight | 64 位 IEEE 浮点数的数组。 |

image 注意虽然Uint8ArrayUint8ClampedArray非常相似,但是在 0-255 范围之外的值的处理方式上有一个关键的区别。Uint8Array在确定一个值时只查看最低有效的 8 位。因此,255、256 和 257 分别被解释为 255、0 和 1。另一方面,Uint8ClampedArray将任何大于 255 的值解释为 255,将任何小于 0 的值解释为 0。也就是说 255,256,257 都解释为 255。

清单 8-13 中的例子展示了视图在实践中是如何使用的。在这种情况下,由两个 32 位无符号整数组成的视图是基于一个 8 字节的ArrayBuffer创建的。接下来,将两个整数写入视图,并显示视图。

清单 8-13 。使用Uint32Array视图的示例

var buf = new ArrayBuffer(8);
var view = new Uint32Array(buf);

view[0] = 100;
view[1] = 256;

console.log(view);

清单 8-14 显示了结果输出。它的前两行显示了写入视图的两个值,100 和 256。跟随数组值的是BYTES_PER_ELEMENT属性。这个只读属性包含在每种类型的视图中,表示每个数组元素中的原始字节数。跟在BYTES_PER_ELEMENT属性后面的是一个方法集合,我们将很快再次访问它。

清单 8-14 。运行清单 8-13 中的代码得到的输出

$ node array-buffer-view.js
{ '0': 100,
  '1': 256,
  BYTES_PER_ELEMENT: 4,
  get: [Function: get],
  set: [Function: set],
  slice: [Function: slice],
  subarray: [Function: subarray],
  buffer:
   { '0': 100,
     '1': 0,
     '2': 0,
     '3': 0,
     '4': 0,
     '5': 1,
     '6': 0,
     '7': 0,
     slice: [Function: slice],
     byteLength: 8 },
  length: 2,
  byteOffset: 0,
  byteLength: 8 }

注意,底层的ArrayBuffer也显示为buffer属性。检查ArrayBuffer中每个字节的值,您将看到它与视图中存储的值的对应关系。在本例中,字节 0 至 3 对应于值 100,字节 4 至 7 表示值 256。

image 注意提醒一下,256 相当于 2 8 ,意思是不能用单个字节表示。单个无符号字节最多可以容纳 255。所以 256 的十六进制表示是01 00

这带来了视图的另一个重要方面。与返回数据新副本的ArrayBuffer slice()方法不同,视图直接操作原始数据。因此修改视图的值会改变ArrayBuffer的内容,反之亦然。同样,拥有相同ArrayBuffer的两种观点可能会意外地(或有意地)改变彼此的价值观。在清单 8-15 中所示的例子中,一个 4 字节的ArrayBuffer由一个Uint32Array视图和一个Uint8Array视图共享,首先向Uint32Array写入 100,然后打印该值。然后Uint8Array将值 1 写入其第二个字节(实际上写入值 256)。然后再次打印来自Uint32Array的数据。

清单 8-15 。相互影响的视图

var buf = new ArrayBuffer(4);
var view1 = new Uint32Array(buf);
var view2 = new Uint8Array(buf);

// write to view1 and print the value
view1[0] = 100;
console.log("Uint32 = " + view1[0]);

// write to view2 and print view1's value
view2[1] = 1;
console.log("Uint32 = " + view1[0]);

清单 8-16 显示了来自清单 8-15 的输出。正如所料,第一个 print 语句显示值 100。但是,到第二个 print 语句出现时,该值已经增加到 356。在示例中,这种行为是意料之中的。然而,在更复杂的应用中,当创建同一数据的多个视图时,您必须小心谨慎。

清单 8-16 。运行清单 8-15 中代码的输出

$ node view-overwrite.js
Uint32 = 100
Uint32 = 356

关于视图大小的注释

视图的大小必须保证每个元素可以完全由ArrayBuffer中的数据组成。也就是说,视图只能从以字节为单位的长度是视图的BYTES_PER_ELEMENT属性的倍数的数据中构造。例如,一个 4 字节的ArrayBuffer可以用来构建一个保存单个整数的Int32Array视图。然而,同样的 4 字节缓冲区不能用于构建元素长度为 8 字节的Float64Array视图。

构造者信息

每种类型的视图都有四个构造函数。您已经看到的一种形式将一个ArrayBuffer作为它的第一个参数。这个构造函数也可以选择指定ArrayBuffer中的起始字节偏移量和视图的长度。字节偏移量默认为 0, 必须BYTES_PER_ELEMENT的倍数,否则抛出RangeError异常。如果省略,长度将试图消耗整个ArrayBuffer,从字节偏移量开始。这些参数,如果指定的话,允许视图基于ArrayBuffer的一部分,而不是全部。如果ArrayBuffer的长度不是视图BYTES_PER_ELEMENT的整数倍,这就特别有用。

在清单 8-17 的例子中,展示了如何从一个大小不是BYTES_PER_ELEMENT的整数倍的缓冲区构建一个视图,一个Int32Array视图建立在一个 5 字节的ArrayBuffer上。字节偏移量 0 表示视图应该从ArrayBuffer的第一个字节开始。同时,length 参数指定视图应该包含一个整数。没有这些论点,就不可能从这个ArrayBuffer中构建出这个观点。另外,请注意,该示例包含对buf[4]处的字节的写操作。由于视图只使用前四个字节,因此写入第五个字节不会改变视图中的数据。

清单 8-17 。基于ArrayBuffer的一部分构建视图

var buf = new ArrayBuffer(5);
var view = new Int32Array(buf, 0, 1);

view[0] = 256;
buf[4] = 5;
console.log(view[0]);

创建空视图

第二个构造函数用于创建一个预定义长度的空视图n。这种形式的构造函数还创建了一个新的足够大的ArrayBuffer来容纳n视图元素。例如,清单 8-18 中的代码创建了一个空的Float32Array视图,其中包含两个浮点数。在幕后,构造函数还创建了一个 8 字节的ArrayBuffer来保存浮动。在构建期间,ArrayBuffer中的所有字节都被初始化为 0。

清单 8-18 。创建一个空的Float32Array视图

var view = new Float32Array(2);

从数据值创建视图

第三种形式的构造函数接受用于填充视图数据的值数组。数组中的值被转换为适当的数据类型,然后存储在视图中。构造函数还创建了一个新的ArrayBuffer来保存这些值。清单 8-19 显示了一个创建用值 1、2 和 3 填充的Uint16Array视图的例子。

清单 8-19 。从包含三个值的数组创建一个Uint16Array视图

var view = new Uint16Array([1, 2, 3]);

从另一个视图创建视图

构造函数的第四个版本与第三个非常相似。唯一的区别是,这个版本接受另一个视图作为唯一的参数,而不是传入一个标准数组。新创建的视图还实例化了自己的后台ArrayBuffer——也就是说,底层数据是不共享的。清单 8-20 显示了这个版本的构造函数在实践中是如何使用的。在这个例子中,一个 4 字节的ArrayBuffer被用来创建一个包含四个数字的Int8Array视图。然后使用Int8Array视图创建一个新的Uint32Array视图。Uint32Array也包含四个数字,对应于Int8Array视图中的数据。然而,它的底层ArrayBuffer是 16 字节长,而不是 4 字节。当然,因为两个视图有不同的ArrayBuffer s,更新一个视图并不影响另一个。

清单 8-20 。从Int8Array视图创建Uint32Array视图

var buf = new ArrayBuffer(4);
var view1 = new Int8Array(buf);
var view2 = new Uint32Array(view1);

console.log(buf.byteLength);  // 4
console.log(view1.byteLength);  // 4
console.log(view2.byteLength);  // 16

查看属性

您已经看到视图的ArrayBuffer可以通过buffer属性访问,并且BYTES_PER_ELEMENT属性表示每个视图元素的字节数。视图还有两个属性,byteLengthlength,与数据大小有关,还有一个byteOffset属性,表示视图使用的缓冲区的第一个字节。

byteLength

byteLength 属性表示视图的数据大小,以字节为单位。这个值不一定等于底层ArrayBufferbyteLength属性。在这个例子中,如清单 8-21 所示,一个Int16Array视图是从一个 10 字节的ArrayBuffer构建的。但是,因为Int16Array构造函数指定它只包含两个整数,所以它的byteLength属性是 4,而ArrayBufferbyteLength是 10。

清单 8-21 。视图的不同byteLength及其ArrayBuffer

var buf = new ArrayBuffer(10);
var view = new Int16Array(buf, 0, 2);

console.log(buf.byteLength);
console.log(view.byteLength);

length

length属性的工作方式类似于标准数组,它指示视图中数据元素的数量。这个属性对于视图数据的循环很有用,如清单 8-22 所示。

清单 8-22 。使用length属性遍历视图数据

var view = new Int32Array([5, 10]);

for (var i = 0, len = view.length; i < len; i++) {
  console.log(view[i]);
}

byteOffset

属性指定了与视图使用的第一个字节相对应的ArrayBuffer的偏移量。该值始终为 0,除非将偏移量作为第二个参数传递给构造函数(参见清单 8-17 )。byteOffset可以与byteLength属性结合使用,以遍历底层ArrayBuffer的字节。在清单 8-23 的例子中,展示了如何使用byteOffsetbyteLength循环仅由视图使用的字节,源ArrayBuffer是 10 字节长,但是视图仅使用字节 4 到 7。

清单 8-23 。在ArrayBuffer中循环使用的字节子集

var buf = new ArrayBuffer(10);
var view = new Int16Array(buf, 4, 2);
var len = view.byteOffset + view.byteLength;

view[0] = 100;
view[1] = 256;

for (var i = view.byteOffset; i < len; i++) {
  console.log(buf[i]);
}

get()

get()方法 用于检索视图中给定索引处的数据值。然而,正如您已经看到的,同样的任务可以使用数组索引符号来完成,这需要更少的字符。如果你出于某种原因选择使用get(),清单 8-24 显示了它的用法示例。

清单 8-24 。使用视图get()方法

var view = new Uint8ClampedArray([5]);

console.log(view.get(0));
// could also use view[0]

set()

set() 用于给视图中的一个或多个值赋值。要分配单个值,将索引传递给 write,然后将要写入的值作为参数传递给set()(也可以使用数组索引符号来完成)。清单 8-25 中显示了一个将值 3.14 赋给第四个视图元素的例子。

清单 8-25 。使用set()分配单个值

var view = new Float64Array(4);

view.set(3, 3.14);
// could also use view[3] = 3.14

为了分配多个值,set()还接受数组和视图作为它的第一个参数。可选地使用这种形式的set()来提供第二个参数,该参数指定开始写入值的偏移量。如果不包括这个偏移量,set()从第一个索引开始写值。在清单 8-26 的中,set()被用来填充一个Int32Array的所有四个元素。

清单 8-26 。使用set()分配多个值

var view = new Int32Array(4);

view.set([1, 2, 3, 4], 0);

关于这个版本的set(),有两件重要的事情需要了解。首先,如果您试图写入视图的末尾,就会抛出一个异常。在清单 8-26 的例子中,如果第二个参数大于 0,就会超出四元素边界,导致错误。其次,注意因为set()接受一个视图作为它的第一个参数,参数的ArrayBuffer可能与调用对象共享。如果源和目标相同,Node 必须智能地复制数据,以便字节在有机会被复制之前不会被覆盖。清单 8-27 是两个Int8Array视图具有相同ArrayBuffer的一个例子。第二个视图view2也较小,表示较大视图view1的前半部分。当调用set()时,0 被分配给view1[1],1 被分配给view1[2]。由于view1[1]是源的一部分(在这个操作中也是目的地的一部分),您需要确保原始值在被覆盖之前被复制。

清单 8-27 。显示单个ArrayBufferset()中的共享位置

var buf = new ArrayBuffer(4);
var view1 = new Int8Array(buf);
var view2 = new Int8Array(buf, 0, 2);

view1[0] = 0;
view1[1] = 1;
view1[2] = 2;
view1[3] = 3;
view1.set(view2, 1);
console.log(view1.buffer);

根据规范,“设置这些值时,就好像首先将所有数据复制到一个不与任何数组重叠的临时缓冲区中,然后将临时缓冲区中的数据复制到当前数组中。”本质上,这意味着 Node 会为您处理一切。为了验证这一点,前面例子的结果输出显示在清单 8-28 中。请注意,字节 1 和 2 包含正确的值 0 和 1。

清单 8-28 。运行清单 8-27 中代码的输出

$ node view-set-overlap.js
{ '0': 0,
  '1': 0,
  '2': 1,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }

subarray()

subarray() 返回依赖于同一ArrayBuffer的数据类型的新视图,它有两个参数。第一个参数指定新视图中引用的第一个索引。第二个是可选的,表示新视图中引用的最后一个索引。如果省略结束索引,新视图的范围将从起始索引到原始视图的结尾。任何一个索引都可以是负数,这意味着偏移量是从数据数组的末尾开始计算的。请注意,subarray()返回的新视图与原始视图具有相同的ArrayBuffer。清单 8-29 展示了如何使用subarray()创建几个相同的Uint8ClampedArray视图,组成另一个视图的子集。

清单 8-29 。使用subarray()从现有视图创建新视图

var view1 = new Uint8ClampedArray([1, 2, 3, 4, 5]);
var view2 = view1.subarray(3, view1.length);
var view3 = view1.subarray(3);
var view4 = view1.subarray(-2);

NodeBuffers

Node 提供了自己的Buffer数据类型来处理二进制数据。这是在 Node 中处理二进制数据的首选方法,因为它比类型化数组稍有效率。到目前为止,您已经遇到了许多处理Buffer对象的方法——例如,fs模块的read()write()方法。这一节详细探讨了Buffer的工作原理,包括它们与类型化数组规范的兼容性。

Buffer建造师

Buffer使用三个Buffer()构造函数中的一个来创建对象。Buffer构造函数是全局的,这意味着它不需要任何模块就可以被调用。一旦Buffer被创建,它就不能被调整大小。第一种形式的Buffer()构造函数创建一个给定字节数的空Buffer。清单 8-30 中的例子创建了一个空的 4 字节Buffer,也展示了Buffer中的单个字节可以使用数组下标符号来访问。

清单 8-30 。创建一个 4 字节的缓冲区并访问各个字节

var buf = new Buffer(4);

buf[0] = 0;
buf[1] = 1;

console.log(buf);

清单 8-31 显示了Buffer的字符串版本。Buffer中的前两个字节保存值0001,它们分别在代码中分配。请注意,最后两个字节也有值,尽管它们从未被赋值。这些实际上是程序运行时已经在内存中的值(如果您运行这段代码,您看到的值可能会有所不同),表明Buffer()构造函数没有将其保留的内存初始化为 0。这样做是有意的——在请求大量内存时节省时间(回想一下ArrayBuffer构造函数将其缓冲区初始化为 0)。由于 web 浏览器中经常使用,不初始化内存可能会有安全隐患——您可能不希望任意网站读取您计算机内存中的内容。由于Buffer类型是特定于 Node 的,所以它不存在同样的安全风险。

清单 8-31 。运行清单 8-30 中代码的输出结果

$ node buffer-constructor-1.js
<Buffer 00 01 05 02>

第二种形式的Buffer()构造函数接受一个字节数组作为它唯一的参数。产生的Buffer用数组中存储的值填充。在清单 8-32 中显示了这种形式的构造函数的一个例子。

清单 8-32 。从八位字节数组创建一个Buffer

var buf = new Buffer([1, 2, 3, 4]);

构造函数的最终版本用于从字符串数据创建一个Buffer。清单 8-33 中的代码展示了如何从字符串"foo"创建一个Buffer

清单 8-33 。从字符串创建一个Buffer

var buf = new Buffer("foo");

在本章的前面,您已经了解到为了将二进制数据转换为文本,必须指定字符编码。当一个字符串作为第一个参数传递给Buffer()时,第二个可选参数可以用来指定编码类型。在清单 8-33 中,没有明确设置编码,所以默认使用 UTF-8。表 8-2 分解了 Node 支持的各种字符编码。(敏锐的读者可能会从第五章的中认出这个表格。然而,值得在书中重复这一点的信息。)

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

|

编码类型

|

描述

| | --- | --- | | utf8 | 多字节编码的 Unicode 字符。许多网页使用 UTF-8 编码来表示 Node 中的字符串数据。 | | ascii | 7 位美国信息交换标准码(ASCII)编码。 | | utf16le | 小端编码的 Unicode 字符。每个字符是 2 或 4 个字节。 | | ucs2 | 这只是utf16le编码的别名。 | | base64 | Base64 字符串编码。Base64 通常用于 URL 编码、电子邮件和类似的应用。 | | binary | 允许仅使用每个字符的前 8 位将二进制数据编码为字符串。由于不赞成使用此选项,而支持使用Buffer对象,因此在 Node 的未来版本中将会删除它。 | | hex | 将每个字节编码为两个十六进制字符。 |

字符串化方法

s 可以通过两种方式进行字符串化。第一个使用了toString()方法,它试图将Buffer的内容解释为字符串数据。toString()方法接受三个参数,都是可选的。它们指定了字符编码和从Buffer到 stringify 的开始和结束索引。如果未指定,整个Buffer将使用 UTF-8 编码进行字符串化。清单 8-34 中的例子使用toString()给出了一个完整的Buffer

清单 8-34 。使用Buffer.toString()方法

var buf = new Buffer("foo");

console.log(buf.toString());

第二个字符串化方法toJSON()Buffer数据作为 JSON 字节数组返回。通过在Buffer对象上调用JSON.stringify()可以得到类似的结果。清单 8-35 显示了一个toJSON()方法的例子。

清单 8-35 。使用Buffer.toJSON()方法

var buf = new Buffer("foo");

console.log(buf.toJSON());
console.log(JSON.stringify(buf));

Buffer.isEncoding()

isEncoding()方法清单 8-36 显示了isEncoding()的两个例子。第一个测试字符串"utf8"并显示true。然而,第二个会打印出false,因为"foo"不是有效的字符编码。

清单 8-36Buffer.isEncoding()类方法的两个例子

console.log(Buffer.isEncoding("utf8"));
console.log(Buffer.isEncoding("foo"));

Buffer.isBuffer()

类方法isBuffer() 用于判断一条数据是否为Buffer对象。它的使用方式与Array.isArray()法相同。清单 8-37 显示了一个isBuffer()的使用示例。这个例子打印了true,因为buf变量实际上是一个Buffer

清单 8-37Buffer.isBuffer()类方法

var buf = new Buffer(1);

console.log(Buffer.isBuffer(buf));

Buffer.byteLength()length

byteLength()类方法 用于计算给定字符串中的字节数。此方法还接受可选的第二个参数来指定字符串的编码类型。这个方法对于计算字节长度很有用,不需要实例化一个Buffer实例。但是,如果您已经构建了一个Buffer,那么它的length属性也有同样的作用。在清单 8-38 的例子中,显示了byteLength()lengthbyteLength()用于计算 UTF-8 编码的字符串"foo"的字节长度。接下来,从同一个字符串中构造一个实际的Buffer。然后使用Bufferlength属性检查字节长度。

清单 8-38Buffer.byteLength()length属性

var byteLength = Buffer.byteLength("foo");
var length = (new Buffer("foo")).length;

console.log(byteLength);
console.log(length);

fill()

Buffer写入数据有多种方式。合适的方法取决于几个因素,包括数据类型及其字节顺序。最简单的方法是fill(),它将相同的值写入全部或部分Buffer,它有三个参数——要写入的值、开始填充的可选偏移量和停止填充的可选偏移量。与其他写入方法一样,起始偏移默认为 0,结束偏移默认为Buffer的结束。由于默认情况下Buffer没有设置为零,fill()对于将Buffer初始化为一个值是有用的。清单 8-39 中的例子显示了如何将Buffer中的所有内存清零。

清单 8-39 。使用fill()Buffer中的内存清零

var buf = new Buffer(1024);

buf.fill(0);

write()

要将一个字符串写入一个Buffer,使用write()方法 。它接受以下四个参数。

  • 要写入的字符串。
  • 开始写入的偏移量。这是可选的,默认为索引 0。
  • 要写入的字节数。如果未指定,则写入整个字符串。但是,如果Buffer缺少容纳整个字符串的空间,它就会被截断。
  • 字符串的字符编码。如果省略,则默认为 UTF-8。

清单 8-40 中的例子用字符串"foo"的三个副本填充一个 9 字节的Buffer。由于第一次写入在Buffer的开始处开始,因此不需要偏移。但是,第二次和第三次写入需要一个偏移值。在第三个示例中,包含了字符串长度,尽管这不是必需的。

清单 8-40 。使用write()对同一个Buffer进行多次写入

var buf = new Buffer(9);
var data = "foo";

buf.write(data);
buf.write(data, 3);
buf.write(data, 6, data.length);

写入数字数据

有一组方法用于将数字数据 写入Buffer,每种方法都用于写入特定类型的数字。这类似于各种类型化的数组视图,每个视图存储不同类型的数据。表 8-3 列出了用于书写数字的方法。

表 8-3 。用于将数字数据写入缓冲区的方法集合

|

方法名称

|

描述

| | --- | --- | | writeUInt8() | 写入一个无符号 8 位整数。 | | writeInt8() | 写入一个有符号的 8 位整数。 | | writeUInt16LE() | 使用 little-endian 格式写入一个无符号 16 位整数。 | | writeUInt16BE() | 使用 big-endian 格式写入一个无符号的 16 位整数。 | | writeInt16LE() | 使用 little-endian 格式写入有符号的 16 位整数。 | | writeInt16BE() | 使用 big-endian 格式写入有符号的 16 位整数。 | | writeUInt32LE() | 使用 little-endian 格式写入一个无符号 32 位整数。 | | writeUInt32BE() | 使用 big-endian 格式写入一个无符号 32 位整数。 | | writeInt32LE() | 使用 little-endian 格式写入有符号的 32 位整数。 | | writeInt32BE() | 使用 big-endian 格式写入有符号的 32 位整数。 | | writeFloatLE() | 使用 little-endian 格式写入一个 32 位浮点数。 | | writeFloatBE() | 使用 big-endian 格式写入 32 位浮点数。 | | writeDoubleLE() | 使用 little-endian 格式写入 64 位浮点数。 | | writeDoubleBE() | 使用 big-endian 格式写入 64 位浮点数。 |

表 8-3 中的所有方法都有三个参数——要写入的数据,Buffer中写入数据的偏移量,以及一个关闭验证检查的可选标志。如果验证标志被设置为false(默认),如果值太大或者数据溢出Buffer,则抛出异常。如果该标志被设置为true,大值将被截断,溢出写操作会自动失败。在使用清单 8-41 中的writeDoubleLE()的例子中,值 3.14 被写入缓冲区的前 8 个字节,没有验证检查。

清单 8-41 。使用writeDoubleLE()

var buf = new Buffer(16);

buf.writeDoubleLE(3.14, 0, true);

读取数字数据

Buffer中读取数值数据 ,和写一样,也需要一组方法。表 8-4 列出了用于读取数据的各种方法。注意与表 8-3 中的写入方法一一对应。

表 8-4 。用于从缓冲区读取数字数据的方法集合

|

方法名称

|

描述

| | --- | --- | | readUInt8() | 读取一个无符号 8 位整数。 | | readInt8() | 读取一个带符号的 8 位整数。 | | readUInt16LE() | 使用 little-endian 格式读取无符号 16 位整数。 | | readUInt16BE() | 使用 big-endian 格式读取无符号 16 位整数。 | | readInt16LE() | 使用 little-endian 格式读取有符号的 16 位整数。 | | readInt16BE() | 使用 big-endian 格式读取有符号的 16 位整数。 | | readUInt32LE() | 使用 little-endian 格式读取一个无符号 32 位整数。 | | readUInt32BE() | 使用 big-endian 格式读取一个无符号 32 位整数。 | | readInt32LE() | 使用 little-endian 格式读取有符号的 32 位整数。 | | readInt32BE() | 使用 big-endian 格式读取有符号的 32 位整数。 | | readFloatLE() | 使用 little-endian 格式读取 32 位浮点数。 | | readFloatBE() | 使用 big-endian 格式读取 32 位浮点数。 | | readDoubleLE() | 使用 little-endian 格式读取 64 位浮点数。 | | readDoubleBE() | 使用 big-endian 格式读取 64 位浮点数。 |

所有数字读取方法都有两个参数。第一个是从Buffer中读取数据的偏移量。可选的第二个参数用于禁用验证检查。如果是false(默认值),当偏移量超过Buffer大小时抛出异常。如果标志为true,则不进行验证,返回的数据可能无效。清单 8-42 展示了一个 64 位浮点数如何被写入一个缓冲区,然后使用readDoubleLE()读回。

清单 8-42 。写入和读取数字数据

var buf = new Buffer(8);
var value;

buf.writeDoubleLE(3.14, 0);
value = buf.readDoubleLE(0);

slice()

slice()方法 返回一个新的Buffer,它与原来的Buffer共享内存。换句话说,对新的Buffer的更新会影响原来的,反之亦然。slice()方法有两个可选参数,代表切片的开始和结束索引。索引也可以是负的,这意味着它们相对于Buffer的结束。清单 8-43 显示了如何使用slice()提取一个 4 字节Buffer的前半部分。

清单 8-43 。使用slice()创建一个新的Buffer

var buf1 = new Buffer(4);
var buf2 = buf1.slice(0, 2);

copy()

copy()方法 用于将数据从一个Buffer复制到另一个Buffercopy()的第一个参数是目的地Buffer。第二个(如果存在)表示要复制的目标中的起始索引。第三和第四个参数,如果存在的话,是要复制的源Buffer中的开始和结束索引。将一个Buffer的全部内容复制到另一个的例子如清单 8-44 所示。

清单 8-44 。使用copy()将一个Buffer的内容复制到另一个

var buf1 = new Buffer([1, 2, 3, 4]);
var buf2 = new Buffer(4);

buf1.copy(buf2, 0, 0, buf1.length);

Buffer.concat()

concat() 类方法允许将多个Buffer连接成一个更大的Bufferconcat()的第一个参数是要连接的Buffer对象的数组。如果没有提供Buffer,则concat()返回零长度Buffer。如果提供了单个Buffer,则返回对该Buffer的引用。如果提供了多个Buffer,则创建一个新的Buffer。清单 8-45 提供了一个连接两个Buffer对象的例子。

清单 8-45 。连接两个Buffer对象

var buf1 = new Buffer([1, 2]);
var buf2 = new Buffer([3, 4]);
var buf = Buffer.concat([buf1, buf2]);

console.log(buf);

类型化数组兼容性

与类型化数组视图兼容。当从一个Buffer构建一个视图时,Buffer的内容被克隆到一个新的ArrayBuffer中。克隆的ArrayBuffer不与原Buffer共享内存。在清单 8-46 的例子中,它从一个缓冲区创建一个视图,一个 4 字节的Buffer被克隆到一个 16 字节的ArrayBuffer中,后者支持一个Uint32Array视图。注意,在创建视图之前,Buffer被初始化为全 0。如果不这样做,视图将包含任意数据。

清单 8-46 。从Buffer创建视图

var buf = new Buffer(4);
var view;

buf.fill(0);
view = new Uint32Array(buf);
console.log(buf);
console.log(view);

同样值得指出的是,虽然视图可以从一个Buffer构造,但是ArrayBuffer s 不能。一艘Buffer也不能由一艘ArrayBuffer建造。可以从视图中构造一个Buffer,但是在这样做的时候要小心,因为视图很可能包含不能很好传输的数据。在说明这一点的清单 8-47 中的简单示例中,当从Uint32Array视图移动到Buffer视图时,整数 257 变成字节值 1。

*清单 8-47 。从视图构建Buffer时数据丢失

var view = new Uint32Array([257]);
var buf = new Buffer(view);

console.log(buf);

摘要

这一章涵盖了许多材料。从二进制数据的概述开始,您接触到了包括高级字符编码和字符顺序的主题。从那以后,本章进入了类型化数组规范。希望你觉得这份材料有用。毕竟,它是 JavaScript 语言的一部分,可以在浏览器和 Node 中使用。在介绍了ArrayBuffer和视图之后,本章继续介绍 Node 的Buffer数据类型,最后,介绍了Buffer类型如何处理类型化数组。*

九、执行代码

本章关注的是不可信代码的执行。在这种情况下,“不可信”指的是不属于您的应用或导入模块的一部分,但仍然可以执行的代码。本章特别关注运行不可信代码的两个主要用例。第一种涉及通过产生子进程来执行应用和脚本。这个用例允许 Node 应用表现得像一个 shell 脚本,编排多个实用程序来实现一个更大的目标。第二个用例涉及 JavaScript 源代码的执行。虽然这种场景不像流程派生那样常见,但它在 Node 核心中受到支持,应该理解为eval()的替代方案。

child_process模块

用于产生子进程并与其交互的child_process核心模块 提供了几种运行这些进程的方法,每种方法提供不同级别的控制和实现复杂性。本节解释了每种方法的工作原理,并指出了每种方法的优缺点。

exec()

exec()方法 可能是启动子进程最简单的方法。exec()方法将命令(例如,从命令行发出的命令)作为其第一个参数。当exec()被调用时,一个新的 shell——在 Windows 中为cmd.exe,否则为/bin/sh——被启动并用于执行命令字符串。额外的配置选项可以通过可选的第二个参数传递给exec()。该参数(如果存在)应该是包含表 9-1 中所示的一个或多个属性的对象。

表 9-1 。exec()支持的配置选项

|

财产

|

描述

| | --- | --- | | cwd | 用于设置子进程工作目录的值。 | | env | env应该是一个对象,它的键值对指定子进程的环境。这个对象相当于子对象中的process.env。如果未指定,子进程将从父进程继承其环境。 | | encoding | 子进程的stdoutstderr流使用的字符编码。默认为utf8 (UTF-8) | | timeout | 用于在一定时间后终止子进程的属性。如果该值大于 0,进程将在timeout毫秒后终止。否则,该过程将无限期运行。该属性默认为 0。 | | maxBuffer | 子进程的stdoutstderr流中可以缓冲的最大数据量。默认为 200 KB。如果任何一个流超过了这个值,子进程就会被终止。 | | killSignal | 用于终止子进程的信号。例如,如果发生超时或者超过了最大缓冲区大小,它将被发送到子进程。默认为SIGTERM。 |

exec()的最后一个参数是子进程终止后调用的回调函数。这个函数通过三个参数调用。按照 Node 约定,第一个参数是任何错误条件。论成功,这个论点就是null。如果存在错误,参数就是Error的一个实例。第二个和第三个参数是来自子进程的缓冲的stdoutstderr数据。因为回调是在子进程终止后调用的,所以stdoutstderr参数不是流,而是包含子进程执行时通过流传递的数据的字符串。stdoutstderr各可保存总共maxBuffer字节。清单 9-1 显示了一个使用exec()的例子,它执行ls命令(Windows 用户可以替换为dir)来显示根目录的内容(注意这个例子没有使用配置选项参数)。清单 9-2 显示了一个等价的例子,一个传递配置选项的例子。在第二个示例中,要列出的目录不再在实际的命令字符串中指定。但是,cwd选项用于将工作目录设置为根目录。尽管清单 9-1 和 9-2 的输出应该是相同的,但是它们将取决于您本地机器的内容。

清单 9-1 。使用exec()显示过程的输出

var cp = require("child_process");

cp.exec("ls -l /", function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

清单 9-2 。相当于清单 9-1 中的显示(带有配置选项)

var cp = require("child_process");

cp.exec("ls -l", {
  cwd: "/"
}, function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

execFile()

execFile()方法 与exec()类似,有两点细微区别。第一个是execFile()不会产生新的壳。相反,execFile()直接执行传递给它的文件,使得execFile()exec()消耗的资源稍微少一些。第二个区别是execFile()的第一个参数是要执行的文件的名称,没有其他参数。清单 9-3 显示了如何调用ls命令来显示当前工作目录的内容。

清单 9-3 。使用execFile()执行没有附加参数的文件

var cp = require("child_process");

cp.execFile("ls", function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

image 警告因为execFile()没有产生新的 shell,Windows 用户无法让它发出dir之类的命令。在 Windows 中,dir是 shell 的内置功能。另外,execFile()不能用于运行.cmd.bat文件,它们依赖于 shell。然而,您可以使用execFile()来运行.exe文件。

如果需要向命令传递额外的参数,可以指定一个参数数组作为execFile()的第二个参数。清单 9-4 展示了这是如何完成的。在本例中,再次执行ls命令。然而,这次还传入了-l标志和/来显示根目录的内容。

清单 9-4 。向由execFile()执行的文件传递参数

var cp = require("child_process");

cp.execFile("ls", ["-l", "/"], function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

第三个参数——或者第二个,如果没有命令参数传入的话——是可选的配置对象。由于execFile()支持与exec()相同的选项,可以从表 9-1 中获得对所支持属性的解释。清单 9-5 中的例子使用了配置对象的cwd选项,在语义上等同于清单 9-4 中的代码。

清单 9-5 。相当于清单 9-4 中的,它利用了cwd选项

var cp = require("child_process");

cp.execFile("ls", ["-l"], {
  cwd: "/"
}, function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

image 注意在幕后,exec()调用execFile(),用你操作系统的 shell 作为文件参数。然后,要执行的命令被传递给数组参数中的execFile()

spawn()

exec()execFile()方法很简单,当您只需要发出一个命令并捕获它的输出时,它们工作得很好。然而,一些应用需要更复杂的交互。这就是spawn()发挥作用的地方,它是 Node 为子进程提供的最强大、最灵活的抽象(从开发人员的角度来看,它也需要做最多的工作)。spawn()也被execFile()——引申为exec()——以及fork()(本章后面会讲到)。

spawn()最多接受三个参数。第一个是要执行的命令,它应该只是可执行文件的路径。它不应该包含命令的任何参数。若要向命令传递参数,请使用可选的第二个参数。如果存在,它应该是要传递给命令的值的数组。第三个也是最后一个参数,也是可选的,用于将选项传递给spawn()本身。表 9-2 列出了spawn()支持的选项。

表 9-2 。spawn()支持的选项列表

|

财产

|

描述

| | --- | --- | | cwd | 用于设置子进程工作目录的值。 | | env | env应该是一个对象,它的键值对指定子进程的环境。这个对象相当于子对象中的process.env。如果未指定,子进程将从父进程继承其环境。 | | stdio | 用于配置子进程的标准流的数组或字符串。这一论点将在下文阐述。 | | detached | 一个布尔值,指定子进程是否将成为进程组领导。如果true,即使父终止,子也可以继续执行。这默认为false。 | | uid | 这个数字代表运行进程的用户身份,允许程序作为另一个用户运行并临时提升特权。默认为null,使子进程作为当前用户运行。 | | gid | 用于设置进程组标识的数字。默认为null,根据当前用户设置。 |

stdio选项

stdio选项 用于配置子进程的stdinstdoutstderr流。该选项可以是一个三项数组或以下字符串之一:"ignore""pipe""inherit"。在解释字符串参数之前,必须先理解数组形式。如果stdio是一个数组,第一个元素为子进程的stdin流设置文件描述符。类似地,第二个和第三个元素分别为孩子的stdoutstderr流设置文件描述符。表 9-3 列举了每个数组元素的可能值。

表 9-3 。stdio 数组条目的可能值

|

价值

|

描述

| | --- | --- | | "pipe" | 在子进程和父进程之间创建管道。spawn()返回一个ChildProcess对象(稍后将详细解释)。父对象可以通过ChildProcess对象的stdinstdoutstderr流访问子对象的标准流。 | | "ipc" | 在子进程和父进程之间创建一个进程间通信(IPC)通道,用于传递消息和文件描述符。一个子进程最多可以有一个 IPC 文件描述符。(IPC 通道将在后面的章节中详细介绍。) | | "ignore" | 导致子级的相应流被忽略。 | | 流对象 | 可以与子进程共享的可读或可写的流。流的基础文件描述符在子进程中是重复的。例如,父进程可以建立一个子进程来从文件流中读取命令。 | | 正整数 | 对应于与子进程共享的父进程中当前打开的文件描述符。 | | nullundefined | 分别对stdinstdoutstderr使用默认值 0、1 和 2。 |

如果stdio是字符串,可以是"ignore""pipe""inherit"。这些值是某些阵列配置的简写。各值的含义如表 9-4 所示。

表 9-4 。每个 stdio 字符串值的翻译

|

线

|

价值

| | --- | --- | | "ignore" | ["ignore", "ignore", "ignore"] | | "pipe" | ["pipe", "pipe", "pipe"] | | "inherit" | [process.stdin, process.stdout, process.stderr][0, 1, 2] |

ChildProcess

spawn()不接受exec()``execFile()等回调函数。相反,它返回一个ChildProcess对象。ChildProcess类继承自EventEmitter,用于与衍生的子进程交互。ChildProcess对象提供了三个流对象stdinstdoutstderr,代表底层子流程的标准流。清单 9-6 中的例子使用spawn()来运行根目录中的ls命令。然后子进程被设置为从父进程继承它的标准流。因为子级的标准流被连接到父级的流,所以子级的输出被打印到控制台。因为我们唯一真正感兴趣的是ls命令的输出,所以stdio选项也可以使用数组["ignore", process.stdout, "ignore"]来设置。

清单 9-6 。使用spawn()执行命令

var cp = require("child_process");
var child = cp.spawn("ls", ["-l"], {
  cwd: "/",
  stdio: "inherit"
});

image 注意为了复习使用标准流,请重温第五章和第七章。这一章着重于前面没有提到的内容。

在最后一个例子中,子进程的stdout流基本上是通过使用stdio属性的"inherit"值来管理的。然而,该流也可以被显式控制。清单 9-7 中的例子直接接入子进程的stdout流及其data事件处理程序。

清单 9-7 。清单 9-6 中的替代实现

var cp = require("child_process");
var child = cp.spawn("ls", ["-l", "/"]);

child.stdout.on("data", function(data) {
  process.stdout.write(data.toString());
});

error事件

当不能产生或杀死子对象时,或者当向它发送 IPC 消息失败时,ChildProcess对象发出一个error事件。ChildProcess error事件处理程序的通用格式如清单 9-8 所示。

清单 9-8 。一个事件处理程序

var cp = require("child_process");
var child = cp.spawn("ls");

child.on("error", function(error) {
  // process error here
  console.error(error.toString());
});

exit事件

当子进程终止时,ChildProcess对象发出一个exit事件。向exit事件处理程序传递了两个参数。第一个是进程被父进程终止时的退出代码(如果进程没有被父进程终止,代码参数为null))。第二个是用来杀死进程的信号。如果子进程没有被来自父进程的信号终止,这也是null。清单 9-9 显示了一个通用的exit事件处理程序。

清单 9-9 。一个事件处理程序

var cp = require("child_process");
var child = cp.spawn("ls");

child.on("exit", function(code, signal) {
  console.log("exit code:  " + code);
  console.log("exit signal:  " + signal);
});

close事件

当子进程的标准流关闭时,发出close事件 。这不同于exit事件,因为多个进程可能共享相同的流。像exit事件一样,close也提供退出代码和信号作为事件处理程序的参数。清单 9-10 中显示了一个通用的close事件处理程序。

清单 9-10 。一个事件处理程序

var cp = require("child_process");
var child = cp.spawn("ls");

child.on("close", function(code, signal) {
  console.log("exit code:  " + code);
  console.log("exit signal:  " + signal);
});

pid属性

一个ChildProcesspid属性 用于获取子进程的标识符。清单 9-11 显示了如何访问pid属性。

清单 9-11 。访问子进程的pid属性

var cp = require("child_process");
var child = cp.spawn("ls");

console.log(child.pid);

kill()

kill() 用于向子进程发送信号。这个给孩子的信号是kill()的唯一论据。如果没有提供参数,kill()发送SIGTERM信号试图终止子进程。在清单 9-12 中调用kill()的例子中,还包含了一个exit事件处理程序来显示终止信号。

清单 9-12 。使用kill()向子进程发送信号

var cp = require("child_process");
var child = cp.spawn("cat");

child.on("exit", function(code, signal) {
  console.log("Killed using " + signal);
});

child.kill("SIGTERM");

fork()

fork()``spawn()的特例,用于创建 Node 流程(见清单 9-13 )。modulePath参数是运行在子进程中的 Node 模块的路径。可选的第二个参数是一个数组,用于将参数传递给子进程。最后一个参数是一个可选对象,用于将选项传递给fork()fork()支持的选项如表 9-5 所示。

清单 9-13 。使用child_process.fork()方法

child_process.fork(modulePath, [args], [options])

表 9-5 。fork()支持的选项

|

[计]选项

|

描述

| | --- | --- | | cwd | 用于设置子进程工作目录的值。 | | env | env应该是一个对象,它的键值对指定子进程的环境。该对象相当于子对象中的process.env。如果未指定,子进程将从父进程继承其环境。 | | encoding | 子进程使用的字符编码。默认为"utf8" (UTF-8)。 |

image fork()返回的流程是 Node 的新实例,包含 V8 的完整实例。注意不要创建太多这样的进程,因为它们会消耗大量资源。

fork()返回的ChildProcess对象配备了内置的 IPC 通道,允许不同的 Node 进程通过 JSON 消息进行通信。默认情况下,子流程的标准流也与父流程相关联。

为了演示fork()如何工作,需要两个测试应用。第一个应用(见清单 9-14 )代表要执行的子模块。该模块只是打印传递给它的参数、它的环境和它的工作目录。将这段代码保存在名为child.js的文件中。

清单 9-14 。子模块

console.log("argv:  " + process.argv);
console.log("env:  " + JSON.stringify(process.env, null, 2));
console.log("cwd:  " + process.cwd());

清单 9-15 显示了相应的父进程。这段代码派生出 Node 的一个新实例,它运行清单 9-14 中的child模块。对fork()的调用传递了一个-foo参数给孩子。它还将孩子的工作目录设置为/,并提供自定义环境。当应用运行时,子进程的打印语句显示在父进程的控制台上。

清单 9-15 。清单 9-14 中显示的子模块的父模块

var cp = require("child_process");
var child;

child = cp.fork(__dirname + "/child", ["-foo"], {
  cwd: "/",
  env: {
    bar: "baz"
  }
});

send()

send()方法 使用内置的 IPC 通道在 Node 进程间传递 JSON 消息。父进程可以通过调用ChildProcess对象的send()方法来发送数据。然后,通过在process对象上设置一个message事件处理程序,可以在子流程中处理数据。类似地,子 Node 可以通过调用process.send()方法向其父 Node 发送数据。在父流程中,数据通过ChildProcessmessage事件处理程序接收。

以下示例包含两个 Node 应用,它们无限期地来回传递消息。子模块(见清单 9-16 )应该存储在一个名为message-counter.js的文件中。整个模块就是process对象的message处理程序。每次收到消息时,处理程序都会显示消息计数器。接下来,我们通过检查process.connected的值来验证父进程仍然存在,并且 IPC 通道完好无损。如果信道被连接,计数器递增,并且消息被发送回父进程。

清单 9-16 。将消息传递回其父模块的子模块

process.on("message", function(message) {
  console.log("child received:  " + message.count);

  if (process.connected) {
    message.count++;
    process.send(message);
  }
});

清单 9-17 显示了相应的父进程。父进程首先派生一个子进程,然后设置两个事件处理程序。第一个处理来自子进程的message事件。处理器显示消息计数,并检查 IPC 通道是否通过child.connected值连接。如果是,处理程序递增计数器,然后将消息传递回子进程。

第二个处理器监听SIGINT信号。如果收到了SIGINT,子进程被杀死,父进程退出。添加这个处理程序是为了允许用户终止两个程序,这两个程序正在一个无限的消息传递循环中运行。在清单 9-17 的末尾,通过向孩子发送一个计数为 0 的消息来开始消息传递。要测试这个程序,只需运行父进程。要终止,只需按下Ctrl+C

清单 9-17 。与清单 9-16 中的子模块协同工作的父模块

var cp = require("child_process");
var child = cp.fork(__dirname + "/message-counter");

child.on("message", function(message) {
  console.log("parent received:  " + message.count);

  if (child.connected) {
    message.count++;
    child.send(message);
  }
});

child.on("SIGINT", function() {
  child.kill();
  process.exit();
});

child.send({
  count: 0
});

image 注意如果通过send()传输的对象有一个名为cmd的属性,其值是一个以"NODE_"开头的字符串,那么消息不会作为message事件发出。对象{cmd: "NODE_foo"}就是一个例子。这些是 Node 核心使用的特殊消息,并导致发出internalMessage事件。官方文档强烈反对使用此功能,因为它可能会在不通知的情况下更改。

disconnect()

要关闭父进程和子进程之间的 IPC 通道,使用disconnect()方法。从父流程中,调用ChildProcessdisconnect()方法。从子进程来看,disconnect()process对象的一个方法。

disconnect(),不接受任何参数,导致几件事情发生。首先,在父进程和子进程中将ChildProcess.connectedprocess.connected设置为false。第二,在两个进程中都发出了一个disconnect事件。一旦disconnect()被调用,试图发送更多消息将导致错误。

清单 9-18 显示了一个只包含一个disconnect事件处理程序的子模块。当父进程断开连接时,子进程会向控制台打印一条消息。将这段代码存储在一个名为disconnect.js的文件中。清单 9-19 显示了相应的父进程。父进程派生一个子进程,设置一个disconnect事件处理程序,然后立即与子进程断开连接。当disconnect事件由子进程发出时,父进程也会向控制台输出一条再见消息。

清单 9-18 。实现disconnect事件处理程序的子模块

process.on("disconnect", function() {
  console.log("Goodbye from the child process");
});

清单 9-19 。与清单 9-18 中的所示的子 Node 相对应的父 Node

var cp = require("child_process");
var child = cp.fork(__dirname + "/disconnect");

child.on("disconnect", function() {
  console.log("Goodbye from the parent process");
});

child.disconnect();

vm模块

vm(虚拟机)核心模块 用于执行 JavaScript 代码的原始字符串。乍一看,它似乎只是 JavaScript 内置eval()函数的另一种实现,但是vm要强大得多。对于初学者来说,vm允许你解析一段代码并在以后运行它——这是用eval()做不到的。vm还允许您定义代码执行的上下文,使其成为eval()的更安全的替代方案。关于vm,上下文是由一个全局对象和一组内置对象和函数组成的 V8 数据结构。代码执行的上下文可以被认为是 JavaScript 环境。本节的剩余部分描述了vm为使用上下文和执行代码提供的各种方法。

image 注意 eval(),一个不与任何对象关联的全局函数,以一个字符串作为唯一的参数。这个字符串可以包含任意的 JavaScript 代码,eval()将试图执行这些代码。由eval()执行的代码拥有与调用者相同的特权,以及对当前作用域内任何变量的访问权。eval()被认为是一个安全风险,因为它让任意代码对您的数据进行读/写访问,,通常应该避免。

runInThisContext()

runInThisContext()方法允许代码使用与应用其余部分相同的上下文来执行。这个方法有两个参数。第一个是要执行的代码字符串。可选的第二个参数表示所执行代码的“文件名”。如果存在,这可以是任何字符串,因为它只是一个虚拟文件名,用于提高堆栈跟踪的可读性。清单 9-20 是一个使用runInThisContext()打印到控制台的简单例子。结果输出如清单 9-21 中的所示。

清单 9-20 。使用vm.runInThisContext()

var vm = require("vm");
var code = "console.log(foo);";

foo = "Hello vm";
vm.runInThisContext(code);

清单 9-21 。清单 9-20 中的代码生成的输出

$ node runInThisContext-hello.js
Hello vm

runInThisContext()执行的代码可以访问与您的应用相同的上下文,这意味着它可以访问所有全局定义的数据。但是,执行代码不能访问非全局变量。这大概是runInThisContext()eval()最大的区别。为了说明这个概念,首先看看清单 9-22 中的例子,它从runInThisContext()内部访问全局变量foo。回想一下,没有使用var关键字声明的 JavaScript 变量会自动成为全局变量。

清单 9-22 。在vm.runInThisContext()内更新全局变量

var vm = require("vm");
var code = "console.log(foo); foo = 'Goodbye';";

foo = "Hello vm";
vm.runInThisContext(code);
console.log(foo);

清单 9-23 显示了运行清单 9-22 中代码的输出。在这个例子中,变量foo最初保存值"Hello vm"。当runInThisContext()被执行时,foo被打印到控制台,然后赋值"Goodbye"。最后,再次打印出foo的值。在runInThisContext()内发生的分配持续存在,并且Goodbye被打印。

清单 9-23 。清单 9-22 中的代码产生的输出

$ node runInThisContext-update.js
Hello vm
Goodbye

如前所述,runInThisContext()不能访问非全局变量。清单 9-22 在清单 9-24 中被重写,因此foo现在是一个局部变量(使用var关键字声明)。另外,请注意,指定可选文件名的附加参数现在已经被传递到了runInThisContext()中。

清单 9-24 。试图访问vm.runInThisContext()中的非全局变量

var vm = require("vm");
var code = "console.log(foo);";
var foo = "Hello vm";

vm.runInThisContext(code, "example.vm");

当执行清单 9-24 中的代码时,试图访问foo时会出现ReferenceError。异常和堆栈跟踪如列表 9-25 所示。注意堆栈跟踪引用了example.vm,与runInThisContext()相关的文件名。

清单 9-25 。清单 9-24 中代码的堆栈跟踪输出

$ node runInThisContext-var.js

/home/colin/runInThisContext-var.js:5
vm.runInThisContext(code, "example.vm");
   ^
ReferenceError: foo is not defined
    at example.vm:1:13
    at Object.<anonymous> (/home/colin/runInThisContext-var.js:5:4)
    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)
    at node.js:901:3

清单 9-26 用对eval()的调用替换了对runInThisContext()的调用。结果输出也显示在清单 9-27 中。根据观察到的输出,eval()显然能够在本地范围内访问foo

清单 9-26 。使用eval()成功访问局部变量

var vm = require("vm");
var code = "console.log(foo);";
var foo = "Hello eval";

eval(code);

清单 9-27 。清单 9-26 的输出结果

$ node runInThisContext-eval.js
Hello eval

runInNewContext()

在上一节中,您看到了如何通过使用runInThisContext()而不是eval()来保护局部变量。然而,因为runInThisContext()与当前的上下文一起工作,它仍然允许不可信的代码访问您的全局数据。如果你需要进一步限制访问,使用vmrunInNewContext()方法。顾名思义,runInNewContext()创建了一个全新的上下文,代码可以在其中执行。清单 9-28 显示了runInNewContext()的用法。第一个参数是要执行的 JavaScript 字符串。第二个可选参数用作新上下文中的全局对象。第三个参数也是可选的,是堆栈跟踪中显示的文件名。

清单 9-28 。使用vm.runInNewContext()

vm.runInNewContext(code, [sandbox], [filename])

sandbox参数用于设置上下文中的全局变量,以及在runInNewContext()完成后检索值。记住,使用runInThisContext(),我们能够直接修改全局变量,并且这些改变会持续下去。然而,因为runInNewContext()使用了一组不同的全局变量,所以同样的技巧并不适用。例如,人们可能期望清单 9-29 中的代码在运行时显示"Hello vm",但事实并非如此。

清单 9-29 。试图使用vm.runInNewContext()执行代码

var vm = require("vm");
var code = "console.log(foo);";

foo = "Hello vm";
vm.runInNewContext(code);

这段代码没有成功运行,而是崩溃了,错误如清单 9-30 中的所示。出现错误是因为新的上下文无权访问应用的console对象。值得指出的是,程序崩溃前只抛出一个错误。然而,即使console可用,也会抛出第二个异常,因为全局变量foo在新的上下文中不可用。

清单 9-30 。清单 9-29 中的代码抛出的ReferenceError

ReferenceError: console is not defined

幸运的是,我们可以使用sandbox参数显式地将fooconsole对象传递给新的上下文。清单 9-31 展示了如何完成这个任务。运行时,这段代码如预期的那样显示"Hello vm"

清单 9-31vm.runInNewContext()的成功运用

var vm = require("vm");
var code = "console.log(foo);";
var sandbox;

foo = "Hello vm";
sandbox = {
  console: console,
  foo: foo
};
vm.runInNewContext(code, sandbox);

沙盒数据

关于runInNewContext()的一件好事是,对沙盒数据所做的更改实际上不会改变应用的数据。在清单 9-32 所示的例子中,全局变量fooconsole通过沙箱传递给runInNewContext()。在runInNewContext()内部,定义了一个名为bar的新变量,foo被打印到控制台,然后foo被修改。在runInNewContext()完成之后,foo会被再次打印,同时还有几个沙箱值。

清单 9-32 。创建和修改沙盒数据

var vm = require("vm");
var code = "var bar = 1; console.log(foo); foo = 'Goodbye'";
var sandbox;

foo = "Hello vm";
sandbox = {
  console: console,
  foo: foo
};
vm.runInNewContext(code, sandbox);
console.log(foo);
console.log(sandbox.foo);
console.log(sandbox.bar);

清单 9-33 显示了结果输出。"Hello vm"的第一个实例来自于runInNewContext()内部的 print 语句。不出所料,这是通过沙箱传入的foo的值。接下来,foo被设置为"Goodbye"。但是,下一个打印语句显示的是foo的原始值。这是因为runInNewContext()内部的赋值语句更新了foo的沙盒副本。最后两条 print 语句反映了foo ( "Goodbye")和bar (1)在runInNewContext()末尾的沙箱值。

清单 9-33 。清单 9-32 的输出结果

$ node runInNewContext-sandbox.js
Hello vm
Hello vm
Goodbye
1

runInContext()

Node 允许您创建单独的 V8 上下文对象,并使用runInContext()方法在其中执行代码。使用vmcreateContext()方法创建单独的上下文。runInContext()可以不带参数调用,导致它返回一个空的上下文。或者,沙盒对象可以传递给createContext(),它被浅层复制到上下文的全局对象。createContext()的用法如清单 9-34 所示。

清单 9-34 。使用vm.createContext()

vm.createContext([initSandbox])

createContext()返回的上下文对象可以作为第二个参数传递给vmrunInContext()方法,这与runInNewContext()几乎相同。唯一的区别是runInContext()的第二个参数是一个上下文对象,而不是沙箱。清单 9-35 显示了如何使用runInContext()重写清单 9-32 。不同之处在于runInContext()取代了runInNewContext()和用createContext()创建的context,,取代了sandbox变量。运行这段代码的输出与清单 9-33 中显示的相同。

清单 9-35 。使用vm.createContext()重写清单 9-34

var vm = require("vm");
var code = "var bar = 1; console.log(foo); foo = 'Goodbye'";
var context;

foo = "Hello vm";
context = vm.createContext({
  console: console,
  foo: foo
});
vm.runInContext(code, context);
console.log(foo);
console.log(context.foo);
console.log(context.bar);

createScript()

createScript()方法 ,用于编译一个 JavaScript 字符串以备将来执行,当你想多次执行代码时,这个方法很有用。createScript()方法接受两个参数,该方法返回一个无需重新解释代码就可以重复执行的vm.Script对象。首先是要编译的代码。可选的第二个参数表示将在堆栈跟踪中显示的文件名。

createScript()返回的vm.Script对象有三种执行代码的方法。这些方法是runInThisContext()runInNewContext()runInContext()的修改版本。这三种方法的用法如清单 9-36 所示。它们的行为与同名的vm方法相同。不同之处在于,这些方法不接受 JavaScript 代码字符串或文件名参数,因为它们已经是脚本对象的一部分。

清单 9-36vm.Script类型的脚本执行方法

script.runInThisContext()
script.runInNewContext([sandbox])
script.runInContext(context)

清单 9-37 显示了一个在循环中多次运行脚本的例子。在这个例子中,使用createScript()编译了一个简单的脚本。接下来,使用设置为 0 的单个值i创建沙箱。然后使用runInNewContext()在一个for循环中执行该脚本十次。每次迭代都会增加i的沙箱值。当循环完成时,沙箱被打印出来。当显示沙箱时,增量操作的累积效果是明显的,因为i的值是 10。

清单 9-37 。多次执行已编译的脚本

var vm = require("vm");
var script = vm.createScript("i++;", "example.vm");
var sandbox = {
      i: 0
    }

for (var i = 0; i < 10; i++) {
  script.runInNewContext(sandbox);
}

console.log(sandbox);
// displays {i: 10}

摘要

本章向您展示了如何以各种方式执行代码。首先讨论的是程序需要执行另一个应用的常见情况。在这些情况下,使用child_process模块中的方法。详细检查了方法exec()execFile()spawn()fork(),以及每种方法提供的不同抽象级别。接下来将介绍 JavaScript 代码字符串的执行。探索了vm模块,并将其各种方法与 JavaScript 的原生eval()函数进行了比较。还涵盖了上下文的概念和由vm提供的各种类型的上下文。最后,您学习了如何编译脚本并在以后使用vm.Script类型执行它们。

十、网络编程

到目前为止,本书中提供的示例代码都集中在您的本地机器上。无论是访问文件系统、解析命令行参数还是执行不受信任的代码,所有示例都被隔离到一台计算机上。本章开始探索本地主机之外的世界。它涵盖了网络编程并介绍了许多重要的主题,包括套接字、客户机-服务器编程、传输控制协议(TCP)、用户数据报协议(UDP)和域名服务(DNS)。对所有这些概念的完整解释超出了本书的范围,但是对它们的基本理解是至关重要的,因为它们是接下来几章中涉及的 web 应用的基础。

Sockets

当两个应用通过网络进行通信时,它们使用套接字进行通信。套接字是互联网协议(IP)地址和端口号的组合。IP 地址用于唯一标识网络上的设备,网络可以是小型家庭网络或整个互联网。该设备可以是 PC、平板电脑、智能手机、打印机或任何其他支持互联网的设备。IP 地址是 32 位数字,格式为由点分隔的四个 8 位数字。IP 地址的例子有184.168.230.12874.125.226.193。这些对应于www.cjihrig.comwww.google.com的 Web 服务器。

image 注意这里描述的 IP 地址被称为 IPv4 地址,最常见的一种。这些地址基于互联网协议版本 4。由于互联网的发展,预计 IPv4 地址的数量将会耗尽。为了缓解这个问题,互联网协议版本 6 (IPv6)被开发出来。IPv6 地址的长度为 128 位,这意味着可以代表更多的地址。IPv6 地址字符串也更长,包括十六进制值,用冒号而不是点作为分隔符。

套接字的端口组件是一个 16 位数字,用于唯一标识计算机上的通信端点。端口允许一台计算机同时维护许多套接字连接。为了更好地理解端口的概念,想象自己给在大型公司办公楼工作的人打电话。当你打电话时,你需要知道办公室的电话号码。在这个类比中,办公室是一台远程计算机,它的电话号码就是它的 IP 地址。公司办公室提供联系个人的分机。电话分机类似于端口号,您试图联系的一方代表远程机器上的一个进程或线程。进入对方分机并接通后,您就可以继续通话了。类似地,一旦两个套接字建立了通信通道,它们就可以开始来回发送数据。

前面提到过,IP 地址74.125.226.193对应于位于www.google.com的 web 服务器。要验证这一点,在你浏览器的地址栏中输入http://74.125.226.193。显然这个请求包含了服务器的 IP 地址,但是端口号在哪里呢?事实证明,谷歌的 Web 服务器接受 80 端口的连接。URL 语法允许您通过在主机后包含冒号和端口号来明确标识要连接的端口。要验证这一点,请尝试在您的浏览器中连接到http://74.125.226.193:80(或www.google.com:80)。你应该再次看到谷歌主页。现在尝试连接到http://74.125.226.193:81 ( www.google.com:81)。突然,页面再也找不到了。当www.google.com被输入地址栏时,浏览器如何知道要连接到 80 端口?为了回答这个问题,让我们回到我们的电话类比。在美国,你如何知道在紧急情况下打 911 而不是 912?答案是:因为这个国家的每个孩子都被教导在紧急情况下拨打 911。这是社会公认的惯例。

在互联网上,公共服务遵循类似的惯例。从 0 到 1023 的端口号称为知名端口,或保留端口。例如,端口 80 保留用于服务 HTTP 流量。因此,当您导航到以http://开头的 URL 时,您的浏览器会假定端口号为 80,除非您明确声明不是这样。这就是为什么 Google 的 web 服务器在端口 80 上响应了我们的请求,而不是 81。可以用 HTTPS(安全 HTTP)协议进行类似的实验。端口 443 是为 HTTPS 流量保留的。如果您在浏览器的地址栏中输入 URL http://74.125.226.193:443,将会遇到错误。然而,如果你将网址改为https://74.125.226.193:443,你将通过安全连接登陆谷歌主页。请注意,在导航过程中,您可能会遇到浏览器警告。在这种情况下,可以安全地忽略此警告。

如果您计划实现一个公共服务,比如一个 web 服务器,使用它众所周知的端口号是明智的。但是,没有什么可以阻止您在非标准端口上运行 web 服务器。例如,您可以在端口 8080 上运行 web 服务器,只要每个试图连接到服务器的人都在 URL 中明确指定端口 8080。同样,如果您正在创建自定义应用,请避免使用通常用于其他目的的端口。在为您的应用选择一个端口之前,您可能希望在 Internet 上快速搜索可能与之冲突的其他常见服务。此外,避免使用保留的端口号之一。

客户端-服务器编程

客户机-服务器模型 是一种范例,其中计算任务在服务器(提供资源的机器)和客户机(请求并消耗这些资源的机器)之间进行划分。Web 是客户机-服务器模型的一个很好的例子。当您打开浏览器窗口并导航到网站时,您的计算机充当客户端。您的计算机请求和使用的资源是网页。该网页由服务器提供,您的计算机使用套接字通过互联网连接到该服务器。这个模型的高层次抽象如图图 10-1 所示。

9781430258605_Fig10-01.jpg

图 10-1 。在互联网上工作的客户机-服务器模型

image 提示【Tip 地址127.0.0.1用来标识本地机器,称为localhost。通过让客户机连接到运行在localhost上的服务器,许多客户机-服务器应用可以在一台机器上进行测试。

image 上一节讨论了知名港口。在客户机-服务器模型中,这个概念通常只适用于服务器应用。由于客户端发起到服务器的连接,因此客户端必须知道要连接到哪个端口。另一方面,服务器不需要担心连接的客户端使用的端口。

传输控制协议

传输控制协议,简称 TCP ,是一种用于在互联网上传输数据的通信协议 。互联网数据传输不可靠。当你的计算机将一条信息发送到网络上时,这条信息首先被分解成称为信息包的小块,然后被发送到网络上,并开始向目的地前进。因为您的计算机与世界上的其他计算机没有直接连接,所以每个数据包都必须经过许多中间机器,直到找到到达目的地的路由。每个数据包都有可能采用唯一的路径到达目的地,这意味着数据包到达的顺序可能不同于它们被发送的顺序。此外,互联网不可靠,个别数据包可能会在途中丢失或损坏。

TCP 有助于给混乱的互联网带来可靠性。TCP 是所谓的面向连接的协议 ,这个术语指的是机器之间建立的虚拟连接。两台机器通过以一种被称为握手的定义模式来回发送小块数据来进入 TCP 连接。在多步握手结束时,两台机器已经建立了连接。使用这种连接,TCP 强制执行数据包之间的排序,并确认数据包在目的地被成功接收。此外,TCP 提供的功能还包括错误检查和丢失数据包的重新传输。

在 Node 生态系统中,使用 TCP 的网络编程是使用net核心模块实现的。清单 10-1 展示了如何将net模块导入到一个 Node 应用中。本模块包括创建客户端和服务器应用的方法。本节的剩余部分将探索由net提供的使用 TCP 的各种方法。

清单 10-1 。将Net模块导入应用

var net = require("net");

Creating a TCP Server

使用createServer()方法可以很容易地创建 TCP 服务器(参见清单 10-2 )。该方法有两个可选参数。第一个是包含配置选项的对象。createServer()支持单个选项allowHalfOpen,默认为false。如果该选项被明确设置为true,服务器将保持客户端连接打开,即使客户端终止它们。在这种情况下,套接字变得不可读,但仍可由服务器写入。此外,如果allowHalfOpentrue,则无论客户端做什么,都必须在服务器端显式关闭连接。这个问题将在后面讲述end()方法时详细解释。

清单 10-2 。使用net.createServer() 创建 TCP 服务器

var net = require("net");
var server = net.createServer({
  allowHalfOpen: false
}, function(socket) {
  // handle connection
});

清单 10-2 中createServer()的第二个参数是一个事件处理器,用于处理来自客户端的连接。事件处理程序接受一个参数,一个代表客户端套接字连接的net.Socket对象。在这一章的后面还会更详细地讨论net.Socket类。最后,createServer()将新创建的 TCP 服务器作为一个net.Server实例返回。net.Server类继承自EventEmitter并发出与套接字相关的事件。

监听连接

客户端无法访问由createServer()返回的服务器,因为它没有与特定端口相关联。要使服务器可访问,它必须在端口上侦听传入的客户端连接。listen()方法,其使用如清单 10-3 所示,用于将服务器绑定到指定端口。listen()唯一需要的参数是要绑定到的端口号。要监听随机选择的端口,请将 0 作为port参数传递。(请注意,通常应该避免这样做,因为客户端不知道要连接到哪个端口。)

清单 10-3 。使用net.Server.listen()方法

server.listen(port, [host], [backlog], [callback])

如果省略了host参数,服务器将接受指向任何有效 IPv4 地址的连接。要限制服务器接受的连接,请指定服务器将作为其响应的主机。此功能在具有多个网络接口的服务器上非常有用,因为它允许应用的作用范围局限于一个单独的网络。如果您的机器只有一个 IP 地址,您可以尝试这个特性。例如,清单 10-4 中的代码只接受指向localhost ( 127.0.0.1)的连接。这允许您为您的应用创建一个 web 接口,同时不会将它暴露给远程的、潜在的恶意连接。

清单 10-4 。仅接受端口 8000 上的localhost连接的代码

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen(8000, "127.0.0.1");

服务器的 backlog 是已经连接到服务器但尚未处理的客户端连接的队列。一旦积压满了,任何新的传入连接到该端口被删除。backlog参数用于指定该队列的最大长度。该值默认为 511。

listen()的最后一个参数是一个响应listening事件的事件处理程序。当服务器成功绑定到一个端口并监听连接时,它会发出一个listening事件。listening事件不为它的处理函数提供参数,但是它对于调试和日志记录非常有用。例如,清单 10-5 中的代码试图监听一个随机端口。包含了一个listening事件处理程序,显示随机选择的端口。

清单 10-5 。带有listening事件处理程序的服务器

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen(0, function() {
  var address = server.address();

  console.log("Listening on port " + address.port);
});

image 注意listen()的事件处理程序是为了方便而提供的。也可以使用on()方法添加listening事件处理程序。

address()

在清单 10-5 中,服务器的address()方法 用于显示随机选择的端口。address()方法返回一个包含服务器绑定地址、地址族和端口的对象。如前所述,port属性表示绑定端口。如果未指定host,则绑定地址从host参数到listen()"0.0.0.0"获取其值。地址族代表地址的类型(IPv4、IPv6 等。).注意,由于address()返回的值依赖于传递给listen()的参数,所以在发出listening事件之前,不应该调用这个方法。在清单 10-6 的例子中,展示了address()的另一种用法,使用了一个随机端口和地址::1(IPv6 中的localhost)。结果输出如清单 10-7 中的所示。当然,因为是随机的,你的端口号很可能是不一样的。

清单 10-6 。使用net.Server.address()

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen(0, "::1", function() {
  var address = server.address();

  console.log(address);
});

清单 10-7 。清单 10-6 中的代码产生的输出

$ node server-address.js
{ address: '::1', family: 'IPv6', port: 64269 }

听歌的变奏()

listen()方法有两个不常用的签名。第一种变化允许服务器监听已经绑定的现有服务器/套接字。新的服务器开始接受本来会被定向到现有服务器/套接字的连接。创建两个服务器server1server2的示例如清单 10-8 所示(示例输出如清单 10-9 所示)。接下来,在server2上设置一个listening事件处理程序,调用address()并显示结果。接下来,server1listen()方法被它自己的listening事件处理程序调用。这个处理程序也显示address()的结果,但是告诉server2监听server1的配置。

清单 10-8 。将服务器实例传递给listen()

var net = require("net");
var server1 = net.createServer();
var server2 = net.createServer(function(socket) {
  // handle connection
});

server2.on("listening", function() {
  console.log("server2:");
  console.log(server2.address());
});

server1.listen(0, "127.0.0.1", function() {
  console.log("server1:");
  console.log(server1.address());
  server2.listen(server1);
});

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

$ node server-listen-handle.js
server1:
{ address: '127.0.0.1', family: 'IPv4', port: 53091 }
server2:
{ address: '127.0.0.1', family: 'IPv4', port: 53091 }

请注意,address()(见清单 10-9 )的结果对于两台服务器是相同的。您还没有看到如何实际处理连接,但是值得指出的是,在这个例子中,到server1的连接被定向到server2。同样值得注意的是,listen()的这个实例接受一个listening事件处理程序作为可选的第二个参数。

listen()的最后一个变体接受一个 Unix 套接字文件名或 Windows 命名管道作为其第一个参数,接受一个listening事件处理程序作为其可选的第二个参数。在清单 10-10 中显示了一个使用 Unix 套接字的例子。

清单 10-10 。将 Unix 套接字文件传递给listen()

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen("/tmp/foo.sock");

Handling Connections

一旦服务器被绑定并侦听,它就可以开始接受连接。每当服务器接收到一个新连接时,就会发出一个connection事件。为了处理传入的连接,必须将一个connection事件处理程序传递给createServer()或使用一种方法(如on())附加。连接处理程序将一个net.Socket对象作为它唯一的参数。然后,这个套接字用于向客户端发送数据和从客户端接收数据。相同的 socket 类用于实现 TCP 客户端,因此完整的 API 将在该部分中介绍。现在,清单 10-11 展示了一个监听端口 8000 并响应客户端请求的服务器。

清单 10-11 。用简单消息响应客户机的服务器

var net = require("net");
var server = net.createServer(function(socket) {
  socket.end("Hello and Goodbye!\n");
});

server.listen(8000);

要测试服务器,像运行任何其他 Node 应用一样运行清单 10-11 中的代码。接下来,使用telnet或网络浏览器连接到服务器(telnet是用于建立网络连接和发送/接收数据的命令行实用程序)。要使用telnet测试服务器,从终端窗口发出命令telnet localhost 8000。如果使用网络浏览器,只需导航至http://localhost:8000。如果一切正常,终端或浏览器应该显示消息"Hello and Goodbye!" 清单 10-12 显示了使用telnet的输出。注意,telnet应用打印了几行实际上与服务器无关的代码。

清单 10-12 。清单 10-11 中连接到服务器的telnet输出

$ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello and Goodbye!
Connection closed by foreign host.

关闭服务器

要终止服务器,请使用close()方法。调用close()阻止服务器接受新的连接。但是,允许任何现有的连接完成它们的工作。一旦没有连接,服务器就会发出一个close事件。close()方法可选地接受一个处理close事件的事件处理程序。清单 10-13 中的例子启动了一个新的服务器,然后,一旦它在监听,就立即关闭。使用on()定义了一个close事件处理程序,而不是作为close()的参数。

清单 10-13 。监听然后立即关闭的服务器

var net = require("net");
var server = net.createServer();

server.on("close", function() {
  console.log("And now it's closed.");
});

server.listen(function() {
  console.log("The server is listening.");
  server.close();
});

ref()unref()

第四章介绍了定时器和间隔上下文中的两种方法ref()unref() 。如果计时器/时间间隔是事件循环中唯一剩余的项目,这些方法用于防止或允许 Node 应用终止。TCP 服务器有相同名称的等价方法。如果一个绑定的服务器是事件循环队列中唯一剩下的项目,调用unref()允许程序终止。这个场景在清单 10-14 中演示。相反,如果服务器是事件循环中唯一剩下的项目,调用ref()会恢复阻止应用退出的默认行为。

清单 10-14 。调用unref()后立即关机的服务器

var net = require("net");
var server = net.createServer();

server.listen();
server.unref();

错误事件

当事情出错时,net.Server实例发出error事件。一个常见的异常是EADDRINUSE错误,当一个应用试图使用一个已经被另一个应用使用的端口时就会出现这个错误。清单 10-15 展示了如何检测和处理这种类型的错误。一旦检测到错误,您的应用可以尝试连接到另一个端口,在尝试再次连接到同一个端口之前等待,或者直接退出。

清单 10-15 。检测端口已被使用的错误的处理程序

server.on("error", function(error) {
  if (error.code === "EADDRINUSE") {
    console.error("Port is already in use");
  }
});

另一个常见的错误是EACCES,当您没有足够的权限绑定到一个端口时抛出的异常。在 Unix 风格的操作系统上,当您试图绑定到保留端口时,会出现这些错误。例如,web 服务器通常需要管理员权限才能绑定到端口 80。

创建 TCP 客户端

net模块提供了两种方法,connect()createConnection() ,它们可以互换使用来创建 TCP 客户端套接字。这些客户端套接字用于连接本章中创建的服务器应用。本书通篇使用connect()是因为它的名字更短。请注意,在任何情况下,createConnection()都可以代替connect()connect()有三个实例,第一个如清单 10-16 所示。

清单 10-16net.connect()方法的一个用途

net.connect(port, [host], [connectListener])

在清单 10-16 中,在port指定的端口上创建了一个到host指定的机器的 TCP 连接。如果未指定host,则连接到localhost。如果连接成功建立,客户端将发出一个没有参数的connect事件。可选的第三个参数connectListener是一个事件处理程序,它将处理连接event。清单 10-17 显示了一个客户端连接到localhost上的端口 8000。这个客户端可以用清单 10-11 中创建的服务器进行测试。首先打开终端窗口并运行服务器应用。接下来,打开一个单独的终端窗口并运行客户端应用。成功连接到服务器后,客户端会显示一条消息。服务器返回的实际数据并没有显示出来(稍后会详细介绍)。

清单 10-17 。连接到端口 8000 上的localhost的客户端

var net = require("net");
var client = net.connect(8000, "localhost", function() {
  console.log("Connection established");
});

第二个版本的connect()将一个 Unix 套接字文件名或 Windows 命名管道作为第一个参数,将一个可选的connect事件处理程序作为第二个参数。清单 10-17 已被重写,以使用清单 10-18 中的 Unix 套接字文件。为了测试这个客户机,使用清单 10-19 中所示的修改后的服务器,它绑定到同一个套接字文件。

清单 10-18 。连接到套接字文件/tmp/foo.sock的客户端

var net = require("net");
var client = net.connect("/tmp/foo.sock", function() {
  console.log("Connection established");
});

清单 10-19 。用于测试清单 10-18 中的客户端的服务器

var net = require("net");
var server = net.createServer(function(socket) {
  socket.end("Hello and Goodbye!\n");
});

server.listen("/tmp/foo.sock");

connect()的最终版本采用一个配置对象和一个可选的connect事件处理程序作为参数。表 10-1 显示了配置对象支持的属性。清单 10-20 重写了清单 10-17 来使用这种形式的connect()。类似地,清单 10-21 重写了清单 10-18 。

表 10-1 。connect()支持的配置选项列表

|

财产

|

描述

| | --- | --- | | port | 如果通过 TCP 套接字连接(相对于 Unix 套接字文件或 Windows 命名管道),这将指定客户端应该连接的端口号。这是必需的。 | | host | 如果通过 TCP 套接字连接,这将指定要连接的主机。如果省略,默认为localhost。 | | localAddress | 创建连接时使用的本地接口。当一台机器有多个网络接口时,此选项很有用。 | | path | 如果连接到 Unix 套接字文件或 Windows 命名管道,这用于指定路径。 | | allowHalfOpen | 如果true,客户端不会在服务器关闭连接时关闭连接。相反,必须手动关闭连接。这默认为false。 |

清单 10-20 。连接到端口 8000 上的localhost的客户端

var net = require("net");
var client = net.connect({
  port: 8000,
  host: "localhost"
}, function() {
  console.log("Connection established");
});

清单 10-21 。连接到套接字文件/tmp/foo.sock的客户端

var net = require("net");
var client = net.connect({
  path: "/tmp/foo.sock"
}, function() {
  console.log("Connection established");
});

net.Socket类类

理解net.Socket类对于客户机和服务器开发都是必不可少的。在服务器端,一个套接字被传递给connection事件处理程序。在客户端,connect()返回一个套接字。由于 socket 类使用流来移动数据,您已经知道了一些基础知识(如果您需要复习,请重新阅读第七章)。例如,从一个套接字读取数据使用所有你已经知道并喜欢的可读流基础,包括data事件和pause()resume()方法。清单 10-22 显示了使用流从套接字读取数据是多么简单。这个客户端与清单 10-11 中的服务器协同工作,使用一个data事件处理程序从套接字读取数据并将数据打印到控制台。

清单 10-22 。在清单 10-11 的中,客户端显示从服务器读取的数据

var net = require("net");
var clientSocket = net.connect({
  port: 8000,
  host: "localhost"
});

clientSocket.setEncoding("utf8");

clientSocket.on("data", function(data) {
  process.stdout.write(data);
});

向套接字写入数据也可以使用 stream write()方法来完成。套接字有一个额外的方法end(),它关闭连接。end()可以有选择地通过dataencoding类似于write()的论证。因此,可以使用单个函数调用编写和关闭一个套接字(在清单 10-11 中的服务器中以这种方式使用了end())。注意,必须至少调用一次end()来关闭连接。此外,在调用end()后试图写入套接字会导致错误。

socket 类有几个其他的事件和方法,您应该已经知道了。例如,套接字有ref()unref()方法,如果套接字是事件循环中唯一剩余的项,它们会影响应用终止的能力。套接字也有一个address()方法,它返回连接套接字的绑定地址、端口号和地址族。关于事件,当写缓冲区变空时发出一个drain事件,当异常发生时发出一个error事件。

本地和远程地址

如前所述,address()方法返回一个包含本地绑定地址、其家族类型和使用的端口的对象。还有四个属性——remoteAddressremotePortlocalAddresslocalPort——提供关于套接字的远程和本地端点的信息。清单 10-23 中显示了这些属性的一个例子。

清单 10-23 。一个显示本地和远程地址和端口的例子

var net = require("net");
var client = net.connect(8000, function() {
  console.log("Local endpoint " + client.localAddress + ":" +
               client.localPort);
  console.log("is connected to");
  console.log("Remote endpoint " + client.remoteAddress + ":" +
               client.remotePort);
});

关闭套接字

如前所述,使用end()方法关闭套接字。技术上来说,end()只是半开插座。连接的另一端仍有可能继续发送数据。如果您需要完全关闭套接字——例如,在出现错误的情况下——您可以使用destroy()方法,它可以确保套接字上不再发生 I/O。

当远程主机调用end()destroy()时,本地端发出一个end事件。如果创建套接字时将allowHalfOpen选项设置为false(缺省值),本地端将写出所有未决数据,并关闭其连接端。但如果allowHalfOpen为真,本地端必须显式调用end()destroy()。一旦连接的两端都关闭,就会发出一个close事件。如果有一个close事件处理程序,它将接受一个布尔参数,如果套接字有任何传输错误,则为true,否则为false

清单 10-24 包括一个将其allowHalfOpen选项设置为true的客户端。该示例还包括endclose事件处理程序。注意,end()方法在end处理程序中被显式调用。如果这一行不存在,连接就不会完全关闭,也不会发出close事件。

清单 10-24 。带有endclose事件处理程序的客户端

var net = require("net");
var client = net.connect({
  port: 8000,
  host: "localhost",
  allowHalfOpen: true
});

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

client.on("close", function(error) {
  console.log("close handler");
  console.log("had error:  " + error);
});

Timeouts

默认情况下,套接字没有超时。这可能很糟糕,因为如果网络或远程主机出现故障,连接将无限期地处于空闲状态。但是,您可以使用套接字的setTimeout()方法在套接字上定义超时(不要与用于创建计时器的核心 JavaScript 方法混淆)。这个版本的setTimeout()将一个以毫秒为单位的超时作为它的第一个参数。如果套接字空闲了这段时间,就会发出一个timeout事件。一次性的timeout事件处理程序可以作为第二个参数传递给setTimeout()。一个timeout事件不关闭套接字;您负责使用end()destroy()关闭它。此外,您可以通过将 0 传递给setTimeout()来移除现有的超时。清单 10-25 显示了如何在一个套接字上创建十秒钟的超时。在本例中,当超时发生时,打印一条错误消息并关闭套接字。

清单 10-25 。有十秒钟超时的客户端

var net = require("net");
var client = net.connect(8000, "localhost");

client.setTimeout(10000, function() {
  console.error("Ten second timeout elapsed");
  client.end();
});

套接字、服务器和子进程

第九章展示了如何使用fork()方法创建 Node 子流程。使用send()方法,可以在进程间通信通道上的这些进程之间传输数据。要传输的数据作为第一个参数传递给send()。第九章中没有提到的是send()方法采用可选的第二个参数,TCP 套接字或服务器,它允许多个进程共享一个网络连接。如您所知,Node 进程是单线程的。产生共享单个套接字的多个进程允许更好地利用现代多核硬件。当涉及到cluster模块时,将在第十六章中更详细地回顾这个用例。

清单 10-26 包含了创建一个新的 TCP 服务器,派生一个子进程,并将服务器作为server消息传递给子进程的代码。子进程的代码(见清单 10-27 )应该保存在一个名为child.js的文件中。子进程检测server消息并设置一个connection处理程序。要验证套接字是否由两个进程共享,请建立到端口 8000 的多个连接。您将看到一些连接用"Handled by parent process"响应,而另一些用"Handled by child process"响应。

清单 10-26 。将 TCP 服务器传递给分叉的子进程

var cp = require("child_process");
var net = require("net");
var server = net.createServer();
var child = cp.fork("child");

server.on("connection", function(socket) {
  socket.end("Handled by parent process");
});

server.listen(8000, function() {
  child.send("server", server);
});

清单 10-27 。与清单 10-26 中的一起工作的child.js代码

process.on("message", function(message, server) {
  if (message === "server") {
    server.on("connection", function(socket) {
      socket.end("Handled by child process");
    });
  }
});

用户数据报协议

用户数据报协议,或 UDP ,是 TCP 的替代方案。UDP 和 TCP 一样,运行在 IP 之上。然而,UDP 并不包括许多使 TCP 如此可靠的特性。例如,UDP 在通信期间不建立连接。它也缺乏消息排序、有保证的传递和丢失数据的重新传输。由于协议开销较少,UDP 通信通常比 TCP 更快、更简单。硬币的另一面是,UDP 与底层网络一样可靠,因此数据很容易丢失。UDP 通常适用于音频和视频流等应用,在这些应用中,性能至关重要,一些数据可能会丢失。在这些应用中,一些丢失的数据包可能会对播放质量产生最低程度的影响,但媒体仍然可用。另一方面,UDP 不适合查看网页,因为即使一个丢失的数据包也会破坏页面的呈现能力。

要在 Node 应用中包含 UDP 功能,请使用dgram核心模块。清单 10-28 展示了这个模块是如何导入的。本节的剩余部分将探索由dgram模块提供的各种方法。

清单 10-28 。导入dgram核心模块

var dgram = require("dgram");

创建 UDP 套接字

客户机和服务器套接字都是使用createSocket()方法 创建的。指定套接字类型的createSocket()的第一个参数应该是"udp4""udp6"(对应于 IPv4 和 IPv6)。第二个参数(可选)是一个回调函数,用于处理通过套接字接收数据时发出的message事件。清单 10-29 中显示了一个创建新 UDP 套接字的示例。这个例子包含了一个message事件处理程序,当涉及到接收数据时将会被重新访问。

清单 10-29 。创建 UDP 套接字和message事件处理程序

var dgram = require("dgram");
var socket = dgram.createSocket("udp4", function(msg, rinfo) {
  console.log("Received data");
});

绑定到端口

创建套接字时,它使用随机分配的端口号。然而,服务器应用通常需要监听预定义的端口。UDP 套接字可以使用bind()方法 监听指定的端口,其用法如清单 10-30 所示。port参数是要绑定到的端口号。可选的address参数指定监听的 IP 地址(如果服务器有多个网络接口,这很有用)。如果省略,套接字将监听所有地址。可选的回调函数是一次性的listening事件处理程序。

清单 10-30 。使用bind()方法

socket.bind(port, [address], [callback])

清单 10-31 中的显示了bind()的一个例子。这个例子创建了一个 UDP 套接字并将其绑定到端口 8000。为了验证一切工作正常,绑定的地址被打印到控制台。清单 10-32 显示了结果输出。

清单 10-31 。将 UDP 套接字绑定到端口 8000

var dgram = require("dgram");
var server = dgram.createSocket("udp4");

server.bind(8000, function() {
  console.log("bound to ");
  console.log(server.address());
});

清单 10-32 。运行清单 10-31 中代码的输出

$ node udp-bind.js
bound to
{ address: '0.0.0.0', family: 'IPv4', port: 8000 }

Receiving Data

当在 UDP 套接字上接收到数据时,会发出一个message事件来触发任何现有的message事件处理程序。一个message事件处理程序接受两个参数,一个Buffer代表数据,一个对象包含发送者的信息。在清单 10-33 中,创建了一个绑定到端口 8000 的 UDP 服务器。当收到消息时,服务器显示消息大小、远程主机的 IP 地址和端口以及消息有效负载。

清单 10-33 。接收和显示消息的服务器

var dgram = require("dgram");
var server = dgram.createSocket("udp4", function(msg, rinfo) {
  console.log("received " + rinfo.size + " bytes");
  console.log("from " + rinfo.address + ":" + rinfo.port);
  console.log("message is:  " + msg.toString());
});

server.bind(8000);

接下来,让我们看看如何发送数据来测试服务器。

发送数据

使用send()方法 通过 UDP 套接字发送数据。清单 10-34 显示了如何使用这种方法。send()传输的数据来自一个Buffer,用buffer自变量表示。offset参数指定相关数据在缓冲区中的起始位置,而length指定要发送的字节数,从偏移量开始。由于 UDP 是一种无连接协议,因此在发送前没有必要连接到远程机器。因此,远程端口和地址是send()的参数。send()的最后一个参数是一个可选的回调函数,在数据发送后调用。回调函数有两个参数,代表潜在的错误和发送的字节数。包含这个回调是验证数据是否被实际发送的唯一方法。但是,UDP 没有用于验证数据已收到的内置机制。

清单 10-34 。使用send()方法

socket.send(buffer, offset, length, port, address, [callback])

清单 10-35 中的客户端代码可以与清单 10-33 中的服务器结合使用。客户端向服务器发送消息,然后服务器显示该消息。请注意,客户端的回调函数检查错误并报告发送的字节数,然后关闭连接。一旦套接字关闭,就会发出一个close事件,而不会发出新的message事件。

清单 10-35 。从清单 10-33 向服务器发送数据的客户端

var dgram = require("dgram");
var client = dgram.createSocket("udp4");
var message = new Buffer("Hello UDP");

client.send(message, 0, message.length, 8000, "127.0.0.1", function(error, bytes) {
  if (error) {
    console.error("An error occurred while sending");
  } else {
    console.log("Successfully sent " + bytes + " bytes");
  }

  client.close();
});

域名系统

域名系统(DNS )是一个分布式网络,它将域名映射到 IP 地址。DNS 是需要的,因为人们更容易记住名字,而不是一长串数字。DNS 可以被认为是互联网的电话簿。当您想要访问某个网站时,您可以在导航栏中键入其域名。然后,您的浏览器对该域名发出 DNS 查找请求。然后,DNS 查找返回该域的相应 IP 地址,假设它存在。

在 Node 生态系统中,DNS 通常在幕后处理,这意味着开发人员提供一个 IP 地址或域名,一切正常。但是,如果需要,可以使用dns核心模块直接访问 DNS。本节探讨用于 DNS 查找和反向查找的最重要的方法,这些方法将 IP 地址映射到域名。

执行查找

最重要的 DNS 方法可能是lookup(),它将一个域名作为输入,并返回找到的第一个 IPv4 或 IPv6 DNS 记录。lookup()方法 接受可选的第二个参数,指定要搜索的地址族。此参数默认为null,但也可以是46,对应 IPv4 或 IPv6 地址族。如果族参数为null,则同时搜索 IPv4 和 IPv6 地址。

lookup()的最后一个参数是一个回调函数,一旦 DNS 查找完成就调用这个函数。回调函数有三个参数,erroraddressfamilyerror参数表示发生的任何异常。如果查找由于任何原因失败,error.code被设置为字符串"ENOENT"address参数是字符串形式的 IP 地址,而family参数是46

在清单 10-36 的中,执行google.com的 DNS 查找。其输出如清单 10-37 中的所示。在本例中,DNS 查找仅限于 IPv4 地址。请注意,由于 Google 使用多个 IP 地址,您观察到的 IP 地址可能会有所不同。

清单 10-36 。执行 DNS 查找

var dns = require("dns");
var domain = "google.com";

dns.lookup(domain, 4, function(error, address, family) {
  if (error) {
    console.error("DNS lookup failed with code " + error.code);
  } else {
    console.log(domain + " -> " + address);
  }
});

清单 10-37 。清单 10-36 中代码的结果输出

$ node dns-lookup.js
google.com-> 74.125.226.229

resolve()

lookup()方法返回找到的第一个 IPv4 或 IPv6 DNS 记录。然而,还有其他类型的记录,并且每种类型可以有多个记录。要以数组格式检索特定类型的多个 DNS 记录,请使用resolve()来代替。resolve()的用法如清单 10-38 所示。

清单 10-38 。使用resolve()方法

dns.resolve(domain, [recordType], callback)

domain参数是要解析的域名。可选的recordType参数指定要查找的 DNS 记录的类型。表 10-2 列出了resolve()支持的各种 DNS 记录类型。如果没有提供recordTyperesolve()查找A记录(IPv4 地址记录)。第三个参数是在 DNS 查找之后调用的回调函数。一个可能的Error对象和一组 DNS 响应被传递给回调函数。

image注还有许多方法(如表 10-2 的第三列所示)用于解析特定类型的记录。每种方法的行为类似于resolve(),但是只适用于单一类型的记录,因此不需要recordType参数。例如,如果您对检索CNAME记录感兴趣,只需调用dns.resolveCname()

表 10-2 。resolve()支持的各种 DNS 记录类型

|

留档活字

|

描述

|

方法

| | --- | --- | --- | | A | IPv4 地址记录。这是resolve()的默认行为。 | dns.resolve4() | | AAAA | IPv6 地址记录。 | dns.resolve6() | | MX | 邮件交换记录。这些记录将一个域映射到邮件传输代理。 | dns.resolveMx() | | TXT | 文字记录。这些记录应该包括人类可读的文本。 | dns.resolveTxt() | | SRV | 服务定位器记录。这些记录将服务映射到位置。这些用于映射新协议,而不是为每个协议创建新的 DNS 记录类型。 | dns.resolveSrv() | | PTR | 指针记录。这些记录用于反向 DNS 查找。 | 没有人 | | NS | 名称服务器记录。这些委派一个 DNS 区域来使用给定的服务器名称。 | dns.resolveNs() | | CNAME | 规范的名称记录。这些用于将一个域作为另一个域的别名。 | dns.resolveCname() |

清单 10-39 显示了通过查找与域google.com相关的 IPv6 地址(AAAA DNS 记录)来使用resolve()的例子。如果没有错误发生,域和地址数组将被打印到控制台。

清单 10-39 。使用resolve()查找google.com的 IPv6 地址

var dns = require("dns");
var domain = "google.com";

dns.resolve(domain, "AAAA", function(error, addresses) {
  if (error) {
    console.error("DNS lookup failed with code " + error.code);
  } else {
    console.log(domain + " -> " + addresses);
  }
});

反向查找

反向 DNS 查找将 IP 地址解析为域。在 Node 中,这种类型的查找是使用dns模块的reverse()方法 实现的。这个方法有两个参数,一个 IP 地址和一个回调函数。回调函数的参数是代表潜在错误的error和域名数组domains。在使用reverse()的例子中,如清单 10-40 所示,对www.google.com执行 DNS 查找。产生的 IP 地址然后用于执行反向 DNS 查找。

清单 10-40 。执行 DNS 查找,然后反向查找

var dns = require("dns");
var domain = "www.google.com";

dns.lookup(domain, 4, function(error, address, family) {
  dns.reverse(address, function(error, domains) {
    console.log(domain + " -> " + address + " -> " + domains);
  });
});

image 注意根据网站的 DNS 配置,反向搜索的结果可能会让你大吃一惊。如果一个站点没有建立任何PTR记录,反向查找可能是不可能的。例如,当清单 10-40 中的代码为www.nodejs.org运行时,反向查找返回undefined

Detecting Valid IP Addresses

为了结束这一章,让我们回到net模块,研究一些有用的实用方法。net模块提供了三种识别有效 IP 地址的方法:isIP()isIPv4()isIPv6()。每个方法都接受一个要测试的参数作为输入。isIP()检查其输入是否是有效的 IPv4 或 IPv6 地址。如果输入是 IPv4、IPv6 或无效,则isIP()返回460isIPv4()isIPv6()更具体,返回truefalse表示输入是否在给定的地址族中。列出 10-41 展示了在各种输入字符串上调用的所有三种方法。清单 10-42 显示了结果。

清单 。IP 地址分类

var net = require("net");
var input1 = "127.0.0.1";
var input2 = "fe80::1610:9fff:fee4:d63d";
var input3 = "foo";

function classify(input) {
  console.log("isIP('" + input + "') = " + net.isIP(input));
  console.log("isIPv4('" + input + "') = " + net.isIPv4(input));
  console.log("isIPv6('" + input + "') = " + net.isIPv6(input));
  console.log();
}

classify(input1);
classify(input2);
classify(input3);

清单 10-42 。清单中代码的输出 10-41

$ node ip-address-classification.js
isIP('127.0.0.1') = 4
isIPv4('127.0.0.1') = true
isIPv6('127.0.0.1') = false

isIP('fe80::1610:9fff:fee4:d63d') = 6
isIPv4('fe80::1610:9fff:fee4:d63d') = false
isIPv6('fe80::1610:9fff:fee4:d63d') = true

isIP('foo') = 0
isIPv4('foo') = false
isIPv6('foo') = false

摘要

本章提供了大量关于网络编程的信息。它的很多内容在 Node 的世界之外都是适用的。无论您使用哪种语言进行开发,对 IP、TCP、UDP 和 DNS 等流行网络主题的一般知识都会派上用场。当然,本章的主要焦点是网络编程,因为它与 Node 有关。到目前为止,您应该对netdgramdns核心模块有了很好的理解。但是,由于这些模块中的所有内容无法在一章中涵盖,因此建议您浏览 Node 文档,看看还有哪些内容是可行的。

这本书接下来的几章集中在创建 web 应用上。大多数人将 Node 与 web 服务器/应用联系在一起(尽管您现在应该意识到 Node 可以做更多事情)。由于 Web 应用主要使用建立在本章讨论的协议之上的更高级别的协议(如 HTTP ),所以您需要理解这里所涉及的内容。