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方法的url、method和data相同,最后一个参数的设置请看下表:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| header | Object | {} | 设置请求的 header,header 中不能设置Referer,content-type默认为application/json |
| reconnectInterval | number | 1000 | 连接断开后重连的时间间隔,单位为ms |
| timeout | number | 10000 | 请求超时时间,单位为ms |
| enableCache | boolean | false | 是否使用HTTP缓存 |
| redirect | string | follow | 是否使用客户端重定向,follow为使用,manual为不使用 |
| enableProfile | boolean | true | 是否开启profile,开启后可在接口回调的res.profile中查看性能调试信息 |
| enableHttpDNS | boolean | false | 是否开启HttpDNS服务。如开启,需要同时填入httpDNSServiceId,用法详见移动解析HttpDNS |
| httpDNSServiceId | string | 空 | HttpDNS服务商ID,用法详见移动解析HttpDNS |
| forceCellularNetwork | boolean | false | 强制使用蜂窝网络发送请求 |
有的读者可能发现,上边的参数大部分都是wx.request方法的参数,但是有一些参数不存在,比如dataType、respondType和enableChunked,这是由于为了实现SSE这些值被设置为固定值,不可修改。另外一些参数也不存在,比如useHighPerformanceMode、enableHttp2和enableQuic,这是由于wx.request的chunked模式只能在HTTP1.1下实现,如果开启这些参数就会与chunked模式冲突导致连接中断,因此只能保持默认的关闭状态。
笔者使用的envoy代理服务器和后端基于koa2的应用服务器已经全部实现了HTTP/2或H2C协议,如果能在HTTP/2协议下实现SSE就可以更好的复用连接,进一步提高性能,但是微信小程序的SSE实现目前只能基于HTTP/1.1协议,不过除此之外的其他请求可以全部通过HTTP/2协议发送。
690

被折叠的 条评论
为什么被折叠?



