【ServerSentEvents】服务端单向消息推送

 

依赖库:

Springboot 不需要而外支持,WebMVC组件自带的类库

浏览器要检查内核是否支持EventSource库

 

Springboot搭建SSE服务:

这里封装一个服务Bean, 用于管理SSE的会话,推送消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
package cn.hyite.amerp.common.sse;
 
import cn.hyite.amerp.common.log.HyiteLogger;
import cn.hyite.amerp.common.log.LogFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
 
/**
 * 服务单向消息传递,SeverSentEvents web端自动重连
 *
 * @author Cloud9
 * @version 1.0
 * @project amerp-server
 * @date 2022年11月10日 10:04
 */
@Service
public class SeverSentEventService {
 
    private static final HyiteLogger logger = LogFactory.getLogger(SeverSentEventService.class);
 
    /**
     * SSE客户端管理容器
     */
    private static final Map<String, SseEmitter> SESSION_MAP = new ConcurrentHashMap<>();
 
    /**
     * 连接超时时限1小时 (客户端不活跃的时长上限?)
     */
    private static final Long TIME_OUT = 1000L * 60L * 60L;
 
    /**
     * 根据客户端标识创建SSE连接
     *
     * @param clientId
     * @return SseEmitter
     * @author Cloud9
     * @date 2022/11/10 10:17
     */
    public SseEmitter createConnection(String clientId) {
        SseEmitter emitter = new SseEmitter(TIME_OUT);
        emitter.onTimeout(() -> {
            logger.info(" - - - - SSE连接超时 " + clientId + " - - - - ");
            SESSION_MAP.remove(clientId);
        });
        emitter.onCompletion(() -> {
            logger.info(" - - - - - SSE会话结束 " + clientId + " - - - - ");
            SESSION_MAP.remove(clientId);
        });
        emitter.onError(exception -> {
            logger.error("- - - - - SSE连接异常 " + clientId + " - - - - -", exception);
            SESSION_MAP.remove(clientId);
        });
 
        SESSION_MAP.put(clientId, emitter);
        return emitter;
    }
 
    /**
     * 根据客户端令牌标识,向目标客户端发送消息
     *
     * @param clientId
     * @param message
     * @author Cloud9
     * @date 2022/11/10 10:17
     */
    public void sendMessageToClient(String clientId, String message) {
        SseEmitter sseEmitter = SESSION_MAP.get(clientId);
         
        /* 如果客户端不存在,不执行发送 */
        if (Objects.isNull(sseEmitter)) return;
        try {
            SseEmitter.SseEventBuilder builder = SseEmitter
                    .event()
                    .data(message);
            sseEmitter.send(builder);
        } catch (Exception e) {
            logger.error("- - - - Web连接会话中断, ClientId:" + clientId + "已经退出 - - - - ", e);
            /* 结束服务端这里的会话, 释放会话资源 */
            sseEmitter.complete();
            SESSION_MAP.remove(clientId);
        }
    }
 
    /**
     * 给所有客户端发送消息
     *
     * @param message
     * @author Cloud9
     * @date 2022/11/10 10:18
     */
    public void sendMessageToClient(String message) {
        SseEmitter.SseEventBuilder builder = SseEmitter
                .event()
                .data(message);
 
        for (String clientId : SESSION_MAP.keySet()) {
            SseEmitter emitter = SESSION_MAP.get(clientId);
            if (Objects.isNull(emitter)) continue;
            try {
                emitter.send(builder);
            } catch (Exception e) {
                logger.error("- - - - Web连接会话中断, ClientId:" + emitter + "已经退出 - - - - ", e);
                emitter.complete();
                SESSION_MAP.remove(clientId);
            }
        }
    }
 
}

  

接口和写普通接口有小小区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package cn.hyite.amerp.approve.controller;
 
 
import cn.hyite.amerp.approve.dto.ApproveDTO;
import cn.hyite.amerp.common.Constant;
import cn.hyite.amerp.common.activiti.ActivitiUtils;
import cn.hyite.amerp.common.activiti.ProcessInstanceQueryDTO;
import cn.hyite.amerp.common.activiti.TaskQueryDTO;
import cn.hyite.amerp.common.annotation.IgnoreLoginCheck;
import cn.hyite.amerp.common.config.mybatis.page.PageResult;
import cn.hyite.amerp.common.dingtalk.DingDingUtils;
import cn.hyite.amerp.common.security.LoginUserContext;
import cn.hyite.amerp.common.security.UserContext;
import cn.hyite.amerp.common.sse.SeverSentEventService;
import cn.hyite.amerp.common.util.PageUtil;
import cn.hyite.amerp.system.dingtalk.todotask.dto.SysDiTodotaskDTO;
import cn.hyite.amerp.system.dingtalk.todotask.service.ISysDiTodotaskService;
import org.activiti.engine.runtime.ProcessInstance;
import org.activiti.engine.task.Task;
import org.activiti.engine.task.TaskQuery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
 
 
/**
 *
 * @author cloud9
 * @version 1.0
 * @project
 * @date 2022-09-27
 */
@RestController
@RequestMapping("${api.path}/approve")
public class ApproveController {
 
    @Resource
    private SeverSentEventService severSentEventService;
 
    /**
     * web连接的SSE目标地址, 保管凭证并返回这个会话资源给web,注意produces属性必须是文本事件流形式
     * 凭证可以使用@PathVariable或者Url参数获取
     * /approve/sse/message
     * @param token
     * @return SseEmitter
     * @author cloud9
     * @date 2022/11/10 09:57
     *
     */
    @IgnoreLoginCheck
    @GetMapping(value = "/sse/message", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter message(@RequestParam("clientToken") String token) {
        return severSentEventService.createConnection(token);
    }
 
    /**
     * 提供给POSTMAN测试调用,方便给web发送消息调试
     * /approve/sse/message/send
     * @param map
     * @author cloud9
     * @date 2022/11/10 10:25
     *
     */
    @IgnoreLoginCheck
    @PostMapping("/sse/message/send")
    public void send(@RequestBody Map<String, String> map) {
        String client = map.get("client");
        String txt = map.get("txt");
        severSentEventService.sendMessageToClient(client, txt);
    }
 
}

  

检测SSE服务是否正常,可以直接浏览器访问目标接口:

发送消息,查看是否能够接收:

 

编写SSE客户端EventSource

使用JS原生EventSource编写:

三个钩子函数:open, error, message

message 数据在event对象的data属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
initialSseConnection() {
  const isSupportSse = 'EventSource' in window
  if (!isSupportSse) {
    console.log('不支持SSE')
    return
  }
  const clientId = 1001 // 假设这是客户端标识令牌,根据实际情况动态设置
  const url = `${window._config.default_service}approve/sse/message/?clientToken=${clientId}`
  const eventSource = new EventSource(url, { withCredentials: true })
  console.log(`url ${eventSource.url}`)
  this.sseConnection = eventSource
  eventSource.onopen = function(event) {
    console.log(`SSE连接建立 ${eventSource.readyState}`)
  }
 
  const that = this
  eventSource.onmessage = function(event) {
    console.log('id: ' + event.lastEventId + ', data: ' + event.data)
    ++that.unreadDeliverTotal
  }
  eventSource.onerror = function(error) {
    console.log('connection state: ' + eventSource.readyState + ', error: ' + error)
  }
},

  

在创建实例周期函数时调用:

1
2
3
created() {
  this.initialSseConnection()
},

在实例销毁时,释放连接资源:

1
2
3
beforeDestroy() {
    if (this.sseConnection) this.sseConnection.close()
}

 

 

NPM仓库提供了一个封装库:

https://www.npmjs.com/package/vue-sse

默认npm我这下不动,改用cnpm就可以了,按文档CV就可以直接用

这里不赘述了

 

实践效果:

初始化时,open方法触发:

 

HTTP接口状态可以查看服务发送的信息:

 

PostMan调用开放的接口:

1
2
3
4
5
6
7
8
Post请求
http://localhost:8091/amerp-server/api/approve/sse/message/send
 
JsonBody:
{
    "client": "1001",
    "txt": "{ 'id': 1001, 'msg': 'success qsadasdasd' }"
}

 

注意客户端不要混用令牌标识,这将导致前一个客户端的连接资源被新的客户端覆盖

 

SseEmitterBuilder发射器对象API

1
2
3
4
5
6
7
8
9
10
11
12
SseEmitter.SseEventBuilder builder = SseEmitter.event();
/* 目前不知道干嘛用的 */
builder.comment("注释数据...");
/* 设置一个消息ID,要有唯一性意义,客户端可以通过event.lastEventId 获取这个ID */
builder.id(UUID.randomUUID(true).toString());
/* 设置这个发射器的名称,也就是事件的名称,如果设置了,客户端则调用这个名字的处理方法,不会执行onmessage */
builder.name("自定义处理器名");
/* 设置消息数据 */
builder.data(message);
 
/* 发射器可以不设置上述内容发送消息 */
emitter.send(builder);

  

参考来源:

1
https://oomake.com/question/9520150

  

自定义事件处理案例:

这里我设置的是customHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void sendMessageToClient(String message) {
    for (String clientId : SESSION_MAP.keySet()) {
        final SseEmitter emitter = SESSION_MAP.get(clientId);
        if (Objects.isNull(emitter)) continue;
 
        SseEmitter.SseEventBuilder builder = SseEmitter.event();
        builder.comment("注释数据...");
        builder.name("customHandler");
        builder.id(UUID.randomUUID(true).toString());
        builder.data(message);
        try {
            emitter.send(builder);
        } catch (Exception e) {
            log.error("- - - - - 发送给客户端:{} 消息失败,异常原因:{}", clientId, e.getMessage());
            log.error("- - - - - 客户端已经下线,开始删除会话连接 - - - - - ");
            emitter.completeWithError(e);
            SESSION_MAP.remove(clientId);
        }
    }
 }

  

客户端的编写是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SSE客户端连接测试</title>
</head>
<body>
 
<script>
const clientId = 'admin-' + new Date().getTime()
const url = `http://127.0.0.1:8080/test/sse/release?clientId=${clientId}`
const connection = new EventSource(url, { withCredentials: true })
 
connection.onopen = () => {
  console.log(`连接已建立,状态: ${connection.readyState}`)
  console.log(`连接地址: ${connection.url}`)
   
}
 
connection.onerror = event => {
  console.log(JSON.stringify(event))
}
 
connection.onmessage = event => {
  console.log(`- - - - 收到服务器消息 - - - - - -`)
  console.log(`event.lastEventId -> ${event.lastEventId}`)
  console.log(`event.data -> ${event.data}`)
}
 
customHandler = event => {
  debugger
  console.log(`- - - - 收到服务器消息(自定义处理器) - - - - - -`)
  console.log(`event.lastEventId -> ${event.lastEventId}`)
  console.log(`event.data -> ${JSON.stringify(event)}`)
}
 
connection.addEventListener('customHandler', customHandler, true);
 
</script>
 
</body>
</html>

  

 

 
可以检查event对象,类型变为我们对发射器对象设置的name值了

 

 

思路场景:

可以通过url参数传入后台设置动态的接收器对象?

一般来说好像用不上

 

最后测试一个JSON解析:

1
2
3
4
5
6
7
8
9
10
connection.onmessage = event => {
  console.log(`- - - - 收到服务器消息 - - - - - -`)
  console.log(`event.lastEventId -> ${event.lastEventId}`)
  console.log(`event.data -> ${event.data}`)
 
  // "{ \"a\": 100, \"b\": \"javascript\", \"c\": true }"
  const obj = JSON.parse( JSON.parse(event.data))
  console.log(typeof obj)
  console.log(obj.a)
}

  

 

posted @   emdzz  阅读(1438)  评论(0编辑  收藏  举报
(评论功能已被禁用)
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示