背景

在咱们的日常工作中,咱们往往会遇到客户端须要实时获取服务端最新数据的场景,例如聊天零碎(WeChat/Telegram),股票行情查看软件(同花顺/富途),feed推送零碎(Twitter/微博)等等。在实现这些需要的时候,咱们的技术计划是有很多的,本文将会给大家介绍四种常见的实时获取服务端数据的计划,它们别离是:短轮询(polling)长轮询(long polling)长连贯(WebSocket)以及服务器事件推送(Sever-Sent Events, aka SSE)。本篇文章将会介绍每种计划的基本原理,以及别离应用他们来实现同一个需要:动静事件列表,咱们用到的技术栈是React + 原生NodeJS

需要介绍

先说一下这个动静事件列表的需要:咱们的服务器每隔5秒会产生一个新的事件,每个事件都有一个id字段以及timestamp字段,id和timestamp字段都是该事件生成的工夫戳,前端会以列表的模式展现目前服务端已产生的所有事件信息,前面当服务器产生新的事件时,前端会获取到最新的事件并增加到页面列表的开端。

上面是我的项目的运行效果图:

轮询

概念解释

我置信大多数程序员或多或少都应用过轮询来获取服务端的资源,简略来说轮询就是客户端不停地调用服务端接口以取得最新的数据。下图是一个简略的轮询过程:

在上图中客户端在发动申请后服务端会立刻响应,不过因为这时服务端的数据没有更新所以返回了一个空的后果给客户端。客户端在期待了一段时间后(可能是几秒),再次申请服务端的数据,这时因为服务端的数据产生了更新,所以会给客户端返回最新的数据,客户端在拿到数据后期待一下而后持续发送申请,如此重复。

代码实现

上面就让咱们用轮询来实现动静事件列表的需要, 首先是Node代码:

// node/polling.jsconst http = require('http')const url = require('url')// 工夫列表const events = []// 最新生成的事件工夫let latestTimestamp = 0// 事件生产者const EventProducer = () => {  const event = {    id: Date.now(),    timestamp: Date.now()  }  events.push(event)  latestTimestamp = event.timestamp}// 每隔5秒生成一个新的事件setInterval(() => {  EventProducer()}, 5000)const server = http.createServer((req, resp) => {  const urlParsed = url.parse(req.url, true)  resp.setHeader('Access-Control-Allow-Origin', '*')  resp.setHeader('Origin', '*')  if (urlParsed.pathname == '/events') {    // 每次客户端都带上它最初拿到的事件工夫戳来获取新的事件    const timestamp = parseInt(urlParsed.query['timestamp'])    // 判断客户端是否拿到最新事件    if (timestamp < latestTimestamp) {      // 将所有没发送过给这个客户端的事件一次性发送进来      resp.write(JSON.stringify(events.filter(event => event.timestamp > timestamp)))    }    resp.end()  }})server.listen(8080, () => {  console.log('server is up')})

下面的代码非常简略,咱们实现了一个eventsAPI,前端每次都会带上上一次的工夫戳来申请这个工夫点后的最新事件。接着再来看一下前端的实现:

// react/Polling.jsximport { useEffect, useRef, useState } from 'react'const fetchLatestEvents = async (timestamp) => {  // 获取最新的事件  const body = await fetch(`http://localhost:8080/events?timestamp=${timestamp}`)  if (body.ok) {    const json = await body.json()    return json  } else {    console.error('failed to fetch')  }}function App() {  // 记录以后客户端拿到的最新事件的timestamp  const timestampRef = useRef(0)  const [events, setEvents] = useState([])    useEffect(() => {    const timer = setInterval(async () => {      const latestEvents = await fetchLatestEvents(timestampRef.current)      if (latestEvents && latestEvents.length) {        timestampRef.current = latestEvents[latestEvents.length - 1].timestamp        setEvents(events => [...events, ...latestEvents])      }    }, 3000)    return () => {      clearInterval(timer)    }  }, [])  return (    <div className="App">      <h2>event list</h2>      <ol>        {          events.map(event => {            return <li key={event.id}>{`${event.id}`}</li>          })        }      </ol>    </div>  );}export default App

关上Chrome的Devtools咱们发现,前端每隔3s向后端申请一次,申请得相当频繁,并且在后端没有产生新数据的时候,很多申请的返回值是空的,也就是说大多数的网络资源都被节约了

Polling的优缺点

从下面的代码咱们能够看出,短轮询这个技术计划最大的长处就是实现简略,而它的毛病也很显著:

  • 无用的申请多: 因为客户端不晓得服务端什么时候有数据更新,所以它只能不停地询问服务端,如果服务端的数据更新并不频繁的话,这些申请大多都是无用的。无用的申请会导致服务端的带宽占用减少,耗费服务端资源,同时如果客户端是一些挪动设施的话,耗电速度也会很快。
  • 数据实时性差: 因为不想耗费太多客户端或者服务端的资源,咱们通常在实现轮询时不会拿到上一个申请的后果后立刻发送第二个申请,这就导致了即便服务端的数据更新了,咱们客户端还是须要一段时间能力拿到最新的数据,这对于一些数据实时性要求高的利用例如IM零碎是致命的。

    应用场景

    个别生产级别的利用都不会应用短轮询这个计划,除非你只是写一些给多数人用的零碎。

    长轮询

    看完了下面对于短轮询的介绍,咱们晓得了轮询有两个重大的缺点:一个是无用申请过多,另外一个是数据实时性差。为了解决这两个问题,某些聪慧的程序员就创造了另外一个计划:长轮询。上面是一个简略的长轮询示意图:

    在上图中,客户端发动申请后,服务端发现以后没有新的数据,这个时候服务端没有立刻返回申请,而是将申请挂起,在期待一段时间后(个别为30s或者是60s),发现还是没有数据更新的话,就返回一个空后果给客户端。客户端在收到服务端的回复后,立刻再次向服务端发送新的申请。这次服务端在接管到客户端的申请后,同样期待了一段时间,这次好运的是服务端的数据产生了更新,服务端给客户端返回了最新的数据。客户端在拿到后果后再次发送下一个申请,如此重复。

    代码实现

    接着就让咱们应用长轮询来动静实现事件列表的性能,先看一下后端代码:

    // node/long-polling.jsconst http = require('http')const url = require('url')const events = []let timers = new Set()// 以后挂起的申请let subscribers = new Set()const EventProducer = () => {const event = {  id: Date.now(),  timestamp: Date.now()}events.push(event)// 告诉所有挂起的申请subscribers.forEach(subscriber => {  subscriber.resp.write(JSON.stringify(events.filter(event => event.timestamp > subscriber.timestamp)))  subscriber.resp.end()})// 重置subscriberssubscribers.clear()// 勾销申请的超时回调timers.forEach(timer => clearTimeout(timer))// 重置timerstimers.clear()}// 5秒生成一个事件setInterval(() => {EventProducer()}, 5000)const server = http.createServer((req, resp) => {const urlParsed = url.parse(req.url, true)resp.setHeader('Access-Control-Allow-Origin', '*')resp.setHeader('Origin', '*')if (urlParsed.pathname == '/list') {  // 发送服务端现存事件  resp.write(JSON.stringify(events))  resp.end()} else if (urlParsed.pathname == '/subscribe') {  const timestamp = parseInt(urlParsed.query['timestamp'])  const subscriber = {    timestamp,    resp  }  // 新建的连贯挂起来  subscribers.add(subscriber)    // 30s超时,主动敞开连贯  const timer = setTimeout(() => {    resp.end()    timers.delete(timer)  }, 30000)    // 客户端被动断开连接  req.on('close', () => {    subscribers.delete(subscriber)    clearTimeout(timer)  })    timers.add(timer)}})server.listen(8080, () => {console.log('server is up')})

    下面的代码中每来一个新的连贯咱们都会将它挂起来(保留在set外面),而后当有新的事件产生时再将所有该客户端没有获取过的事件返回给它,接着来看一下前端代码的实现:

    // react/LongPolling.jsximport { useEffect, useRef, useState } from 'react'const fetchLatestEvents = async (timestamp) => {const body = await fetch(`http://localhost:8080/subscribe?timestamp=${timestamp}`)if (body.ok) {  const json = await body.json()  return json} else {  console.error('failed to fetch')}}const listEvents = async () => {const body = await fetch(`http://localhost:8080/list`)if (body.ok) {  const json = await body.json()  return json} else {  console.error('failed to fetch')}}function App() {const timestampRef = useRef(0)const eventsRef = useRef([])const [refresh, setRefresh] = useState(false)useEffect(() => {  const fetchTask = async () => {    if (timestampRef.current === 0) {      // 首次加载      const currentEvents = await listEvents()      timestampRef.current = currentEvents[currentEvents.length - 1].timestamp      eventsRef.current = [...eventsRef.current, ...currentEvents]    }    const latestEvents = await fetchLatestEvents(timestampRef.current)    if (latestEvents && latestEvents.length) {      timestampRef.current = latestEvents[latestEvents.length - 1].timestamp      eventsRef.current = [...eventsRef.current, ...latestEvents]    }  }  fetchTask()    .catch(err => {      console.error(err)    })    .finally(() => {      // 触发下次加载      setRefresh(refresh => !refresh)    })}, [refresh])return (  <div className="App">    <h2>event list</h2>    <ol>      {        eventsRef.current.map(event => {          return <li key={event.id}>{`${event.id}`}</li>        })      }    </ol>  </div>);}export default App

    值得注意的是,这个时候,咱们关上浏览器的调试工具能够发现浏览器每一次收回的申请都不会立马收到回复,而是pending一段时间后(大略是5秒)才会有后果,并且后果外面都是有数据的。

Long Polling的优缺点

长轮询很完满地解决了短轮询的问题,首先服务端在没有数据更新的状况下没有给客户端返回数据,所以防止了客户端大量的反复申请。再者客户端在收到服务端的返回后,马上发送下一个申请,这就保障了更好的数据实时性。不过长轮询也不是完满的:

  • 服务端资源大量耗费: 服务端会始终hold住客户端的申请,这部分申请会占用服务器的资源。对于某些语言来说,每一个HTTP连贯都是一个独立的线程,过多的HTTP连贯会消耗掉服务端的内存资源。
  • 难以解决数据更新频繁的状况: 如果数据更新频繁,会有大量的连贯创立和重建过程,这部分耗费是很大的。尽管HTTP的keep-alive字段能够解决一部分问题,不过每次拿到数据后客户端都须要从新subscribe,因而绝对于WebSocket和SSE它多了一个发送新申请的阶段,对实时性和性能还是有影响的。

利用场景

从网上找的材料来看之前的WebQQ和Web微信都是基于长轮询实现的,当初是不是我就不晓得了,有趣味的读者能够自行验证一下。

WebSocket

概念解释

下面说到长轮询不适用于服务端资源频繁更新的场景,而解决这类问题的一个计划就是WebSocket。用最简略的话来介绍WebSocket就是:客户端和服务器之间建设一个长久的长连贯,这个连贯是双工的,客户端和服务端都能够实时地给对方发送音讯。上面是WebSocket的图示:


在上图中,首先客户端会给服务端发送一个HTTP申请,这个申请的Header会通知服务端它想基于WebSocket协定通信,如果服务端反对降级协定的话,会给客户端发送一个Switching Protocal的响应,它们之间前面都是基于WebSocket协定来通信了。

代码实现

咱们再来看一下如何应用WebSocket来实现动静事件列表的需要,上面是后端代码:

// node/websocket.jsconst WebSocket = require('ws')const events = []let latestTimestamp = Date.now()const clients = new Set()const EventProducer = () => {  const event = {    id: Date.now(),    timestamp: Date.now()  }  events.push(event)  latestTimestamp = event.timestamp    // 推送给所有连贯着的socket  clients.forEach(client => {    client.ws.send(JSON.stringify(events.filter(event => event.timestamp > client.timestamp)))    client.timestamp = latestTimestamp  })}// 每5秒生成一个新的事件setInterval(() => {  EventProducer()}, 5000)// 启动socket服务器const wss = new WebSocket.Server({ port: 8080 })wss.on('connection', (ws, req) => {  console.log('client connected')  // 首次连贯,推送现存事件  ws.send(JSON.stringify(events))    const client = {    timestamp: latestTimestamp,    ws,  }  clients.add(client)  ws.on('close', _ => {    clients.delete(client)  })})

下面的代码中客户端连贯服务端的时候,服务端会记住客户端的工夫戳,当新事件产生的时候会给客户端推送所有的新事件。上面是前端代码实现:

// react/LongPolling.jsximport { useEffect, useRef, useState } from 'react'function App() {  const timestampRef = useRef(0)  const eventsRef = useRef([])  const [_, setRefresh] = useState(false)    useEffect(() => {    const ws = new WebSocket(`ws://localhost:8080/ws?timestamp=${timestampRef.current}`)        ws.addEventListener('open', e => {      console.log('successfully connected')    })        ws.addEventListener('close', e => {      console.log('socket close')    })        ws.addEventListener('message', (ev) => {      const latestEvents = JSON.parse(ev.data)      if (latestEvents && latestEvents.length) {        timestampRef.current = latestEvents[latestEvents.length - 1].timestamp        eventsRef.current = [...eventsRef.current, ...latestEvents]        setRefresh(refresh => !refresh)      }    })        return () => {      ws.close()    }  }, [])  return (    <div className="App">      <h2>event list</h2>      <ol>        {          eventsRef.current.map(event => {            return <li key={event.id}>{`${event.id}`}</li>          })        }      </ol>    </div>  );}export default App

关上Chrome的网络调试工具点击ws,你会发现客户端和服务端只有一个websocket连贯,它们所有的通信都是产生在这个连贯下面的:

WebSocket的优缺点

总的来说,我认为WebSocket有上面这些长处:

  • 客户端和服务端建设连贯的次数少:现实状况下客户端只须要发送一个HTTP降级协定就能够降级到WebSocket连贯,前面所有的音讯都是通过这个通道进行通信,无需再次建设连贯。
  • 音讯实时性高:因为客户端和服务端的连贯是始终建设的,所以当数据更新的时候能够马上推送给客户端。
  • 双工通信:服务端和客户端都能够随时给对方发送音讯,这对于本文的其它三种计划都是很难做到的。
  • 实用于服务端数据频繁更新的场景:和长轮询不同,服务端能够随时给客户端推送新的信息,而客户端在拿到信息后不须要从新建设连贯或者发送申请,因而WebSocket适宜于数据频繁更新的场景。

同样WebSocket也不是完满的,它有上面这些问题:

  • 扩容麻烦:基于WebSocket的服务是有状态的。这就意味着在扩容的时候很麻烦,零碎设计也会较简单。
  • 代理限度:某些代理层软件(如Nginx)默认配置的长连接时间是有限度的,可能只有几十秒,这个时候客户端须要主动重连。要想冲破这个限度你就须要将从客户端到服务端之间所有的代理层的配置都改掉,在事实中这可能是不可行的。

利用场景

WebSocket的利用场景是一些实时性要求很高的而且须要双工通信的零碎例如IM软件等。

Server-Sent Events

概念解释

Server-Sent Events简称SSE,是一个基于HTTP协定的服务端向客户端推送数据的技术。上面是一个简略的SSE图示:

在上图中,客户端向服务端发动一个长久化的HTTP连贯,服务端接管到申请后,会挂起客户端的申请,有新音讯时,再通过这个连贯将数据推送给客户端。这里须要指出的是和WebSocket长连贯不同,SSE的连贯是单向的,也就是说它不容许客户端向服务端发送音讯

代码实现

和下面一样,咱们应用SSE来实现一下动静事件列表的需要,先看后端代码:

// node/sse.jsconst http = require('http')const events = []const clients = new Set()let latestTimestamp = Date.now()const headers = {  // 通知HTTP连贯,它是一个event-stream的申请  'Content-Type': 'text/event-stream',  // 放弃HTTP连接不断开  'Connection': 'keep-alive',  'Cache-Control': 'no-cache',  'Access-Control-Allow-Origin': '*',  "Origin": '*'}const EventProducer = () => {  const event = {    id: Date.now(),    timestamp: Date.now()  }  events.push(event)  latestTimestamp = event.timestamp  clients.forEach(client => {    client.resp.write(`id: ${(new Date()).toLocaleTimeString()}\n`)    // 前面的两个\n\n肯定要有,能够了解为服务端先客户端推送信息的非凡格局    client.resp.write(`data: ${JSON.stringify(events.filter(event => event.timestamp > client.timestamp))}\n\n`)    client.timestamp = latestTimestamp  })}// 每5秒生成一个新的事件setInterval(() => {  EventProducer()}, 5000)const server = http.createServer((req, resp) => {  const urlParsed = url.parse(req.url, true)  if (urlParsed.pathname == '/subscribe') {    resp.writeHead(200, headers)        // 发送现存事件    resp.write(`id: ${(new Date()).toLocaleTimeString()}\n`)    resp.write(`data: ${JSON.stringify(events)}\n\n`)       const client = {      timestamp: latestTimestamp,      resp    }        clients.add(client)    req.on('close', () => {      clients.delete(client)    })  }})server.listen(8080, () => {  console.log('server is up')})

在下面的代码中,每次客户端给服务端发送申请后,服务端先给客户端返回所有的现存事件而后将该申请挂起,在新的事件生成时再给客户端返回所有的新事件。上面是前端代码实现:

// react/SSE.jsximport { useEffect, useRef, useState } from 'react'function App() {  const timestampRef = useRef(0)  const eventsRef = useRef([])  const [, setRefresh] = useState(false)    useEffect(() => {    const source = new EventSource(`http://localhost:8080/subscribe?timestamp=${timestampRef.current}`)        source.onopen = () => {      console.log('connected')    }        source.onmessage = event => {      const latestEvents = JSON.parse(event.data)      if (latestEvents.length) {        timestampRef.current = latestEvents[latestEvents.length - 1].timestamp        eventsRef.current = [...eventsRef.current, ...latestEvents]        setRefresh(refresh => !refresh)      }    }        source.addEventListener('error', (e) => {      console.error('Error: ',  e);    })    return () => {      source.close()    }  }, [])  return (    <div className="App">      <h2>event list</h2>      <ol>        {          eventsRef.current.map(event => {            return <li key={event.id}>{`${event.id}`}</li>          })        }      </ol>    </div>  );}export default App

关上Chrome的网络调试工具,会发现HTTP申请变成了EventStream类型,而且服务端给客户端所有的事件推送都在这个连贯上,而无需建设新的连贯。

SSE的优缺点

在我看来,SSE的技术有上面的长处:

  • 连接数少: 客户端和服务端只有一个长久的HTTP连贯,因而性能也是很好的。
  • 数据实时性高: 它比长轮询更加实时,因为服务端和客户端的连贯是长久的,所以有新音讯的话能够间接推送到客户端。

SSE的问题也很显著:

  • 单向通信: SSE长连贯是单向的,不容许客户端给服务端推送数据。
  • 代理层限度: 和WebSocket一样会遇到代理层配置的问题,配置谬误的话,客户端须要一直和服务端进行重连。

    应用场景

    SSE技术适宜一些只须要服务端单向推送事件给客户端的场景,例如股票行情推送软件。

    总结

    在本篇文章中我通过图解和理论代码给大家介绍了四种不同的和服务端保持数据同步的计划,看完本篇文章后,置信你前面再遇到相似的需要时,除了短轮询你会有更多的计划能够抉择。同时这里也还是要强调一下:任何一种技术都不是瑞士军刀,都有本人实用和不实用的场景,肯定要依据本人的理论状况进行取舍,从而抉择最适宜的计划,千万不要为了用某个技术而用某个技术

集体技术动静

创作不易,如果你从这篇文章中学到货色,请给我点一下赞或者关注,你的反对是我持续创作的最大能源!

同时欢送关注公众号进击的大葱一起学习成长