SpringBoot利用WebSocket结合任务调度线程池实现多客户端实时监控同一日志文件

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去测试,将就看一下

GIF 2021-12-17 10-33-38

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
下面是一个简单的 Spring Boot WebSocket 客户端示例,可以建立多个 WebSocket 连接: 首先,添加依赖: ```xml <!-- WebSocket 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> ``` 然后,创建一个 `WebSocketClient` 类,用于建立 WebSocket 连接: ```java @Component public class WebSocketClient { private final Logger logger = LoggerFactory.getLogger(getClass()); private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); /** * 建立 WebSocket 连接 * * @param url WebSocket URL * @throws Exception */ public void connect(String url) throws Exception { WebSocketHandler handler = new MyWebSocketHandler(); WebSocketSession session = new StandardWebSocketClient() .doHandshake(handler, null, new URI(url)) .get(); logger.info("WebSocket 连接已建立:{}", url); sessions.put(url, session); } /** * 发送消息 * * @param url WebSocket URL * @param message 消息内容 * @throws Exception */ public void sendMessage(String url, String message) throws Exception { WebSocketSession session = sessions.get(url); if (session != null && session.isOpen()) { session.sendMessage(new TextMessage(message)); logger.info("已发送消息:{}", message); } else { logger.warn("WebSocket 连接不存在或已关闭:{}", url); } } /** * 关闭连接 * * @param url WebSocket URL * @throws Exception */ public void close(String url) throws Exception { WebSocketSession session = sessions.get(url); if (session != null && session.isOpen()) { session.close(); logger.info("WebSocket 连接已关闭:{}", url); } else { logger.warn("WebSocket 连接不存在或已关闭:{}", url); } sessions.remove(url); } /** * WebSocket 处理器 */ private class MyWebSocketHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { logger.info("WebSocket 连接已建立:{}", session.getUri()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { logger.info("已收到消息:{}", message.getPayload()); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { logger.error("WebSocket 连接出现异常:{}", session.getUri(), exception); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { logger.info("WebSocket 连接已关闭:{},状态码:{},原因:{}", session.getUri(), status.getCode(), status.getReason()); } } } ``` 在 `WebSocketClient` 类中,我们通过 Map 存储多个 WebSocket 连接,使用 `connect()` 方法创建一个新的 WebSocket 连接,使用 `sendMessage()` 方法发送消息,使用 `close()` 方法关闭连接。 此外,我们还定义了一个内部类 `MyWebSocketHandler`,继承自 `TextWebSocketHandler`,用于处理 WebSocket 消息。 最后,在需要使用 WebSocket 客户端的地方,注入 `WebSocketClient` 即可: ```java @RestController public class MyController { @Autowired private WebSocketClient webSocketClient; @GetMapping("/connect") public void connect() throws Exception { webSocketClient.connect("ws://localhost:8080/ws"); webSocketClient.connect("ws://localhost:8081/ws"); } @GetMapping("/send") public void send() throws Exception { webSocketClient.sendMessage("ws://localhost:8080/ws", "Hello, WebSocket 1!"); webSocketClient.sendMessage("ws://localhost:8081/ws", "Hello, WebSocket 2!"); } @GetMapping("/close") public void close() throws Exception { webSocketClient.close("ws://localhost:8080/ws"); webSocketClient.close("ws://localhost:8081/ws"); } } ``` 在上面的代码中,我们调用 `WebSocketClient` 的 `connect()` 方法建立两个 WebSocket 连接,调用 `sendMessage()` 方法发送消息,调用 `close()` 方法关闭连接。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值