七天学会 Node.js 第四章 网络操作

[TOCM]

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

一、开门红


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

  1. var http = require('http');
  2. http.createServer(function (request, response) {
  3. response.writeHead(200, { 'Content-Type': 'text-plain' });
  4. response.end('Hello World\n');
  5. }).listen(8124);

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

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

二、API 走马观花


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

1、HTTP

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

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

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

  • 作为客户端使用时,发起一个 HTTP 客户端请求,获取服务端响应。

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

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

  1. POST / HTTP/1.1
  2. User-Agent: curl/7.26.0
  3. Host: localhost
  4. Accept: */*
  5. Content-Length: 11
  6. Content-Type: application/x-www-form-urlencoded
  7. Hello World

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

  1. http.createServer(function (request, response) {
  2. var body = [];
  3. console.log(request.method);
  4. console.log(request.headers);
  5. request.on('data', function (chunk) {
  6. body.push(chunk);
  7. });
  8. request.on('end', function () {
  9. body = Buffer.concat(body);
  10. console.log(body.toString());
  11. });
  12. }).listen(80);
  13. ------------------------------------
  14. POST
  15. { 'user-agent': 'curl/7.26.0',
  16. host: 'localhost',
  17. accept: '*/*',
  18. 'content-length': '11',
  19. 'content-type': 'application/x-www-form-urlencoded' }
  20. Hello World

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

  1. HTTP/1.1 200 OK
  2. Content-Type: text/plain
  3. Content-Length: 11
  4. Date: Tue, 05 Nov 2013 05:31:38 GMT
  5. Connection: keep-alive
  6. Hello World

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

  1. http.createServer(function (request, response) {
  2. response.writeHead(200, { 'Content-Type': 'text/plain' });
  3. request.on('data', function (chunk) {
  4. response.write(chunk);
  5. });
  6. request.on('end', function () {
  7. response.end();
  8. });
  9. }).listen(80);

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

  1. var options = {
  2. hostname: 'www.example.com',
  3. port: 80,
  4. path: '/upload',
  5. method: 'POST',
  6. headers: {
  7. 'Content-Type': 'application/x-www-form-urlencoded'
  8. }
  9. };
  10. var request = http.request(options, function (response) {});
  11. request.write('Hello World');
  12. request.end();

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

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

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

  1. http.get('http://www.example.com/', function (response) {
  2. var body = [];
  3. console.log(response.statusCode);
  4. console.log(response.headers);
  5. response.on('data', function (chunk) {
  6. body.push(chunk);
  7. });
  8. response.on('end', function () {
  9. body = Buffer.concat(body);
  10. console.log(body.toString());
  11. });
  12. });
  13. ------------------------------------
  14. 200
  15. { 'content-type': 'text/html',
  16. server: 'Apache',
  17. 'content-length': '801',
  18. date: 'Tue, 05 Nov 2013 06:08:41 GMT',
  19. connection: 'keep-alive' }
  20. <!DOCTYPE html>
  21. ...

2、HTTPS

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

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

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

  1. var options = {
  2. key: fs.readFileSync('./ssl/default.key'),
  3. cert: fs.readFileSync('./ssl/default.cer')
  4. };
  5. var server = https.createServer(options, function (request, response) {
  6. // ...
  7. });

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

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

  1. server.addContext('foo.com', {
  2. key: fs.readFileSync('./ssl/foo.com.key'),
  3. cert: fs.readFileSync('./ssl/foo.com.cer')
  4. });
  5. server.addContext('bar.com', {
  6. key: fs.readFileSync('./ssl/bar.com.key'),
  7. cert: fs.readFileSync('./ssl/bar.com.cer')
  8. });

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

  1. var options = {
  2. hostname: 'www.example.com',
  3. port: 443,
  4. path: '/',
  5. method: 'GET'
  6. };
  7. var request = https.request(options, function (response) {});
  8. request.end();

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

3、URL

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

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

  1. href
  2. -----------------------------------------------------------------
  3. host path
  4. --------------- ----------------------------
  5. http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
  6. ----- --------- -------- ---- -------- ------------- -----
  7. protocol auth hostname port pathname search hash
  8. -------------
  9. query

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

  1. url.parse('http://user:pass@host.com:8080/p/a/t/h?query=string#hash');
  2. /* =>
  3. { protocol: 'http:',
  4. auth: 'user:pass',
  5. host: 'host.com:8080',
  6. port: '8080',
  7. hostname: 'host.com',
  8. hash: '#hash',
  9. search: '?query=string',
  10. query: 'query=string',
  11. pathname: '/p/a/t/h',
  12. path: '/p/a/t/h?query=string',
  13. href: 'http://user:pass@host.com:8080/p/a/t/h?query=string#hash' }
  14. */

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

  1. http.createServer(function (request, response) {
  2. var tmp = request.url; // => "/foo/bar?a=b"
  3. url.parse(tmp);
  4. /* =>
  5. { protocol: null,
  6. slashes: null,
  7. auth: null,
  8. host: null,
  9. port: null,
  10. hostname: null,
  11. hash: null,
  12. search: '?a=b',
  13. query: 'a=b',
  14. pathname: '/foo/bar',
  15. path: '/foo/bar?a=b',
  16. href: '/foo/bar?a=b' }
  17. */
  18. }).listen(80);

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

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

  1. url.format({
  2. protocol: 'http:',
  3. host: 'www.example.com',
  4. pathname: '/p/a/t/h',
  5. search: 'query=string'
  6. });
  7. /* =>
  8. 'http://www.example.com/p/a/t/h?query=string'
  9. */

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

  1. url.resolve('http://www.example.com/foo/bar', '../baz');
  2. /* =>
  3. http://www.example.com/baz
  4. */

4、Query String

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

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

  1. querystring.parse('foo=bar&baz=qux&baz=quux&corge');
  2. /* =>
  3. { foo: 'bar', baz: ['qux', 'quux'], corge: '' }
  4. */
  5. querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' });
  6. /* =>
  7. 'foo=bar&baz=qux&baz=quux&corge='
  8. */

5、Zlib

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

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

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

  1. http.createServer(function (request, response) {
  2. var i = 1024,
  3. data = '';
  4. while (i--) {
  5. data += '.';
  6. }
  7. if ((request.headers['accept-encoding'] || '').indexOf('gzip') !== -1) {
  8. zlib.gzip(data, function (err, data) {
  9. response.writeHead(200, {
  10. 'Content-Type': 'text/plain',
  11. 'Content-Encoding': 'gzip'
  12. });
  13. response.end(data);
  14. });
  15. } else {
  16. response.writeHead(200, {
  17. 'Content-Type': 'text/plain'
  18. });
  19. response.end(data);
  20. }
  21. }).listen(80);

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

  1. var options = {
  2. hostname: 'www.example.com',
  3. port: 80,
  4. path: '/',
  5. method: 'GET',
  6. headers: {
  7. 'Accept-Encoding': 'gzip, deflate'
  8. }
  9. };
  10. http.request(options, function (response) {
  11. var body = [];
  12. response.on('data', function (chunk) {
  13. body.push(chunk);
  14. });
  15. response.on('end', function () {
  16. body = Buffer.concat(body);
  17. if (response.headers['content-encoding'] === 'gzip') {
  18. zlib.gunzip(body, function (err, data) {
  19. console.log(data.toString());
  20. });
  21. } else {
  22. console.log(data.toString());
  23. }
  24. });
  25. }).end();

6、Net

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

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

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

  1. net.createServer(function (conn) {
  2. conn.on('data', function (data) {
  3. conn.write([
  4. 'HTTP/1.1 200 OK',
  5. 'Content-Type: text/plain',
  6. 'Content-Length: 11',
  7. '',
  8. 'Hello World'
  9. ].join('\n'));
  10. });
  11. }).listen(80);

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

  1. var options = {
  2. port: 80,
  3. host: 'www.example.com'
  4. };
  5. var client = net.connect(options, function () {
  6. client.write([
  7. 'GET / HTTP/1.1',
  8. 'User-Agent: curl/7.26.0',
  9. 'Host: www.baidu.com',
  10. 'Accept: */*',
  11. '',
  12. ''
  13. ].join('\n'));
  14. });
  15. client.on('data', function (data) {
  16. console.log(data.toString());
  17. client.end();
  18. });

三、灵机一点


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

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

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

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

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

四、小结


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

  • httphttps模块支持服务端模式和客户端模式两种使用方式。

  • requestresponse对象除了用于读写头数据外,都可以当作数据流来操作。

  • url.parse方法加上request.url属性是处理 HTTP 请求时的固定搭配。

  • 使用zlib模块可以减少使用 HTTP 协议时的数据传输量。

  • 通过net模块的 Socket 服务器与客户端可对 HTTP 协议做底层操作。

  • 小心踩坑。

(完)