基于socket的聊天室,目前还比较少见,国内比较知名的有网易和碧海银沙聊天室。这种聊天室的特点 很明显,不象CGI聊天室那样不管有没有人发言,都会定时刷新。而是当有人发言时,屏幕上才会出现新聊天内容,而且聊天内容是不断向上滚动的,如果浏览器 状态栏在的话,可以看到进度条始终处于下载页面状态。这种聊天室可以容纳许多人而性能不会明显降低,象网易聊天室经常有数百人在一台服务器上聊天。由于这 种方式不同于CGI聊天室由客户端浏览器定时请求聊天内容,而是由聊天服务器软件向客户浏览器主动发送信息。
Socket聊天室基本原理是,抛开cgi和www服务器,根据html规范,接收到浏览器的请求以后,模仿www服务器的响应,将聊天内容发回浏览器。 在浏览器看来就象浏览一个巨大的页面一样始终处于页面联接状态。实际上就是一个专门的聊天服务器,一个简化了的www服务器。
这样相比CGI方式来说,Socket聊天室的优点就很明显:
1. 不需要专门的WWW Server,在聊天服务器里完成必要的工作,避开耗时的CGI过程
2. 如果使用单进程服务器,就不需要每次产生新进程
3. 数据交换完全在内存进行,不用读写文件
4. 不需要定时刷新,减少屏幕的闪烁,减少对服务器的请求次数
在讨论具体流程之前,我们先来讨论相关的一些技术:
http请求和应答过程
http协议是浏览器与www服务器之间通信的标准,作为一个简化了的www服务器,socket聊天服务器应当遵守这个协议。实际上只要实现一小部分就可以了。
http使用了客户服务器模式,其中浏览器是http客户,浏览某个页面实际上就是打开一个连接,发送一个请求到www服务器,服务器根据所请求的资源发 送应答给浏览器,然后关闭连接。客户和服务器之间的请求和应答有一定的格式要求,只要按照这个格式接收请求发送应答,就可以“欺骗”浏览器,使它以为正在 与www服务器通信。
请求和应答具有类似的结构,包括:
· 一个初始行
· 0个或多个header lines
· 一个空行
· 可选的信息
我们看看一个浏览器发出的请求:
当我们浏览网页:http://www.somehost.com/path/file.html的时候,浏览器首先打开一个到主机www.somehost.com的80端口的socket,然后发送以下请求:
GET /path/file.html HTTP/1.0
From: someuser@somehost.com
User-Agent: Mozilla/4.0 (compatible; MSIE 5.0; Windows NT 4.0; DigExt)
[空行]
第一行GET /path/file.html HTTP/1.0是我们需要处理的核心。由以空格分隔的三部分组成,方法(method):GET,请求资源:/path/file.html,http版本:HTTP/1.0。
服务器将会通过同一个socket用以下信息回应:
HTTP/1.0 200 OK
Date: Fri, 31 Dec 1999 23:59:59 GMT
Content-Type: text/html
Content-Length: 1354
<html>
<body>
<h1>Hello world!</h1>
(其他内容)
.
.
.
</body>
</html>
第一行同样也包括三部分:http版本,状态码,与状态码相关的描述。状态码200表示请求成功。
发送完应答信息以后,服务器就会关闭socket。
以上过程可以利用telnet www.somehost.com:80来模拟。
循环服务器和并发服务器
循环服务器是一个时刻只能处理一个请求的服务器,多个请求同时到来将会放在请求队列里。
而并发服务器则是在每个请求到来以后分别产生一个新进程来处理。
并发服务器由于其算法而具有与生俱来的快速响应优势,而且当某一个用户与服务器通信死锁不会影响其他进程,但由于多个进程之间需要通过进程间通信实现信息交换,而且fork新进程所带来的开销随着用户数量的增加越来越大,因此并发服务器在某些情况下不一定是最佳选择。
循环服务器虽然表面上会产生时延,但是象聊天室这样的系统实际上处理每个请求的过程非常的短,对客户而言,可以取得与并发服务器一样的效果,而且由于是单 进程服务器,不需要进程间通信,不需要fork新进程,编程简单,系统资源消耗极少。但由于是单进程,某个客户与服务器之间死锁将导致整个系统死锁。
POST与GET
提交form信息一般常用的有两种:POST & GET,POST由于长度不受限制,而作为大多数form提交时使用的方法。GET方法通过URL来发送提交信息,由于URL最长只能1024字节,所以 如果发送信息很长的话,不能使用这种方法。由于聊天内容有长度限制,不会很长,而且因为普通浏览页面使用GET方法,因此使用GET方法提交form表 单,可以简化处理过程。
使用perl模块实现Socket通信
假定您对socket编程有一定的了解,如果您使用过C语言进行过socket编程,那么理解perl语言socket编程将是一件非常容易的事。如果不熟悉socket,请看本文所附socket参考。
使用perl编写socket程序可以通过use Socket,也可以通过use IO::Socket。前一种方法接近于C语言,后一种则进行了对象封装,编写维护会容易许多。
我们在通过单进程循环服务器实现并发服务的时候,基本思路是:允许多个客户打开到服务器的socket 连接,服务器通过一定的方法监测哪些socket有数据到达,并处理该连接。在这个思路中有个关键问题服务器如何触发数据处理?了解C语言socket编 程就会知道有个系统函数select可以完成这一操作,但由于使用了位操作,perl语言处理不是很清晰,但是如果使用了模块IO::Select就会变 得很简单。
我们看一个来自IO::Select的帮助的例子:
use IO::Select;
use IO::Socket;
$lsn = new IO::Socket::INET(Listen => 1, LocalPort => 8080);
#创建socket,在端口 8080上监听,相当于使用系统函数
#socket(),bind(),listen()
$sel = new IO::Select( $lsn );
#创建select对象,并把前面创建的socket对象加入
while(@ready = $sel->can_read) {#处理每个可读的socket
foreach $fh (@ready) {
if($fh == $lsn) {
#如果最初创建的socket可读,说明有新连接
#建立一个新的socket,加入select
$new = $lsn->accept;
$sel->add($new);
}
else {
#如果是其他socket,读取数据,处理数据
……
#处理完成以后,从select中删除socket,然后关闭socket
$sel->remove($fh);
$fh->close;
}
}
}
IO::Socket的基本操作,
创建socket对象:$socket=new IO::Socket::INET();
接收客户的连接请求:$new_socket=$socket->accept;
通过socket发送数据:$socket->send($message);
从socket接收数据:$socket->recv($buf,LENGTH);
关闭socket连接:$socket->close;
判断socket是否出于打开状态:$socket->opened;
IO::Select的基本操作
创建select对象:$select=new IO::Select();
添加socket到select中:$select->add($new_socket);
从select中删除socket:$select->remove($old_socket);
从select中查找可读的socket:@readable=$select->can_read;
找出select中的所有socket:@sockets=$select->handles;
Daemon实现方法
实现一个后台进程需要完成一系列的工作,包括
· 关闭所有的文件描述字
· 改变当前工作目录
· 重设文件存取屏蔽码(umask)
· 在后台执行
· 脱离进程组
· 忽略终端I/O信号
· 脱离控制终端
这些操作可以利用perl模块来简化:
use Proc::Daemon;
Proc::Daemon::Init;
pipe信号处理
如果客户关闭了socket以后,服务器继续发送数据,将会产生PIPE Signal,如果不加处理,就会导致服务器意外中断,为避免这一情况的发生,我们必须对它进行处理,一般情况下,只需要简单地忽略这个信号即可。
$SIG{‘PIPE’}=’IGNORE’;
意外处理
在Socket通信过程中很容易出现一些意外情况,如果不加处理直接发送数据,就可能导致程序意外退出。Perl语言中的eval函数可以用于意外处理。例如:
if (!defined(eval{操作语句;})){
错误处理;
}
这样当eval中的操作语句出现错误,如die的时候,只会中止eval语句,并不会中断主程序。
用户断线判断和处理
许多情况下,用户不是通过提交“离开”按钮离开聊天室,这时候就需要判断用户是否断线了。方法是:当用户关闭浏览器,或者点击了浏览器stop按钮,或者跳转到其他网页的时候,相对应的socket将会变成可读状态,而此时读出的数据却是空字符串。
利用这个原理,只要在某个可读的socket读取数据时,读到的却是空数据,那么我们就可以断定,与这个socket相对应的用户断线了。
防止用户断线
如果浏览器在一段时间内没有接到任何数据,那么就会出现超时错误。要避免这一错误,必须在一定间隔内发送一些数据,在我们这个应用系统里,可以发送一些html注释。发送注释的工作可以由在线名单刷新过程顺带完成。
下面我们来看看具体实现流程:
聊天服务器实现流程
· 服务器端
下图是NS盒图程序流程:
上图中的“处理用户输入”部分可以细化为下图:
用户数据输入都是通过URL传送,下面是几个url实例,结合后面客户端流程,可以更好地理解系统结构:
这是一个用户名密码均为’aaa’的聊天用户登录系统,说了一句话“hello”,然后退出所产生的一系列请求,其中密码用系统函数crypt加密过:
/login?name=aaa&passwd=PjHIIEleipsEE
/chat?sid=ZUyPHh3TWhENKsICnjOv&passwd=PjHIIEleipsEE
/talk?sid=ZUyPHh3TWhENKsICnjOv&passwd=PjHIIEleipsEE
/names?sid=ZUyPHh3TWhENKsICnjOv
/doTalk?sid=ZUyPHh3TWhENKsICnjOv&passwd=PjHIIEleipsEE&message=hello
/leave?sid=ZUyPHh3TWhENKsICnjOv&passwd=PjHIIEleipsEE
以上是服务器程序流程,下面我们从客户端看看具体登录过程。
我们先看看聊天界面:
聊天界面由三个frame组成,其中chat帧是聊天内容显示部分;talk帧是用户输入部分,包括聊天内容输入、动作、过滤以及管理功能都在这一帧输入;names是在线名单显示部分,这一部分是定时刷新的。
让我们从浏览器的角度来看看进入聊天室的过程。
· 首先浏览器请求页面
http://host:9148/login?name=NAME&passwd=PWD
此时产生了一个连接到服务器聊天端口的socket联接,并发送了一行数据:
GET /login?name=NAME&passwd=PWD HTTP/1.1
· 服务器生成一个session ID,验证密码以后,发回:
HTTP/1.1 200 OK
<其他头信息>
Content-TYPE: text/html
<空行>
<html>
……
<frameset cols="*,170" rows="*" border="1" framespacing="1">
<frameset rows="*,100,0" cols="*" border="0" framespacing="0">
<frame src="/chat?sid=$sid&passwd=$encrypt_pass" name="u" frameborder="NO" noresize>
<frame src="/talk?sid=$sid&passwd=$encrypt_pass" name="d" frameborder="NO" noresize>
</frameset>
<frame src="/names?sid=$sid" name="r" noresize>
</frameset>
……
</html>
然后服务器关闭socket联接。
· 浏览器收到以上html文件后,将会依次打开三个联接(其中的$sid和$encrypt_pass是变量):
/chat?sid=$sid&passwd=$encrypt_pass /talk?sid=$sid&passwd=$encrypt_pass
/names?sid=$sid
这三个联接中的第一个联接chat在整个聊天过程中都是保持联接的,这样从浏览器角度来看,就是一个始终下载不完的大页面,显示效果上就是聊天内容不是靠 刷新来更新,而是不断地向上滚动。通过察看html代码可以看到,只有<html><body>,然后就是不断增加的聊天内容, 没有</body></html>。
另外两个联接在页面发送完毕以后,socket就关闭了。
这样一次登录聊天室实际上有四次socket联接,但登录完成以后,只有chat帧的socket是保持联接的,用于接收来自服务器的聊天信息,这是socket聊天室的关键所在。
在服务器端储存了所有参加聊天的客户的chat socket,当有人发言时,服务器就向所有chat socket发送聊天内容。
Talk与names帧的html实际上和普通的form是一样的。
· 在用户登录以后,服务器端保存了一张包括用户信息的表格。
在perl实现中,我们使用哈希结构储存信息,以session ID作为key索引。这样的存储结构便于存取数据,回收空间。每个客户信息是一个数组:
[socket,name,passwd,privilige,filter,login_time,color]
socket:储存chat帧socket联接
name:用户名
passwd:密码
privilige:权限
filter:某个用户的过滤列表的引用(reference)
login_time:记录登录时间,以便以后清除一些超时联接
color:用户聊天颜色
以上用户数据大部分是在login阶段,用户通过密码验证以后填入的。只有chat socket要等到chat帧显示以后才得到。如果超过一定时间,socket还是没有填入,说明浏览器取得主框架以后连接中断了,这时候就需要删除该用户数据。
以上是聊天室核心部分,其他部分,如用户注册、改密码等可以沿用CGI聊天室代码。
需要改进的地方
目前提供了聊天、悄悄话、动作这些基本聊天功能以及过滤用户名单这样的附加功能。管理功能完成了踢人、查IP、任命室主。今后需要改进的地方有:
稳定性:目前聊天室还没有经过大用户量测试,稳定性还不能充分保证。由于是单进程循环服务器,某个用户通信死锁将导致所有人死锁。如果采用并发多进程服务器,可以使稳定性得到提高。但这样的系统对服务器资源消耗也会大许多。
功能:自建聊天室等功能还没有完成,这些外围功能在稳定性有保证以后就可以比较容易地加入。
[参考内容]
1. 本文所述的聊天室的最初结构来自于Entropy Chat 2.0(http://missinglink.darkorb.net/pub/entropychat/),如果没有它的启示,完成这一系统会有许多 困难,非常感谢他们的努力工作,愿意共同完善这个程序的朋友们,可以到http://tucows.qz.fj.cn/chat下载源代码。
2. http的基本交互过程请参考
HTTP Made Really Easy(http://www.jmarshall.com/easy/http/),RFC1945:Hypertext Transfer Protocol — HTTP/1.0
3. 本文所提到的perl模块,都可以在http://tucows.qz.fj.cn找到,请使用页面上方的搜索功能搜索。
IO::Socket和IO::Select是perl标准模块,也可以通过安装IO-1.20.tar.gz得到。
Proc:Daemon需要另外安装,模块为Proc-Daemon-0.02.tar.gz
上述模块版本号可能有所不同,搜索时只要输部分关键字如:”Daemon”即可找到。
4. 为加快开发过程,程序的界面部分参考了网易聊天室(http://chat.163.net/),程序的很多想法也来自于他们的工作。
5. 《How to Write a Chat Server》可以作为一个很好的参考
http://hotwired.lycos.com/webmonkey/97/18/index2a.html
6. 需要测试聊天室功能可以到http://tucows.qz.fj.cn/chat;
7. socket编程参考
· Unix Socket FAQ(http://www.ntua.gr/sock-faq/)
· Beejs Guide to Network Programming
(http://www.ecst.csuchico.edu/~beej/guide/net/