使用 Flask-SocketIO 編寫一個匿名聊天應用

僅供學習, 歡迎指正~css

最近在研究 WebSocket, 就用 Flask-SocketIO 寫了個 匿名聊天Demo 用來實現 點對點通訊, 下面是實際效果:html

匿名聊天

這個 Demo 會涉及到 前端後端 的相關知識點, 不過都很簡單, 我都會一一將清楚.前端

什麼是 SocketIO

說到 SocketIO, 就要從 HTTP 請求提及. 傳統的基於 HTTP 的請求是 請求-響應 式的, 客戶端 請求, 服務器 響應, 一次通訊就結束了. 可是聊天程序須要服務器有 主動推送 的能力, 在一個用戶發送消息的時候, 服務器能夠主動將消息推送給另外一個用戶進行通訊, 這就須要服務器與客戶端之間保持鏈接, 可是 HTTP 沒法作到這一點.python

之前的解決方案可使用 輪詢, 輪詢 分爲 長輪詢短輪詢, 長輪詢 是服務器收到請求後若是有數據就響應給客戶端, 若是沒有數據, 就將請求掛起, 等待有數據或者鏈接超時後再響應, 客戶端收到響應後再次發送請求, 並重復以上步驟; 而 短輪詢 則是客戶端每隔一段時間就發送一個請求, 服務器無論有無數據, 當即返回. 輪詢 的缺陷顯而易見, 不論是短輪詢的重複請求仍是長輪詢的請求掛起, 都會形成資源浪費, 並且服務器一直處於被動地位. 還有一種解決方案稱爲 SSE(Server-Sent Events), 在瀏覽器與服務器創建鏈接後, 就一直保持 長鏈接, 服務器有數據就推送給瀏覽器, 但不關閉鏈接, 等待下次有數據後繼續推送, 這種通訊方式要優於輪詢, 但這隻能實現服務器 單向 推送, 瀏覽器沒法在同一個鏈接裏給服務器再次發送數據.jquery

以後出現了新的協議 WebSocket 解決了以上問題. WebSocket 是在單個 TCP 鏈接上, 實現了全雙工的通訊協議, 瀏覽器和服務器之間只須要完成一次握手就能夠保持鏈接. 鏈接後經過 觸發事件 進行通訊.git

SocketIOWebSocket 進行了封裝, 用來兼容不支持 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 內部使用了 Flasksession 對象, 因此須要設置程序的 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 完成. 爲方便起見, CSSJS 庫都使用 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 鏈接

建立客戶端實例

客戶端須要依賴 JQuerySocket.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 內置了一些事件, 這裏須要用到的是 connectdisconnect 事件. 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 的值放在 FlaskRequest 對象中. 下面代碼用於在客戶端鏈接成功後打印它的 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 對象上掛載兩個變量 waiton, wait 變量使用 set 數據結構, 用來存放 待匹配 客戶端的 sid, on 變量使用 dict 數據結構, 用來將 sidSocket 對象作映射, 拿到 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.htmlnew_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 略高一點, 下面是實際效果:

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…

相關文章
相關標籤/搜索