SpringBoot结合SSE模式实现服务端命令下发

SSE(Server-Sent Events)是一种基于HTTP的单向通信协议,适用于服务端向客户端推送实时数据的场景。SpringBoot提供了对SSE的原生支持,以下为具体实现方法。

技术要点:

1、spring boot集群

2、sse命令下发

3、redis订阅发布(可用rocketmq或者其他消息中间件代替)

4、nginx反向代理

1、添加依赖

确保pom.xml中包含SpringBoot Web依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
 
 <dependency>
         <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>

 <!-- redis 缓存操作 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

2、创建SSE控制器

通过设备编号、密钥确保连接安全。

复制代码
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;


/**
 * @author xuancg
 */
@RestController
@RequestMapping("/sse")
@Validated
@Slf4j
public class SseController {

    @Resource
    private SseServiceImpl sseService;
 

    /**
     * 创建sse链接
     */
    @GetMapping("/createConnect/{devNo}/{devKey}")
    public SseEmitterUTF8 createConnect(@PathVariable("devNo") String devNo, @PathVariable("devKey") String devKey) {
        if(StrUtil.isBlank(devNo) || StrUtil.isBlank(devKey)){
            log.info("sse注册信息不全");
            return null;
        }
        return  sseService.createConnect(devNo, devKey);
    }

    /**
     * 关闭链接
     */
    @GetMapping("/closeConnect/{devNo}/{devKey}")
    public Boolean closeConnect(@PathVariable("devNo") String devNo, @PathVariable("devKey") String devKey) {
        ThreadUtil.execute(() -> {
            sseService.closeConnect(devNo, devKey);
        });
        return true;
    }

 

}

3、创建SSE业务服务

3.1、自定义 SseEmitter对象

其中uuid作为唯一标识,为后续连接下线使用

复制代码
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.nio.charset.StandardCharsets;

/***
 *
 * @author xuancg
 * @date 2025/7/24
 */
public class SseEmitterUTF8 extends SseEmitter {
    public SseEmitterUTF8(Long timeout) {
        super(timeout);
        this.uuid = IdUtils.fastUUID();
    }

    private String uuid;

    @Override
    protected void extendResponse(ServerHttpResponse outputMessage) {
        super.extendResponse(outputMessage);
        HttpHeaders headers = outputMessage.getHeaders();
        headers.setContentType(new MediaType(MediaType.TEXT_EVENT_STREAM, StandardCharsets.UTF_8));
    }

    public String getUuid() {
        return uuid;
    }

    public void setUuid(String uuid) {
        this.uuid = uuid;
    }
}

3.2、创建具体业务处理

复制代码
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.http.HttpStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;


/**
 * @author xuancg
 */
@Slf4j
@Service
public class SseServiceImpl {

    /**
     * 容器,保存连接,用于输出返回 ;可使用其他方法实现,使用广播消息方式
     */
    private static final Map<String, SseEmitterUTF8> SSE_CACHE = MapUtil.newConcurrentHashMap();

    /**
     * 重试次数
     */
    private final Integer RESET_COUNT = 3;

    /**
     * 重试等待事件 单位 ms
     */
    private final Integer RESET_TIME = 5000;
	/**连接超时时间10分钟*/
    private final long TIME_OUT = 10 * 60 * 1000L;

    @Resource
    private DeviceService deviceService;
    @Resource
    private RedisCache redisCache;
    @Resource
    private RedisParserFactory redisParserFactory;

   

    /**
     * 创建连接
     */
    public SseEmitterUTF8 createConnect(String devNo, String devKey) {
        // 是否需要给客户端推送ID
        // 检查 设备账号密码是否匹配
        Device device = deviceService.getByDevNoAndKey(devNo, devKey);
        if(null == device){
            log.info("sse注册设备信息不匹配" + devNo);
            return null;
        }
        // 设置超时时间 单位毫秒,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutException
        SseEmitterUTF8 sseEmitter = new SseEmitterUTF8(TIME_OUT);

        // 如果存在上一个,关闭连接
        if(SSE_CACHE.containsKey(devNo)){
            closeConnect(devNo);
        } else {
			// 集群下,广播通知关闭
            ClientNotifyResp resp = new ClientNotifyResp(ClientOptType.CLOSE_CONNECT, sseEmitter.getUuid());
            sendMessageByPublish(devNo, resp, true);
        }
        // 注册回调
        // 长链接完成后回调接口(即关闭连接时调用)
        sseEmitter.onCompletion(completionCallBack(devNo));
        // 连接超时回调
        sseEmitter.onTimeout(timeoutCallBack(devNo));
        // 推送消息异常时,回调方法
        sseEmitter.onError(errorCallBack(devNo));

        SSE_CACHE.put(devNo, sseEmitter);
        log.info("创建新的sse连接,当前用户:{}  累计用户:{}", devNo, SSE_CACHE.size());
        // TODO 连接成功的后续操作
        return sseEmitter;
    }

       
    /**
     * 关闭连接
     *
     * @param devNo 客户端ID
     */
    public void closeConnect(String devNo, String devKey) {
        Device device = deviceService.getByDevNoAndKey(devNo, devKey);
        if(null ==  device){
            return ;
        }
        closeConnect(devNo);
    }

    private void closeConnect(String devNo){
        SseEmitterUTF8 sseEmitter = SSE_CACHE.get(devNo);
        if (sseEmitter != null) {
            log.info("关闭sse连接,当前用户:{} uuid:{}", devNo, sseEmitter.getUuid());
            sseEmitter.complete();
            removeUser(devNo);
        }
    }

    /**
     * 1、如果当前sessionMap存在deviceId则直接发送消息
     * 2、否则消息发布至redis,进行广播通知处理;
     * @param resp
     * @param broadcast 是否广播
     */
    public void sendMessageByPublish(String devNo, ClientNotifyResp resp, boolean broadcast){
        if(SSE_CACHE.containsKey(devNo)){
            if(resp.getOptType() == ClientOptType.CLOSE_CONNECT){
                // 防止自己清除自己
                SseEmitterUTF8 sseEmitter = SSE_CACHE.get(devNo);
                if(sseEmitter.getUuid().equals(String.valueOf(resp.getData()))){
                    return ;
                }
                log.info("sse关闭上一个连接");
                closeConnect(devNo);
                return ;
            }
            sendMsgToClientByDevNo(devNo, resp);
            return ;
        }
        if(broadcast){
            redisParserFactory.getParser(ContentType.CLIENT_BROWSER_BROADCAST_PARSER).send(devNo, resp);
        }
    }

    /**
     * 推送消息到客户端
     * 此处做了推送失败后,重试推送机制,可根据自己业务进行修改
     *
     * @param devNo  客户端ID
     * @param msg 推送信息,此处结合具体业务,定义自己的返回值即可
     **/
    private void sendMsgToClientByDevNo(String devNo, ClientNotifyResp msg) {
        SseEmitterUTF8 emitter = SSE_CACHE.get(devNo);
        if (emitter == null) {
            return;
        }
        log.info("SSE推送消息:设备={},类型={}", devNo, msg.getOptType());
        SseEmitterUTF8.SseEventBuilder sendData = SseEmitterUTF8.event().id(String.valueOf(HttpStatus.HTTP_OK))
                .data(msg, MediaType.APPLICATION_JSON);
        try {
            emitter.send(sendData);
        } catch (Exception e) {
            // 推送消息失败,记录错误日志,进行重推
            log.error("推送消息失败:{},尝试进行重推", msg);
            // 推送消息失败后,每隔10s推送一次,推送5次
            for (int i = 0; i < RESET_COUNT; i++) {
                try {
                    // 防止等待过程中,连接断开
                    Thread.sleep(RESET_TIME);
                    emitter = SSE_CACHE.get(devNo);
                    if (emitter == null) {
                        log.error("{}的第{}次消息重推失败,未创建长链接", devNo, i + 1);
                        continue;
                    }
                    emitter.send(sendData);
                } catch (Exception ex) {
                    log.error("{}的第{}次消息重推失败", devNo, i + 1, ex);
                    continue;
                }
                log.info("{}的第{}次消息重推成功,{}", devNo, i + 1, msg);
                return;
            }
        }
    }


    /**
     * 长链接完成后回调接口(即关闭连接时调用)
     *
     * @param devNo 客户端ID
     **/
    private Runnable completionCallBack(String devNo) {
        return () -> {
            log.info("结束连接:{}", devNo);
            removeUser(devNo);
        };
    }

    /**
     * 连接超时时调用
     *
     * @param devNo 客户端ID
     **/
    private Runnable timeoutCallBack(String devNo) {
        return () -> {
            log.info("连接超时:{}", devNo);
            removeUser(devNo);
        };
    }

    /**
     * 推送消息异常时,回调方法
     *
     * @param devNo 客户端ID
     **/
    private Consumer<Throwable> errorCallBack(String devNo) {
        return throwable -> {
            log.error("SseEmitterServiceImpl[errorCallBack]:连接异常,客户端ID:{}", devNo);

            // 推送消息失败后,每隔10s推送一次,推送5次
            for (int i = 0; i < RESET_COUNT; i++) {
                try {
                    Thread.sleep(RESET_TIME);
                    SseEmitterUTF8 sseEmitter = SSE_CACHE.get(devNo);
                    if (sseEmitter == null) {
                        log.error("SseEmitterServiceImpl[errorCallBack]:第{}次消息重推失败,未获取到 {} 对应的长链接", i + 1, devNo);
                        continue;
                    }
                    sseEmitter.send("失败后重新推送");
                } catch (Exception e) {
                    log.error("sse推送消息异常", e);
                }
            }
        };
    }

    /**
     * 移除用户连接
     *
     * @param devNo 客户端ID
     **/
    private void removeUser(String devNo) {
        SSE_CACHE.remove(devNo);  
        // 登录对象移除
        log.info("SseEmitterServiceImpl[removeUser]:移除用户:{}", devNo);
    }



}

3.3、消息类型

复制代码
/**
 * 客户端操作类型记录
 * @author xuancg
 */
public enum ClientOptType {

    /** 保持联通性*/
    PING("保持联通性"),

    /** 创建支付通知 */
    CREATE_PAY("创建支付通知"),

    /**检测项完成通知*/
    CHECK_ITEM_SUCCESS("检测项完成通知"),

    /** 检测完成通知 */
    CHECK_COMPLETE("检测完成通知"),

    /**反馈接收通知 */
    RECEIVE_RESP_ID("反馈接收通知"),

    /**终端激活通知*/
    ACTIVE_SUCCESS("终端激活"),

    /**终端上传日志*/
    UPLOAD_LOG("终端上传日志"),

    /**服务端关闭已经存在的上一个连接*/
    CLOSE_CONNECT("关闭连接"),

    CONNECT_SUCCESS("登录成功"),

    private String desc;

    ClientOptType(String desc) {
        this.desc = desc;
    }
}

3.4、消息对象

复制代码
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/***
 * 返回客户端通知对象
 * @author xuancg
 */
@Data
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ClientNotifyResp implements Serializable {

    /** 返回状态码 code200=成功 500=异常*/
    private int code;

    private String msg;

    private Object data;

    /**操作类型*/
    private ClientOptType optType;

    public ClientNotifyResp(ClientOptType optType, Object data) {
        this.data = data;
        this.optType = optType;
        this.code = HttpStatus.SUCCESS;
    }
}

4、Redis订阅发布

如果服务端为单机模式,则不需要redis或者其他消息中间件

详见附件资源

5、客户端连接

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>SSE 客户端测试</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 p-8">
    <div class="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md">
        <h1 class="text-2xl font-bold mb-4">SSE 客户端测试</h1>
        
        <div class="mb-4">
            <label for="devNo" class="block text-gray-700 mb-2">客户端编号:</label>
            <input type="text" id="devNo" value="11111" 
                   class="w-full px-3 py-2 border border-gray-300 rounded-md">
				   
			<label for="devKey" class="block text-gray-700 mb-2">客户端密钥:</label>
            <input type="text" id="devKey" value="80f326f450f8ba0717bc6a071bc" 
                   class="w-full px-3 py-2 border border-gray-300 rounded-md">
				   
            <button id="connectBtn" class="mt-2 bg-blue-500 text-white px-4 py-2 rounded-md">
                建立连接
            </button>
            <button id="disconnectBtn" class="mt-2 bg-red-500 text-white px-4 py-2 rounded-md ml-2" disabled>
                断开连接
            </button>
        </div>
        
        <div class="mb-4">
            <label for="message" class="block text-gray-700 mb-2">发送消息:</label>
            <input type="text" id="message" placeholder="输入要发送的消息"
                   class="w-full px-3 py-2 border border-gray-300 rounded-md">
            <button id="sendBtn" class="mt-2 bg-green-500 text-white px-4 py-2 rounded-md" disabled>
                发送消息
            </button>
        </div>
        
        <div>
            <h2 class="text-xl font-semibold mb-2">接收消息:</h2>
            <div id="messageContainer" class="border border-gray-300 rounded-md p-4 h-64 overflow-y-auto bg-gray-50">
                <!-- 消息将显示在这里 -->
            </div>
        </div>
    </div>

    <script>
        let eventSource;
        const devNoInput = document.getElementById('devNo');
		const devKeyInput = document.getElementById('devKey');
        const messageInput = document.getElementById('message');
        const messageContainer = document.getElementById('messageContainer');
        const connectBtn = document.getElementById('connectBtn');
        const disconnectBtn = document.getElementById('disconnectBtn');
        const sendBtn = document.getElementById('sendBtn');
		
		const host = "http://127.0.0.1:8081"
		
		const devNo = devNoInput.value.trim();
		const devKey = devKeyInput.value.trim();
        // 建立连接
        connectBtn.addEventListener('click', () => {
            
            
            if (!devNo) {
                alert('请输入客户端ID');
                return;
            }
            
            // 关闭已存在的连接
            if (eventSource) {
                eventSource.close();
            }
            
            // 建立新连接
            eventSource = new EventSource(host + `/app/sse/createConnect/${devNo}/${devKey}`);
            
            // 连接成功
            eventSource.onopen = () => {
                addMessage('连接已建立');
                connectBtn.disabled = true;
                disconnectBtn.disabled = false;
                sendBtn.disabled = false;
            };
            
            // 接收消息
            eventSource.onmessage = (event) => {
                addMessage(`收到消息: ${event.data}`);
            };
            
            // 处理特定类型的事件
            eventSource.addEventListener('connect', (event) => {
                addMessage(`系统消息: ${event.data}`);
            });
            
            eventSource.addEventListener('timer', (event) => {
                addMessage(`定时消息: ${event.data}`);
            });
            
            // 连接错误
            eventSource.onerror = (error) => {
                addMessage(`连接错误: ${error}`);
                eventSource.close();
                connectBtn.disabled = false;
                disconnectBtn.disabled = true;
                sendBtn.disabled = true;
            };
        });
        
        // 断开连接
        disconnectBtn.addEventListener('click', () => {
            if (eventSource) {
                eventSource.close();
                fetch(host + `/app/sse/closeConnect/${devNo}/${devKey}`)
                    .then(response => response.text())
                    .then(message => addMessage(message));
            }
            connectBtn.disabled = false;
            disconnectBtn.disabled = true;
            sendBtn.disabled = true;
        });
        
        // 发送消息
        sendBtn.addEventListener('click', () => {
            const devNo = devNoInput.value.trim();
            const message = messageInput.value.trim();
            
            if (!message) {
                alert('请输入消息内容');
                return;
            }
            
            fetch(`/sse/send/${devNo}`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'text/plain'
                },
                body: message
            })
            .then(response => response.text())
            .then(result => {
                addMessage(`发送状态: ${result}`);
                messageInput.value = '';
            });
        });
        
        // 添加消息到页面
        function addMessage(text) {
            const timestamp = new Date().toLocaleTimeString();
            const messageElement = document.createElement('div');
            messageElement.className = 'mb-2 p-2 border-b border-gray-200';
            messageElement.innerHTML = `<span class="text-gray-500">[${timestamp}]</span> ${text}`;
            messageContainer.appendChild(messageElement);
            messageContainer.scrollTop = messageContainer.scrollHeight;
        }
    </script>
</body>
</html>

6、Nginx代理配置

与一般http请求存在差异,需要给sse配置代理

复制代码
location /sse/createConnect {
			add_header X-XSS-Protection "1;mode=block";
			add_header X-Frame-Options DENY;
			add_header X-Content-Type-Options nosniff;
			proxy_set_header X-Real-IP $remote_addr;
			proxy_set_header X-Forwarded-For $http_x_forwarded_for;
			proxy_set_header Host $host;
			proxy_set_header X-Scheme $scheme;
			proxy_set_header Accept-Encoding "";
			proxy_ssl_session_reuse on;
			proxy_redirect off;
			proxy_ignore_client_abort on;
			proxy_http_version 1.1;
			proxy_set_header Connection "";
			proxy_connect_timeout 86400s;
			# SSE 连接时的超时时间 
			proxy_buffering off;
			proxy_cache off;
			gzip off; 
			add_header Access-Control-Allow-Origin *;
			add_header Cache-Control no-cache;
			proxy_read_timeout 3600s;
			proxy_send_timeout 3600s; 
			proxy_pass http://127.0.0.1:8091;
		}