10分钟搞定!SpringBoot+Vue3 整合 SSE 实现实时消息推送

前言

在日常开发中,我们经常需要实现实时消息推送的功能。比如新闻应用、聊天系统、监控告警等场景。这篇文章基于SpringBootVue3来简单实现一个入门级的例子。

实现场景:在一个浏览器发送通知,其他所有打开的浏览器都能实时收到!

先大概介绍下SSE

SSE(Server-Sent Events)是一种允许服务器向客户端推送数据的技术。与 WebSocket 相比,SSE 更简单易用,特别适合只需要服务器向客户端单向通信的场景。

就像你订阅了某公众号,有新的文章就会主动推送给你一样。

下面来看下实际代码,完整代码都在这里。

后端实现(SpringBoot)

控制器类 - 核心逻辑都在这里

java 复制代码
package com.im.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

@Slf4j
@RestController
@RequestMapping("/api/sse")
public class SseController {

    // 存储所有连接的客户端 - 关键:这个列表保存了所有浏览器的连接
    private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    /**
     * 订阅SSE事件 - 前端通过这个接口建立连接
     * 当浏览器打开页面时,就会调用这个接口
     */
    @GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe() {
        // 创建SSE发射器,3600000毫秒=1小时超时
        SseEmitter emitter = new SseEmitter(3600_000L);
        log.info("新的SSE客户端连接成功");
        
        // 将新连接加入到客户端列表
        emitters.add(emitter);

        // 设置连接完成时的回调(用户关闭页面时会触发)
        emitter.onCompletion(() -> {
            log.info("SSE客户端断开连接(正常完成)");
            emitters.remove(emitter);
        });

        // 设置超时时的回调
        emitter.onTimeout(() -> {
            log.info("SSE客户端断开连接(超时)");
            emitters.remove(emitter);
        });

        // 设置错误时的回调
        emitter.onError(e -> {
            log.error("SSE客户端连接错误", e);
            emitters.remove(emitter);
        });

        // 发送初始测试消息 - 告诉前端连接成功了
        try {
            News testNews = new News();
            testNews.setTitle("连接成功");
            testNews.setContent("您已成功连接到SSE服务");
            emitter.send(SseEmitter.event()
                    .name("news")        // 事件名称,前端根据这个来区分不同消息
                    .data(testNews));    // 实际发送的数据
        } catch (IOException e) {
            log.error("发送初始消息失败", e);
        }

        return emitter;
    }

    /**
     * 发送新闻通知 - 前端调用这个接口来发布新闻
     * 这个方法会把新闻推送给所有连接的浏览器
     */
    @PostMapping("/send-news")
    public void sendNews(@RequestBody News news) {
        List<SseEmitter> deadEmitters = new ArrayList<>();
        
        log.info("开始向 {} 个客户端发送新闻: {}", emitters.size(), news.getTitle());

        // 向所有客户端发送消息 - 关键:遍历所有连接
        emitters.forEach(emitter -> {
            try {
                // 向每个客户端发送新闻数据
                emitter.send(SseEmitter.event()
                        .name("news") // 事件名称,前端监听这个事件
                        .data(news)); // 新闻数据
                log.info("新闻发送成功到客户端");
            } catch (IOException e) {
                // 发送失败,说明这个连接可能已经断开
                log.error("发送新闻到客户端失败", e);
                deadEmitters.add(emitter);
            }
        });

        // 移除已经断开的连接,避免内存泄漏
        emitters.removeAll(deadEmitters);
        log.info("清理了 {} 个无效连接", deadEmitters.size());
    }

    // 新闻数据模型 - 简单的Java类,用来存储新闻数据
    public static class News {
        private String title;    // 新闻标题
        private String content;  // 新闻内容

        // getters and setters
        public String getTitle() {
            return title;
        }

        public void setTitle(String title) {
            this.title = title;
        }

        public String getContent() {
            return content;
        }

        public void setContent(String content) {
            this.content = content;
        }
    }
}

后端代码就这么多,代码非常的简单。

后端核心思路:

  1. 用一个列表 emitters 保存所有浏览器的连接
  2. 当有新闻发布时,遍历这个列表,向每个连接发送消息
  3. 及时清理断开的连接,保持列表的清洁

前端实现(Vue3)

1. 数据类型定义

typescript 复制代码
// src/types/news.ts

// 定义新闻数据的类型
export interface News {
  title: string     // 新闻标题
  content: string   // 新闻内容
}

// 定义错误信息的类型
export interface SseError {
  message: string
}

2. SSE服务类 - 封装连接逻辑

typescript 复制代码
// src/utils/sseService.ts
import type { News } from '@/types/news'

// 定义回调函数的类型
type MessageCallback = (data: News) => void  // 收到消息时的回调
type ErrorCallback = (error: Event) => void  // 发生错误时的回调

class SseService {
  private eventSource: EventSource | null = null  // SSE连接对象
  private retryCount = 0                          // 重试次数
  private maxRetries = 3                          // 最大重试次数
  private retryDelay = 3000                       // 重试延迟(3秒)
  private onMessageCallback: MessageCallback | null = null  // 消息回调
  private onErrorCallback: ErrorCallback | null = null      // 错误回调

  /**
   * 订阅SSE服务 - 建立连接并设置回调函数
   * @param onMessage 收到消息时的处理函数
   * @param onError 发生错误时的处理函数
   */
  public subscribe(onMessage: MessageCallback, onError: ErrorCallback): void {
    this.onMessageCallback = onMessage
    this.onErrorCallback = onError
    this.connect()  // 开始连接
  }

  /**
   * 建立SSE连接
   */
  private connect(): void {
    // 如果已有连接,先断开
    if (this.eventSource) {
      this.disconnect()
    }

    // 创建新的SSE连接,连接到后端的/subscribe接口
    this.eventSource = new EventSource('/api/sse/subscribe')

    // 连接成功时的处理
    this.eventSource.addEventListener('open', () => {
      console.log('SSE连接建立成功')
      this.retryCount = 0 // 连接成功后重置重试计数
    })

    // 监听新闻事件 - 当后端发送name为"news"的消息时会触发
    this.eventSource.addEventListener('news', (event: MessageEvent) => {
      try {
        // 解析后端发送的JSON数据
        const data: News = JSON.parse(event.data)
        console.log('收到新闻消息:', data)
        // 调用消息回调函数,把新闻数据传递给组件
        this.onMessageCallback?.(data)
      } catch (error) {
        console.error('解析SSE消息失败:', error)
      }
    })

    // 错误处理
    this.eventSource.onerror = (error: Event) => {
      console.error('SSE连接错误:', error)
      this.onErrorCallback?.(error)  // 调用错误回调
      this.disconnect()              // 断开连接

      // 自动重连逻辑 - 网络不稳定时的自我恢复
      if (this.retryCount < this.maxRetries) {
        this.retryCount++
        console.log(`尝试重新连接 (${this.retryCount}/${this.maxRetries})...`)
        setTimeout(() => this.connect(), this.retryDelay)
      } else {
        console.error('已达到最大重连次数,停止重连')
      }
    }
  }

  /**
   * 取消订阅 - 组件销毁时调用
   */
  public unsubscribe(): void {
    this.disconnect()
    this.onMessageCallback = null
    this.onErrorCallback = null
  }

  /**
   * 断开连接
   */
  private disconnect(): void {
    if (this.eventSource) {
      this.eventSource.close()  // 关闭连接
      this.eventSource = null
    }
  }
}

// 导出单例实例,整个应用共用同一个SSE服务
export default new SseService()

3. 新闻通知组件 - 显示实时新闻

html 复制代码
<!-- src/components/NewsNotification.vue -->
<template>
  <div class="news-container">
    <h2>新闻通知</h2>

    <!-- 连接状态 -->
    <div v-if="loading" class="status loading">连接服务器中...</div>
    <div v-if="error" class="status error">连接错误: {{ error }}</div>

    <!-- 新闻列表 -->
    <div class="news-list">
      <div v-for="(news, index) in newsList" :key="index" class="news-item">
        <h3>{{ news.title }}</h3>
        <p>{{ news.content }}</p>
        <div class="time">{{ getCurrentTime() }}</div>
      </div>
    </div>

    <!-- 空状态 -->
    <div v-if="newsList.length === 0 && !loading" class="no-news">暂无新闻通知</div>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, onMounted, onUnmounted } from 'vue'
import sseService from '@/utils/sseService'
import type { News } from '@/types/news'

export default defineComponent({
  name: 'NewsNotification',
  setup() {
    const newsList = ref<News[]>([]) // 新闻列表
    const loading = ref<boolean>(true) // 加载状态
    const error = ref<string | null>(null) // 错误信息

    // 处理新消息
    const handleNewMessage = (news: News): void => {
      newsList.value.unshift(news) // 新消息放在最前面
    }

    // 处理错误
    const handleError = (err: Event): void => {
      error.value = (err as ErrorEvent)?.message || '连接服务器失败'
      loading.value = false
    }

    // 获取当前时间
    const getCurrentTime = (): string => {
      return new Date().toLocaleTimeString()
    }

    // 组件挂载时建立连接
    onMounted(() => {
      sseService.subscribe(handleNewMessage, handleError)
      loading.value = false
    })

    // 组件销毁时断开连接
    onUnmounted(() => {
      sseService.unsubscribe()
    })

    return {
      newsList,
      loading,
      error,
      getCurrentTime,
    }
  },
})
</script>

<style scoped>
.news-container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.status {
  padding: 10px;
  margin: 10px 0;
  border-radius: 4px;
  text-align: center;
}

.loading {
  background-color: #e3f2fd;
  color: #1976d2;
}

.error {
  background-color: #ffebee;
  color: #d32f2f;
}

.news-list {
  margin-top: 20px;
}

.news-item {
  padding: 15px;
  margin-bottom: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #fafafa;
}

.news-item h3 {
  margin: 0 0 8px 0;
  color: #333;
  font-size: 16px;
}

.news-item p {
  margin: 0 0 8px 0;
  color: #666;
  line-height: 1.4;
}

.time {
  font-size: 12px;
  color: #999;
  text-align: right;
}

.no-news {
  padding: 40px 20px;
  text-align: center;
  color: #999;
  font-style: italic;
}
</style>

4. 新闻发送组件 - 管理员发送新闻

html 复制代码
<!-- src/components/SendNewsForm.vue -->
<template>
  <div class="send-news-form">
    <h2>发送新闻通知</h2>
    <form @submit.prevent="sendNews">
      <div class="form-group">
        <label for="title">标题</label>
        <input id="title" v-model="news.title" type="text" required placeholder="输入新闻标题" />
      </div>

      <div class="form-group">
        <label for="content">内容</label>
        <textarea
          id="content"
          v-model="news.content"
          required
          placeholder="输入新闻内容"
          rows="4"
        ></textarea>
      </div>

      <button type="submit" :disabled="isSending">
        {{ isSending ? '发送中...' : '发送新闻' }}
      </button>

      <!-- 操作反馈 -->
      <div v-if="message" class="message" :class="messageType">
        {{ message }}
      </div>
    </form>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue'
import type { News } from '@/types/news'

export default defineComponent({
  name: 'SendNewsForm',
  setup() {
    // 表单数据
    const news = ref<News>({
      title: '',
      content: '',
    })

    // 界面状态
    const isSending = ref<boolean>(false)
    const message = ref<string>('')
    const isSuccess = ref<boolean>(false)

    // 消息类型样式
    const messageType = computed(() => {
      return isSuccess.value ? 'success' : 'error'
    })

    // 发送新闻
    const sendNews = async (): Promise<void> => {
      isSending.value = true
      message.value = ''

      try {
        const response = await fetch('/api/sse/send-news', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(news.value),
        })

        if (response.ok) {
          message.value = '新闻发送成功!'
          isSuccess.value = true
          // 清空表单
          news.value = { title: '', content: '' }
        } else {
          throw new Error('发送失败')
        }
      } catch (err) {
        message.value = '发送新闻失败'
        isSuccess.value = false
        console.error(err)
      } finally {
        isSending.value = false
      }
    }

    return {
      news,
      isSending,
      message,
      messageType,
      sendNews,
    }
  },
})
</script>

<style scoped>
.send-news-form {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.form-group {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
  color: #333;
}

input,
textarea {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
  font-size: 14px;
}

input:focus,
textarea:focus {
  outline: none;
  border-color: #1976d2;
}

textarea {
  resize: vertical;
  min-height: 80px;
}

button {
  width: 100%;
  background-color: #1976d2;
  color: white;
  padding: 10px 15px;
  border: none;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
}

button:hover:not(:disabled) {
  background-color: #1565c0;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.message {
  margin-top: 15px;
  padding: 10px;
  border-radius: 4px;
  text-align: center;
  font-size: 14px;
}

.success {
  background-color: #e8f5e8;
  color: #2e7d32;
  border: 1px solid #c8e6c9;
}

.error {
  background-color: #ffebee;
  color: #c62828;
  border: 1px solid #ffcdd2;
}
</style>

5. 主页面引用组件

html 复制代码
<!-- src/Home.vue -->
<template>
  <div class="welcome">
    <SendNewsForm />
    <NewsNotification />
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import SendNewsForm from './components/SendNewsForm.vue'
import NewsNotification from './components/NewsNotification.vue'
</script>

全部代码和注释都在这里,可以直接启动项目测试了。

测试

1. 测试多浏览器接收

  1. 打开第一个浏览器(比如 Chrome)访问应用
  2. 打开第二个浏览器(比如 Firefox)访问应用
  3. 在任意浏览器中使用发送表单发布新闻
  4. 观察两个浏览器是否都实时收到了新闻通知

2. 预期效果

  • 第一个浏览器打开:显示"连接成功"
  • 第二个浏览器打开:显示"连接成功"
  • 在任意浏览器发送新闻:两个浏览器都立即显示新新闻
  • 关闭一个浏览器:不影响另一个浏览器的正常使用

总结

通过这个小案例的源码,我们学会了SSE的简单使用:

  • SSE 的基本原理和使用方法
  • SpringBoot 如何维护多个客户端连接
  • Vue3 组合式 API 的使用
  • 前后端分离架构的实时通信实现

这个方案非常适合新闻推送、系统通知、实时数据展示等场景。代码简单易懂,扩展性强,你也可以基于这个基础添加更多功能。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

相关推荐
LayZhangStrive6 分钟前
融360 一面
java·面试·后端开发
城管不管31 分钟前
重生——第十次面试之开源中国一面挂
java·linux·开发语言·算法·面试·职场和发展·开源
Java小白笔记35 分钟前
Windows系统免软件命令激活
java·网络·人工智能·windows·ai·ai编程
爱敲键盘的猴子44 分钟前
深入理解 Java HashMap
java·hashmap
霸道流氓气质44 分钟前
Java开发者AI编程常用MCP、Skills、Rules全景汇总
java·开发语言·ai编程
魔兽大山哥1 小时前
提示词注入很热闹:NL2SQL 里它打得穿 Prompt,打不穿执行层
后端
智能架构人生1 小时前
Java开发者技能如何迁移到Kotlin
后端
程序员老赵1 小时前
Docker 部署禅道 ZenTao:轻松搭建研发项目管理平台
前端·后端·github
字节跳动数据库1 小时前
火山 PostgreSQL Serverless × 飞书妙搭:把 AI 装进数据库,一句话唤醒数据智能
数据库·人工智能·后端
倾颜1 小时前
# Node.js Event Loop 到底是怎么工作的?从一段异步代码讲清事件循环
后端