這幾天忙着研究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,畢竟它除了兼容性之外,沒有其餘缺點了。不但性能更好,限制更少,實現起來也更加輕鬆。