作为一名资深后端开发,你有没有遇到过这样的场景:服务器上应用出问题了,你需要查看日志排查问题,但又不想或者不能直接登录服务器,只能麻烦运维同学帮忙查看?
今天就来聊聊如何用SpringBoot构建一个轻量级的日志查看器,让你在浏览器上就能实时查看服务器日志,省时又省力!
一、为什么需要Web日志查看器?
在日常开发和运维过程中,查看日志是排查问题的重要手段。传统的日志查看方式存在以下痛点:
1.1 传统方式的痛点
登录服务器麻烦:需要记住服务器地址、用户名、密码,还要处理各种权限问题命令行操作不友好:对于不熟悉Linux命令的开发者来说,使用tail、grep等命令比较困难无法共享日志:当多人协作排查问题时,需要反复沟通日志内容安全性问题:直接暴露服务器访问权限存在安全风险
1.2 Web日志查看器的优势
操作简单:通过浏览器访问,无需登录服务器实时查看:支持类似tail -f的实时日志查看功能易于分享:团队成员可以通过URL共享日志信息安全可控:通过权限控制,避免直接暴露服务器访问权限
二、技术方案选型
实现Web日志查看器有多种技术方案,我们来对比一下:
2.1 WebSocket方案
WebSocket是一种全双工通信协议,适合需要频繁双向通信的场景。
优点:
实时性强支持双向通信
缺点:
实现相对复杂连接管理复杂
2.2 Server-Sent Events (SSE)方案
SSE是HTML5提供的服务器推送技术,适合服务器向客户端单向推送数据的场景。
优点:
实现简单基于HTTP协议,兼容性好自动重连机制轻量级
缺点:
只支持服务器向客户端推送IE浏览器不支持
2.3 长轮询方案
客户端定时向服务器发送请求获取最新日志。
优点:
兼容性最好实现简单
缺点:
实时性差服务器压力大
2.4 我们的选择
考虑到日志查看器主要是服务器向客户端推送日志数据,我们选择SSE方案,它既满足了实时性要求,又实现简单。
三、核心实现原理
3.1 SSE工作原理
SSE通过HTTP长连接实现服务器向客户端推送数据:
客户端发起请求到特定端点服务器保持连接不关闭服务器有新数据时主动推送给客户端连接断开时自动重连
3.2 日志读取原理
通过Java NIO的FileChannel监控日志文件变化使用RandomAccessFile随机访问文件末尾内容实现类似tail -f的功能
四、项目结构设计
log-viewer/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/logviewer/
│ │ │ ├── LogViewerApplication.java
│ │ │ ├── controller/
│ │ │ │ └── LogController.java
│ │ │ ├── service/
│ │ │ │ └── LogService.java
│ │ │ ├── config/
│ │ │ │ └── WebConfig.java
│ │ │ └── util/
│ │ │ └── LogFileTailer.java
│ │ └── resources/
│ │ ├── application.yml
│ │ ├── static/
│ │ │ ├── index.html
│ │ │ ├── css/
│ │ │ │ └── style.css
│ │ │ └── js/
│ │ │ └── log-viewer.js
│ │ └── templates/
│ └── test/
└── pom.xml
五、核心代码实现
5.1 Maven依赖配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>log-viewer</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Log Viewer</name>
<description>SpringBoot轻量级日志查看器</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/>
</parent>
<properties>
<java.version>8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
5.2 应用启动类
package com.example.logviewer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LogViewerApplication {
public static void main(String[] args) {
SpringApplication.run(LogViewerApplication.class, args);
}
}
5.3 日志文件监控工具类
package com.example.logviewer.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
public class LogFileTailer implements Runnable {
private final File logFile;
private final CopyOnWriteArrayList<SseEmitter> emitters;
private volatile boolean running = true;
private long lastPosition = 0;
public LogFileTailer(File logFile, CopyOnWriteArrayList<SseEmitter> emitters) {
this.logFile = logFile;
this.emitters = emitters;
// 初始化文件位置
if (logFile.exists()) {
this.lastPosition = logFile.length();
}
}
@Override
public void run() {
try (RandomAccessFile file = new RandomAccessFile(logFile, "r")) {
while (running) {
long fileLength = logFile.length();
// 文件被截断的情况
if (fileLength < lastPosition) {
lastPosition = fileLength;
}
// 文件有新增内容
if (fileLength > lastPosition) {
file.seek(lastPosition);
String line;
while ((line = file.readLine()) != null) {
// 处理中文编码问题
String logLine = new String(line.getBytes("ISO-8859-1"), "UTF-8");
broadcastLog(logLine);
}
lastPosition = file.getFilePointer();
}
// 休眠1秒再检查
Thread.sleep(1000);
}
} catch (Exception e) {
log.error("读取日志文件出错", e);
broadcastError("读取日志文件出错: " + e.getMessage());
}
}
/**
* 广播日志到所有连接的客户端
*/
private void broadcastLog(String logLine) {
emitters.removeIf(emitter -> {
try {
emitter.send(SseEmitter.event().name("message").data(logLine));
return false;
} catch (IOException e) {
log.warn("发送日志到客户端失败", e);
return true;
}
});
}
/**
* 广播错误信息
*/
private void broadcastError(String errorMessage) {
emitters.removeIf(emitter -> {
try {
emitter.send(SseEmitter.event().name("error").data(errorMessage));
return false;
} catch (IOException e) {
log.warn("发送错误信息到客户端失败", e);
return true;
}
});
}
/**
* 停止监控
*/
public void stop() {
running = false;
}
}
5.4 日志服务类
package com.example.logviewer.service;
import com.example.logviewer.util.LogFileTailer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.PostConstruct;
import java.io.File;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
@Service
public class LogService {
@Value("${log.viewer.file-path:/var/log/application.log}")
private String logFilePath;
private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private LogFileTailer logFileTailer;
private Thread tailerThread;
@PostConstruct
public void init() {
File logFile = new File(logFilePath);
if (!logFile.exists()) {
log.warn("日志文件不存在: {}", logFilePath);
return;
}
logFileTailer = new LogFileTailer(logFile, emitters);
tailerThread = new Thread(logFileTailer);
tailerThread.setDaemon(true);
tailerThread.start();
log.info("日志监控已启动,监控文件: {}", logFilePath);
}
/**
* 获取SSE连接
*/
public SseEmitter connect() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
// 添加到连接列表
emitters.add(emitter);
// 连接完成时移除
emitter.onCompletion(() -> emitters.remove(emitter));
// 连接超时处理
emitter.onTimeout(() -> {
emitters.remove(emitter);
emitter.complete();
});
// 连接错误处理
emitter.onError(throwable -> {
emitters.remove(emitter);
emitter.complete();
});
return emitter;
}
}
5.5 控制器类
package com.example.logviewer.controller;
import com.example.logviewer.service.LogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Slf4j
@RestController
@RequestMapping("/api/logs")
@RequiredArgsConstructor
public class LogController {
private final LogService logService;
/**
* SSE实时日志流
*/
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamLogs() {
return logService.connect();
}
}
5.6 前端页面
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>轻量级日志查看器</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🚀 轻量级日志查看器</h1>
<p>实时查看应用日志,无需登录服务器</p>
</header>
<div class="controls">
<button id="connectBtn">连接日志</button>
<button id="clearBtn">清空日志</button>
<input type="text" id="searchInput" placeholder="搜索日志内容...">
</div>
<div class="log-container">
<pre id="logOutput"></pre>
</div>
</div>
<script src="/js/log-viewer.js"></script>
</body>
</html>
5.7 前端CSS样式
/* static/css/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
background-color: #1e1e1e;
color: #d4d4d4;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 30px;
}
header h1 {
color: #569cd6;
margin-bottom: 10px;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
background-color: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #005a9e;
}
#searchInput {
flex: 1;
min-width: 200px;
padding: 10px;
border: 1px solid #3c3c3c;
border-radius: 4px;
background-color: #3c3c3c;
color: #d4d4d4;
}
.log-container {
background-color: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 4px;
height: 70vh;
overflow-y: auto;
}
#logOutput {
padding: 20px;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 14px;
}
.log-line {
margin: 2px 0;
padding: 2px 0;
}
.log-line.error {
color: #f48771;
}
.log-line.warn {
color: #cca700;
}
.log-line.info {
color: #75beff;
}
.highlight {
background-color: #ffff00;
color: #000000;
}
5.8 前端JavaScript
// static/js/log-viewer.js
class LogViewer {
constructor() {
this.eventSource = null;
this.isConnected = false;
this.logOutput = document.getElementById('logOutput');
this.connectBtn = document.getElementById('connectBtn');
this.clearBtn = document.getElementById('clearBtn');
this.searchInput = document.getElementById('searchInput');
this.initEventListeners();
}
initEventListeners() {
this.connectBtn.addEventListener('click', () => {
if (this.isConnected) {
this.disconnect();
} else {
this.connect();
}
});
this.clearBtn.addEventListener('click', () => {
this.logOutput.innerHTML = '';
});
this.searchInput.addEventListener('input', () => {
this.highlightSearchTerm();
});
}
connect() {
if (this.eventSource) {
this.eventSource.close();
}
this.eventSource = new EventSource('/api/logs/stream');
this.isConnected = true;
this.connectBtn.textContent = '断开连接';
this.eventSource.addEventListener('message', (event) => {
this.appendLog(event.data);
});
this.eventSource.addEventListener('error', (event) => {
console.error('SSE连接错误:', event);
this.appendLog('❌ 连接错误,请检查服务器状态');
});
this.appendLog('✅ 已连接到日志服务器,开始接收实时日志...');
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.isConnected = false;
this.connectBtn.textContent = '连接日志';
this.appendLog('🔚 已断开日志连接');
}
appendLog(logLine) {
const logElement = document.createElement('div');
logElement.className = 'log-line';
logElement.textContent = `[${new Date().toLocaleString()}] ${logLine}`;
// 根据日志级别添加样式
if (logLine.includes('ERROR') || logLine.includes('error')) {
logElement.classList.add('error');
} else if (logLine.includes('WARN') || logLine.includes('warn')) {
logElement.classList.add('warn');
} else if (logLine.includes('INFO') || logLine.includes('info')) {
logElement.classList.add('info');
}
this.logOutput.appendChild(logElement);
// 自动滚动到底部
this.logOutput.scrollTop = this.logOutput.scrollHeight;
// 应用搜索高亮
this.highlightSearchTerm();
}
highlightSearchTerm() {
const searchTerm = this.searchInput.value.trim();
if (!searchTerm) {
// 清除所有高亮
const highlightedElements = this.logOutput.querySelectorAll('.highlight');
highlightedElements.forEach(element => {
element.outerHTML = element.innerHTML;
});
return;
}
// 重新应用高亮
const logLines = this.logOutput.querySelectorAll('.log-line');
logLines.forEach(line => {
const text = line.textContent;
if (text.includes(searchTerm)) {
const regex = new RegExp(`(${searchTerm})`, 'gi');
const highlightedText = text.replace(regex, '<span class="highlight">$1</span>');
line.innerHTML = highlightedText;
}
});
}
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
new LogViewer();
});
5.9 配置文件
# application.yml
server:
port: 8080
spring:
application:
name: log-viewer
thymeleaf:
cache: false
encoding: UTF-8
mode: HTML
prefix: classpath:/static/
suffix: .html
log:
viewer:
file-path: ./logs/application.log # 日志文件路径
logging:
level:
com.example.logviewer: INFO
file:
name: ./logs/application.log
六、功能特性
6.1 核心功能
实时日志查看:通过SSE实现类似tail -f的实时日志查看功能日志级别高亮:自动识别ERROR、WARN、INFO等日志级别并高亮显示日志搜索:支持关键字搜索和高亮显示日志清空:支持清空当前显示的日志内容连接管理:支持手动连接和断开日志流
6.2 安全特性
权限控制:可通过Spring Security添加访问权限控制日志文件限制:可配置允许查看的日志文件路径连接数限制:可限制同时连接的客户端数量
七、部署与使用
7.1 项目构建
# 克隆项目
git clone <项目地址>
# 进入项目目录
cd log-viewer
# 构建项目
mvn clean package
# 运行项目
java -jar target/log-viewer-1.0.0.jar
7.2 配置说明
在application.yml中配置日志文件路径:
log:
viewer:
file-path: /var/log/myapp/application.log # 实际日志文件路径
7.3 访问方式
启动应用后,通过浏览器访问:
http://localhost:8080/index.html
八、性能优化建议
8.1 内存优化
限制缓存行数:避免日志过多导致内存溢出及时清理连接:断开的SSE连接要及时清理
8.2 网络优化
压缩传输:启用GZIP压缩减少网络传输量连接复用:合理设置SSE连接超时时间
8.3 文件读取优化
缓冲读取:使用合适的缓冲区大小文件监控:使用NIO的WatchService监控文件变化
九、扩展功能
9.1 多文件支持
可以扩展支持多个日志文件的查看:
// 添加文件选择功能
@GetMapping("/files")
public List<String> getLogFiles() {
// 返回可查看的日志文件列表
return Arrays.asList("application.log", "error.log", "access.log");
}
9.2 日志过滤
支持按时间、级别等条件过滤日志:
// 添加过滤参数
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamLogs(
@RequestParam(required = false) String level,
@RequestParam(required = false) String keyword) {
// 根据参数过滤日志
return logService.connect(level, keyword);
}
9.3 历史日志查看
支持查看历史日志内容:
// 添加历史日志查询接口
@GetMapping("/history")
public List<String> getHistoryLogs(
@RequestParam(defaultValue = "100") int lines) {
// 返回最近N行日志
return logService.getHistoryLogs(lines);
}
十、总结
通过SpringBoot构建的轻量级日志查看器具有以下优势:
实现简单:基于SpringBoot和SSE技术,开发难度低实时性强:接近tail -f的实时查看体验易于部署:打包成jar包即可运行,无需额外依赖功能丰富:支持日志高亮、搜索、清空等实用功能扩展性好:可根据需要添加更多功能
掌握了这个技术,相信你再面对日志查看需求时会更加从容不迫,让你的开发效率更上一层楼!
源代码工程:公众号回复:【日志查看器】获取源码!
今日思考:你们团队在日志查看方面有什么好的工具或方法?欢迎在评论区分享你的经验!


