深入浅出 nodeJs-网络编程

网络编程

Node 具备良好的伸缩性,适合在分布式网络 扮演各种各样的角色。

Node 提供了 net、dgram、http、https 这四个模块,分别用于处理 TCP、UDP、HTTP、HTTPS,适用于服务器端和客户端。

TCP 网络

目前大部分的应用都是基于 TCP 搭建而成

  • TCP

TCP 全名为传输控制协议,在OSI模型(分别为物理层、数据链结层)中属于传输层协议。

  • 创建 TCP 服务器端来接收网络请求
var net = require('net');
// 创建构建 TCP 服务器,接收的函数是连接时间 connection 侦听器
var server = net.createServer(function(socket) {
    // 新的连接
    socket.on('data', function(data) {
        socket.write("你好");
    });
    socket.on('end', function() {
        console.log('连接断开');
    });
    socket.write("欢迎光临");
});
server.listen(8124, function() {
    console.log('server bound');
});

// 也可采用下面的方式进行侦听
var server = net.createServer();
server.on('connection', function(socket) {
    // 新的连接
})
server.listen(8124);

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

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 服务的事件 代码分为服务器事件和连接事件

  1. 服务器事件。对于net.createServer()创建的服务器而言,它是一个EventEmitter实例,它的自定义事件有以下几种:
    • listening: 调用server.listen()绑定端口或Domain Socket后触发。简洁写法为server.listen(port, listeningListener),通过listen()的第二个参数传入。
    • connection: 每个客户端套接字连接到服务器端时触发
    • close: 当服务器关闭时触发,在调用server.close()后,服务器停止接收新的套接字连接,但保存当前存在的连接,当所有连接都断开后,会触发该事件
    • error: 当服务器发生异常时,会触发该事件。
  2. 连接事件。客户端可以同时与多个客户端保持连接,对于每个连接而言是典型的可写可读 Stream 对象。它具有如下自定义事件
    • data: 当一端调用write()发送数据时,另一端会触发 data 事件,时间传递的数据即是write()发送的数据
    • end: 当连接中的任意一端发送了 FIN 数据时
    • connect: 当套接字与服务器端连接成功时触发
    • drain: 当任意一端调用write()发送数据时,当前这端会触发该事件
    • error: 当异常发生时
    • close: 当套接字完全关闭时
    • timeout: 当一定时间连接不再活跃时,该事件将会被触发,通知用户当前该连接已经被闲置了

由于TCP套接字是可读可写的 Stream 对象,可以利用 pipe() 方法实现管道操作

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

在 Node 中,TCP 默认启用 Nagle 算法,这种算法会在要求缓存区的数据达到一定数量或一定时间后才将其发出,以此来优化网络

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

创建 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])

UDP 套接字事件 UDP 套接字只是一个 EventEmitter 的实例,而非 Stream 的实例。

  • message: 当 UDP 套接字侦听网卡端口后,接收到消息时触发该事件,触发携带的数据为消息 Buffer 对象和一个远程地址信息
  • listening: 当 UDP 套接字开始侦听时触发该事件
  • close: 调用close()方法时触发改时间,并不触发 message 事件
  • error: 当异常发生时触发该事件,如果不侦听,异常将直接抛出,使进程退出

构建 HTTP 服务

如果要构建高效的网络应用,就应该从传输层出手。但是对于经典的应用场景,而无需从传输层协议入手构造自己的应用,比如 HTTP 或 SMTP 等。

Node 提供了基本的 http 和 https 模块用于 HTTP 和 HTTPS 的封装,其他应用层协议的封装,也能从社区中找到实现。构建 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 服务继承自 TCP(net 模块),它能够与多个客户端保持连接,由于采用事件驱动的形式,能实现高并发。http 模块即是将 connection 到 request 的过程进行了封装。

除此之外,http 模块将连接所用套接字的读写抽象为 ServerRequest 和 ServerResponse 对象,分别对应请求和响应操作。在请求产生的过程中,http 模块拿到连接中传来的数据,调用二进制模块 http_parse 进行解析,解析完请求报文的报头后,触发 request 事件。

  1. HTTP 请求。要在数据流结束后才能进行操作
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');
    });
}
  1. HTTP 响应。它封装了对底层连接的写操作,可已经其看成一个可写的流对象。他影响响应报文头部信息的 API 为res.setHeader()res.writeHead()。可以通过 setHeader 进行多次设置,但是在调用 writeHead 后,报文才会写入到连接中。

无论服务端是否发生异常,务必在结束时调用 res.end() 结束请求,否则客户端将一直处于等待的状态。当然,也可以通过延迟 res.end() 的方式实现长连接

  1. HTTP 服务的事件。
    • connection: 开始 HTTP 请求和响应前,客户端与服务器端需要建立底层的 TCP 连接,建立连接时触发
    • request: 建立 TCP 连接后,当请求数据发送到服务端,解析出 HTTP 请求后,将会触发改时间
    • close: 与 TCP 一致,调用server.close()方法停止接收新的连接,当已有的连接都断开时,触发该事件
    • checkContinue: 在发送较大数据时,并不会将数据直接发送,而是先发送一个头部带Expect: 100-continue的请求到服务器,服务器将会触发此事件;如果服务器没监听,服务器会自动响应客户端 100 Continue 的状态码,表示接收数据上传;如果不接受的数据较多时,可以响应客户端 400 Bad Request 拒绝客户端
    • connect: 当客户端发起 connect 请求时出发
    • upgrade: 当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求头中带上 Upgrade 字段,服务器接收到这样的请求时触发该事件
    • clientError: 连接的客户端触发 error 事件时,这个错误会传递到服务器端,此时触发该事件。

HTTP 客户端 HTTP 客户端其实就是服务器端服务模型的另一部分,处于 HTTP 的另一端。它提供了一个底层的 API: http.request(options, connect) ,用于构造 HTTP 客户端。其中 options 参数有以下这些

  • host: 服务器的域名或 IP 地址
  • hostname: 服务器名称
  • port: 服务器端口,默认 80
  • localAddress: 建立网络连接的本地网卡
  • socketPath: Domain 套接字路径
  • method: HTTP 请求方法,默认 get
  • path: 请求路径
  • headers: 请求头对象
  • auth: Basic 认证,将被计算成请求头中的 Authorization 部分
  1. HTTP响应。响应对象与服务器端较为类似,它的时间叫 response。ClienRequest 在解析响应对象时,解析完响应头就会触发 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);
    });
}
  1. HTTP 代理。为了重用 TCP 连接,http 模块包含一个默认的客户端代理对象http.globalAgent。默认情况下,通过 ClientRequest 对象对同一个服务端发起的 HTTP 请求最多可以创建 5 个连接。它实质是一个连接池

代理连接池

调用 HTTP 客户端同时发起 10 个请求时,实质只有 5 个请求处于并发状态。可以在 options 中传递 agent 选项来破解限制

  1. HTTP客户端事件
  • response: 客户端在请求发送后得到服务端响应时会触发
  • socket: 当地曾连接池中建立的连接分配给当前请求对象时触发
  • connect: 发起 CONNECT 请求时,如果响应了 200 状态码触发
  • upgrade: 发起 Upgrade 请求时,如果响应了 101 Switching Protocols
  • continue: 发起 Expect: 100-continue 头信息,如果响应了 100 Continue 状态

构建 WebSocket 服务

它与 Node 之间的配合堪称完美

  • webSocket 客户端基于事件的编程模型与 Node 中自定义事件相差无几
  • webSocket 实现了客户端与服务端之间的长连接,而 Node 事件驱动的方式十分擅长于大量客户端保持高并发连接。

除此之外,WebSocket 和传统 HTTP 有如下好处

  • 客户端与客户端只建立一个 TCP 连接,可以使用更少的连接
  • WebSocket 服务器端可以推送数据到客户端
  • 更轻量级的协议头,减少数据传送量

websocket 的握手部分是由 HTTP 完成的。它主要分为两个部分:握手和数据传输

WebSocket握手 客户端建立连接时,通过 HTTP 发起请求报文,与普通的 HTTP 请求协议有区别的部分在于下面的协议头

Upgrade: websocket
Connection: Upgrade

上面两个字段表示请求服务器端升级协议为 WebSocket。其中 Sec-WebSocket-Key 用于安全校验 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Sec-WebSocket-Key 的值是随机生成的 Base64 编码的字符串。此时服务器端的响应行为

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数据传输 握手完成之后,会开始 WebSocket 的数据帧协议,实现客户端与服务器端的数据交换。握手完成后,客户端的 onopen() 将会被触发执行

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

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 位为一列,也就是一个字节。每一位都有它的意义

  • fin: 如果这个数据帧是最后一帧,则这个 fin 位为 1,其余情况为 0
  • rsv1、rsv2、rsv3: 各为1位长,3 个标识用于扩展,当有已协商的扩展,这些值可能为 1,其余情况为 0
  • opcode: 长为 4 位的操作码,可以用来表示 0 ~ 15 的值,用于解释当前数据帧。
    • 0: 附加数据帧
    • 1: 文本数据帧
    • 2: 二进制数据帧
    • 8: 发送一个连接关闭的数据帧
    • 9: ping 数据帧,用于心跳响应,当一端发送 ping 时,另一端以 pong 回应,告知处于响应状态
    • 10: pong 数据帧
  • masked: 标识是否进行掩码处理,长度为 1。客户端发送给服务端时为 1,服务端发送给客户端时为 0。
  • payload length: 一个 7、7+16或7+64位长的数据位,标识数据的长度
  • masking key: 当 masked 为 1 时存在,是一个 32 位唱的数据位,用于与解密数据
  • payload data: 我们的目标数据,位数为 8 的倍数

网络服务与安全

ssl 在传输层提供对网络连接加密的功能,对于应用层而言,它是透明的。

node 在网络安全上提供了三个模块,分别为 cryptotlshttps

  • crypto 主要用于加密解密,包括 SHA1、MD5 等加密算法
  • tls 模块提供了与 net 模块类似的功能,区别在于它建立在 TLS/SSL 加密的 TCP 连接上。
  • https 完全与 http 模块接口一致,区别仅在于它建立在安全的连接之上

TLS/SSL

  1. 密钥。TLS/SSL 是一个公钥/私钥的结构,它是一个非对称的结构,每个客户端和服务端都有自己的公私钥。Node 在底层采用的是 openssl 实现 TLS/SSL 的,因此生成公钥和私钥可以通过 openssl 完成

为了应对中间人攻击,TLS/SSL 引入了数字证书来进行认证。数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在连接前,会确认公钥来自目标服务器

  1. 数字证书。通过 CA 来为站点颁发证书,这个证书具有 CA 通过自己的公钥和私钥实现的签名。

TLS 服务

  1. 创建服务器端。我们可以通过 Node 的 tls 模块来创建一个安全的 TCP 服务:
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');
});
  1. TLS 客户端。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 证书。客户端启动之后可以在输入流中输入数据,服务端将会回应相同的数据

HTTPS 服务

  1. 准备证书。可以直接用上文生成的私钥和证书
  2. 创建 HTTPS 服务。它只比 HTTP 服务多一个选项配置,其余地方相同
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);
  1. HTTPS 客户端。与HTTP的客户端相差不,除了指定证书相关的参数
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);
});