僅供學習, 歡迎指正~css
最近在研究 WebSocket
, 就用 Flask-SocketIO
寫了個 匿名聊天
的 Demo
用來實現 點對點通訊
, 下面是實際效果:html
這個 Demo
會涉及到 前端
和 後端
的相關知識點, 不過都很簡單, 我都會一一將清楚.前端
SocketIO
說到 SocketIO
, 就要從 HTTP
請求提及. 傳統的基於 HTTP
的請求是 請求-響應
式的, 客戶端 請求
, 服務器 響應
, 一次通訊就結束了. 可是聊天程序須要服務器有 主動推送
的能力, 在一個用戶發送消息的時候, 服務器能夠主動將消息推送給另外一個用戶進行通訊, 這就須要服務器與客戶端之間保持鏈接, 可是 HTTP
沒法作到這一點.python
之前的解決方案可使用 輪詢
, 輪詢
分爲 長輪詢
和 短輪詢
, 長輪詢
是服務器收到請求後若是有數據就響應給客戶端, 若是沒有數據, 就將請求掛起, 等待有數據或者鏈接超時後再響應, 客戶端收到響應後再次發送請求, 並重復以上步驟; 而 短輪詢
則是客戶端每隔一段時間就發送一個請求, 服務器無論有無數據, 當即返回. 輪詢
的缺陷顯而易見, 不論是短輪詢的重複請求仍是長輪詢的請求掛起, 都會形成資源浪費, 並且服務器一直處於被動地位. 還有一種解決方案稱爲 SSE(Server-Sent Events)
, 在瀏覽器與服務器創建鏈接後, 就一直保持 長鏈接
, 服務器有數據就推送給瀏覽器, 但不關閉鏈接, 等待下次有數據後繼續推送, 這種通訊方式要優於輪詢, 但這隻能實現服務器 單向
推送, 瀏覽器沒法在同一個鏈接裏給服務器再次發送數據.jquery
以後出現了新的協議 WebSocket
解決了以上問題. WebSocket
是在單個 TCP
鏈接上, 實現了全雙工的通訊協議, 瀏覽器和服務器之間只須要完成一次握手就能夠保持鏈接. 鏈接後經過 觸發事件
進行通訊.git
而 SocketIO
對 WebSocket
進行了封裝, 用來兼容不支持 Websocket
的瀏覽器, 它將 WebSocket
及 其餘輪詢方式
所有封裝成了統一的通訊接口, 在執行的時候會自動選擇最佳的通訊方式. 同時它還提供了 廣播
、命名空間
、房間
等功能, 所以使用 SocketIO
通訊將會很是方便.github
Flask-SocketIO
pip install flask-socketio
複製代碼
├── app.py // 程序邏輯
├── static/ // 靜態文件
│ ├── css/ // css 文件
│ ├── imgs/ // 圖片
│ └── js/ // js 文件
└── templates/ // 模板文件
複製代碼
由於程序邏輯不復雜, 因此將程序全部邏輯寫在單個 app.py
中, static
文件夾用來放靜態文件, templates
文件夾用來放須要渲染的模板文件. 建立以上目錄結構後就能夠開始寫代碼了, 固然你也能夠按照本身的習慣建立目錄.web
import secrets
from flask import Flask
from flask_socketio import SocketIO
app = Flask(__name__)
app.secret_key = secrets.token_hex()
socketio = SocketIO(app)
複製代碼
因爲 Flask-SocketIO
內部使用了 Flask
的 session
對象, 因此須要設置程序的 secret_key
. 初始化以後, 就可使用 app
對象構建 HTTP
路由了, 而使用 socketio
對象就能夠展開以 事件
爲基礎的通訊, 具體下面會講到.shell
一般狀況下, 一個 Flask
程序的啓動方式有兩種, 一種使用 app.run()
, 這種已經被官方 廢棄
; 另外一種是使用 Flask
提供的命令行工具 flask run
啓動. 使用了 Flask-Socketio
後依然可使用 flask run
啓動應用, 由於它重寫了這條命令, 可是使用 flask run
命令只能使用 Flask
默認的 werkzeug
開發服務器. 這個服務器只支持 長輪詢
, 不支持 WebSocket
. 因此要想使用其餘支持 WebSocket
的服務器, 就須要使用 socketio.run()
方法.flask
SocketIO
推薦的異步服務器爲 eventlet
, 它原生支持 Websocket
, 性能最佳, gevent
也可使用, 可是須要使用額外的配置讓它支持 Websocket
. 在使用 socketio.run()
啓動應用時會優先先擇 eventlet
, 其次 gevent
, 再其次是 werkzeug
. 不過也能夠在建立 socketio
對象的時候手動指定, 下面是示例代碼:
socketio = SocketIO(app, async_mode='eventlet')
複製代碼
使用前須要先使用 pip install eventlet
安裝. 而後一個完整的初始化就是這樣子:
app = Flask(__name__)
app.secret_key = secrets.token_hex()
socketio = SocketIO(app, async_mode='eventlet')
if __name__ == '__main__':
socketio.run(app, debug=True)
複製代碼
聊天氣泡使用了 Github
中找到的 litewebchat.css
, 它提供了簡單易用的聊天樣式, 這裏是 地址; 輸入框
和 發送
按鈕使用 Bootstrap
完成. 爲方便起見, CSS
和 JS
庫都使用 CDN
導入. 在 templates
文件夾中新建一個文件 index.html
, 寫入如下代碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"/>
<title>匿名聊天室</title>
<link type="text/css" href="https://lab.morfans.cn/LiteWebChat_Frame/litewebchat.min.css" rel="stylesheet"/>
</head>
<body>
<div class="lite-chatbox">
<div class="tips">
<span>系統消息:normal</span>
</div>
<div class="cright cmsg">
<img class="headIcon radius" src="http://img2.imgtn.bdimg.com/it/u=3615831237,1510664097&fm=26&gp=0.jpg"/>
<span class="name">SuperPaxxs</span>
<span class="content">LiteChat_Frame(輕聊天氣泡框架),一個賊簡潔 <del>(簡單)</del> 、美觀、易用的 HTML 聊天界面框架</span>
</div>
<div class="cleft cmsg">
<img class="headIcon radius" src="http://img2.imgtn.bdimg.com/it/u=3615831237,1510664097&fm=26&gp=0.jpg"/>
<span class="name">SuperPaxxs</span>
<span class="content">LiteChat_Frame(輕聊天氣泡框架),一個賊簡潔 <del>(簡單)</del> 、美觀、易用的 HTML 聊天界面框架</span>
</div>
<div class="cright cmsg">
<img class="headIcon radius" src="http://img2.imgtn.bdimg.com/it/u=3615831237,1510664097&fm=26&gp=0.jpg"/>
<span class="name">SuperPaxxs</span>
<span class="content">LiteChat_Frame(輕聊天氣泡框架),一個賊簡潔 <del>(簡單)</del> 、美觀、易用的 HTML 聊天界面框架</span>
</div>
</div>
</body>
</html>
複製代碼
而後編寫 Flask
視圖函數進行渲染:
@app.route('/')
def index():
return render_template('index.html')
複製代碼
啓動程序, 訪問 http://127.0.0.1:5000/
, 上面代碼對應的頁面是這樣的 ( 瀏覽器調成手機模式 ) :
消息主體主要關注 body
中的 div
, 這裏先用默認消息佔位, 等到實際聊天時會把它們刪除. 下面是 css
類對應的功能:
lite-chatbox
: 定義一個聊天框, 容納全部消息cmsg
: 定義一條消息cright
: 消息在右側cleft
: 消息在左側headicon
: 用戶頭像radius
: 頭像設置爲圓角name
: 用戶名content
: 消息內容tips
: 提示
tips-primary
: 首要的提示tips-success
: 成功提示tips-info
: 信息提示tips-warning
: 警告提示tips-danger
錯誤/危險提示使用以上 css
類就能夠編寫一個精美的聊天框了, 如今編寫輸入框和發送按鈕.
首先引入 Bootstrap
:
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
複製代碼
而後在聊天框下面添加如下代碼:
<body>
<div class="lite-chatbox">
...
</div>
<div class="input-group">
<input type="text" class="form-control">
<div class="input-group-append">
<button class="btn btn-primary">發送</button>
</div>
</div>
</body>
複製代碼
效果以下:
如今的輸入框是拼在聊天框下面的, 可是輸入框應該固定在底部, 因此須要 固定定位
讓它一直保持在底部. 如今在 static/css
目錄建立 style.css
文件編寫自定義樣式:
.input-group {
position: fixed;
bottom: 0;
left: 0;
}
複製代碼
而後在 index.html
中導入:
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
複製代碼
重啓應用後刷新頁面, 輸入框應該已經到底部了, 可是 Bootstrap
組件自帶的樣式會影響效果, 我這裏去除了 圓角
和 聚焦高亮
, 在 style.css
文件中添加:
.btn, .btn:focus, .form-control, .form-control:focus {
border-radius: 0;
box-shadow: none;
}
複製代碼
下面是最終效果:
如今頁面也準備好啦, 能夠正式寫邏輯啦.
SocketIO
鏈接客戶端須要依賴 JQuery
和 Socket.IO
, 在 index.html
中引入:
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
<script src="https://cdn.bootcss.com/socket.io/2.3.0/socket.io.slim.js"></script>
複製代碼
而後在 static/js
目錄中新建一個 chat.js
用來編寫客戶端邏輯.
客戶端須要使用 io()
方法鏈接服務器建立 socket
實例, 建立成功的 socket
實例用來與服務器通訊. io
方法的第一個參數是服務器的地址, 若是不傳這個參數, 默認爲 window.location
, 這裏爲 http://127.0.0.1:5000
. 代碼以下:
$(function () {
let socketio = io(); // 建立 socket 實例
});
複製代碼
SockcetIO
是基於事件進行雙向通訊. 調用 socket
實例的 emit
方法能夠向對方發送事件並帶上參數做爲通訊的數據; 而調用 on
方法能夠在收到數據時觸發某個事件, 參數將自動解析到回調函數中.
SocketIO
內置了一些事件, 這裏須要用到的是 connect
和 disconnect
事件. connect
事件在鏈接成功時觸發, disconnect
事件在斷開鏈接時觸發. 如今給客戶端綁定事件:
$(function () {
let socketio = io();
// 鏈接成功後觸發
socketio.on('connect', function () {
console.log('connectted')
});
// 斷開鏈接後觸發
socketio.on('disconnect', function () {
console.log('disconnected')
});
});
複製代碼
重啓服務器刷新頁面後, 應該就能夠在 瀏覽器控制檯
看到 connectted
, 服務器應該會有如下請求日誌輸出在控制檯:
服務器一樣能夠監聽事件, 在 Python
裏使用 裝飾器
的方式監聽. SocketIO
爲每個鏈接到服務器的 socket
分配一個 sid
, 用來標識每個 socket
, 後面須要根據這個 sid
指定給哪個鏈接到的客戶端發送消息. sid
的值放在 Flask
中 Request
對象中. 下面代碼用於在客戶端鏈接成功後打印它的 id
:
@socketio.on('connect')
def connect():
print(request.sid)
複製代碼
發送消息使用上文提到的 emit
方法. 如今若是想要在客戶端鏈接成功後給服務器發送 我收到了
消息, 服務器收到消息後回覆 我也收到了
, 能夠這樣寫:
// chat.js
// 鏈接成功後觸發
socketio.on('connect', function () {
console.log('connectted');
socketio.emit('test message', {'content': '我收到了'}); // 發送數據
});
// 監聽 testMessage 事件
socketio.on('test message', function (data) {
console.log(data)
})
複製代碼
# app.py
@socketio.on('test message')
def test_message(data):
"""監聽客戶端發送 test message 事件"""
print('recv:', data)
emit('test message', {'content': '我也收到了'}, room=request.sid) # 經過 flask_socketio 導入, 對 socketio.emit 進行了封裝
複製代碼
emit
的第一個參數表示 事件名
, 第二個參數表示 發送數據
, room
參數表示要給哪個 客戶端
或者 房間(本文不涉及房間)
發送消息, 默認爲當前請求的客戶端也就是 request.sid
, 這裏顯示傳參, 便於理解. 這樣子經過監聽和發送不一樣的事件, 客戶端和服務器就能夠進行雙向通訊了.
實現匹配也就是實現點對點, 匹配就是在第二個客戶端鏈接上來的時候, 將它與第一個客戶端作對應, 這樣在一個客戶端發送消息, 經過這個客戶端j就能夠找到另外一個客戶端, 而後經過它的 sid
就能夠給他發消息了, 因此對於鏈接到的全部客戶端以及對應關係, 須要額外存儲.
每一個鏈接到的客戶端能拿到的只有 sid
這個值, 若是想要作對應關係, 就要本身去實現了, 在 app.py
建立一個 Socket
類:
class Socket:
def __init__(self, name, sid):
""" 封裝鏈接到的客戶端 :param name: 客戶端用戶的姓名 :param sid: 客戶端的sid """
self.name = name
self.sid = sid
self.target = None # target 指向另外一個 Socket 對象; 另外一個對象也指向本身, 就能夠實現對應關係
複製代碼
對於匹配到的兩個客戶端分辨建立兩個 Socket
對象, target
屬性分別指向對方, 而後當一方發送消息, 經過其 sid
找到 Socket
對象, 再經過它的 target
屬性找到另外一個 SOcket
對象, 拿到它的 sid
就能夠給對方發送消息了, 這樣子就能夠將二者對應起來.
在 app
對象上掛載兩個變量 wait
和 on
, wait
變量使用 set
數據結構, 用來存放 待匹配
客戶端的 sid
, on
變量使用 dict
數據結構, 用來將 sid
和 Socket
對象作映射, 拿到 sid
就能拿到 Socket
對象, 反過來也可.
具體的匹配邏輯爲, 當一個新的客戶端鏈接到服務器, 判斷 wait
變量中是否有客戶端能夠匹配, 若是有就從中隨機取出一個, 爲其生成隨機的 name
, 建立 Socket
對象, 而後互相引用, 最後給雙方發送匹配到的用戶信息; 若是沒有可用的客戶端能夠匹配, 那就把當前客戶端加入 wait
變量, 等待其餘客戶端匹配本身, 最後給本身發送匹配數據.
其中生成隨機 name
會使用到 Faker
庫, 它能夠用來生成不少虛擬數據, 使用如下代碼安裝:
pip install faker
複製代碼
下面是邏輯代碼:
# app.py
from faker import Faker
app.wait = set()
app.on = dict()
fake = Faker(locale='zh_CN')
def client_count():
"""鏈接到的客戶端數"""
return len(list(app.wait)) + len(list(app.on.keys()))
@socketio.on('connect')
def connect():
"""鏈接成功"""
print('connectted:', request.sid, 'clients:', client_count() + 1)
# 若是有匹配對象就匹配, 沒有就添加到 待匹配列表
if len(list(app.wait)) >= 1:
# 隨機取出一個
sid_self = request.sid
sid_target = random.choice(list(app.wait))
app.wait.remove(sid_target)
# 生成隨機姓名
name_self = fake.name()
name_target = fake.name()
# 建立 Socket 對象並互相引用
socket_self = Socket(name_self, sid_self)
socket_target = Socket(name_target, sid_target)
socket_self.target = socket_target
socket_target.target = socket_self
# 添加映射關係
app.on[sid_self] = socket_self
app.on[sid_target] = socket_target
# 給雙方發送匹配到的用戶數據
emit('matching', {'html': render_template('matching_tip.html', user={'name': name_self})}, room=sid_target)
emit('matching', {'html': render_template('matching_tip.html', user={'name': name_target})}, room=sid_self)
else:
app.wait.add(request.sid)
emit('matching', {'html': render_template('matching_tip.html')})
複製代碼
發送數據須要 模板文件
, 因此在 templates
文件夾中建立 matching_tip.html
文件, 寫入如下代碼:
<div class="tips">
{% if user %}
<span>匹配到 {{ user.name }}</span>
{% else %}
<span>等待匹配...</span>
{% endif %}
</div>
複製代碼
其中使用 模板語法
判斷是否有 user
變量, 有的話就是匹配成功, 渲染匹配到的用戶名, 沒有則等待匹配. 在服務器給客戶端迴應匹配結果後, 客戶端須要監聽 matching
事件使用 JQuery
動態渲染收到的 HTML
文本, 如下是代碼:
// chat.js
// 監聽匹配結果
socketio.on('matching', function (data) {
console.log(data);
$('#chatbox').append(data.html);
});
複製代碼
原先聊天框中的轉爲數據所有刪除, 而且給聊天框添加 id
屬性爲 chatbox
, 聊天框代碼變爲:
<!-- index.html -->
<div class="lite-chatbox" id="chatbox">
</div>
複製代碼
如今重啓服務器, 先打開一個頁面訪問, 應該會顯示 等待匹配...
, 而後再打開一個頁面就會匹配到第一個頁面而且輸出匹配到的 用戶名
.
在匹配成功後若是有任何一方斷開了鏈接, 那另外一方也要退出, 因此須要在服務器監聽斷開事件, 判斷斷開鏈接的是否有匹配用戶, 有的話給這個用戶發送消息, 讓它主動斷開, 下面爲代碼:
@socketio.on('disconnect')
def disconnnect():
"""斷開鏈接"""
print('disconnectted:', request.sid, 'clients:', client_count() - 1)
# 若是斷開的客戶端在待匹配列表裏說明沒有匹配對象
# 直接將它從待匹配裏面中刪除
# 若是不在那就將它從映射關係刪除
# 並找到匹配對象讓它主動斷開鏈接
if request.sid in app.wait:
app.wait.remove(request.sid)
elif request.sid in app.on:
socket_self = app.on.pop(request.sid)
socket_target = socket_self.target
target_sid = socket_target.sid
emit('dismatching', {'html': render_template('dismatching.html', user={'name': socket_self.name})},
room=target_sid)
複製代碼
這裏一樣須要 模板文件
, 在 templates
中新建 dismatching.html
寫入如下內容:
<div class="tips">
<span>{{ user.name }} 已離開</span>
</div>
複製代碼
同時客戶端須要監聽 dismatching
事件:
// chat.js
// 監聽斷開匹配
socketio.on('dismatching', function (data) {
console.log(data);
$('#chatbox').append(data.html);
socketio.disconnect();
});
複製代碼
重啓服務器刷新頁面就能看到效果(頁面有緩存時須要強制刷新).
相信經過前面的代碼編寫消息轉發已經很簡單了, 收到一個用戶的消息, 根據其 Socket
找對目標 Socket
而後使用其 sid
將數據直接轉發就能夠了, 下面爲代碼:
// chat.js
let send = $('#send'); // 發送按鈕 須要給按鈕添加 id 屬性
let message = $('#message'); // 輸入框 須要給輸入框添加 id 屬性
// 發送按鈕點擊事件
send.click(function () {
sendMessage();
});
// 輸入框回車事件
message.keypress(function (e) {
if (e.which === 13) {
sendMessage();
}
});
// 發送消息
function sendMessage() {
if (message.val().length !== 0) {
socketio.emit('new message', {'content': message.val()});
message.val(''); // 發送消息後清空輸入框
}
}
複製代碼
客戶端發送了 new message
事件, 服務器須要監聽這個事件, 同時客戶端也要監聽服務器轉發來的這個事件:
// chat.js
// 監聽新消息
socketio.on('new message', function (data) {
console.log(data);
$('#chatbox').append(data.html);
});
複製代碼
# app.py
@socketio.on('new message')
def new_message(data):
"""作新消息轉發"""
print('recv:', data)
# 獲取 Socket 對象併發送數據
socket_self = app.on[request.sid]
socket_target = socket_self.target
user_self = {'name': socket_self.name}
emit('new message', {'html': render_template('new_message_left.html', user=user_self, msg=data)},
room=socket_target.sid)
emit('new message', {'html': render_template('new_message_right.html', user=user_self, msg=data)},
room=socket_self.sid)
複製代碼
其中因爲消息發送方和消息接收方的消息展現位置不一致, 因此須要兩個 模板文件
: new_message_left.html
和 new_message_right.html
, 發送消息的在右方, 接收消息的在左方:
<!-- new_message_left.html -->
<div class="cleft cmsg">
<img class="headIcon radius" ondragstart="return false;" oncontextmenu="return false;" src="{{ url_for('static', filename='imgs/default.jpeg') }}"/>
<span class="name">{{ user.name }}</span>
<span class="content">{{ msg.content }}</span>
</div>
<!-- new_message_right.html -->
<div class="cright cmsg">
<img class="headIcon radius" ondragstart="return false;" oncontextmenu="return false;" src="{{ url_for('static', filename='imgs/default.jpeg') }}"/>
<span class="name">{{ user.name }}</span>
<span class="content">{{ msg.content }}</span>
</div>
複製代碼
此時重啓服務器強制刷新頁面從新匹配, 發送消息就能夠收到了, 可是此時頭像還有問題, 示例代碼中頭像默認使用同一張, 在 static\imgs
文件夾中隨意放一張頭像並命名爲 default.jpeg
就能夠了.
在聊天消息不少超出屏幕底部的時候, 應該讓聊天框自動下拉到底部, 下面示例代碼:
// chat.js
function scrollToBottom() {
let h = $(document).height() - $(window).height();
if (h > 0) {
$(document).scrollTop(h);
}
}
複製代碼
而後在 new message
事件中添加上面的函數:
// chat.js
socketio.on('new message', function (data) {
console.log(data);
$('#chatbox').append(data.html);
scrollToBottom();
});
複製代碼
添加函數後以下圖所示:
能夠看到仍是有一部分消息被 輸入框遮擋
, 解決方案能夠在 聊天框
下方添加一個與 輸入框
一樣高的 div
, 就能夠把消息擠到上方了, 代碼以下:
<!-- index.html -->
<div class="holder"></div>
複製代碼
而後在 style.css
添加樣式:
.btn, .form-control {
height: 45px;
}
.holder {
height: 50px;
}
複製代碼
這裏設置 holder
略高一點, 下面是實際效果:
能夠看到底部消息已經能夠顯示出來了.
部署與常規的 Flask
應用一致, 我習慣使用 Gunicorn
, 下面是使用 一個worker
, worker
類型爲 eventlet
的示例:
gunicorn -w 1 -k eventlet -b 0.0.0.0:7000 'app:app'
複製代碼
以前在開發的時候使用的一直都是 單個worker
, 若是使用 多個worker
, 因爲不一樣的 worker
之間內存數據獨立, 因此請求過來的客戶端會落在不一樣的 worker
上, 就會形成匹配數據不一致, 這個時候就須要使用 Redis
之類的 消息隊列
和 緩存機制
來共享數據, SocketIO
一樣是支持的, 具體可看文檔.
好了, 寫到這裏, 一個簡易的匿名聊天應用就完成了, 此案例中未設計到 命名空間
、房間
、廣播
等概念, 你們能夠進行修改, 自行添加 羣聊
等其餘功能. 這裏是 Github地址
: github.com/Abyssknight…