本文将带你了解如何在 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
对日志进行格式化后,调用 LoggingChannel
的 push
方法把日志消息广播到所有存活的 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 连接创建后输出的日志记录。
现在,再打开一个新的页面,访问 http://localhost:8080/404
。这个端点并不存在,所以会响应 404 错误。这并不重要,这样做的目的是为了触发服务器输出日志信息。
然后,切换回刚才的日志页面,你可以看到服务端输出的 404 错误日志已经通过 WebSoket 推送到客户端,渲染在页面上了:
你也可以对比一下 HTML 客户端的日志和服务器控制台的输出是否一致。只要不关闭日志页面,那么就可以实时预览到 Spring Boot 应用输出的日志。