SSE实现进度条功能
一 业务场景描述
用户在对条款提起批量审批的时候,当大量的条款审批通过或拒绝的时候,由于使用的是mq队列,队列回调后有业务处理需要时间,需要一个进度条来实现当前用户提交的审批进度。
二 技术分析
提交后以用户ID及条款ID为redisKey,批量存储至redis,然后业务回调后再清除掉key值。以此来监控此批条款的审批完成进度。
这样的话我们就知道此次提交的总条数及待处理的条数,从而得知审批进度。
计划方案
- WebSocket:体量比较大,https需要配置相应证书文件,前端对接也比较麻烦,也无法很好的处理token的传值,所以我不考虑
- SSE:全称Server-sent events , 是一种基于 HTTP 协议的通信技术,允许服务器主动向客户端(通常是Web浏览器)发送更新。体量小,而且和普通http用起来无感,体量也比较小,比较适合做这种简单的进度条实现
三 代码示例
后端示例
@Operation(summary = "SSE 审批进度条")
//实现post请求,请求使用"application/json"类型,响应使用"text/event-stream"类型
@PostMapping(value = "/approvalProgress", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> approvalProgress(@RequestBody ApprovalProgressDTO param) {
return sseApprovalProgressHandler.approvalProgress(param);
}
private static final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();
private static final ScheduledExecutorService schedulerExecutor = new ScheduledThreadPoolExecutor(1);
public ResponseEntity<SseEmitter> approvalProgress(ApprovalProgressDTO param){
//生成一个唯一的请求ID
String requestId = "approval_" + IdWorker.getIdStr();
log.info("requestId: {}",requestId);
//过期时间30分钟
SseEmitter emitter = new SseEmitter(30 * 60 * 3600L);
// 添加禁止缓存的头部(这块配置好像没有生效,但是建议配置下)
HttpHeaders headers = new HttpHeaders();
headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
headers.set("Pragma", "no-cache");
headers.set("Expires", "0");
// 禁用分块传输编码
headers.set("Transfer-Encoding", "identity");
// 处理关闭连接的情况
emitter.onCompletion(() -> emitterMap.remove(requestId));
emitter.onTimeout(() -> emitterMap.remove(requestId));
// 添加到映射表中
emitterMap.put(requestId, emitter);
//测试
testSendingEvents(emitter, requestId, param);
return ResponseEntity.ok()
.headers(headers)
.body(emitter);
}
// 模拟一个进度条业务实现
private void testSendingEvents(final SseEmitter emitter,
final String requestId,
ApprovalProgressDTO param) {
log.info("开始发送数据---->");
AtomicInteger finishAtomic = new AtomicInteger();
//申明一个等待的变量
AtomicReference<Integer> atomicWaitCount = new AtomicReference<>(0);
schedulerExecutor.scheduleAtFixedRate(() -> {
if (!emitterMap.containsKey(requestId)) {
return; // 如果已经被移除,不再发送数据
}
int finishCount = finishAtomic.incrementAndGet();
log.info("发送数据---->");
log.info("finishCount -> {}", finishCount);
try {
//模拟数据
ApprovalProgressVO data = new ApprovalProgressVO();
data.setTotalCount(15);
data.setFinishCount(finishCount);
Integer waitCount = data.getTotalCount() - finishCount;
data.setWaitCount(waitCount);
data.setStatus("running");
data.setProgress(BigDecimal.valueOf(finishCount).divide(BigDecimal.valueOf(data.getTotalCount()), 2, RoundingMode.HALF_UP));
if (finishCount >= data.getTotalCount()) {
data.setProgress(BigDecimal.ONE);
data.setStatus("success");
}
emitter.send(SseEmitter.event()
.name("message")
.data(data));
if ("success".equals(data.getStatus())) {
emitterMap.remove(requestId);
//业务中如果后端能检测到此次任务推送完毕,可以直接关闭此次连接
emitter.complete();
log.info("结束----->");
}
} catch (IOException e) {
// 如果发生错误,移除该emitter
emitterMap.remove(requestId);
emitter.complete();
}
}, 2, 2, TimeUnit.SECONDS);
}
public class ApprovalProgressVO {
private Integer totalCount;
private Integer waitCount;
private Integer finishCount;
private BigDecimal progress;
private String remark;
private String status;
}
前端示例
// sse
import { fetchEventSource } from '@microsoft/fetch-event-source';
import dayjs from 'dayjs';
// 参数处理
const factory = (options) => (
(options.method = options.method || 'POST'),
(options.headers = {
...options.headers,
}),
(options.body = options.body || {}),
options
);
// 参数处理
export default function () {
return {
// 开始sse
start: async (url, options, callback) => {
try {
if (!url) {
console.log('请输入有效url');
return;
}
await fetchEventSource(url, {
...factory(options),
openWhenHidden: true,
onmessage(event) {
if (event.data === '[DONE]') return;
if (event.data.length !== 0) {
callback({ status: null, message: JSON.parse(event.data) });
}
console.log('======================');
console.log(dayjs(new Date().getTime()).format('YYYY-MM-DD HH:mm:ss'));
console.log(JSON.parse(event.data));
},
onerror(error) {
callback({
status: 'finish',
message: '服务器出错了, 请稍后重试.....',
error: true,
});
throw error; // 必须抛出错误,否则停止不了
},
onclose() {
callback({ status: 'success', message: '审批完成' });
},
});
} catch (error) {
console.log(error);
}
},
// 停止sse
stop: () => {},
};
}
四 注意事项
如果连接之后,后端一直再发送,但是调用一直无响应,可能不是没调通的问题,在合适的地方加上emitter.complete();结束连接,当连接结束时,会全部一下子响应回来。
postman本地调试时可能不会实时的返回流数据,可以换一种测试工具我换成了apifox。
如果后端工具测试时是逐条响应, 但是前端本地连接服务器不是逐条,可能是前端的网页和电脑问题,让前端也发布到环境上再测试
如果还不行,由于流数据缓存的原因, 前端在请求头中加 Cache-Control: no-cache,服务器Nginx中配置
location ^~ /gateway/ { proxy_redirect / /gateway/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://xxxxxx:8014/; # 禁止缓存 proxy_cache off; proxy_buffering off; add_header 'Cache-Control' 'no-cache'; # SSE 连接时的超时时间 proxy_read_timeout 86400s; # 禁用分块传输编码 chunked_transfer_encoding off; }发版至服务器之后,需检查一下这个SSE接口的token传值是否正确, 我们在调试中发现首次调用这个接口时token传值不对,后面修复了
1 前端直接取得ts中的token导致刷新不及时导致的,后续改到直接调用获取token的方式。 2 使用 Vuex 存储 Token 使用 Vuex 状态管理库来存储用户的认证 token,并在页面加载时自动设置到请求头中。 当用户登录成功后,将 token 存储到 Vuex store 和本地存储(如 localStorage)中。