三、Node.js api 和 Web 抓取

学习目标

在本章结束时,你将能够:

  • 使用全局对象实现 Node.js 应用
  • 创建可读和可写流
  • 使用异步和同步 api 读写文件
  • 使用 http 模块创建静态和动态 web 服务器
  • 使用 http/https 模块从网站下载内容
  • 从解析的 HTML 内容中查询和提取数据

在本章中,我们将学习全局对象和函数。 然后,我们将学习如何使用 http 模块编写高效的 web 服务器,包括静态和动态的。 最后,我们将使用 http 和 https 模块来抓取网页并从中提取数据。

简介

从一开始,Node.js 的创建就为第一代 HTTP 服务器的每请求线程模型提供了一种替代方案。 Node.js 的事件循环和异步特性使其成为 I/ o 密集型服务器的理想选择,这些服务器需要为大量并发客户端提供高吞吐量。 正因为如此,它配备了强大且易于使用的 api 来构建 HTTP 服务器。

在前一章中,我们讨论了什么是 Node.js 和 NPM 以及它们是如何工作的。 在本章中,你将学习 Node.js 中每个脚本都可以使用的基本全局对象。 您将了解可读流和可写流,以及如何使用它们异步读写文件。 您还将学习如何使用同步文件系统 api 读取和写入文件。

在最后几节中,您将学习如何使用 HTTP 模块来编写 web 服务器和发出 HTTP 请求。 您将构建一个静态和一个动态的 web 服务器。 然后,您将学习 web 抓取的基础知识,以及如何使用它从网站提取数据。

Globals

Node.js 的执行上下文包含一些全局变量和函数,这些变量和函数可以在任何脚本的任何地方使用。 其中使用最广泛的是require函数,因为它可以帮助你加载其他模块和访问 Node.js api 中可用的非全局函数、类和变量。

你一定注意到在上一章我们从你安装的应用包中加载commander模块时使用了这个函数:

const program = require('commander');

它接收一个参数,该参数是一个字符串,表示要加载的模块的 ID,并返回模块的内容。 内部模块,比如我们将在本章中讨论的模块,以及从包中加载并由 npm 安装的模块,都直接通过它们的名称来标识,比如 commander、fs 和 http。 在第 5 章,模块化 JavaScript中,您将看到如何创建自己的模块,以及如何使用这个函数来加载它们。

另一个重要且广泛使用的全局变量是控制台。 就像在 Chrome 开发工具,控制台可以使用标准输出和标准错误打印文本到终端。 它还可以用于将文本打印到文件中以进行日志记录。

到目前为止,您已经多次使用了控制台,比如在上一章的最后一个练习中,您打印了以下经过操纵的 HTML:

console.log(html);

除了log功能,控制台还有很多功能。 让我们更深入地了解它的一些应用。

当你想打印一些文本到控制台,你可以使用下列任何一个功能:debugerrorinfo,和warn。 它们之间的区别在于文本的结尾。 当您使用debuginfo方法时,文本将被打印到标准输出。 对于warnerror,消息将打印为标准错误。

确保你有以下代码在index.js:

console.debug('This will go to Standard Output');
console.info('This will also go to Standard Output');
console.warn('This will go to standard error');
console.error('Same here');

现在,运行脚本并重定向到不同的文件,然后打印它们的内容:

$ node index.js > std.out 2> err.out
$ cat std.out 
This will go to Standard Output
This will also go to Standard Output
$ cat err.out 
This will go to standard error
Same here

所有前面的函数以及日志函数都可以格式化文本,如果需要的话,它们可以通过提供额外的参数和格式字符串来实现。 你可以在util.format函数文档中阅读更多关于格式字符串的信息:https://nodejs.org/dist/latest-v12.x/docs/api/util.html#util_util_format_format_args。 如果你喜欢,也可以使用反勾号:

const whatILike = 'cheese';
console.log('I like %s', whatILike);
console.log(`I like ${whatILike}`);

产出如下:

I like cheese
I like cheese

如果需要有条件地打印一些文本,可以使用assert。 断言可用于检查条件是否为真。 如果为 false,则它将使用console.warn打印文本,并使用一些文本说明断言失败。 如果这是真的,它就不会打印任何东西。 下面是一个例子:

console.assert(1 == 1, 'One is equal to one');
console.assert(2 == 1, 'Oh no! One is not equal to two');

这将只输出以下内容:

Assertion failed: Oh no! One is not equal to two

函数的作用是:识别源文件和输出行。 它接收与 log 和其他语句相同的参数,但它也将打印 log 语句的堆栈跟踪; 也就是调用发生的文件名和行:

console.trace('You can easily find me.');

这将打印以下内容:

Trace: You can easily find me.
    at Object.<anonymous> (.../Lesson03/sample_globals/console.js:14:9)
    at Module._compile (internal/modules/cjs/loader.js:776:30)
    at Object.Module._extensions.js (internal/modules/cjs/loader.js:787:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:829:12)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:622:3)

如果您有一个数据数组并希望将其显示为一个表,则可以使用 table 方法。 它接收两个参数:表格数据和您希望在表格中看到的属性。 例如,考虑以下表格数据(对象数组):

const myTable = [
  { name: 'John Doe', age: 10 },
  { name: 'Jane Doe', age: 17 },
];

你可以通过传递数据到console.table来打印所有的列:

console.table(myTable);

这将给我们以下输出:

Figure 3.1: Output of the console.table function

图 3.1:控制台的输出 表函数

或者,你可以传递一个你想要的属性名列表:

console.table(myTable, ['name']);

下面是上述代码的输出:

图 3.2:控制台的输出 当传递要打印的属性列表时

您可以使用console进行的另一件有趣的事情是计算代码的特定部分运行所需的时间。 为此,您可以使用timetimeEnd方法,如下例所示:

console.time();
blockFor2Seconds();
console.timeEnd();

这将输出以下内容:

default: 2000.911ms

你也可以给你的定时器一个名字,同时使用多个定时器:

console.time('Outer');
console.time('Inner');
blockFor2Seconds();
console.timeEnd('Inner');
console.timeEnd('Outer');

这将输出以下内容:

Inner: 2000.304ms
Outer: 2000.540ms

有时,您想知道脚本是从哪里加载的,或者文件的完整路径是什么。 为此,每个脚本都定义了两个全局变量:__filename__dirname(两个下划线,然后是 filename/dirname)。 一个例子如下:

console.log(`This script is in: ${__dirname}`);
console.log(`The full path for this file is: ${__filename}`);

这将输出以下内容:

This script is in: /.../Lesson03/sample_globals
The full path for this file is: /.../Lesson03/sample_globals/dir_and_filename.js

在浏览器中,当您希望将来或定期执行某个函数时,可以分别使用setTimeoutsetInterval。 这些函数在 Node.js 执行上下文中也可用,其工作方式与在浏览器中相同。

你可以通过传递一个待执行的回调函数和你希望它在未来执行的毫秒数来调度代码的执行时间:

const start = Date.now();
setTimeout(() => {
  console.log('I'm ${Date.now() - start}ms late.');
}, 1000);

在浏览器中,setTimeout返回一个计时器 ID,它是一个整数,只能通过clearTimeout函数取消计时器。 在 Node.js 中,setTimeout返回一个本身有一些方法的Timeout对象。 一个有趣的方法是refresh方法,该方法将计时器的开始时间重置为当前时间,并重新开始计算计时器,就好像它是在那个时刻被安排的一样。 看看下面的示例代码:

const secondTimer = setTimeout(() => {
  console.log(`I am ${Date.now() - start}ms late.');
}, 3000);
setTimeout(() => {
  console.log(`Refreshing second timer at ${Date.now() - start}ms`);
  secondTimer.refresh();
}, 2000);

这将打印以下内容:

Refreshing second timer at 2002ms
I am 5004ms late.

从输出中,您可以看到,即使secondTimer计划在未来 3 秒内运行,但它在未来 5 秒内运行。 这是因为第二个setTimeout,它被设置为 2 秒,刷新它,重新启动计数,在 3 秒计时器上增加了 2 秒。

正如我们前面提到的,您可以使用Timeout实例来使用clearTimeout函数取消计时器。 下面的代码就是一个例子:

const thirdTimer = setTimeout(() => {
  console.log('I am never going to be executed.');
}, 5000);
setTimeout(() => {
  console.log('Cancelling third timer at ${Date.now() - start}ms');
  clearTimeout(thirdTimer);
}, 2000);

这段代码的输出如下:

Cancelling third timer at 2007ms

setTimeout只执行一次。 你可以用setInterval在特定的时间内完成特定的任务。 setInterval还返回一个Timeout实例,该实例可用于使用clearInterval取消计时器。 下面的示例设置一个计时器,使其每秒钟运行一次,并跟踪它运行的次数。 在执行一定次数后,它取消计时器:

let counter = 0;
const MAX = 5;
const start = Date.now();
const timeout = setInterval(() => {
  console.log(`Executing ${Date.now() - start}ms in the future.`);
  counter++
  if (counter >= MAX) {
    console.log(`Ran for too long, cancelling it at ${Date.now() - start}ms`);
    clearInterval(timeout);
  }
}, 1000);

这段代码的输出如下所示:

Executing 1004ms in the future.
Executing 2009ms in the future.
Executing 3013ms in the future.
Executing 4018ms in the future.
Executing 5023ms in the future.
Ran for too long, cancelling it at 5023ms

在浏览器中,我们有一个全局对象 window,它表示浏览器。 在 Node.js 中,我们有 process,它表示当前正在运行的应用。 通过它,我们可以访问传递给应用的参数,包括标准输入和输出,以及关于进程的其他信息,比如版本或进程 ID。

要访问传递给进程的参数,可以使用全局变量 process 的argv属性。 argv是一个数组,它包含一个位置上的每个参数。 它包括 Node.js 二进制文件的路径和脚本的完整路径作为前两个元素。 在此之后,所有其他额外参数都被传递进来。

下面的代码将打印传入的所有参数,每个参数在一行中:

console.log(`Arguments are:\n${process.argv.join('\n')}`);

让我们看看这个单行应用的一些示例输出。

没有额外的参数:

$ node argv.js 
Arguments are:
/usr/local/bin/node
/Users/visola/git/Professional-JavaScript/Lesson03/sample_globals/argv.js

一个接一个的多个参数:

$ node argv.js this is a test
Arguments are:
/usr/local/bin/node
/Users/visola/git/Professional-JavaScript/Lesson03/sample_globals/argv.js
this
is
a
test

一个参数全部在一个字符串中:

$ node argv.js 'this is a test'
Arguments are:
/usr/local/bin/node
/Users/visola/git/Professional-JavaScript/Lesson03/sample_globals/argv.js
this is a test

在前一章中,我们使用了commander库来解析命令行参数。 当配置commander时,对它的最后一个调用是parse(process.argv),这让commander访问所有传入的选项:

program.version('0.1.0')
  .option('-b, --add-bootstrap', 'Add Bootstrap 4 to the page.')
  .option('-c, --add-container', 'Adds a div with container id in the body.')
  .option('-t, --title [title]', 'Add a title to the page.')
  .parse(process.argv);

流程变量的另一个重要作用是访问标准输入和输出。 如果你想打印一些东西到控制台,你可以使用stdoutstderr。 这两个属性正是console.log和控制台中的所有其他方法在幕后使用的。 不同之处在于,stdoutstderr不会在每次通话结束时添加新线路,所以如果你想让每个输出进入自己的线路,你必须自己做:

process.stdout.write(`You typed: '${text}'\n`);
process.stderr.write('Exiting your application now.\n');

下面是两个例子,它们输出的内容在末尾有一个新行。 在大多数情况下,建议使用控制台,因为它在顶部提供了一些额外的内容,如日志级别和格式。

如果您想从命令行读取输入,可以使用process.stdinstdin是一个流,我们将在下一节中讨论更多。 现在,您只需要知道 Streams 是基于事件的。 这意味着当输入进来时,它将以数据事件的形式到达。 要接收来自用户的输入,您需要侦听该事件:

process.stdin.addListener('data', (data) => {
  ...
});

当没有更多代码要执行时,事件循环将阻塞,等待来自标准输入的输入。 当输入被读取时,它将作为一个字节缓冲区传递给回调函数。 你可以通过调用它的toString方法将它转换成一个字符串,如下面的代码所示:

const text = data.toString().trim();

然后,您可以像使用任何普通字符串一样使用它。 下面的示例应用演示了如何使用stdoutstderrstdin从命令行请求用户输入:

process.stdout.write('Type something then press [ENTER]\n');
process.stdout.write('> ');
process.stdin.addListener('data', (data) => {
  const text = data.toString().trim();
  process.stdout.write('You typed: '${text}'\n');
  if (text == 'exit') {
    process.stderr.write('Exiting your application now.\n');
    process.exit(0);
  } else {
    process.stdout.write('> ');
  }
});

下面的代码显示了运行应用并输入一些单词后的样子,按输入,然后输入“exit”退出应用:

$ node read_input.js 
Type something then press [ENTER]
> test
You typed: 'test'
> something
You typed: 'something'
> exit
You typed: 'exit'
Exiting your application now.

在前面的代码中,您可以看到,当用户键入“exit”时,它执行应用代码的一个特殊分支,该分支调用process.exit,这是一个退出整个进程并返回指定退出代码的函数。

练习 11:创建任务提醒应用

在本练习中,我们将创建一个任务提醒应用。 现在我们已经学习了如何使用全局变量 process 与用户交互,并学习了如何创建计时器,现在让我们编写一个应用,该应用将使用这些新技能从命令行管理提醒。

应用将接收来自用户的输入,并收集信息以构建提醒。 它将使用一个消息、一个时间单位和一个时间量。 对申请的输入将分阶段提供。 每个阶段都将要求用户获取一些输入,收集它,验证它,然后设置一个变量的值以进入下一个阶段。

执行以下步骤来完成这个练习:

  1. 在一个空文件夹中,使用npm init和一个名为index.js的文件创建一个新的包。 在index.js文件中,我们将开始通过添加一些常量和变量,我们将存储的状态创建计时器:

    js // Constants to calculate the interval based on time unit

    【4】【5】

    js // Variables that will store the application state

    js let amount = null;

    【显示】

    js let timeUnit = null;

    js // Alias to print to console

    js const write = process.stdout.write.bind(process.stdout);

  2. Next, we will add the function that is the heart of the application. This is what the function looks like:

    js function processInput(input) {

    js   // Phase 1 - Collect message

    js   if (message == null) {

    js     askForMessage(input);

    js     input = null;

    js   }

    js   // Phase 2 - Collect time unit

    js   if (message != null && timeUnit == null) {

    js     askForTimeUnit(input);

    js     input = null;

    js   }

    js   // Phase 3 - Collect amount of time

    js   if (timeUnit != null && amount == null) {

    js     askForAmount(input);

    js   }

    js }

    这个函数在一组条件中处理来自用户的所有输入,这些条件将基于当前状态,基于已经可用的变量。 当处理输入时,它会被设回 null,以便执行下一阶段。

    上面的函数调用了一些不存在的函数:askForMessageaskForTimeUnitaskForAmount。 这些函数负责验证输入并根据每个阶段设置变量,以便代码可以进入下一个阶段。

  3. Add some details to the askForMessage function. The first thing the function does is check whether the input is null, which means that it's changing phase for the first time. This means it needs to print the prompt for input for the user.

    这是代码的样子:

    js function askForMessage(input) {

    js   if (input == null) {

    js     write('What do you want to be reminded of? > ');

    js     return;

    js   }

    js   if (input.length == 0) {

    js     write('Message cannot be empty. Please try again. > ');

    js     return;

    js   }

    js   message = input;

    js }

    如果输入不是null,这意味着用户已经为当前状态输入了信息,需要进行验证。 如果验证失败,则打印更多信息并等待下一次输入。

    如果输入是有效的,那么它将为当前状态设置变量,在本例中是message,这将把代码移动到下一个阶段。

  4. 接下来,我们创建askForTimeUnit函数,该函数将处理下一阶段的代码。 该函数使用第一步中列出的常量打印支持的时间单位,并让用户选择一个。 它的工作原理类似于askForMessage功能:prompt,validate,

    js function askForTimeUnit(input) {

    set value:

    js   if (input == null) {

    js     console.log('What unit?');

    【显示】

    js     write('> ');

    js     return;

    js   }

    【病人】

    js   if (isNaN(index) || index <= 0 || index > timeUnits.length) {

    js     write(`Sorry, '${input}' is not valid. Please try again. > `);

    js     return;

    【t16.1】

    js timeUnit = index - 1;

    js   console.log(`Picked: ${timeUnits[timeUnit]}`);

    js }

  5. 最后,我们创建askForAmount函数,它处理最后一个阶段。 这个函数提示用户需要一定的时间来创建计时器。 像以前一样,它有三个部分:prompt,validate,

    js function askForAmount(input) {

    set value:

    js   if (input == null) {

    js     write(`In how many ${timeUnits[timeUnit]}? > `);

    js     return;

    【显示】

    js   const number = parseInt(input, 10);

    js   if (isNaN(number)) {

    js     write(`Sorry, '${input}' is not valid. Try again. > `);

    【病人】

    js   }

    js   amount = number;

    js   setTimerAndRestart();

    【t16.1】 6. At the end of the askForAmount function, it calls the setTimerAndRestart function. Let's create that function, which creates the timer and resets all states so that the loop can start again, and the user can create new timers. This is what the setTimerAndRestart function looks like:

    js function setTimerAndRestart() {

    js   const currentMessage = message;

    js   write(`Setting reminder: '${message}' in ${amount} ${unit} from now.\n`);

    js   let timerMessage = `\n\x07Time to '${currentMessage}'\n> `;

    js   setTimeout(() => write(timerMessage), amount * multipliers[timeUnit]);

    js   amount = message = timeUnit = null;

    js   askForMessage();

    js }

    这里重要的一点是特殊字符\x07。 这将使您的终端发出“哔”声,然后打印消息中设置的文本。 此外,文本在开始和结束处采用了特殊格式的新行,这样就不会对工具的使用造成太大的干扰,因为计时器将在用户继续使用应用时打印出来。

  6. 应用的最后一部分需要将侦听器注册到标准输入中的数据事件,并通过向用户请求消息来开始这个循环:

  7. 现在,你可以从你的终端运行应用,设置几个提醒,并听到它在计时器到期时向你发出的哔哔声:

图 3.3:运行应用后的输出

你会注意到退出应用的唯一方法是通过同时按Ctrl+C键发送中断信号。 作为一个额外的挑战,尝试添加一些代码来创建退出点,以便用户可以以更友好的方式退出。

处理用户输入是每个命令行应用的基础。 在这个练习中,您学习了如何掌握 Node.js 的异步特性,以便您能够处理一组复杂的输入,从而在创建提醒的决策过程中指导用户。

文件系统 api

在上一节中,我们学习了在 Node.js 执行上下文中可用的全局变量。 在本节中,我们将学习文件系统 api,这些 api 用于访问文件和目录、对文件读写数据等等。

但是在我们深入研究文件系统 api 之前,我们需要了解流。 在 Node.js 中,Stream 是表示流数据的抽象接口。 在前一节中,我们使用了标准 I/O,并简要地提到了它们是流,所以让我们详细地理解它们。

流可以是可读的,可写的,或者两者兼而有之。 它们是事件发射器,这意味着要接收数据,你需要注册事件监听器,就像我们在前一节中对标准输入所做的那样:

process.stdin.addListener('data', (data) => {
  ...
});

在下一节中,我们将继续构建在我们对前一节的理解的基础上,并看到流被用作一个抽象来表示数据流经的所有东西,包括标准输入和输出、文件和网络套接字。

为了开始理解它是如何工作的,我们将编写一个应用,它通过使用文件系统包中的createReadStream读取自己的代码。 要使用文件系统 api,我们需要导入它们,因为它们不是全局可用的:

const fs = require('fs');

然后,我们可以创建一个指向脚本文件本身的可读流:

const readStream = fs.createReadStream(__filename);

最后,我们注册流的事件,这样我们就可以了解发生了什么。 读流有四个您应该关心的事件:准备、数据、关闭和错误。

Ready 告诉您文件何时可以开始读取,但是当您创建指向文件的可读流时,它将在文件可用时立即开始读取文件。

正如我们在标准输入中看到的,Data 将通过传入作为字节缓冲区从流中读取的数据来调用。 缓冲区需要通过调用其toString方法或将其与另一个字符串连接来转换为字符串。

当所有字节都已被读取,且流不再可读时,将调用 Close。

如果从流中读取时发生错误,则调用Error

下面的代码演示了如何通过在事件发生时将内容打印到控制台来注册事件:

readStream.on('data', (data) => console.log(`--data--\n${data}`));
readStream.on('ready', () => console.log(`--ready--`));
readStream.on('close', () => console.log(`--close--`));

这个应用的输出如下所示:

$ node read_stream.js 
--ready--
--data--
const fs = require('fs');
const readStream = fs.createReadStream(__filename);
readStream.on('data', (data) => console.log(`--data--\n${data}`));
readStream.on('ready', () => console.log(`--ready--`));
readStream.on('close', () => console.log(`--close--`));
--close--

现在您已经知道了如何读取文件和如何使用读流,让我们更详细地了解一下可写流。 你在上一节中看到了它们的一些用法,因为标准输出是一个可写流:

process.stdout.write('You typed: '${text}'\n');
process.stderr.write('Exiting your application now.\n');

write方法是可写流中最常用的方法。 如果你想创建一个写入文件的可写流,你只需要传递文件名的名称:

const fs = require('fs');
const writable = fs.createWriteStream('todo.txt');

然后,你可以开始写它:

writable.write('- Buy milk\n');
writable.write('- Buy eggs\n');
writable.write('- Buy cheese\n');

不要忘记在末尾添加换行符,否则所有内容将在同一行中打印。

当你写完文件后,你调用end方法关闭它:

writable.end();

可写流也有可以监听的事件。 最重要的两个是errorclose。 当写入流时发生错误时将触发错误事件。 当流关闭时,将调用close事件。 还有finish事件,当调用end方法时将触发该事件。 以下代码是可以在 GitHub 上找到的样例代码的最后一部分:https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_filesystem/write_stream.js:

writable.on('finish', () => console.log("-- finish --"));
writable.on('close', () => console.log("-- close --"));

运行应用后,你会看到它将创建todo.txt文件,其中包含预期的内容:

$ node write_stream.js 
-- finish --
-- close --
$ cat todo.txt 
- Buy milk
- Buy eggs
- Buy cheese

请注意

创建指向文件的流将创建默认覆盖文件内容的流。 要创建一个要追加到文件中的流,你需要传递一个带有"a"标志的选项对象,如 append 中所示:

const writable = fs.createWriteStream('todo.txt', { flags: 'a'});

关于流的另一件有趣的事情是,你可以用管道传送它们。 这意味着您可以将读流中的所有字节发送到写流。 你可以用下面的代码轻松地将一个文件的内容复制到另一个文件:

const fs = require('fs');
fs.createReadStream('somefile.txt')
  .pipe(fs.createWriteStream('copy.txt'));

除了读写文件之外,FileSystem api 还提供了一些方法,以便您可以在目录中列出文件、检查文件状态、查看目录或文件的更改、复制、删除、更改文件权限等等。

在处理文件系统操作时,必须记住这些操作是异步的。 这意味着所有操作都将收到一个回调,该回调在操作完成时被调用。 例如,在创建目录时,可以编写以下代码:

const firstDirectory = 'first';
fs.mkdir(firstDirectory, (error) => {
  if (error != null) {
    console.error(`Error: ${error.message}`, error);
    return;
  }
  console.log(`Directory created: ${firstDirectory}`);
});

如果在尝试创建目录时出现错误(例如,如果目录已经存在),回调函数将接收一个错误参数。 第一次运行代码会工作:

$ node directories_and_files.js
...
Directory created: first

但是当第二次运行它时,它会失败,因为目录已经创建了:

$ node directories_and_files.js 
Error: EEXIST: file already exists, mkdir 'first' { [Error: EEXIST: file already exists, mkdir 'first'] errno: -17, code: 'EEXIST', syscall: 'mkdir', path: 'first' }
...

如果你想在你刚刚创建的目录中创建一个文件,你需要在回调函数中创建文件,这个回调函数传递给mkdir。 以下方法可能会失败:

const firstDirectory = 'first';
fs.mkdir(firstDirectory, (error) => {
  ...
});
fs.writeFile(`${firstDirectory}/test.txt`, 'Some content', (error) => {
  console.assert(error == null, 'Error while creating file.', error);
});

当你尝试运行它时,会发生以下情况:

$ node directories_and_files.js 
Assertion failed: Error while creating file. { [Error: ENOENT: no such file or directory, open 'first/test.txt']
...

这是因为当调用writeFile时,目录可能仍然不存在。 正确的方法是在传递给mkdir的回调函数中调用writeFile:

const firstDirectory = 'first';
fs.mkdir(firstDirectory, (error) => {
  ...
  fs.writeFile(`${firstDirectory}/test.txt`, 'Some content', (error) => {
    console.assert(error == null, 'Error while creating file.', error);
  });
});

由于处理前面的异步调用很复杂,并不是所有情况都需要高性能的异步操作,因此在文件系统模块中,几乎所有操作都包含同一个 API 的同步版本。 因此,如果你想创建一个目录,并创建一个包含一些内容的文件,而当目录不存在时,你的应用不能做任何其他事情,你可以按照以下方式编写代码:

const thirdDirectory = 'third';
fs.mkdirSync(thirdDirectory);
console.log(`Directory created: ${thirdDirectory}`);
const thirdFile = `${thirdDirectory}/test.txt`;
fs.writeFileSync(thirdFile, 'Some content');
console.log(`File created: ${thirdFile}`);

注意每个方法名称后面的Sync单词。 上述代码的输出如下:

$ node directories_and_files.js 
Directory created: third
File created: third/test.txt

在 Node.js 10 中,基于承诺的 API 也被添加到文件系统模块中。 处理异步操作的承诺和其他技术将在以后的章节中讨论,所以我们现在将跳过这些内容。

现在,您已经知道了如何创建目录以及如何对文件读写数据,接下来让我们进行下一个最常用的文件系统操作:列出目录。

要列出目录中的文件,可以使用readdir方法。 当你试图读取目录和文件名列表时,如果出现错误,传递给函数的回调函数将接收一个错误对象。 下面的代码将打印当前目录下所有文件的名称:

fs.readdir('./', (error, files) => {
  if (error != null) {
    console.error('Error while reading directory.', error);
    return;
  }
  console.log('-- File names --');
  console.log(files.join('\n'));
});

这是一个示例输出:

$ node list_dir.js 
-- File names --
.gitignore
copy_file.js
directories_and_files.js
first
list_dir.js
read_stream.js
second
third
write_stream.js
...

但有时,你想要的不仅仅是文件名。 在这里,readdir函数接受一个选项对象,它可以通过withFileTypes标志提供。 如果传递了该标志,那么回调函数得到的不是文件名,而是一个Dirents数组,其中包含了关于文件的一些额外信息,比如它是一个目录或文件。 下面的示例将打印当前目录中的文件名,并根据是目录还是文件,分别在其前面加上(D)或(F):

fs.readdir('./', { withFileTypes: true }, (error, files) => {
  if (error != null) {
    console.error('Error while reading directory.', error);
    return;
  }
  console.log('-- File infos --');
  console.log(files.map(d => `(${d.isDirectory() ? 'D': 'F'}) ${d.name}`)
    .sort()
    .join('\n'));
});

示例输出如下所示:

$ node list_dir.js 
...
-- File infos --
(D) first
(D) second
(D) third
(F) .gitignore
(F) copy_file.js
(F) directories_and_files.js
(F) list_dir.js
(F) read_stream.js
(F) write_stream.js

文件系统 api 的最后一个需要注意的重要操作是如何检查文件的状态。 如果你只需要知道一个文件是否存在并且是可读的,你可以使用access函数,它接收到文件的路径和一组要检查的状态标志。 如果文件状态匹配指定的标志,则不会向回调传递任何错误。 让我们来看一个例子:

const fs = require('fs');
const filename = process.argv[2];
fs.access(filename, fs.constants.F_OK | fs.constants.R_OK, (error) => {
  if (error == null) {
    console.log('File exists and is readable');
  } else {
    console.log(error.message);
  }
});

在这个例子中,我们结合了两个标志,F_OKR_OK。 第一个检查文件是否存在,而第二个检查文件是否可读。 可以使用|(或)操作符组合多个标志。

执行上述代码后,如果文件存在,您将看到以下输出:

$ node file_status.js test.txt 
File exists and is readable

如果该文件不存在,那么您将看到以下输出:

$ node file_status.js not.txt 
ENOENT: no such file or directory, access 'not.txt'

最后,如果文件存在但不可读,你会收到以下消息:

$ node file_status.js not.txt 
EACCES: permission denied, access 'not.txt'

所有这些看起来都很有趣,但是如果您需要知道一个路径是文件还是目录,它最后一次修改是什么时候,等等,那么您需要使用lstat函数,它将返回一个 Stats 实例。 Stats 包含您需要了解的关于路径的所有信息。

下面的示例检查路径是否为文件或目录,它是何时创建和最后修改的,并将该信息打印到控制台:

fs.lstat(filename, (statError, stat) => {
  if (statError != null) {
    console.error('Error while file status.', statError);
    return;
  }
  console.log(`Is file: ${stat.isFile()}`);
  console.log(`Is directory: ${stat.isDirectory()}`);
  console.log(`Created at: ${stat.birthtime}`);
  console.log(`Last modified at: ${stat.mtime}`);
});

这是一个示例输出:

$ node file_status.js first/test.txt 
...
Is file: true
Is directory: false
Created at: Tue Aug 13 2019 20:39:37 GMT-0400 (Eastern Daylight Time)
Last modified at: Tue Aug 13 2019 21:26:53 GMT-0400 (Eastern Daylight Time)

Globs 是类似路径的字符串,包含被*表示的通配符替换的部分路径。 当您有两个*时,例如**,这意味着任何目录或子目录。 一个简单的例子是在当前目录的任何子目录中搜索所有的.txt文件:

$ search '**/*.txt'

练习 12:使用 Glob 模式在目录中搜索文件

在本练习中,我们将创建一个应用,它将扫描目录树并基于 glob 搜索文件。 为了实现这一点,我们将递归调用readdir函数的同步版本,并使用commanderglob-to-regexp模块来帮助我们处理用户的输入。

执行以下步骤来完成这个练习:

  1. 在一个空目录中,使用npm``init启动一个新的应用,并添加一个index.js文件,该文件将作为我们的入口点。
  2. 安装我们将要使用的两个外部模块:commanderglob-to-regexp。 请执行npm install命令:

    js $ npm install commander glob-to-regexp

    js npm notice created a lockfile as package-lock.json. You should commit this file.

    js + glob-to-regexp@0.4.1

    js + commander@3.0.0

    js added 2 packages from 2 contributors and audited 2 packages in 0.534s

    js found 0 vulnerabilities

  3. Inside the index.js file, using your favorite editor, import all the necessary modules for this project at the beginning of the file:

    js const fs = require('fs');

    js const globToRegExp = require('glob-to-regexp');

    js const join = require('path').join;

    js const program = require('commander');

    我们已经知道了fs和指挥官模块。 globToRegExp模块和join函数将在接下来的步骤中解释。

  4. 初始化counterfound变量。 这些将用于显示一些与正在执行的搜索相关的统计信息:

  5. 配置commander以接收 glob 作为参数,并为用户提供一个额外的选项来设置开始搜索的初始目录:

    js      program.version('1.0.0')

    js   .arguments('<glob>')

    js   .option('-b, --base-dir <dir>', 'Base directory to start the search.', './')

    js   .parse(process.argv);

  6. For this exercise, we will use a recursive function to walk through the directory tree. The walkDirectory function calls readdirSync, with the withFileTypes flag set to true. The walkDirectory function receives two arguments: the path to start reading from and the callback to be called for each file. When a directory is found, it is passed to the walkDirectory function so that the recursion continues:

    js function walkDirectory(path, callback) {

    js   const dirents = fs.readdirSync(path, { withFileTypes: true });

    js   dirents.forEach(dirent => {

    js     if (dirent.isDirectory()) {

    js       walkDirectory(join(path, dirent.name), callback);

    js     } else {

    js       counter++;

    js       callback(join(path, dirent.name));

    js    }

    js   });

    js }

    当找到文件时,路径将传递给回调函数,计数器将递增。 在这里,我们使用path.join函数将文件名连接到父路径,以重建文件的整个路径。

  7. 现在我们有了walkDirectory树函数,我们将验证已经传递给应用的参数:

  8. 然后,我们使用globToRegExp模块将 glob 转换为可用于测试文件的RegExp:
  9. 准备好匹配器和遍历目录树函数后,我们现在可以遍历目录树并测试我们找到的每个文件:
  10. 最后,由于所有的代码都是同步执行的,在调用walkDirectory之后,所有的目录和子目录都将被处理。 现在,我们可以打印我们发现的统计数据:

Figure 3.4: Statistics of the files found

图 3.4:找到的文件的统计信息

你可以从父目录开始执行搜索:

Figure 3.5: Executing the search in the parent directory

图 3.5:在父目录中执行搜索

在这个练习中,您学习了如何使用 FileSystem api 来遍历目录树。 您还使用正则表达式按名称过滤文件。

文件系统 api 为几乎所有的应用提供了基础。 学习如何同步和异步地使用它们是你在后台做任何事情的基础。 在下一节中,我们将使用这些 api 来构建一个基本的 web 服务器,以便向浏览器提供文件。

HTTP api

一开始,Node.js 的目标是取代使用传统模式的每个连接一个线程的旧 web 服务器。 在每请求线程模型中,服务器保持一个端口打开,当新连接进入时,它使用池中的一个线程或创建一个新线程来执行用户请求的工作。 服务器端的所有操作都是同步发生的,这意味着当从磁盘或数据库读取文件时,线程处于休眠状态。 下图描述了这个模型:

Figure 3.6: On the thread-per-request model, threads sleep while the I/O and other blocking operations happen

图 3.6:在逐请求线程模型中,当 I/O 和其他阻塞操作发生时,线程会休眠

每个请求线程模型的问题是,创建线程的成本很高,在有更多工作要做时让线程休眠意味着资源的浪费。 另一个问题是,当线程的数量大于 cpu 的数量时,它们开始失去最宝贵的价值:并发性。

由于这些问题,使用每请求一个线程模型的 web 服务器的线程池不够大,因此服务器仍然可以并行地响应许多请求。 因为线程的数量是有限的,当并发用户请求的数量增长太多时,服务器会耗尽线程,用户现在不得不等待:

图 3.7:当并发请求数量增加时,用户必须等待线程可用

Node.js 的异步模型和事件循环带来了这样一种想法:如果你只有一个线程来执行工作,并将阻塞和 I/O 操作移到后台,只有在数据可用时才返回到它那里,那么你会更有效率。 当你有数据密集型的工作要做时,比如 web 服务器,它主要从文件、磁盘和数据库记录中读取和写入数据,异步模型变得更加高效。 下图描述了这个模型:

Figure 3.8: Asynchronous model with an event loop

图 3.8:带有事件循环的异步模型

当然,这个模型并不是什么灵丹妙药,在高负载和大量并发用户的情况下,队列上的工作量将变得非常大,最终用户将开始相互阻塞。

现在您已经了解了异步模型的历史以及 Node.js 实现它的原因,让我们构建一个简单的 hello world web 服务器。 在接下来的章节中,您将学习更多关于 REST api 的知识,以及如何使用一些库来帮助您做一些更高级的事情。 现在,我们将使用 http 模块构建一个返回“hello world”字符串的服务器。

要创建 HTTP 服务器,可以使用 HTTP 模块中的createServer函数。 这就像做下面的事情一样简单:

const http = require('http');
const server = http.createServer();

服务器是由事件驱动的,而我们最感兴趣的事件是请求。 当 HTTP 客户端连接到服务器并发起请求时触发此事件。 我们可以通过一个接收两个参数的回调来监听这个事件:

  • request:客户端发送给服务器的请求。
  • response:用于与客户端通信的响应对象。

响应是一个可写流,这意味着我们已经知道如何向它发送数据:通过调用write方法。 但它还包含一个名为writeHead的特殊方法,该方法将返回 HTTP 状态码和任何额外的报头。 下面是一个如何将 hello world 字符串发送回客户端的示例:

server.on('request', (request, response) => {
  console.log('Request received.', request.url);
  response.writeHead(200, { 'Content-type': 'text/plain' });
  response.write('Hello world!');
  response.end();
});

我们有服务器和请求处理程序。 现在,我们可以开始侦听特定端口上的请求。 为此,我们调用服务器实例上的listen方法:

const port = 3000;
console.log('Starting server on port %d.', port);
console.log('Go to: http://localhost:%d', port);
server.listen(port);

这个示例的代码可在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_server.js

如果你通过运行这个应用来启动 hello world 服务器,你会在控制台中看到类似这样的东西:

$ node http_server.js 
Starting server on port 3000.
Go to: http://localhost:3000

如果你打开浏览器,进入指定的路径,你会看到以下内容:

Figure 3.9: Hello world web server example response

图 3.9:Hello world web 服务器示例响应

您可以尝试到达其他路径,例如http://localhost:3000/index.html。 结果是一样的:

Figure 3.10: Hello world server always responds with Hello world

图 3.10:Hello world 服务器总是响应 Hello world

如果你回到运行服务器的控制台,你会看到如下内容:

$ node http_server.js 
Starting server on port 3000.
Go to: http://localhost:3000
Request received. /
Request received. /favicon.ico
Request received. /index.html
Request received. /favicon.ico

您可以看到服务器正正确地从浏览器接收路径。 但是,由于代码不处理任何特殊情况,它只返回 Hello world。 客户端总是得到相同的结果,无论他们请求什么路径。

练习 13:服务静态文件

我们已经学习了如何构建一个 hello world web 服务器,无论客户端要求什么,它总是响应相同的字符串。 在本练习中,我们将创建一个 HTTP 服务器来服务目录中的文件。 这种类型的服务器称为静态 HTTP 服务器,因为它只查找目录中的文件,并将它们返回给客户端,而不进行任何修改。

执行以下步骤来完成这个练习:

  1. 在空目录下,使用init命令初始化一个新的 npm 应用,并向它添加一个index.js文件。 另外,使用npm install安装mime包。 我们将使用这个包来确定将要服务的文件的内容类型:

    js npm install mime

  2. Let's start by importing all the modules we will need for this project:

    js const fs = require('fs');

    js const http = require('http');

    js const mime = require('mime');

    js const path = require('path');

    js const url = require('url');

    我们将使用fs模块从磁盘加载文件。 http 模块将用于创建 http 服务器和处理 http 请求。 mime模块是我们在上一步中安装的,将用于确定每个文件的内容类型。 path 模块用于以独立于平台的方式处理路径。 最后,使用url模块解析 url。

  3. 知道哪些文件我们需要服务,我们将扫描的目录使用walkDirectory函数从之前的练习:

    js function walkDirectory(dirPath, callback) {

    js   const dirents = fs.readdirSync(dirPath, { withFileTypes: true });

    js   dirents.forEach(dirent => {

    【4】【5】

    js     } else {

    js       callback(path.join(dirPath, dirent.name));

    【显示】

    js   });

    js }

  4. 然后,我们将选择根目录,它可以作为参数传递。 否则,我们将假设它是运行脚本的目录:

  5. 现在,我们可以扫描所有文件的目录树和存储路径Set,这将使检查文件的可用性的过程更快:

    js const files = new Set();

    js walkDirectory(rootDirectory, (file) => {

    js file = file.substr(rootDirectory.length);

    【4】【5】

    js console.log(`Found ${files.size} in '${rootDirectory}'...`);

  6. 有了准备服务的文件列表,我们将创建 HTTP 服务器实例:

  7. 启动请求处理函数:

    js server.on('request', (request, response) => {

  8. 在处理程序函数中,解析用户向 URL 请求的内容。 为此,我们将使用 url 模块,并从解析的 url 中获取指向客户端需要的文件的路径名:

  9. 文件路径,我们将检查该文件是否在列表中我们收集之前,如果没有,回复 404(未找到)错误消息,日志请求的结果,并返回:

    js if (!files.has(requestedPath)) {

    js   console.log('404 %s', requestUrl.href);

    js   response.writeHead(404);

    js   response.end();

    【4】【5】 10. 如果文件在Set中,我们将使用 path 模块提取它的扩展名,并使用mime模块解析内容类型。 然后,我们将响应一个 200 (ok)错误消息,创建一个读流到文件,并将它管道到响应: 11. 这就是 handler 函数的全部内容。 在此之后,我们可以通过选择一个端口来启动服务器,让用户知道该端口是什么,并调用什么来侦听 http 服务器中的方法: 12. 启动服务器的方式如下:

    js $ node .

    js Found 23 in '/Path/to/Folder'...

    js Starting server on port 3000.

    js o to: http://localhost:3000

  10. 从另一个终端窗口,我们可以使用命令行 HTTP 客户端 curl 调用我们的服务器,并看到响应:

我们也可以在浏览器中做同样的事情:

Figure 3.11: Static index.js served from our HTTP server as viewed from the browser

图 3.11:从浏览器中看到的来自 HTTP 服务器的静态 index.js

你也可以尝试使用不存在的文件来查看结果:

$ curl -i localhost:3000/not_real.js
HTTP/1.1 404 Not Found
Date: Fri, 16 Aug 2019 02:07:14 GMT
Connection: keep-alive
Transfer-Encoding: chunked

从浏览器上看,404 响应看起来像一个错误页面:

Figure 3.12: The server responds with a 404 error when asked for a file that doesn't exist

图 3.12:当请求一个不存在的文件时,服务器返回一个 404 错误

在运行服务器的终端上,你可以看到它打印出关于服务对象的信息:

$ node .
Found 23 in '/Path/to/Folder'...
Starting server on port 3000
Go to: http://localhost:3000
200 /index.js
404 /not_real.js

只需要几行代码,就可以构建一个提供静态内容的 HTTP 服务器。

HTTP 服务器是互联网的基本组件之一。 Node.js 使构建强大的服务器变得简单。 在这个练习中,只用几行代码,我们就构建了一个静态 HTTP 服务器。 在本节的其余部分中,我们将学习如何构建一个动态服务器,该服务器可以使用模板和数据生成 HTML,这些模板和数据通过请求传递,也可以从其他数据源(如 JSON 文件)加载。

在继续构建动态 HTTP 服务器之前,让我们先看看 Node.js 中可用的 HTTP 客户端 api。 为了测试 HTTP 客户端 api,我们将使用 HTTP Bin,这是一个可以用来测试 HTTP 请求的免费服务。 点击:https://httpbin.org

在接下来的章节中,您将了解每个 HTTP 方法的含义,但现在,我们只探讨其中的两个:GET 和 POST。 到目前为止,我们一直在使用 HTTP GET。 它告诉服务器:“帮我获取这个 URL。” HTTP POST 的意思是:“为我在这个 URL 上存储这个内容。” 在我们在前面的练习中构建的静态服务器中,它是磁盘上真实文件的真实路径。 但它可以以任何服务器认为合适的方式使用。

让我们使用 Node.js 来执行一个对httpbinAPI 的 GET 请求。 HTTP 客户端模块与服务器位于同一个模块中,因为它使用了许多相同的构造。 因此,我们必须要求 http 模块:

const http = require('http');

由于 GET 是一种广泛使用的 HTTP 方法,HTTP 模块有它的别名。 我们可以通过调用get函数来执行 GET:

const request = http.get('http://httpbin.org/get', (response) => {

get函数接收 URL 和一个回调函数,当服务器开始发送它时,它将被调用并传递给响应。 传递给回调函数的响应是一个可读流,我们可以通过监听 data 事件来获取数据:

response.on('data', (data) => {
  console.log(data.toString());
});

这里的数据是响应的主体。 如果我们只是将它打印到控制台,我们将在终端中看到响应。

get方法返回的请求实例是一个可写流。 为了告诉服务器我们已经完成了请求,我们需要调用end方法:

request.end();

下面是来自上述代码的一些示例输出,可以在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_client_get.js中找到:

$ node http_client_get.js 
{
  "args": {}, 
  "headers": {
    "Host": "httpbin.org"
  }, 
  "origin": "100.0.53.211, 100.0.53.211", 
  "url": "https://httpbin.org/get"
}

您可以看到,它将响应正文打印到终端。

有时,您需要发送一些额外的头或使用 HTTP 基本身份验证。 为此,get方法接受一个options对象,您可以在其中设置头信息、基本身份验证等。 下面是一个示例选项对象,其中设置了自定义头和基本身份验证:

const options = {
  auth: 'myuser:mypass',
  headers: {
    Test: 'Some Value'
  }
};

然后,在回调函数之前传递 options 对象:

const request = http.get(url, options, (response) => {

下面的代码片段是上述代码的输出,也可以在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_client_get_with_headers.js中获得:

$ node http_client_get_with_headers.js 
{
  "args": {}, 
  "headers": {
    "Authorization": "Basic bXl1c2VyOm15cGFzcw==", 
    "Host": "httpbin.org", 
    "Test": "Some Value"
  }, 
  "origin": "100.0.53.211, 100.0.53.211", 
  "url": "https://httpbin.org/get"
}

httpbin响应我们在请求中传递的所有信息。 您可以看到现在有两个额外的头文件 Test 和 Authorization,带有我们指定的值。 授权头是 base64 编码的,正如基本身份验证规范中所指定的。

正如我们前面提到的,get 方法只是一个别名。 request 方法是它的更灵活的版本,可以用于执行 HTTP POST 请求。 尽管请求方法更加灵活,但它接收相同的参数:urloptionscallback

要指定要执行的 HTTP 方法,我们在 options 对象中设置它:

const options = {
  method: 'POST',
};

然后,我们调用 request 函数,而不是 get 函数:

const request = http.request(url, options, (response) => {

如果希望将数据发送到服务器,可以使用我们创建的请求对象。 记住,它是一个可写流,所以我们可以直接将内容写入它:

request.write('Hello world.');

当你写完数据到请求后,调用end方法,请求就完成了:

request.end();

我们之前解释过的一些使用 write 和end方法的示例代码可以在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_http/http_client_post.js上找到。

下面是运行上述代码的输出:

$ node http_client_post.js 
{
  "args": {}, 
  "data": "Hello world.", 
  "files": {}, 
  "form": {}, 
  "headers": {
    "Content-Length": "12", 
    "Host": "httpbin.org"
  }, 
  "json": null, 
  "origin": "100.0.53.211, 100.0.53.211", 
  "url": "https://httpbin.org/post"
}

您可以看到 http 模块根据您发送的数据量自动设置 Content-Length 报头。 您还可以看到,响应上设置了 data 属性,指示服务器接收到的数据。

练习 14:服务动态内容

在本练习中,我们将重写前一章中的店面。 但是现在,内容将被动态提供,HTML 将在服务器端生成。 为此,我们将有一个存储在 JSON 文件中的产品数组,该文件将被加载并用于生成一个 HTML 文件返回给客户端。

有很多方法可以生成 HTML 发送给客户端:连接字符串、搜索和替换、模板字符串,甚至可以使用像 cheerio 这样的库。 一般来说,模板是最简单的,因为您可以将模板存储在一个单独的文件中,该文件可以像普通 HTML 文件一样编写,但带有一些占位符。 对于本练习,我们将使用把手模板库来完成艰巨的工作。

执行以下步骤来完成这个练习:

  1. Create a new npm package with an index.js file in it. Install the two external packages that we will use in this exercise:

    js $ npm init

    js ...

    js $ npm install handlebars mime

    js + handlebars@4.1.2

    js + mime@2.4.4

    js updated 2 packages and audited 10 packages in 1.075s

    js found 0 vulnerabilities

    handlebars 包是一个模板引擎。 它可以用来呈现带有占位符和一些基本逻辑的模板文本,例如 for 循环和 if/else 语句。 我们还将使用以前使用过的mime包来确定将要静态提供的文件的内容类型。

  2. js const fs = require('fs');

    js const handlebars = require('handlebars');

    js const http = require('http');

    js const mime = require('mime');

    js const path = require('path');

    js const url = require('url');

  3. 使用基本目录检查静态文件的路径。 该目录将是加载脚本的静态目录。 我们将路径存储在一个变量中,以便以后使用:

  4. Next, we load the product array from the JSON file using readFileSync. We parse the JSON using the built-in JSON.parse function and then print the number of products found to the console:

    js const data = fs.readFileSync(`products.json`);

    js const products = JSON.parse(data.toString());

    js console.log(`Loaded ${products.length} products...`);

    把手有一个助手函数的概念。 这些函数可以在模板中注册和使用。 要注册一个 helper 函数,调用registerHelp函数,将 helper 的名称作为第一个参数传递,处理函数作为第二个参数传递。

  5. 让我们添加一个 helper 函数,可以用来格式化货币:

    js handlebars.registerHelper('currency', (number) => `$${number.toFixed(2)}`);

  6. To initialize the HTTP handler and start listening to connections, we will use the following function:

    js function initializeServer() {

    js   const server = http.createServer();

    js   server.on('request', handleRequest);

    js   const port = 3000;

    js   console.log('Go to: http://localhost:%d', port);

    js   server.listen(port);

    js }

    我们向 HTTP 服务器注册了一个名为handleRequest的函数。 这是根处理程序,所有请求都将通过它。 对于这个应用,我们期待两种类型的请求:第一种是指向 css、图像和其他静态文件的静态请求,而第二种是获取店面 HTML 的请求。 这意味着我们的根处理程序只关心这两个。

  7. 为了请求店面,我们假设当用户请求//index.html(http://localhost:3000/http://localhost:3000/index.html)时,用户正在尝试访问商店,也称为应用的基础页或根页。 其他所有内容都将被视为静态资源。 为了处理这些请求,我们将解析 URL,检查路径名,并使用一个if语句:

    js function handleRequest(request, response) {

    js   const requestUrl = url.parse(request.url);

    js   const pathname = requestUrl.pathname;

    js   if (pathname == '/' || pathname == '/index.html') {

    js     handleProductsPage(requestUrl, response);

    js     return;

    js   }

    js   handleStaticFile(pathname, response);

    js }

  8. 为了处理静态文件,我们将把静态文件应该来自的目录的路径放在前面,并使用它作为完整路径。 然后,我们将使用文件系统 API 中的access函数检查文件是否存在,是否可读。 如果有错误,则返回一个404错误; 否则,只需创建一个可读流,并将文件的内容通过管道发送到响应。 我们也想使用 mime 库检查每个文件的内容类型,并添加一个响应头:

    js function handleStaticFile(pathname, response) {

    js   // For security reasons, only serve files from static directory

    【4】【5】

    js   fs.access(fullPath, fs.constants.R_OK, (error) => {

    js     if (error) {

    【显示】

    js       response.writeHead(404);

    js       response.end();

    js       return;

    【病人】

    js     const contentType = mime.getType(path.extname(fullPath));

    js    response.writeHead(200, { 'Content-type': contentType });

    js     fs.createReadStream(fullPath)

    【t16.1】

    js   });

    js }

  9. Now that we have the function to serve static files, let's use handlebars to serve the dynamic content. For that, we need to load the HTML template using readFileSync and then compile it. The compiled script is transformed into a function that, when called, returns a string of the processed template.

    模板函数接收用于呈现模板的上下文。 可以在模板中访问上下文,这将在下一个步骤中演示。 对于这个应用,上下文将是一个具有一个属性products的对象:

    js const htmlString = fs.readFileSync(`html/index.html`).toString();

    js const template = handlebars.compile(htmlString);

    js function handleProductsPage(requestUrl, response) {

    js   response.writeHead(200);

    js response.write(template({ products: products }));

    js   response.end();

    js }

  10. With the template handling in place, we need a template. Handlebars uses double curly braces as placeholders (for example, {{variable}}), and you can execute a for loop using the double curly braces with a hash: {{#arrayVariable}}. In a separate html/index.html file, relative to the index.js file, add the following HTML template:

    js <html>

    js   <head>

    js     <link rel="stylesheet" type="text/css" href="css/semantic.min.css" />

    js     <link rel="stylesheet" type="text/css" href="css/store.css" />

    js   </head>

    js   <body>

    js     <section>

    js       <h1 class="title">Welcome to Fresh Products Store!</h1>

    js       <div class="ui items">

    js         {{#products}}

    js         <div class="item">

    js           <div class="image"><img src="{{image}}" /></div>

    js           <div class="content">

    js             <a class="header">{{name}}</a>

    js             <div class="meta">

    js               <span>{{currency price}} / {{unit}}</span>

    js             </div>

    js             <div class="description">{{description}}</div>

    js             <div class="extra">

    js               {{#tags}}

    js               <div class="ui label teal">{{this}}</div>

    js               {{/tags}}

    js             </div>

    js          </div>

    js         </div>

    js         {{/products}}

    js       </div>

    js     </section>

    js   </body>

    js </html>

    注意 helper 函数 currency,调用它来呈现价格:

  11. Don't forget to call the initialize function at the end to start listening for HTTP connections:

    js initializeServer();

    为了正确地加载和渲染店面,您还需要 css 文件和图像。 把它们都放在一个叫做static的文件夹里。 你可以在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson03/Exercise14找到这些文件。

  12. 所有文件就绪后,运行服务器:

    js $ node .

    js Static resources from

    js .../Lesson03/Exercise14/static

    js Loaded 21 products...

    js Go to: http://localhost:3000

  13. 打开浏览器窗口,点击http://localhost:3000。 你应该看到店面:

Figure 3.13: Storefront served from the dynamic web server

图 3.13:来自动态 web 服务器的店面

在本练习中,我们将店面应用转换为动态 web 应用,该应用从 JSON 文件读取数据,并在用户请求时呈现 HTML 请求。

动态 web 服务器是所有在线应用的基础,从优步(Uber)到 Facebook。 您可以将工作总结为加载数据/处理数据以生成 HTML。 在第二章 Node.js 和 npm中,我们使用了一些简单的 HTML 在前端进行处理。 在本练习中,您学习了如何使用模板引擎在后端完成相同的工作。 每种方法都有其优点和缺点,大多数应用最终都是两者的结合。

作为改进,您可以在店面网页中添加过滤选项。 假设用户希望通过标签或标签的组合来筛选产品。 在你的handleProductsPage函数中,你可以使用查询参数来过滤你要传递给模板用于呈现的产品列表。 看看你自己能不能做出改进。

什么是刮痧?

在本章的剩余部分,我们将讨论 web抓取。 但是什么是网络抓取呢? 它是一个下载页面并处理其内容的过程,以执行一些重复的自动化任务,否则将花费太长时间来手动执行这些任务。

例如,如果你想买汽车保险,你需要访问每个保险公司的网站并获得报价。 这个过程通常需要几个小时,因为你必须填写表格,提交,等他们在每个网站上给你发邮件,比较价格,然后选择你想要的:

Figure 3.14: The user downloads content, types data in, submits it, and then wait for the results

图 3.14:用户下载内容,输入数据,提交,然后等待结果

所以为什么不做一个程序来帮你做到这一点呢? 这就是网络抓取的全部内容。 程序像人一样下载一个页面,从其中抓取信息,并根据某种算法做出决定,然后将必要的数据提交给网站。

当你为你的车买保险时,自动化似乎不会带来太多的价值。 为不同的网站正确地编写一个应用需要花费很多时间——比自己手动完成要多。 但如果你是一名保险经纪人呢? 那么你一天要做几百次,也许更多。

如果您是一家保险经纪公司,如果您花时间构建一个机器人(这就是这些应用的名称),那么您将开始变得更加高效。 这是因为,对于一个网站,你不会花时间填写表格。 有了制造第一个机器人的效率,你可以节省时间,可以制造第二个,然后是第三个,以此类推:

Figure 3.15: Robot executing the task automatically by downloading content and making decisions based on an algorithm

图 3.15:机器人通过下载内容,根据算法进行决策,自动执行任务

网络抓取开始于互联网的早期,当时 Yahoo! 试图手动索引所有存在的网站。 然后,一家初创公司从两个大学生的车库里开始使用机器人提取数据并为所有东西建立索引。 在很短的时间内,谷歌成为了排名第一的搜索网站,竞争对手越来越难以挑战这个位置。

Web 抓取是一种广泛使用的技术,用于从不提供 api 的网站中提取数据,比如大多数保险公司和银行。 搜索和索引也是另一种非常常见的情况。 一些公司使用抓取来分析网站的表现并对其进行评级,如 HubSpot(https://website.grader.com)。

有许多 web 抓取技术,这取决于您试图实现的目标。 最基本的方法是从网站上下载基本 HTML,然后从那里阅读内容。 如果你只需要下载数据或填写表格,这可能就足够了:

Figure 3.16: The basic scraping technique involves downloading and processing the base HTML file

图 3.16:基本的抓取技术涉及下载和处理基本 HTML 文件

但有时,在 HTML 呈现后,网站使用 Ajax 动态加载内容。 对于这些情况,仅仅下载 HTML 是不够的,因为它只是一个空模板。 要解决这个问题,可以使用无头浏览器,它的工作方式类似于浏览器,解析所有 HTML,下载并解析相关文件(CSS、JavaScript 等),一起呈现所有内容,并执行动态代码。 这样,您可以等待数据可用:

Figure 3.17: Depending on the use case, scraping requires a simulated or a fully headless browser to download and render the page more accurately

图 3.17:根据用例,抓取需要一个模拟的或完全无头的浏览器来下载和更准确地呈现页面

第二种技术要慢得多,因为它需要整个页面来下载、解析和呈现。 它也更加脆弱,因为执行额外的调用可能会失败,而等待 JavaScript 完成数据处理可能很难预测。

下载和解析网页

让我们研究一种更简单的 web 抓取方法。 假设我们想要关注关于 JavaScript 在 Medium 中的最新帖子。 我们可以编写一个应用来下载 JavaScript 主题页面,然后搜索锚(链接),并使用它进行导航。

首先,使用一个通用的下载函数在 HTTP 客户机上执行一些基本的包装是一个好主意。 我们可以使用一个外部库,比如 request,但是让我们看看如何包装这种逻辑。

我们将需要 http 模块,但在这种情况下,我们将使用 https 版本的它,因为现在大多数网站将重定向到他们的安全版本,如果你试图达到普通的 http 版本。 https 模块提供了相同的 api,除了它理解 https 协议,这是 HTTP 的安全版本:

const http = require('https');

downloadPage函数接收一个要下载的 URL 和一个回调函数,该回调函数将在页面内容下载完成后被调用:

function downloadPage(urlToDownload, callback) {
}

在这个函数中,我们首先发出一个请求,并确保调用了 end 函数来完成这个请求:

const request = http.get(urlToDownload, (response) => {
});
request.end();

在传递给 get 函数的回调函数中,我们要做的第一件事是检查响应状态,如果响应状态不匹配 200,则打印一条错误消息,200 是表示请求成功的 HTTP 代码。 我们还通过回调函数的返回来停止一切,因为如果发生这种情况,body 可能不会是我们所期望的:

if (response.statusCode != 200) {
  console.error('Error while downloading page %s.', urlToDownload);
  console.error('Response was: %s %s', response.statusCode, response.statusMessage);
  return;
}

在语句if之后,我们可以使用数据事件在变量中积累页面内容。 当连接关闭时,在close事件中,我们用 content 变量中积累的全部内容调用回调函数:

let content = '';
response.on('data', (chunk) => content += chunk.toString());
response.on('close', () => callback(content));

这个示例的完整代码可在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/blob/master/Lesson03/sample_scraping/print_all_texts.js

这个函数的简单用法如下:

downloadPage('https://medium.com/topic/javascript', (content) => {
  console.log(content);
});

这将下载页面并将其打印到控制台。 但是我们还想做更多的工作,所以我们将使用jsdom库来解析 HTML 并从中获取一些信息。 jsdom是一个库,解析 HTML 并生成一个可以查询和操作的 DOM 表示,就像浏览器中的 DOM。

在使用npm install命令安装它之后,您可以在代码中要求它。 该模块公开了一个接收字符串的构造函数。 实例化后,JSDOM实例包含一个窗口对象,其工作原理与浏览器中的窗口对象完全相同。 下面是一个使用它来获取所有锚的例子,过滤掉空的锚,并打印它们的文本:

const JSDOM = require('jsdom').JSDOM;
downloadPage('https://medium.com/topic/javascript', (content) => {
 const document = new JSDOM(content).window.document;
  Array.from(document.querySelectorAll('a'))
    .map((el) => el.text)
    .filter(s => s.trim() != '')
    .forEach((s) => console.log(s));
});

下面是上述代码的输出示例:

$ node print_all_texts.js 
Javascript
Become a member
Sign in
14 Beneficial Tips to Write Cleaner Code in React Apps
Be a hygienic coder by writing cleaner
14 Beneficial Tips to Write Cleaner Code in React Apps
Be a hygienic coder by writing cleaner
...

练习 15:刮中等物品

在本练习中,我们将使用抓取在控制台中打印文章。 让我们使用这些知识来构建一个应用,该应用将从 Medium 下载主题页面,解析信息,并以可消费的方式打印出来。 应用将有一个硬编码的主题列表,并将下载每个页面的 HTML。 然后,它将使用jsdom来解析下载的内容,获取关于每一篇文章的信息,并在控制台中以漂亮的格式打印出来,使每一篇文章只需点击即可。

执行以下步骤来完成这个练习:

  1. 创建一个新文件夹,其中有一个index.js文件。 然后使用npm install:

    js $ npm init

    js ...

    js $ npm install jsdom

    js + jsdom@15.1.1

    js added 97 packages from 126 contributors and audited 140 packages in 12.278s

    js found 0 vulnerabilities

    运行npm initinstall jsdom 2. 在index.js文件中,对我们将要使用的所有模块使用 require 函数: 3. js const topics = [

    js   'artificial-intelligence',

    js   'data-science',

    js   'javascript',

    js   'programming',

    js   'software-engineering',

    js ];

  2. 复制downloadPage功能我们在前一节中创建:

    js function downloadPage(urlToDownload, callback) {

    js   const request = http.get(urlToDownload, (response) => {

    js     if (response.statusCode != 200) {

    【4】【5】

    js       return;

    js     }

    【显示】

    js     response.on('data', (chunk) => content += chunk.toString());

    js     response.on('close', () => callback(content));

    js   });

    【病人】

    js }

  3. Iterate over each topic calling the downloadPage function for each topic:

    js topics.forEach(topic => {

    js   downloadPage(`https://medium.com/topic/${topic}`, (content) => {

    js     const articles = findArticles(new JSDOM(content).window.document);

    js     Object.values(articles)

    js      .forEach(printArticle);

    js   });

    js });

    在前面的代码中,我们调用了两个函数:findArticlesprintArticle。 第一个将遍历从页面解析的 DOM 并返回一个对象,其中的键是文章标题,值是包含每一篇文章信息的对象。

  4. 接下来,我们编写findArticles函数。 我们首先初始化对象,这将是函数的结果,然后为 H1 和 H3 元素中的所有锚元素查询传入的文档,这些元素代表文章的标题:

  5. Filter the anchors based on the fact that Medium article paths have two parts: /author/articleId. This means we can parse the href of the anchor as a URL, get the pathname, split using "/" as a separator, and ignore anyone that doesn't have exactly two parts:

    js .filter(el => {

    js   const parsedUrl = url.parse(el.href);

    js   const split = parsedUrl.pathname.split('/').filter((s) => s.trim() != '');

    js   return split.length == 2;

    js })

    在页面上使用 Chrome 开发者工具,你可以看到文章的标题位于标题元素中,它的下一个兄弟是一个 DIV,包含以下简短的描述:

    Figure 3.18: The next sibling of the parent contains a short description of the article

    图 3.18:父级的下一个兄弟级包含对文章的简短描述

    这意味着对于每个锚元素,我们都可以获取 DIV,查询锚元素,并获取其文本作为文章的描述。

  6. 使用标题作为键在结果对象中设置项目信息。 我们使用文章标题作为键,因为这会自动从结果中删除文章:

    js .forEach(el => {

    js   const description = el.parentNode.nextSibling.querySelector('p a').text;

    js   articles[el.text] = {

    js     description: description,

    js     link: url.parse(el.href).pathname,

    js     title: el.text,

    js };

    js });

  7. Lastly, from the findArticles function, we return the array containing all the articles:

    js   return articles;

    js }

    我们在传递给downloadPage的回调函数中调用的另一个函数是printArticle。 它也是创建此应用的最后一段代码。

  8. Let's write the printArticle function, which receives an article object and prints it to the console in a pretty way:

    js function printArticle(article) {

    js   console.log('-----');

    js   console.log(` ${article.title}`);

    js   console.log(` ${article.description}`);

    js   console.log(` https://medium.com${article.link}`);

    js }

    运行应用,以漂亮的格式将文章打印到控制台,并提供额外的信息:

Figure 3.19: Article printed to the console after running the application

图 3.19:在运行应用后,文章被打印到控制台

在这个练习中,我们编写了一个应用,从 Medium 中提取数据,并将找到的文章摘要打印到控制台。

Web 抓取是在没有 API 可用时获取数据的一种强大方法。 许多公司使用抓取来同步系统之间的数据,分析网站的性能,并优化无法扩展的流程,从而阻止一些重要的业务需求。 理解抓取背后的概念可以让您构建在其他情况下无法构建的系统。

活动 4:从店面抓取产品和价格

第二章,Node.js 和 npm中,我们编写了一些代码,可以在店面示例页面中获取关于产品的信息。 当时,我们说网站不会那么频繁地更新,所以从 Chrome 开发者控制台手动更新是可以的。 对于某些场景,这是可以的,但是当内容是动态生成的时候,比如在我们在本章中编写的店面的新版本中,我们可能需要消除所有手工干预。

在这个活动中,您将编写一个应用,它将通过使用 http 模块下载并使用jsdom解析它来抓取店面网页。 然后,您将从 DOM 中提取数据并使用该数据生成一个CSV文件。

您需要执行以下步骤来完成此活动:

  1. 使用你之前建立的代码或使用它的副本,以服务于店面网站localhost:3000。 代码可在 GitHub 上的https://github.com/TrainingByPackt/Professional-JavaScript/tree/master/Lesson03/Activity04上找到。
  2. 创建一个新的npm包,安装jsdom库,并创建一个名为index.js的入口文件。
  3. 在输入文件中,调用require()方法来加载项目中需要的所有模块。
  4. localhost:3000发送 HTTP 请求。
  5. 确保反应成功并从身体中收集数据。
  6. 使用jsdom解析 HTML。
  7. 从 DOM 中提取产品数据; 您将需要名称、价格和单位。
  8. 打开将要写入数据的CSV文件。
  9. 将产品数据写入一个CSV文件,该文件是一个产品线。
  10. 运行应用并检查结果。

输出应该是这样的:

$ node .
Downloading http://localhost:3000...
Download finished.
Parsing product data...
.....................
Found 21 products.
Writing data to products.csv...
Done.
$ cat products.csv 
name,price,unit
Apples,3.99,lb
Avocados,4.99,lb
Blueberry Muffin,2.5,each
Butter,1.39,lb
Cherries,4.29,lb
Chocolate Chips Cookies,3.85,lb
Christmas Cookies,3.89,lb
Croissant,0.79,each
Dark Chocolate,3.49,lb
Eggs,2.99,lb
Grapes,2.99,lb
Milk Chocolate,3.29,lb
Nacho Chips,2.39,lb
Parmesan Cheese,8.99,lb
Pears,4.89,lb
Petit French Baguette,0.39,each
Smiling Cookies,2.79,lb
Strawberries,7.29,lb
Swiss Cheese,2.59,lb
White Chocolate,3.49,lb
Whole Wheat Bread,0.89,each

请注意

这个活动的解决方案可以在 591 页找到。

小结

在本章中,我们学习了每个 Node.js 脚本都可以使用的全局变量。 我们学习了如何设置计时器以及从控制台读取和写入。 在那之后,我们学习了流以及如何使用它们从文件中读取和写入数据。 我们还学习了如何使用同步文件系统 api。 然后,我们学习了如何使用 HTTP 模块构建 web 服务器和从网页中抓取内容。

既然您已经很熟悉 web 抓取的概念,那么您就可以开始探索构建自己的 web 应用和构建自动机器人从其他 web 应用中抓取内容的机会了。 一个好主意是尝试构建一个简单的内容管理应用来服务于您的博客,在这里您将写下您刚刚了解的所有新内容。

在下一章中,您将学习 REST api 并使用一些框架来帮助您构建它们。 在以后的章节中,你将学习一些技巧,你可以用来管理异步操作,使你的 Node.js 应用功能强大,但保持代码易于编写和维护。