这几天忙着研究Tornado,想着总得学以致用吧,于是就决定做个聊天室玩玩。
实际上在Tornado的源码里就有chat和websocket这2个聊天室的demo,分别采用Ajax Long-Polling和WebSocket技术构建。
而我要实现的则很简单:将这2种技术融合在一起。
当然,这样做并不是为了好玩。
就技术而言,WebSocket的通信开销很少,没有连接数的限制,但由于本身比较新,支持它的浏览器并不多(目前仅有Chrome 6+、Safari 5.0.1+、Firefox 4+和Opera 11+,且Firefox和Opera还因安全原因默认禁用了)。
而现代的浏览器中,只要能用JavaScript的,几乎都支持Ajax,连古老的IE 6都不例外。但与WebSocket相比,每次通信都需要传递header,这在小数据量的通信时显得很低效。
所以如果实现2种技术,根据浏览器的支持度来自动切换,自然是一种较好的方式。
其实还有通过Flash来模拟WebSocket的,不过我是很讨厌Flash的,于是就无视了。另外还有用iframe实现的,感觉比较影响用户体验,也无视。
考虑到通信开销,Ajax还需要与长连接技术搭配,以避免客户端盲目地轮询,减少请求的数目。这里又存在一个问题:IE不支持在readyState为3时读取服务器返回的数据,也就是不支持streaming方式。虽说我历来就无视IE,但jQuery封装的ajax函数也不支持streaming方式,让我去写原生的Ajax代码太麻烦了,于是只好采用long-polling方式了。
那么streaming和long-polling的差别在哪呢?
它们都是由客户端发起请求,服务器并不急于返回响应,等到事件发生后,才输出响应。
这时候,streaming方式并不关闭连接,因此服务器可以在未来的任意时刻继续发送响应;同时,客户端也会捕捉到这个响应事件,只不过readyState为3。
而如果用long-polling方式的话,服务器发送完响应就关闭连接;此时客户端检测到readyState为4,不存在兼容性问题;然后客户端再次发起Ajax请求,进入下一个轮回。
由此可见,long-polling方式在断开连接和重新连接时会存在时间差,因此如果不保存这段期间的事件的话,未连上的客户端就不会接收到。此外,重新连接也就意味着更多的通信开销——TCP 3次握手和发送header。
值得一提的是,即使是streaming方式,因为服务器端阻塞了响应,客户端的更新需要通过另一个Ajax请求来完成。而WebSocket没有这个限制,客户端可以随时用它发送数据。
此外,HTTP 1.1还规定了客户端不应该与服务器端建立超过2个的HTTP连接,否则新连接会被阻塞。这也就意味着如果一个浏览器与一个服务器建立了2个长连接(无论是在一个页面中,还是2个窗口或标签中),那么就无法发起新请求了,包括Ajax请求和打开页面。这对streaming和long-polling来说都是一个不小的限制。
那么HTTP 1.0是怎样规定的呢?答案就是发送完了响应就必须关闭连接,因此streaming也被枪毙了。
而WebSocket采用的是WebSocket协议,并没有规定连接数的限制。
原理介绍完了,就该开工了,首先来实现WebSocket:
import logging
import os.path
import uuid
import tornado.httpserver
import tornado.ioloop
import tornado.options
import tornado.web
import tornado.websocket
def send_message(message):
for handler in ChatSocketHandler.socket_handlers:
try:
handler.write_message(message)
except:
logging.error('Error sending message', exc_info=True)
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.render('index.html')
class ChatSocketHandler(tornado.websocket.WebSocketHandler):
socket_handlers = set()
def open(self):
ChatSocketHandler.socket_handlers.add(self)
send_message('A new user has entered the chat room.')
def on_close(self):
ChatSocketHandler.socket_handlers.remove(self)
send_message('A user has left the chat room.')
def on_message(self, message):
send_message(message)
def main():
settings = {
'template_path': os.path.join(os.path.dirname(__file__), 'templates'),
'static_path': os.path.join(os.path.dirname(__file__), 'static')
}
application = tornado.web.Application([
('/', MainHandler),
('/new-msg/', ChatHandler),
('/new-msg/socket', ChatSocketHandler)
], **settings)
http_server = tornado.httpserver.HTTPServer(application)
http_server.listen(8000)
tornado.ioloop.IOLoop.instance().start()
if __name__ == '__main__':
main()
看上去很简单。实际上Tornado提供了tornado.websocket.WebSocketHandler这个类,因此只需要实现open、on_close和on_message这3个方法就行了。
而我在open()的时候保存了handler,它与建立好的WebSocket是一一对应的关系,所以在发送信息时,只需要遍历ChatSocketHandler.socket_handlers就行了。
简单起见,我就没有保存信息队列了。这在WebSocket方式中并没有问题,但Ajax Long-Polling方式会存在丢失事件的风险,所以如果要完善这个demo的话,这里需要特别注意。
接着看客户端的代码,简单起见我就没去管样式什么的了:
<!DOCTYPE html>
<html>
<head>
<title>chat demo</title>
</head>
<body>
<form action="/new-msg/" method="post">
<textarea id="text"></textarea>
<input type="submit"/>
</form>
<div id="msg"></div>
<script src="{{ static_url('jquery-1.6.4.js') }}"></script>
<script src="{{ static_url('chat.js') }}"></script>
</body>
</html>
chat.js:
(function() {
var $msg = $('#msg');
var $text = $('#text');
var WebSocket = window.WebSocket || window.MozWebSocket;
if (WebSocket) {
try {
var socket = new WebSocket('ws://localhost:8000/new-msg/socket');
} catch (e) {}
}
if (socket) {
socket.onmessage = function(event) {
$msg.append('<p>' + event.data + '</p>');
}
$('form').submit(function() {
socket.send($text.val());
$text.val('').select();
return false;
});
}
})();
同样是简单到不行了,创建一个WebSocket对象,然后实现onmessage方法即可获取服务器端的更新,发送数据则用send方法。此外还有onopen、onclose、onerror和close方法,都顾名思义而无需解释。
接着实现Ajax Long-Polling,它使用的是普通的tornado.web.RequestHandler类。
class ChatHandler(tornado.web.RequestHandler):
callbacks = set()
users = set()
@tornado.web.asynchronous
def get(self):
ChatHandler.callbacks.add(self.on_new_message)
self.user = user = self.get_cookie('user')
if not user:
self.user = user = str(uuid.uuid4())
self.set_cookie('user', user)
if user not in ChatHandler.users:
ChatHandler.users.add(user)
send_message('A new user has entered the chat room.')
def on_new_message(self, message):
if self.request.connection.stream.closed():
return
self.write(message)
self.finish()
def on_connection_close(self):
ChatHandler.callbacks.remove(self.on_new_message)
ChatHandler.users.discard(self.user)
send_message('A user has left the chat room.')
def post(self):
send_message(self.get_argument('text'))
这里我用get来获取更新,post来发送信息。其中获取更新需要阻塞,因此要用@tornado.web.asynchronous修饰。
和WebSocket不同的是,这次我保存的是callback,而非handler。
由于每次广播信息都需要断开和重新连接,我就不能直接在get时判定用户有新用户进入。而我又懒得让客户端发送用户标识,于是就直接在cookie中进行设置了。这个cookie是session类型,本站的所有窗口关闭后就实效,再次打开就会生成一个新的,正好符合我的需求。
而send_message也需要兼容新方式:
def send_message(message):
for handler in ChatSocketHandler.socket_handlers:
try:
handler.write_message(message)
except:
logging.error('Error sending message', exc_info=True)
for callback in ChatHandler.callbacks:
try:
callback(message)
except:
logging.error('Error in callback', exc_info=True)
ChatHandler.callbacks = set()
最后是客户端:
if (socket) {
// ...
} else {
var error_sleep_time = 500;
function poll() {
$.ajax({
url: '/new-msg/',
type: 'GET',
success: function(event) {
$msg.append('<p>' + event + '</p>');
error_sleep_time = 500;
poll();
},
error: function() {
error_sleep_time *= 2;
setTimeout(poll, error_sleep_time);
}
});
}
poll();
$('form').submit(function() {
$.ajax({
url: '/new-msg/',
type: 'POST',
data: {text: $text.val()},
success: function() {
$text.val('').select();
}
});
return false;
});
}
稍微比WebSocket复杂一点,不过还是很容易理解的。
试验一番后发现,WebSocket方式工作非常正常,只不过Chrome的调试控制台没法看到传输的数据。
而Ajax Long-Polling方式在打开2个标签时出现异常,只有1个标签能接收到更新,但发送新信息的请求并没被阻塞。
最后还得赞一句Tornado,对长连接的支持非常好,短短几行代码就能完成想要的功能。
此外还希望越来越多的客户端和服务器能够支持WebSocket,毕竟它除了兼容性以外,没有其他缺点了。不但性能更好,限制更少,实现起来也更加轻松。