51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

在 Spring Boot 中使用 WebSocket 构建在线日志系统

本文将带你了解如何在 Spring Boot 应用中使用 WebSocket 构建一个在线的日志系统。通过该系统,不需要登录服务器,即可在 HTML 页面上通过 WebSocket 长连接预览到服务器的即时日志。

创建 Spring Boot 应用 {#创建-spring-boot-应用}

添加 spring-boot-starter-websocket 依赖。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocket 配置 {#websocket-配置}

创建 WebSocketConfiguration 配置类,配置 ServerEndpointExporter Bean,用于扫描系统中的 WebSocket 端点实现。

package cn.springdoc.demo.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

创建日志端点 {#创建日志端点}

创建 LoggingChannel WebSocket 端点实现类,接受客户端连接,并且推送日志消息。

package cn.springdoc.demo.web.channel;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import jakarta.websocket.CloseReason;
import jakarta.websocket.CloseReason.CloseCodes;
import jakarta.websocket.EndpointConfig;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnError;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;

/**
 * 
 * WebSocket 日志端点
 * 
 */
@Component
@ServerEndpoint(value = "/channel/logging")
public class LoggingChannel { 

    private static final Logger LOGGER = LoggerFactory.getLogger(LoggingChannel.class);

    public static final ConcurrentMap<String, Session> SESSIONS = new ConcurrentHashMap<>();

    private Session session;

    @OnMessage
    public void onMessage(String messagel){
        try {
            this.session.close(new CloseReason(CloseCodes.CANNOT_ACCEPT, "该端点不接受推送"));
        } catch (IOException e) {
        }
    }

    @OnOpen
    public void onOpen(Session session,  EndpointConfig endpointConfig){
        this.session = session;
        SESSIONS.put(this.session.getId(), this.session);
        
        LOGGER.info("新的连接:{}", this.session.getId());
    }

    @OnClose
    public void onClose(CloseReason closeReason){
        
        LOGGER.info("连接断开:id={},reason={}",this.session.getId(),closeReason);
        
        SESSIONS.remove(this.session.getId());
    }

    @OnError
    public void onError(Throwable throwable) throws IOException {
        LOGGER.info("连接异常:id={},throwable={}", this.session.getId(), throwable);
        this.session.close(new CloseReason(CloseCodes.UNEXPECTED_CONDITION, throwable.getMessage()));
    }

    /**
     * 推送日志
     * @param log
     */
    public static void push (String log) {
        SESSIONS.values().stream().forEach(session -> {
            if (session.isOpen()) {
                try {
                    session.getBasicRemote().sendText(log);
                } catch (IOException e) {
                }
            }
        });
    }
}

如上,使用 @ServerEndpoint 注解表示该 Bean 是一个 WebSocket 端点,并且定义了该端点的 URI。

onOpen 方法中处理新的 WebSocket 连接事件,把连接存储到线程安全的 SESSIONS 中,而且在连接断开时将其从 SESSIONS 中移除。

最关键,也是最简单的方法就是 push 方法,该方法接收一行日志消息,然后遍历所有存活的 WebSocket 连接,并且把消息推送给客户端。

关于 Spring Boot 整合 WebSocket 的更多细节,你可以参考 这篇文章

日志配置 {#日志配置}

Spring Boot 默认使用 Logback 作为日志实现,它有一个关键的组件 Appender(附加器),用于定义日志消息的输出位置(如控制台、文件、数据库等)。要把日志消息输出到 WebSocket 客户端,我们需要定义自己的实现类。

创建 WebSocketAppender Appender 实现,继承 AppenderBase,其泛型对象为日志事件:

package cn.springdoc.demo.logging;

import java.nio.charset.StandardCharsets;
import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import cn.springdoc.demo.web.channel.LoggingChannel;

/**
 * 
 * WebSocketAppender
 * 
 */
public class WebSocketAppender extends AppenderBase<ILoggingEvent> {

    // encoder 是必须的
    private PatternLayoutEncoder encoder;

    @Override
    public void start() {
        // TODO 可以进行资源检查
        super.start();
    }

    @Override
    public void stop() {
        // TODO 可以进行资源释放
        super.stop();
    }

    @Override
    protected void append(ILoggingEvent eventObject) {
        
        // 使用 encoder 编码日志
        byte[] data = this.encoder.encode(eventObject);
        
        // 推送到所有 WebSocket 客户端
        LoggingChannel.push(new String(data, StandardCharsets.UTF_8));
    }

    // 必须要有合法的getter/setter
    public PatternLayoutEncoder getEncoder() {
        return encoder;
    }
    public void setEncoder(PatternLayoutEncoder encoder) {
        this.encoder = encoder;
    }
}

WebSocketAppender 类中定义了一个 PatternLayoutEncoder 用于以指定的格式编码日志。这会通过配置进行注入,所以,需要有标准的 Getter 和 Setter 方法。

覆写 append 方法,处理日志事件。在该方法中,使用 PatternLayoutEncoder 对日志进行格式化后,调用 LoggingChannelpush 方法把日志消息广播到所有存活的 WebSocket 客户端。

logback-spring.xml {#logback-springxml}

自定义 Logback 配置文件。

src/main/resources 下创建 logback-spring.xml,内容如下:

<configuration scan="true" scanPeriod="30 seconds">

    <!-- 继承 Spring 预定义的 Logback 配置 -->
    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <appender name="webSocket" class="cn.springdoc.demo.logging.WebSocketAppender">
        <encoder>
            <!-- 日志格式化,使用预定义的 FILE_LOG_PATTERN,也就是输出到文件中的格式 -->
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- ROOT Logger 的日志级别 -->
    <root level="DEBUG">
        <!-- 输出到控制台 -->
        <appender-ref ref="CONSOLE" />
        <!-- 输出到 WebSocket -->
        <appender-ref ref="webSocket" />
    </root>
    
</configuration>

该配置文件继承了 Spring Boot 默认的 Logback 配置文件,这样的话就可以使用 Spring Boot 中预定义的一些配置项。

通过 <appender> 标签定义 Appender,name 属性用于定义名称,class 指定 Appender 实现类的完整路径。然后配置其 encoder,指定日志输出格式。这里使用了 base.xml 中定义的 FILE_LOG_PATTERN 属性,即输出到文件的日志格式。

最后在 <root> 标签中,通过 <appender-ref> 引入 webSocket Appender,也就是说系统中所有 logger 的日志输出都会记录到 webSocket Appender 中。并且 ROOT logger 的日志级别设置为了 DEBUG

application.yaml {#applicationyaml}

最后,还需要在 application.yaml 中指定 Logback 配置文件的位置。

logging:
  config: "classpath:logback-spring.xml"

HTML 客户端 {#html-客户端}

src/main/resources/public 目录下创建 index.html 作为客户端,内容如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>在线日志</title>
    <style>
        #logContainer {
            height: 500px;
            overflow-y: auto;
            border: 1px solid #ccc;
            padding: 10px;
        }
        
        .logEntry {
            white-space: pre;
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
    <div id="logContainer"></div>

    <script>
        const MAX_LOGS = 500; // 最大日志数量

        // 创建WebSocket连接
        const socket = new WebSocket('ws://localhost:8080/channel/logging');

        // 日志数组
        const logs = [];

        // 监听WebSocket连接事件
        socket.onopen = function() {
            console.log('WebSocket连接已打开');
        };

        // 监听WebSocket消息事件
        socket.onmessage = function(event) {
            const logContainer = document.getElementById('logContainer');

            // 将收到的日志消息添加到日志数组中
            logs.push(event.data);

            // 限制日志数组的长度为最大日志数量
            if (logs.length > MAX_LOGS) {
                logs.splice(0, logs.length - MAX_LOGS); // 删除旧的日志条目
            }

            // 更新日志容器的内容
            logContainer.innerHTML = logs.map(log => '<div class="logEntry">' + log + '</div>').join('');
            
            // 滚动到最底部
            logContainer.scrollTop = logContainer.scrollHeight;
        };

        // 监听WebSocket关闭事件
        socket.onclose = function() {
            console.log('WebSocket连接已关闭');
        };
    </script>
</body>
</html>

HTML 被打开的时候就会与 ws://localhost:8080/channel/logging 创建 WebSocket 连接。当收到服务器的日志消息,就会在页面上渲染显示。为了避免内存溢出,只显示最后 500 条日志信息。

测试 {#测试}

启动服务器,打开浏览器访问主页:http://localhost:8080/(即客户端):

WebSocket 在线日志

如上图,在打开客户端的一瞬间,已经在页面上看到了 WebSocket 连接创建后输出的日志记录。

现在,再打开一个新的页面,访问 http://localhost:8080/404。这个端点并不存在,所以会响应 404 错误。这并不重要,这样做的目的是为了触发服务器输出日志信息。

然后,切换回刚才的日志页面,你可以看到服务端输出的 404 错误日志已经通过 WebSoket 推送到客户端,渲染在页面上了:

WebSocket 在线日志

你也可以对比一下 HTML 客户端的日志和服务器控制台的输出是否一致。只要不关闭日志页面,那么就可以实时预览到 Spring Boot 应用输出的日志。

赞(1)
未经允许不得转载:工具盒子 » 在 Spring Boot 中使用 WebSocket 构建在线日志系统