微信小程序中使用Server-Sent Events(SSE)

该文章已生成可运行项目,

SSE简介及与WebSocket的区别

SSE 是一种在浏览器和服务器之间建立单向通信的技术,允许服务器推送数据到客户端。它基于HTTP/1.1或HTTP/2协议,通过持久的 HTTP 连接发送数据。其工作方式为,客户端通过HTTP请求建立连接,服务器持续发送数据,客户端接收数据但不能主动发送数据给服务器。适合需要频繁向客户端推送数据的应用,例如实时更新、通知、新闻推送等。

而WebSocket 是一种全双工通信协议,它允许客户端和服务器在单个连接上进行双向数据交换。相比于传统的 HTTP,它可以在一个持续的连接上发送和接收数据。其工作方式为,客户端和服务器通过HTTP握手升级为WebSocket连接,连接建立后,客户端和服务器都可以主动发送数据。WebSocket适合需要频繁双向通信的应用,例如聊天应用、在线游戏、实时协作等。

SSE 和 WebSocket 的主要区别

通信方式:
    SSE:单向通信,服务器可以推送数据到客户端,但客户端不能向服务器发送数据。
    WebSocket:双向通信,客户端和服务器都可以发送和接收数据。
协议:
    SSE:基于HTTP/1.1或HTTP/2 协议,易于集成在现有的 HTTP 基础设施中。
    WebSocket:独立于HTTP协议,虽然使用HTTP握手,但一旦连接建立,就切换到 WebSocket 协议。
连接:
    SSE:保持长时间的HTTP连接,连接中断时可以自动重连。
    WebSocket:建立持久的连接,允许实时双向通信。
浏览器支持:
    SSE:原生支持大多数现代浏览器,但对Internet Explorer支持较差,微信小程序中也不支持
    WebSocket:现代浏览器和许多服务器框架都广泛支持WebSocket。

复杂性:

    SSE:实现相对简单,因为它是基于HTTP的扩展。
    WebSocket:实现更复杂,但提供了强大的双向通信功能。
选择依据:
  SSE:适合轻量级的服务器推送应用,数据流量少,且不需要双向通信。
  WebSocket:适合需要双向通信、实时互动的应用,例如多人协作、在线游戏等。

项目选型

笔者的项目为一个物联网监控项目,需要在微信小程序接受服务器端推送的设备状态,因此需要在WebSocket和SSE两个推送方案中二选一。由于小程序只需要接受数据,不需要发送,因此使用SSE似乎更好,但是后续的调研给了笔者当头一棒:微信小程序框架并不支持SSE!但是使用WebSocket有些杀鸡用牛刀,而且不利于复用现有的部分代码(比如鉴权、路由前一些资源的注册等),因此决定再查询一下有没有其他方案。经过一段时间的研究,发现可以设置wx.request使用chunked方式流式处理响应来模拟SSE的行为,之后又参考了eventsource这个垫片库,最后实现了微信小程序下的SSE库。

代码

代码eventsource.js如下

import events from "events"
import util from "util"
import { URL } from "core-js"

const bom = [239, 187, 191]
const colon = 58
const space = 32
const lineFeed = 10
const carriageReturn = 13
// Beyond 256KB we could not observe any gain in performance
const maxBufferAheadAllocation = 1024 * 256
// Headers matching the pattern should be removed when redirecting to different origin
const reUnsafeHeader = /^(cookie|authorization)$/i

function hasBom (buf) {
  return bom.every(function (charCode, index) {
    return buf[index] === charCode
  })
}

/**
 * Creates a new EventSource object
 *
 * @param {String} url the URL to which to connect
 * @param {String} method request method
 * @param {Object} payload payload of request
 * @param {Object} [eventSourceInitDict] extra init params. Check {@link https://developers.weixin.qq.com/miniprogram/dev/api/network/request/wx.request.html} for details.
 * @api public
 **/
function EventSource (url, method='GET', payload={}, eventSourceInitDict={}) {
  let readyState = EventSource.CONNECTING
  let headers = eventSourceInitDict && eventSourceInitDict.header
  let hasNewOrigin = false
  Object.defineProperty(this, 'readyState', {
    get: function () {
      return readyState
    }
  })

  Object.defineProperty(this, 'url', {
    get: function () {
      return url
    }
  })

  const self = this
  self.reconnectInterval = 1000
  self.connectionInProgress = false
  
  if (eventSourceInitDict && eventSourceInitDict.reconnectInterval) self.reconnectInterval = eventSourceInitDict.reconnectInterval

  function onConnectionClosed (message) {
    if (readyState === EventSource.CLOSED) return
    readyState = EventSource.CONNECTING

    // The url may have been changed by a temporary redirect. If that's the case,
    // revert it now, and flag that we are no longer pointing to a new origin
    if (reconnectUrl) {
      url = reconnectUrl
      reconnectUrl = null
      hasNewOrigin = false
    }
    setTimeout(function () {
      if (readyState !== EventSource.CONNECTING || self.connectionInProgress) {
        return
      }
      self.connectionInProgress = true
      connect()
    }, self.reconnectInterval)
  }

  let req
  let lastEventId = ''
  if (headers && headers['Last-Event-ID']) {
    lastEventId = headers['Last-Event-ID']
    delete headers['Last-Event-ID']
  }

  let discardTrailingNewline = false
  let data = ''
  let eventName = ''

  let reconnectUrl = null

  function connect () {
    const options = {url: url}
    options.header = { 'Cache-Control': 'no-cache', 'Accept': 'text/event-stream' }
    if (lastEventId) options.header['Last-Event-ID'] = lastEventId
    if (headers) {
      const reqHeaders = hasNewOrigin ? removeUnsafeHeaders(headers) : headers
      for (let i in reqHeaders) {
        let header = reqHeaders[i]
        if (header) {
          options.header[i] = header
        }
      }
    }
	
	options.enableChunked = true,
	options.responseType = 'text'
	options.dataType = 'text'
	options.method = method
	options.data = payload
	options.timeout = 10000
	options.enableCache = false
	options.redirect = "follow"
	
	if (eventSourceInitDict && eventSourceInitDict.timeout) options.timeout = eventSourceInitDict.timeout
	if (eventSourceInitDict && eventSourceInitDict.enableCache) options.enableCache = eventSourceInitDict.enableCache
	if (eventSourceInitDict && eventSourceInitDict.redirect) options.redirect = eventSourceInitDict.redirect
	if (eventSourceInitDict && eventSourceInitDict.enableProfile) options.enableProfile = eventSourceInitDict.enableProfile
	if (eventSourceInitDict && eventSourceInitDict.enableHttpDNS) options.enableHttpDNS = eventSourceInitDict.enableHttpDNS
	if (eventSourceInitDict && eventSourceInitDict.httpDNSServiceId) options.httpDNSServiceId = eventSourceInitDict.httpDNSServiceId
	if (eventSourceInitDict && eventSourceInitDict.forceCellularNetwork) options.forceCellularNetwork = eventSourceInitDict.forceCellularNetwork
	

	options.success = (res) => {
		self.connectionInProgress = false
		// Handle HTTP errors
		if (res.statusCode === 500 || res.statusCode === 502 || res.statusCode === 503 || res.statusCode === 504) {
		  _emit('error', new Event('error', {status: res.statusCode, message: res.data}))
		  onConnectionClosed()
		  return
		}
		
		// Handle HTTP redirects
		if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) {
		  const location = res.header.location
		  if (!location) {
		    // Server sent redirect response without Location header.
		    _emit('error', new Event('error', {status: res.statusCode, message: res.statusMessage}))
		    return
		  }
		  const prevOrigin = new URL(url).origin
		  const nextOrigin = new URL(location).origin
		  hasNewOrigin = prevOrigin !== nextOrigin
		  if (res.statusCode === 307) reconnectUrl = url
		  url = location
		  setImmediate(connect)
		  return
		}
		
		if (res.statusCode !== 200) {
		  _emit('error', new Event('error', {status: res.statusCode, message: res.statusMessage}))
		  return self.close()
		}
	}
	
	options.fail = (err) => {
		_emit('error', new Event('error', {status: err.errno, message: err.errMsg}))
	    self.connectionInProgress = false
	    onConnectionClosed(err.message)
		return
	}
	
	req = wx.request(options)
	
	req.onHeadersReceived(res => {
		readyState = EventSource.OPEN
		_emit('open', new Event('open'))
	})
	
	let buf
	let newBuffer
	let startingPos = 0
	let startingFieldLength = -1
	let newBufferSize = 0
	let bytesUsed = 0
	
	req.onChunkReceived(chunk => {
		chunk = Buffer.from(chunk.data)
		if (!buf) {
		  buf = chunk
		  if (hasBom(buf)) {
		    buf = buf.slice(bom.length)
		  }
		  bytesUsed = buf.length
		} else {
		  if (chunk.length > buf.length - bytesUsed) {
		    newBufferSize = (buf.length * 2) + chunk.length
		    if (newBufferSize > maxBufferAheadAllocation) {
		      newBufferSize = buf.length + chunk.length + maxBufferAheadAllocation
		    }
		    newBuffer = Buffer.alloc(newBufferSize)
		    buf.copy(newBuffer, 0, 0, bytesUsed)
		    buf = newBuffer
		  }
		  chunk.copy(buf, bytesUsed)
		  bytesUsed += chunk.length
		}
		
		let pos = 0
		const length = bytesUsed
		
		while (pos < length) {
		  if (discardTrailingNewline) {
		    if (buf[pos] === lineFeed) {
		      ++pos
		    }
		    discardTrailingNewline = false
		  }
		
		  let lineLength = -1
		  let fieldLength = startingFieldLength
		  let c
		
		  for (let i = startingPos; lineLength < 0 && i < length; ++i) {
		    c = buf[i]
		    if (c === colon) {
		      if (fieldLength < 0) {
		        fieldLength = i - pos
		      }
		    } else if (c === carriageReturn) {
		      discardTrailingNewline = true
		      lineLength = i - pos
		    } else if (c === lineFeed) {
		      lineLength = i - pos
		    }
		  }
		
		  if (lineLength < 0) {
		    startingPos = length - pos
		    startingFieldLength = fieldLength
		    break
		  } else {
		    startingPos = 0
		    startingFieldLength = -1
		  }
		
		  parseEventStreamLine(buf, pos, fieldLength, lineLength)
		
		  pos += lineLength + 1
		}
		
		if (pos === length) {
		  buf = void 0
		  bytesUsed = 0
		} else if (pos > 0) {
		  buf = buf.slice(pos, bytesUsed)
		  bytesUsed = buf.length
		}
	})
  }
  self.connectionInProgress = true
  connect()

  function _emit () {
    if (self.listeners(arguments[0]).length > 0) {
      self.emit.apply(self, arguments)
    }
  }

  this._close = function () {
    if (readyState === EventSource.CLOSED) return
    readyState = EventSource.CLOSED
    if (req) req.abort()
  }

  function parseEventStreamLine (buf, pos, fieldLength, lineLength) {
    if (lineLength === 0) {
      if (data.length > 0) {
        const type = eventName || 'message'
        _emit(type, new MessageEvent(type, {
          data: data.slice(0, -1), // remove trailing newline
          lastEventId: lastEventId,
          origin: new URL(url).origin
        }))
        data = ''
      }
      eventName = void 0
    } else if (fieldLength > 0) {
      const noValue = fieldLength < 0
      let step = 0
      const field = buf.slice(pos, pos + (noValue ? lineLength : fieldLength)).toString()

      if (noValue) {
        step = lineLength
      } else if (buf[pos + fieldLength + 1] !== space) {
        step = fieldLength + 1
      } else {
        step = fieldLength + 2
      }
      pos += step

      const valueLength = lineLength - step
      const value = buf.slice(pos, pos + valueLength).toString()

      if (field === 'data') {
        data += value + '\n'
      } else if (field === 'event') {
        eventName = value
      } else if (field === 'id') {
        lastEventId = value
      } else if (field === 'retry') {
        const retry = parseInt(value, 10)
        if (!Number.isNaN(retry)) {
          self.reconnectInterval = retry
        }
      }
    }
  }
}

util.inherits(EventSource, events.EventEmitter)
EventSource.prototype.constructor = EventSource; // make stacktraces readable

['open', 'error', 'message'].forEach(function (method) {
  Object.defineProperty(EventSource.prototype, 'on' + method, {
    /**
     * Returns the current listener
     *
     * @return {Mixed} the set function or undefined
     * @api private
     */
    get: function get () {
      const listener = this.listeners(method)[0]
      return listener ? (listener._listener ? listener._listener : listener) : undefined
    },

    /**
     * Start listening for events
     *
     * @param {Function} listener the listener
     * @return {Mixed} the set function or undefined
     * @api private
     */
    set: function set (listener) {
      this.removeAllListeners(method)
      this.addEventListener(method, listener)
    }
  })
})

/**
 * Ready states
 */
Object.defineProperty(EventSource, 'CONNECTING', {enumerable: true, value: 0})
Object.defineProperty(EventSource, 'OPEN', {enumerable: true, value: 1})
Object.defineProperty(EventSource, 'CLOSED', {enumerable: true, value: 2})

EventSource.prototype.CONNECTING = 0
EventSource.prototype.OPEN = 1
EventSource.prototype.CLOSED = 2

/**
 * Closes the connection, if one is made, and sets the readyState attribute to 2 (closed)
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/close
 * @api public
 */
EventSource.prototype.close = function () {
  this._close()
}

/**
 * Emulates the W3C Browser based WebSocket interface using addEventListener.
 *
 * @param {String} type A string representing the event type to listen out for
 * @param {Function} listener callback
 * @see https://developer.mozilla.org/en/DOM/element.addEventListener
 * @see http://dev.w3.org/html5/websockets/#the-websocket-interface
 * @api public
 */
EventSource.prototype.addEventListener = function addEventListener (type, listener) {
  if (typeof listener === 'function') {
    // store a reference so we can return the original function again
    listener._listener = listener
    this.on(type, listener)
  }
}

/**
 * Emulates the W3C Browser based WebSocket interface using dispatchEvent.
 *
 * @param {Event} event An event to be dispatched
 * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent
 * @api public
 */
EventSource.prototype.dispatchEvent = function dispatchEvent (event) {
  if (!event.type) {
    throw new Error('UNSPECIFIED_EVENT_TYPE_ERR')
  }

  this.emit(event.type, event)
}

/**
 * Emulates the W3C Browser based WebSocket interface using removeEventListener.
 *
 * @param {String} type A string representing the event type to remove
 * @param {Function} listener callback
 * @see https://developer.mozilla.org/en/DOM/element.removeEventListener
 * @see http://dev.w3.org/html5/websockets/#the-websocket-interface
 * @api public
 */
EventSource.prototype.removeEventListener = function removeEventListener (type, listener) {
  if (typeof listener === 'function') {
    listener._listener = undefined
    this.removeListener(type, listener)
  }
}

/**
 * W3C Event
 *
 * @see http://www.w3.org/TR/DOM-Level-3-Events/#interface-Event
 * @api private
 */
function Event (type, optionalProperties) {
  Object.defineProperty(this, 'type', { writable: false, value: type, enumerable: true })
  if (optionalProperties) {
    for (let f in optionalProperties) {
      if (optionalProperties.hasOwnProperty(f)) {
        Object.defineProperty(this, f, { writable: false, value: optionalProperties[f], enumerable: true })
      }
    }
  }
}

/**
 * W3C MessageEvent
 *
 * @see http://www.w3.org/TR/webmessaging/#event-definitions
 * @api private
 */
function MessageEvent (type, eventInitDict) {
  Object.defineProperty(this, 'type', { writable: false, value: type, enumerable: true })
  for (let f in eventInitDict) {
    if (eventInitDict.hasOwnProperty(f)) {
      Object.defineProperty(this, f, { writable: false, value: eventInitDict[f], enumerable: true })
    }
  }
}

/**
 * Returns a new object of headers that does not include any authorization and cookie headers
 *
 * @param {Object} headers An object of headers ({[headerName]: headerValue})
 * @return {Object} a new object of headers
 * @api private
 */
function removeUnsafeHeaders (headers) {
  const safe = {}
  for (var key in headers) {
    if (reUnsafeHeader.test(key)) {
      continue
    }

    safe[key] = headers[key]
  }

  return safe
}

export default EventSource

调用方式

假设服务器端实现了一个基于koa2的时间推送服务,每隔5秒推送一次当前时间:

import KoaRouter from "koa-router"
import { PassThrough } from "stream"

const router = new KoaRouter()
export default router
  .get('/sse', async (ctx) => {
    console.log("============ 建立SSE连接")
    // 设置适当的头信息
    ctx.set({
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        "Connection": "keep-alive",
    });
    const stream = new PassThrough();
    ctx.status = 200;
    ctx.body = stream;
    // 定义发送消息的函数
    const sendSSE = (data) => {
        stream.write(`data: ${data}\n\n`);
        console.log('send data:', data)
    };
    // 示例:每隔5秒推送一条消息
    const intervalId = setInterval(() => {
        sendSSE(`Current time: ${new Date().toLocaleTimeString()}`);
    }, 5000);
    stream.on("close", () => {
      console.log('============ 关闭SSE连接')
      clearInterval(intervalId);
      ctx.res.end();
    });
  });

在小程序端可以这样调用:

<script>
import EventSource from "@/libs/eventsource"

export default {
  onLoad() {
  	const evtSource = new EventSource("http://127.0.0.1:8080/sse", "GET", {}, {})
	evtSource.onmessage = function(event) {
		console.log("定时上报:", event.data);
	}
	
	evtSource.onerror = function(err) {
		console.log('发生异常:', err);
	}
	
	setTimeout(()=> {
		evtSource.close()
	}, 20000)
  },
  ......
 }
 </script>

上边的代码接收来自本地服务器的实时时间推送,并在20秒后关闭连接

调用参数

EventSource构造方法有四个参数,分别是访问路径url,请求方法method(默认值为GET),请求参数payload(默认值为空对象),初始化参数eventSourceInitDict(默认值为空对象),前三个和wx.request方法的urlmethoddata相同,最后一个参数的设置请看下表:

属性类型默认值说明
headerObject{}设置请求的 header,header 中不能设置Referercontent-type默认为application/json
reconnectIntervalnumber1000连接断开后重连的时间间隔,单位为ms
timeoutnumber10000请求超时时间,单位为ms
enableCachebooleanfalse是否使用HTTP缓存
redirectstringfollow是否使用客户端重定向,follow为使用,manual为不使用
enableProfilebooleantrue是否开启profile,开启后可在接口回调的res.profile中查看性能调试信息
enableHttpDNSbooleanfalse是否开启HttpDNS服务。如开启,需要同时填入httpDNSServiceId,用法详见移动解析HttpDNS
httpDNSServiceIdstringHttpDNS服务商ID,用法详见移动解析HttpDNS
forceCellularNetworkbooleanfalse强制使用蜂窝网络发送请求

有的读者可能发现,上边的参数大部分都是wx.request方法的参数,但是有一些参数不存在,比如dataTyperespondTypeenableChunked,这是由于为了实现SSE这些值被设置为固定值,不可修改。另外一些参数也不存在,比如useHighPerformanceModeenableHttp2enableQuic,这是由于wx.request的chunked模式只能在HTTP1.1下实现,如果开启这些参数就会与chunked模式冲突导致连接中断,因此只能保持默认的关闭状态。

笔者使用的envoy代理服务器和后端基于koa2的应用服务器已经全部实现了HTTP/2或H2C协议,如果能在HTTP/2协议下实现SSE就可以更好的复用连接,进一步提高性能,但是微信小程序的SSE实现目前只能基于HTTP/1.1协议,不过除此之外的其他请求可以全部通过HTTP/2协议发送。

本文章已经生成可运行项目
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

aurawing

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值