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

这几天忙着研究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,毕竟它除了兼容性以外,没有其他缺点了。不但性能更好,限制更少,实现起来也更加轻松。

你可能感兴趣的:(Ajax,tornado)