用Tornado打造WebSocket与Ajax Long-Polling自适应聊天室

标签: tornado websocket ajax | 发表时间:2011-09-30 04:17 | 作者:keakon satan
出处:http://www.keakon.net/
这几天忙着研究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,毕竟它除了兼容性以外,没有其他缺点了。不但性能更好,限制更少,实现起来也更加轻松。

相关 [tornado websocket ajax] 推荐:

用Tornado打造WebSocket与Ajax Long-Polling自适应聊天室

- satan - keakon的涂鸦馆
这几天忙着研究Tornado,想着总得学以致用吧,于是就决定做个聊天室玩玩. 实际上在Tornado的源码里就有chat和websocket这2个聊天室的demo,分别采用Ajax Long-Polling和WebSocket技术构建. 而我要实现的则很简单:将这2种技术融合在一起. 就技术而言,WebSocket的通信开销很少,没有连接数的限制,但由于本身比较新,支持它的浏览器并不多(目前仅有Chrome 6+、Safari 5.0.1+、Firefox 4+和Opera 11+,且Firefox和Opera还因安全原因默认禁用了).

反向Ajax,第2部分:WebSocket

- KnightE - 译言-电脑/网络/数码科技
来源Reverse Ajax, Part 2: WebSockets. 时至今日,用户期待的是可通过web访问快速、动态的应用. 这一文章系列展示了如何使用反向Ajax(Reverse Ajax)技术来开发事件驱动的web应用. 系列的第1部分介绍了反向Ajax、轮询(polling)、流(streaming)、Comet和长轮询(long polling).

WebSocket实战

- - 新浪UED
互联网发展到现在,早已超越了原始的初衷,人类从来没有像现在这样依赖过他;也正是这种依赖,促进了互联网技术的飞速发展. 而终端设备的创新与发展,更加速了互联网的进化;. WebSocket的前世今生. 为什么使用WebSocket. 搭建WebSocket服务器. 以上六点分为两大块,前3点侧重理论,主要让大家明白WebSocket是什么,而后3点则结合代码实战,加深对WebSocket的认知.

tomcat7之websocket

- - ITeye博客
从tomcat7.0.26之后开始支持websocket,建议大家使用tomcat7.0.30,期间版本的接口有一些改动. chrome默认支持websocket. 其他浏览器可能由于安全原因,默认是关闭的. // 与7.0.27不同的,Tomcat改变了createWebSocketInbound方法的定义,增加了一个HttpServletRequest参数.

基于Tomcat的WebSocket

- - ITeye博客
之前大概的看过WebSocket,当时Tomcat还不支持WebSocket,所以当时写了一篇基于Jetty的WebSocket实现,地址如下:. 现在Tomcat7.0.27发布了,从这个版本开始Tomcat就支持WebSocket了. Tomcat的WebSocket和Jetty的大致上差不多,大同小异,这里就简单的贴两个类吧:.

原生AJAX

- - Web前端 - ITeye博客
对象是ajax的基础,几乎所有的浏览器都支持他,只是创建方式不同,如IE5,IE6. 2、AJAX - 向服务器发送请求请求. 与 POST 相比,GET 更简单也更快,并且在大部分情况下都能用. 然而,在以下情况中,请使用 POST 请求:. 无法使用缓存文件(更新服务器上的文件或数据库). 向服务器发送大量数据(POST 没有数据量限制).

初识Ajax

- - CSDN博客推荐文章
Ajax(Asynchronous JavaScript and XMLS异步JavaScript和XML)(“阿贾克斯”)技术. 完成页面的局部刷新,从而提升操作性能. AJAX 不是一种新的编程语言,而是一种用于创建更好更快以及交互性更强的 Web 应用程序的技术. 依赖的核心对象:XMLHttpRequest.

#Tornado# 文档翻译中文版

- sasiky - python.cn(jobs, news)
内容索引 Table of Contents. 4.1   请求处理和请求参数. 4.2   RequestHandler中的主要方法. 4.4   Cookies和安全Cookies. 4.6   跨站伪造请求的防范. 4.7   静态文件和主动式文件缓存. 4.10   非阻塞式的异步请求. 7   WSGI 和 Google AppEngine.

Tornado 的 IOStream 简介与应用

- Ken - python.cn(jobs, news)
Tornado的核心源码是由ioloop.py和iostream.py这2个文件组成的. 前者提供了一个循环,用于处理I/O事件;后者则封装了一个非阻塞的socket. 有了这2者后,就能搭建起TCP server和HTTP server,实现异步HTTP客户端,这便是Tornado的主要内容了. 之前在研究socket时已差不多弄懂了ioloop的逻辑,于是本文就接着研究iostream了.

Torrent Tornado:浏览器内 BT 下载

- - LinuxTOY
Torrent Tornado 是一款完全使用 JavaScript 实现的附加组件,可以为 Firefox 浏览器增加 BT 下载功能. 体积小巧(不到 100K),完全使用 JavaScript 实现,跨平台且无本地二进制依赖. 支持和磁力链接及种子文件关联. 注意 当前 1.0 版本仅支持下载,不支持上传.