Tornado 与 WebSocket 实现实时聊天室

WebSocket 是用于浏览器与服务器之间进行双相连接的协议,可以用于创建基于浏览器的实时聊天工具。Tornado 自身支持 WebSocket 协议,也可以用来接收网站管理员的编辑指令。

根据官方文档,可以通过继承 tornado.websocket.WebSocketHandler 处理来自 WebSocket 协议的请求:

class EchoWebSocket(tornado.websocket.WebSocketHandler):
  def open(self):
    print("WebSocket opened")

  def on_message(self, message):
    self.write_message(u"You said: " + message)

  def on_close(self):
    print("WebSocket closed")

重载的 openon_messageon_close 方法分别用于处理连接创建时、接收信息时和连接关闭时的相关操作。对应的浏览器端操作为:

var ws = new WebSocket("ws://"+window.location.host+"/websocket");
ws.onopen = function() {
   ws.send("Hello, world");
};
ws.onmessage = function (evt) {
   console.log(evt.data);
};

首先需要创建聊天页面:

class ChatWebHandler(BaseController):
  def get(self):
    self.render("chat/index.html")
  def post(self):
    # 登录用户信息存入 Cookie
    uid = self.get_body_argument("uid")
    self.set_secure_cookie("uid", uid)
    self.redirect("/chat")
router = [
  (r'/chat', ChatWebHandler),
]

如果是多人聊天,需要保存所有的请求对象,并在有消息进入时更新所有连接:

from datetime import datetime
class EchoWebSocket(tornado.websocket.WebSocketHandler):
  pool = set()
  def open(self):
    EchoWebSocket.pool.add(self)
  def on_close(self):
    EchoWebSocket.pool.remove(self)
  def on_message(self, message):
    uid = self.get_secure_cookie("uid")
    uid = uid.decode() if uid is not None else "匿名"
    EchoWebSocket.update(dict(
      uid = uid,
      msg = message,
      t   = datetime.now().strftime("%m-%d %H:%M:%S")
    ))

  @classmethod
  def update(cls, msg):
    for chat in cls.pool:
      chat.write_message(msg)

router = [
  (r'/ws', WebSocketHandler), # WebSocket 地址为 ws://host/ws
  (r'/chat', ChatWebHandler)  # 聊天室地址为 http://host/chat
]

浏览器客户端接收和发送消息:

$(document).ready(function(){
  var input = $('#msgInput');
  var frame = $('#msgFrame');
  var wraper= $('#msgWraper');
  var ws    = new WebSocket("ws://" + window.location.host + "/ws");
  // 发送消息
  $('#msgForm').on('submit', function (e) {
    var msg = input.val();
    input.val('');
    ws.send(msg);
    return false;
  });
  // 接收消息
  ws.onmessage = function (evt) {
    var data = JSON.parse(evt.data);

    // 更新到界面
    frame.append($('<li style="margin-left:12px;"><b>'+ data.uid+': </b>'+ data.message + '<span style="float:right; margin-right:12px;">' + data.t +'</span></li>'))
    if (frame.height() >= wraper.height()) {
      wraper.scrollTop(frame.height());
    };
  };
});

chat

更新:异步处理

由于 tornado.websocket.WebSocketHandler 不支持异步请求,因此当需要与数据库进行交互的时候,例如保留聊天记录等,则只能通过同步阻塞的方式处理。而且若采用 Motor 这种异步驱动,也很难与 WebSocket 交互。解决方案是讲异步操作通过 IOLoop.current().spawn_callback 完成:

async def callback(self, msg, db):
  res = await self.db.do_with(msg)
  self.write_message(res)
def on_message(self, message):
  tornado.ioloop.IOLoop.current().spawn_callback(lambda : self.callback(db, message))

更新2:Nginx 反向代理设置

参考 官方文档

map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
}
 location /ws {
        proxy_pass http://pyhub_daily;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host 'daily.pyhub.cc';
    }

更新3:Nginx 连接超时问题

发现每个一分钟左右没有回复就会被服务器断开连接,经过搜索发现需要设定 Nginx 读取超时时间:

proxy_read_timeout 500;