SSE实现java进度条

742 阅读4分钟

SSE实现进度条功能

一 业务场景描述

用户在对条款提起批量审批的时候,当大量的条款审批通过或拒绝的时候,由于使用的是mq队列,队列回调后有业务处理需要时间,需要一个进度条来实现当前用户提交的审批进度。

二 技术分析

提交后以用户ID及条款ID为redisKey,批量存储至redis,然后业务回调后再清除掉key值。以此来监控此批条款的审批完成进度。

这样的话我们就知道此次提交的总条数及待处理的条数,从而得知审批进度。

计划方案
  1. WebSocket:体量比较大,https需要配置相应证书文件,前端对接也比较麻烦,也无法很好的处理token的传值,所以我不考虑
  2. 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: () => {},
  };
}


四 注意事项

  1. 如果连接之后,后端一直再发送,但是调用一直无响应,可能不是没调通的问题,在合适的地方加上emitter.complete();结束连接,当连接结束时,会全部一下子响应回来。

  2. postman本地调试时可能不会实时的返回流数据,可以换一种测试工具我换成了apifox。

  3. 如果后端工具测试时是逐条响应, 但是前端本地连接服务器不是逐条,可能是前端的网页和电脑问题,让前端也发布到环境上再测试

  4. 如果还不行,由于流数据缓存的原因, 前端在请求头中加 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;
        }
    
  5. 发版至服务器之后,需检查一下这个SSE接口的token传值是否正确, 我们在调试中发现首次调用这个接口时token传值不对,后面修复了

1 前端直接取得ts中的token导致刷新不及时导致的,后续改到直接调用获取token的方式。
2 使用 Vuex 存储 Token
    使用 Vuex 状态管理库来存储用户的认证 token,并在页面加载时自动设置到请求头中。
    当用户登录成功后,将 token 存储到 Vuex store 和本地存储(如 localStorage)中。

image.png

image.png