JavaScript 权威指南第七版(GPT 重译)(七)(1)https://developer.aliyun.com/article/1485472
暂停模式
可读流的另一种模式是“暂停模式”。这是流开始的模式。如果你从未注册“data”事件处理程序,也从未调用pipe()
方法,那么可读流将保持在暂停模式。在暂停模式下,流不会以“data”事件的形式向你推送数据。相反,你需要通过显式调用其read()
方法来从流中拉取数据。这不是一个阻塞调用,如果流上没有可读数据,它将返回null
。由于没有同步 API 来等待数据,暂停模式 API 也是基于事件的。在暂停模式下,当流上有数据可读时,可读流会发出“readable”事件。作为响应,你的代码应该调用read()
方法来读取数据。你必须在循环中这样做,重复调用read()
直到它返回null
。这样完全排空流的缓冲区是必要的,以便在将来触发新的“readable”事件。如果在仍然有可读数据时停止调用read()
,你将不会收到另一个“readable”事件,你的程序可能会挂起。
暂停模式下的流会像流动模式下的流一样发出“end”和“error”事件。如果你正在编写一个从可读流读取数据并将其写入可写流的程序,那么暂停模式可能不是一个好选择。为了正确处理背压,你只想在输入流可读且输出流没有积压时才读取。在暂停模式下,这意味着读取和写入直到read()
返回null
或write()
返回false
,然后在readable
或drain
事件上重新开始读取或写入。这是不够优雅的,你可能会发现在这种情况下流动模式(或管道)更容易。
以下代码演示了如何计算指定文件内容的 SHA256 哈希。它使用一个处于暂停模式的可读流以块的形式读取文件的内容,然后将每个块传递给计算哈希的对象。(请注意,在 Node 12 及更高版本中,使用for/await
循环编写此函数会更简单。)
const fs = require("fs"); const crypto = require("crypto"); // Compute a sha256 hash of the contents of the named file and pass the // hash (as a string) to the specified error-first callback function. function sha256(filename, callback) { let input = fs.createReadStream(filename); // The data stream. let hasher = crypto.createHash("sha256"); // For computing the hash. input.on("readable", () => { // When there is data ready to read let chunk; while(chunk = input.read()) { // Read a chunk, and if non-null, hasher.update(chunk); // pass it to the hasher, } // and keep looping until not readable }); input.on("end", () => { // At the end of the stream, let hash = hasher.digest("hex"); // compute the hash, callback(null, hash); // and pass it to the callback. }); input.on("error", callback); // On error, call callback } // Here's a simple command-line utility to compute the hash of a file sha256(process.argv[2], (err, hash) => { // Pass filename from command line. if (err) { // If we get an error console.error(err.toString()); // print it as an error. } else { // Otherwise, console.log(hash); // print the hash string. } });
16.6 进程、CPU 和操作系统详细信息
全局 Process 对象具有许多有用的属性和函数,通常与当前运行的 Node 进程的状态有关。请查阅 Node 文档以获取完整详情,但以下是一些你应该知道的属性和函数:
process.argv // An array of command-line arguments. process.arch // The CPU architecture: "x64", for example. process.cwd() // Returns the current working directory. process.chdir() // Sets the current working directory. process.cpuUsage() // Reports CPU usage. process.env // An object of environment variables. process.execPath // The absolute filesystem path to the node executable. process.exit() // Terminates the program. process.exitCode // An integer code to be reported when the program exits. process.getuid() // Return the Unix user id of the current user. process.hrtime.bigint() // Return a "high-resolution" nanosecond timestamp. process.kill() // Send a signal to another process. process.memoryUsage() // Return an object with memory usage details. process.nextTick() // Like setImmediate(), invoke a function soon. process.pid // The process id of the current process. process.ppid // The parent process id. process.platform // The OS: "linux", "darwin", or "win32", for example. process.resourceUsage() // Return an object with resource usage details. process.setuid() // Sets the current user, by id or name. process.title // The process name that appears in `ps` listings. process.umask() // Set or return the default permissions for new files. process.uptime() // Return Node's uptime in seconds. process.version // Node's version string. process.versions // Version strings for the libraries Node depends on.
“os”模块(与process
不同,需要使用require()
显式加载)提供了关于 Node 运行的计算机和操作系统的类似低级细节的访问。你可能永远不需要使用这些功能中的任何一个,但值得知道 Node 提供了它们:
const os = require("os"); os.arch() // Returns CPU architecture. "x64" or "arm", for example. os.constants // Useful constants such as os.constants.signals.SIGINT. os.cpus() // Data about system CPU cores, including usage times. os.endianness() // The CPU's native endianness "BE" or "LE". os.EOL // The OS native line terminator: "\n" or "\r\n". os.freemem() // Returns the amount of free RAM in bytes. os.getPriority() // Returns the OS scheduling priority of a process. os.homedir() // Returns the current user's home directory. os.hostname() // Returns the hostname of the computer. os.loadavg() // Returns the 1, 5, and 15-minute load averages. os.networkInterfaces() // Returns details about available network. connections. os.platform() // Returns OS: "linux", "darwin", or "win32", for example. os.release() // Returns the version number of the OS. os.setPriority() // Attempts to set the scheduling priority for a process. os.tmpdir() // Returns the default temporary directory. os.totalmem() // Returns the total amount of RAM in bytes. os.type() // Returns OS: "Linux", "Darwin", or "Windows_NT", e.g. os.uptime() // Returns the system uptime in seconds. os.userInfo() // Returns uid, username, home, and shell of current user.
16.7 处理文件
Node 的“fs”模块是一个用于处理文件和目录的全面 API。它由“path”模块补充,后者定义了用于处理文件和目录名称的实用函数。“fs”模块包含一些高级函数,用于轻松读取、写入和复制文件。但是,该模块中的大多数函数都是低级 JavaScript 绑定到 Unix 系统调用(以及它们在 Windows 上的等效物)。如果之前有过低级文件系统调用的经验(在 C 或其他语言中),那么 Node API 对你来说将是熟悉的。如果没有,你可能会发现“fs”API 的某些部分很简洁和不直观。例如,删除文件的函数称为unlink()
。
“fs”模块定义了一个庞大的 API,主要是因为通常每个基本操作都有多个变体。正如本章开头所讨论的,大多数函数(如fs.readFile()
)都是非阻塞的、基于回调的和异步的。通常情况下,每个函数都有一个同步阻塞的变体,比如fs.readFileSync()
。在 Node 10 及更高版本中,许多这些函数还有基于 Promise 的异步变体,比如fs.promises.readFile()
。大多数“fs”函数的第一个参数是一个字符串,指定要操作的文件的路径(文件名加可选的目录名)。但是其中一些函数也支持一个以整数“文件描述符”作为第一个参数而不是路径的变体。这些变体的名称以字母“f”开头。例如,fs.truncate()
截断由路径指定的文件,而fs.ftruncate()
截断由文件描述符指定的文件。还有一个基于 Promise 的fs.promises.truncate()
,它期望一个路径,还有另一个基于 Promise 的版本,它作为 FileHandle 对象的方法实现。(FileHandle 类相当于 Promise-based API 中的文件描述符。)最后,在“fs”模块中有一些函数的变体的名称以字母“l”开头。这些“l”变体类似于基本函数,但不会遵循文件系统中的符号链接,而是直接操作符号链接本身。
16.7.1 路径、文件描述符和 FileHandles
要使用“fs”模块处理文件,首先需要能够命名要处理的文件。文件通常由路径指定,这意味着文件本身的名称,以及文件所在的目录层次结构。如果路径是绝对的,这意味着指定了一直到文件系统根目录的所有目录。否则,路径是相对的,只有与其他路径相关时才有意义,通常是当前工作目录。处理路径可能有点棘手,因为不同的操作系统使用不同的字符来分隔目录名称,当连接路径时很容易意外加倍这些分隔符字符,并且../
父目录路径段需要特殊处理。Node 的“path”模块和其他几个重要的 Node 功能有所帮助:
// Some important paths process.cwd() // Absolute path of the current working directory. __filename // Absolute path of the file that holds the current code. __dirname // Absolute path of the directory that holds __filename. os.homedir() // The user's home directory. const path = require("path"); path.sep // Either "/" or "\" depending on your OS // The path module has simple parsing functions let p = "src/pkg/test.js"; // An example path path.basename(p) // => "test.js" path.extname(p) // => ".js" path.dirname(p) // => "src/pkg" path.basename(path.dirname(p)) // => "pkg" path.dirname(path.dirname(p)) // => "src" // normalize() cleans up paths: path.normalize("a/b/c/../d/") // => "a/b/d/": handles ../ segments path.normalize("a/./b") // => "a/b": strips "./" segments path.normalize("//a//b//") // => "/a/b/": removes duplicate / // join() combines path segments, adding separators, then normalizes path.join("src", "pkg", "t.js") // => "src/pkg/t.js" // resolve() takes one or more path segments and returns an absolute // path. It starts with the last argument and works backward, stopping // when it has built an absolute path or resolving against process.cwd(). path.resolve() // => process.cwd() path.resolve("t.js") // => path.join(process.cwd(), "t.js") path.resolve("/tmp", "t.js") // => "/tmp/t.js" path.resolve("/a", "/b", "t.js") // => "/b/t.js"
请注意,path.normalize()
只是一个字符串操作函数,没有访问实际文件系统。fs.realpath()
和fs.realpathSync()
函数执行文件系统感知的规范化:它们解析符号链接并解释相对于当前工作目录的相对路径名。
在前面的示例中,我们假设代码在基于 Unix 的操作系统上运行,path.sep
是“/”。如果想在 Windows 系统上使用 Unix 风格的路径,可以使用path.posix
而不是path
。反之,如果想在 Unix 系统上使用 Windows 路径,可以使用path.win32
。path.posix
和path.win32
定义了与path
本身相同的属性和函数。
我们将在接下来的章节中介绍一些“fs”函数,它们期望一个文件描述符而不是文件名。文件描述符是作为操作系统级别引用“打开”文件的整数。通过调用fs.open()
(或fs.openSync()
)函数,你可以为给定的名称获取一个描述符。进程一次只能打开有限数量的文件,因此当你使用完文件描述符时,调用fs.close()
是很重要的。如果你想要使用最底层的fs.read()
和fs.write()
函数,允许你在文件中跳转,不同时间读取和写入文件的位,你需要打开文件。在“fs”模块中有其他使用文件描述符的函数,但它们都有基于名称的版本,只有当你打算打开文件进行读取或写入时,才真正有意义使用基于描述符的函数。
最后,在fs.promises
定义的基于 Promise 的 API 中,fs.open()
的等价物是fs.promises.open()
,它返回一个解析为 FileHandle 对象的 Promise。这个 FileHandle 对象用于与文件描述符具有相同的目的。然而,除非你需要使用 FileHandle 的最底层的read()
和write()
方法,否则真的没有理由创建一个。如果你确实创建了一个 FileHandle,记得在使用完毕后调用它的close()
方法。
16.7.2 读取文件
Node 允许你一次性读取文件内容,通过流,或使用低级别的 API。
如果你的文件很小,或者内存使用和性能不是最高优先级,那么通常最容易的方法是一次性读取整个文件的内容。你可以同步地、通过回调或 Promise 来做到这一点。默认情况下,你会得到文件的字节作为缓冲区,但如果指定了编码,你将得到一个解码后的字符串。
const fs = require("fs"); let buffer = fs.readFileSync("test.data"); // Synchronous, returns buffer let text = fs.readFileSync("data.csv", "utf8"); // Synchronous, returns string // Read the bytes of the file asynchronously fs.readFile("test.data", (err, buffer) => { if (err) { // Handle the error here } else { // The bytes of the file are in buffer } }); // Promise-based asynchronous read fs.promises .readFile("data.csv", "utf8") .then(processFileText) .catch(handleReadError); // Or use the Promise API with await inside an async function async function processText(filename, encoding="utf8") { let text = await fs.promises.readFile(filename, encoding); // ... process the text here... }
如果你能够按顺序处理文件的内容,并且不需要同时将文件的整个内容保存在内存中,那么通过流来读取文件可能是最有效的方法。我们已经广泛讨论了流:这里是你如何使用流和pipe()
方法将文件的内容写入标准输出的示例:
function printFile(filename, encoding="utf8") { fs.createReadStream(filename, encoding).pipe(process.stdout); }
最后,如果你需要对从文件中读取的字节以及何时读取它们进行低级别的控制,你可以打开一个文件以获取文件描述符,然后使用fs.read()
、fs.readSync()
或fs.promises.read()
从文件的指定源位置读取指定数量的字节到指定的缓冲区的指定目标位置:
const fs = require("fs"); // Reading a specific portion of a data file fs.open("data", (err, fd) => { if (err) { // Report error somehow return; } try { // Read bytes 20 through 420 into a newly allocated buffer. fs.read(fd, Buffer.alloc(400), 0, 400, 20, (err, n, b) => { // err is the error, if any. // n is the number of bytes actually read // b is the buffer that they bytes were read into. }); } finally { // Use a finally clause so we always fs.close(fd); // close the open file descriptor } });
如果你需要从文件中读取多个数据块,基于回调的read()
API 使用起来很麻烦。如果你可以使用同步 API(或基于 Promise 的 API 与await
),那么从文件中读取多个数据块变得很容易:
const fs = require("fs"); function readData(filename) { let fd = fs.openSync(filename); try { // Read the file header let header = Buffer.alloc(12); // A 12 byte buffer fs.readSync(fd, header, 0, 12, 0); // Verify the file's magic number let magic = header.readInt32LE(0); if (magic !== 0xDADAFEED) { throw new Error("File is of wrong type"); } // Now get the offset and length of the data from the header let offset = header.readInt32LE(4); let length = header.readInt32LE(8); // And read those bytes from the file let data = Buffer.alloc(length); fs.readSync(fd, data, 0, length, offset); return data; } finally { // Always close the file, even if an exception is thrown above fs.closeSync(fd); } }
16.7.3 写入文件
在 Node 中写入文件与读取文件非常相似,但有一些额外的细节需要了解。其中一个细节是,创建一个新文件的方式就是简单地向一个尚不存在的文件名写入。
与读取类似,Node 中有三种基本的写入文件的方式。如果文件的整个内容是一个字符串或缓冲区,你可以使用fs.writeFile()
(基于回调)、fs.writeFileSync()
(同步)或fs.promises.writeFile()
(基于 Promise)一次性写入整个内容:
fs.writeFileSync(path.resolve(__dirname, "settings.json"), JSON.stringify(settings));
如果要写入文件的数据是字符串,并且想要使用除了“utf8”之外的编码,请将编码作为可选的第三个参数传递。
相关的函数fs.appendFile()
、fs.appendFileSync()
和fs.promises.appendFile()
类似,但当指定的文件已经存在时,它们会将数据追加到末尾而不是覆盖现有文件内容。
如果要写入文件的数据不是一个块,或者不是同时在内存中的所有数据,那么使用 Writable 流是一个不错的方法,假设您计划从头到尾写入数据而不跳过文件中的位置:
const fs = require("fs"); let output = fs.createWriteStream("numbers.txt"); for(let i = 0; i < 100; i++) { output.write(`${i}\n`); } output.end();
最后,如果您想要将数据写入文件的多个块,并且希望能够控制写入每个块的确切位置,那么可以使用fs.open()
、fs.openSync()
或fs.promises.open()
打开文件,然后使用结果文件描述符与fs.write()
或fs.writeSync()
函数。这些函数有不同形式的字符串和缓冲区。字符串变体接受文件描述符、字符串和要写入该字符串的文件位置(可选的第四个参数为编码)。缓冲区变体接受文件描述符、缓冲区、偏移量和长度,指定缓冲区内的数据块,并指定要写入该块的字节的文件位置。如果您有要写入的 Buffer 对象数组,可以使用单个fs.writev()
或fs.writevSync()
。使用fs.promises.open()
和它生成的 FileHandle 对象写入缓冲区和字符串存在类似的低级函数。
你可以使用fs.truncate()
、fs.truncateSync()
或fs.promises.truncate()
来截断文件的末尾。这些函数以路径作为第一个参数,长度作为第二个参数,并修改文件使其具有指定的长度。如果省略长度,则使用零,并且文件变为空。尽管这些函数的名称是这样的,但它们也可以用于扩展文件:如果指定的长度比当前文件大小长,文件将扩展为零字节到新大小。如果您已经打开要修改的文件,可以使用带有文件描述符或 FileHandle 的ftruncate()
或ftruncateSync()
。
这里描述的各种文件写入函数在数据“写入”后返回或调用其回调或解析其 Promise,这意味着 Node 已将数据交给操作系统。但这并不一定意味着数据实际上已经写入到持久存储中:至少您的一些数据可能仍然在操作系统中的某个地方或设备驱动程序中缓冲,等待写入磁盘。如果调用fs.writeSync()
同步将一些数据写入文件,并且在函数返回后立即发生停电,您可能仍会丢失数据。如果要强制将数据写入磁盘,以确保它已经安全保存,使用fs.fsync()
或fs.fsyncSync()
。这些函数仅适用于文件描述符:没有基于路径的版本。
16.7.4 文件操作
Node 的流类的前面讨论包括两个copyFile()
函数的示例。这些不是您实际使用的实用程序,因为“fs”模块定义了自己的fs.copyFile()
方法(当然还有fs.copyFileSync()
和fs.promises.copyFile()
)。
这些函数将原始文件的名称和副本的名称作为它们的前两个参数。这些可以指定为字符串或 URL 或缓冲区对象。可选的第三个参数是一个整数,其位指定控制copy
操作细节的标志。对于基于回调的fs.copyFile()
,最后一个参数是在复制完成时不带参数调用的回调函数,或者如果出现错误则带有错误参数调用。以下是一些示例:
// Basic synchronous file copy. fs.copyFileSync("ch15.txt", "ch15.bak"); // The COPYFILE_EXCL argument copies only if the new file does not already // exist. It prevents copies from overwriting existing files. fs.copyFile("ch15.txt", "ch16.txt", fs.constants.COPYFILE_EXCL, err => { // This callback will be called when done. On error, err will be non-null. }); // This code demonstrates the Promise-based version of the copyFile function. // Two flags are combined with the bitwise OR opeartor |. The flags mean that // existing files won't be overwritten, and that if the filesystem supports // it, the copy will be a copy-on-write clone of the original file, meaning // that no additional storage space will be required until either the original // or the copy is modified. fs.promises.copyFile("Important data", `Important data ${new Date().toISOString()}" fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE) .then(() => { console.log("Backup complete"); }); .catch(err => { console.error("Backup failed", err); });
fs.rename()
函数(以及通常的同步和基于 Promise 的变体)移动和/或重命名文件。调用它时,传入当前文件的路径和所需的新文件路径。没有标志参数,但基于回调的版本将回调作为第三个参数:
fs.renameSync("ch15.bak", "backups/ch15.bak");
请注意,没有标志可以防止重命名覆盖现有文件。同时请记住,文件只能在文件系统内重命名。
函数fs.link()
和fs.symlink()
及其变体具有与fs.rename()
相同的签名,并且类似于fs.copyFile()
,只是它们分别创建硬链接和符号链接,而不是创建副本。
最后,fs.unlink()
、fs.unlinkSync()
和fs.promises.unlink()
是 Node 用于删除文件的函数。(这种不直观的命名是从 Unix 继承而来,其中删除文件基本上是创建其硬链接的相反操作。)调用此函数并传递一个回调(如果使用基于回调的版本)来删除要删除的文件的字符串、缓冲区或 URL 路径:
fs.unlinkSync("backups/ch15.bak");
16.7.5 文件元数据
fs.stat()
、fs.statSync()
和fs.promises.stat()
函数允许您获取指定文件或目录的元数据。例如:
const fs = require("fs"); let stats = fs.statSync("book/ch15.md"); stats.isFile() // => true: this is an ordinary file stats.isDirectory() // => false: it is not a directory stats.size // file size in bytes stats.atime // access time: Date when it was last read stats.mtime // modification time: Date when it was last written stats.uid // the user id of the file's owner stats.gid // the group id of the file's owner stats.mode.toString(8) // the file's permissions, as an octal string
返回的 Stats 对象包含其他更隐晦的属性和方法,但此代码演示了您最有可能使用的属性。
fs.lstat()
及其变体的工作方式与fs.stat()
完全相同,只是如果指定的文件是符号链接,则 Node 将返回链接本身的元数据,而不是跟随链接。
如果您已打开文件以生成文件描述符或 FileHandle 对象,则可以使用fs.fstat()
或其变体获取已打开文件的元数据信息,而无需再次指定文件名。
除了使用fs.stat()
及其所有变体查询元数据外,还有用于更改元数据的函数。
fs.chmod()
、fs.lchmod()
和fs.fchmod()
(以及同步和基于 Promise 的版本)设置文件或目录的“模式”或权限。模式值是整数,其中每个位具有特定含义,并且在八进制表示法中最容易理解。例如,要使文件对其所有者只读且对其他人不可访问,请使用0o400
:
fs.chmodSync("ch15.md", 0o400); // Don't delete it accidentally!
fs.chown()
、fs.lchown()
和fs.fchown()
(以及同步和基于 Promise 的版本)设置文件或目录的所有者和组(作为 ID)。 (这很重要,因为它们与fs.chmod()
设置的文件权限交互。)
最后,您可以使用fs.utimes()
和fs.futimes()
及其变体设置文件或目录的访问时间和修改时间。
16.7.6 处理目录
在 Node 中创建新目录,使用fs.mkdir()
、fs.mkdirSync()
或fs.promises.mkdir()
。第一个参数是要创建的目录的路径。可选的第二个参数可以是指定新目录的模式(权限位)的整数。或者您可以传递一个带有可选mode
和recursive
属性的对象。如果recursive
为true
,则此函数将创建路径中尚不存在的任何目录:
// Ensure that dist/ and dist/lib/ both exist. fs.mkdirSync("dist/lib", { recursive: true });
fs.mkdtemp()
及其变体接受您提供的路径前缀,将一些随机字符附加到其后(这对安全性很重要),创建一个以该名称命名的目录,并将目录路径返回(或传递给回调)给您。
要删除一个目录,使用fs.rmdir()
或其变体之一。请注意,在删除之前目录必须为空:
// Create a random temporary directory and get its path, then // delete it when we are done let tempDirPath; try { tempDirPath = fs.mkdtempSync(path.join(os.tmpdir(), "d")); // Do something with the directory here } finally { // Delete the temporary directory when we're done with it fs.rmdirSync(tempDirPath); }
“fs”模块为列出目录内容提供了两种不同的 API。首先,fs.readdir()
、fs.readdirSync()
和fs.promises.readdir()
一次性读取整个目录,并向您提供一个字符串数组或指定每个项目的名称和类型(文件或目录)的 Dirent 对象数组。这些函数返回的文件名只是文件的本地名称,而不是整个路径。以下是示例:
let tempFiles = fs.readdirSync("/tmp"); // returns an array of strings // Use the Promise-based API to get a Dirent array, and then // print the paths of subdirectories fs.promises.readdir("/tmp", {withFileTypes: true}) .then(entries => { entries.filter(entry => entry.isDirectory()) .map(entry => entry.name) .forEach(name => console.log(path.join("/tmp/", name))); }) .catch(console.error);
如果你预计需要列出可能有数千条条目的目录,你可能更喜欢 fs.opendir()
及其变体的流式处理方法。这些函数返回表示指定目录的 Dir 对象。你可以使用 Dir 对象的 read()
或 readSync()
方法逐个读取 Dirent。如果向 read()
传递一个回调函数,它将调用该回调。如果省略回调参数,它将返回一个 Promise。当没有更多目录条目时,你将得到 null
而不是 Dirent 对象。
使用 Dir 对象最简单的方法是作为异步迭代器与 for/await
循环一起使用。以下是一个使用流式 API 列出目录条目、对每个条目调用 stat()
并打印文件和目录名称及大小的函数示例:
const fs = require("fs"); const path = require("path"); async function listDirectory(dirpath) { let dir = await fs.promises.opendir(dirpath); for await (let entry of dir) { let name = entry.name; if (entry.isDirectory()) { name += "/"; // Add a trailing slash to subdirectories } let stats = await fs.promises.stat(path.join(dirpath, name)); let size = stats.size; console.log(String(size).padStart(10), name); } }
16.8 HTTP 客户端和服务器
Node 的 “http”,“https” 和 “http2” 模块是完整功能但相对低级的 HTTP 协议实现。它们定义了全面的 API 用于实现 HTTP 客户端和服务器。由于这些 API 相对较低级,本章节无法覆盖所有功能。但接下来的示例演示了如何编写基本的客户端和服务器。
发起基本的 HTTP GET 请求的最简单方法是使用 http.get()
或 https.get()
。这些函数的第一个参数是要获取的 URL。(如果是一个 http://
URL,你必须使用 “http” 模块,如果是一个 https://
URL,你必须使用 “https” 模块。)第二个参数是一个回调函数,当服务器的响应开始到达时将调用该回调,并传入一个 IncomingMessage 对象。当回调被调用时,HTTP 状态和头部信息是可用的,但正文可能还没有准备好。IncomingMessage 对象是一个可读流,你可以使用本章前面演示的技术从中读取响应正文。
§13.2.6 结尾的 getJSON()
函数使用了 http.get()
函数作为 Promise()
构造函数演示的一部分。现在你已经了解了 Node 流和 Node 编程模型,值得重新访问该示例,看看如何使用 http.get()
。
http.get()
和 https.get()
是稍微简化的 http.request()
和 https.request()
函数的变体。以下的 postJSON()
函数演示了如何使用 https.request()
发起包含 JSON 请求体的 HTTPS POST 请求。与 第十三章 的 getJSON()
函数一样,它期望一个 JSON 响应,并返回一个解析后的该响应的 Promise:
const https = require("https"); /* * Convert the body object to a JSON string then HTTPS POST it to the * specified API endpoint on the specified host. When the response arrives, * parse the response body as JSON and resolve the returned Promise with * that parsed value. */ function postJSON(host, endpoint, body, port, username, password) { // Return a Promise object immediately, then call resolve or reject // when the HTTPS request succeeds or fails. return new Promise((resolve, reject) => { // Convert the body object to a string let bodyText = JSON.stringify(body); // Configure the HTTPS request let requestOptions = { method: "POST", // Or "GET", "PUT", "DELETE", etc. host: host, // The host to connect to path: endpoint, // The URL path headers: { // HTTP headers for the request "Content-Type": "application/json", "Content-Length": Buffer.byteLength(bodyText) } }; if (port) { // If a port is specified, requestOptions.port = port; // use it for the request. } // If credentials are specified, add an Authorization header. if (username && password) { requestOptions.auth = `${username}:${password}`; } // Now create the request based on the configuration object let request = https.request(requestOptions); // Write the body of the POST request and end the request. request.write(bodyText); request.end(); // Fail on request errors (such as no network connection) request.on("error", e => reject(e)); // Handle the response when it starts to arrive. request.on("response", response => { if (response.statusCode !== 200) { reject(new Error(`HTTP status ${response.statusCode}`)); // We don't care about the response body in this case, but // we don't want it to stick around in a buffer somewhere, so // we put the stream into flowing mode without registering // a "data" handler so that the body is discarded. response.resume(); return; } // We want text, not bytes. We're assuming the text will be // JSON-formatted but aren't bothering to check the // Content-Type header. response.setEncoding("utf8"); // Node doesn't have a streaming JSON parser, so we read the // entire response body into a string. let body = ""; response.on("data", chunk => { body += chunk; }); // And now handle the response when it is complete. response.on("end", () => { // When the response is done, try { // try to parse it as JSON resolve(JSON.parse(body)); // and resolve the result. } catch(e) { // Or, if anything goes wrong, reject(e); // reject with the error } }); }); }); }
除了发起 HTTP 和 HTTPS 请求, “http” 和 “https” 模块还允许你编写响应这些请求的服务器。基本的方法如下:
- 创建一个新的 Server 对象。
- 调用其
listen()
方法开始监听指定端口的请求。 - 为 “request” 事件注册一个事件处理程序,使用该处理程序来读取客户端的请求(特别是
request.url
属性),并编写你的响应。
接下来的代码创建了一个简单的 HTTP 服务器,从本地文件系统提供静态文件,并实现了一个调试端点,通过回显客户端的请求来响应。
// This is a simple static HTTP server that serves files from a specified // directory. It also implements a special /test/mirror endpoint that // echoes the incoming request, which can be useful when debugging clients. const http = require("http"); // Use "https" if you have a certificate const url = require("url"); // For parsing URLs const path = require("path"); // For manipulating filesystem paths const fs = require("fs"); // For reading files // Serve files from the specified root directory via an HTTP server that // listens on the specified port. function serve(rootDirectory, port) { let server = new http.Server(); // Create a new HTTP server server.listen(port); // Listen on the specified port console.log("Listening on port", port); // When requests come in, handle them with this function server.on("request", (request, response) => { // Get the path portion of the request URL, ignoring // any query parameters that are appended to it. let endpoint = url.parse(request.url).pathname; // If the request was for "/test/mirror", send back the request // verbatim. Useful when you need to see the request headers and body. if (endpoint === "/test/mirror") { // Set response header response.setHeader("Content-Type", "text/plain; charset=UTF-8"); // Specify response status code response.writeHead(200); // 200 OK // Begin the response body with the request response.write(`${request.method} ${request.url} HTTP/${ request.httpVersion }\r\n`); // Output the request headers let headers = request.rawHeaders; for(let i = 0; i < headers.length; i += 2) { response.write(`${headers[i]}: ${headers[i+1]}\r\n`); } // End headers with an extra blank line response.write("\r\n"); // Now we need to copy any request body to the response body // Since they are both streams, we can use a pipe request.pipe(response); } // Otherwise, serve a file from the local directory. else { // Map the endpoint to a file in the local filesystem let filename = endpoint.substring(1); // strip leading / // Don't allow "../" in the path because it would be a security // hole to serve anything outside the root directory. filename = filename.replace(/\.\.\//g, ""); // Now convert from relative to absolute filename filename = path.resolve(rootDirectory, filename); // Now guess the type file's content type based on extension let type; switch(path.extname(filename)) { case ".html": case ".htm": type = "text/html"; break; case ".js": type = "text/javascript"; break; case ".css": type = "text/css"; break; case ".png": type = "image/png"; break; case ".txt": type = "text/plain"; break; default: type = "application/octet-stream"; break; } let stream = fs.createReadStream(filename); stream.once("readable", () => { // If the stream becomes readable, then set the // Content-Type header and a 200 OK status. Then pipe the // file reader stream to the response. The pipe will // automatically call response.end() when the stream ends. response.setHeader("Content-Type", type); response.writeHead(200); stream.pipe(response); }); stream.on("error", (err) => { // Instead, if we get an error trying to open the stream // then the file probably does not exist or is not readable. // Send a 404 Not Found plain-text response with the // error message. response.setHeader("Content-Type", "text/plain; charset=UTF-8"); response.writeHead(404); response.end(err.message); }); } }); } // When we're invoked from the command line, call the serve() function serve(process.argv[2] || "/tmp", parseInt(process.argv[3]) || 8000);
Node 的内置模块就足以编写简单的 HTTP 和 HTTPS 服务器。但请注意,生产服务器通常不直接构建在这些模块之上。相反,大多数复杂的服务器是使用外部库实现的——比如 Express 框架——提供了后端 web 开发人员所期望的 “中间件” 和其他更高级的实用工具。
16.9 非 HTTP 网络服务器和客户端
Web 服务器和客户端已经变得如此普遍,以至于很容易忘记可以编写不使用 HTTP 的客户端和服务器。 尽管 Node 以编写 Web 服务器的良好环境而闻名,但 Node 还完全支持编写其他类型的网络服务器和客户端。
如果您习惯使用流,那么网络相对简单,因为网络套接字只是一种双工流。 “net”模块定义了 Server 和 Socket 类。 要创建一个服务器,调用net.createServer()
,然后调用生成的对象的listen()
方法,告诉服务器在哪个端口上监听连接。 当客户端在该端口上连接时,Server 对象将生成“connection”事件,并传递给事件侦听器的值将是一个 Socket 对象。 Socket 对象是一个双工流,您可以使用它从客户端读取数据并向客户端写入数据。 在 Socket 上调用end()
以断开连接。
编写客户端甚至更容易:将端口号和主机名传递给net.createConnection()
以创建一个套接字,用于与在该主机上运行并在该端口上监听的任何服务器通信。 然后使用该套接字从服务器读取和写入数据。
以下代码演示了如何使用“net”模块编写服务器。 当客户端连接时,服务器讲一个 knock-knock 笑话:
// A TCP server that delivers interactive knock-knock jokes on port 6789. // (Why is six afraid of seven? Because seven ate nine!) const net = require("net"); const readline = require("readline"); // Create a Server object and start listening for connections let server = net.createServer(); server.listen(6789, () => console.log("Delivering laughs on port 6789")); // When a client connects, tell them a knock-knock joke. server.on("connection", socket => { tellJoke(socket) .then(() => socket.end()) // When the joke is done, close the socket. .catch((err) => { console.error(err); // Log any errors that occur, socket.end(); // but still close the socket! }); }); // These are all the jokes we know. const jokes = { "Boo": "Don't cry...it's only a joke!", "Lettuce": "Let us in! It's freezing out here!", "A little old lady": "Wow, I didn't know you could yodel!" }; // Interactively perform a knock-knock joke over this socket, without blocking. async function tellJoke(socket) { // Pick one of the jokes at random let randomElement = a => a[Math.floor(Math.random() * a.length)]; let who = randomElement(Object.keys(jokes)); let punchline = jokes[who]; // Use the readline module to read the user's input one line at a time. let lineReader = readline.createInterface({ input: socket, output: socket, prompt: ">> " }); // A utility function to output a line of text to the client // and then (by default) display a prompt. function output(text, prompt=true) { socket.write(`${text}\r\n`); if (prompt) lineReader.prompt(); } // Knock-knock jokes have a call-and-response structure. // We expect different input from the user at different stages and // take different action when we get that input at different stages. let stage = 0; // Start the knock-knock joke off in the traditional way. output("Knock knock!"); // Now read lines asynchronously from the client until the joke is done. for await (let inputLine of lineReader) { if (stage === 0) { if (inputLine.toLowerCase() === "who's there?") { // If the user gives the right response at stage 0 // then tell the first part of the joke and go to stage 1. output(who); stage = 1; } else { // Otherwise teach the user how to do knock-knock jokes. output('Please type "Who\'s there?".'); } } else if (stage === 1) { if (inputLine.toLowerCase() === `${who.toLowerCase()} who?`) { // If the user's response is correct at stage 1, then // deliver the punchline and return since the joke is done. output(`${punchline}`, false); return; } else { // Make the user play along. output(`Please type "${who} who?".`); } } } }
这样的简单基于文本的服务器通常不需要一个定制的客户端。如果您的系统上安装了nc
(“netcat”)实用程序,您可以使用它来与这个服务器通信,方法如下:
$ nc localhost 6789 Knock knock! >> Who's there? A little old lady >> A little old lady who? Wow, I didn't know you could yodel!
另一方面,在 Node 中编写一个定制的客户端对于笑话服务器来说很容易。 我们只需连接到服务器,然后将服务器的输出导向 stdout,并将 stdin 导向服务器的输入:
// Connect to the joke port (6789) on the server named on the command line let socket = require("net").createConnection(6789, process.argv[2]); socket.pipe(process.stdout); // Pipe data from the socket to stdout process.stdin.pipe(socket); // Pipe data from stdin to the socket socket.on("close", () => process.exit()); // Quit when the socket closes.
除了支持基于 TCP 的服务器,Node 的“net”模块还支持通过“Unix 域套接字”进行进程间通信,这些套接字通过文件系统路径而不是端口号进行标识。 我们不打算在本章中涵盖这种类型的套接字,但 Node 文档中有详细信息。 我们在这里没有空间涵盖的其他 Node 功能包括“dgram”模块用于基于 UDP 的客户端和服务器,以及“tls”模块,它类似于“https”对“http”的关系。 tls.Server
和tls.TLSSocket
类允许创建使用 SSL 加密连接的 TCP 服务器(如 knock-knock 笑话服务器),就像 HTTPS 服务器一样。
16.10 使用子进程进行操作
除了编写高度并发的服务器,Node 还适用于编写执行其他程序的脚本。 在 Node 中,“child_process”模块定义了许多函数,用于作为子进程运行其他程序。 本节演示了其中一些函数,从最简单的开始,逐渐过渡到更复杂的函数。
16.10.1 execSync()和 execFileSync()
运行另一个程序的最简单方法是使用child_process.execSync()
。 此函数将要运行的命令作为其第一个参数。 它创建一个子进程,在该进程中运行一个 shell,并使用 shell 执行您传递的命令。 然后它阻塞,直到命令(和 shell)退出。 如果命令以错误退出,则execSync()
会抛出异常。 否则,execSync()
返回命令写入其 stdout 流的任何输出。 默认情况下,此返回值是一个缓冲区,但您可以在可选的第二个参数中指定编码以获得一个字符串。 如果命令将任何输出写入 stderr,则该输出将直接传递到父进程的 stderr 流。
所以,例如,如果您正在编写一个脚本,性能不是一个问题,您可能会使用child_process.execSync()
来列出一个目录,而不是使用fs.readdirSync()
函数:
const child_process = require("child_process"); let listing = child_process.execSync("ls -l web/*.html", {encoding: "utf8"});
execSync()
调用完整的 Unix shell 意味着您传递给它的字符串可以包含多个以分号分隔的命令,并且可以利用 shell 功能,如文件名通配符、管道和输出重定向。这也意味着您必须小心,永远不要将来自用户输入或类似不受信任来源的命令传递给 execSync()
。shell 命令的复杂语法很容易被利用,以允许攻击者运行任意代码。
如果您不需要 shell 的功能,可以通过使用 child_process.execFileSync()
避免启动 shell 的开销。此函数直接执行程序,而不调用 shell。但由于不涉及 shell,它无法解析命令行,您必须将可执行文件作为第一个参数传递,并将命令行参数数组作为第二个参数传递:
let listing = child_process.execFileSync("ls", ["-l", "web/"], {encoding: "utf8"});
16.10.2 exec() 和 execFile()
execSync()
和 execFileSync()
函数是同步的:它们会阻塞并在子进程退出之前不返回。使用这些函数很像在终端窗口中输入 Unix 命令:它们允许您逐个运行一系列命令。但是,如果您正在编写一个需要完成多个任务且这些任务彼此不依赖的程序,那么您可能希望并行运行它们并同时运行多个命令。您可以使用异步函数 child_process.exec()
和 child_process.execFile()
来实现这一点。
exec()
和 execFile()
与它们的同步变体类似,只是它们立即返回一个代表正在运行的子进程的 ChildProcess 对象,并且它们将错误优先的回调作为最后一个参数。当子进程退出时,将调用回调,并实际上会使用三个参数调用它。第一个是错误(如果有的话);如果进程正常终止,则为 null
。第二个参数是发送到子进程标准输出流的收集输出。第三个参数是发送到子进程标准错误流的任何输出。
exec()
和 execFile()
返回的 ChildProcess 对象允许您终止子进程,并向其写入数据(然后可以从其标准输入读取)。当我们讨论 child_process.spawn()
函数时,我们将更详细地介绍 ChildProcess。
如果您计划同时执行多个子进程,则最简单的方法可能是使用 exec()
的“promisified”版本,它返回一个 Promise 对象,如果子进程无错误退出,则解析为具有 stdout
和 stderr
属性的对象。例如,这是一个接受 shell 命令数组作为输入并返回一个 Promise 的函数,该 Promise 解析为所有这些命令的结果:
const child_process = require("child_process"); const util = require("util"); const execP = util.promisify(child_process.exec); function parallelExec(commands) { // Use the array of commands to create an array of Promises let promises = commands.map(command => execP(command, {encoding: "utf8"})); // Return a Promise that will fulfill to an array of the fulfillment // values of each of the individual promises. (Instead of returning objects // with stdout and stderr properties we just return the stdout value.) return Promise.all(promises) .then(outputs => outputs.map(out => out.stdout)); } module.exports = parallelExec;
16.10.3 spawn()
到目前为止描述的各种 exec
函数——同步和异步——都设计用于与快速运行且不产生大量输出的子进程一起使用。即使是异步的 exec()
和 execFile()
也不是流式的:它们在进程退出后才一次性返回进程输出。
child_process.spawn()
函数允许您在子进程仍在运行时流式访问子进程的输出。它还允许您向子进程写入数据(子进程将把该数据视为其标准输入流上的输入):这意味着可以动态与子进程交互,根据其生成的输出发送输入。
spawn()
默认不使用 shell,因此您必须像使用 execFile()
一样调用它,提供要运行的可执行文件以及一个单独的命令行参数数组传递给它。spawn()
返回一个类似于 execFile()
的 ChildProcess 对象,但它不接受回调参数。您可以监听 ChildProcess 对象及其流上的事件,而不是使用回调函数。
由spawn()
返回的 ChildProcess 对象是一个事件发射器。你可以监听“exit”事件以在子进程退出时收到通知。ChildProcess 对象还有三个流属性。stdout
和stderr
是可读流:当子进程写入其 stdout 和 stderr 流时,该输出通过 ChildProcess 流变得可读。请注意这里名称的倒置。在子进程中,stdout
是一个可写输出流,但在父进程中,ChildProcess 对象的stdout
属性是一个可读输入流。
类似地,ChildProcess 对象的stdin
属性是一个可写流:你写入到这个流的任何内容都会在子进程的标准输入上可用。
ChildProcess 对象还定义了一个pid
属性,指定子进程的进程 ID。它还定义了一个kill()
方法,用于终止子进程。
JavaScript 权威指南第七版(GPT 重译)(七)(3)https://developer.aliyun.com/article/1485475