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>
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐