Tornado 搭建基于 WebSocket 的聊天服务

这年头 Python web 框架是有点泛滥了. 下面要介绍的是 facebook 的开源框架 tornado. 这东西比较简单, 而且自带 WebSocket 支持, 可以用它做个简单的聊天室.

读者最好已具备 Javascript 与 WebSocket 的基础知识.

安装

使用 easy_install 能很方便地爬到 tornado. 或者, 下载源代码, 解包后在源码目录执行

$ python setup . py build 
# python setup.py install

即可. 开张 首先还是来个 hello world.

import tornado.web
import tornado.ioloop
 
class Index(tornado.web.RequestHandler):
    def get(self):
        self.write('<html><body>Hello, world!')
 
if __name__ == '__main__':
    app = tornado.web.Application([
        ('/', Index),
    ])
    app.listen(8000)
    tornado.ioloop.IOLoop.instance().start()

保存为 main.py, 然后执行

$ python main.py

并访问 http://localhost:8000/ 即可看到页面中的 "Hello, world!".

在分支中定义的app在构造时接受的一个列表参数

[ 
     ( '/' ,   Index ), 
]

用来配置 URL 映射, 比如这里访问根路径则映射至Index实例去处理, 在Index实例中, 定义的get方法将会处理请求. 处理 WebSocket 连接 添加请求处理类

接下来就进入 WebSocket 环节. 先修改返回的页面, 让这个页面在加载后连接服务器.

class Index(tornado.web.RequestHandler):
    def get(self):
        self.write('''
<html>
<head>
<script>
var ws = new WebSocket('ws://localhost:8000/soc');
ws.onmessage = function(event) {
    document.getElementById('message').innerHTML = event.data;
};
</script>
</head>
<body>
<p id='message'></p>
        ''')

修改这个类后, 然后在控制台中止服务器 (猛击 Ctrl-C), 并重新启动之. 现在, 访问 http://localhost:8000/ 会遇到 404 错误, 因为 WebSocket 请求的 URL "ws://localhost:8000/soc" 还没有映射任何处理器, 因此这里需要再添加一个, 用于处理 WebSocket 请求的类.

import tornado.websocket
 
class SocketHandler(tornado.websocket.WebSocketHandler):
    def open(self):
        self.write_message('Welcome to WebSocket')

并为这个类加上 URL 映射

if __name__ == '__main__':
    app = tornado.web.Application([
        ('/', Index),
        ('/soc', SocketHandler),
    ])
    app.listen(8000)
    tornado.ioloop.IOLoop.instance().start()

然后重启服务器, 并访问 http://localhost:8000/ 就可以在页面上看到服务器传来的信息了. 使用模板

在进一步完善聊天功能之前, 先整理一下代码. 让大坨的 HTML 出现在 Python 源码文件中显然是件不合适的事情. 使用render函数可以处理模板 HTML 文件并传递给客户端.

class Index(tornado.web.RequestHandler):
    def get(self):
        self.render('templates/index.html')

然后把先前的 HTML 内容放入 templates 目录下的 index.html 文件中. 再重启服务器, 这样就将 HTML 内容分离出去了. 管理连接者

接下来要做的一件事情是记录客户端的连接. 在SocketHandler类里面放置一个集合, 用来记录开启着的连接.

class SocketHandler(tornado.websocket.WebSocketHandler):
    clients = set()
 
    def open(self):
        self.write_message('Welcome to WebSocket')
        SocketHandler.clients.add(self)
 
    def on_close(self):
        SocketHandler.clients.remove(self)

然后再改改, 安排每个连接者给其它连接者打个招呼.

class SocketHandler(tornado.websocket.WebSocketHandler):
    clients = set()
 
    @staticmethod
    def send_to_all(message):
        for c in SocketHandler.clients:
            c.write_message(message)
 
    def open(self):
        self.write_message('Welcome to WebSocket')
        SocketHandler.send_to_all(str(id(self)) + ' has joined')
        SocketHandler.clients.add(self)
 
    def on_close(self):
        SocketHandler.clients.remove(self)
        SocketHandler.send_to_all(str(id(self)) + ' has left')

再把首页给改改

<html>
<head>
<script>
var ws = new WebSocket('ws://localhost:8000/soc');
ws.onmessage = function(event) {
    var table = document.getElementById('message');
    table.insertRow().insertCell().innerHTML = event.data;
};
</script>
</head>
<body>
<table id='message'></table>

再来一发, 多开浏览器标签页访问网站, 那么先进入的则会看到后连上的客户端连入信息. (刷新页面则会产生一次离开一次进入.) 聊天功能

更换协议

之前, WebSocket 处理程序都是直接将字符串写回给客户端, 这样的问题是, 客户端很难区分是聊天信息还是系统信息. 下面规定一个简单的通信协议.

传递给客户端的将是一个 JSON 字典
字典中至少包含键 "type"
当有用户连接或离开聊天室时, "type" 对应的值为 "sys", 并且字典中还将包含键 "message", 值为连接或离开的信息
当有用户输入聊天信息时, "type" 对应的值为 "user", 且字典中还将包含键 "id" 对应聊天用户的 id, 以及键 "message" 表示聊天内容
用户输入的聊天信息为字符串

现在按照这个协议来修改 HTML

ws.onmessage = function(event) {
    var table = document.getElementById('message');
    var data = eval('(' + event.data + ')');
    ({
        'sys': function() {
            var cell = table.insertRow().insertCell();
            cell.colSpan = 2;
            cell.innerHTML = data['message'];
        },
        'user': function() {
            var row = table.insertRow();
            row.insertCell().innerHTML = data['message'];
            row.insertCell().innerHTML = data['id'];
        },
    }[data['type']])();
};

然后改SocketHandler(这里需要用到 json 库)

import json
 
# ...
 
@staticmethod
def send_to_all(message):
    for c in SocketHandler.clients:
        c.write_message(json.dumps(message))
 
def open(self):
    self.write_message(json.dumps({
        'type': 'sys',
        'message': 'Welcome to WebSocket',
    }))
    SocketHandler.send_to_all({
        'type': 'sys',
        'message': str(id(self)) + ' has joined',
    })
    SocketHandler.clients.add(self)
 
def on_close(self):
    SocketHandler.clients.remove(self)
    SocketHandler.send_to_all({
        'type': 'sys',
        'message': str(id(self)) + ' has left',
    })

聊天能力, 展开!

这样每次有连接新加入或者断开, 其它页面都可以接收到消息了. 现在要做的则是接受用户聊天信息. 那么要添加一些小玩意儿到 HTML 来具备发信能力.

<script>
/* ... */
function send() {
    ws.send(document.getElementById('chat').value);
    document.getElementById('chat').value = '';
}
</script>
</head>
<body>
<input id='chat'><button onclick='send()'>Send</button>
<table id='message' border='1'></table>

再为SocketHandler加上消息处理函数

class SocketHandler(tornado.websocket.WebSocketHandler):
    # ...
 
    def on_message(self, message):
        SocketHandler.send_to_all({
            'type': 'user',
            'id': id(self),
            'message': message,
        })

现在重启, 然后访问 http://localhost:8000/, 一个简易的聊天室就弄好了.

Relative Articles