七天学会NodeJS(二)文件操作(fs、buffer、stream(防爆仓)、path、遍历目录)、网络操作(http、URL)

七天学会NodeJS(二)文件操作(fs、buffer、stream(防爆仓)、path、遍历目录)、网络操作(http、URL)

总结:

  • 文件操作

    • 小文件拷贝

      •  fs.writeFileSync(dst, fs.readFileSync(src));
        
    • 大文件拷贝

      • fs.createReadStream(src).pipe(fs.createWriteStream(dst));
        
    • agrv

      • process是一个全局变量,可通过process.argv获得命令行参数。
      • 由于argv[0]固定等于NodeJS执行程序的绝对路径,argv[1]固定等于主模块的绝对路径,因此第一个命令行参数从argv[2]这个位置开始。
    • Buffer

      • JS语言自身只有字符串数据类型,没有二进制数据类型,因此NodeJS提供了一个与String对等的全局构造函数Buffer来提供对二进制数据的操作。除了可以读取文件得到Buffer的实例外,还能够直接构造,例如:

        var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
        
      • Buffer与字符串类似,除了可以用.length属性得到字节长度外,还可以用[index]方式读取指定位置的字节

      • Buffer与字符串能够互相转化,例如可以使用指定编码将二进制数据转化为字符串

      • Buffer与字符串有一个重要区别。字符串是只读的,并且对字符串的任何修改得到的都是一个新字符串,原字符串保持不变。至于Buffer,更像是可以做指针操作的C语言数组。例如,可以用[index]方式直接修改某个位置的字节.

    • stream(防爆仓)

      • 当内存中无法一次装下需要处理的数据时,或者一边读取一边处理更加高效时,我们就需要用到数据流。NodeJS中通过各种Stream来提供对数据流的操作。
      • Stream基于事件机制工作,所有Stream的实例都继承于NodeJS提供的EventEmitter
      • 如果写入速度跟不上读取速度的话,只写数据流内部的缓存会爆仓。我们可以根据.write方法的返回值来判断传入的数据是写入目标了,还是临时放在了缓存了,并根据drain事件来判断什么时候只写数据流已经将缓存中的数据写入目标,可以传入下一个待写数据了。
    • fs文件系统

      • NodeJS最精华的异步IO模型在fs模块里有着充分的体现

      • 文件属性读写。

        其中常用的有fs.statfs.chmodfs.chown等等。

      • 文件内容读写。

        其中常用的有fs.readFilefs.readdirfs.writeFilefs.mkdir等等。

      • 底层文件操作。

        其中常用的有fs.openfs.readfs.writefs.close等等。

      • 掌握好目录遍历

        • fs.readdir(dir, function (err, files)
          
        •  var pathname = path.join(dir, files[i]);
          
        • fs.stat(pathname, function (err, stats) 
          
        •  if (stats.isDirectory())
          
  • 网络操作

    • 两种使用方式

      • 作为服务端使用时,创建一个HTTP服务器,监听HTTP客户端请求并返回响应。

        var http = require('http');
        
        http.createServer(function (request, response) {
            response.writeHead(200, { 'Content-Type': 'text-plain' });
            response.end('Hello World\n');
        }).listen(8124);
        
      • 作为客户端使用时,发起一个HTTP客户端请求,获取服务端响应。

        • 客户端模式
        • .request方法创建了一个客户端,并指定请求目标和请求头数据。
        • 之后,就可以把request对象当作一个只写数据流来写入请求体数据和结束请求
    • HTTP请求本质上是一个数据流,由请求头(headers)和请求体(body)组成。

    • HTTP响应本质上也是一个数据流,同样由响应头(headers)和响应体(body)组成。

    • https

      • https模块与http模块极为类似,区别在于https模块需要额外处理SSL证书。
    • URL

      • .parse方法来将一个URL字符串转换为URL对象:url.parse()
        • .parse方法还支持第二个和第三个布尔类型可选参数。
        • 第二个参数等于true时,该方法返回的URL对象中,query字段不再是一个字符串,而是一个经过querystring模块转换后的参数对象。
        • 第三个参数等于true时,该方法可以正确解析不带协议头的URL,例如//www.example.com/foo/bar
      • format方法允许将一个URL对象转换为URL字符串
      • .resolve方法可以用于拼接URL

1. 文件操作

让前端觉得如获神器的不是NodeJS能做网络编程,而是NodeJS能够操作文件。小至文件查找,大至代码编译,几乎没有一个前端工具不操作文件。换个角度讲,几乎也只需要一些数据处理逻辑,再加上一些文件操作,就能够编写出大多数前端工具。本章将介绍与之相关的NodeJS内置模块。

开门红

NodeJS提供了基本的文件操作API,但是像文件拷贝这种高级功能就没有提供,因此我们先拿文件拷贝程序练手。与copy命令类似,我们的程序需要能接受源文件路径与目标文件路径两个参数。

小文件拷贝

我们使用NodeJS内置的fs模块简单实现这个程序如下。

var fs = require('fs');

function copy(src, dst) {
    fs.writeFileSync(dst, fs.readFileSync(src));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

以上程序使用fs.readFileSync从源路径读取文件内容,并使用fs.writeFileSync将文件内容写入目标路径。

豆知识: process是一个全局变量,可通过process.argv获得命令行参数。由于argv[0]固定等于NodeJS执行程序的绝对路径,argv[1]固定等于主模块的绝对路径,因此第一个命令行参数从argv[2]这个位置开始。

大文件拷贝

上边的程序拷贝一些小文件没啥问题,但这种一次性把所有文件内容都读取到内存中后再一次性写入磁盘的方式不适合拷贝大文件,内存会爆仓。对于大文件,我们只能读一点写一点,直到完成拷贝。因此上边的程序需要改造如下。

var fs = require('fs');

function copy(src, dst) {
    fs.createReadStream(src).pipe(fs.createWriteStream(dst));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

以上程序使用fs.createReadStream创建了一个源文件的只读数据流,并使用fs.createWriteStream创建了一个目标文件的只写数据流,并且用pipe方法把两个数据流连接了起来。连接起来后发生的事情,说得抽象点的话,水顺着水管从一个桶流到了另一个桶。

API走马观花

我们先大致看看NodeJS提供了哪些和文件操作有关的API。这里并不逐一介绍每个API的使用方法,官方文档已经做得很好了。

Buffer(数据块)

官方文档: http://nodejs.org/api/buffer.html

JS语言自身只有字符串数据类型,没有二进制数据类型,因此NodeJS提供了一个与String对等的全局构造函数Buffer来提供对二进制数据的操作。除了可以读取文件得到Buffer的实例外,还能够直接构造,例如:

var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);

Buffer与字符串类似,除了可以用.length属性得到字节长度外,还可以用[index]方式读取指定位置的字节,例如:

bin[0]; // => 0x68;

Buffer与字符串能够互相转化,例如可以使用指定编码将二进制数据转化为字符串:

var str = bin.toString('utf-8'); // => "hello"

或者反过来,将字符串转换为指定编码下的二进制数据:

var bin = new Buffer('hello', 'utf-8'); // => <Buffer 68 65 6c 6c 6f>

Buffer与字符串有一个重要区别。字符串是只读的,并且对字符串的任何修改得到的都是一个新字符串,原字符串保持不变。至于Buffer,更像是可以做指针操作的C语言数组。例如,可以用[index]方式直接修改某个位置的字节。

bin[0] = 0x48;

.slice方法也不是返回一个新的Buffer,而更像是返回了指向原Buffer中间的某个位置的指针,如下所示。

[ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]
    ^           ^
    |           |
   bin     bin.slice(2)

因此对.slice方法返回的Buffer的修改会作用于原Buffer,例如:

var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var sub = bin.slice(2);

sub[0] = 0x65;
console.log(bin); // => <Buffer 68 65 65 6c 6f>

也因此,如果想要拷贝一份Buffer,得首先创建一个新的Buffer,并通过.copy方法把原Buffer中的数据复制过去。这个类似于申请一块新的内存,并把已有内存中的数据复制过去。以下是一个例子。

var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var dup = new Buffer(bin.length);

bin.copy(dup);
dup[0] = 0x48;
console.log(bin); // => <Buffer 68 65 6c 6c 6f>
console.log(dup); // => <Buffer 48 65 65 6c 6f>

总之,Buffer将JS的数据处理能力从字符串扩展到了任意二进制数据。

Stream(数据流)

官方文档: http://nodejs.org/api/stream.html

当内存中无法一次装下需要处理的数据时,或者一边读取一边处理更加高效时,我们就需要用到数据流。NodeJS中通过各种Stream来提供对数据流的操作

以上边的大文件拷贝程序为例,我们可以为数据来源创建一个只读数据流,示例如下:

var rs = fs.createReadStream(pathname);

rs.on('data', function (chunk) {
    doSomething(chunk);
});

rs.on('end', function () {
    cleanUp();
});

豆知识: Stream基于事件机制工作,所有Stream的实例都继承于NodeJS提供的EventEmitter

上边的代码中data事件会源源不断地被触发,不管doSomething函数是否处理得过来。代码可以继续做如下改造,以解决这个问题。

var rs = fs.createReadStream(src);

rs.on('data', function (chunk) {
    rs.pause();
    doSomething(chunk, function () {
        rs.resume();
    });
});

rs.on('end', function () {
    cleanUp();
});

以上代码给doSomething函数加上了回调,因此我们可以在处理数据前暂停数据读取,并在处理数据后继续读取数据。

此外,我们也可以为数据目标创建一个只写数据流,示例如下:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);

rs.on('data', function (chunk) {
    ws.write(chunk);
});

rs.on('end', function () {
    ws.end();
});

我们把doSomething换成了往只写数据流里写入数据后,以上代码看起来就像是一个文件拷贝程序了。但是以上代码存在上边提到的问题,如果写入速度跟不上读取速度的话,只写数据流内部的缓存会爆仓。我们可以根据.write方法的返回值来判断传入的数据是写入目标了,还是临时放在了缓存了,并根据drain事件来判断什么时候只写数据流已经将缓存中的数据写入目标,可以传入下一个待写数据了。因此代码可以改造如下:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);

rs.on('data', function (chunk) {
    if (ws.write(chunk) === false) {
        rs.pause();
    }
});

rs.on('end', function () {
    ws.end();
});

ws.on('drain', function () {
    rs.resume();
});

以上代码实现了数据从只读数据流到只写数据流的搬运,并包括了防爆仓控制。因为这种使用场景很多,例如上边的大文件拷贝程序,NodeJS直接提供了.pipe方法来做这件事情,其内部实现方式与上边的代码类似。

File System(文件系统)

官方文档: http://nodejs.org/api/fs.html

NodeJS通过fs内置模块提供对文件的操作。fs模块提供的API基本上可以分为以下三类:

  • 文件属性读写。

    其中常用的有fs.statfs.chmodfs.chown等等。

  • 文件内容读写。

    其中常用的有fs.readFilefs.readdirfs.writeFilefs.mkdir等等。

  • 底层文件操作。

    其中常用的有fs.openfs.readfs.writefs.close等等。

NodeJS最精华的异步IO模型在fs模块里有着充分的体现,例如上边提到的这些API都通过回调函数传递结果。以fs.readFile为例:

fs.readFile(pathname, function (err, data) {
    if (err) {
        // Deal with error.
    } else {
        // Deal with data.
    }
});

如上边代码所示,基本上所有fs模块API的回调参数都有两个。第一个参数在有错误发生时等于异常对象,第二个参数始终用于返回API方法执行结果。

此外,fs模块的所有异步API都有对应的同步版本,用于无法使用异步操作时,或者同步操作更方便时的情况。同步API除了方法名的末尾多了一个Sync之外,异常对象与执行结果的传递方式也有相应变化。同样以fs.readFileSync为例:

try {
    var data = fs.readFileSync(pathname);
    // Deal with data.
} catch (err) {
    // Deal with error.
}

fs模块提供的API很多,这里不一一介绍,需要时请自行查阅官方文档。

Path(路径)

官方文档: http://nodejs.org/api/path.html

操作文件时难免不与文件路径打交道。NodeJS提供了path内置模块来简化路径相关操作,并提升代码可读性。以下分别介绍几个常用的API。

  • path.normalize

    将传入的路径转换为标准路径,具体讲的话,除了解析路径中的...外,还能去掉多余的斜杠。如果有程序需要使用路径作为某些数据的索引,但又允许用户随意输入路径时,就需要使用该方法保证路径的唯一性。以下是一个例子:

      var cache = {};
    
      function store(key, value) {
          cache[path.normalize(key)] = value;
      }
    
      store('foo/bar', 1);
      store('foo//baz//../bar', 2);
      console.log(cache);  // => { "foo/bar": 2 }
    

    坑出没注意: 标准化之后的路径里的斜杠在Windows系统下是\,而在Linux系统下是/。如果想保证任何系统下都使用/作为路径分隔符的话,需要用.replace(/\\/g, '/')再替换一下标准路径。

  • path.join

    将传入的多个路径拼接为标准路径。该方法可避免手工拼接路径字符串的繁琐,并且能在不同系统下正确使用相应的路径分隔符。以下是一个例子:

      path.join('foo/', 'baz/', '../bar'); // => "foo/bar"
    
  • path.extname

    当我们需要根据不同文件扩展名做不同操作时,该方法就显得很好用。以下是一个例子:

      path.extname('foo/bar.js'); // => ".js"
    

path模块提供的其余方法也不多,稍微看一下官方文档就能全部掌握。

遍历目录

遍历目录是操作文件时的一个常见需求。比如写一个程序,需要找到并处理指定目录下的所有JS文件时,就需要遍历整个目录。

递归算法

遍历目录时一般使用递归算法,否则就难以编写出简洁的代码。递归算法与数学归纳法类似,通过不断缩小问题的规模来解决问题。以下示例说明了这种方法。

function factorial(n) {
    if (n === 1) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

上边的函数用于计算N的阶乘(N!)。可以看到,当N大于1时,问题简化为计算N乘以N-1的阶乘。当N等于1时,问题达到最小规模,不需要再简化,因此直接返回1。

陷阱: 使用递归算法编写的代码虽然简洁,但由于每递归一次就产生一次函数调用,在需要优先考虑性能时,需要把递归算法转换为循环算法,以减少函数调用次数。

遍历算法

目录是一个树状结构,在遍历时一般使用深度优先+先序遍历算法。深度优先,意味着到达一个节点后,首先接着遍历子节点而不是邻居节点。先序遍历,意味着首次到达了某节点就算遍历完成,而不是最后一次返回某节点才算数。因此使用这种遍历方式时,下边这棵树的遍历顺序是A > B > D > E > C > F

          A
         / \
        B   C
       / \   \
      D   E   F
同步遍历

了解了必要的算法后,我们可以简单地实现以下目录遍历函数。

function travel(dir, callback) {
    fs.readdirSync(dir).forEach(function (file) {
        var pathname = path.join(dir, file);

        if (fs.statSync(pathname).isDirectory()) {
            travel(pathname, callback);
        } else {
            callback(pathname);
        }
    });
}

可以看到,该函数以某个目录作为遍历的起点。遇到一个子目录时,就先接着遍历子目录。遇到一个文件时,就把文件的绝对路径传给回调函数。回调函数拿到文件路径后,就可以做各种判断和处理。因此假设有以下目录:

- /home/user/
    - foo/
        x.js
    - bar/
        y.js
    z.css

使用以下代码遍历该目录时,得到的输入如下。

travel('/home/user', function (pathname) {
    console.log(pathname);
});

------------------------
/home/user/foo/x.js
/home/user/bar/y.js
/home/user/z.css
异步遍历

如果读取目录或读取文件状态时使用的是异步API,目录遍历函数实现起来会有些复杂,但原理完全相同。travel函数的异步版本如下。

function travel(dir, callback, finish) {
    fs.readdir(dir, function (err, files) {
        (function next(i) {
            if (i < files.length) {
                var pathname = path.join(dir, files[i]);

                fs.stat(pathname, function (err, stats) {
                    if (stats.isDirectory()) {
                        travel(pathname, callback, function () {
                            next(i + 1);
                        });
                    } else {
                        callback(pathname, function () {
                            next(i + 1);
                        });
                    }
                });
            } else {
                finish && finish();
            }
        }(0));
    });
}

这里不详细介绍异步遍历函数的编写技巧,在后续章节中会详细介绍这个。总之我们可以看到异步编程还是蛮复杂的。

文本编码

使用NodeJS编写前端工具时,操作得最多的是文本文件,因此也就涉及到了文件编码的处理问题。我们常用的文本编码有UTF8GBK两种,并且UTF8文件还可能带有BOM。在读取不同编码的文本文件时,需要将文件内容转换为JS使用的UTF8编码字符串后才能正常处理。

BOM的移除

BOM用于标记一个文本文件使用Unicode编码,其本身是一个Unicode字符("\uFEFF"),位于文本文件头部。在不同的Unicode编码下,BOM字符对应的二进制字节如下:

    Bytes      Encoding
----------------------------
    FE FF       UTF16BE
    FF FE       UTF16LE
    EF BB BF    UTF8

因此,我们可以根据文本文件头几个字节等于啥来判断文件是否包含BOM,以及使用哪种Unicode编码。但是,BOM字符虽然起到了标记文件编码的作用,其本身却不属于文件内容的一部分,如果读取文本文件时不去掉BOM,在某些使用场景下就会有问题。例如我们把几个JS文件合并成一个文件后,如果文件中间含有BOM字符,就会导致浏览器JS语法错误。因此,使用NodeJS读取文本文件时,一般需要去掉BOM。例如,以下代码实现了识别和去除UTF8 BOM的功能。

function readText(pathname) {
    var bin = fs.readFileSync(pathname);

    if (bin[0] === 0xEF && bin[1] === 0xBB && bin[2] === 0xBF) {
        bin = bin.slice(3);
    }

    return bin.toString('utf-8');
}
GBK转UTF8

NodeJS支持在读取文本文件时,或者在Buffer转换为字符串时指定文本编码,但遗憾的是,GBK编码不在NodeJS自身支持范围内。因此,一般我们借助iconv-lite这个三方包来转换编码。使用NPM下载该包后,我们可以按下边方式编写一个读取GBK文本文件的函数。

var iconv = require('iconv-lite');

function readGBKText(pathname) {
    var bin = fs.readFileSync(pathname);

    return iconv.decode(bin, 'gbk');
}
单字节编码

有时候,我们无法预知需要读取的文件采用哪种编码,因此也就无法指定正确的编码。比如我们要处理的某些CSS文件中,有的用GBK编码,有的用UTF8编码。虽然可以一定程度可以根据文件的字节内容猜测出文本编码,但这里要介绍的是有些局限,但是要简单得多的一种技术。

首先我们知道,如果一个文本文件只包含英文字符,比如Hello World,那无论用GBK编码或是UTF8编码读取这个文件都是没问题的。这是因为在这些编码下,ASCII0~128范围内字符都使用相同的单字节编码。

反过来讲,即使一个文本文件中有中文等字符,如果我们需要处理的字符仅在ASCII0~128范围内,比如除了注释和字符串以外的JS代码,我们就可以统一使用单字节编码来读取文件,不用关心文件的实际编码是GBK还是UTF8。以下示例说明了这种方法。

1. GBK编码源文件内容:
    var foo = '中文';
2. 对应字节:
    76 61 72 20 66 6F 6F 20 3D 20 27 D6 D0 CE C4 27 3B
3. 使用单字节编码读取后得到的内容:
    var foo = '{乱码}{乱码}{乱码}{乱码}';
4. 替换内容:
    var bar = '{乱码}{乱码}{乱码}{乱码}';
5. 使用单字节编码保存后对应字节:
    76 61 72 20 62 61 72 20 3D 20 27 D6 D0 CE C4 27 3B
6. 使用GBK编码读取后得到内容:
    var bar = '中文';

这里的诀窍在于,不管大于0xEF的单个字节在单字节编码下被解析成什么乱码字符,使用同样的单字节编码保存这些乱码字符时,背后对应的字节保持不变。

NodeJS中自带了一种binary编码可以用来实现这个方法,因此在下例中,我们使用这种编码来演示上例对应的代码该怎么写。

function replace(pathname) {
    var str = fs.readFileSync(pathname, 'binary');
    str = str.replace('foo', 'bar');
    fs.writeFileSync(pathname, str, 'binary');
}

小结

本章介绍了使用NodeJS操作文件时需要的API以及一些技巧,总结起来有以下几点:

  • 学好文件操作,编写各种程序都不怕。
  • 如果不是很在意性能,fs模块的同步API能让生活更加美好。
  • 需要对文件读写做到字节级别的精细控制时,请使用fs模块的文件底层操作API。
  • 不要使用拼接字符串的方式来处理路径,使用path模块。
  • 掌握好目录遍历和文件编码处理技巧,很实用。

2. 网络操作

不了解网络编程的程序员不是好前端,而NodeJS恰好提供了一扇了解网络编程的窗口。通过NodeJS,除了可以编写一些服务端程序来协助前端开发和测试外,还能够学习一些HTTP协议与Socket协议的相关知识,这些知识在优化前端性能和排查前端故障时说不定能派上用场。本章将介绍与之相关的NodeJS内置模块。

开门红

NodeJS本来的用途是编写高性能Web服务器。我们首先在这里重复一下官方文档里的例子,使用NodeJS内置的http模块简单实现一个HTTP服务器。

var http = require('http');

http.createServer(function (request, response) {
    response.writeHead(200, { 'Content-Type': 'text-plain' });
    response.end('Hello World\n');
}).listen(8124);

以上程序创建了一个HTTP服务器并监听8124端口,打开浏览器访问该端口http://127.0.0.1:8124/就能够看到效果

const http = require('http')

const port = 3000

const server = http.createServer((req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain')
  res.end('你好世界\n')
})

server.listen(port, () => {
  console.log(`服务器运行在 http://${hostname}:${port}/`)
})

豆知识: 在Linux系统下,监听1024以下端口需要root权限。因此,如果想监听80或443端口的话,需要使用sudo命令启动程序。

API走马观花

我们先大致看看NodeJS提供了哪些和网络操作有关的API。这里并不逐一介绍每个API的使用方法,官方文档已经做得很好了。

HTTP

官方文档: http://nodejs.org/api/http.html

'http’模块提供两种使用方式:

  • 作为服务端使用时,创建一个HTTP服务器,监听HTTP客户端请求并返回响应。
  • 作为客户端使用时,发起一个HTTP客户端请求,获取服务端响应。

首先我们来看看服务端模式下如何工作。如开门红中的例子所示,首先需要使用.createServer方法创建一个服务器,然后调用.listen方法监听端口。之后,每当来了一个客户端请求,创建服务器时传入的回调函数就被调用一次。可以看出,这是一种事件机制。

HTTP请求本质上是一个数据流,由请求头(headers)和请求体(body)组成。例如以下是一个完整的HTTP请求数据内容

POST / HTTP/1.1
User-Agent: curl/7.26.0
Host: localhost
Accept: */*
Content-Length: 11
Content-Type: application/x-www-form-urlencoded

Hello World

可以看到,空行之上是请求头,之下是请求体。HTTP请求在发送给服务器时,可以认为是按照从头到尾的顺序一个字节一个字节地以数据流方式发送的。而http模块创建的HTTP服务器在接收到完整的请求头后,就会调用回调函数。在回调函数中,除了可以使用request对象访问请求头数据外,还能把request对象当作一个只读数据流来访问请求体数据。以下是一个例子。

http.createServer(function (request, response) {
    var body = [];

    console.log(request.method);
    console.log(request.headers);

    request.on('data', function (chunk) {
        body.push(chunk);
    });

    request.on('end', function () {
        body = Buffer.concat(body);
        console.log(body.toString());
    });
}).listen(80);

------------------------------------
POST
{ 'user-agent': 'curl/7.26.0',
  host: 'localhost',
  accept: '*/*',
  'content-length': '11',
  'content-type': 'application/x-www-form-urlencoded' }
Hello World

HTTP响应本质上也是一个数据流,同样由响应头(headers)和响应体(body)组成。例如以下是一个完整的HTTP请求数据内容。

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 11
Date: Tue, 05 Nov 2013 05:31:38 GMT
Connection: keep-alive

Hello World

在回调函数中,除了可以使用response对象来写入响应头数据外,还能把response对象当作一个只写数据流来写入响应体数据。例如在以下例子中,服务端原样将客户端请求的请求体数据返回给客户端。

http.createServer(function (request, response) {
    response.writeHead(200, { 'Content-Type': 'text/plain' });

    request.on('data', function (chunk) {
        response.write(chunk);
    });

    request.on('end', function () {
        response.end();
    });
}).listen(80);

接下来我们看看客户端模式下如何工作。为了发起一个客户端HTTP请求,我们需要指定目标服务器的位置并发送请求头和请求体,以下示例演示了具体做法。

var options = {
        hostname: 'www.example.com',
        port: 80,
        path: '/upload',
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    };

var request = http.request(options, function (response) {});

request.write('Hello World');
request.end();

可以看到,.request方法创建了一个客户端,并指定请求目标和请求头数据。之后,就可以把request对象当作一个只写数据流来写入请求体数据和结束请求。另外,由于HTTP请求中GET请求是最常见的一种,并且不需要请求体,因此http模块也提供了以下便捷API。

http.get('http://www.example.com/', function (response) {});

当客户端发送请求并接收到完整的服务端响应头时,就会调用回调函数。在回调函数中,除了可以使用response对象访问响应头数据外,还能把response对象当作一个只读数据流来访问响应体数据。以下是一个例子。

http.get('http://www.example.com/', function (response) {
    var body = [];

    console.log(response.statusCode);
    console.log(response.headers);

    response.on('data', function (chunk) {
        body.push(chunk);
    });

    response.on('end', function () {
        body = Buffer.concat(body);
        console.log(body.toString());
    });
});

------------------------------------
200
{ 'content-type': 'text/html',
  server: 'Apache',
  'content-length': '801',
  date: 'Tue, 05 Nov 2013 06:08:41 GMT',
  connection: 'keep-alive' }
<!DOCTYPE html>
...
HTTPS

官方文档: http://nodejs.org/api/https.html

https模块与http模块极为类似,区别在于https模块需要额外处理SSL证书。

在服务端模式下,创建一个HTTPS服务器的示例如下。

var options = {
        key: fs.readFileSync('./ssl/default.key'),
        cert: fs.readFileSync('./ssl/default.cer')
    };

var server = https.createServer(options, function (request, response) {
        // ...
    });

可以看到,与创建HTTP服务器相比,多了一个options对象,通过keycert字段指定了HTTPS服务器使用的私钥和公钥。

另外,NodeJS支持SNI技术,可以根据HTTPS客户端请求使用的域名动态使用不同的证书,因此同一个HTTPS服务器可以使用多个域名提供服务。接着上例,可以使用以下方法为HTTPS服务器添加多组证书。

server.addContext('foo.com', {
    key: fs.readFileSync('./ssl/foo.com.key'),
    cert: fs.readFileSync('./ssl/foo.com.cer')
});

server.addContext('bar.com', {
    key: fs.readFileSync('./ssl/bar.com.key'),
    cert: fs.readFileSync('./ssl/bar.com.cer')
});

在客户端模式下,发起一个HTTPS客户端请求与http模块几乎相同,示例如下。

var options = {
        hostname: 'www.example.com',
        port: 443,
        path: '/',
        method: 'GET'
    };

var request = https.request(options, function (response) {});

request.end();

但如果目标服务器使用的SSL证书是自制的,不是从颁发机构购买的,默认情况下https模块会拒绝连接,提示说有证书安全问题。在options里加入rejectUnauthorized: false字段可以禁用对证书有效性的检查,从而允许https模块请求开发环境下使用自制证书的HTTPS服务器。

URL

官方文档: http://nodejs.org/api/url.html

处理HTTP请求时url模块使用率超高,因为该模块允许解析URL、生成URL,以及拼接URL。首先我们来看看一个完整的URL的各组成部分。

                           href
 -----------------------------------------------------------------
                            host              path
                      --------------- ----------------------------
 http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
 -----    ---------   --------   ---- -------- ------------- -----
protocol     auth     hostname   port pathname     search     hash
                                                ------------
                                                   query

我们可以使用.parse方法来将一个URL字符串转换为URL对象,示例如下。

url.parse('http://user:pass@host.com:8080/p/a/t/h?query=string#hash');
/* =>
{ protocol: 'http:',
  auth: 'user:pass',
  host: 'host.com:8080',
  port: '8080',
  hostname: 'host.com',
  hash: '#hash',
  search: '?query=string',
  query: 'query=string',
  pathname: '/p/a/t/h',
  path: '/p/a/t/h?query=string',
  href: 'http://user:pass@host.com:8080/p/a/t/h?query=string#hash' }
*/

传给.parse方法的不一定要是一个完整的URL,例如在HTTP服务器回调函数中,request.url不包含协议头和域名,但同样可以用.parse方法解析。

http.createServer(function (request, response) {
    var tmp = request.url; // => "/foo/bar?a=b"
    url.parse(tmp);
    /* =>
    { protocol: null,
      slashes: null,
      auth: null,
      host: null,
      port: null,
      hostname: null,
      hash: null,
      search: '?a=b',
      query: 'a=b',
      pathname: '/foo/bar',
      path: '/foo/bar?a=b',
      href: '/foo/bar?a=b' }
    */
}).listen(80);

.parse方法还支持第二个和第三个布尔类型可选参数。第二个参数等于true时,该方法返回的URL对象中,query字段不再是一个字符串,而是一个经过querystring模块转换后的参数对象。第三个参数等于true时,该方法可以正确解析不带协议头的URL,例如//www.example.com/foo/bar

反过来,format方法允许将一个URL对象转换为URL字符串,示例如下。

url.format({
    protocol: 'http:',
    host: 'www.example.com',
    pathname: '/p/a/t/h',
    search: 'query=string'
});
/* =>
'http://www.example.com/p/a/t/h?query=string'
*/

另外,.resolve方法可以用于拼接URL,示例如下。

url.resolve('http://www.example.com/foo/bar', '../baz');
/* =>
http://www.example.com/baz
*/
Query String

官方文档: http://nodejs.org/api/querystring.html

querystring模块用于实现URL参数字符串与参数对象的互相转换,示例如下。

querystring.parse('foo=bar&baz=qux&baz=quux&corge');
/* =>
{ foo: 'bar', baz: ['qux', 'quux'], corge: '' }
*/

querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' });
/* =>
'foo=bar&baz=qux&baz=quux&corge='
*/
Zlib

官方文档: http://nodejs.org/api/zlib.html

zlib模块提供了数据压缩和解压的功能。当我们处理HTTP请求和响应时,可能需要用到这个模块。

首先我们看一个使用zlib模块压缩HTTP响应体数据的例子。这个例子中,判断了客户端是否支持gzip,并在支持的情况下使用zlib模块返回gzip之后的响应体数据。

http.createServer(function (request, response) {
    var i = 1024,
        data = '';

    while (i--) {
        data += '.';
    }

    if ((request.headers['accept-encoding'] || '').indexOf('gzip') !== -1) {
        zlib.gzip(data, function (err, data) {
            response.writeHead(200, {
                'Content-Type': 'text/plain',
                'Content-Encoding': 'gzip'
            });
            response.end(data);
        });
    } else {
        response.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        response.end(data);
    }
}).listen(80);

接着我们看一个使用zlib模块解压HTTP响应体数据的例子。这个例子中,判断了服务端响应是否使用gzip压缩,并在压缩的情况下使用zlib模块解压响应体数据。

var options = {
        hostname: 'www.example.com',
        port: 80,
        path: '/',
        method: 'GET',
        headers: {
            'Accept-Encoding': 'gzip, deflate'
        }
    };

http.request(options, function (response) {
    var body = [];

    response.on('data', function (chunk) {
        body.push(chunk);
    });

    response.on('end', function () {
        body = Buffer.concat(body);

        if (response.headers['content-encoding'] === 'gzip') {
            zlib.gunzip(body, function (err, data) {
                console.log(data.toString());
            });
        } else {
            console.log(data.toString());
        }
    });
}).end();
Net

官方文档: http://nodejs.org/api/net.html

net模块可用于创建Socket服务器或Socket客户端。由于Socket在前端领域的使用范围还不是很广,这里先不涉及到WebSocket的介绍,仅仅简单演示一下如何从Socket层面来实现HTTP请求和响应。

首先我们来看一个使用Socket搭建一个很不严谨的HTTP服务器的例子。这个HTTP服务器不管收到啥请求,都固定返回相同的响应。

net.createServer(function (conn) {
    conn.on('data', function (data) {
        conn.write([
            'HTTP/1.1 200 OK',
            'Content-Type: text/plain',
            'Content-Length: 11',
            '',
            'Hello World'
        ].join('\n'));
    });
}).listen(80);

接着我们来看一个使用Socket发起HTTP客户端请求的例子。这个例子中,Socket客户端在建立连接后发送了一个HTTP GET请求,并通过data事件监听函数来获取服务器响应。

var options = {
        port: 80,
        host: 'www.example.com'
    };

var client = net.connect(options, function () {
        client.write([
            'GET / HTTP/1.1',
            'User-Agent: curl/7.26.0',
            'Host: www.baidu.com',
            'Accept: */*',
            '',
            ''
        ].join('\n'));
    });

client.on('data', function (data) {
    console.log(data.toString());
    client.end();
});

灵机一点

使用NodeJS操作网络,特别是操作HTTP请求和响应时会遇到一些惊喜,这里对一些常见问题做解答。

  • 问: 为什么通过headers对象访问到的HTTP请求头或响应头字段不是驼峰的?

    答: 从规范上讲,HTTP请求头和响应头字段都应该是驼峰的。但现实是残酷的,不是每个HTTP服务端或客户端程序都严格遵循规范,所以NodeJS在处理从别的客户端或服务端收到的头字段时,都统一地转换为了小写字母格式,以便开发者能使用统一的方式来访问头字段,例如headers['content-length']

  • 问: 为什么http模块创建的HTTP服务器返回的响应是chunked传输方式的?

    答: 因为默认情况下,使用.writeHead方法写入响应头后,允许使用.write方法写入任意长度的响应体数据,并使用.end方法结束一个响应。由于响应体数据长度不确定,因此NodeJS自动在响应头里添加了Transfer-Encoding: chunked字段,并采用chunked传输方式。但是当响应体数据长度确定时,可使用.writeHead方法在响应头里加上Content-Length字段,这样做之后NodeJS就不会自动添加Transfer-Encoding字段和使用chunked传输方式。

  • 问: 为什么使用http模块发起HTTP客户端请求时,有时候会发生socket hang up错误?

    答: 发起客户端HTTP请求前需要先创建一个客户端。http模块提供了一个全局客户端http.globalAgent,可以让我们使用.request.get方法时不用手动创建客户端。但是全局客户端默认只允许5个并发Socket连接,当某一个时刻HTTP客户端请求创建过多,超过这个数字时,就会发生socket hang up错误。解决方法也很简单,通过http.globalAgent.maxSockets属性把这个数字改大些即可。另外,https模块遇到这个问题时也一样通过https.globalAgent.maxSockets属性来处理。

小结

本章介绍了使用NodeJS操作网络时需要的API以及一些坑回避技巧,总结起来有以下几点:

  • httphttps模块支持服务端模式和客户端模式两种使用方式。
  • requestresponse对象除了用于读写头数据外,都可以当作数据流来操作。
  • url.parse方法加上request.url属性是处理HTTP请求时的固定搭配。
  • 使用zlib模块可以减少使用HTTP协议时的数据传输量。
  • 通过net模块的Socket服务器与客户端可对HTTP协议做底层操作。
  • 小心踩坑。
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ChrisP3616

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值