【基于Springboot+SSE+React 实现服务端向客户端推送及时通知(内附演示视频+详细代码注释+前后端项目地址)】


前言

项目背景:在某医院的急诊死亡预测系统中,后端通过接受前端传入的病人体征参数然后通过机器学习算法,计算出风险值后,判断该病人是否会有生命危险,然后通过后端给用户端发送消息,让用户(医生)能及时的抢救病人。那么后端如何实现向前端发消息呢,其实有两种选择一种是websocket一种就是本文用到的SSE。本项目只是独立于原项目的一个小demo与原项目无关。本demo前端采用react框架搭建,使用的antd组件,由于本文主要介绍的是后端技术,前端代码不会详细介绍,前端只是一个空壳,代码不难,很容易看懂,而且博主已经差不多半年没碰前端了。


一、前导知识

1.1 SSE简介

SSE(Server-Sent Event)直译为服务器发送事件,顾名思义,也就是客户端可以获取到服务器发送的事件我们常见的 http 交互方式是客户端发起请求,服务端响应,然后一次请求完毕;但是在 SSE 的场景下,客户端发起请求,连接一直保持,服务端有数据就可以返回数据给客户端,这个返回可以是多次间隔的方式。

SSE的特点总结为两个:
1. 长连接
2. 服务端可以向客户端推送消息
3. 客户端自动重连

SSE扫盲连接

1.2 各通信技术对比

Ajax短轮询CometWebSocketSSE
http端轮询是服务器收到请求不管是否有数据都直接响应 http 请求; 浏览器受到 http 响应隔一段时间在发送同样的http 请求查询是否有数据;http 长轮询是服务器收到请求后如果有数据, 立刻响应请求; 如果没有数据就会 hold 一段时间,这段时间内如果有数据立刻响应请求; 如果时间到了还没有数据, 则响应 http 请求;浏览器受到 http 响应后立在发送一个同样http 请求查询是否有数据;WebSocket的实现了一次连接,双方通信的功能。首先由客户端发出WebSocket请求,服务器端进行响应,实现类似TCP握手的动作。这个连接一旦建立起来,就保持在客户端和服务器之间,两者之间可以直接的进行数据的互相传送。在 sse 的场景下,客户端发起请求,连接一直保持,服务端有数据就可以返回数据给客户端,这个返回可以是多次间隔的方式。sse 是单通道,只能服务端向客户端发消息

1.3 后端SseEmitter 核心方法

后端中实现SSE 主要依靠的就是SseEmitter这个类,下面对于它的核心方法如下:

1. send(): 发送数据,如果传入的是一个非SseEventBuilder对象,那么传递参数会被封装到 data 中

2. complete(): 表示执行完毕,会断开连接

3. onTimeout(): 超时回调触发

4. onCompletion(): 结束之后的回调触发

1.4 前端EventSource核心方法

前端中实现SSE主要是声明一个EventSource 然后调用它的addEventListener,实现对消息的接收,连接的建立与连接的断开进行监听。从而实现相应的功能。
1. source.addEventListener(‘open’,(e)=>{ }) :连接建立
2. source.addEventListener(‘message’,(e)=>{ }) :监听消息
3. source.addEventListener(‘error’,(e)=>{ }) :连接出错

二、后端实现

1.控制层 SseController

SseController主要有4个方法,分别是
1.建立连接的createConnect(),
2.向所有的客户端进行广播的sendMessageToAllClient()方法,
3.根据客户端id向某一客户端单独发送消息的sendMessageToOneClient()方法接受的参数为MessageVo,该类为自定义类。
4.请求关闭连接的方法closeConnect()

package com.ypf.controller;

import com.ypf.domain.MessageVo;
import com.ypf.service.Impl.SseServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.websocket.server.PathParam;

@RestController
@RequestMapping("/sse")
public class SseController {
    @Autowired
    private SseServiceImpl sseService;

    @CrossOrigin
    @GetMapping("/createConnect")
    public SseEmitter createConnect(String clientId){
       return sseService.createConnect(clientId);
    }

    @CrossOrigin
    @PostMapping("/broadcast")
    public void sendMessageToAllClient(@RequestBody(required = false) String msg){
        sseService.sendMessageToAllClient(msg);
    }

    @CrossOrigin
    @PostMapping("/sendMessage")
    public void sendMessageToOneClient(@RequestBody(required = false) MessageVo messageVo){
        if (messageVo.getClientId().isEmpty()){
            return;
        }
        sseService.sendMessageToOneClient(messageVo.getClientId(),messageVo.getMsg());
    }

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

}

2.SseServiceImpl层

整个后端中实现类中都是围绕着SseEmitter这个类的四个核心方法来写,具体代码如下:

package com.ypf.service.Impl;

import com.ypf.service.SseService;
import com.ypf.util.ResponseResult;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;

@Service
public class SseServiceImpl implements SseService {

    // 创建一个容器来存储所有的 SseEmitter 使用ConcurrentHashMap 是因为它是线程安全的。
    private static Map<String, SseEmitter> sseCache = new ConcurrentHashMap<>();

    private static Integer num=0;

    /**
     * 模拟数据
     */
    private static List<String> msgList = new ArrayList<String>(){{
        add("早上好呀!");
        add("新的一天要加油呀!");
        add("要天天开心呀!");
        add("你可以的呀!");
    }};

    @Override
    public SseEmitter createConnect(String clientId) {
        // 设置过期时间 0 表示 不过期 默认值位30秒
        SseEmitter sseEmitter = new SseEmitter(0L);
        // 如果clientId 为空 后端自动创建一个clientId 并返回给前端
        if (ObjectUtils.isEmpty(clientId)){
            clientId = UUID.randomUUID().toString().replaceAll("-","");
        }
        // 注册回调
        sseEmitter.onCompletion(completionCallBack(clientId));     // 长链接完成后回调接口(即关闭连接时调用)
        sseEmitter.onTimeout(timeoutCallBack(clientId));        // 连接超时回调
        sseEmitter.onError(errorCallBack(clientId));          // 推送消息异常时,回调方法

        // 存入容器中
        sseCache.put(clientId,sseEmitter);
        System.out.println("创建新的sse连接,当前用户:"+clientId+"   累计用户数:"+sseCache.size());
        try {
            List<ResponseResult> list = new ArrayList<>();
            list.add(new ResponseResult(0,clientId));
            sseEmitter.send(SseEmitter.event().data(list,MediaType.APPLICATION_JSON));
        }catch (Exception e){
            System.out.println("创建ss连接异常,客户端id:"+clientId);
            e.printStackTrace();
        }
        return sseEmitter;
    }

    /**
     * 发送消息给所有客户端
     * @param msg
     */
    @Override
    public void sendMessageToAllClient(String msg) {
        if (ObjectUtils.isEmpty(sseCache)){
            return;
        }

        List<ResponseResult> list = new ArrayList<>();
        // 判断发送的消息是否为空
        if (!ObjectUtils.isEmpty(msg)){
            ResponseResult responseResult = new ResponseResult(200,msg);
            list.add(responseResult);
        }else {
             ResponseResult responseResult = new ResponseResult(200,getMessage());
             list.add(responseResult);
        }

        for (Map.Entry<String, SseEmitter> entry : sseCache.entrySet()) {
            sendMsgToClientByClientId(entry.getKey(),list,entry.getValue());
        }
    }

    /**
     * 根据clientId发送消息给某一客户端
     * @param clientId
     * @param msg
     */
    @Override
    public void sendMessageToOneClient(String clientId, String msg) {
        List<ResponseResult> list = new ArrayList<>();
        // 判断发送的消息是否为空
        if (!ObjectUtils.isEmpty(msg)){
            ResponseResult responseResult = new ResponseResult(200,msg);
            list.add(responseResult);
        }else {
            ResponseResult responseResult = new ResponseResult(200,getMessage());
            list.add(responseResult);
        }
        sendMsgToClientByClientId(clientId,list,sseCache.get(clientId));
    }

    /**
     * 关闭连接
     * @param clientId
     */
    @Override
    public void closeConnect(String clientId) {
        // 获取对应的sseEmitter
        SseEmitter sseEmitter = sseCache.get(clientId);

        if (sseEmitter!=null){
            sseEmitter.complete();
            removeUser(clientId);
        }
    }


    /**
     * 获取写死的消息
     * @return
     */
    private String getMessage(){
        String result = msgList.get(num);
        num = num+1;
        num = num%4;
       return result;
    }


    /**
     * 长链接完成后回调接口(即关闭连接时调用)
     * @param clientId
     * @return
     */
    private Runnable completionCallBack(String clientId) {
        return () -> {
            System.out.println("结束连接:"+clientId);
            removeUser(clientId);
        };
    }

    /**
     * 连接超时回调
     * @param clientId
     * @return
     */
    private Runnable timeoutCallBack(String clientId){
        return ()->{
            System.out.println("连接超时:"+clientId);
            removeUser(clientId);
        };
    }

    /**
     * 根据客户端id 发送给某一客户端
     * @param clientId
     * @param ResponseResultList
     * @param sseEmitter
     */
    private void sendMsgToClientByClientId(String clientId, List<ResponseResult> ResponseResultList, SseEmitter sseEmitter){
        if (sseEmitter == null){
            System.out.println("推送消息失败:客户端:"+clientId+" 未创建长连接,失败消息:"+ResponseResultList.toString());
            return;
        }
        SseEmitter.SseEventBuilder sendData = SseEmitter.event().id("201").data(ResponseResultList, MediaType.APPLICATION_JSON);
        try {
            sseEmitter.send(sendData);
        } catch (IOException e) {
            // 推送消息失败,记录错误日志,进行重推
            System.out.println(" 推送消息失败:"+ResponseResultList.toString());
            boolean isSuccess = true;
            for (int i = 0;i<5;i++){
                try {
                    Thread.sleep(1000);
                    sseEmitter = sseCache.get(clientId);
                    if(sseEmitter == null){
                        System.out.println(ResponseResultList.toString()+"消息的"+"第"+i+1+"次"+"重推失败,未创建长链接");
                        continue;
                    }
                    sseEmitter.send(sendData);
                }catch (Exception ex){
                    System.out.println(ResponseResultList.toString()+"消息的"+"第"+i+1+"次"+"重推失败");
                    ex.printStackTrace();
                    continue;
                }
                System.out.println(ResponseResultList.toString()+"消息的"+"第"+i+1+"次"+"重推成功");
                return;
            }
        }
    }

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

            // 推送消息失败后 每隔1s 推送一次 推送5次
            for (int i = 0;i<5;i++){
                try {
                    Thread.sleep(1000);
                    SseEmitter sseEmitter = sseCache.get(clientId);
                    if (sseEmitter == null){
                        System.out.println("第"+i+"次消息重推失败,未获取到"+clientId+"对应的长链接");
                        continue;
                    }
                    sseEmitter.send("失败后重新推送");
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        };
    }


    /**
     * 删除用户
     * @param clientId
     */
    private void  removeUser(String clientId){
        sseCache.remove(clientId);
        System.out.println("移除用户:"+clientId);
    }
}

3前端实现

前端主要采用的是react框架进行搭建,依据EventSource中的三个核心方法,监听到相应的变化后做出一定的状态改变,具体就不一一赘述了,有疑问可在评论区留言或者私信我。

import React, { Component } from 'react'
import { Button , Comment, Tooltip, Avatar,List,Steps, notification, Divider, Space} from 'antd'
import moment from 'moment';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import axios from 'axios';

import "./App.css"


const openNotification = (msg) => {
    notification.open({
      message: '系统消息',
      description:
        msg,
      icon:<ExclamationCircleOutlined style={{ color: '#108ee9' }} />,
      duration:5
    });
  };

export default class App extends Component {
    state={
        current:0,
        status:"wait",
        systemMessages:[{msg:'【hello ypf】 请创建连接以获取后台消息!'}],
    }

    

    // 新建Sse连接
    createSseConnect=()=>{
        if(window.EventSource){
           
            var source = new EventSource('http://localhost:3000/api1/sse/createConnect?clientId=001')

            // 监听打开事件
            source.addEventListener('open',(e)=>{ 
               console.log("打开连接 onopen==>",e)
               this.setState({current:1,status:'process'})
               openNotification('建立连接成功')
            })

            // 监听消息事件
            source.addEventListener("message",(e)=>{
                let systemMessages = this.state.systemMessages;
                const data =JSON.parse(e.data) 
                const code = data[0].code
                const msg = data[0].msg  
                
                if(code===200){
                   openNotification(msg);
                   systemMessages.push({"msg":msg})   
                   this.setState({systemMessages:systemMessages})
                }else if(code === 0){
                    // 然后状态码为000 把客户端id储存在本地
                  localStorage.setItem("clientId",msg)  
                }
                console.log(systemMessages);
               
            })

            // 监听错误事件
            source.addEventListener("error",(e)=>{
               let systemMessages = this.state.systemMessages;
              
                openNotification('已断开与后端连接')
                systemMessages.push({"msg":"已断开与后端连接"})
                
                this.setState({current:0,status:'error',systemMessages:systemMessages}) 
        
            })
    
            // 关闭连接
            source.close = function(e){
                console.log("断开 οnerrοr==>",e)
           }           

        }else {
            alert("该浏览器不支持sse")
        }
    }

    // 获取系统消息
    getSystemMessage=()=>{
        // 发送网络请求
        axios.post(`http://localhost:3000/api1/sse/broadcast`).then(
        response=>{
           
        },
        error=>{
        
        }
      )
    }

    // 断开连接
    closeSseConnect=()=>{

        // 先获取到本地存储的clientId 再
        const clientId = localStorage.getItem("clientId")
        if(clientId===null){
           return 
        }

        // 发送网络请求
        axios.get(`http://localhost:3000/api1/sse/closeConnect?clientId=${clientId}`).then(
            response=>{
            
            },
            error=>{
            
            }
        )

    }

  render() {
    const { Step } = Steps;
    const {current,status,systemMessages} = this.state;

    return (
      <div className='center'>
        <Button type='primary' style={{ marginRight:20}} onClick={()=>{this.createSseConnect()}}>创建连接</Button>
        <Button type='primary' style={{ marginRight:20}} onClick={()=>{this.getSystemMessage()}}>获取消息</Button> 
        <Button type='primary' danger onClick={()=>{this.closeSseConnect()}}>断开连接</Button>

        <Steps direction="vertical" current={current} status={status}>
            <Step title="成功建立SSE连接" description="successful connected" />
            <Step title="接收后端通知中" description="waiting for message" />
        </Steps> 

        {
           systemMessages.map((systemMessage)=>{
               return(
                    <Comment 
                        key={systemMessage.msg}
                        author={<a>系统消息</a>}
                        avatar={<Avatar src="https://joeschmoe.io/api/v1/random" alt="Han Solo" />}
                        content={
                            <p>
                            {systemMessage.msg}
                            </p>
                        }
                        datetime={
                            <Tooltip title={moment().format('YYYY-MM-DD HH:mm:ss')}>
                            <span>{moment().fromNow()}</span>
                            </Tooltip>
                        }

                        className='comment'      
                    /> 
               )
              
           })
        }
        
    
      </div>
      
    )
  }
}


4 整体演示

使用步骤:首先启动后端服务器,然后再启动前端。打开前端后点击创建连接,连接成功后,可以点击获取消息,或者使用postman访问http://localhost:8080/sse/sendMessage 然后输入发送消息内容。具体如图所示:
postman参数配置
然后点击发送即可在前端收到后端推送的消息
结果演示
用于SSE自动重连机制,即使手动断开连接后,前端在几秒后也会自动重连。
手动断开连接
自动重连结果

5 项目地址

1 前端地址 点我跳转
2 后端地址 点我跳转


总结

这是我第三个项目的其中一个小技术点,觉得相对于传统的CRUD,这个小技术点还是很有意思的,所以特此写了一个独立的小demo来记录一下本次开发中遇到的后端向前端推送消息的问题解决办法。最后加油吧,少年!

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值