Node-基于TCP的简易网络聊天室
写程序之前先回顾一下TCP~
有关TCP
传输控制协议(TCP)是一个面向连接的协议,它保证了两台计算机之间数据传输的可靠性和顺序。
如今。网络应用都是用TCP/IP协议进行通信的。
Node HTTP服务器是构建在Node TCP服务器之上的。从编程角度来说,也就是Node中的http.Server继承自net.Server(net是TCP模块)
除了Web浏览器和服务器(HTTP)之外,很多我们日常使用的如邮件客户端(SMTP/IMAP/POP)、聊天程序(IRC/XMPP)以及远程shell(SSH)等都基于HTTP协议。
TCP协议的特点
TCP的首要特性就是它面向连接的。
面向连接的通信和保证顺序的传递
可以将客户端和服务端的通信看作是一个连接或者数据流。这对开发面向服务的应用和流应用是很好的抽象,因为TCP协议做基于的IP协议是面向无连接的。
IP是基于数据报的传输。这些数据报是独立进行传输的,传达的顺序也是无序的。为了保证数据包送达时是有序的,在TCP连接内进行数据传递时,发送的IP数据包包含了标示该连接以及数据流顺序的信息。
假设一条信息分为四个部分,当服务器从连接A收到第一部分和第四部分后,它就直到还要等待其他数据段中的第二部分和第三部分
面向字节
TCP允许数据以ASCII字符(每个字符一个字节)或者Unicode(即每个字符四个字节)进行传输。
可靠性
TCP需要基于确认和超时实现一些列的机制达到可靠性。
当数据发送出去后,发送方就会等待一个确认消息(标示数据包已经收到的简短的确认消息)。如果过了指定的窗口事件,还未收到确认消息,发送方就对数据进行重发。
流控制
TCP通过一种叫流控制的方式来保证两台互相通信的计算机之间传输数据的平衡,避免有一台速度远快于另一台。
拥塞控制
TCP有一种内置的机制能够控制数据包的延迟率及丢包率不会太高,以此来保证服务的质量(QoS)。
基于TCP的简易网络聊天室通过Telnet通信
Telnet
Telnet是一种早期的网络协议,旨在提供一种双向的虚拟终端。
telnet到服务器:
// test.js
var http = require('http');
http.createServer(function(req,res){
res.writeHead(200,{'Content-Type':'text/html'});
res.end('<h1>hello</h1>');
}).listen(3000);
此时运行代码,可以在浏览器获取相应数据。
但是,当使用telnet时建立连接,会接收不到数据,这是由于:
要往TCP中写入数据,必须首先创建一个HTTP请求,这就是套接字(socket)(在终端写入GET / HTTP/1.1再两次回车即可成功请求到数据。)
基于TCP的聊天程序
定义需求
创建一个基本的TCP服务器,任何人都可以连接到该服务器,无须实现任何协议或者指令:
- 成功连接到服务器后,服务器会显示欢迎信息,并要求输入用户名。同时还会告诉你当前还有多少其他客户端也连接到了该服务器上。
- 输入用户名,按下回车键后,就认为是成功连接上了。
- 连接后,就可以通过输入信息再次按下回车键,来向其他客户端进行消息的收发。
这里有一个重要的问题就是:
为什么要按下回车键?
事实上,Telnet中输入的任何信息都会立即发送到服务器。按下回车键是为了输入\n字符。在Node端,通过\n判断消息是否已完全到达。所以,回车符作为一个分隔符来使用
细分步骤
- 创建模块
- 理解NET.SERVER API
- 接收连接
- data事件
- 状态以及记录连接情况
- 退出程序的消息显示
创建模块
// package.json
{
"name": "tcp-chat-room",
"version": "0.0.1",
"description": "Our first tcp server"
}
理解NET.SERVER API
// index.js
var net = require('net');
var server = net.createServer(function(cnn){
console.log('new connection!');
});
server.listen(3000,function(){
console.log('server listening on 3000');
});
上述代码中为createServer指定了一个回调函数。该回调函数在每次有新连接的时候都会重新执行。
HTTP是建立在TCP之上的。
createServer回调函数会接收一个对象,该对象是Node中一个很常见的实例:流(Stream),本例中,它传递的是net.Stream,该对象是既可读又可写的。
接收连接
添加计数器,追踪连接的数目。
/*
* 计数器
*/
var count = 0;
/*
* 添加模块
*/
var net = require('net');
var server = net.createServer(function(conn){
conn.write(
'\n> welcome to \033[92mnode-chat\033[39m'
+ '\n>' + count + 'other people are commected at this time'
+ '\n> please write your name and press enter:'
);
count++;
});
/*
* 监听
*/
server.listen(3000,function(){
console.log('server listening on 3000');
});
打开两个终端,输入命令telnet 127.0.0.1 3000连接服务器,结果每次有其他客户端连进去后,计数器就增加了1。
当客户端请求关闭连接的时候,计数器需要进行递减操作。
当底层套接字关闭时,Nodejs会触发close事件。Nodejs中有两个和连接终止连接的事件:end和close。前者是当客户端显示关闭TCP连接时触发。比如当你关闭telnet时,它会发一个名为“FIN”的包给服务器,意味着结束连接。
当连接发生错误时(触发error事件),end事件不会触发,因为服务器端并未收到“FIN”包信息。不过这两种情况下,close事件都会触发。
data事件
由于net.Stream同时也是一个EventEmitter,可以监听data事件处理客户端输入的信息。
conn.on('data',function(data) {
console.log(data);
});
可以看到服务器的确是面向字节连接的。
设置utf8编码
conn.setEncoding('utf8');
此时服务器能正确显示客户端输入的信息。
状态以及记录连接情况
此前定义的计数器称为状态。
两个不同连接的用户需要修改同一个状态变量,这在Node中称为共享状态的并发。
conn.on('data',function(data) {
data = data.replace('\r\n',''); // 删除回车符
if (!nickname) { // 未通过验证
if (users[data]) {
conn.write('\033[93m> nickname already in use. try again:\033[39m');
return;
} else {
nickname = data;
users[nickname] = conn;
for (var i in users) {
users[i].write('\033[90m>' + nickname + ' joined the room\033[39m\n');
}
}
} else { // 已经通过验证,则示为聊天信息
for (var i in users) {
if (i != nickname) { // 确保消息只发送给了除了自己以外的其他客户端
users[i].write('\033[96m>' + nickname + ':\033[39m' + data + '\n');
}
}
}
});
本例关键在于存储net.Stream对象。
从这里也可以看到,Node单线程的表现了!每次都是使用同一个程序,users数组是共享的。只是连接之后对应了不同的net.Stream对象。
退出程序的消息显示
当有人断开连接时,需要清除users数组中对应的元素
delete users[nickname];
用户断开时候通知其他用户:
// 封装一个广播
function broadcast (msg, exceptMyself) {
for (var i in users) {
if (i != exceptMyself || i != nickname) {
users[i].write(msg);
}
}
}
完整代码:
// index.js
/*
* count:计数器
* users:记录设置了昵称的用户
*/
var count = 0;
var users = [];
/*
* 添加模块
*/
var net = require('net');
var server = net.createServer(function(conn){
conn.write(
'\n> welcome to \033[92mnode-chat\033[39m'
+ '\n>' + count + 'other people are commected at this time'
+ '\n> please write your name and press enter:'
);
count++;
conn.setEncoding('utf8');
var nickname; // 表示当前连接的昵称,注意这句不能放在监听data的事件里面,因为
conn.on('data',function(data) {
data = data.replace('\r\n',''); // 删除回车符
if (!nickname) { // 未通过验证
if (users[data]) {
conn.write('\033[93m> nickname already in use. try again:\033[39m');
return;
} else {
nickname = data;
users[nickname] = conn;
for (var i in users) {
users[i].write('\033[90m>' + nickname + ' joined the room\033[39m\n');
}
}
} else { // 已经通过验证,则示为聊天信息
for (var i in users) {
if (i != nickname) { // 确保消息只发送给了除了自己以外的其他客户端
users[i].write('\033[96m>' + nickname + ':\033[39m' + data + '\n');
}
}
}
});
conn.on('close',function(){
count--;
broadcast('\033[90m >' + nickname + 'left the room\033[39m\n');
delete users[nickname];
});
function broadcast (msg, exceptMyself) {
for (var i in users) {
if (i != exceptMyself || i != nickname) {
users[i].write(msg);
}
}
}
});
/*
* 监听
*/
server.listen(3000,function(){
console.log('server listening on 3000');
});
IRC
IRC是因特网中继聊天(Internet Relay Chat)的缩写,它也是一项常用的基于TCP的协议。
IRC是一项非常直观、简单的协议。通过一些简单的命令就可以和所有的应用以及服务器进行通信。
构建一个实现TCP协议的客户端意味着,需要实现通过一组命令来实现于IRC服务器进行“通信”,进行数据的交换。
参考
《了不起的Nodejs》