用 WebSocket/SSE + Java 原生功能实现 Web 实时日志(兼容Win平台)
Tomcat 产生的日志以文件形式保存在服务器上。如果要在 Web 上浏览这些日志,采用 WebSocket + tail 命令是简单可行的方式,例如这文章介绍得都很好。只是在 Win 系统上面就没有类似 tail 命令行的工具,除非找第三方或者 PowerShell 的,多少有点不便,——尽管多数 Win 用于开发环境,日志直接在 IDE 控制台上看就可以了。这里为大家介绍的是一种原生 Java 方法浏览日志,实际上不复杂。
LogFileTailer
以下这个 LogFileTailer 类便是是实现 Linux tail 的跟踪日志功能,可持续监控某日志信息。tail 命令用途是依照要求将指定的文件的最后部分输出到标准设备,通常是终端,通俗讲来就是把某个档案文件的最后几行显示到终端上,假设该档案有更新,tail 会自己主动刷新,确保你看到最新的文件内容。
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.function.Consumer;
public class LogFileTailer extends Thread {
private long sampleInterval = 2000;
private File logfile;
private boolean startAtBeginning;
private Consumer<String> callback;
/**
* 监视开关,true = 打开监视
*/
private boolean tailing;
/**
*
* @param file 要监视的文本文件
* @param sampleInterval 读取时间间隔
* @param startAtBeginning 是否显示文件头?还是说只显示后面变化的部分
*/
public LogFileTailer(String file, long sampleInterval, boolean startAtBeginning) {
logfile = new File(file);
this.sampleInterval = sampleInterval;
this.startAtBeginning = startAtBeginning;
}
/**
* 设置回调事件
*
* @param callback 回调事件
*/
public void addListener(Consumer<String> callback) {
this.callback = callback;
}
/**
* 监视开关,true = 打开监视
*
* @param tailing true = 打开监视
*/
public void setTailing(boolean tailing) {
this.tailing = tailing;
}
@Override
public void run() {
long filePointer = startAtBeginning ? 0 : logfile.length();
try {
RandomAccessFile file = new RandomAccessFile(logfile, "r");
while (tailing) {
long fileLength = logfile.length();
if (fileLength < filePointer) {
file = new RandomAccessFile(logfile, "r");
filePointer = 0;
}
if (fileLength > filePointer) {
file.seek(filePointer);
String line = file.readLine();
while (line != null) {
line = new String(line.getBytes("ISO-8859-1"), "utf-8");
if (callback != null)
callback.accept(line);
line = file.readLine();
}
filePointer = file.getFilePointer();
}
sleep(sampleInterval);
}
file.close();
} catch (IOException | InterruptedException e) {
}
}
}
测试代码如下。使用了 Java 8 的 Lambda 转入回调事件。
public static void main(String[] args) throws IOException {
LogFileTailer tailer = new LogFileTailer("C:\\temp\\bar.txt", 1000, true);
tailer.setTailing(true);
tailer.addListener(System.out::println);
tailer.start();
}
效果图如下
WebSocket
WebSocket 部分也比较简单,用户可以根据此自己扩展一下。
import java.io.IOException;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import com.ajaxjs.framework.BaseController;
import com.ajaxjs.mvc.controller.IController;
import com.ajaxjs.util.logger.LogHelper;
@ServerEndpoint("/tomcat_log")
@Path("/admin/tomcat-log")
public class TomcatLogController implements IController {
private static final LogHelper LOGGER = LogHelper.getLog(TomcatLogController.class);
private LogFileTailer tailer;
@GET
public String UI() {
LOGGER.info("实时浏览 Tomcat 日志");
return BaseController.jsp("admin/tomcat-log");
}
/**
* 新的WebSocket请求开启
*/
@OnOpen
public void onOpen(Session session) {
tailer = new LogFileTailer("C:\\temp\\bar.txt", 1000, true);
tailer.setTailing(true);
tailer.addListener(log -> {
try {
session.getBasicRemote().sendText(log + "<br />");
} catch (IOException e) {
LOGGER.warning(e);
}
});
tailer.start();
}
/**
* WebSocket请求关闭
*/
@OnClose
public void onClose() {
tailer.setTailing(false);
}
@OnError
public void onError(Throwable thr) {
thr.printStackTrace();
}
}
前端
<div id="log-container" style="height: 450px; overflow-y: scroll; background: #333; color: #aaa; padding: 10px; margin: 0 auto;width: 89%;">
<div></div>
</div>
<script src="//cdn.bootcss.com/jquery/2.1.4/jquery.js"></script>
<script>
$(document).ready(function() {
// 指定websocket路径
var websocket = new WebSocket('ws://' + document.location.host + '/${ctx}/tomcat_log');
websocket.onmessage = function(event) {
// 接收服务端的实时日志并添加到HTML页面中
$("#log-container div").append(event.data);
// 滚动条滚动到最低部
$("#log-container").scrollTop($("#log-container div").height() - $("#log-container").height());
};
});
</script>
参考
- https://blog.51cto.com/6140717/1052845
- https://www.cnblogs.com/snowater/p/7603611.html
- https://blog.csdn.net/xxgwo/article/details/51198113
SSE
感觉和以前提到的基于 HTTP 的长连接 PUSH 是一个概念吧。还有1个像素的iframe长连接,就是会出现页面夹在进度条永远不结束的问题。老技术换了套新衣?如果是的话,那么笔者觉得其实也没有什么大的突破。而且觉得那会有比较严重的性能上限,也就是说连接数不能太多,实际来说占资源比 WebSocket 多。但好处如下:
- 单向连接,无形中比较安全,客户端不会发消息给 Server 适合当前打印 log 的场景
- 实现简单,80端口
参考:
支持 SSE
edit:2026-4-5 新增 SSE支持。建议使用 SSE 的即可。
package com.ajaxjs.framework.livelog;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.util.function.Consumer;
@EqualsAndHashCode(callSuper = true)
@Data
public class LogFileTailer extends Thread {
/**
* 读取时间间隔
*/
private long sampleInterval = 1000;
/**
* 是否显示文件头?还是说只显示后面变化的部分
*/
private boolean startAtBeginning = true;
private final File logfile;
/**
* 回调事件
*/
private Consumer<String> listener;
/**
* 监视开关,true = 打开监视
*/
private boolean tailing;
/**
* @param file 要监视的文本文件
*/
public LogFileTailer(String file) {
logfile = new File(file);
}
@Override
public void run() {
long filePointer = startAtBeginning ? 0 : logfile.length();
try {
RandomAccessFile file = new RandomAccessFile(logfile, "r");
while (tailing) {
long fileLength = logfile.length();
if (fileLength < filePointer) {
file = new RandomAccessFile(logfile, "r");
filePointer = 0;
}
if (fileLength > filePointer) {
file.seek(filePointer);
String line = file.readLine();
while (line != null) {
line = new String(line.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
if (listener != null)
listener.accept(line);
line = file.readLine();
}
filePointer = file.getFilePointer();
}
sleep(sampleInterval);
}
file.close();
} catch (IOException | InterruptedException e) {
}
}
public static void main(String[] args) throws IOException {
LogFileTailer tailer = new LogFileTailer("C:\\temp\\bar.txt");
tailer.setTailing(true);
tailer.setListener(System.out::println);
tailer.start();
}
}
Cotroller:
package com.ajaxjs.framework.livelog;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;
@RestController
@Slf4j
@ConditionalOnProperty(name = "aj-framework.livelog.enable", havingValue = "true")
public class LiveLogController {
/**
* 存储所有活跃的 SSE 连接
*/
private static final CopyOnWriteArraySet<SseEmitter> emitters = new CopyOnWriteArraySet<>();
/**
* 用于控制日志监控
* 不管多少个连接,只有一个 LogFileTailer 实例
*/
private volatile LogFileTailer tailer;
private volatile boolean isMonitoring = false;
@Value("${aj-framework.livelog.logFilePath}")
private String logFilePath;
/**
* SSE 端点 - 客户端连接后开始推送日志
*/
@GetMapping(value = "/tomcat_log_stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handleSse() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 设置超时时间为最大值
Runnable cleanup = () -> { // 添加连接事件
emitters.remove(emitter);
if (emitters.isEmpty())
stopLogMonitoring();
};
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError((ex) -> {
log.error("Error in SSE connection: " + ex.getMessage(), ex);
cleanup.run();
});
emitters.add(emitter);
if (!isMonitoring) // 如果还没有启动日志监控,则启动它
startLogMonitoring();
return emitter;
}
/**
* 启动日志监控
*/
private void startLogMonitoring() {
if (!isMonitoring) {
synchronized (this) {
if (!isMonitoring) {
tailer = new LogFileTailer(logFilePath);
tailer.setTailing(true);
tailer.setListener(this::sendLogToAllClients);
tailer.start();
isMonitoring = true;
}
}
}
}
/**
* 停止日志监控
*/
private void stopLogMonitoring() {
if (isMonitoring) {
synchronized (this) {
if (isMonitoring && emitters.isEmpty()) {
if (tailer != null)
tailer.setTailing(false);
isMonitoring = false;
}
}
}
}
/**
* 向所有连接的客户端发送日志消息
*/
private void sendLogToAllClients(String logMessage) {
emitters.removeIf(emitter -> {
try {
emitter.send(SseEmitter.event()
.data(logMessage + "<br/>")
.build());
return false; // 不移除正常的连接
} catch (IOException e) {
log.error("Error sending log message to client: " + e.getMessage(), e);
return true; // 连接已断开,需要移除
}
});
}
}
前端 JavaScript 代码
<!DOCTYPE html>
<html>
<head>
<title>实时日志查看</title>
<style>
#logContainer {
height: 500px;
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
background-color: #f5f5f5;
font-family: monospace;
}
.log-line {
margin: 2px 0;
}
</style>
</head>
<body>
<h2>Tomcat 实时日志</h2>
<div id="logContainer"></div>
<script>
const logContainer = document.getElementById('logContainer');
let eventSource = null;
function connectToLogs() {
// 关闭之前的连接
if (eventSource) {
eventSource.close();
}
// 创建新的 SSE 连接
eventSource = new EventSource('/tomcat_log_stream');
eventSource.onmessage = function(event) {
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.innerHTML = event.data;
logContainer.appendChild(logLine);
// 自动滚动到底部
logContainer.scrollTop = logContainer.scrollHeight;
};
eventSource.onerror = function(event) {
console.error('SSE 连接错误:', event);
// 可以在这里添加重连逻辑
setTimeout(connectToLogs, 3000);
};
}
// 页面加载时连接
window.onload = function() {
connectToLogs();
};
// 页面卸载时关闭连接
window.onbeforeunload = function() {
if (eventSource) {
eventSource.close();
}
};
</script>
</body>
</html>
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)