使用輪詢&長輪詢實現網頁聊天室

雲崖先生發表於2020-12-21

前言

   如果有一個需求,讓你構建一個網路的聊天室,你會怎麼解決?

   首先,對於HTTP請求來說,Server端總是處於被動的一方,即只能由Browser傳送請求,Server才能夠被動回應。

   也就是說,如果Browser沒有傳送請求,則Server就不能回應。

   並且HTTP具有無狀態的特點,即使有長連結(Connection請求頭)的支援,但受限於Server的被動特性,要有更好的解決思路才行。

輪詢

基本概念

   根據上面的需求,最簡單的解決方案就是不斷的朝Server端傳送請求,Browser獲取最新的訊息。

   對於前端來說一般都是基於setInterval來做,但是輪詢的缺點非常明顯:

  1. Server需要不斷的處理請求,壓力非常大
  2. 前端資料重新整理不及時,setInterval間隔時間越長,資料重新整理越慢,setInterval間隔時間越短,Server端的壓力越大

   image-20201219204119137>

示例演示

   以下是用FlaskVue做的簡單例項。

   每個使用者開啟該頁面後都會生成一個隨機名字,前端採用輪詢的方式更新記錄。

   後端用一個列表儲存最近的聊天記錄,最多儲存100條,超過一百條擷取最近十條。

   總體流程就是前端傳送過來的訊息都放進列表中,然後前端輪詢時就將整個聊天記錄列表獲取到後在頁面進行渲染。

   image-20201221140125610

   缺點非常明顯,僅僅有兩個使用者線上時,後端的請求就非常頻繁了:

   image-20201221141051340

   後端程式碼:

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 }}&nbsp;{{ 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端建立連線的使用者)建立一個佇列,每個使用者輪詢時都去詢問自己的佇列,如果沒有新訊息就等待,如果後端一旦接收到新訊息就將訊息放入所有的等待佇列中返回本次請求。

   長輪詢是在輪詢基礎上做的,也是不斷的訪問伺服器,但是伺服器不會即刻返回,而是等有新訊息到來時再返回,或者等到超時時間到了再返回。

  1. Server端採用佇列,為每一個請求建立一個專屬佇列
  2. Server端有新訊息進來,放入每一個請求的佇列中進行返回,或者等待超時時間結束捕獲異常後再返回

   image-20201219204347371

示例演示

   使用長輪詢實現聊天室是最佳的解決方案。

   前端頁面開啟後的流程依舊是生成隨機名字,後端立馬為這個隨機名字拼接上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 }}&nbsp;{{ 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>

相關文章