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

可能有不少的同窗有用 setInterval 控制 ajax 不斷向服務端請求最新數據的經歷(輪詢)看下面的代碼:html

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 用來實現長輪詢,下面咱們來分析一下這二者的特色以及使用場景。node

WebSocket

是什麼: WebSocket是一種通信手段,基於TCP協議,默認端口也是80和443,協議標識符是ws(加密爲wss),它實現了瀏覽器與服務器的全雙工通訊,擴展了瀏覽器與服務端的通訊功能,使服務端也能主動向客戶端發送數據,不受跨域的限制。git

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

EventSource

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

有什麼用: 由於受單項通訊的限制EventSource只能用來實現像股票報價、新聞推送、實時天氣這些只須要服務器發送消息給客戶端場景中。EventSource的使用更加便捷這也是他的優勢。ajax

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的實現案例

客戶端代碼express

// 實例化 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)npm

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/ 會看到以下的輸出: api

來總結一下相關的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/*
相關文章
相關標籤/搜索