利用WebSocket和EventSource實現服務端推送

船長_發表於2018-12-13

可能有很多的同學有用 setInterval 控制 ajax 不斷向服務端請求最新資料的經歷(輪詢)看下面的程式碼:

setInterval(function() {
    $.get('/get/data-list', function(data, status) {
        console.log(data)
    })
}, 5000)
複製程式碼

這樣每隔5秒前端會向後臺請求一次資料,實現上看起來很簡單但是有個很重要的問題,就是我們沒辦法控制網速的穩定,不能保證在下次發請求的時候上一次的請求結果已經順利返回,這樣勢必會有隱患,有聰明的同學馬上會想到用 setTimeout 配合遞迴看下面的程式碼:

function poll() {
    setTimeout(function() {
        $.get('/get/data-list', function(data, status) {
            console.log(data)
            poll()
        })
    }, 5000)
}
複製程式碼

當結果返回之後再延時觸發下一次的請求,這樣雖然沒辦法保證兩次請求之間的間隔時間完全一致但是至少可以保證資料返回的節奏是穩定的,看似已經實現了需求但是這麼搞我們先不去管他的效能就程式碼結構也算不上優雅,為了解決這個問題可以讓服務端長時間和客戶端保持連線進行資料互通h5新增了 WebSocket 和 EventSource 用來實現長輪詢,下面我們來分析一下這兩者的特點以及使用場景。

WebSocket

是什麼: WebSocket是一種通訊手段,基於TCP協議,預設埠也是80和443,協議識別符號是ws(加密為wss),它實現了瀏覽器與伺服器的全雙工通訊,擴充套件了瀏覽器與服務端的通訊功能,使服務端也能主動向客戶端傳送資料,不受跨域的限制。

有什麼用: WebSocket用來解決http不能持久連線的問題,因為可以雙向通訊所以可以用來實現聊天室,以及其他由服務端主動推送的功能例如 實時天氣、股票報價、餘票顯示、訊息通知等。

EventSource

是什麼: EventSource的官方名稱應該是 Server-sent events(縮寫SSE)服務端派發事件,EventSource 基於http協議只是簡單的單項通訊,實現了服務端推的過程客戶端無法通過EventSource向服務端傳送資料。喜聞樂見的是ie並沒有良好的相容當然也有解決的辦法比如 npm install event-source-polyfill。雖然不能實現雙向通訊但是在功能設計上他也有一些優點比如可以自動重連線,event IDs,以及傳送隨機事件的能力(WebSocket要藉助第三方庫比如socket.io可以實現重連。)

有什麼用: 因為受單項通訊的限制EventSource只能用來實現像股票報價、新聞推送、實時天氣這些只需要伺服器傳送訊息給客戶端場景中。EventSource的使用更加便捷這也是他的優點。

WebSocket & EventSource 的區別

  1. WebSocket基於TCP協議,EventSource基於http協議。
  2. EventSource是單向通訊,而websocket是雙向通訊。
  3. EventSource只能傳送文字,而websocket支援傳送二進位制資料。
  4. 在實現上EventSource比websocket更簡單。
  5. EventSource有自動重連線(不借助第三方)以及傳送隨機事件的能力。
  6. websocket的資源佔用過大EventSource更輕量。
  7. websocket可以跨域,EventSource基於http跨域需要服務端設定請求頭。

EventSource的實現案例

客戶端程式碼

// 例項化 EventSource 引數是服務端監聽的路由
var source = new EventSource('/EventSource-test')
source.onopen = function (event) { // 與伺服器連線成功回撥
  console.log('成功與伺服器連線')
}
// 監聽從伺服器傳送來的所有沒有指定事件型別的訊息(沒有event欄位的訊息)
source.onmessage = function (event) { // 監聽未命名事件
  console.log('未命名事件', event.data)
}
source.onerror = function (error) { // 監聽錯誤
  console.log('錯誤')
}
// 監聽指定型別的事件(可以監聽多個)
source.addEventListener("myEve", function (event) {
  console.log("myEve", event.data)
})
複製程式碼

服務端程式碼(node.js)

const fs = require('fs')
const express = require('express') // npm install express
const app = express()

// 啟動一個簡易的本地server返回index.html
app.get('/', (req, res) => {
  fs.stat('./index.html', (err, stats) => {
    if (!err && stats.isFile()) {
      res.writeHead(200)
      fs.createReadStream('./index.html').pipe(res)
    } else {
      res.writeHead(404)
      res.end('404 Not Found')
    }
  })
})

// 監聽EventSource-test路由服務端返回事件流
app.get('/EventSource-test', (ewq, res) => {
  // 根據 EventSource 規範設定報頭
  res.writeHead(200, {
    "Content-Type": "text/event-stream", // 規定把報頭設定為 text/event-stream
    "Cache-Control": "no-cache" // 設定不對頁面進行快取
  })
  // 用write返回事件流,事件流僅僅是一個簡單的文字資料流,每條訊息以一個空行(\n)作為分割。
  res.write(':註釋' + '\n\n')  // 註釋行
  res.write('data:' + '訊息內容1' + '\n\n') // 未命名事件

  res.write(  // 命名事件
    'event: myEve' + '\n' +
    'data:' + '訊息內容2' + '\n' +
    'retry:' + '2000' + '\n' +
    'id:' + '12345' + '\n\n'
  )

  setInterval(() => { // 定時事件
    res.write('data:' + '定時訊息' + '\n\n')
  }, 2000)
})

// 監聽 6788
app.listen(6788, () => {
  console.log(`server runing on port 6788 ...`)
})
複製程式碼

客戶端訪問 http://127.0.0.1:6788/ 會看到如下的輸出:

利用WebSocket和EventSource實現服務端推送

來總結一下相關的api,客戶端的api很簡單都在註釋裡了,服務端有一些要注意的地方:

事件流格式?

事件流僅僅是一個簡單的文字資料流,文字應該使用UTF-8格式的編碼。每條訊息後面都由一個空行作為分隔符。以冒號開頭的行為註釋行,會被忽略。

註釋有何用?

註釋行可以用來防止連線超時,伺服器可以定期傳送一條訊息註釋行,以保持連線不斷。

EventSource規範中規定了那些欄位?

event: 事件型別,如果指定了該欄位,則在客戶端接收到該條訊息時,會在當前的EventSource物件上觸發一個事件,事件型別就是該欄位的欄位值,你可以使用addEventListener()方法在當前EventSource物件上監聽任意型別的命名事件,如果該條訊息沒有event欄位,則會觸發onmessage屬性上的事件處理函式。 data: 訊息的資料欄位,如果該條訊息包含多個data欄位,則客戶端會用換行符把它們連線成一個字串來作為欄位值。 id: 事件ID,會成為當前EventSource物件的內部屬性"最後一個事件ID"的屬性值。 retry: 一個整數值,指定了重新連線的時間(單位為毫秒),如果該欄位值不是整數,則會被忽略。

重連是幹什麼的?

上文提過retry欄位是用來指定重連時間的,那為什麼要重連呢,我們拿node來說,大家知道node的特點是單執行緒非同步io,單執行緒就意味著如果server端報錯那麼服務就會停掉,當然在node開發的過程中會處理這些異常,但是一旦服務停掉了這時就需要用pm2之類的工具去做重啟操作,這時候server雖然正常了,但是客戶端的EventSource連結還是斷開的這時候就用到了重連。

為什麼案例中訊息要用\n結尾?

\n是換行的轉義字元,EventSource規範規定每條訊息後面都由一個空行作為分隔符,結尾加一個\n表示一個欄位結束,加兩個\n表示一條訊息結束。(兩個\n表示換行之後又加了一個空行)

注: 如果一行文字中不包含冒號,則整行文字會被解析成為欄位名,其欄位值為空。



WebSocket的實現案例

WebSocket的客戶端原生api

var ws = new WebSocket('ws://localhost:8080') WebSocket 物件作為一個建構函式,用於新建 WebSocket 例項。

ws.onopen = function(){} 用於指定連線成功後的回撥函式。

ws.onclose = function(){} 用於指定連線關閉後的回撥函式

ws.onmessage = function(){} 用於指定收到伺服器資料後的回撥函式

ws.send('data') 例項物件的send()方法用於向伺服器傳送資料

socket.onerror = function(){} 用於指定報錯時的回撥函式

服務端的WebSocket如何實現

npm上有很多包對websocket做了實現比如 socket.io、WebSocket-Node、ws、還有很多,本文只對 socket.io以及ws 做簡單的分析,細節還請檢視官方文件。

socket.io和ws有什麼不同

Socket.io: Socket.io是一個WebSocket庫,包括了客戶端的js和伺服器端的nodejs,它會自動根據瀏覽器從WebSocket、AJAX長輪詢、Iframe流等等各種方式中選擇最佳的方式來實現網路實時應用(不支援WebSocket的情況會降級到AJAX輪詢),非常方便和人性化,相容性非常好,支援的瀏覽器最低達IE5.5。遮蔽了細節差異和相容性問題,實現了跨瀏覽器/跨裝置進行雙向資料通訊。

ws: 不像 socket.io 模組, ws 是一個單純的websocket模組,不提供向上相容,不需要在客戶端掛額外的js檔案。在客戶端不需要使用二次封裝的api使用瀏覽器的原生Websocket API即可通訊。

基於socket.io實現WebSocket雙向通訊

客戶端程式碼

<button id="closeSocket">斷開連線</button>
<button id="openSocket">恢復連線</button>
<script src="/socket.io/socket.io.js"></script>
<script>
// 建立連線 預設指向 window.location
let socket = io('http://127.0.0.1:6788')

openSocket.onclick = () => {
  socket.open()  // 手動開啟socket 也可以重新連線
}
closeSocket.onclick = () => {
  socket.close() // 手動關閉客戶端對伺服器的連結
}

socket.on('connect', () => { // 連線成功
  // socket.id是唯一標識,在客戶端連線到伺服器後被設定。
  console.log(socket.id)
})

socket.on('connect_error', (error) => {
  console.log('連線錯誤')
})
socket.on('disconnect', (timeout) => {
  console.log('斷開連線')
})
socket.on('reconnect', (timeout) => {
  console.log('成功重連')
})
socket.on('reconnecting', (timeout) => {
  console.log('開始重連')
})
socket.on('reconnect_error', (timeout) => {
  console.log('重連錯誤')
})

// 監聽服務端返回事件
socket.on('serverEve', (data) => {
  console.log('serverEve', data)
})

let num = 0
setInterval(() => {
  // 向服務端傳送事件
  socket.emit('feEve', ++num)
}, 1000)

複製程式碼

服務端程式碼(node.js)

const app = require('express')()
const server = require('http').Server(app)
const io = require('socket.io')(server, {})

// 啟動一個簡易的本地server返回index.html
app.get('/', (req, res) => {
  res.sendfile(__dirname + '/index.html')
})

// 監聽 6788
server.listen(6788, () => {
  console.log(`server runing on port 6788 ...`)
})

// 伺服器監聽所有客戶端 並返回該新連線物件
// 每個客戶端socket連線時都會觸發 connection 事件
let num = 0
io.on('connection', (socket) => {

  socket.on('disconnect', (reason) => {
    console.log('斷開連線')
  })
  socket.on('error', (error) => {
    console.log('發生錯誤')
  })
  socket.on('disconnecting', (reason) => {
    console.log('客戶端斷開連線但尚未離開')
  })

  console.log(socket.id) // 獲取當前連線進入的客戶端的id
  io.clients((error, ids) => {
    console.log(ids)  // 獲取已連線的全部客戶機的ID
  })

  // 監聽客戶端傳送的事件
  socket.on('feEve', (data) => {
    console.log('feEve', data)
  })
  // 給客戶端傳送事件
  setInterval(() => {
    socket.emit('serverEve', ++num)
  }, 1000)
})

/*
  io.close()  // 關閉所有連線
*/
複製程式碼

const io = require('socket.io')(server, {}) 第二個引數是配置項,可以傳入如下引數:

  • path: '/socket.io' 捕獲路徑的名稱
  • serveClient: false 是否提供客戶端檔案
  • pingInterval: 10000 傳送訊息的時間間隔
  • pingTimeout: 5000 在該時間下沒有資料傳輸連線斷開
  • origins: '*' 允許跨域
  • ...

上面基於socket.io的實現中 express 做為socket通訊的依賴服務基礎 socket.io 作為socket通訊模組,實現了雙向資料傳輸。最後,需要注意的是,在伺服器端 emit 區分以下三種情況:

  • socket.emit() :向建立該連線的客戶端傳送
  • socket.broadcast.emit() :向除去建立該連線的客戶端的所有客戶端傳送
  • io.sockets.emit() :向所有客戶端傳送 等同於上面兩個的和
  • io.to(id).emit() : 向指定id的客戶端傳送事件

基於ws實現WebSocket雙向通訊

客戶端程式碼

let num = 0
let ws = new WebSocket('ws://127.0.0.1:6788')
ws.onopen = (evt) => {
  console.log('連線成功')
  setInterval(() => {
    ws.send(++ num)  // 向伺服器傳送資料
  }, 1000)
}
ws.onmessage = (evt) => {
  console.log('收到服務端資料', evt.data)
}
ws.onclose = (evt) => {
  console.log('關閉')
}
ws.onerror = (evt) => {
  console.log('錯誤')
}
closeSocket.onclick = () => {
  ws.close()  // 斷開連線
}
複製程式碼

服務端程式碼(node.js)

const fs = require('fs')
const express = require('express')
const app = express()

// 啟動一個簡易的本地server返回index.html
const httpServer = app.get('/', (req, res) => {
  res.writeHead(200)
  fs.createReadStream('./index.html').pipe(res)
}).listen(6788, () => {
  console.log(`server runing on port 6788 ...`)
})

// ws
const WebSocketServer = require('ws').Server
const wssOptions = {  
  server: httpServer,
  // port: 6789,
  // path: '/test'
}
const wss = new WebSocketServer(wssOptions, () => {
  console.log(`server runing on port ws 6789 ...`)
})

let num = 1
wss.on('connection', (wsocket) => {
  console.log('連線成功')

  wsocket.on('message', (message) => {
    console.log('收到訊息', message)
  })
  wsocket.on('close', (message) => {
    console.log('斷開了')
  })
  wsocket.on('error', (message) => {
    console.log('發生錯誤')
  })
  wsocket.on('open', (message) => {
    console.log('建立連線')
  })

  setInterval(() => {
    wsocket.send( ++num )
  }, 1000)
})
複製程式碼
        上面程式碼中在 new WebSocketServer 的時候傳入了 server: httpServer 目的是統一埠,雖然 WebSocketServer 可以使用別的埠,但是統一埠還是更優的選擇,其實express並沒有直接佔用6788埠而是express呼叫了內建http模組建立了http.Server監聽了6788。express只是把響應函式註冊到該http.Server裡面。類似的,WebSocketServer也可以把自己的響應函式註冊到 http.Server中,這樣同一個埠,根據協議,可以分別由express和ws處理。我們拿到express建立的http.Server的引用,再配置到 wssOptions.server 裡讓WebSocketServer根據我們傳入的http服務來啟動,就實現了統一埠的目的。
        要始終注意,瀏覽器建立WebSocket時傳送的仍然是標準的HTTP請求。無論是WebSocket請求,還是普通HTTP請求,都會被http.Server處理。具體的處理方式則是由express和WebSocketServer注入的回撥函式實現的。WebSocketServer會首先判斷請求是不是WS請求,如果是,它將處理該請求,如果不是,該請求仍由express處理。所以,WS請求會直接由WebSocketServer處理,它根本不會經過express。

*案例倉庫:https://github.com/cp0725/YouChat/tree/master/webSocket-eventSource-test* *部分概念參考自 https://www.w3cschool.cn/socket/*

相關文章