SSE 服务端推送实现和实际问题解决

实现框架springboot

很多文章都用它来和webscoket对比,就不细讲了,简单来说sse 只能server主动向client推送

并且特点是具备主动断开重连功能,这点是和webscoket我认为比较重要的区别点因为这点有实际bug,原因是sse客户端每次重连都是建立新通道,导致服务端找不到之前的通道,但是消息也会推送成功,只是报错就很烦了因为这是个性能问题(如果服务器是巨兽或低并发不考虑)

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 消息体
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageVo {
    /**
     * 客户端id
     */
    private String clientId;
    /**
     * 传输数据体(json)
     */
    private String data;
}

数据载体类

import com.ruoyi.business.sse.main.MessageVo;
import lombok.extern.slf4j.Slf4j;
import org.java_websocket.client.WebSocketClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;

/**
 * 实现
 */
@Slf4j
@Service
public class SseEmitterServiceImpl implements SseEmitterService {

    @Value("${wss.url}")
    private String wssUrl;

    /**
     * 容器,保存连接,用于输出返回 ;可使用其他方法实现
     */
    private static final Map<String, SseEmitter> sseCache = new ConcurrentHashMap<>();

    /**
     * 创建连接
     *
     * @param clientId 客户端ID
     */
    @Override
    public SseEmitter createConnect(String clientId) throws URISyntaxException {

        if (sseCache.containsKey(clientId)) {
            removeUser(clientId);
        }
        // 设置超时时间,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutException
        SseEmitter sseEmitter = new SseEmitter(50 * 1000L);

        // 添加自己的业务

        // 注册回调
        sseEmitter.onCompletion(completionCallBack(clientId,client));     // 长链接完成后回调接口(即关闭连接时调用)
        sseEmitter.onTimeout(timeoutCallBack(clientId,client));        // 连接超时回调
        sseEmitter.onError(errorCallBack(clientId,client));          // 推送消息异常时,回调方法
        sseCache.put(clientId, sseEmitter);
        System.out.println("创建新的sse连接,当前用户:{" + clientId + "}  累计用户:{" + sseCache.size() + "}");
        try {
            // 注册成功返回用户信息
            sseEmitter.send(SseEmitter.event().id(String.valueOf(HttpStatus.OK)).data(clientId, MediaType.APPLICATION_JSON));
        } catch (IOException e) {
            sseEmitter.complete();
            System.out.println("创建长链接异常,客户端ID:{}   异常信息:{" + clientId + "}    异常信息:{}{" + e.getMessage() + "}");
        }
        return sseEmitter;
    }

    /**
     * 关闭连接
     *
     * @param clientId 客户端ID
     */
    @Override
    public void closeConnect(String clientId) {
        SseEmitter sseEmitter = sseCache.get(clientId);
        if (sseEmitter != null) {
            sseEmitter.complete();
            removeUser(clientId);
        }
    }

    /**
     * 长链接完成后回调接口(即关闭连接时调用)
     *
     * @param clientId 客户端ID
     **/
    private Runnable completionCallBack(String clientId,SSEWebSocketClient client) {
        return () -> {
            System.out.println("连接关闭!!!");
            closeConnect(clientId);
            client.close();
        };
    }

    /**
     * 连接超时时调用
     *
     * @param clientId 客户端ID
     **/
    private Runnable timeoutCallBack(String clientId,SSEWebSocketClient client) {
        return () -> {
            System.out.println("连接超时!!!");
            closeConnect(clientId);
            client.close();
        };
    }

    /**
     * 推送消息异常时,回调方法
     *
     * @param clientId 客户端ID
     **/
    private Consumer<Throwable> errorCallBack(String clientId,SSEWebSocketClient client) {
        return throwable -> {
            System.out.println("消息异常!!!");
            closeConnect(clientId);
            client.close();
        };
    }

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

业务实现类

import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.business.sse.main.MessageVo;
import lombok.SneakyThrows;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;
import java.io.IOException;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;

public class SSEWebSocketClient extends WebSocketClient {

    private String clientId;
    private SseEmitter sseEmitter;


    public String getClientId() {
        return clientId;
    }

    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    public SseEmitter getSseEmitter() {
        return sseEmitter;
    }

    public void setSseEmitter(SseEmitter sseEmitter) {
        this.sseEmitter = sseEmitter;
    }

    public SSEWebSocketClient(URI serverUri, Map<String, String> httpHeaders) {
        super(serverUri, httpHeaders);
    }

    @Override
    public void onOpen(ServerHandshake handshakedata) {
        System.out.println("新连接已打开");
        super.send("{\"type\":\"connection_init\"}");
        try {
            Thread.sleep(700L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        String param = "private-search-" + clientId;
        super.send("{\"id\": \"" + param + "\",\"payload\": {\"data\": \"{\\\"query\\\":\\\"subscription Subscribe($name: String!) {\\\\n  subscribe(name: $name) {\\\\n    name\\\\n    data\\\\n    __typename\\\\n  }\\\\n}\\\\n\\\",\\\"variables\\\":{\\\"name\\\":\\\"" + param + "\\\"}}\",\"extensions\": {\"authorization\": {\"host\": \"apndpknqrjhgpbhqwfwdmcx2du.appsync-api.cn-northwest-1.amazonaws.com.cn\",\"x-amz-date\": \"20240412T033202Z\",\"x-api-key\": \"da2-rduvi64eizehdotnp3a3jhokuq\",\"x-amz-user-agent\": \"aws-amplify/6.0.27 api/1 framework/2\"}}},\"type\": \"start\"}");
    }

    @Override
    public void onMessage(String message) {
        System.out.println("接收到消息: " + message);
        if (!message.contains("连接成功")) {
            JSONObject result1 = JSONObject.parse(message);
            if (result1.getString("type").equals("data")) {
                if (result1.getJSONObject("payload").getJSONObject("data").getJSONObject("subscribe").getJSONObject("data").getString("message").equals("streaming")) {
                    MessageVo messageVo = new MessageVo(clientId, result1.getJSONObject("payload").getJSONObject("data").getJSONObject("subscribe").getString("data"));
                    SseEmitter.SseEventBuilder sendData = SseEmitter.event().id(String.valueOf(HttpStatus.OK))
                            .data(messageVo, MediaType.APPLICATION_JSON);
                    try {
                        sseEmitter.send(sendData);
                    } catch (IOException e) {
                        e.printStackTrace();
                        sseEmitter.complete();
                    }
                }

                if (result1.getJSONObject("payload").getJSONObject("data").getJSONObject("subscribe").getJSONObject("data").getString("message").equals("streaming_end")) {
                    MessageVo messageVo = new MessageVo(clientId, result1.getJSONObject("payload").getJSONObject("data").getJSONObject("subscribe").getString("data"));
                    SseEmitter.SseEventBuilder sendData = SseEmitter.event().id(String.valueOf(HttpStatus.OK))
                            .data(messageVo, MediaType.APPLICATION_JSON);
                    try {
                        sseEmitter.send(sendData);
                        sseEmitter.complete();

                    } catch (IOException e) {
                        e.printStackTrace();
                        sseEmitter.complete();
                    }
                }

            } else if (result1.getString("type").equals("start_ack")) {
                System.out.println("连接成功!!!");
            } else {
                System.out.println(result1.toJSONString());
            }
        }
    }

    @Override
    public void onClose(int code, String reason, boolean remote) {
        System.out.println("连接已关闭");
    }

    @Override
    public void onError(Exception ex) {
        ex.printStackTrace();
    }

}

接口类

import com.ruoyi.business.sse.service.SseEmitterService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.StringUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.Map;

/**
 * SSE长链接
 */
@RestController
@RequestMapping("/sse")
public class SseEmitterController {

    @Resource
    private SseEmitterService sseEmitterService;

    @Value("${appsync.url}")
    private String url;

    @CrossOrigin
    @GetMapping("/createConnect/{clientId}")
    public SseEmitter createConnect(@PathVariable("clientId") String clientId) throws URISyntaxException {
        return sseEmitterService.createConnect(clientId);
    }

    @CrossOrigin
    @GetMapping("/closeConnect")
    public void closeConnect(@RequestParam(required = true) String clientId) {
        sseEmitterService.closeConnect(clientId);
    }

    @CrossOrigin
    @PostMapping("/sendAjaxMessage")
    public AjaxResult sendMessage(@RequestBody Map map) {

        // map参数体校验
        if (map == null) {
            return AjaxResult.error(601, "请求接口需要传递正确的JSON数据");
        }
        // query 参数校验
        String query = (String) map.get("query");
        if (StringUtils.isEmpty((String) map.get("query"))) {
            return AjaxResult.error(601, " query 不能为空");
        }

        // query 参数校验
        String messageId = (String) map.get("messageId");
        if (StringUtils.isEmpty((String) map.get("messageId"))) {
            return AjaxResult.error(601, " messageId 不能为空");
        }

        // connectionId 参数校验
        String connectionId = (String) map.get("connectionId");
        if (StringUtils.isEmpty((String) map.get("connectionId"))) {
            return AjaxResult.error(601, " connectionId 不能为空");
        }


        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.add("connectionId", connectionId);

        String indexName = "smart_search_qa_test";
        String prompt = "";


        String jsonBody = "{\n" +
                "    \"action\": \"search\",\n" +
                "    \"configs\": {\n" +
                "        \"name\": \"demo\",\n" +
                "        \"searchEngine\": \"opensearch\",\n" +
                "        \"llmData\": {\n" +
                "            \"strategyName\": \"unimportant\",\n" +
                "            \"type\": \"sagemaker_endpoint\",\n" +
                "            \"embeddingEndpoint\": \"huggingface-inference-eb\",\n" +
                "            \"modelType\": \"non_llama2\",\n" +
                "            \"recordId\": \"dji-inference-chatglm3-6b-62277\",\n" +
                "            \"sagemakerEndpoint\": \"dji-inference-chatglm3-6b\",\n" +
                "            \"streaming\": true\n" +
                "        },\n" +
                "        \"role\": \"\",\n" +
                "        \"language\": \"chinese\",\n" +
                "        \"taskDefinition\": \"\",\n" +
                "        \"outputFormat\": \"stream\",\n" +
                "        \"isCheckedGenerateReport\": false,\n" +
                "        \"isCheckedContext\": false,\n" +
                "        \"isCheckedKnowledgeBase\": true,\n" +
                "        \"indexName\": \"" + indexName + "\",\n" +
                "        \"topK\": 2,\n" +
                "        \"searchMethod\": \"vector\",\n" +
                "        \"txtDocsNum\": 0,\n" +
                "        \"vecDocsScoreThresholds\": 0,\n" +
                "        \"txtDocsScoreThresholds\": 0,\n" +
                "        \"isCheckedScoreQA\": true,\n" +
                "        \"isCheckedScoreQD\": true,\n" +
                "        \"isCheckedScoreAD\": true,\n" +
                "        \"contextRounds\": 2,\n" +
                "        \"isCheckedEditPrompt\": false,\n" +
                "        \"prompt\": \"" + prompt + "\",\n" +
                "        \"tokenContentCheck\": \"\",\n" +
                "        \"responseIfNoDocsFound\": \"Cannot find the answer\"\n" +
                "    },\n" +
                "    \"query\": \"" + query + "\"," +
                "    \"messageId\": \"" + messageId + "\"" +
                "}";
        HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers);

        try {
            RestTemplate restTemplate = new RestTemplate();
            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
            return AjaxResult.success("操作成功");
        } catch (Exception e) {
            e.printStackTrace();
            return AjaxResult.error("操作失败");
        }
    }


}

控制类

MessageVo messageVo = new MessageVo(clientId,"你想推送给页面的消息这段代码写在自己的也业务");
                    SseEmitter.SseEventBuilder sendData = SseEmitter.event().id(String.valueOf(HttpStatus.OK))
                            .data(messageVo, MediaType.APPLICATION_JSON);
                    try {
                        sseEmitter.send(sendData);
                        sseEmitter.complete();

                    } catch (IOException e) {
                        e.printStackTrace();
                        sseEmitter.complete();
                    }

有一点很抱歉接口封装的发送消息代码是个未完成的代码一定要用上者SseEmitter对象要注意甄别这个类似通道的对象

还有一点注意如果发送消息完成之后一定要complete() 不然会出现异常这个问题资料都避而不谈网上一直强调sse使用简单但是实现并且维护一个高压sse很复杂异常百出资料稀少;

还有一点注意sseEmitter.complete();要在你设置超时时间之内;

其实可以设置0L永不超时但是对服务器压力巨大,因为用户量也大,也根据实际情况(我这边是aws服务产品二开请求或连接不能超过1分钟,超过主动断开)

结语:虽然我实现了webscoket 和 sse 两个版本但是我还是最后采用了sse ,没有别的新技术还是要支持即使会有问题但是我也要多发现暴露出来别让后人踩坑,注意代码要结合自己的业务符合java语言整改,我也是找别人改的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值