网络编程

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2020-05-21

构建TCP服务

TCP是面向连接的协议,其显著的特征是在传输之前需要3次握手形成会话,只有会话形成之后,服务端和客户端之间才能互相发送数据,在创建会话的过程中,服务端和客户端分别提供一个套接字,这两个套接字共同形成一个连接,服务端与客户端则通过套接字实现两者之间连接的操作。

创建TCP服务器端

var net = require('net');
var server = net.createServer(function (socket) {
  // 新的连接
  socket.on('data', function (data) {
    socket.write("你好");
  });

  socket.on('end', function () {
    console.log('连接断开');
  });
  socket.write("欢迎光临深入浅出Node.js");
});

server.listen(8124, function () {
  console.log('server bound');
});

然后可以通过net模块自行构造客户端进行会话,测试上面构建的TCP服务的代码。

// client.js
var net = require('net');
var client = net.connect({port: 8124}, function () { //'connect' listener
  console.log('client connected');
  client.write('world!\r\n');
});

client.on('data', function (data) {
  console.log(data.toString());
  client.end(); // 主动关闭连接
});

client.on('end', function () {
  console.log('client disconnected');
});
$ node client.js
client connected
欢迎光临深入浅出Node.js

你好
client disconnected

TCP服务的事件

服务器事件

对于通过net.createServer()创建的服务器而言,它是一个EventEmitter实例,它的自定义事件有如下几种:

  • listening: 在调用server.listen()绑定端口触发,对这个事件的监听,简洁写法:可以当做server.listen()的第二个参数传入,server.listen(port,listeningListener)

  • connection: 每个客户端套接字连接到服务器端时触发,同样这个事件的监听也有简洁写法,当做net.createServer()最后一个参数传递。

  • close: 当服务器关闭时触发,在调用sever.close()后,服务器将停止接受新的套接字连接,但保持当前存在的连接,等待所有连接都断开后,会触发该事件。

  • error: 当服务器发生异常时,将会触发该事件,比如侦听一个使用中的端口,将会触发一个异常,如果不侦听error事件,服务器将会抛出异常。

连接事件

服务器可以同时与多个客户端保持连接,对于每个连接而言是典型的可写可读Stream对象,就是上面代码示例中的socket,用于服务端和客户端之间的通信。它的事件有:

  • data: 当一端调用write()事件发送数据时,另一端接收到数据就会触发data事件,传递的数据就是write()发送的。

  • end: 当连接中的任意一端发送了FIN数据时,将会触发该事件。

  • connect: 该事件用于客户端,当套接字与服务器端连接成功时会被触发,就是上面示例代码client.js中的’connect’ listener,也是一种简写的方式。

  • drain: 当任意一端调用write()发送时,当前这段会触发该事件。

  • error: 当异常发生时,触发该事件。

  • close: 当套接字完全关闭时,触发该事件。

  • timeout: 当一定时间后连接不再活跃时,该事件将会被触发,通知用户当前该连接已经被闲置了。

值得注意的是,TCP针对网络中的小数据包有一定的优化策略:Nagle算法。TCP/IP协议中,无论发送多少数据,总要在数据前面加上协议头,同时,对方接到数据,也需要发送ACK表示确认,为了尽可能的利用网络带宽,TCP总是希望尽可能的发送足够大的数据(一个连接会设置MSS参数,因此,TCP/IP希望每次能够以MSS尺寸的数据块来发送数据),Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。

如果每次只发送1字节的数据,会在传输上造成41字节的包,其中包括1字节的有用信息和40字节的首部数据,这种情况转变成了4000%的消耗,对于轻负载的网络还是可以接受的,但是重负载的就受不了了。Nagle算法通常会在未确认数据发送的时候让发送器把数据送到缓存里,任何数据随后继续直到得到明显的数据确认或者直到攒到了一定数量的数据了再发包。

在Node中,TCP默认开启Nagle算法,可调用socket.setNoDelay(true)去掉Nagle算法,使得write()可以立即发送数据到网络中。

另一个需要注意的是,尽管在网络的一段调用writre()会触发另一端得到data事件,但是并不意味着每次write()都会触发一次data事件,在关闭掉Nagle算法后,接受端可能会将接受到的多个小数据包合并,然后只触发一次data事件。

构建UDP服务

UDP又称用户数据包协议,与TCP一样同属于网络传输层,UDP和TCP最大的同是UDP不是面向连接的,在UDP中,一个套接字可以与多个UDP服务通信,它虽然提供面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢到严重的情况,但是它无须连接、资源消耗低、处理快速且灵活,所以常常应用在那种偶尔丢一两个数据包也不会产生重大影响的场景,比如音频、视频等,DNS服务也是基于它实现的。

创建UDP套接字

UDP套接字一旦创建,既可以作为客户端发送数据,也可以作为服务端接受数据,

var dgram = require('dgram');
var socket = dgram.createSocket("udp4"); // 套接字

创建UDP服务端

var dgram = require('dgram');
var server = dgram.createSocket("udp4"); // 套接字

// 套接字事件
// 当UDP套接字侦听网卡端口后,接受到消息后触发,触发携带的数据为消息的Buffer对象和一个远程地址信息
server.on("message", function (msg, rinfo) {
  console.log("server got: " + msg + " from " + rinfo.address + ":" + rinfo.port);
});

// 当UDP套接字开始侦听时触发
server.on("listening", function () {
  var address = server.address();
  console.log("server listening " + address.address + ":" + address.port);
});

// 还有close事件 和error事件
// close:调用close()方式时触发该事件,并不再触发message事件,如需继续触发message 重新bind即可
// error:当异常发生时触发该事件,如果不侦听,异常将直接抛出,使进程退出

server.bind(41234); // 接受网卡上所有41234端口上的消息,绑定完成后,触发listening事件

创建UDP客户端

var dgram = require('dgram');
var message = new Buffer("深入浅出Node.js");
var client = dgram.createSocket("udp4");

client.send(message, 0, message.length, 41234, "localhost", function(err, bytes) {
  client.close();
});

当套接字对象用在客户端时,可以调用send方法发送消息到网络中,send的参数如下socket.send(buf, offset, length, port, address, [callback])

分别为要发送的Buffer、Buffer的偏移、Buffer的长度、目标端口、目标地址、发送完成后的回调。虽然参数列表相对复杂,但是它更灵活的地方在于可以随意发送数据到网络中的服务器端,而TCP如果要发送数据给另一个服务器端,则需要重新通过套接字构造新的连接。

构建HTTP服务

HTTP是应用层协议,Node提供了http和https模块用于HTTP和HTTPS的封装。

HTTP协议构建在请求和响应的概念上,对应在Node.js中就是由http.ServerRequest和http.ServerResponse这两个构造器构造出来的对象。

当用户浏览一个网站时,用户代理(浏览器)会创建一个请求,该请求通过TCP发送给Web服务器,随后服务器会给出响应。

在构建TCP服务器时,createServer()中接受的回调的参数是一个连接对象(connection)对象,而在HTTP服务器中则是请求和响应对象。

尽管我们可以通过req.connection获取TCP连接对象,但大多数情况下你还是与请求和响应的抽象打交道,默认情况下,Node会告诉浏览器始终保持连接(请求头:connection: keep-alive),通过它发送更多的请求,这是为了提高性能,因为不想浪费时间去重新建立和关闭TCP连接,当然我们可以使用writeHead()传递一个不同的值,如Close,将连接关闭。

HTTP

HTTP全称是超文本传输协议(HyperText Transfer Protocol),在其两端是服务器和浏览器,即B/S模式,Web即是HTTP应用。

HTTP是基于请求响应式的,以一问一答的方式实现服务,虽然基于TCP会话,但是本身并无会话的特点,从协议的的角度来说,浏览器其实是一个HTTP的代理,用户的行为会通过它转化为HTTP请求报文发送给服务端,服务器端在处理请求后,发送响应报文给代理,代理在解析报文后,将用户需要的内容呈现在界面上。简而言之。HTTP服务只做两件事,处理HTTP请求发送和发送HTTP响应。

无论是请求报文还是响应报文,报文内容都包含两个部分报文头和报文体,但是GET的请求报文中没有包含报文体,传递的消息包含在报文头中。

http模块

在Node中,HTTP服务继承自TCP服务器(net模块),它能够与多个客户端保持连接,由于其采用事件驱动的形式,并不为每一个连接创建额外的线程或进程,保持很低的内存占用,所以能实现高并发。

HTTP服务于TCP服务模型有区别的地方在于,在开启keepalive后,一个TCP会话可以用于多次请求和响应,TCP服务以connection为单位进行服务,HTTP服务以request进行服务。

http模块将连接所用套接字的读写抽象为ServerRequest和ServerResponse对象,它们分别对应请求和响应操作(http服务回调中常用的req, res),在请求产生的过程中,http模块拿到连接中传来的数据,调用二进制模块http_parser进行解析,在解析完请求报文的报头后,触发request事件,调用用户的业务逻辑。

http请求

对于TCP连接的读操作,http模块将其封装为ServerRequest对象,报文头通过http_parser进行解析。

报文头解析为如下属性。

  • req.method:请求方法

  • req.url:请求的url

  • req.httpVersion:使用的http的版本

  • req.headers: 其余报文,包含’user-agent’、‘host’、‘accept’等。

报文体部分则抽象为一个只读流对象,如果业务逻辑需要读取报文体中的数据,则需要在数据流结束后才能进行操作,即req对象上的end事件触发后。

http响应

http模块将其封装为ServerResponse对象,可以将其看成一个可写的流对象,它影响响应报文头部信息的API为res.setHeader()和res.writeHead()

我们可以调用setHeader进行多次设置,但只有调用writeHead后,报头才会写入到连接中,除此之外,http模块会自动帮你设置一些头信息。

报文体部分则是调用res.write()和res.end()方法实现,res.end()会调用write()发送数据,然后发送信号告知服务器这次响应结束。

值得注意的是,报头是在报文体发送前发送的,一旦开始了数据的发送,writeHead()和setHeader()将不再生效。

无论服务端在处理业务逻辑时是否发生异常,务必在结束时调用res.end()结束请求,否则客户端将一直处于等待的状态。

http服务的事件

  • connection事件:在开始http请求和响应前,客户端和服务器端需要建立底层的TCP连接,这个连接可能因为开启了keep-alive,可以在多次请求和响应之间使用,当这个连接建立时服务器触发一次connection事件。

  • request事件:建立TCP连接后,http模块底层将在数据流中抽象出HTTP请求和HTTP响应,当请求数据发送到服务端,并解析出http请求头后,会触发该事件。

  • close事件:与TCP服务器的行为一致,调用server.close()方法停止接受新的连接,当已有的连接都段开始,触发该事件。

  • checkContinue事件:某些客户端在发送较大的数据时,并不会将数据直接发送,而是先发送头部带Expect: 100-continue的请求到服务器,服务器触发checkContinue触发,如果服务器没有监听这个事件,将自动响应客户带100 Continue的状态码表示接受数据上传,如果不接受的数据较多时,则响应400 Bad Request拒绝客户端继续发送,需要注意的是,当该事件发送时不会触发request事件,两个事件之间互斥,当客户端收到100 Continue后重新发起请求时,才会触发request事件。

  • connect事件:当客户端发起CONNECT请求时触发,而发起CONNECT请求通常在HTTP代理时出现,如果不监听该事件,发起该请求的连接将会关闭。

  • upgradd事件:当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求头中带上Upgrade字段,服务器会在接受到这样的请求时触发该事件,如果不监听该事件,发起该请求的连接将会关闭。

  • clientError事件:连接的客户端触发error事件时,这个错误会传递到服务器端,此时触发该事件。

HTTP客户端

http模块提供了一个底层API:http.request(options, connect),用于构造HTTP客户端

var options = { 
  hostname: '127.0.0.1', // 服务器名称
  port: 1334, // 服务器端口
  path: '/', // 具体请求的路由
  method: 'GET' // 请求的方法
};

// 其他选项
// host: 服务器的域名或IP地址,默认为localhost
// localAddress: 建立网络连接的本地网卡
// socketPath: Domain套接字路径
// headers: 请求头对象
// auth: Basic认证,这个值计算成请求头中Authorization部分

var req = http.request(options, function(res) { // response listener
  console.log('STATUS: ' + res.statusCode); 
  console.log('HEADERS: ' + JSON.stringify(res.headers)); 
  res.setEncoding('utf8');
  res.on('data', function (chunk) { 
    console.log(chunk);
  }); 
});

req.end();

HTTP代理

为了重用TCP连接,http模块包含一个默认的客户端代理对象http.globalAgent,它对每个服务器端(host + port)创建的连接进行管理,默认情况下,通过ClientRequest对象对同一个服务器端发起的HTTP请求最多创建5个连接,如果调用HTTP客户端同时对一个服务器发送10次HTTP请求时,其实质只有5个请求处于并发状态,后续的请求需要等待某个请求完成服务后才真正发出。

可以通过在options中传递agent选项来改变这个限制。

var agent = new http.Agent({
  maxSockets: 10
});

var options = {
  hostname: '127.0.0.1', 
  port: 1334,
  path: '/',
  method: 'GET',
  agent: agent  // 直接设为false,可以使请求不受并发的控制
};

// Agent对象的sockets和requests属性分别表示当前连接池中使用中的连接数和处于等待状态的请求数
// 在业务中监视这两个值有助于发现业务状态的繁忙程度

http客户端事件

  • response:请求发出后得到服务端的响应时触发该事件。

  • socket:当底层连接池中建立的连接分配给当前请求对象时,触发该事件。

  • connect:当客户端向服务端发送CONNECT请求时,如果服务器响应了200状态码,客户端触发该事件。

  • upgrade:客户端向服务端发起Upgrade请求时,如果服务器响应101 Switching Protocols状态,客户端触发该事件。

  • continue: 客户端向服务端发起Expect:100-continue头信息,以试图发送较大数据量,如果服务端响应了100 continue状态,客户端触发该事件。

构建WebSocket服务

WebSocket与Node之间的配合堪称完美,其理由有两条:

  • WebSocket客户端基于事件的编程模型与Node中自定义事件相差无几

  • WebSocket实现了客户端与服务器端之间的长连接,而Node事件驱动的方式十分擅长与大量的客户端保持高并发连接。

相比于HTTP,WebSocket有如下优势:

  • 客户端与服务端只建立一个TCP连接即可完成双向通信,在服务端和客户端频繁通信时,无需频繁断开连接和重发请求,连接可以得到高效应用,编程模型也十分简洁。

  • WebSocket服务器端可以推送数据到客户端,远比HTTP的请求响应模式更灵活、更高效。

  • 有更轻量级的协议头,减少数据传送量。

相比于HTTP,WebSocket更接近于传输层协议,它并没有在HTTP的基础上模拟服务器端的推送,而是在TCP上定义独立的协议,让人迷惑的部分在于WebSocket的握手部分是由HTTP完成的,使人觉得它可能是基于HTTP实现的

WebSocket协议主要分为两个部分:握手和数据传输。

WebSocket握手

客户端建立连接时,通过HTTP发起请求报文:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== 
Sec-WebSocket-Protocol: chat, superchat 
Sec-WebSocket-Version: 13

其中UpgradeConnection字段代表请求服务器升级协议为WebSocket,其中Sec-WebSocket-ProtocolSec-WebSocket-Version指定子协议和版本,

Sec-WebSocket-Key字段用于安全校验,它的值是客户端随机生成的Base64编码的字符串。服务端接收之后,将其与字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11(固定的)相拼接,然后通过sha1安全散列算法计算出结果后,再进行Base64编码,最后当做响应头Sec-WebSocket-Accept的值返回给客户端。

// 服务端的对Sec-WebSocket-Key的处理
var crypto = require('crypto');
var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

var key = req.headers['sec-websocket-key'];
key = crypto.createHash('sha1').update(key + WS).digest('base64');
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade  
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

服务器应答之后,Client 拿到Sec-WebSocket-Accept ,然后本地做一次验证,如果验证通过了,就会触发 onopen 函数。

WebSocket数据传输

在握手顺利完成后,当前连接不再进行HTTP的交互,而是开始WebSocket的数据帧协议,实现客户端和服务器端的数据交换。

当我们调用send()发送一条数据时,协议可能将这个数据封装为一帧或多帧数据,然后逐帧发送。

为了安全考虑,客户端需要对发送的数据进行掩码处理,服务器一旦收到无掩码帧,连接将关闭。

服务器发送到客户端的数据则需要做无掩码处理,客户端如果收到带掩码的数据帧,连接将关闭。

ws数据传输协议

  • FIN:占1位,如果这个数据帧是最后一帧,这个FIN位是1,其余情况为0,当一个数据没有被分为多桢时,它既是第一帧也是最后一帧,为1。

  • RSV1、RSV2、RSV3:占3位,保留位。

  • opcode:占4位的操作码,即表示0-15的二进制数值,用来解释当前数据帧,0表示附加数据帧,1表示文本数据帧,2表示二进制数据帧,8表示发送一个连接关闭的数据帧,9表示ping数据帧,10表示pong数据值,其余的值暂时未定义。ping数据帧和pong数据帧用于心跳检测,当一端发送ping数据帧时,另一端必须发送pong数据帧作为响应,告知对方这一段仍然处于响应状态

  • MASK: 占1位,表示是否进行掩码处理,为1时代表是,客户端发送给服务端为1,反之服务端发送给客户端时为0。

  • payload length: 占7、7+16或7+64位,前7位标示数据的长度,即表示0~127的二进制数值,当值在0~125之间,那么该值就是数据的真实长度,如果值是126,则后面16位的值是数据的真实长度(16位可表示的数值为0-65535,即在数据长度范围在126b~8kb,65536代表数据转换成二进制的长度,则字节长度 65536 / 8 = 8192b),如果值是127,则后面64位的值是数据的真实长度。

  • Masking key: 当MASK为1时存在,即当客户端给服务端发送数据时,是一个32位长的数据位,用于解密数据。

  • Payload Data: 目标数据,位数为8的整数。

如果客户端发送hello world!到服务端,12个字符,则长度为12 * 8 = 96位,转换为二进制位1100000,则报文应当如下:

fin(1) + res(000) + opcode(0001) + masked(1) + payload length(1100000) + masking key(32位) + payload data(hello world!加密后的二进制)

服务器回复yakexi,报文则如下,无需掩码。

fin(1) + res(000) + opcode(0001) + masked(0) + payload length(0110000) +  + payload data(yakexi加密后的二进制)