网络编程
Node是一个面向网络而生的平台,它具有事件驱动、无阻塞、单线程等特性,具备良好的可伸缩性,使得它十分轻量,适合在分布式网络中扮演各种各样的角色。同时Node提供的API十分贴合网络,适合用它基础的API构建灵活的网络服务。
Node提供了net、dgram、http、https这4个模块,分别用于处理TCP、UDP、HTTP、HTTPS,适用于服务器端和客户端。
# 01. 构建 TCP 服务
TCP全名为传输控制协议,在OSI模型(由七层组成,分别为物理层、数据链结层、网络层、传输层、会话层、表示层、应用层)中属于传输层协议。许多应用层协议基于TCP构建,典型的是HTTP、SMTP、IMAP等协议.
# TCP
OSI模型:
TCP是面向连接的协议,其显著的特征是在传输之前需要3次握手形成会话.
TCP在传输之前的3次握手:
只有会话形成之后,服务器端和客户端之间才能互相发送数据。在创建会话的过程中,服务器端和客户端分别提供一个套接字,这两个套接字共同形成一个连接。服务器端与客户端则通过套接字实现两者之间连接的操作。
# 创建TCP服务器端
通过net.createServer(listener)即可创建一个TCP服务器,listener是连接事件connection 的侦听器
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》示例:\n");
});
server.listen(8124, function () {
console.log('server bound');
});
采用如下的方式进行侦听
var server = net.createServer();
server.on('connection', function (socket) {
// 新的连接
});
server.listen(8124);
# 02. 构建 UDP 服务
UDP又称用户数据包协议,与TCP一样同属于网络传输层。UDP与TCP最大的不同是UDP不是面向连接的。TCP中连接一旦建立,所有的会话都基于连接完成,客户端如果要与另一个TCP服通信,需要另创建一个套接字来完成连接。但在UDP中,一个套接字可以与多个UDP服务通信,它虽然提供面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,但是由于它无须连接,资源消耗低,处理快速且灵活,所以常常应用在那种偶尔丢一两个数据包也不会产生重大影响的场景,比如音频、视频等。UDP目前应用很广泛,DNS服务即是基于它实现的。
# 创建UDP套接字
创建UDP套接字十分简单,UDP套接字一旦创建,既可以作为客户端发送数据,也可以作为服务器端接收数据。下面的代码创建了一个UDP套接字:
var dgram = require('dgram');
var socket = dgram.createSocket("udp4");
# 创建UDP服务器端
若想让UDP套接字接收网络消息,只要调用dgram.bind(port, [address])方法对网卡和端口进行绑定即可。以下为一个完整的服务器端示例:
var dgram = require("dgram");
var server = dgram.createSocket("udp4");
server.on("message", function (msg, rinfo) {
console.log("server got: " + msg + " from " +
rinfo.address + ":" + rinfo.port);
});
server.on("listening", function () {
var address = server.address();
console.log("server listening " + address.address + ":" + address.port);
});
server.bind(41234);
// server listening 0.0.0.0: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();
});
服务器端的message命令行输出:server got: 深入浅出Node.js from 127.0.0.1:51987
当套接字对象用在客户端时,可以调用send()方法发送消息到网络中。send()方法的参数如下:
socket.send(buf, offset, length, port, address, [callback])
- Buffer
- Buffer的偏移
- Buffer的长度
- 目标端口
- 目标地址
- 发送完成后的回调。
# UDP套接字事件
UDP套接字相对TCP套接字使用起来更简单,它只是一个EventEmitter
的实例,而非Stream的实例。它具备如下自定义事件:
- message:当UDP套接字侦听网卡端口后,接收到消息时触发该事件,触发携带的数据为 消息Buffer对象和一个远程地址信息。
- listening:当UDP套接字开始侦听时触发该事件。
- close:调用close()方法时触发该事件,并不再触发message事件。如需再次触发message 事件,重新绑定即可。
- error:当异常发生时触发该事件,如果不侦听,异常将直接抛出,使进程退出。
# 03. 构建 HTTP 服务
TCP与UDP都属于网络传输层协议,如果要构造高效的网络应用,就应该从传输层进行着手。
简易HTTP服务器:
var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');
# HTTP
- HTTP报文
wsh@wsh ~ % curl -v http://127.0.0.1:1337
// TCP的3次握手过程
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
// 在完成握手之后,客户端向服务器端发送请求报文
> GET / HTTP/1.1
> Host: 127.0.0.1:1337
> User-Agent: curl/7.64.1
> Accept: */*
// 是服务器端完成处理后,向客户端发送响应内容,包括响应头和响应体
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Wed, 04 May 2022 06:56:44 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< Transfer-Encoding: chunked
<
Hello World
// 结束会话的信息
* Connection #0 to host 127.0.0.1 left intact
* Closing connection 0
http是基于请求响应式的,以一问一答的方式实现服务,虽然基于TCP会话,但是本身却并无会话的特点。
HTTP服务只做两件事情:处理HTTP请求和发送HTTP响应。
无论是HTTP请求报文还是HTTP响应报文,报文内容都包含两个部分:报文头和报文体。上文的报文代码中>和<部分属于报文的头部,由于是GET请求,请求报文中没有包含报文体,响应报文中的Hello World即是报文体。
# http模块
http模块即是将connection到request的过程进行了封装:
http模块将连接所用套接字的读写抽象为ServerRequest
和ServerResponse
对象,它们分别对应请求和响应操作。在请求产生的过程中,http模块拿到连接中传来的数据,调用二进制模块http_parser进行解析,在解析完请求报文的报头后,触发request事件,调用用户的业务逻辑。
- HTTP请求
对于TCP连接的读操作,http模块将其封装为ServerRequest对象。让我们再次查看前面的请 求报文,报文头部将会通过http_parser进行解析。请求报文的代码如下所示:
> GET / HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: 127.0.0.1:1337
> Accept: */*
>
报文头第一行GET / HTTP/1.1被解析之后分解为如下属性。
- req.method属性:值为GET,是为请求方法,常见的请求方法有GET、POST、DELETE、PUT、 CONNECT等几种。
- req.url属性:值为/。
- req.httpVersion属性:值为1.1。 其余报头是很规律的Key: Value格式,被解析后放置在req.headers属性上传递给业务逻辑以 供调用,如下所示:
headers:
{ 'user-agent': 'curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5',
host: '127.0.0.1:1337',
accept: '*/*' },
报文体部分则抽象为一个只读流对象,如果业务逻辑需要读取报文体中的数据,则要在这个数据流结束后才能进行操作,如下所示:
function (req, res) {
// console.log(req.headers);
var buffers = [];
req.on('data', function (trunk) {
buffers.push(trunk);
}).on('end', function () {
var buffer = Buffer.concat(buffers);
// TODO
res.end('Hello world');
});
}
HTTP请求对象和HTTP响应对象是相对较底层的封装,现行的Web框架如Connect和Express都是在这两个对象的基础上进行高层封装完成的。
- HTTP响应
HTTP响应对象可以看成一个可写的流对象。它影响响应报文头部信息的 API 为 res.setHeader() 和 res.writeHead()。在上述示例中:
res.writeHead(200, {'Content-Type': 'text/plain'});
其分为setHeader()和writeHead()两个步骤。它在http模块的封装下,实际生成如下报文:
< HTTP/1.1 200 OK
< Content-Type: text/plain
我们可以调用setHeader进行多次设置,但只有调用writeHead后,报头才会写入到连接中.
# HTTP客户端
http模块提供了一个底层API:http.request(options, connect),用于构造HTTP客户端。
- HTTP响应 ClientRequest在解析响应报文时,一解析完响应头就触发response事件,同时传递一个响应对象以供操作ClientResponse。后续响应报文体以只读流的方式提供.
- HTTP 代理
如同服务器端的实现一般,http提供的ClientRequest对象也是基于TCP层实现的,在keepalive的情况下,一个底层会话连接可以多次用于请求。为了重用TCP连接,http模块包含一
个默认的客户端代理对象http.globalAgent。它对每个服务器端(host + port)创建的连接进行了
管理,默认情况下,通过ClientRequest对象对同一个服务器端发起的HTTP请求最多可以创建
5个连接
。它的实质是一个连接池.
HTTP代理对服务器端创建的连接进行管理:
调用HTTP客户端同时对一个服务器发起10次HTTP请求时,其实质只有5个请求处于并发状态,后续的请求需要等待某个请求完成服务后才真正发出。这与浏览器对同一个域名有下载连接数的限制是相同的行为。
# 04. 构建 WebSocket 服务
- WebSocket客户端基于事件的编程模型与Node中自定义事件相差无几。
- WebSocket实现了客户端与服务器端之间的长连接,而Node事件驱动的方式十分擅长与大 量的客户端保持高并发连接。
# WebSocket握手
客户端建立连接时,通过HTTP发起请求报文,与普通的HTTP请求协议略有区别的部分在于如下这些协议头:
Upgrade: websocket
Connection: Upgrade
上述两个字段表示请求服务器端升级协议为WebSocket。其中Sec-WebSocket-Key
用于安全校验:
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Key的值是随机生成的Base64编码的字符串。服务器端接收到之后将其与字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11相连,形成字符串dGhlIHNhbXBsZSBub25jZQ==258EAFA5- E914-47DA-95CA-C5AB0DC85B11,然后通过sha1安全散列算法计算出结果后,再进行Base64编码, 最后返回给客户端。这个算法如下所示:
var crypto = require('crypto');
var val = crypto.createHash('sha1').update(key).digest('base64');
另外,下面两个字段指定子协议和版本号:
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
服务器端在处理完请求后,响应如下报文:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
上面的报文告之客户端正在更换协议,更新应用层协议为WebSocket协议,并在当前的套接字连接上应用新协议。剩余的字段分别表示服务器端基于Sec-WebSocket-Key生成的字符串和选中的子协议。客户端将会校验Sec-WebSocket-Accept的值,如果成功,将开始接下来的数据传输。
# WebSocket数据传输
在握手顺利完成后,当前连接将不再进行HTTP的交互,而是开始WebSocket的数据帧协议, 实现客户端与服务器端的数据交换.
# 05. 网络服务与安全
Node在网络安全上提供了3个模块,分别为crypto、tls、https。 其中crypto主要用于加密解密,SHA1、MD5等加密算法都在其中有体现。
# TLS/SSL
- 密钥
TLS/SSL
是一个公钥/私钥的结构,它是一个非对称的结构,每个服务器端和客户端都有自己 的公私钥。公钥用来加密要传输的数据,私钥用来解密接收到的数据。公钥和私钥是配对的,通 过公钥加密的数据,只有通过私钥才能解密,所以在建立安全传输之前,客户端和服务器端之间 需要互换公钥。客户端发送数据时要通过服务器端的公钥进行加密,服务器端发送数据时则需要 客户端的公钥进行加密,如此才能完成加密解密的过程。
客户端和服务器端交换密钥:
中间人攻击示意图:
为了解决中间人攻击问题,TLS/SSL引入了数字证书来进行认证。与直接用公钥不同,数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在连接建立前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而产生信任关系。