SpringBoot实现消息推送:让服务器学会“主动搭讪”

大家好,我是小悟。

想象一下这个场景:你的APP像个腼腆的男生,只会傻傻等着用户来"敲门"(刷新页面),而不知道主动说"嘿,我有新消息给你!"这多尴尬啊!消息推送就像给服务器装了社交牛逼症,让它能从幕后跳出来大喊:"注意!有热乎的消息!"

一、推送技术选型:给服务器装上"大喇叭"

SSE(Server-Sent Events) - 像单相思,服务器可以一直对客户端叨叨叨,但客户端只能听着 WebSocket - 像热恋中的情侣,双方可以随时互发消息 轮询(Polling) - 像查岗的女朋友,隔几秒就问一次"有新消息吗?" 长轮询(Long Polling) - 像有耐心的女朋友,等不到消息就不挂电话

今天咱们重点玩一下SSE,因为它简单直接,就像给服务器装了个校园广播站!

二、SpringBoot推送实战:三步搞定

第一步:添加依赖(给项目喂点"能量饮料")

xml 复制代码
<!-- pom.xml -->
<dependencies>
    <!-- SpringBoot基础套餐 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- 给模板引擎加点料 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    
    <!-- 让我们能处理异步请求 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
</dependencies>

第二步:创建SSE控制器(服务器的"播音室")

kotlin 复制代码
package com.example.pushdemo.controller;

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.concurrent.CopyOnWriteArrayList;

@RestController
@RequestMapping("/sse")
public class SseController {
    
    // 保存所有连接的客户端,CopyOnWriteArrayList是线程安全的
    private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();
    
    /**
     * 客户端连接入口 - 相当于打开收音机
     * @return SseEmitter对象
     */
    @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter connect() {
        // 设置连接超时时间(毫秒),0表示永不超时
        SseEmitter emitter = new SseEmitter(0L);
        
        // 连接建立时的处理
        emitter.onCompletion(() -> {
            System.out.println("客户端断开连接了,有点小失落...");
            emitters.remove(emitter);
        });
        
        emitter.onTimeout(() -> {
            System.out.println("客户端连接超时了,是不是网不好?");
            emitters.remove(emitter);
        });
        
        // 添加到连接列表
        emitters.add(emitter);
        
        try {
            // 发送欢迎消息
            emitter.send(SseEmitter.event()
                    .name("welcome")  // 事件名称
                    .data("连接成功!我是话痨服务器,我会主动给你推送消息!"));
        } catch (IOException e) {
            emitter.completeWithError(e);
        }
        
        System.out.println("新客户端加入,当前连接数:" + emitters.size());
        return emitter;
    }
    
    /**
     * 向所有客户端广播消息 - 服务器开始广播啦!
     * @param message 要推送的消息
     * @return 推送结果
     */
    @PostMapping("/broadcast")
    public String broadcast(@RequestParam String message) {
        System.out.println("准备广播消息:" + message);
        
        int successCount = 0;
        // 遍历所有连接
        for (SseEmitter emitter : emitters) {
            try {
                // 发送消息
                emitter.send(SseEmitter.event()
                        .name("message")  // 事件类型
                        .data(message + " - " + System.currentTimeMillis()));
                successCount++;
                
                System.out.println("消息已推送给客户端:" + emitter);
            } catch (IOException e) {
                System.out.println("推送失败,移除失效连接");
                emitters.remove(emitter);
            }
        }
        
        return String.format("广播完成!成功推送给 %d/%d 个客户端", 
                successCount, emitters.size());
    }
    
    /**
     * 发送系统通知
     */
    @PostMapping("/system-notice")
    public String sendSystemNotice(@RequestParam String notice) {
        for (SseEmitter emitter : emitters) {
            try {
                emitter.send(SseEmitter.event()
                        .name("system")
                        .data("系统通知:" + notice));
            } catch (IOException e) {
                // 静默处理错误连接
            }
        }
        return "系统通知已发送";
    }
}

第三步:创建WebSocket配置(备选方案,双向通信)

kotlin 复制代码
package com.example.pushdemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new MyWebSocketHandler(), "/ws")
                .setAllowedOrigins("*");  // 生产环境记得限制域名哦!
    }
}
java 复制代码
package com.example.pushdemo.config;

import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;

public class MyWebSocketHandler extends TextWebSocketHandler {
    
    private static final CopyOnWriteArraySet<WebSocketSession> sessions = 
            new CopyOnWriteArraySet<>();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session);
        System.out.println("WebSocket连接建立,当前连接数:" + sessions.size());
        
        try {
            session.sendMessage(new TextMessage("💬 欢迎来到WebSocket聊天室!"));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, 
            TextMessage message) throws Exception {
        // 广播收到的消息
        String payload = message.getPayload();
        System.out.println("收到消息:" + payload);
        
        for (WebSocketSession s : sessions) {
            if (s.isOpen()) {
                s.sendMessage(new TextMessage("用户说:" + payload));
            }
        }
    }
    
    @Override
    public void afterConnectionClosed(WebSocketSession session, 
            org.springframework.web.socket.CloseStatus status) {
        sessions.remove(session);
        System.out.println("WebSocket连接关闭");
    }
}

第四步:创建HTML测试页面(给客户端配个"收音机")

xml 复制代码
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>消息推送测试 - 服务器的碎碎念</title>
    <style>
        body {
            font-family: 'Microsoft YaHei', sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
        }
        
        .container {
            background: white;
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.1);
        }
        
        h1 {
            color: #2c3e50;
            text-align: center;
            margin-bottom: 30px;
        }
        
        .card {
            background: #f8f9fa;
            border-radius: 10px;
            padding: 20px;
            margin: 20px 0;
            border-left: 5px solid #3498db;
        }
        
        .message-area {
            height: 300px;
            overflow-y: auto;
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 15px;
            margin: 20px 0;
            background: #fff;
        }
        
        .message {
            padding: 10px;
            margin: 10px 0;
            border-radius: 8px;
            animation: fadeIn 0.5s;
        }
        
        .system { background: #e3f2fd; }
        .welcome { background: #e8f5e9; }
        .user { background: #fff3e0; }
        
        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(10px); }
            to { opacity: 1; transform: translateY(0); }
        }
        
        button {
            background: #3498db;
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 6px;
            cursor: pointer;
            font-size: 16px;
            transition: all 0.3s;
            margin: 5px;
        }
        
        button:hover {
            background: #2980b9;
            transform: translateY(-2px);
        }
        
        .btn-danger {
            background: #e74c3c;
        }
        
        .btn-success {
            background: #2ecc71;
        }
        
        .input-group {
            display: flex;
            gap: 10px;
            margin: 20px 0;
        }
        
        input {
            flex: 1;
            padding: 12px;
            border: 2px solid #ddd;
            border-radius: 6px;
            font-size: 16px;
        }
        
        input:focus {
            border-color: #3498db;
            outline: none;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>服务器广播站测试</h1>
        
        <div class="card">
            <h3>连接状态</h3>
            <p id="status">准备连接服务器...</p>
            <button onclick="connectSSE()">连接SSE服务器</button>
            <button onclick="connectWebSocket()">连接WebSocket</button>
            <button class="btn-danger" onclick="disconnect()">断开连接</button>
        </div>
        
        <div class="card">
            <h3>消息测试</h3>
            <div class="input-group">
                <input type="text" id="messageInput" 
                       placeholder="输入要广播的消息..." />
                <button onclick="sendBroadcast()">广播消息</button>
                <button class="btn-success" onclick="sendSystemNotice()">
                    发送系统通知
                </button>
            </div>
        </div>
        
        <div class="card">
            <h3>收到的消息</h3>
            <div id="messageArea" class="message-area"></div>
            <button onclick="clearMessages()">清空消息</button>
            <span id="counter">消息数量: 0</span>
        </div>
    </div>

    <script>
        let eventSource = null;
        let ws = null;
        let messageCount = 0;
        
        // 添加消息到显示区域
        function addMessage(content, type = 'system') {
            const area = document.getElementById('messageArea');
            const message = document.createElement('div');
            message.className = `message ${type}`;
            message.innerHTML = `
                <strong>[${new Date().toLocaleTimeString()}]</strong>
                <span>${content}</span>
            `;
            area.appendChild(message);
            area.scrollTop = area.scrollHeight;
            
            messageCount++;
            document.getElementById('counter').textContent = 
                `消息数量: ${messageCount}`;
        }
        
        // 连接SSE
        function connectSSE() {
            if (eventSource) {
                addMessage('已经连接过了,别着急嘛!');
                return;
            }
            
            eventSource = new EventSource('/sse/connect');
            
            eventSource.onopen = () => {
                document.getElementById('status').innerHTML = 
                    'SSE连接成功!服务器现在可以主动推送消息了';
                addMessage('SSE连接已建立', 'welcome');
            };
            
            // 监听不同类型的消息
            eventSource.addEventListener('welcome', (e) => {
                addMessage(e.data, 'welcome');
            });
            
            eventSource.addEventListener('message', (e) => {
                addMessage(`收到广播: ${e.data}`, 'user');
            });
            
            eventSource.addEventListener('system', (e) => {
                addMessage(e.data, 'system');
            });
            
            eventSource.onerror = (e) => {
                document.getElementById('status').innerHTML = 
                    'SSE连接出错,尝试重连中...';
                console.error('SSE错误:', e);
                
                // 3秒后重连
                setTimeout(() => {
                    if (eventSource.readyState === EventSource.CLOSED) {
                        disconnect();
                        connectSSE();
                    }
                }, 3000);
            };
        }
        
        // 连接WebSocket
        function connectWebSocket() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                addMessage('WebSocket已经连接了!');
                return;
            }
            
            const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const host = window.location.host;
            ws = new WebSocket(`${protocol}//${host}/ws`);
            
            ws.onopen = () => {
                document.getElementById('status').innerHTML = 
                    'WebSocket连接成功!可以双向通信了';
                addMessage('WebSocket连接已建立', 'welcome');
            };
            
            ws.onmessage = (e) => {
                addMessage(`WebSocket消息: ${e.data}`, 'user');
            };
            
            ws.onerror = (e) => {
                addMessage('WebSocket连接错误', 'system');
            };
            
            ws.onclose = () => {
                document.getElementById('status').innerHTML = 
                    'WebSocket连接已关闭';
            };
        }
        
        // 发送广播消息
        async function sendBroadcast() {
            const input = document.getElementById('messageInput');
            const message = input.value.trim();
            
            if (!message) {
                addMessage('请输入要发送的消息!', 'system');
                return;
            }
            
            try {
                const response = await fetch('/sse/broadcast', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                    },
                    body: `message=${encodeURIComponent(message)}`
                });
                
                const result = await response.text();
                addMessage(`${result}`, 'system');
                input.value = '';
            } catch (error) {
                addMessage('发送失败: ' + error, 'system');
            }
        }
        
        // 发送系统通知
        async function sendSystemNotice() {
            const notice = prompt('请输入系统通知内容:', '服务器即将维护');
            if (!notice) return;
            
            try {
                const response = await fetch(`/sse/system-notice?notice=${encodeURIComponent(notice)}`, {
                    method: 'POST'
                });
                
                const result = await response.text();
                addMessage(`${result}`, 'system');
            } catch (error) {
                addMessage('发送系统通知失败', 'system');
            }
        }
        
        // 断开连接
        function disconnect() {
            if (eventSource) {
                eventSource.close();
                eventSource = null;
                addMessage('SSE连接已关闭', 'system');
            }
            
            if (ws) {
                ws.close();
                ws = null;
                addMessage('WebSocket连接已关闭', 'system');
            }
            
            document.getElementById('status').innerHTML = 
                '连接已断开';
        }
        
        // 清空消息
        function clearMessages() {
            document.getElementById('messageArea').innerHTML = '';
            messageCount = 0;
            document.getElementById('counter').textContent = '消息数量: 0';
            addMessage('消息已清空', 'system');
        }
        
        // 页面加载时的小动画
        window.onload = () => {
            addMessage('消息推送演示系统已启动', 'welcome');
            addMessage('试试点击"连接SSE服务器"按钮开始体验吧!', 'system');
        };
        
        // 监听键盘事件
        document.getElementById('messageInput').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                sendBroadcast();
            }
        });
    </script>
</body>
</html>

三、进阶优化:让推送更"聪明"

1. 连接管理器(专业版)

scss 复制代码
@Component
public class ConnectionManager {
    
    private final Map<String, SseEmitter> userConnections = 
            new ConcurrentHashMap<>();
    
    private final Map<String, String> connectionUserMap = 
            new ConcurrentHashMap<>();
    
    /**
     * 添加用户连接
     */
    public SseEmitter addConnection(String userId) {
        // 移除旧连接(避免重复登录)
        if (userConnections.containsKey(userId)) {
            userConnections.get(userId).complete();
        }
        
        SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); // 30分钟超时
        
        emitter.onCompletion(() -> removeConnection(userId));
        emitter.onTimeout(() -> removeConnection(userId));
        
        userConnections.put(userId, emitter);
        
        // 发送连接成功消息
        try {
            emitter.send(SseEmitter.event()
                    .name("connected")
                    .data("用户 " + userId + " 连接成功!"));
        } catch (IOException e) {
            removeConnection(userId);
        }
        
        return emitter;
    }
    
    /**
     * 向指定用户推送消息
     */
    public void pushToUser(String userId, String message) {
        SseEmitter emitter = userConnections.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event()
                        .data(message));
            } catch (IOException e) {
                removeConnection(userId);
            }
        }
    }
    
    /**
     * 向所有用户广播
     */
    public void broadcast(String message) {
        userConnections.forEach((userId, emitter) -> {
            try {
                emitter.send(SseEmitter.event()
                        .data(message));
            } catch (IOException e) {
                removeConnection(userId);
            }
        });
    }
    
    private void removeConnection(String userId) {
        userConnections.remove(userId);
        System.out.println("用户 " + userId + " 的连接已移除");
    }
}

2. 心跳检测(保持连接活跃)

typescript 复制代码
@Component
public class HeartbeatScheduler {
    
    @Autowired
    private ConnectionManager connectionManager;
    
    @Scheduled(fixedRate = 25000) // 每25秒发送一次心跳
    public void sendHeartbeat() {
        connectionManager.broadcast("心跳检测 - " + new Date());
    }
}

四、不同方案的对比总结

方案 优点 缺点 适用场景
SSE 简单易用、自动重连、HTTP协议友好 只能服务器到客户端单向 实时通知、新闻推送、股票行情
WebSocket 双向通信、实时性最好 实现复杂、需要额外协议 聊天室、在线游戏、协同编辑
长轮询 兼容性好、实现简单 延迟高、服务器压力大 兼容性要求高的老系统
短轮询 极其简单、无状态 实时性差、资源浪费 更新频率低的应用

五、总结:让服务器"开口说话"的艺术

通过这次探索,我们给SpringBoot服务器装上了"嘴巴",让它学会了主动和客户端聊天!总结一下关键点:

  1. SSE是你的好朋友 - 对于服务器向客户端的单向推送,SSE简单到让人感动
  2. 连接管理很重要 - 记得及时清理断开的连接,不然服务器内存会"爆炸"
  3. 错误处理不能忘 - 网络世界充满了不确定性,要优雅地处理各种异常
  4. 心跳检测保活力 - 定期发送心跳,防止连接被防火墙误杀
  5. 生产环境要优化 - 记得添加认证、限流、集群支持等

推送消息就像谈恋爱,不能太频繁(用户会烦),也不能太冷淡(用户会跑),要掌握好节奏!而且千万别"已读不回",那比不推送还糟糕!

现在,你的服务器已经从"闷葫芦"变成了"社交达人",快去让它和客户端愉快地聊天吧!记住:好的推送系统,应该是用户感觉不到它的存在,但需要时它永远在那里!

如果你想让推送更有趣,可以添加表情包识别、消息优先级、智能推送时间等功能。毕竟,谁不喜欢一个会"察言观色"的服务器呢?

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

相关推荐
蒟蒻小袁3 小时前
Hot100--找到字符串中所有字母异位词
java·算法·leetcode·面试
+VX:Fegn08953 小时前
人力资源管理|基于springboot + vue人力资源管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·课程设计
即随本心0.o3 小时前
SSE服务搭建
java·spring boot
MarkHD3 小时前
车辆TBOX科普 第56次 从模块拼接到可靠交付的实战指南
java·开发语言
灰什么鱼3 小时前
OkHttp + Retrofit2 调用第三方接口完整教程(以nomad为例)
java·spring boot·okhttp·retrofit
一 乐3 小时前
海鲜商城购物|基于SprinBoot+vue的海鲜商城系统(源码+数据库+文档)
前端·javascript·数据库·vue.js·spring boot
GOTXX3 小时前
性能与可靠双突破:openEuler 服务器场景评测报告
运维·服务器·网络·人工智能·后端·python
秋邱3 小时前
AR 技术团队搭建与规模化接单:从个人到团队的营收跃迁
前端·人工智能·后端·python·html·restful