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

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

创建 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 配置

创建 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 配置文件。

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

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

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

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 应用输出的日志。