1. 程式人生 > >Web端即時通訊、訊息推送的實現

Web端即時通訊、訊息推送的實現

在瀏覽某些網頁的時候,例如 WebQQ、京東線上客服服務、CSDN私信訊息等類似的情況下,我們可以在網頁上進行線上聊天,或者即時訊息的收取與回覆,可見,這種功能的需求由來已久,並且應用廣泛。

網上關於這方面的文章也能搜到一大堆,不過基本上都是理論,真正能夠執行的程式碼很少,原理性的東西我就不當搬運工了,本文主要是貼示例程式碼,最多在程式碼中穿插一點便於理解,本文主要的示例程式碼基於 javascript,服務端基於 nodejskoa(1/2)框架實現。

模擬推送

Web端 常見的訊息推送實際上大多數都是模擬推送,之所以是模擬推送,是因為這種實現並不是伺服器主動推送,本質依舊是客戶端發起請求,服務端返回資料,起主動作用的是客戶端。

短輪詢

實現上最簡單的一種模擬推送方法,原理就是客戶端不斷地向服務端發請求,如果服務端資料有更新,服務端就把資料傳送回來,客戶端就能接收到新資料了。

一種實現的示例如下:

const loadXMLDoc = (url, callback) => {
  let xmlhttp
  if(window.XMLHttpRequest) {
    //  IE7+ Firefox Chrome Safari 等現代瀏覽器執行的程式碼
    xmlhttp = new XMLHttpRequest()
  } else {
    // IE5 IE6瀏覽器等老舊瀏覽器執行的程式碼
    xmlhttp = new
ActiveXObject('Microsoft.XMLHTTP') } xmlhttp.onreadystatechange = () => { if(xmlhttp.readyState === 4 && xmlhttp.status === 200) { document.getElementById('box1').innerHTML = xmlhttp.responseText callback && callback() } } // 開啟連結傳送請求 xmlhttp.open('GET'
, 'http://127.0.0.1:3000/' + url, true) xmlhttp.send() } // 輪詢 setInterval(function() { loadXMLDoc('fetchMsg') }, 2000)

上述程式碼,設定定時任務,每隔 2s使用 ajax發起一次請求,客戶端根據服務端返回的資料來進行決定執行對應的操作,除了傳送 ajax,你還可以使用 fetch

fetch('localhost:3000/fetchMsg', {
    headers: {
        'Accept': 'application/json, text/plain, */*'
    }
}

引申:fetch目前的瀏覽器支援度還很低,所以在實際生產環境中使用的時候,最好新增一些 polyfill,一種墊片使用順序示例如下:
- es5polyfilles5-shim
- Promisepolyfilles6-promise - IE8+
- fetchpolyfillfetch - IE10+

如果你在使用某種框架,例如 vue 或者 angular,那麼你同樣可以使用這些框架自帶的請求方法,總之基於頁面的友好訪問性,在傳送請求的同時不要重新整理頁面就行了。

這裡寫圖片描述

另外,如果不想在主程式的執行緒中做這種機械的輪詢,可以嘗試使用 Web Worker,下面是一個使用 Web Worker輪詢的例子:

// 在主程式中建立 Web Worker
function createWorker(fn) {
  const blob = new Blob([fn.toString()])
  const url = window.URL.createObjectURL(blob)
  const worker = new Worker(url)
  return worker
}

// 輪詢函式
const pollingWorker = createWorker(() => {
  let cache = null
  // 如果這一個 worker只幹這一件事,setInterval是不會受到干擾的
  setInterval(function() {
    fetch('/my-api').then(res => {
      const data = res.json()
      if (data !== cache) {
        // 輪詢的資料發生變化
        cache = data
        // worker向主程式傳送通知
        self.postMessage(data)
      }
    })
  }, 1000)
})

pollingWorker.onmessage = function() {
  // 主程式接收到 worker發來的通知
  // do something
}

優點:

前後端程式都很容易編寫,沒什麼技術難度

缺點:

這種方法因為需要對伺服器進行持續不斷的請求,就算你設定的請求間隔時間很長,但在使用者訪問量比較大的情況下,也很容易給伺服器帶來很大的壓力,而且絕大部分情況下都是無效請求,浪費頻寬和伺服器資源,一般不會用於實際生產環境的,自己知道一下就行了。

長輪詢

相比於上一種實現,長輪詢同樣是客戶端發起請求,服務端返回資料,只不過不同的是,在長輪詢的情況下,伺服器端在接到客戶端請求之後,如果發現數據庫中的資料並沒有更新或者不符合要求,那麼就不會立即響應客戶端,而是 hold住這次請求,直到符合要求的資料到達或者因為超時等原因才會關閉連線,客戶端在接收到新資料或者連線被關閉後,再次發起新的請求。

為了節約資源,一次長輪詢的週期時間最好在 10s ~ 25s左右,長連線也是實際生產環境中,被廣泛運用於實時通訊的技術。

客戶端程式碼如下:

function getData() {
  loadXMLDoc('holdFetchMsg', ()=>{
    getData()
  })
}
getData()

想要在連線斷開或發生錯誤的時候,再次發起請求連線,實現也很簡單,以下問使用 fetch 實現示例:

function getData() {
  let result = fetch('http://127.0.0.1:3000/holdFetchMsg', {
    headers: {
        'Accept': 'application/json, text/plain, */*'
    }
  })
  result.then(res => {
    return res.text()
  }).then(data => {
    document.getElementById('box1').innerHTML = data
  }).catch(e => {
    console.log('Catch Error:', e)
  }).then(() => {
    getData()
  })
}
getData()

一種較為直觀的伺服器 hold住連線的實現如下:

router.get('/holdFetchMsg', (ctx, next)=> {
  let i = 0
  while(true) {
    // 這裡的條件在實際環境中可以換成是到資料庫查詢資料的操作
    // 如果查詢到了符合要求的資料,再 break
    // 不過這種可能會導致伺服器進行例如瘋狂查詢資料庫的操作,非常不友好
    if(++i > 2222222222) {
      ctx.body = '做我的狗吧'
      break
    }
  }
})

還有一種方法,不過這種純粹是為了 hold住而 hold住,可以作為上一種方法的輔助,解決諸如服務端進行瘋狂查詢資料庫的操作,類似於 Java中的 Thread.sleep()操作

let delay = 2000, i = 0
 while(true) {
   let startTime = new Date().getTime()
   // 這裡的條件在實際環境中可以換成是到資料庫查詢資料的操作
   if(++i > 3) {
     ctx.body = '做我的狗吧'
     break
   } else {
     // 休息會,別那麼頻繁地進行諸如查詢資料庫的操作
     // delay 為每次查詢後 sleep的時間
     while(new Date().getTime() < startTime + delay);
   }
 }

如果你現在的 Nodejs版本支援 ES6中的 Generator的話,那麼還可以這樣(koa1環境, Generator寫法):

app.use(function* (next){
  let i = 0
  const sleep = ms => {
    return new Promise(function timer(resolve){
      setTimeout(()=>{
        if(++i > 3) {
          resolve()
        } else {
          timer(resolve)
        }
      }, ms)
    })
  }
  yield sleep(2000)
  this.body = '做我的狗吧'
})

如果你現在的 Nodejs版本支援 ES7中的 async/await的話,,那麼還有一種 hold住連線的方法可供選擇(koa2環境):

router.get('/holdFestchMsg', async(ctx, next) => {
    let i = 0
    const sleep = ms => {
       return new Promise(function timer(resolve) {
         setTimeout(async()=>{
           // 這裡的條件在實際環境中可以換成是到資料庫查詢資料的操作
           if(++i > 3) {
             resolve()
           } else {
             timer(resolve)
           }
         }, ms)
       })
     }
     await sleep(2000)
     ctx.body = '做我的狗吧'
})

這裡寫圖片描述

優點:

儘管長輪詢不可能做到每一次的響應都是有用的資料,因為伺服器超時或者客戶端網路環境的變化,以及服務端為了更好的分配資源而自動在一個心跳週期的末尾斷掉連線等原因,而導致長輪詢不可能一直存在,必須要不斷地進行斷開和連線操作,但無論如何,相比於短輪詢來說,長輪詢耗費資源明顯小了很多

缺點:

伺服器 hold連線依舊會消耗不少的資源,特別是當連線數很大的時候,返回資料順序無保證,難於管理維護。

長連線

這種是基於 iframe 或者 script實現的,主要原理大概就是在主頁面中插入一個隱藏的 iframe(script),然後這個 iframe(script)src屬性指向服務端獲取資料的介面,因為是iframe(script)是隱藏的,而且 iframe(script)的 重新整理也不會導致 主頁面重新整理,所以可以為這個 iframe(script)設定一個定時器,讓其每隔一段時間就朝伺服器傳送一次請求,這樣就能獲得服務端的最新資料了。

先說一下 利用 script的長連線:

前端實現:

<script>
  function callback(msg) {
    // 得到後端返回的資料
    console.log(msg);
  }
  function createScript() {
    let script = document.createElement('script')
    script.src = 'http://127.0.0.1:3000/fetchMsg'
    document.body.appendChild(script)
    document.body.removeChild(script)
  }
</script>
<script>
  window.onload = function() {
    setInterval(()=>{
      createScript()
    }, 3000)
  }
</script>

後端實現:

router.get('/fetchMsg', (ctx, next)=> {
  ctx.body = 'callback("做我的狗吧")'
})

主要是在前端,一共兩條 script指令碼,大致左右就是在一定的時間間隔內(示例為 3s)就動態地在頁面中增刪一個連結為用於請求後端資料的 script指令碼。

後端則返回一段字串,這段字串在返回前端時,有一個 callback欄位呼叫前端的程式碼,類似於 jsonp的請求。

注意:修改一個已經執行過的 script指令碼的 src屬性是沒什麼卵用的,修改之後,最多在頁面的 DOM上發生一些變化,而瀏覽器既不會發請求,也不會執行指令碼,所以這裡採用動態增刪整個 script標籤的做法。

可以看到,這種方法其實與短輪詢沒什麼區別,唯一的區別在於短輪詢保證每次請求都能收到響應,但上述示例的長連線不一定每次都能得到響應,如果下一次長連線開始請求,上一次連線還沒得到響應,則上一次連線將被終止。

當然,如果你想長連線每次也都能保證得到響應也是可以的,大致做法就是在頁面中插入不止一條 script標籤,每條標籤對應一個請求,等到當前請求到達再決定是否移除當前 script標籤。

如果想要得到有序的資料響應,則還可以將 setInterval換成遞迴呼叫,例如:

function createScript() {
  let script = document.createElement('script')
  script.src = 'http://127.0.0.1:3000/fetchMsg'
  document.body.appendChild(script)
  script.onload = ()=> {
    document.body.removeChild(script)
    // 約束輪詢的頻率
    setTimeout(()=>{
      createScript()
    }, 2000)
  }
}

window.onload = function() {
 createScript()
}

使用 iframe的方式與此類似,就不贅述了,不過需要注意的是, iframe可能存在跨域的情況,可能會比 script方式麻煩一些。

WebSocket

WebSoketHTML5新增的 API,具體介紹如下(來源w3c菜鳥教程

WebSocket是HTML5開始提供的一種在單個 TCP 連線上進行全雙工通訊的協議。

在WebSocket API中,瀏覽器和伺服器只需要做一個握手的動作,然後,瀏覽器和伺服器之間就形成了一條快速通道。兩者之間就直接可以資料互相傳送。

瀏覽器通過 JavaScript 向伺服器發出建立 WebSocket 連線的請求,連線建立以後,客戶端和伺服器端就可以通過 TCP 連線直接交換資料。

當你獲取 Web Socket 連線後,你可以通過 send() 方法來向伺服器傳送資料,並通過 onmessage 事件來接收伺服器返回的資料。

上面所提到的短輪詢、長輪詢、長連線,本質都是單向通訊,客戶端主動發起請求,服務端被動響應請求,但 WebSocket則已經是全雙工通訊了,也就是說無論是客戶端還是服務端都能主動向對方發起響應,伺服器具備了真正的 推送能力。

一段簡單的 客戶端 WebSocket程式碼示例如下:

function myWebSocket() {
  let ws = new WebSocket('ws://localhost:3000')
  ws.onopen = ()=> {
    console.log('send data')
    ws.send('client send data')
  }

  ws.onmessage = (e)=> {
    let receiveMsg = e.data
    console.log('client get data')
  }

  ws.onerror = (e)=>{
    console.log('Catch Error:', e)
  }

  ws.onclose = ()=> {
    console.log('ws close')
  }
}

想要讓客戶端的 WebSocket能夠連線上伺服器,服務端必須要具備能夠響應 WebSocket型別的請求才行,一般的伺服器是沒有自帶這種能力的,所以必須要對伺服器端程式程式碼做出些改變。

自己封裝伺服器端響應 WebSocket的程式碼可能會涉及到很底層的東西,所以一般都是使用第三方封裝好的庫,基於nodejsWebSocket庫有很多,ws 功能簡單, API形式更貼近於原生,大名鼎鼎的 socket.io 是與 Nodejs聯手開發,功能齊全,被廣泛運用於遊戲、實時通訊等應用。

以下給出一種基於 socket.io 實現 簡單客戶端和服務端通訊的示例:

客戶端:

// HTML
<body>
  <ul id="messages"></ul>
  <form action="" id="msgForm">
    <input id="m" autocomplete="off" /><input type="submit" class="submit" value="submit">
  </form>
</body>

// 引入 socket.io
<script src='/socket.io/socket.io.js'></script>
<script>
  function appendEle(parent, childValue, position = 'appendChild') {
    let child = document.createElement('li')
    child.innerHTML = childValue
    parent[position](child)
  }

  function socketIO(msgForm, msgBox, msgList) {
    const socket = io()
    msgForm.addEventListener('submit', (e)=>{
      e.preventDefault()
      socket.emit('chat message', msgBox.value)
      appendEle(msgList, '<b>Client: </b>' + msgBox.value)
      msgBox.value = ''
    })

    socket.on('chat message', (msg)=>{
      appendEle(msgList, msg)
    })
  }

  window.onload = ()=>{
    let msgForm = document.querySelector('#msgForm')
    let msgBox = document.querySelector('#m')
    let msgList = document.querySelector('#messages')
    socketIO(msgForm, msgBox, msgList)
  }
</script>

服務端實現:

const app = require('express')()
const http = require('http').Server(app)
const io = require('socket.io')(http)

app.get('/', (req, res)=> {
  res.sendFile(__dirname + '/index.html')
})

io.on('connection', socket=>{
  console.log('a user connected')
  socket.on('disconnect', ()=>{
    console.log('user disconnect')
  })
  socket.on('chat message', (msg)=>{
    console.log('clien get message: ', msg)
    setTimeout(()=>{
      io.emit('chat message', '<b>Server:</b>' + ' Are you Sure? -- Come from your father')
    }, 1500)
  })
})

http.listen(3000, ()=> {
  console.log('Server running at 3000.')
})

效果如下:

這裡寫圖片描述

WebSocket 的瀏覽器支援程式為 IE10+Android 4.4+,所以,如果不是 淘寶那樣量級超大的產品,應該都可以使用 這項應該不算是新技術的技術了。