乐趣区

关于前端:都2022年了实时更新数据你还只会用短轮询

背景

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

需要介绍

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

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

轮询

概念解释

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

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

代码实现

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

// node/polling.js

const 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.jsx

import {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.js
    
    const 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()})
    // 重置 subscribers
    subscribers.clear()
    // 勾销申请的超时回调
    timers.forEach(timer => clearTimeout(timer))
    // 重置 timers
    timers.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.jsx
    
    import {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.js

const 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.jsx

import {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.js

const 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.jsx
import {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 技术适宜一些只须要服务端单向推送事件给客户端的场景,例如股票行情推送软件。

    总结

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

集体技术动静

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

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

退出移动版