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个细节》

相关推荐
l***77522 小时前
总结:Spring Boot 之spring.factories
java·spring boot·spring
稚辉君.MCA_P8_Java2 小时前
Gemini永久会员 Go 实现动态规划
数据结构·后端·算法·golang·动态规划
天若有情6732 小时前
笑喷!乌鸦哥版demo函数掀桌怒怼主函数:难办?那就别办了!
java·前端·servlet
北极糊的狐3 小时前
Vue 中 vue-awesome-swiper的使用笔记(适配 Vue2/Vue3)
前端·javascript·vue.js
SimonKing3 小时前
你的IDEA还缺什么?我离不开的这两款效率插件推荐
java·后端·程序员
better_liang3 小时前
每日Java面试场景题知识点之-数据库连接池配置优化
java·性能优化·面试题·hikaricp·数据库连接池·企业级开发
Wpa.wk3 小时前
自动化测试环境配置-java+python
java·开发语言·python·测试工具·自动化
武子康3 小时前
大数据-165 Apache Kylin Cube7 实战:聚合组/RowKey/编码与体积精度对比
大数据·后端·apache kylin