SSE(Server-Sent Events)深入总结(踩坑)

1,245 阅读3分钟

背景

  • 老板说, 项目中要进行前端触发任务同步,完成后进行实时通知
  • 实时查看同步过程中的日志

技术选型

  • 轮询(淘汰,性能要求)
  • ws(websocket)(淘汰,过于强大,业务中只需要服务器主动推送,不需要互相推送)
  • sse(符合)

技术栈

  • 后端: java(SpringBoot + SseEmitter)
  • 前端: vue3 + sse

问题

  1. 刷新页面sse失效
  • 方案: 在刷新页面后,重新请求sse 建立新的连接
  1. 打开一个新标签页,上一个标签页面sse失效
  • 方案一(项目中使用): 使用浏览器APIBroadcastChannel
// sse.js
import { reactive } from 'vue';
import { defineStore } from 'pinia';
// import { usePermissStore } from '@/store/permiss';
import { BASE_URL } from '@/utils/constant';
import { notify } from '@/utils/notification';
let sourceSync = null,
  isMainTab = false,
  mainTabClientId = null,
  mainTabTimerId = null,
  clientId = null;
const totalLogs = reactive({});
const syncStatus = reactive({});
const channel = new BroadcastChannel('sse-channel');
// const testRandom = ' === ' + randomRange(100, 1);
function sseClosedDelayTime() {
  const sseDelay = 'SSE_Closed_Delay_Time';
  const random = localStorage.getItem(sseDelay);
  let r = 110;
  if (random) {
    r = Number(random) + 110;
    if (r > 1500) {
      r = 110;
    }
  }
  localStorage.setItem(sseDelay, String(r));
  return r;
}

function randomRange(max, min) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}
function createId() {
  // const userStore = usePermissStore();
  const data = localStorage.getItem('user');
  if (data) {
    const user = JSON.parse(data);
    clientId = user.clientId;
  }
}

const createSse = () => {
  if ('EventSource' in window && !sourceSync) {
    sourceSync = new EventSource(`${BASE_URL}AssessmentHospital/metricsInfos/examination-sse/${clientId}`, {
      withCredentials: true,
    });
    sourceSync.addEventListener(
      'sync',
      function (event) {
        const data = JSON.parse(event.data);
        // console.log(data, 'sse sync');
        channel.postMessage(formatSendMsg('sync', data));
        syncHandler(data);
      },
      false,
    );

    sourceSync.addEventListener(
      'log',
      function (event) {
        const data = JSON.parse(event.data);
        // console.log(data, 'sse log');

        // let message = data.message + '===channel' + testRandom;
        // channel.postMessage(formatSendMsg('log', { ...data, message }));
        // message = data.message + '===sse' + testRandom;
        // logsHandler({ ...data, message });

        channel.postMessage(formatSendMsg('log', data));
        logsHandler(data);
      },
      false,
    );

    sourceSync.addEventListener(
      'heartBeat',
      function (event) {
        const data = JSON.parse(event.data);
        // console.log(data, 'sse headerbeat');
        channel.postMessage(formatSendMsg('heartBeat', data));
      },
      false,
    );
  }
};

function syncHandler(data) {
  const flag = data.status;
  const msg = data.message;
  const id = data.metricsId;
  if (flag === 'ok' || flag === 'error') {
    notify(msg);
    syncStatus[id] = 'end';
  }
}

function logsHandler(data) {
  if (data.status === 'ok') {
    if (!totalLogs[data.metricsId]) {
      totalLogs[data.metricsId] = [];
    }
    const temp = {
      id: 'log==' + totalLogs[data.metricsId].length,
      text: data.message,
    };
    totalLogs[data.metricsId].push(temp);
  }
}
function checkForMainTab() {
  channel.postMessage(formatSendMsg('main-tab-check'));
  mainTabTimerId = setTimeout(() => {
    if (!isMainTab) {
      becomeMainTab();
    }
  }, 100);
}
function becomeMainTab() {
  console.log('become MainTab');
  createSse();
  isMainTab = true;
}
// 监听来自其他标签页的消息
channel.addEventListener('message', function (event) {
  // 处理SSE消息
  const type = event.data.type;
  const data = event.data.data;
  if (type === 'main-tab-check') {
    if (isMainTab) {
      console.log('isMainTab 999');
      channel.postMessage(formatSendMsg('main-tab-response'));
    }
    return;
  }
  if (type === 'main-tab-response') {
    console.log('has mainTab  888');
    mainTabClientId = event.data.clientId;
    if (mainTabClientId === clientId) {
      clearTimeout(mainTabTimerId);
    }
    return;
  }
  if (type === 'main-tab-closed') {
    const rt = randomRange(50, 10) * randomRange(25, 11);
    setTimeout(() => {
      const delay = sseClosedDelayTime();
      console.log(rt, delay, 'rt');
      setTimeout(checkForMainTab, delay);
    }, rt);
  }

  if (type === 'sync') {
    syncHandler(data);
  } else if (type === 'log') {
    // console.log(data, 'channel log');
    logsHandler(data);
  } else if (type === 'heartBeat') {
    console.log(data, 'channel heartBeat');
  }
});

function formatSendMsg(type, data = {}) {
  const temp = {
    type,
    clientId,
    data,
  };
  return temp;
}

// 在页面卸载时关闭EventSource
window.addEventListener('beforeunload', () => {
  if (sourceSync) {
    sourceSync.close();
  }
  if (isMainTab) {
    channel.postMessage(formatSendMsg('main-tab-closed'));
    channel.close();
  }
});

export const useSseStore = defineStore('sse', () => {
  return { totalLogs, syncStatus, sourceSync };
});

const init = () => {
  // 检查是否已经存在活跃的SSE连接
  createId();
  checkForMainTab();
};

init();
  • 方案二: 使用SharedWorker(未验证)

  • 这个实现包含两个主要部分:

A. shared-worker.js:这是SharedWorker的脚本,它负责:

  • 创建和维护SSE连接
  • 管理连接到它的所有页面(ports)
  • 将接收到的SSE消息广播给所有连接的页面

B. main.js:这是你在每个网页中使用的脚本,它负责:

  • 创建SharedWorker的实例
  • 处理从SharedWorker接收到的消息

使用这种方法,所有打开的标签页将共享同一个SSE连接,避免了连接数量限制问题。

要使用这个解决方案,你需要:

a. 将shared-worker.js放在你的服务器上,确保它可以被浏览器访问到。

b. 在你的网页中引入并使用main.js中的代码

// shared-worker.js 文件
let sseConnection = null;
let ports = [];

function createSSEConnection() {
  sseConnection = new EventSource('/events');
  
  sseConnection.onmessage = function(event) {
    // Broadcast the message to all connected ports
    ports.forEach(port => port.postMessage(event.data));
  };

  sseConnection.onerror = function(error) {
    console.error('SSE connection error:', error);
    // Implement reconnection logic here if needed
  };
}

self.onconnect = function(e) {
  const port = e.ports[0];
  ports.push(port);

  port.onmessage = function(e) {
    if (e.data === 'connect' && !sseConnection) {
      createSSEConnection();
    }
  };

  port.start();

  // If this is the first connection, create the SSE connection
  if (ports.length === 1) {
    createSSEConnection();
  }
};

// main.js 文件
if (typeof SharedWorker !== 'undefined') {
  const worker = new SharedWorker('shared-worker.js');
  
  worker.port.onmessage = function(e) {
    console.log('Received message:', e.data);
    // Handle the SSE message here
  };

  worker.port.start();
  worker.port.postMessage('connect');
} else {
  console.error('SharedWorker is not supported in this browser');
  // Fallback to regular SSE connection
}
  1. 主标签关闭后,选一个后续标签担任主标签的角色
  • 方案: 广播随机延时,延时时间大于 110 ms
  1. 同一个用户,不同浏览器登录,同步同一个任务,sse会被覆盖

  2. 使用sse日志,刷新浏览器后,之前叠加的日志,丢失

  • 方案: 再单独开一个get接口,返回全部日志,每次打开日志窗口调用get接口,再叠加sse日志,进行展示

参考资料