SpringBoot+Vue3整合SSE實(shí)現(xiàn)實(shí)時(shí)消息推送功能
前言
在日常開發(fā)中,我們經(jīng)常需要實(shí)現(xiàn)實(shí)時(shí)消息推送的功能。比如新聞應(yīng)用、聊天系統(tǒng)、監(jiān)控告警等場(chǎng)景。這篇文章基于SpringBoot和Vue3來簡(jiǎn)單實(shí)現(xiàn)一個(gè)入門級(jí)的例子。
實(shí)現(xiàn)場(chǎng)景:在一個(gè)瀏覽器發(fā)送通知,其他所有打開的瀏覽器都能實(shí)時(shí)收到!

先大概介紹下SSE
SSE(Server-Sent Events)是一種允許服務(wù)器向客戶端推送數(shù)據(jù)的技術(shù)。與 WebSocket 相比,SSE 更簡(jiǎn)單易用,特別適合只需要服務(wù)器向客戶端單向通信的場(chǎng)景。
就像你訂閱了某公眾號(hào),有新的文章就會(huì)主動(dòng)推送給你一樣。
下面來看下實(shí)際代碼,完整代碼都在這里。
后端實(shí)現(xiàn)(SpringBoot)
控制器類 - 核心邏輯都在這里
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 {
// 存儲(chǔ)所有連接的客戶端 - 關(guān)鍵:這個(gè)列表保存了所有瀏覽器的連接
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
/**
* 訂閱SSE事件 - 前端通過這個(gè)接口建立連接
* 當(dāng)瀏覽器打開頁面時(shí),就會(huì)調(diào)用這個(gè)接口
*/
@GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe() {
// 創(chuàng)建SSE發(fā)射器,3600000毫秒=1小時(shí)超時(shí)
SseEmitter emitter = new SseEmitter(3600_000L);
log.info("新的SSE客戶端連接成功");
// 將新連接加入到客戶端列表
emitters.add(emitter);
// 設(shè)置連接完成時(shí)的回調(diào)(用戶關(guān)閉頁面時(shí)會(huì)觸發(fā))
emitter.onCompletion(() -> {
log.info("SSE客戶端斷開連接(正常完成)");
emitters.remove(emitter);
});
// 設(shè)置超時(shí)時(shí)的回調(diào)
emitter.onTimeout(() -> {
log.info("SSE客戶端斷開連接(超時(shí))");
emitters.remove(emitter);
});
// 設(shè)置錯(cuò)誤時(shí)的回調(diào)
emitter.onError(e -> {
log.error("SSE客戶端連接錯(cuò)誤", e);
emitters.remove(emitter);
});
// 發(fā)送初始測(cè)試消息 - 告訴前端連接成功了
try {
News testNews = new News();
testNews.setTitle("連接成功");
testNews.setContent("您已成功連接到SSE服務(wù)");
emitter.send(SseEmitter.event()
.name("news") // 事件名稱,前端根據(jù)這個(gè)來區(qū)分不同消息
.data(testNews)); // 實(shí)際發(fā)送的數(shù)據(jù)
} catch (IOException e) {
log.error("發(fā)送初始消息失敗", e);
}
return emitter;
}
/**
* 發(fā)送新聞通知 - 前端調(diào)用這個(gè)接口來發(fā)布新聞
* 這個(gè)方法會(huì)把新聞推送給所有連接的瀏覽器
*/
@PostMapping("/send-news")
public void sendNews(@RequestBody News news) {
List<SseEmitter> deadEmitters = new ArrayList<>();
log.info("開始向 {} 個(gè)客戶端發(fā)送新聞: {}", emitters.size(), news.getTitle());
// 向所有客戶端發(fā)送消息 - 關(guān)鍵:遍歷所有連接
emitters.forEach(emitter -> {
try {
// 向每個(gè)客戶端發(fā)送新聞數(shù)據(jù)
emitter.send(SseEmitter.event()
.name("news") // 事件名稱,前端監(jiān)聽這個(gè)事件
.data(news)); // 新聞數(shù)據(jù)
log.info("新聞發(fā)送成功到客戶端");
} catch (IOException e) {
// 發(fā)送失敗,說明這個(gè)連接可能已經(jīng)斷開
log.error("發(fā)送新聞到客戶端失敗", e);
deadEmitters.add(emitter);
}
});
// 移除已經(jīng)斷開的連接,避免內(nèi)存泄漏
emitters.removeAll(deadEmitters);
log.info("清理了 {} 個(gè)無效連接", deadEmitters.size());
}
// 新聞數(shù)據(jù)模型 - 簡(jiǎn)單的Java類,用來存儲(chǔ)新聞數(shù)據(jù)
public static class News {
private String title; // 新聞標(biāo)題
private String content; // 新聞內(nèi)容
// 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;
}
}
}
后端代碼就這么多,代碼非常的簡(jiǎn)單。
后端核心思路:
- 用一個(gè)列表
emitters保存所有瀏覽器的連接 - 當(dāng)有新聞發(fā)布時(shí),遍歷這個(gè)列表,向每個(gè)連接發(fā)送消息
- 及時(shí)清理斷開的連接,保持列表的清潔
前端實(shí)現(xiàn)(Vue3)
1. 數(shù)據(jù)類型定義
// src/types/news.ts
// 定義新聞數(shù)據(jù)的類型
export interface News {
title: string // 新聞標(biāo)題
content: string // 新聞內(nèi)容
}
// 定義錯(cuò)誤信息的類型
export interface SseError {
message: string
}
2. SSE服務(wù)類 - 封裝連接邏輯
// src/utils/sseService.ts
import type { News } from '@/types/news'
// 定義回調(diào)函數(shù)的類型
type MessageCallback = (data: News) => void // 收到消息時(shí)的回調(diào)
type ErrorCallback = (error: Event) => void // 發(fā)生錯(cuò)誤時(shí)的回調(diào)
class SseService {
private eventSource: EventSource | null = null // SSE連接對(duì)象
private retryCount = 0 // 重試次數(shù)
private maxRetries = 3 // 最大重試次數(shù)
private retryDelay = 3000 // 重試延遲(3秒)
private onMessageCallback: MessageCallback | null = null // 消息回調(diào)
private onErrorCallback: ErrorCallback | null = null // 錯(cuò)誤回調(diào)
/**
* 訂閱SSE服務(wù) - 建立連接并設(shè)置回調(diào)函數(shù)
* @param onMessage 收到消息時(shí)的處理函數(shù)
* @param onError 發(fā)生錯(cuò)誤時(shí)的處理函數(shù)
*/
public subscribe(onMessage: MessageCallback, onError: ErrorCallback): void {
this.onMessageCallback = onMessage
this.onErrorCallback = onError
this.connect() // 開始連接
}
/**
* 建立SSE連接
*/
private connect(): void {
// 如果已有連接,先斷開
if (this.eventSource) {
this.disconnect()
}
// 創(chuàng)建新的SSE連接,連接到后端的/subscribe接口
this.eventSource = new EventSource('/api/sse/subscribe')
// 連接成功時(shí)的處理
this.eventSource.addEventListener('open', () => {
console.log('SSE連接建立成功')
this.retryCount = 0 // 連接成功后重置重試計(jì)數(shù)
})
// 監(jiān)聽新聞事件 - 當(dāng)后端發(fā)送name為"news"的消息時(shí)會(huì)觸發(fā)
this.eventSource.addEventListener('news', (event: MessageEvent) => {
try {
// 解析后端發(fā)送的JSON數(shù)據(jù)
const data: News = JSON.parse(event.data)
console.log('收到新聞消息:', data)
// 調(diào)用消息回調(diào)函數(shù),把新聞數(shù)據(jù)傳遞給組件
this.onMessageCallback?.(data)
} catch (error) {
console.error('解析SSE消息失敗:', error)
}
})
// 錯(cuò)誤處理
this.eventSource.onerror = (error: Event) => {
console.error('SSE連接錯(cuò)誤:', error)
this.onErrorCallback?.(error) // 調(diào)用錯(cuò)誤回調(diào)
this.disconnect() // 斷開連接
// 自動(dòng)重連邏輯 - 網(wǎng)絡(luò)不穩(wěn)定時(shí)的自我恢復(fù)
if (this.retryCount < this.maxRetries) {
this.retryCount++
console.log(`嘗試重新連接 (${this.retryCount}/${this.maxRetries})...`)
setTimeout(() => this.connect(), this.retryDelay)
} else {
console.error('已達(dá)到最大重連次數(shù),停止重連')
}
}
}
/**
* 取消訂閱 - 組件銷毀時(shí)調(diào)用
*/
public unsubscribe(): void {
this.disconnect()
this.onMessageCallback = null
this.onErrorCallback = null
}
/**
* 斷開連接
*/
private disconnect(): void {
if (this.eventSource) {
this.eventSource.close() // 關(guān)閉連接
this.eventSource = null
}
}
}
// 導(dǎo)出單例實(shí)例,整個(gè)應(yīng)用共用同一個(gè)SSE服務(wù)
export default new SseService()
3. 新聞通知組件 - 顯示實(shí)時(shí)新聞
<!-- src/components/NewsNotification.vue -->
<template>
<div class="news-container">
<h2>新聞通知</h2>
<!-- 連接狀態(tài) -->
<div v-if="loading" class="status loading">連接服務(wù)器中...</div>
<div v-if="error" class="status error">連接錯(cuò)誤: {{ 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>
<!-- 空狀態(tài) -->
<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) // 加載狀態(tài)
const error = ref<string | null>(null) // 錯(cuò)誤信息
// 處理新消息
const handleNewMessage = (news: News): void => {
newsList.value.unshift(news) // 新消息放在最前面
}
// 處理錯(cuò)誤
const handleError = (err: Event): void => {
error.value = (err as ErrorEvent)?.message || '連接服務(wù)器失敗'
loading.value = false
}
// 獲取當(dāng)前時(shí)間
const getCurrentTime = (): string => {
return new Date().toLocaleTimeString()
}
// 組件掛載時(shí)建立連接
onMounted(() => {
sseService.subscribe(handleNewMessage, handleError)
loading.value = false
})
// 組件銷毀時(shí)斷開連接
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. 新聞發(fā)送組件 - 管理員發(fā)送新聞
<!-- src/components/SendNewsForm.vue -->
<template>
<div class="send-news-form">
<h2>發(fā)送新聞通知</h2>
<form @submit.prevent="sendNews">
<div class="form-group">
<label for="title">標(biāo)題</label>
<input id="title" v-model="news.title" type="text" required placeholder="輸入新聞標(biāo)題" />
</div>
<div class="form-group">
<label for="content">內(nèi)容</label>
<textarea
id="content"
v-model="news.content"
required
placeholder="輸入新聞內(nèi)容"
rows="4"
></textarea>
</div>
<button type="submit" :disabled="isSending">
{{ isSending ? '發(fā)送中...' : '發(fā)送新聞' }}
</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() {
// 表單數(shù)據(jù)
const news = ref<News>({
title: '',
content: '',
})
// 界面狀態(tài)
const isSending = ref<boolean>(false)
const message = ref<string>('')
const isSuccess = ref<boolean>(false)
// 消息類型樣式
const messageType = computed(() => {
return isSuccess.value ? 'success' : 'error'
})
// 發(fā)送新聞
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 = '新聞發(fā)送成功!'
isSuccess.value = true
// 清空表單
news.value = { title: '', content: '' }
} else {
throw new Error('發(fā)送失敗')
}
} catch (err) {
message.value = '發(fā)送新聞失敗'
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. 主頁面引用組件
<!-- 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>
全部代碼和注釋都在這里,可以直接啟動(dòng)項(xiàng)目測(cè)試了。
測(cè)試
1. 測(cè)試多瀏覽器接收
- 打開第一個(gè)瀏覽器(比如 Chrome)訪問應(yīng)用
- 打開第二個(gè)瀏覽器(比如 Firefox)訪問應(yīng)用
- 在任意瀏覽器中使用發(fā)送表單發(fā)布新聞
- 觀察兩個(gè)瀏覽器是否都實(shí)時(shí)收到了新聞通知
2. 預(yù)期效果
- 第一個(gè)瀏覽器打開:顯示"連接成功"
- 第二個(gè)瀏覽器打開:顯示"連接成功"
- 在任意瀏覽器發(fā)送新聞:兩個(gè)瀏覽器都立即顯示新新聞
- 關(guān)閉一個(gè)瀏覽器:不影響另一個(gè)瀏覽器的正常使用
總結(jié)
通過這個(gè)小案例的源碼,我們學(xué)會(huì)了SSE的簡(jiǎn)單使用:
- SSE 的基本原理和使用方法
- SpringBoot 如何維護(hù)多個(gè)客戶端連接
- Vue3 組合式 API 的使用
- 前后端分離架構(gòu)的實(shí)時(shí)通信實(shí)現(xiàn)
這個(gè)方案非常適合新聞推送、系統(tǒng)通知、實(shí)時(shí)數(shù)據(jù)展示等場(chǎng)景。代碼簡(jiǎn)單易懂,擴(kuò)展性強(qiáng),你也可以基于這個(gè)基礎(chǔ)添加更多功能。
到此這篇關(guān)于SpringBoot+Vue3整合SSE實(shí)現(xiàn)實(shí)時(shí)消息推送功能的文章就介紹到這了,更多相關(guān)SpringBoot實(shí)時(shí)消息推送內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
SpringBoot3.2.2整合MyBatis Plus3.5.5的詳細(xì)過程
這篇文章給大家介紹了SpringBoot3.2.2整合MyBatis Plus3.5.5的詳細(xì)過程,文中通過代碼示例給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2024-01-01
搭建MyBatis-Plus框架并進(jìn)行數(shù)據(jù)庫增刪改查功能
這篇文章主要介紹了搭建MyBatis-Plus框架并進(jìn)行數(shù)據(jù)庫增刪改查,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-03-03
Java Swing SpringLayout彈性布局的實(shí)現(xiàn)代碼
這篇文章主要介紹了Java Swing SpringLayout彈性布局的實(shí)現(xiàn)代碼,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-12-12
JAVA多線程Thread和Runnable的實(shí)現(xiàn)
java中實(shí)現(xiàn)多線程有兩種方法:一種是繼承Thread類,另一種是實(shí)現(xiàn)Runnable接口。2013-03-03
Zookeeper如何實(shí)現(xiàn)分布式服務(wù)配置中心詳解
Zookeeper在實(shí)際使用場(chǎng)景很多,比如配置中心,分布式鎖,注冊(cè)中心等,下面這篇文章主要給大家介紹了關(guān)于Zookeeper如何實(shí)現(xiàn)分布式服務(wù)配置中心的相關(guān)資料,需要的朋友可以參考下2021-11-11
詳解Java使用Pipeline對(duì)Redis批量讀寫(hmset&hgetall)
本篇文章主要介紹了Java使用Pipeline對(duì)Redis批量讀寫(hmset&hgetall),具有一定的參考價(jià)值,有興趣的可以了解一下。2016-12-12

