NODEJS硬实战笔记 (TCP与UDP)
2017-03-23 本文已影响1978人
77即是正义
NodeJS TCP与UDP
一个最简单的TCP服务端
var net = require('net');
var clients = 0;
var server = net.createServer(function (client) {
clients++;
var clientId = clients;
console.log('Client connected:', clientId);
client.on('end', function () {
console.log('Client disconnected:', clientId);
});
client.write('Welcome client: ' + clientId + 'rn');
client.pipe(client);
});
server.listen(8000, function () {
console.log('Server started on port 8000');
});
当一个客户端创建了一个新连接,传递给net.createServer回调函数将会执行。回调接收一个面向事件的连接对象。这个服务对象是net.Server的一个实例,仅仅是对net.Socket类的一个封装,而net.Socket类又是使用双工流来实现的,所以服务端在发送信息给客户端的时候可以使用client.pipe()管道来发送。
TCP客户端和服务端
// 主要是验证每次的连接都对应不同的clientId
var assert = require('assert');
var net = require('net');
var clients = 0;
var expectedAssertions = 2;
// 创建一个服务端
var server = net.createServer(function (client) {
clients++;
var clientId = clients;
console.log('Client connected:', clientId);
client.on('end', function () {
console.log('Client disconnected:', clientId);
});
client.write('Welcome client:' + clientId + '\r\n');
client.pipe(client);
});
// 主线程监听8000端口
server.listen(8000, function () {
console.log('Server started on port 8000');
runTest(1, function () {
runTest(2, function () {
console.log('Tests finished');
assert.equal(0, expectedAssertions);
server.close()
})
});
// 创建客户端并去连接服务端
function runTest(expectedId, done) {
var client = net.connect(8000);
client.on('data', function (data) {
var expected = 'Welcome client:' + expectedId + '\r\n';
assert.equal(data.toString(), expected);
expectedAssertions--;
client.end();
});
client.on('end', done)
}
});
这里发现了一个问题,当服务端和客户端处于同一线程中的时候,两边互发消息,服务端接收到的流存在异常,内容将变成客户端发送的消息+服务端之前发送的消息。暂时只知道在write后再通过管道传递会发送这种情况,不知道为什么?并且也不是很清楚为什么在write了之后还需要通过管道传递。
-
连接服务端:
- 连接TCP服务端要创建一个客户端对象,这个对象是是一个UNIX Socket,即:net.Socket实例。它是了一个双向的流接口,创建对象服务器端的connection事件会被触发。创建一个TCP客户端可以使用createConnection()方法或其别名方法connect(),也可以使用构造函数new net.Socket()。连接成功后'connect'事件会被触发。
-
和服务端交换数据:
- 与TCP服务端建立连接后就可以向服务端发送数据,或接收来自服务器的数据。接收到服务器端的数据数据后后触发data事件,可以通监听这个事件接收数据。向服务器发送数据可以使用write()方法。数据传输前可以通过setEncoding()方法设置流的编码格式。
- socket.write(data, [encoding], [callback]):在UNIX Socket套接字上发送数据时,如果第二参数用于设置发送数据的编码方式,默认为UTF8编码。如果所有数据被成功刷新到缓冲区,则返回true。如果所有或部分数据在用户内存里还处于队列中,则返回false。当缓冲区再次被释放时,'drain'事件会被触发。(注意这边write的实现本身就是流模式的,所以更不明白上面write后还通过管道传递的写法)
- 当数据最终被完整写入时,可选参数callback会被执行。
- socket.setEncoding([encoding]):设置Socket流的编码格式
-
Socket流的暂停与关闭:
- 关闭与终端的的连接使用end()方法,end()方法在关闭连接前也可以向终端发送数据。对于发生错误的连接,可以调用destroy()方法,关闭已没有 I/O 活动的TCP连接。
-
socket.end(data, [encoding]):半关闭Socket套接字。例如:当发送一个FIN包时,可能服务器仍在发送数据。(管道是双向的,所以这边是半关闭,具体原因参见后期将会写的TCP状态机)
如果传入data参, 等同于调用 socket.write(data, encoding)然后调用socket.end()。 - socket.destroy():销毁已没有I/O活动的TCP连接
- Socket客户端是一个可读写的流,这意味着你可以对它进行暂停和恢复。
- socket.pause():暂停读取数据,暂停后'data'事件不会再触发
- socket.resume():恢复pause()方法暂停的流
-
Socket客户端一些设置和方法:
- 除了前面介绍的设置编码的setEncoding()方法外,还有其它一些设置方法,如:设置超时的setTimeout()方法。
-
socket.setTimeout(timeout[, callback]):套接字超过timeout毫秒闲置状态,则将套接字设为超时。默认net.Socket不存在超时。
当一个闲置超时被触发时,会触发一个'timeout'事件,但是连接将不会被断开。用户必须手动end()或destroy()断开这个套接字。可选参数callback会被添加成为'timeout'事件的一次性监听器。 - socket.setNoDelay([noDelay]):禁用Nagle算法。默认情况下TCP连接使用Nagle算法,这些连接在发送数据之前对数据进行缓冲处理。 将noDelay设成true会在每次socket.write()被调用时立刻发送数据。noDelay默认为true。
-
socket.setKeepAlive([enable], [initialDelay]):禁用/启用长连接功能,并在第一个在闲置套接字上的长连接probe被发送之前,可选地设定初始延时。enable默认为false。
设定initialDelay (毫秒),来设定在收到的最后一个数据包和第一个长连接probe之间的延时。设置为0会保留默认(或者之前)的值。默认为0。 - socket.address():返回Socket套接字绑定的IP地址, 协议类型以及端口号。其返回值是一个包含三个属性的对象, 形如{ port: 2345, family: 'IPv4', address: '127.0.0.1' }。
- socket.unref():如果当前套接字对象是事件系统中唯一一个活动的套接字,调用unref方法将允许程序退出。如果套接字已被 unref,则再次调用 unref 并不会产生影响。
- socket.ref():与unref 相反。如果当前套接字对象是仅剩的套接字,在一个之前被 unref 了的套接字上调用 ref 将不会让程序退出(缺省行为)。如果一个套接字已经被 ref,则再次调用 ref 并不会产生影响。
-
Socket类中的属性:
- socket.bufferSize:当前准备写入缓冲区的字符数,用户可根据此属性对数据流进行控制。遇到很大或增长很快的 bufferSize 时,用户可用尝试用pause() 和 resume()来控制字符流。
- socket.remoteAddress:远程的IP地址(TCP服务端),例如:'74.125.127.100'或'2001:4860:a005::68'
- socket.remoteFamily:远程IP协议版本,例如:'IPv4'或'IPv6'
- socket.remotePort:远程端口号,例如:80或22
- socket.localAddress:本地IP地址(TCP客户端),例如:'198.168.0.10'
- socket. localPort:本地端口号,例如:80或22
- socket.bytesRead:客户端收到的字节数
- socket.bytesWritten:客户端发送的字节数
TCP基础知识
数据包与MTU
- 数据包主要分为IPv4和IPv6数据包,那这里先简要说一下IPv4协议与IPv6协议的区别:
- 更大的地址空间,IPv4中规定IP地址长度为32,即有232-1个地址;而IPv6中IP地址的长度为128,即有2128-1个地址。
- 更小的路由表。IPv6的地址分配一开始就遵循聚类(Aggregation)的原则,这使得路由器能在路由表中用一条记录(Entry)表示一片子网,大大减小了路由器中路由表的长度,提高了路由器转发数据包的速度。 增强的组播(Multicast)支持以及对流的支持(Flow-control)。
- IPv4的数据包大小是65535字节,包括IPv4的首部,首部中说明大小的字段为16位。
- IPv6的数据包大小是65575字节,因为IPv6的首部是40字节,但因为不算在其中所以比IPv4大一个首部。
- (具体的比较分析见后期将会写的IPv4与IPv6数据报分析)
- MTU(Maximum Transmission Unit):最大传输单元
- MTU就像是高速公路上的车道宽度。
- 许多网络有一个可由硬件规定的MTU。以太网的MTU为1500字节。有一些链路的MTU的MTU可以由认为配置。IPv4要求的最小链路MTU为68字节。这允许最大的IPv4首部(包括20字节的固定长度部分和最多40字节的选项部分)拼接最小的片段(IPv4首部中片段偏移字段以8个字节为单位)IPv6要求的最小链路MTU为1280字节。
分片
- 当一个IP数据报从某个接口送出时,如果它的大小超过相应链路的MTU,IPv4和IPv6都将执行分片。这些片段在到达终点之前通常不会被重组(reassembling)。IPv4主机对其产生的数据报执行分片,IPv4路由器则对其转发的数据报进行分片。然后IPv6只有主机对其产生的数据报执行分片,IPv6路由器不对其转发的数据报执行分片。
- IPv4首部的“不分片”(do not fragment)位(即DF位)若被设置,那么不管是发送这些数据报的主机还是转发他们的路由器,都不允许对它们分片。当路由器接收到一个超过其外出链路MTU大小且设置了DF位的IPv4数据报时,它将产生一个ICMPv4“destination unreachable,fragmentation needed but DF bit set”(目的不可到达,需分片但DF位已设置)的出错消息。
- 既然IPv6路由器不执行分片,每个IPv6数据报于是隐含一个DF位。当IPv6路由器接收到一个超过其外出链路MTU大小的IPv6数据报时,它将产生一个ICMPv6 “packet too big”的出错消息。IPv4的DF位和隐含DF位可用于路径MTU发现。
缓冲区
- 缓冲区是TCP进行数据交流的最主要的承载部分。就像高速公路两端的休息站。
- 而缓冲区主要是分为发送缓冲区与重组缓冲区:
- MSS(maximun segment size): 最大分段尺寸
- TCP有一个最大分段大小,用于对端TCP通告对端每个分段中能发送的最大TCP数据量。MSS的目的是告诉对端其重组缓冲区大小的实际值,从而避免分片。MSS经常设计成MTU减去IP和TCP首部的固定长度。以太网中使用IPv4MSS值为1460,使用IPv6的MSS值为1440(两者TCP首部都是20字节,但是IPv6首部是40字节,IPv4首部是20字节)。
- 发送缓冲区:
- 每个TCP套接字有一个发送缓冲区,我们可以用SO_SNDBUF套接字选项来更改该缓冲区的大小。当某个应用进程调用write时,内核从该应用进程的缓冲区复制所有数据到缩写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),该应用进程将被投入睡眠。这里假设该套接字是阻塞的,它通常是默认设置。内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接受到数据。
- 这一端的TCP提取套接字发送缓冲区中的数据并把它发送给对端的TCP,其过程基于TCP数据传送的所有规则。对端TCP必须确认收到的数据,伴随来自对端的ACK的不断到达,本段TCP至此才能从套接字发送缓冲区中丢弃已确认的数据。TCP必须为已发送的数据保留一个副本,直到它被对端确认为止。本端TCP以MSS大小或是更小的块把数据传递给IP,同时给每个数据块安上一个TCP首部以构成TCP分节,其中MSS或是由对端告知的值,或是536(若未发送一个MSS选项为576-TCP首部-IP首部)。IP给每个TCP分节安上一个IP首部以构成IP数据报,并按照其目的的IP地址查找路由表项以确定外出接口,然后把数据报传递给相应的数据链路。每个数据链路都有一个数据队列,如果该队列已满,那么新到的分组将被丢弃,并沿协议栈向上返回一个错误:从数据链路到IP,在从IP到TCP。TCP将注意到这个错误,并在以后某个时候重传相应的分节。应用程序不知道这种暂时的情况。
- 重组缓冲区:
- IPv4和IPv6都定义了最小缓冲区大小,它是IPv4或IPv6任何实现都必须保重支持的最小数据报大小。其值对IPv4为576字节,对于IPv6为1500字节。例如,对于IPv4而言,我们不能判定某个给定的目的能否接受577字节的数据报,为此很多应用避免产生大于这个大小的数据报。
- MSS(maximun segment size): 最大分段尺寸
Nagle算法
- TCP/IP协议中,无论发送多少数据,总是要在数据前面加上协议头,同时,对方接收到数据,也需要发送ACK表示确认。为了尽可能的利用网络带宽,TCP总是希望尽可能的发送足够大的数据。(一个连接会设置MSS参数,因此,TCP/IP希望每次都能够以MSS尺寸的数据块来发送数据)。Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。
- Nagle算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓“小段”,指的是小于MSS尺寸的数据块,所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的ACK确认该数据已收到。
- Nagle算法的规则(可参考tcp_output.c文件里tcp_nagle_check函数注释):
- 如果包长度达到MSS,则允许发送;
- 如果该包含有FIN,则允许发送;
- 设置了TCP_NODELAY选项,则允许发送;
- 未设置TCP_CORK选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;
- 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。
UDP服务端和客户端
var assert = require('assert');
var dgram = require('dgram');
var fs = require('fs');
var defaultSize = 16;
var port = 41234;
// 创建客户端
function Client(remoteIP) {
var socket = dgram.createSocket('udp4');
var readline = require('readline');
var rl = readline.createInterface(process.stdin, process.stdout);
socket.send(new Buffer('<JOIN>'), 0, 6, port, remoteIP);
rl.setPrompt('Message> ');
// 开始等待用户的输入
rl.prompt();
// 当用户输入完一行按回车后触发
rl.on('line', function (line) {
sendData(line)
// readline一开始执行就不会结束,所以需要监听close事件来关闭进程
}).on('close', function () {
process.exit(0)
});
socket.on('message', function (msg, rinfo) {
console.log('\n<' + rinfo.address + '>', msg.toString());
rl.prompt();
});
function sendData(message) {
socket.send(new Buffer(message), 0, message.length, port, remoteIP,
function (err, bytes) {
console.log('Sent:', message);
rl.prompt();
})
}
}
// 创建服务端
function Server() {
var clients = [];
var server = dgram.createSocket('udp4');
server.on('message', function (msg, rinfo) {
var clientId = rinfo.address + ':' + rinfo.port;
msg = msg.toString();
if (!clients[clientId]) {
clients[clientId] = rinfo;
}
if (msg.match(/^</)) {
console.log('Control message:', msg);
return;
}
for (var client in clients) {
if (client !== clientId) {
client = clients[client];
server.send(
new Buffer(msg), 0,
msg.length, client.port, client.address,
function (err, bytes) {
if (err) console.error(err);
console.log('Bytes sent:', bytes);
}
)
}
}
});
server.on('listening', function () {
console.log('Server ready:', server.address());
});
server.bind(port);
}
module.exports = {
Client: Client,
Server: Server
};
// module.parent 返回引用该模板的模板
if (!module.parent) {
switch (process.argv[2]) {
case 'client':
new Client(process.argv[3]);
break;
case 'server':
new Server();
break;
default:
console.log('Unknown option');
}
}
使用dgram.createSocket创建一个客户端socket与服务端相同。发送一个数据报需要一个buffer来承载,用偏移量来表明buffer中消息的开始、消息的长度、服务端口、远程IP和一个可选的回调,当消息发出时会被触发。
- UDP发送缓冲区
- 任何UDP套接字都有发送缓冲区大小(我们可以用SO_SNDBUF套接字选项更改它),不过它仅仅是可写道套接字UDP数据报大小上限。如果一个应用进程写一个大于套接字发送缓冲区大小的数据报,内核将返回该进程一个EMSGSIZE错误。既然UDP是不可靠的,它不必保存应用进程数据的一个副本,因此无需一个真正的发送缓冲区。(应用进程的数据在沿协议栈向下传递时,通常被复制到某种格式的一个内核缓冲区中,然而当该数据被发送之后,这个副本被数据链路层丢弃了。)
- UDP简单地给来自用户的数据报安上8字节首部以构成UDP数据报,然后传递给IP。IPv4或IPv6给UDP数据报安上相应的IP首部以构成IP数据报,执行路由操作确定外出接口,然后或者直接把数据报加入数据链路层输出队列(如果适合于MTU),或者分片后在把每个片段加入数据集链路层的输出队列。如果某个UDP进程发送大数据报,那么它们相比TCP应用数据更有可能被分片,因为TCP会把应用数据划分成MSS大小的块,而UDP却没有对等的手段。
- 从写一个UDP套接字的write调用成功返回表示所写的数据报或其所有片段已被加入数据链路层的输出队列。如果该队列没有足够的空间存放该数据报或它的某个片段,内核通常会返回一个ENOBUFS错误给它的应用进程。有些UDP实现不返回这种错误,这样甚至数据报未经发送就被丢弃的情况进程也不知道。
部分内容摘抄自网上博客