回顾 WebSocket
框架班的学习和练习
什么是 WebSocket
定义
WebSocket一种在单个 TCP 连接上进行全双工通讯的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并被RFC7936所补充规范。WebSocket API也被W3C定为标准。
WebSocket提供了客户端和服务端之间的全双工跨域通信,通过客户端和服务端之间建立WebSocket连接,在同一时刻能够实现客户端到服务器和服务器到客户端的数据发送。
基本操作
(1). 开启连接
(2). 客户端给服务器端发送数据
(3). 服务器端接收数据
(4). 服务器端给客户端发送数据
(5). 客户端接收数据
优点
WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
和 HTTP 对比
传统 HTTP 客户端与服务器的请求-响应模式 对比 WebSocket 模式
适用场景
实时响应的应用
- 聊天室
- 通知等
限制:
不是所有浏览器都支持
WebSocket 关键方法
tornado 的 WebSocketHandler
- open 客户端连接成功时,自动调用
- on_message 客户端连发送消息时,自动调用
- on_close 客户端关闭连接时,自动调用
浏览器客户端
- ws = new WebSocket(url)
- ws.onmessage 接受服务端发来的信息
- ws.send() 发信息给服务端
- 其他 ws.onopen, ws.onerror
客户端
文档全部加载完成的事件,也就是:
$(document).ready(function (){
}
因为并不是所有版本的浏览器都能够支持WebSocket,所以通过下面代码来兼容
// 创建空console对象,避免JS报错
// 兼容Firefox/IE使用console.log
if (!window.console)
window.console = {};
if (!window.console.log)
window.console.log = function () {
};
实现发送表单:messageform的submit事件:
$("#messageform").on("submit", function () { // 点击提交时执行
newMessage($(this)); // 发送新消息给服务器
return false;
});
一旦有message提交了,立马执行newMessage函数,也就是给服务器发消息
下面同样的作用,只不过是监控keyCode == 13的按键,也就是我们键盘上的enter键
$("#messageform").on("keypress", function (e) { // 回车提交时执行
if (e.keyCode == 13) {
newMessage($(this)); // 发送新消息给服务器
return false;
}
});
其中的newmessage()
函数实现如下:
// 发送新消息给服务器
function newMessage(form) {
var message = form.formToDict(); // 将提取的数据转化成字典 {body: "2"}
updater.socket.send(JSON.stringify(message)); // 向服务器发送json形式的新的消息 {"body":"2"}
$("input[name='body']").val("").select(); //清空并选中输入框
}
webSocket.send()
向服务器发送数据。
就是向服务器以json
的格式,发送一个新的消息,其中的formToDict 函数实现如下:
// 将提取的数据转化成字典
jQuery.fn.formToDict = function () {
var fields = this.serializeArray(); // [{name: "body", value: "2"}]
var json = {};
for (var i = 0; i < fields.length; i++) {
json[fields[i].name] = fields[i].value; // json["body"]="2"
}
if (json.next)
delete json.next;
return json; // {body: "2"}
};
作用是把表单中所有的输入保存到json对象中去,最后返回客户端要发给服务器的消息字典,也是一个json对象。
serializeArray()
方法序列化表单元素,返回 JSON 数据结构数据。
注意:此方法返回的是 JSON 对象而非 JSON 字符串。需要使用插件或者第三方库进行字符串化操作。
返回的 JSON 对象是由一个对象数组组成的,其中每个对象包含一个或两个名值对 —— name 参数和 value 参数(如果 value 不为空的话)。举例来说:
[
{name: 'firstname', value: 'Hello'},
{name: 'lastname', value: 'World'},
{name: 'alias'}, // 值为空
]
另外我这里设置了一个,一旦选中了我们之前html文件里定义的id为message编辑框的控件,就开始发起一个连接,动作如下:
$("#message").select();
updater.start(); // 开始 WebSocket
其中,start()内容大致为:
start: function () {
var url = "ws://" + location.host + "/ws"; //ws://127.0.0.1:8000/ws
updater.socket = new WebSocket(url); // 初始化 WebSocket 客户端与服务器建立连接
updater.socket.onmessage = function (event) { // 获取到服务器的信息时响应
updater.showMessage(JSON.parse(event.data));//信息展示在页面上
}
},
这里一旦选中了编辑框,客户端js代码就开始新建一个websocket连接,其中url 就是我们服务器的地址,并且设置了我们的onmessage()
函数,也就是响应服务器消息的函数,其内容大致如下:
showMessage: function (message) {
var existing = $("#m" + message.id);
if (existing.length > 0)
return;
var node = $(message.html);
node.hide();
$("#inbox").append(node); // 添加消息 DIV 到页面
node.slideDown();
}
先选中了消息,为新收到的消息建立一个新的节点node,先隐藏消息(因为发送出去要清空的啊),然后把消息添加到我们的html文件中的inbox标签的尾部,进行显示,inbox标签内容如下:
<div class="inbox">
{% for message in messages %}
{% include "message.html" %}
{% end %}
</div>
WebSocket 对象作为一个构造函数,用于新建 WebSocket 实例。
var ws = new WebSocket('ws://127.0.0.1:8000/ws');
执行上面语句之后,客户端就会与服务器进行连接。
对于客户端,主要就是updater这个对象,该对象会创建并维护了一个WebSocket对象,通过这个WebSocket对象就可以跟服务端进行交互(收取或发送消息)。
var updater = {
socket: null,
start: function () {
var url = "ws://" + location.host + "/ws"; //ws://127.0.0.1:8000/ws
updater.socket = new WebSocket(url); // 初始化 WebSocket 客户端与服务器建立连接
// 获取到服务器的信息时响应
updater.socket.onmessage = function (event) {
updater.showMessage(JSON.parse(event.data));//信息展示在页面上
}
},
showMessage: function (message) {
var existing = $("#m" + message.id);
if (existing.length > 0)
return;
var node = $(message.html);
// node.hide();
$("#inbox").append(node); // 添加消息 DIV 到页面
// node.slideDown();
}
};
服务端
服务端通过ChatSocketHandler
这个类来管理所有的消息,以及所有的WebSocket client。由于WebSocket是一种长连接的方式,所以可以很容易的统计出当前在线的client的数量。
class ChatSocketHandler(tornado.websocket.WebSocketHandler):
"""
处理响应websocket连接
"""
waiters = set() # 等待接受信息的用户
cache = [] # 存放历史信息的列表
cache_size = 200 # 消息列表的大小
def open(self, *args, **kwargs):
"""新的websocket连接打开时 自动调用此函数"""
print('new connection:%s' % self)
ChatSocketHandler.waiters.add(self)
def on_close(self):
"""websocketa连接关闭时 自动调用此函数"""
print('close connection: %s' % self)
ChatSocketHandler.waiters.remove(self)
@classmethod
def update_cache(cls, message):
"""# 更新历史消息列表 加入新的消息"""
cls.cache.append(message)
if len(cls.cache) > cls.cache_size:
cls.cache = cls.cache[-cls.cache_size:]
@classmethod
def send_updates(cls, chat):
"""# 向所有在线用户发送消息"""
for waiter in cls.waiters:
waiter.write_message(chat)
def on_message(self, message):
"""websocket服务端接收到消息 自动调用此函数"""
print('got message %s' % message) # got message {"body":"2"}
parsed = tornado.escape.json_decode(message) # {"body":"2"}
chat = {
"id": str(uuid.uuid4()),
"body": parsed["body"],
}
chat["html"] = tornado.escape.to_basestring(
self.render_string("message.html", message=chat))
ChatSocketHandler.update_cache(chat)
ChatSocketHandler.send_updates(chat)
当客户端发起"/ws"请求后,服务器就会跟客户端建立连接,并将客户端加入ChatSocketHandler.waiters
集合中;当客户端断开连接,就会将客户端从ChatSocketHandler.waiters
集合中移除。
当服务器收到新消息后,自动调用on_message()
函数,再通过ChatSocketHandler.send_updates()
方法,将新消息推送到所有的客户端。
其中write_message()
方法用于向客户端发送消息
用户发送的消息会以json的格式收到 {“body”:“2”},所以要先解析 tornado.escape.json_decode(),然后提取其中的信息 parsed[“body”],包装成我们自己的chat字典,然后群发:
parsed = tornado.escape.json_decode(message) # {"body":"2"}
chat = {
"id": str(uuid.uuid4()),
"body": parsed["body"],
}
广播消息也就是向在线的每一个用户,使用websocket的功能发送一次消息
历史记录的实现
可以缓存200条消息记录放在一个列表中(列表中每一项是一个chat的字典类型),每次新用户访问就把这个列表发给他,让他也能看到历史记录,每当记录满了,那就截取最后200条保存下来
cache = [] # 存放历史信息的列表
cache_size = 200 # 消息列表的大小
if len(cls.cache) > cls.cache_size:
cls.cache = cls.cache[-cls.cache_size:]
历史消息的显示格式,历史消息也是新用户上线的时候服务器在渲染模板html的时候加入的参数,html模板中大致以如下格式进行渲染:
<div class="inbox">
{% for message in messages %}
{% include "message.html" %}
{% end %}
</div>
聊天界面显示的每一条消息,按格式包装起来会更方便,所以每条消息就以一个格式包装与显示:
<div class="message" id="m{{ message["id"] }}">
{% module linkify(message["body"]) %}
</div>
最后就是我们的消息内容输入与发送,这一部分需要服务器JavaScript代码把我们用户输入的消息接收处理,然后发给我们的服务器,服务器进行解析后给与响应,html格式大致如下:
<form action="/a/message/new" method="post" id="messageform">
<table>
<tr>
<td><input name="body" id="message" style="width:500px"></td>
<td style="padding-left:5px">
<input type="submit" value="提交">
<input type="hidden" name="next" value="{{ request.path }}">
</td>
</tr>
</table>
</form>
作业
运行起来 WebSocket
code
app.py
import tornado.web
import tornado.options
import tornado.ioloop
from tornado.options import define, options
from handlers import main,auth,chat
define(name='port', default='8000', type=int, help='run port')
class Application(tornado.web.Application):
def __init__(self):
handlers = [
(r'/', main.IndexHandler),
(r'/explore', main.ExploreHandler),
(r'/post/(?P<post_id>[0-9]+)', main.PostHandler),
(r'/upload', main.UploadHandler),
(r'/profile', main.ProfileHandler),
(r'/login', auth.LoginHandler),
(r'/logout', auth.LogoutHandler),
(r'/signup', auth.SignupHandler),
(r'/room', chat.RoomHandler),
(r'/ws', chat.ChatSocketHandler),
]
settings = dict(
debug=True,
template_path='templates',
static_path='static',
login_url='/login',
cookie_secret='bZJc2sWbQLKos6GkHn/VB9oXwQt8S0R0kRvJ5/xJ89E=',
pycket={
'engine': 'redis',
'storage': {
'host': 'localhost',
'port': 6379,
# 'password': '',
'db_sessions': 5, # redis db index
'db_notifications': 11,
'max_connections': 2 ** 30,
},
'cookies': {
'expires_days': 30,
},
}
)
super(Application, self).__init__(handlers, **settings)
application = Application()
if __name__ == '__main__':
tornado.options.parse_command_line()
application.listen(options.port)
print("Server start on port {}".format(str(options.port)))
tornado.ioloop.IOLoop.current().start()
服务端chat.py
import tornado.web
import tornado.websocket
from .main import AuthBaseHandler
import uuid
class RoomHandler(AuthBaseHandler):
"""
聊天室页面
"""
def get(self, *args, **kwargs):
self.render('room.html', messages=ChatSocketHandler.cache)
class ChatSocketHandler(tornado.websocket.WebSocketHandler):
"""
处理响应websocket连接
"""
waiters = set() # 等待接受信息的用户
cache = [] # 存放历史信息的列表
cache_size = 200 # 消息列表的大小
def open(self, *args, **kwargs):
"""新的websocket连接打开时 自动调用此函数"""
print('new connection:%s' % self)
ChatSocketHandler.waiters.add(self)
def on_close(self):
"""websocketa连接关闭时 自动调用此函数"""
print('close connection: %s' % self)
ChatSocketHandler.waiters.remove(self)
@classmethod
def update_cache(cls, message):
"""# 更新历史消息列表 加入新的消息"""
cls.cache.append(message)
if len(cls.cache) > cls.cache_size:
cls.cache = cls.cache[-cls.cache_size:]
@classmethod
def send_updates(cls, chat):
"""# 向所有在线用户发送消息"""
for waiter in cls.waiters:
waiter.write_message(chat)
def on_message(self, message):
"""websocket服务端接收到消息 自动调用此函数"""
print('got message %s' % message) # got message {"body":"2"}
parsed = tornado.escape.json_decode(message) # {"body":"2"}
chat = {
"id": str(uuid.uuid4()),
"body": parsed["body"],
}
chat["html"] = tornado.escape.to_basestring(
self.render_string("message.html", message=chat))
ChatSocketHandler.update_cache(chat) # 更新历史消息列表
ChatSocketHandler.send_updates(chat) # 向所有在线用户发送消息
客户端chat.js
// 浏览器在页面加载完成后调用此方法
$(document).ready(function () {
// 创建空console对象,避免JS报错 //兼容Firefox/IE使用console.log
if (!window.console)
window.console = {};
if (!window.console.log)
window.console.log = function () {
};
$("#messageform").on("submit", function () { // 点击提交时执行
newMessage($(this)); // 发送新消息给服务器
return false;
});
$("#messageform").on("keypress", function (e) { // 回车提交时执行
if (e.keyCode == 13) {
newMessage($(this)); // 发送新消息给服务器
return false;
}
});
$("#message").select(); // 选中输入框
updater.start(); // 开始 WebSocket 连接服务器 获取信息 展示在页面上
});
// 发送新消息给服务器
function newMessage(form) {
var message = form.formToDict(); // 将提取的数据转化成字典 {body: "2"}
updater.socket.send(JSON.stringify(message)); // 向服务器发送json形式的新的消息 {"body":"2"}
$("input[name='body']").val("").select(); //清空并选中输入框
}
// 将提取的数据转化成字典
jQuery.fn.formToDict = function () {
var fields = this.serializeArray(); // [{name: "body", value: "2"}]
var json = {};
for (var i = 0; i < fields.length; i++) {
json[fields[i].name] = fields[i].value; // json["body"]="2"
}
if (json.next)
delete json.next;
return json; // {body: "2"}
};
var updater = {
socket: null,
start: function () {
var url = "ws://" + location.host + "/ws"; //ws://127.0.0.1:8000/ws
updater.socket = new WebSocket(url); // 初始化 WebSocket 客户端与服务器建立连接
// 收到服务器数据event.data后的回调函数
updater.socket.onmessage = function (event) {
updater.showMessage(JSON.parse(event.data));//信息展示在页面上
}
},
showMessage: function (message) {
var existing = $("#m" + message.id);
if (existing.length > 0)
return;
var node = $(message.html);
// node.hide();
$("#inbox").append(node); // 添加消息 DIV 到页面
// node.slideDown();
}
};
room.html
{% extends 'base.html' %}
{% block title %}
room page
{% end %}
{% block content %}
<div id="body">
<div id="inbox">
{% for message in messages %}
{% include "message.html" %}
{% end %}
</div>
<div id="input">
<form action="#" method="post" id="messageform">
<table>
<tr>
<td><input name="body" id="message" style="width:500px"></td>
<td style="padding-left:5px">
<input type="submit" value="提交">
<input type="hidden" name="next" value="{{ request.path }}">
</td>
</tr>
</table>
</form>
</div>
</div>
{% end %}
{% block extra_scripts %}
<script src="{{ static_url('js/chat.js') }}" type="text/javascript"></script>
{% end %}
message.html
<div class="message" id="m{{ message["id"] }}">
{% module linkify(message["body"]) %}
</div>
base.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ static_url('css/bootstrap.css') }}">
<link rel="stylesheet" href="{{ static_url('font-awesome-4.7.0/css/font-awesome.css') }}">
<title>{% block title %}base{% end %}</title>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">
<i class="fa fa-camera"></i>
Tudo 图片
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="/">首页<span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/explore">发现</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
用户中心
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="/profile">个人信息</a>
<a class="dropdown-item" href="#">收藏</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">Something else here</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="/logout">{{ current_user }}登出</a>
</li>
</ul>
<a class="btn btn-info" href="/upload">
<i class="fa fa-upload"></i>
上传
</a>
</div>
</nav>
<div class="container">
{% block content %}
base
{% end %}
</div>
<script src="{{ static_url('js/jquery-3.3.1.slim.min.js') }}"></script>
<script src="{{ static_url('js/popper.min.js') }}"></script>
<script src="{{ static_url('js/bootstrap.js') }}"></script>
{% block extra_scripts %}{% end %}
</body>
</html>