背景
原本系统中有一个功能,输入服务代码的git地址以及分支,可以扫描代码中使用的所有组件。使用的是process类,执行maven命令。
但是可能出现错误:1. 没有远端仓库权限,2. 或者项目本身不是maven项目,3. 或者命令执行错误等一系列问题。
由于扫描任务为异步进行,扫描过程中无法判断任务进行的状况。所以想要在扫描过程中展示后台日志,查看扫描状态。
工具调查
- websocket 具体使用查看上一篇文章:https://blog.csdn.net/wanghl_/article/details/135696429
- Ssemitter
遇到问题
websocket已经实现基本功能,但为什么要换Ssemitter呢?
从资源消耗的角度来看,WebSocket和SSE在某些方面存在一些差异。
-
建立连接开销:WebSocket在建立连接时需要进行握手,这涉及到发送额外的HTTP请求和响应,这可能会在一开始时产生一些额外的开销。而对于SSE,只需要进行一次普通的HTTP请求,因此在建立连接时开销相对较小。
-
保持连接开销:WebSocket是一种持久连接,可以长时间保持连接状态。这意味着WebSocket服务器需要维护与每个客户端的连接,这可能会占用服务器的内存和其他资源。而SSE是基于单向的HTTP请求响应模型,每次响应完成后就会关闭连接,这样服务器上不需要维持与客户端的持久连接,因此相对来说资源消耗较低。
-
数据传输开销:WebSocket和SSE都可以实现实时双向通信,但WebSocket倾向于在双向传输方面更高效。WebSocket使用了自定义的二进制数据帧协议,可以实现低延迟和高效率的双向数据传输。相比之下,SSE使用了纯文本的传输格式,不支持双向传输,且数据只能从服务器传输到客户端。
最重要的原因是:根据查阅资料,WebSocket无法携带自定义请求头。我的接口有经过拦截,其中必须携带一些除token以外的自定义请求头。这使我不得不更换为Ssemitter。
Ssemitter详细学习,可参看https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html,作者写的很清晰
实现代码
Controller
@RestController
@RequestMapping("/sse")
public class LogSseController {
@Resource
private SseService sseService;
@ApiOperation(value = " 建立连接")
@GetMapping(value = "/sseConnect/{clientId}", produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitterUTF8 sseSonnect(@PathVariable("clientId") @ApiParam("客户端 id") String clientId) {
SseEmitterUTF8 sseEmitter = sseService.getConn(clientId);
CompletableFuture.runAsync(() -> {
try {
sseService.send(clientId);
} catch (Exception e) {
throw new BusinessException("推送数据异常");
}
});
return sseEmitter;
}
// @ApiOperation("发送消息")
// @GetMapping("/pushLog/{clientId}")
// public Result<String> pushLog(@PathVariable("clientId") @ApiParam("客户端 id") String clientId) {
// sseService.send(clientId);
// return Result.success("推送数据成功");
// }
@GetMapping("closeConn/{clientId}")
@ApiOperation(value = " 关闭连接")
public Result<String> closeConn(@PathVariable("clientId") @ApiParam("客户端 id") String clientId) {
sseService.closeConn(clientId);
return Result.success("连接已关闭");
}
}
sevice
@Slf4j
@Service
public class SseService {
private static final Map<String, SseEmitterUTF8> SSE_CACHE = new ConcurrentHashMap<>();
public SseEmitterUTF8 getConn(@NotBlank String clientId) {
SseEmitterUTF8 sseEmitter = SSE_CACHE.get(clientId);
if (sseEmitter != null) {
return sseEmitter;
} else {
// 设置连接超时时间,需要配合配置项 spring.mvc.async.request-timeout: 600000 一起使用
SseEmitterUTF8 emitter = new SseEmitterUTF8(600_000L);
// 注册超时回调,超时后触发
emitter.onTimeout(() -> {
log.info("连接已超时,正准备关闭,clientId = {}", clientId);
SSE_CACHE.remove(clientId);
});
// 注册完成回调,调用 emitter.complete() 触发
emitter.onCompletion(() -> {
log.info("连接已关闭,正准备释放,clientId = {}", clientId);
SSE_CACHE.remove(clientId);
log.info("连接已释放,clientId = {}", clientId);
});
// 注册异常回调,调用 emitter.completeWithError() 触发
emitter.onError(throwable -> {
log.error("连接已异常,正准备关闭,clientId = {}", clientId, throwable);
SSE_CACHE.remove(clientId);
});
SSE_CACHE.put(clientId, emitter);
return emitter;
}
}
/**
* 模拟类似于 chatGPT 的流式推送回答
*
* @param clientId 客户端 id
* @throws IOException 异常
*/
public void send(@NotBlank String clientId){
//日志文件路径,获取最新的
String filePath = "/home/logs/" + new SimpleDateFormat("yyyy-MM-dd").format(new Date()) + ".log";
SseEmitterUTF8 emitter = SSE_CACHE.get(clientId);
try {
//指定文件指针到文件末尾(每次都从当前打开位置开始读取log)
RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "r");
randomAccessFile.seek(randomAccessFile.length()-1);
boolean keepWatching = true;
String line;
while(keepWatching){
line = randomAccessFile.readLine();
if(null != line){
//解决中文乱码问题
line = new String(line.getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);
emitter.send(line + "<br/>", org.springframework.http.MediaType.TEXT_EVENT_STREAM);//APPLICATION_JSON
Thread.sleep(1000);
}
}
}catch (Exception e) {
//捕获但不处理
e.printStackTrace();
}
// 结束推流
emitter.complete();
SSE_CACHE.remove(clientId);
}
//关闭连接
public void closeConn(@NotBlank String clientId) {
SseEmitterUTF8 sseEmitter = SSE_CACHE.get(clientId);
if (sseEmitter != null) {
sseEmitter.complete();
SSE_CACHE.remove(clientId);
}
}
}
自定义返回类,重写SseEmitter
public class SseEmitterUTF8 extends SseEmitter {
public SseEmitterUTF8(Long timeout) {
super(timeout);
}
@Override
protected void extendResponse(ServerHttpResponse outputMessage) {
super.extendResponse(outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
headers.setContentType( new MediaType("text", "event-stream", Charset.forName("UTF-8")));
}
}
自定义html测试
<html>
<script src="https://code.jquery.com/jquery-3.0.0.min.js"></script>
<script th:inline="javascript">
const eventSource = new EventSource('http://localhost:1919/sse/sseConnect/122');
eventSource.onmessage = function(event) {
if (event.data) {
//日志内容
let $loggingText = $("#loggingText");
$loggingText.append(event.data);
//是否开启自动底部
if (window.loggingAutoBottom) {
//滚动条自动到最底部
$loggingText.scrollTop($loggingText[0].scrollHeight);
}
}
// 处理接收到的消息
console.log(event.data);
};
</script>
<body>
<div id = "loggingText">
</div>
</body>
</html>