nodejs深入学(8)网络编程

前言

本章主要介绍如下知识,通过了解这些知识,进一步理解node为何适合在分布式网络中扮演各种角色。另外,由于node跟网络模型非常近似,我们可以通过学习node来更好的理解网络模型。本章,我们会仔细学习如下模块。

模块 说明
net TCP
dgram UDP
http HTTP
https HTTPS

构建TCP服务

TCP全称为传输控制协议,在OSI模型上属于传输层协议。我们看下边的图:

OSI模型

TCP三次握手

TCP在进行传输前需要进行三次握手,并形成会话,我们看一下模型:

TCP三次握手

在这三次握手中,服务器端和客户端,分别提高一个套接字,这两个套接字共同形成了一个连接。因此,只有会话形成之后,服务器核客户端之间才能相互发送数据。这些数据都是通过套接字的读写进行传输的。

创建TCP服务器端程序

var net = require('net');
var server = net.createServer(function (socket) {
    // 新的连接
    socket.on('data', function (data) {
        socket.write("hello") ;
    });
    socket.on('end', function () {
        console.log('连接断开');
    });
    socket.write("hello world,my dear\n");
});
server.listen(8124, function () {
    console.log('server bound');
});

//为了体现listener是连接事件connection的监听器,也可以采用另外一种方式进行监听
var server = net.createServer();
server.on('connection', function (socket) {
 // 新的连接
});
server.listen(8124);

然后,我们就可以使用telnet作为客户端,对服务进行会话交流了:

$ telnet 127.0.0.1 8124
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello world,my dear
hi
hello

除了端口外,我们还可以使用Domain Socket进行监听。

server.listen('/tmp/echo.sock');

通过nc工具进行会话:

$ nc -U /tmp/echo.sock
hello world,my dear
hi
hello

还可以通过net模块自己构建客户端进行会话

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');
});

//如果是domain socket 可以这样写

var client = net.connect({path: '/tmp/echo.sock'});

执行结果跟之前一样,此处不做描述

tcp服务的事件

主要是服务器事件和连接事件。

服务器事件

通过net.createServer()创建的服务器,它继承了eventEmitter实例,同时还是一个stream实例,有如下事件:

1.listening,在调用server.listen()绑定端口或者domain socket后触发,可以写为:server.listen(port,listeningListener)
2.connection,每个客户端套接字连接到服务器端时触发,简介写法为net.createServer()
3.close,调用server.close()后会停止接收新的套接字连接,保持当前存在的连接,等待所以连接都断开后,触发该事件
4.error,服务器出错时,如果不监听该事件,net会抛出异常,因此,必须监听该事件。

连接事件

服务器可以连接多个客户端,每个连接都是一个读写流(读写套接字),这是一个全双工。

1.data,socket一端发起write,另外一端就会触发data,这个data就是write写过来的数据。
2.end,任意一段发送FIN数据,另一端将会触发该事件。
3.connect,客户端与服务器连接成功后,客户端触发该事件
4.drain,当任意一段调用write()时,触发该事件
5.error,异常触发该事件
6.close,完全关闭socket,触发该事件
7.timeout,当一定时间后,连接不活跃,将触发该事件,告知当前用户,该连接已经被限制。

管道操作

既然是流,就可以变成管道,我们感受一下:

var net = require('net');
var server = net.createServer(function (socket) {
socket.write('Echo server\r\n');
socket.pipe(socket);
});
server.listen(1337, '127.0.0.1');

tcp针对网络中的小数据包有优化政策,nagle算法,nagle要求网络中缓冲区数据达到一定数量或一定时间后,才将其触发,小数据包会被nagle合并,来优化网络。这个方法会带来一定的传输延迟。

我们可以通过socket.setNoDelay(true)来去掉nagle算法,是的write可以立即发送数据。但是,data事件还是要进行小包合并后触发的,这个需要注意。

构建UDP服务

udp,用户数据包协议,也是传输层协议。udp不是面向连接的,也就是说udp无需连接,它是面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,由于无需连接,资源消耗低,处理块速且灵活,常常用于那种偶尔丢几个包也不产生重大影响的场景,例如,音频、视频等,DNS就是基于udp实现的。另外,一个udp套接字可以与多个udp服务进行通信。

创建udp套接字

var dgram = require('dgram');
var socket = dgram.createSocket("udp4");

udp socket创建后,即是客户端又是服务器。

创建udp服务器端

创建完udp socket后,我们需要绑定端口,也就是让网卡和端口进行绑定,这样就完成了服务器端的开发,当然,这个也可以认为是客户端。

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);

创建udp客户端

var dgram = require('dgram');
var message = new Buffer("hi");
var client = dgram.createSocket("udp4");

//socket.send(buf, offset, length, port, address, [callback])
//socket.send(要发送的buf, buf的偏移, buf长度, port, address, [callback])
client.send(message, 0, message.length, 41234, "localhost", function(err, bytes) {
client.close();
});

//

$ node server.js
server listening 0.0.0.0:41234
server got: hi from 127.0.0.1:58682

我们可以看出,udp是无需建立连接的,因此,高效快速不可靠。

udpsocket 事件

udp socket只是一个eventemitter实例,不是stream实例,事件如下:

1.message,udp监听网卡后,接收到消息时触发该事件,触发携带的数据为消息buf对象和一个远程地址信息
2.listening,udp开始监听时,触发该事件
3.close,调用close()时触发该事件,并不再触发message事件,如需再次触发message事件,重新绑定即可
4.error,异常触发该事件,如果不监听,则模块抛错,线程退出

构建HTTP服务

如果想要构建高效的网络应用,就应该从传输层的TCP、UDP入手,进行开发。但是,对于一些经典的应用场景,例如,一问一答的形式的web,这个就不需要自己动手写应用层协议了,使用经典的HTTP就可以了,另外,例如邮件服务,也可以直接使用SMTP协议,就可以了。这一节说的HTTP,我们将会使用node的核心模块http和https进行构建,这两个模块分别对http和https协议进行了抽象和封装,最大限度的模拟http协议和https协议的行为。我们来看一下代码的例子:


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是HyperText Transfer Protocol的缩写,它也是构建与TCP协议之上的。在http的两端分别是客户端和服务器,这就是经典的B/S模式。另外,这里的B,就是浏览器的意思,浏览器成为了http的代理,用户的行为将会通过浏览器转化为http请求报文,发送给服务器,服务器也就是S,会处理请求,然后发送响应报文给代理,也就是浏览器,浏览器解析响应报文后,将用户界面展示给用户。这里我们看到,基于http或者https的B/S模式中国,浏览器只负责发送报文、接收报文、解析报文、展示界面,服务器负责处理http请求和发送http响应。

http报文

我们先来看一下刚才的http报文,我们使用curl http://127.0.0.1:1337 -v这条命令。

$ curl -v http://127.0.0.1:1337
* About to connect() to 127.0.0.1 port 1337 (#0)
* Trying 127.0.0.1...
* connected
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
> 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: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
Hello World
* Connection #0 to host 127.0.0.1 left intact
* Closing connection #0

接下来我们来简单分析一下这个报文。这段报文分为3部分,我们来详细分析一下:

报文第一部分

第一部分是经典的TCP三次握手,这样就建立了连接

* About to connect() to 127.0.0.1 port 1337 (#0)
* Trying 127.0.0.1...
* connected
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)

报文第二部分

在完成握手之后,客户端向服务器端发送请求报文。

> 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: */*
>

报文第三部分

第三部分展示的是服务端完成处理后,向客户端发送的响应内容,包括响应头和响应体:

< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
Hello World

另外,最后部分是结束会话:

* Connection #0 to host 127.0.0.1 left intact
* Closing connection #0

注意:报文的内容主要是两部分,报文头和报文体,上一个例子中,使用的是get请求,报文头的部分是上边报文信息中>和<的部分。在响应报文中,有一个报文体,是Hello World。

http模块

node1中,http服务继承tcp服务,也是就http模块继承net模块,基于http模块,可以实现客户端的并发访问,由于采用事件驱动的方式,因此,并不为每一个连接创建额外的线程或进程,保持很低的内存占用,所以能实现高并发。http服务模型与tcp服务模型有区别的地方在于,在开启keepalive之后,一个tcp会话可以用于多次请求和响应,tcp服务以connection为单位进行服务,http以request为单位进行服务。http模块也就是将connection到request的过程进行了封装:

http模块也就是将connection到request的过程进行了封装

http模块将连接所用的套接字的读写抽象为ServerRequest和ServerResponse对象,在请求产生的过程中,http模块拿到连接中传来的数据,调用二进制模块http_parser进行解析,在解析完请求报文的报文头后,触发request事件,之后调用用户的业务逻辑。我们看一下流程:

http模块产生请求的流程

我们再来看看服务器端的处理程序和响应:

function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}

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,也就是req.method='GET',这个就是请求方法,我们常见的请求方法有GET、POST、DELETE、PUT、CONNECT等
req.url 值为/,也就是req.url='/'
req.httpVersion 值为1.1,也就是req.httpVersion='1.1'

其余的报文头都会被解析为很有规律的json,也就是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');
    });
}

其实,我们通过分析node关于http协议的实现,我们可以发现,node目前还是非常底层的一个技术,因此,我们可以看见一个http服务的底层实现是什么,这也是目前学习node的好处之一,这样,我们就可以从源头了解一项技术。

http响应

http响应,也就是对套接字的写操作进行了封装,可以将其看成一个可写的流对象,此处的api是res.setHeader()和res.writeHead():

res.writeHead(200, {'Content-Type': 'text/plain'});

在http模块的封装下,我们实际生成的报文如下:

< HTTP/1.1 200 OK
< Content-Type: text/plain

我们可以多次调用setHeader进行多次设置,但是只能调用一次writeHead,并且也只有调用了writeHead后,才会将响应报文头写入到连接中,除此之外,http模块还会自动帮你设置一些头信息:

< Date: Sat, 06 Apr 2013 08:01:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<

报文体部分则是通过调用res.write()和res.end()实现的,res.end()会先调用write()发送数据,然后,发送信号通知服务器这次响应结束,响应的结果就是我们之前发送的hello world。

响应介绍后,http服务器可能会将当前的连接用于下一个请求,或者关闭连接。另外,一旦开始了数据的发送,再次调用writeHead和setHead将不再有效,这是因为协议的特性所决定的,跟tcp还是有差别的。

另外,服务器不管是完成业务,还是发生异常,都应该调用res.end()以结束请求,否则客户端将会一直处于等待的状态。当然,也可以通过延迟res.end()的方式,来实现与客户端的长连接,但是结束时,务必关闭连接。

http服务事件

http服务也继承了events模块,因此也是一个EventEmitter实例或者对象。

事件 说明
connection 在http请求和响应前,客户端与服务器需要建立tcp连接,这个连接可能因为开启了keep-alive,可以在多次响应和请求之间使用,当建立连接时,服务器触发一次connection事件
request 建立tcp连接后,http模块底层将在数据流中抽象http请求和响应,当请求数据发送到服务器,在解析出http请求头后,将会触发该事件,在res.end()后,tcp连接可能用于下一次请求响应
close 与tcp服务器的行为一致,调用server.close()停止接受新的连接,当已有的连接都断开时,触发该事件,可以给server.close()传递一个回调函数,来快速注册该事件。
checkContinue 客户端发送较大的数据时,不会讲数据直接发送,而是先发送一个头部带Expect:100-continue的请求到服务器,服务器将会触发checkContinue事件,如果没有为服务器监听这个事件,服务器将会自动响应客户端100 Continue的状态码,表示接受数据上传,如果不接受的数据较多时,响应客户端400 Bad Request,拒绝客户端继续发送数据即可。需要注意的是,该事件发生时不会触发request事件,两个事件是互斥的,当客户端收到100 Continue后,重新发起请求时,才会触发request事件
connect 当客户端发起CONNECT请求时触发,而发起CONNECT请求,通常在HTTP代理出现,如果不监听该事件,发起该请求的连接将会关闭
upgrade 当客户端要求升级连接协议时,需要和服务器端协商,客户端会在请求头中带上Upgrade字段,服务器端会在接收到这样的请求时触发该事件,这个会在websocket中详细介绍,同样,如果不监听该事件发起该请求的连接将会关闭。
clientError 连接客户端触发error事件,这个错误会传递到服务器端,此时触发该事件。

http客户端

http客户端会产生请求报文头和报文体,接收响应报文头和报文体,并解析。除了浏览器,我们也可以通过http模块提供的http.request(options,connect)来构造http客户端。我们来感受一下:

var options = {
   host:'127.0.0.1',
    hostname: '127.0.0.1',
    port: 1334,
    path: '/',
    method: 'GET'
};
var req = http.request(options, function (res) {
    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();


//得到的输出:

$ node client.js
STATUS: 200
HEADERS: {"date":"Sat, 06 Apr 2013 11:08:01
GMT","connection":"keep-alive","transfer-encoding":"chunked"}
Hello World

在这个例子中,options决定了http请求头的内容:

参数 说明
host 服务器的域名或IP地址,默认localhost
hostname 服务器名称
port 服务器端口,默认80
localAddress 建立网络连接的本地网卡
sockerPath Domain套接字路径
method http请求方法,默认GET
path 请求路径,默认为/
headers 请求头对象
auth Basic认证,这个值将被计算成请求头中的Authorization

报文体的内容则由请求对象的wirte()和end()方法实现,通过write写入数据,通过end告知报文结束。这个和浏览器中的Ajax调用几乎相同,本质上讲,Ajax的实质就是一个异步的网络HTTP请求。

http客户端响应

http客户端的响应对象与服务器端较为类似,在ClientRequest对象中,它的事件也被称为response,ClientRequest在解析响应报文时,解析完响应头就会触发response事件,同时传递一个响应对象以供操作ClientResponse,后续响应报文以只读流的方式提供:

function(res) {
    console.log('STATUS: ' + res.statusCode);
    console.log('HEADERS: ' + JSON.stringify(res.headers));
    res.setEncoding('utf8');
    res.on('data', function (chunk) {
        console.log(chunk);
    });
}

这个行为与服务器端的ServerRequest读取数据的行为基本一致。

http代理

如同服务器端的实现一样,http提供的ClientRequest对象也是基于tcp实现的,在keepalive的情况下,一个底层会话连接可以多次用于请求,为了重用tcp连接,http模块包含一个默认的客户端代理对象http.globalAgent,它对每个服务器端的host+port创建的连接进行了管理,默认情况下,通过ClientRequest对象对同一个服务器端发起的HTTP请求最多可以创建5个连接,它的实质是一个连接池:

http代理对服务器端创建的连接进行管理

调用http客户端同时对一个服务器发起10次http请求时,其实质只有5个请求处于并发状态,后续的请求需要等待某个请求完成服务后才真正发出,这与浏览器对同一个域名有下载连接数的限制是相同的行为。

如果你的服务器端通过ClientRequest调用网络中的其他Http服务,记得关注代理对象对网络请求的限制,一旦请求量过大,连接限制将会限制服务性能,可以在options中传递agent选项,调整连接数的限制,默认情况下,请求会采用全局的代理对象,默认连接数限制为5。接下来,我们自己自行构造代理对象:

var agent = new http.Agent({
    maxSockets: 10
});
var options = {
    hostname: '127.0.0.1',
    port: 1334,
    path: '/',
    method: 'GET',
    agent: agent
};

也可以设置Agent选项为false,以脱离连接池的管理,使得请求不受并发的限制。

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

http客户端事件

与服务器端一样,客户端也有相应事件

事件 说明
response 处理服务器端返回的response,返回后,触发该事件
socket 当底层连接池中建立的连接分配给当前请求对象时,触发该事件
connect 当客户端向服务器端发起CONNECT请求时,如果服务器端响应了200状态码,客户端会触发该事件
upgrade 客户端向服务器端发起Upgrade请求时,如果服务器端响应了101 Switching Protocols状态,客户端将会触发该事件
continue 客户端向服务器端发起Expect: 100-continue头信息,以试图发送较大数据量,如果服务器端响应100 Continue状态,客户端将触发该事件。

构建websocket服务

websocket与传统的b/s模式有如下好处:

1.让b端与服务器建立tcp连接,减少连接数
2.服务器实现了向b端推送数据的需求
3.更轻的头协议,减少数据传输

websocket是RFC6455规范。现在大多数浏览器都支持这一规范。我们来建立一个websocket客户端程序

var socket = new WebSocket('ws://127.0.0.1:12010/updates');
socket.onopen = function () {
setInterval(function() {
if (socket.bufferedAmount == 0)
socket.send(getUpdateData());
}, 50);
};
socket.onmessage = function (event) {
// TODO: event.data
};

在websocket之前,我们使用comet(long-polling长轮询)或iframe流来实现推送,但是本质还是客户端不断的发起http请求,通过不断获取数据的方式实现推送的效果。websocket是通过tcp重新拟定的新的协议,不是在http协议的基础上的封装。websocket分为握手和数据传输两部分,其中握手使用了http进行,我们来看一下:

websocket握手

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

握手的报文头就是这样的,与http请求的区别在于

Upgrade: websocket
Connection: Upgrade

也就是对于协议进行了升级
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的值,如果成功,将开始接下来的数据传输。

我们使用node来模拟浏览器发起协议切换的行为:

var WebSocket = function (url) {
    // 伪代码,解析ws://127.0.0.1:12010/updates,用于请求
    this.options = parseUrl(url);
    this.connect();
};
WebSocket.prototype.onopen = function () {
    // TODO
};
WebSocket.prototype.setSocket = function (socket) {
    this.socket = socket;
};
WebSocket.prototype.connect = function () {
    var this = that;
    var key = new Buffer(this.options.protocolVersion + '-' + Date.now()).toString('base64');
    var shasum = crypto.createHash('sha1');
    var expected = shasum.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
    var options = {
        port: this.options.port, // 12010
        host: this.options.hostname, // 127.0.0.1
        headers: {
            'Connection': 'Upgrade',
            'Upgrade': 'websocket',
            'Sec-WebSocket-Version': this.options.protocolVersion,
            'Sec-WebSocket-Key': key
        }
    };
    var req = http.request(options);
    req.end();
    req.on('upgrade', function (res, socket, upgradeHead) {
        // 连接成功
        that.setSocket(socket);
        //触发open事件
        that.onopen();
    });
};

下面再写一下服务器端的响应代码

var server = http.createServer(function (req, res) {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World\n');
});
server.listen(12010);
// 在收到upgrade请求后,告知客户端允许切换协议
server.on('upgrade', function (req, socket, upgradeHead) {
    var head = new Buffer(upgradeHead.length);
    upgradeHead.copy(head);
    var key = req.headers['sec-websocket-key'];
    var shasum = crypto.createHash('sha1');
    key = shasum.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest('base64');
    var headers = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' + key,
        'Sec-WebSocket-Protocol: ' + protocol
    ];
    // 让数据立即发送
    socket.setNoDelay(true);
    socket.write(headers.concat('', '').join('\r\n'));
    // 建立服务器端WebSocket连接
    var websocket = new WebSocket();
    websocket.setSocket(socket);
});

一旦websocket握手成功,服务器端与客户端就将会呈现对等的效果,都能接收和发送消息。

websocket数据传输

在顺利握手后,当前连接将不再进行http交互,而是开始websocket的数据帧协议,实现客户端与服务器的数据交换。我们来看一下这个协议升级的过程:

协议升级过程

握完手后,客户端的onopen()将会触发执行,代码如下:

socket.onopen = function () {
// TODO: opened()
};

服务器端一般不写onopen方法,我们按照tcp的解析习惯,让websocket的数据帧协议在底层的data事件上完成封装:

WebSocket.prototype.setSocket = function (socket) {
this.socket = socket;
this.socket.on('data', this.receiver);
};

//发送数据

WebSocket.prototype.send = function (data) {
this._send(data);
};

我们来简单描述一下这个过程:

当客户端调用send()发送数据时,服务器端触发onmessage(),当服务器端调用send()发送数据时,客户端的onmessage()触发,当我们调用send()发送一条数据时,协议可能将这个数据封装为一帧或多帧数据,然后逐帧发送。

为了安全考虑,客户端需要发送的数据帧进行掩码处理,服务器一旦收到无掩码帧,比如中间拦截破坏,连接将会关闭。服务器发送到客户端的数据帧无需做掩码,如果客户端收到了带掩码的数据帧,连接也将关闭。

在websocket中的数据帧的定义,每8位位一列,也就是一个字节,我们看看这些位的意义:

websocket数据帧的定义

1.fin,如果这一帧是最后一帧,这个fin为为1,其余情况为0.
2.rsv1、rsv2、rsv3,都是一位长,用于标识扩展,当有已协商的扩展时,这些值可能为1,其余情况为0。
3.opcode,4位长,可以用来表示0~15的值,用于解释当前数据帧,0表示附加数据帧,1表示文本数据帧,2表示二进制数据帧,8表示发送一个连接关闭的数据帧,9表示ping数据帧,10表示pong数据帧,其余值暂时没有定义。瓶数据帧和pong数据帧用于心跳检测,当一端发送一个ping数据帧时,另一端必须发送pong数据帧作为回应,告知对方这一端仍然处于响应状态。
4.masked,表示是否进行掩码处理,1位长度,客户端发送给服务器时为1,服务器发送回客户端时为0.
5.payload length:一个7、7+16或7+64位长的数据为,标识数据的长度,如果值在0~125之间那么该值就是数据的真实长度,如果是126,则后面16位的值是数据的真实长度,如果是127,则后面64位的值是数据的真实长度。
6.making key,当masked为1时,这里是一个32位长的数据位,用于解密数据。
7.payload data,我们的目标数据,位数为8的倍数。

客户端发哦送你个消息时,需要构造一个或多个数据帧协议报文,例如我们发送一个hello world,这个比较短,不存在分割多个数据帧的情况,并且以文本方式发送,他的payload length长度为96(12字节*8位/字节),二进制表示为110000。所以报文应该是:

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

当以文本方式发送是,文本的编码为utf-8,由于这里发送的不存在中文,所以一个字符占一个字节,即8位。

客户端发送消息后,服务器端在data事件中接收到这些编码数据,然后,解析为相应的数据帧,再以数据帧的格式,通过掩码将真正的数据解密出来,然后触发onmessage()执行:

socket.onmessage = function (event) {
// TODO: event.data
};

假设,服务器回复的是yakexi,这个无需掩码,形式如下:

fin(1) + res(000) + opcode(0001) + masked(0) + payload length(1100000) + payload data(yakexi的ܾ二进制)

这里的行为与tcp相似,可以理解为tcp客户端的connect和data事件。剩下的细节如如何解析数据帧和触发onmessage(),就请大家自己去看ws模块或者socket.io模块了。(当时的版本没有原生的websocket,不知道node8或者node9中是否已经支持了)

网络服务与安全

SSL = secure socket layer,这个协议在传输层提供对网络连接的加密,在应用层实现加密和解密。最开始使用这个协议的是网景的浏览器,然后,为了被更多的服务器核浏览器支持,IETF组织将其标准化,也就是TLS = transport layer security。

node在网络安全方面提供了crypto、tls、https三个模块,crypto用于加密解密,例如sha1、md5等加密算法,tls用于建立一个基于TLS/SSL的tcp链接,它可以看成是net模块的加密升级版本。https用于提供一个加密版本的http,也是http的加密升级版本,甚至提供的接口和事件也跟http模块一样。

TLS/SSL

密钥

TLS/SSL是一个公钥/私钥的结构,这也是一个非对称的结构,每个服务器核客户端都有自己的公钥和私钥。公钥用来加密要传输的数据,私钥用来解密接收到的数据。公钥和私钥是配对的,通过公钥加密的数据,只有通过私钥才能解密,所以在建立安全传输之前,客户端和服务器端之间需要互换公钥。客户端发送数据时要通过服务器端的公钥进行加密,服务器端发送数据时则需要客户端的公钥进行加密,如此才能完成加密解密的过程:

客户端和服务器端交换密钥

node在底层采用openssl来实现TLS/SSL,为此要生成公钥和私钥需要通过openssl来完成,我们分别为服务器核客户端生成私钥:

// 生成服务器端私钥
$ openssl genrsa -out server.key 1024
// 生成客户端私钥
$ openssl genrsa -out client.key 1024

上述命令生成了两个1024位长的RSA私钥文件,我们继续通过它生成公钥:

$ openssl rsa -in server.key -pubout -out server.pem
$ openssl rsa -in client.key -pubout -out client.pem

公钥和私钥的非对称性加密虽然很好,但是网络中依然可能存在窃听的情况,典型的例子就是中间人攻击。客户端和服务器端在交换公钥的过程中,中间人对客户端扮演服务器端的角色,对服务器端扮演客户端的角色,因此客户端和服务器端几乎感受不到中间人的存在,为了解决这个问题,数据传输过程中还需要对得到的公钥进行认证,以确认得到的公钥是出自目标服务器的,如果不能保证这种认证,中间人可能会将伪造的站点响应给用户,从而造成经济损失。

中间人攻击

为了解决中间人攻击的问题,TLS/SSL引入了数字证书来进行认证,与直接公钥不同,数字证书中包含了服务器的名称和主机名称、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在建立连接前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而产生信任关系,下面我们就看看这个数字证书。

数字证书

CA = Certificate Authority是数字证书的颁发机构,这个证书具有ca通过自己的公钥和私钥实现的签名。

为了得到ca的签名证书,服务器端需要通过自己的私钥生成CSR = certificate signing request文件,ca机构将通过这个文件颁发属于该服务器的签名证书,只要通过ca机构就能验证证书是否合法。

通过ca机构颁发证书通常是一个繁琐的过程,需要付出一定的精力和费用,对于中小企业来说,可以采用自签名证书来构建安全的网络,也就是自己给自己的服务器扮演ca机构,给自己的服务器颁发自己的ca生成的签名证书。我们还是使用openssl来实现这一过程

//生ca成服务器私钥
$ openssl genrsa -out ca.key 1024
//生成csr文件
$ openssl req -new -key ca.key -out ca.csr
//通过私钥自签名生成证书,此时还没有业务服务器的签名
$ openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt
生成自签名证书示意图

这样就生成了自己的签名证书,然后再次回到服务器端,服务器需要向ca申请签名,在申请签名之前,依然需要创建自己的csr,值得注意的是,这个过程中的common name需要匹配服务器域名,否则在后续的认证过程中会出错:

//生成自己的业务服务器csr
$ openssl req -new -key server.key -out server.csr
//向自己的ca申请签名证书,这个过程需要ca的证书和私钥参与,最终生成带签名的证书
$ openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in server.csr -out server.crt

之后,客户端发起安全连接前会去捕获服务器端的证书,并通过ca的证书验证服务器端证书的真伪。除了验证真伪外,通常还含有对服务器名称、IP地址等进行检验的过程:

客户端通过ca验证服务器端证书的真伪过程示意图

ca机构将证书颁发给服务器端后,证书在请求的过程中会被发送给客户端,客户端需要通过ca的证书验证真伪。如果是知名的ca机构,他们的证书一般都会预装在浏览器中,如果是自己扮演的ca,就需要让客户自己先去获取这个ca然后才能进行验证。

另外,ca的证书一般被称为根证书,也就是不需要上级证书参与签名的证书。

TLS服务

先基于tls模块创建服务器端程序

var tls = require('tls');
var fs = require('fs');
var options = {
    key: fs.readFileSync('./keys/server.key'),
    cert: fs.readFileSync('./keys/server.crt'),
    requestCert: true,
    ca: [fs.readFileSync('./keys/ca.crt')]
};
var server = tls.createServer(options, function (stream) {
    console.log('server connected', stream.authorized ? 'authorized' : 'unauthorized');
    stream.write("welcome!\n");
    stream.setEncoding('utf8');
    stream.pipe(stream);
});
server.listen(8000, function () {
    console.log('server bound');
});

启动服务后,可以通过openssl s_client -connect 127.0.0.1:8000来测试证书是否正常。

然后,我们通过tls模块的connect()来构建客户端,首先,需要为客户端生成属于自己的私钥和签名:

// 创建私钥
$ openssl genrsa -out client.key 1024
// 生成CSR
$ openssl req -new -key client.key -out client.csr
// 生成签名证书
$ openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in client.csr -out client.crt

然后创建客户端程序

var tls = require('tls');
var fs = require('fs');
var options = {
    key: fs.readFileSync('./keys/client.key'),
    cert: fs.readFileSync('./keys/client.crt'),
    ca: [fs.readFileSync('./keys/ca.crt')]
};
var stream = tls.connect(8000, options, function () {
    console.log('client connected', stream.authorized ? 'authorized' : 'unauthorized');
    process.stdin.pipe(stream);
});
stream.setEncoding('utf8');
stream.on('data', function (data) {
    console.log(data);
});
stream.on('end', function () {
    server.close();
});

我们可以看到,客户端用到了客户端自己生成的私钥、证书、ca证书。

var options = {
    key: fs.readFileSync('./keys/client.key'),
    cert: fs.readFileSync('./keys/client.crt'),
    ca: [fs.readFileSync('./keys/ca.crt')]
};

客户端启动之后,就可以在输入流中输入数据了,服务器端将会回应相同的数据。至此我们完成了TLS的服务器端和客户端的创建,与普通的tcp服务器和客户端相比,TLS的服务器核客户端仅仅只是需要配置证书,其他基本一样。

HTTPS服务

HTTPS其实就是TLS/SSL基础上的HTTP。换句话说,net模块对应http模块,tls模块对应https模块,我们来创建一个https服务:
1.准备证书

按照之前的步骤准备自己的证书,这个过程大家回去看前边的实现

2.然后创建https服务

var https = require('https');
var fs = require('fs');
var options = {
    key: fs.readFileSync('./keys/server.key'),
    cert: fs.readFileSync('./keys/server.crt')
};
https.createServer(options, function (req, res) {
    res.writeHead(200);
    res.end("hello world\n");
}).listen(8000);

然后使用curl进行测试,由于是自签名,因此curl不能校验这个证书是否正确,因此,这里会报错

$ curl https://localhost:8000/
curl: (60) SSL certificate problem, verify that the CA cert is OK. Details:
error:14090086:SSL routines:SSL3_GET_SERVER_CERTIFICATE:certificate verify failed
More details here: http://curl.haxx.se/docs/sslcerts.html
curl performs SSL certificate verification by default, using a "bundle"
of Certificate Authority (CA) public keys (CA certs). If the default
bundle file isn't adequate, you can specify an alternate file
using the --cacert option.
If this HTTPS server uses a certificate signed by a CA represented in
the bundle, the certificate verification probably failed due to a
problem with the certificate (it might be expired, or the name might
not match the domain name in the URL).
If you'd like to turn off curl's verification of the certificate, use
the -k (or --insecure) op

我们可以通过直接忽略签名的方式进行请求:

$ curl -k https://localhost:8000/
hello world

也可以使用自己的证书,也就是告知ca证书,完成服务器端证书的验证:

$ curl --cacert keys/ca.crt https://localhost:8000/
hello world

有了curl的初步使用,我们还可以构建自己的https客户端

var https = require('https');
var fs = require('fs');
var options = {
    hostname: 'localhost',
    port: 8000,
    path: '/',
    method: 'GET',
    key: fs.readFileSync('./keys/client.key'),
    cert: fs.readFileSync('./keys/client.crt'),
    ca: [fs.readFileSync('./keys/ca.crt')]
};
options.agent = new https.Agent(options);
var req = https.request(options, function (res) {
    res.setEncoding('utf-8');
    res.on('data', function (d) {
        console.log(d);
    });
});
req.end();
req.on('error', function (e) {
    console.log(e);
});

//输出结果
$ node client.js
hello world

//如果不设置ca的话,会报错
[Error: UNABLE_TO_VERIFY_LEAF_SIGNATURE]

这个异常可以通过添加属性
rejectUnauthorized:false解决,这个与curl -k效果一致
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容