1、前言
首先是创建一个SpringBoot工程,我相信读这篇文章的你已经会了,所以这里只给出依赖坐标
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.78</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
本文的业务逻辑前提如下:
- 对于某一次构建任务,他会产生一个构建执行日志文件,而这一个日志文件可能不止一个客户端会去实时监控,所以我以构建任务的Id作为不同的客户端去查看同一个日志文件的标识
2、配置类
注入ServerEndpointExpoter,以便后续使用注解开发
/**
* 首先要注入ServerEndpointExporter,这个Bean会自动注册使用了@ServerEndPoint注解声明的WebSocket EndPoint。
* 要注意的是,如果使用独立的Servlet容器,而不是SpringBoot的内置容器,就不要注入ServerEndpointExporter,因为它将由容器自己提供和管理
*
* @author PengHuanZhi
* @date 2021年12月16日 9:30
*/
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3、会话池
会话池代码没有啥技术含量,不作其他解释,具体可参考代码中注释
/**
* @author PengHuanZhi
* @date 2021年12月16日 9:43
*/
public class MonitorSessionPool {
/**
* 根据一个构建任务为单位维护下方Session的Id
**/
private static final Map<Long, List<String>> BUILD_RUN_SESSIONS = new ConcurrentHashMap<>();
/**
* 以SessionId为键维护所有Session
**/
private static final Map<String, Session> ALL_BUILD_RUN_SESSIONS = new ConcurrentHashMap<>();
/**
* @param sessionId 会话Id
* @description 连接关闭方法
**/
public static Long close(String sessionId) {
List<Session> sessions = ALL_BUILD_RUN_SESSIONS.values().stream().filter(session -> session.getId().equals(sessionId)).collect(Collectors.toList());
if (CollectionUtils.isEmpty(sessions) || sessions.size() != 1) {
throw new RuntimeException("获取会话信息失败");
}
Session session = sessions.get(0);
if (session == null) {
throw new RuntimeException("目标Session未找到");
}
Set<Map.Entry<Long, List<String>>> buildRunSessionEntrySet = BUILD_RUN_SESSIONS.entrySet();
for (Map.Entry<Long, List<String>> entry : buildRunSessionEntrySet) {
List<String> ids = entry.getValue();
Iterator<String> idIterator = ids.iterator();
while (idIterator.hasNext()) {
String id = idIterator.next();
if (id.equals(sessionId)) {
idIterator.remove();
ALL_BUILD_RUN_SESSIONS.remove(sessionId);
return entry.getKey();
}
}
}
return -1L;
}
/**
* @param buildRunId 构建执行Id
* @param log 新增的日志内容
* @description 向监听某个构建执行实时日志的客户端推送新的日志信息
**/
public static void sendMessage(Long buildRunId, String log) {
List<String> sessionIds = BUILD_RUN_SESSIONS.get(buildRunId);
if (CollectionUtils.isEmpty(sessionIds)) {
throw new RuntimeException("当前构建任务下方无实时日志监听客户端");
}
sessionIds.forEach(sessionId -> {
Session session = ALL_BUILD_RUN_SESSIONS.get(sessionId);
RemoteEndpoint.Async asyncRemote = session.getAsyncRemote();
asyncRemote.sendText(log);
});
}
/**
* @param session 会话对象
* @param buildRunId 构建执行Id
* @description 实时监听日志客户端上线
**/
public static void openSession(Session session, Long buildRunId) {
List<String> sessionIds = BUILD_RUN_SESSIONS.computeIfAbsent(buildRunId, k -> new ArrayList<>());
sessionIds.add(session.getId());
ALL_BUILD_RUN_SESSIONS.put(session.getId(), session);
}
/**
* @param buildRunId 构建执行Id
* @return Integer 当前构建执行日志监控客户端数量
* @description 获取当前构建执行日志监控客户端数量
**/
public static Integer getAliveClientTotalNumber(Long buildRunId) {
List<String> sessionIds = BUILD_RUN_SESSIONS.get(buildRunId);
if (CollectionUtils.isEmpty(sessionIds)) {
return 0;
}
return sessionIds.size();
}
}
4、多客户端读取同一日志文件的辅助类
因为不同的客户端监听的日志文件可能不唯一,所以我创建了一个监控日志输出内容相关信息的辅助类,其中:
- logFilePath:日志文件路径
- log:已经输出的日志文件内容(如果存在日志内容过大问题,读者可以思考自行解决,我这里强行认为我的内存够用,一般情况下,某一次构建产生的日志文件不会太大)
- nowPosition:当前已经输出的日志内容坐标
- nowLine:因为支持按行输出日志,所以保存当前输出的日志内容行数
- submit:标识一个监控线程异步执行的结果,会用于销毁线程
/**
* @author PengHuanZhi
* @date 2021年12月16日 16:42
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Component
public final class FileLocationDescriptor {
/**
* 已经输出的日志文件内容(如果存在日志内容过大问题,后面会有另一种思路,我这里强行认为我的内存够用,一般情况下,某一次构建产生的日志文件不会太大)
**/
private List<String> log;
/**
* 当前已经输出的日志内容坐标
**/
private Long nowPosition;
/**
* 因为支持按行输出日志,所以保存当前输出的日志内容行数
**/
private Long nowLine;
/**
* 标识一个监控线程异步执行的结果,会用于销毁线程
**/
private Future<?> submit;
/**
* 保存当前操作的流对象,用于关闭
**/
private Closeable closable;
}
5、WebSocket服务端
这里使用了任务定时调度线程池,让其每隔一段时间去监控日志文件内容变化,还有一点就是文件路径,项目中应该需要按照一定的逻辑动态获取,这里简单起见直接用了一个桌面的txt日志文件,具体逻辑可以参考代码中的注释,我写的挺详细的
/**
* @author PengHuanZhi
* @date 2021年12月16日 9:37
*/
@ServerEndpoint(value = "/buildRunLog/{buildRunId}")
@Component
@Slf4j
public class WebSocketEndpoint {
/**
* 缓存监控日志相关类
**/
private static final Map<Long, FileLocationDescriptor> FILE_LOCATION_DESCRIPTORS = new ConcurrentHashMap<>();
/**
* 定时调度线程池
**/
private static final ScheduledExecutorService SCHEDULED_EXECUTOR_POOL = new ScheduledThreadPoolExecutor(5,
new BasicThreadFactory.Builder()
.namingPattern("日志文件监控定时调度线程池-%d")
//是否守护线程
.daemon(true)
.build());
/**
* @param session 会话对象
* @param buildRunId 构建执行Id
* @description 当连接建立成功调用
**/
@OnOpen
public void onOpen(final Session session, @PathParam("buildRunId") Long buildRunId) throws IOException, EncodeException {
//先将当前会话放入会话池
MonitorSessionPool.openSession(session, buildRunId);
//获取输出构建日志文件信息辅助类
FileLocationDescriptor fileLocationDescriptor = FILE_LOCATION_DESCRIPTORS.get(buildRunId);
String filePath = "C:\\Users\\Administrator\\Desktop\\log.txt";
/*
* 当前没有客户端监控这个日志文件,则会创建一个唯一的输出构建日志文件信息辅助类与当前日志对应
**/
if (fileLocationDescriptor == null) {
//初始化输出构建日志文件信息辅助类
fileLocationDescriptor = new FileLocationDescriptor(new ArrayList<>(), 0L, 0L, null);
//放入缓存Map中
FILE_LOCATION_DESCRIPTORS.put(buildRunId, fileLocationDescriptor);
//lambda匿名类表达式中引用外部变量,需要加final
final FileLocationDescriptor finalFileLocationDescriptor = fileLocationDescriptor;
//将当前监控线程异步执行的结果获取出来
Future<?> submit = startMonitoring(buildRunId, filePath, finalFileLocationDescriptor);
//将监控线程异步执行结果存入信息类中
fileLocationDescriptor.setSubmit(submit);
randomAccessFile.close();
} else {
/*
* 如果已经创建了对应的输出构建日志文件信息辅助类,则证明已经有一个监控线程在监听目
* 标日志文件,先将当前已经读出来的内容写出去,后续的新内容会交给监控线程去读取并推送
**/
JSONObject jsonObject = new JSONObject();
jsonObject.put("log", fileLocationDescriptor.getLog());
session.getBasicRemote().sendText(jsonObject.toJSONString());
}
}
/**
* @param session 会话对象
* @description 当连接关闭调用
**/
@OnClose
public void onClose(Session session) {
//先将当前会话移除会话池
Long buildRunId = MonitorSessionPool.close(session.getId());
if (buildRunId == -1) {
throw new RuntimeException("session未找到");
}
//获取当前构建执行Id下方还有多少客户端连接
Integer aliveClientTotalNumber = MonitorSessionPool.getAliveClientTotalNumber(buildRunId);
//如果所有客户端都断开连接,则将当前监控线程销毁
if (aliveClientTotalNumber == 0) {
//获取到目标构建执行监控线程异步执行的结果
Future<?> submit = FILE_LOCATION_DESCRIPTORS.get(buildRunId).getSubmit();
try {
LOG_FILE_PUSH_RECORDS.get(buildRunId).getClosable().close();
} catch (IOException e) {
e.printStackTrace();
}
//直接强制取消,忽略当前是否正在执行
submit.cancel(true);
//从缓存中移除
FILE_LOCATION_DESCRIPTORS.remove(buildRunId);
}
}
/**
* @param buildRunId 目标构建执行Id
* @param filePath 文件路径
* @param finalFileLocationDescriptor 相关信息类
* @return Future<?> 异步线程执行的结果
* @description 开启一个监控线程
**/
private Future<?> startMonitoring(Long buildRunId, String filePath, FileLocationDescriptor finalFileLocationDescriptor) throws FileNotFoundException {
//创建日志文件对象
File logFile = new File(filePath);
if (!logFile.exists()) {
throw new RuntimeException("目标日志文件不存在");
}
RandomAccessFile randomAccessFile = new RandomAccessFile(logFile, "r");
finalLogFilePushRecord.setClosable(randomAccessFile);
return SCHEDULED_EXECUTOR_POOL.scheduleAtFixedRate(() -> {
try {
//获取当前日志文件行数
long nowLine = getFileLines(logFile);
//只有当有新内容添加才会开始读取
Long oldPosition = finalFileLocationDescriptor.getNowPosition();
//新数据集合
List<String> newLogs = new ArrayList<>();
if (finalFileLocationDescriptor.getNowLine() < nowLine) {
//跳转到未读数据的位置
randomAccessFile.seek(oldPosition);
String lineLog;
while ((lineLog = randomAccessFile.readLine()) != null) {
//从上一行末尾换行符前一个位置读取下一行,第一行必然是一个换行,所以需要转换
if ("".equals(lineLog)) {
lineLog = "\n";
}
newLogs.add(lineLog);
}
//获取当前读到的坐标,存入信息类中
long nowPosition = randomAccessFile.getFilePointer();
finalFileLocationDescriptor.setNowPosition(nowPosition);
finalFileLocationDescriptor.setNowLine(nowLine);
//将新的日志内容输出到客户端
JSONObject jsonObject = new JSONObject();
jsonObject.put("newLog", newLogs);
MonitorSessionPool.sendMessage(buildRunId, jsonObject.toJSONString());
finalFileLocationDescriptor.getLog().addAll(newLogs);
} else {
//如果行数没有新增,判断一下有没有在末尾新增日志
long nowPosition = randomAccessFile.length();
//如果有新增
if (oldPosition < nowPosition) {
//设置当前读取开始位置
randomAccessFile.seek(oldPosition);
//内容长度为新旧坐标之差
int newLogByteLength = Integer.parseInt((nowPosition - oldPosition) + "");
//创建对应的读取字节数组
byte[] newLogByte = new byte[newLogByteLength];
//将剩余内容读取到字节数组中
randomAccessFile.read(newLogByte);
//转换日志内容为字符串
String newLog = new String(newLogByte, StandardCharsets.UTF_8);
newLogs.add(newLog);
JSONObject jsonObject = new JSONObject();
jsonObject.put("newLog", newLogs);
MonitorSessionPool.sendMessage(buildRunId, jsonObject.toJSONString());
//发送到客户端
finalFileLocationDescriptor.setNowPosition(nowPosition);
}
}
} catch (IOException e) {
e.printStackTrace();
}
//延时0s立即执行,且周期为1s
}, 0, 1, TimeUnit.SECONDS);
}
/**
* @param file 文件对象
* @return long 返回行数
* @description 获取文件总行数
**/
public static long getFileLines(File file) throws IOException {
long fileLength = file.length();
LineNumberReader lineNumberReader = new LineNumberReader(new FileReader(file));
long skip = lineNumberReader.skip(fileLength);
if (skip == 0) {
return 0;
}
long lines = lineNumberReader.getLineNumber();
lineNumberReader.close();
return lines;
}
}
6、效果
这里我没有编写前端页面测试,而是使用了Postman去测试,将就看一下