前言
如果有一個需求,讓你構建一個網絡的聊天室,你會怎么解決?
首先,對於HTTP
請求來說,Server
端總是處於被動的一方,即只能由Browser
發送請求,Server
才能夠被動回應。
也就是說,如果Browser
沒有發送請求,則Server
就不能回應。
並且HTTP
具有無狀態的特點,即使有長鏈接(Connection請求頭)的支持,但受限於Server
的被動特性,要有更好的解決思路才行。
輪詢
基本概念
根據上面的需求,最簡單的解決方案就是不斷的朝Server
端發送請求,Browser
獲取最新的消息。
對於前端來說一般都是基於setInterval
來做,但是輪詢的缺點非常明顯:
- Server需要不斷的處理請求,壓力非常大
- 前端數據刷新不及時,setInterval間隔時間越長,數據刷新越慢,setInterval間隔時間越短,Server端的壓力越大
>
示例演示
以下是用Flask
和Vue
做的簡單實例。
每個用戶打開該頁面后都會生成一個隨機名字,前端采用輪詢的方式更新記錄。
后端用一個列表存儲最近的聊天記錄,最多存儲100條,超過一百條截取最近十條。
總體流程就是前端發送過來的消息都放進列表中,然后前端輪詢時就將整個聊天記錄列表獲取到后在頁面進行渲染。
缺點非常明顯,僅僅有兩個用戶在線時,后端的請求就非常頻繁了:
后端代碼:
import uuid
from faker import Faker
from flask import Flask, request, jsonify
fake = Faker(locale='zh_CN') # 生成隨機名
app = Flask(__name__)
notes = [] # 存儲聊天記錄,100條
@app.after_request # 解決CORS跨域請求
def cors(response):
response.headers['Access-Control-Allow-Origin'] = "*"
if request.method == "OPTIONS":
response.headers["Access-Control-Allow-Headers"] = "Origin,Content-Type,Cookie,Accept,Token,authorization"
return response
@app.route('/get_name', methods=["POST"])
def get_name():
"""
生成隨機名
"""
username = fake.name() + "==" + str(uuid.uuid4())
return jsonify(username)
@app.route('/send_message', methods=["POST"])
def send_message():
"""
發送信息
"""
username, tag = request.json.get("username").rsplit("==", maxsplit=1) # 取出uuid和名字
message = request.json.get("message")
time = request.json.get("time")
dic = {
"username": username,
"message": message,
"time": time,
"tag": tag + time, # 前端:key唯一標識
}
notes.append(dic) # 追加聊天記錄
return jsonify({
"status": 1,
"error": "",
"message": "",
})
@app.route('/get_all_message', methods=["POST"])
def get_all_message():
"""
獲取聊天記錄
"""
global notes
if len(notes) == 100:
notes = notes[90:101]
return jsonify(notes)
if __name__ == '__main__':
app.run(threaded=True) # 開啟多線程
前端代碼main.js
:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from "axios"
import moment from 'moment'
Vue.prototype.$moment = moment
moment.locale('zh-cn')
Vue.prototype.$axios = axios;
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
前端代碼Home.vue
:
<template>
<div class="index">
<div>{{ title }}</div>
<article id="context">
<ul>
<li v-for="(v,index) in all_message" :key="index">
<p>{{ v.username }} {{ v.time }}</p>
<p>{{ v.message }}</p>
</li>
</ul>
</article>
<textarea v-model.trim="message" @keyup.enter="send"></textarea>
<button type="button" @click="send">提交</button>
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
BASE_URL: "http://127.0.0.1:5000/",
title: "聊天交流群",
username: "",
message: "",
all_message: [],
}
},
mounted() {
// 獲取用戶名
this.get_user_name();
// 輪詢,獲取信息
setInterval(this.get_all_message, 3000);
},
methods: {
// 獲取用戶名
get_user_name() {
this.$axios({
method: "POST",
url: this.BASE_URL + "get_name",
responseType: "json",
}).then(response => {
this.username = response.data;
})
},
// 發送消息
send() {
if (this.message) {
this.$axios({
method: "POST",
url: this.BASE_URL + "send_message",
data: {
message: this.message,
username: this.username,
time: this.$moment().format("YYYY-MM-DD HH:mm:ss"),
},
responseType: "json",
});
this.message = "";
}
},
// 輪詢獲取消息
get_all_message() {
this.$axios({
method: "POST",
url: this.BASE_URL + "get_all_message",
responseType: "json",
}).then(response => {
this.all_message = response.data;
// 使用宏隊列任務,拉滾動條
let context = document.querySelector("#context");
setTimeout(() => {
context.scrollTop = context.scrollHeight;
},)
})
},
}
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
list-style: none;
box-sizing: border-box;
}
.index {
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: center;
}
.index div:first-child {
margin: 0 auto;
background: rebeccapurple;
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
color: aliceblue;
width: 80%;
}
.index article {
margin: 0 auto;
height: 300px;
border: 1px solid #ddd;
overflow: auto;
width: 80%;
font-size: .9rem;
}
.index article ul li {
margin-bottom: 10px;
}
.index article ul li p:last-of-type {
text-indent: 1rem;
}
.index textarea {
outline: none;
resize: none;
width: 80%;
height: 100px;
border: 1px solid #ddd;
margin-bottom: 10px;
}
.index button {
width: 10%;
height: 30px;
align-self: flex-end;
transform: translate(-100%);
background: forestgreen;
color: white;
outline: none;
}
</style>
長輪詢
基本概念
輪詢是不斷的發送請求,Server
端顯然受不了。
這時候就可以使用長輪詢的機制,即為每一個進入聊天室的用戶(與Server
端建立連接的用戶)創建一個隊列,每個用戶輪詢時都去詢問自己的隊列,如果沒有新消息就等待,如果后端一旦接收到新消息就將消息放入所有的等待隊列中返回本次請求。
長輪詢是在輪詢基礎上做的,也是不斷的訪問服務器,但是服務器不會即刻返回,而是等有新消息到來時再返回,或者等到超時時間到了再返回。
- Server端采用隊列,為每一個請求創建一個專屬隊列
- Server端有新消息進來,放入每一個請求的隊列中進行返回,或者等待超時時間結束捕獲異常后再返回
示例演示
使用長輪詢實現聊天室是最佳的解決方案。
前端頁面打開后的流程依舊是生成隨機名字,后端立馬為這個隨機名字拼接上uuid后創建一個專屬的隊列。
然后每次發送消息時都將消息裝到每個用戶的隊列中,如果有隊列消息大於1的說明該用戶已經下線,將該隊列刪除即可。
獲取最新消息的時候就從自己的隊列中獲取,獲取不到就阻塞,獲取到就立刻返回。
后端代碼:
import queue
import uuid
from faker import Faker
from flask import Flask, request, jsonify
fake = Faker(locale='zh_CN') # 生成隨機名
app = Flask(__name__)
notes = [] # 存儲聊天記錄,100條
# 用戶消息隊列
user_queue = {
}
# 已下線用戶
out_user = []
@app.after_request # 解決CORS跨域請求
def cors(response):
response.headers['Access-Control-Allow-Origin'] = "*"
if request.method == "OPTIONS":
response.headers["Access-Control-Allow-Headers"] = "Origin,Content-Type,Cookie,Accept,Token,authorization"
return response
@app.route('/get_name', methods=["POST"])
def get_name():
"""
生成隨機名,還有管道
"""
username = fake.name() + "==" + str(uuid.uuid4())
q = queue.Queue()
user_queue[username] = q # 創建管道 {用戶名+uuid:隊列}
return jsonify(username)
@app.route('/send_message', methods=["POST"])
def send_message():
"""
發送信息
"""
username, tag = request.json.get("username").rsplit("==", maxsplit=1) # 取出uuid和名字
message = request.json.get("message")
time = request.json.get("time")
dic = {
"username": username,
"message": message,
"time": time,
}
for username, q in user_queue.items():
if q.qsize() > 1: # 用戶已下線,五條阻塞信息,加入下線的用戶列表中
out_user.append(username) # 不能循環字典的時候彈出元素
else:
q.put(dic) # 將最新的消息放入管道中
if out_user:
for username in out_user:
user_queue.pop(username)
out_user.remove(username)
print(username + "已下線,彈出消息通道")
notes.append(dic) # 追加聊天記錄
return jsonify({
"status": 1,
"error": "",
"message": "",
})
@app.route('/get_all_message', methods=["POST"])
def get_all_message():
"""
獲取聊天記錄
"""
global notes
if len(notes) == 100:
notes = notes[90:101]
return jsonify(notes)
@app.route('/get_new_message', methods=["POST"])
def get_new_message():
"""
獲取最新的消息
"""
username = request.json.get("username")
q = user_queue[username]
try:
# 獲取不到就阻塞,不立即返回
new_message_dic = q.get(timeout=30)
except queue.Empty:
return jsonify({
"status": 0,
"error": "沒有新消息",
"message": "",
})
return jsonify({
"status": 1,
"error": "",
"message": new_message_dic
})
if __name__ == '__main__':
app.run()
前端代碼main.js
:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from "axios"
import moment from 'moment'
Vue.prototype.$moment = moment
moment.locale('zh-cn')
Vue.prototype.$axios = axios;
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
前端代碼Home.vue
:
<template>
<div class="index">
<div>{{ title }}</div>
<article id="context">
<ul>
<li v-for="(v,index) in all_message" :key="index">
<p>{{ v.username }} {{ v.time }}</p>
<p>{{ v.message }}</p>
</li>
</ul>
</article>
<textarea v-model.trim="message" @keyup.enter="send" :readonly="status"></textarea>
<button type="button" @click="send">提交</button>
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
BASE_URL: "http://127.0.0.1:5000/",
title: "聊天交流群",
username: "",
message: "",
status: false,
all_message: [],
}
},
mounted() {
// 獲取用戶名
this.get_user_name();
// 異步隊列,確認用戶名已獲取到
setTimeout(() => {
// 加載聊天記錄
this.get_all_message();
// 長輪詢
this.get_new_message();
}, 1000)
},
methods: {
// 獲取用戶名
get_user_name() {
this.$axios({
method: "POST",
url: this.BASE_URL + "get_name",
responseType: "json",
}).then(response => {
this.username = response.data;
})
},
// 發送消息
send() {
if (this.message) {
this.$axios({
method: "POST",
url: this.BASE_URL + "send_message",
data: {
message: this.message,
username: this.username,
time: this.$moment().format("YYYY-MM-DD HH:mm:ss"),
},
responseType: "json",
});
this.message = "";
}
},
// 頁面打開后,第一次加載聊天記錄
get_all_message() {
this.$axios({
method: "POST",
url: this.BASE_URL + "get_all_message",
responseType: "json",
}).then(response => {
this.all_message = response.data;
// 控制滾動條
let context = document.querySelector("#context");
setTimeout(() => {
context.scrollTop = context.scrollHeight;
},)
})
},
get_new_message() {
this.$axios({
method: "POST",
// 發送用戶名
data: {"username": this.username},
url: this.BASE_URL + "get_new_message",
responseType: "json",
}).then(response => {
if (response.data.status === 1) {
// 添加新消息
this.all_message.push(response.data.message);
// 控制滾動條
let context = document.querySelector("#context");
setTimeout(() => {
context.scrollTop = context.scrollHeight;
},)
}
// 遞歸
this.get_new_message();
})
}
}
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
list-style: none;
box-sizing: border-box;
}
.index {
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: center;
}
.index div:first-child {
margin: 0 auto;
background: rebeccapurple;
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
color: aliceblue;
width: 80%;
}
.index article {
margin: 0 auto;
height: 300px;
border: 1px solid #ddd;
overflow: auto;
width: 80%;
font-size: .9rem;
}
.index article ul li {
margin-bottom: 10px;
}
.index article ul li p:last-of-type {
text-indent: 1rem;
}
.index textarea {
outline: none;
resize: none;
width: 80%;
height: 100px;
border: 1px solid #ddd;
margin-bottom: 10px;
}
.index button {
width: 10%;
height: 30px;
align-self: flex-end;
transform: translate(-100%);
background: forestgreen;
color: white;
outline: none;
}
</style>