生产环境出问题时,你还在用 tail -f
查日志吗?还在为了下载几个G的日志文件而苦恼吗?本文将手把手教你实现一个轻量级的日志管理系统,让日志查询变得简单而高效。


前言:为什么要自建日志查询系统?
在实际项目中,我们经常遇到这样的场景:
- 生产环境出现问题,需要快速定位错误日志
- 日志文件太大,下载耗时且占用带宽
- 需要根据时间、关键字、日志级别等条件筛选日志
- 多人协作时,都需要登录服务器查看日志
虽然有 ELK、Splunk 等成熟方案,但对于中小型项目来说,部署成本高、资源消耗大。今天我们用 Spring Boot + 纯前端技术栈,打造一个轻量级、开箱即用的日志管理系统。
系统设计
整体架构
lua
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 前端界面 │ │ Spring Boot │ │ 日志文件 │
│ (HTML+JS) │◄──►│ 后端 │◄──►│ (logback) │
│ │ │ │ │ │
│ • 日志查询 │ │ • 文件读取 │ │ • app.log │
│ • 条件筛选 │ │ • 内容解析 │ │ • error.log │
│ • 在线预览 │ │ • 分页处理 │ │ • access.log │
│ • 文件下载 │ │ • 下载服务 │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
核心功能模块
1. 日志文件管理:扫描、列举日志文件
2. 内容解析:按行读取、正则匹配、分页处理
3. 条件筛选:时间范围、关键字、日志级别
4. 在线预览:实时显示、语法高亮
5. 文件下载:支持原文件和筛选结果下载
后端实现
1. 项目结构
css
src/main/java/com/example/logviewer/
├── LogViewerApplication.java
├── config/
│ └── LogConfig.java
├── controller/
│ └── LogController.java
├── service/
│ └── LogService.java
├── dto/
│ ├── LogQueryRequest.java
│ └── LogQueryResponse.java
└── util/
└── LogParser.java
2. 核心依赖 (pom.xml)
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>
3. 配置类
less
@Configuration
@ConfigurationProperties(prefix = "log.viewer")
@Data
public class LogConfig {
/**
* 日志文件根目录
*/
private String logPath = "./logs";
/**
* 允许访问的日志文件扩展名
*/
private List<String> allowedExtensions = Arrays.asList(".log", ".txt");
/**
* 单次查询最大行数
*/
private int maxLines = 1000;
/**
* 文件最大大小(MB)
*/
private long maxFileSize = 100;
/**
* 是否启用安全检查
*/
private boolean enableSecurity = true;
}
4. 数据传输对象
arduino
@Data
public class LogQueryRequest {
@NotBlank(message = "文件名不能为空")
private String fileName;
/**
* 页码,从1开始
*/
@Min(value = 1, message = "页码必须大于0")
private int page = 1;
/**
* 每页行数
*/
@Min(value = 1, message = "每页行数必须大于0")
@Max(value = 1000, message = "每页行数不能超过1000")
private int pageSize = 100;
/**
* 关键字搜索
*/
private String keyword;
/**
* 日志级别过滤
*/
private String level;
/**
* 开始时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
/**
* 结束时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
/**
* 是否倒序
*/
private boolean reverse = true;
}
@Data
public class LogQueryResponse {
/**
* 日志内容列表
*/
private List<String> lines;
/**
* 总行数
*/
private long totalLines;
/**
* 当前页码
*/
private int currentPage;
/**
* 总页数
*/
private int totalPages;
/**
* 文件大小(字节)
*/
private long fileSize;
/**
* 最后修改时间
*/
private LocalDateTime lastModified;
}
5. 日志解析工具类
typescript
@Component
public class LogParser {
private static final Pattern LOG_PATTERN = Pattern.compile(
"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})\s+(\w+)\s+(.*)")
);
/**
* 解析日志行,提取时间和级别
*/
public LogLineInfo parseLine(String line) {
Matcher matcher = LOG_PATTERN.matcher(line);
if (matcher.find()) {
String timestamp = matcher.group(1);
String level = matcher.group(2);
String content = matcher.group(3);
LocalDateTime dateTime = parseTimestamp(timestamp);
return new LogLineInfo(dateTime, level, content, line);
}
// 如果不匹配标准格式,返回原始行
return new LogLineInfo(null, null, line, line);
}
private LocalDateTime parseTimestamp(String timestamp) {
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
return LocalDateTime.parse(timestamp, formatter);
} catch (Exception e) {
return null;
}
}
@Data
@AllArgsConstructor
public static class LogLineInfo {
private LocalDateTime timestamp;
private String level;
private String content;
private String originalLine;
public boolean matchesFilter(LogQueryRequest request) {
// 时间范围过滤
if (timestamp != null) {
if (request.getStartTime() != null && timestamp.isBefore(request.getStartTime())) {
return false;
}
if (request.getEndTime() != null && timestamp.isAfter(request.getEndTime())) {
return false;
}
}
// 日志级别过滤
if (StringUtils.isNotBlank(request.getLevel()) &&
!StringUtils.equalsIgnoreCase(level, request.getLevel())) {
return false;
}
// 关键字过滤
if (StringUtils.isNotBlank(request.getKeyword()) &&
!StringUtils.containsIgnoreCase(originalLine, request.getKeyword())) {
return false;
}
return true;
}
}
}
6. 核心服务类
scss
@Service
@Slf4j
public class LogService {
@Autowired
private LogConfig logConfig;
@Autowired
private LogParser logParser;
/**
* 获取日志文件列表
*/
public List<Map<String, Object>> getLogFiles() {
File logDir = new File(logConfig.getLogPath());
if (!logDir.exists() || !logDir.isDirectory()) {
return Collections.emptyList();
}
return Arrays.stream(logDir.listFiles())
.filter(this::isValidLogFile)
.map(this::fileToMap)
.sorted((a, b) -> ((Long)b.get("lastModified")).compareTo((Long)a.get("lastModified")))
.collect(Collectors.toList());
}
/**
* 查询日志内容
*/
public LogQueryResponse queryLogs(LogQueryRequest request) {
File logFile = getLogFile(request.getFileName());
validateFile(logFile);
try {
List<String> allLines = FileUtils.readLines(logFile, StandardCharsets.UTF_8);
// 过滤日志行
List<String> filteredLines = filterLines(allLines, request);
// 倒序处理
if (request.isReverse()) {
Collections.reverse(filteredLines);
}
// 分页处理
int totalLines = filteredLines.size();
int totalPages = (int) Math.ceil((double) totalLines / request.getPageSize());
int startIndex = (request.getPage() - 1) * request.getPageSize();
int endIndex = Math.min(startIndex + request.getPageSize(), totalLines);
List<String> pageLines = filteredLines.subList(startIndex, endIndex);
LogQueryResponse response = new LogQueryResponse();
response.setLines(pageLines);
response.setTotalLines(totalLines);
response.setCurrentPage(request.getPage());
response.setTotalPages(totalPages);
response.setFileSize(logFile.length());
response.setLastModified(
LocalDateTime.ofInstant(
Instant.ofEpochMilli(logFile.lastModified()),
ZoneId.systemDefault()
)
);
return response;
} catch (IOException e) {
log.error("读取日志文件失败: {}", logFile.getAbsolutePath(), e);
throw new RuntimeException("读取日志文件失败", e);
}
}
/**
* 下载日志文件
*/
public void downloadLog(String fileName, LogQueryRequest request, HttpServletResponse response) {
File logFile = getLogFile(fileName);
validateFile(logFile);
try {
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
if (hasFilter(request)) {
// 下载过滤后的内容
List<String> allLines = FileUtils.readLines(logFile, StandardCharsets.UTF_8);
List<String> filteredLines = filterLines(allLines, request);
try (PrintWriter writer = response.getWriter()) {
for (String line : filteredLines) {
writer.println(line);
}
}
} else {
// 下载原文件
response.setContentLengthLong(logFile.length());
try (InputStream inputStream = new FileInputStream(logFile);
OutputStream outputStream = response.getOutputStream()) {
IOUtils.copy(inputStream, outputStream);
}
}
} catch (IOException e) {
log.error("下载日志文件失败: {}", logFile.getAbsolutePath(), e);
throw new RuntimeException("下载日志文件失败", e);
}
}
private List<String> filterLines(List<String> lines, LogQueryRequest request) {
if (!hasFilter(request)) {
return lines;
}
return lines.stream()
.map(logParser::parseLine)
.filter(lineInfo -> lineInfo.matchesFilter(request))
.map(LogParser.LogLineInfo::getOriginalLine)
.collect(Collectors.toList());
}
private boolean hasFilter(LogQueryRequest request) {
return StringUtils.isNotBlank(request.getKeyword()) ||
StringUtils.isNotBlank(request.getLevel()) ||
request.getStartTime() != null ||
request.getEndTime() != null;
}
private File getLogFile(String fileName) {
// 安全检查:防止路径遍历攻击
if (logConfig.isEnableSecurity()) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\")) {
throw new IllegalArgumentException("非法的文件名");
}
}
return new File(logConfig.getLogPath(), fileName);
}
private void validateFile(File file) {
if (!file.exists()) {
throw new IllegalArgumentException("文件不存在");
}
if (!file.isFile()) {
throw new IllegalArgumentException("不是有效的文件");
}
if (!isValidLogFile(file)) {
throw new IllegalArgumentException("不支持的文件类型");
}
long fileSizeMB = file.length() / (1024 * 1024);
if (fileSizeMB > logConfig.getMaxFileSize()) {
throw new IllegalArgumentException(
String.format("文件过大,超过限制 %dMB", logConfig.getMaxFileSize())
);
}
}
private boolean isValidLogFile(File file) {
String fileName = file.getName().toLowerCase();
return logConfig.getAllowedExtensions().stream()
.anyMatch(fileName::endsWith);
}
private Map<String, Object> fileToMap(File file) {
Map<String, Object> map = new HashMap<>();
map.put("name", file.getName());
map.put("size", file.length());
map.put("lastModified", file.lastModified());
map.put("readable", file.canRead());
return map;
}
}
7. 日志文件实时监控
typescript
/**
* 日志实时监控服务
* 监控日志文件变化,实时推送新增日志内容
*/
@Slf4j
@Service
public class LogMonitorService implements InitializingBean, DisposableBean {
@Autowired
private LogConfig logConfig;
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private LogParser logParser;
// 文件监控服务
private WatchService watchService;
// 线程池
private ScheduledExecutorService executorService;
// 存储每个文件的读取位置
private final Map<String, Long> filePositions = new ConcurrentHashMap<>();
// 当前监控的文件
private volatile String currentMonitorFile;
// 监控状态
private volatile boolean monitoring = false;
@Override
public void afterPropertiesSet() {
try {
watchService = FileSystems.getDefault().newWatchService();
executorService = Executors.newScheduledThreadPool(2);
// 注册日志目录监控
Path logPath = Paths.get(logConfig.getLogPath());
if (Files.exists(logPath)) {
logPath.register(watchService,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_CREATE);
// 启动文件监控线程
executorService.submit(this::watchFiles);
log.info("日志文件监控服务已启动,监控目录: {}", logPath);
}
} catch (Exception e) {
log.error("初始化日志监控服务失败", e);
}
}
@Override
public void destroy() {
monitoring = false;
if (watchService != null) {
try {
watchService.close();
} catch (Exception e) {
log.error("关闭文件监控服务失败", e);
}
}
if (executorService != null) {
executorService.shutdown();
}
log.info("日志监控服务已关闭");
}
/**
* 开始监控指定文件
* @param fileName 文件名
*/
public void startMonitoring(String fileName) {
if (fileName == null || fileName.trim().isEmpty()) {
return;
}
currentMonitorFile = fileName;
monitoring = true;
// 初始化文件读取位置
File file = new File(logConfig.getLogPath(), fileName);
if (file.exists()) {
filePositions.put(fileName, file.length());
}
log.info("开始监控日志文件: {}", fileName);
// 发送监控开始消息
messagingTemplate.convertAndSend("/topic/log-monitor",
Map.of("type", "monitor_started", "fileName", fileName));
}
/**
* 停止监控
*/
public void stopMonitoring() {
monitoring = false;
currentMonitorFile = null;
log.info("停止日志文件监控");
// 发送监控停止消息
messagingTemplate.convertAndSend("/topic/log-monitor",
Map.of("type", "monitor_stopped"));
}
/**
* 文件监控线程
*/
private void watchFiles() {
while (!Thread.currentThread().isInterrupted()) {
try {
WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) {
continue;
}
@SuppressWarnings("unchecked")
WatchEvent<Path> ev = (WatchEvent<Path>) event;
Path fileName = ev.context();
if (monitoring && currentMonitorFile != null &&
fileName.toString().equals(currentMonitorFile)) {
// 延迟处理,避免文件正在写入
executorService.schedule(() -> processFileChange(currentMonitorFile),
100, TimeUnit.MILLISECONDS);
}
}
boolean valid = key.reset();
if (!valid) {
break;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("文件监控异常", e);
}
}
}
/**
* 处理文件变化
* @param fileName 文件名
*/
private void processFileChange(String fileName) {
try {
File file = new File(logConfig.getLogPath(), fileName);
if (!file.exists()) {
return;
}
long currentLength = file.length();
long lastPosition = filePositions.getOrDefault(fileName, 0L);
// 如果文件被截断(如日志轮转),重置位置
if (currentLength < lastPosition) {
lastPosition = 0L;
}
// 如果有新内容
if (currentLength > lastPosition) {
String newContent = readNewContent(file, lastPosition, currentLength);
if (newContent != null && !newContent.trim().isEmpty()) {
// 解析新日志行
String[] lines = newContent.split("\n");
for (String line : lines) {
if (!line.trim().isEmpty()) {
sendLogLine(fileName, line);
}
}
}
// 更新文件位置
filePositions.put(fileName, currentLength);
}
} catch (Exception e) {
log.error("处理文件变化失败: {}", fileName, e);
}
}
/**
* 读取文件新增内容
* @param file 文件
* @param startPosition 开始位置
* @param endPosition 结束位置
* @return 新增内容
*/
private String readNewContent(File file, long startPosition, long endPosition) {
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(startPosition);
long length = endPosition - startPosition;
if (length > 1024 * 1024) { // 限制单次读取大小为1MB
length = 1024 * 1024;
}
byte[] buffer = new byte[(int) length];
int bytesRead = raf.read(buffer);
if (bytesRead > 0) {
return new String(buffer, 0, bytesRead, "UTF-8");
}
} catch (Exception e) {
log.error("读取文件内容失败: {}", file.getName(), e);
}
return null;
}
/**
* 发送日志行到WebSocket客户端
* @param fileName 文件名
* @param logLine 日志行
*/
private void sendLogLine(String fileName, String logLine) {
try {
// 解析日志行
LogParser.LogLineInfo lineInfo = logParser.parseLine(logLine);
// 构建消息
Map<String, Object> message = Map.of(
"type", "new_log_line",
"fileName", fileName,
"content", logLine,
"timestamp", lineInfo.getTimestamp() != null ? lineInfo.getTimestamp().toString() : "",
"level", lineInfo.getLevel() != null ? lineInfo.getLevel() : "",
"rawContent", lineInfo.getContent() != null ? lineInfo.getContent() : logLine
);
// 发送到WebSocket客户端
messagingTemplate.convertAndSend("/topic/log-monitor", message);
} catch (Exception e) {
log.error("发送日志行失败", e);
}
}
/**
* 获取当前监控状态
* @return 监控状态信息
*/
public Map<String, Object> getMonitorStatus() {
return Map.of(
"monitoring", monitoring,
"currentFile", currentMonitorFile != null ? currentMonitorFile : "",
"monitoredFiles", filePositions.size()
);
}
}
8. 控制器
less
@RestController
@RequestMapping("/api/logs")
@Slf4j
public class LogController {
@Autowired
private LogService logService;
/**
* 获取日志文件列表
*/
@GetMapping("/files")
public ResponseEntity<List<Map<String, Object>>> getLogFiles() {
try {
List<Map<String, Object>> files = logService.getLogFiles();
return ResponseEntity.ok(files);
} catch (Exception e) {
log.error("获取日志文件列表失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* 查询日志内容
*/
@PostMapping("/query")
public ResponseEntity<LogQueryResponse> queryLogs(@Valid @RequestBody LogQueryRequest request) {
try {
LogQueryResponse response = logService.queryLogs(request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("查询参数错误: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("查询日志失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* 下载日志文件
*/
@GetMapping("/download/{fileName}")
public void downloadLog(
@PathVariable String fileName,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String level,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
HttpServletResponse response) {
try {
LogQueryRequest request = new LogQueryRequest();
request.setFileName(fileName);
request.setKeyword(keyword);
request.setLevel(level);
request.setStartTime(startTime);
request.setEndTime(endTime);
logService.downloadLog(fileName, request, response);
} catch (IllegalArgumentException e) {
log.warn("下载参数错误: {}", e.getMessage());
response.setStatus(HttpStatus.BAD_REQUEST.value());
} catch (Exception e) {
log.error("下载日志失败", e);
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
}
}
}
前端实现
1. HTML 结构 (static/index.html)
xml
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>日志查询系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
text-align: center;
}
.search-panel {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.form-group {
flex: 1;
min-width: 200px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.result-panel {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.result-header {
background: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.log-content {
max-height: 600px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
}
.log-line {
padding: 5px 15px;
border-bottom: 1px solid #f0f0f0;
white-space: pre-wrap;
word-break: break-all;
}
.log-line:hover {
background-color: #f8f9fa;
}
.log-line.error {
background-color: #fff5f5;
border-left: 3px solid #dc3545;
}
.log-line.warn {
background-color: #fffbf0;
border-left: 3px solid #ffc107;
}
.log-line.info {
background-color: #f0f8ff;
border-left: 3px solid #17a2b8;
}
.pagination {
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
border-top: 1px solid #dee2e6;
}
.pagination-info {
color: #666;
}
.pagination-controls {
display: flex;
gap: 10px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.highlight {
background-color: yellow;
font-weight: bold;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.result-header {
flex-direction: column;
gap: 10px;
}
.pagination {
flex-direction: column;
gap: 10px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📋 日志查询系统</h1>
<p>轻量级日志在线查询、分析、下载工具</p>
</div>
<div class="search-panel">
<div class="form-row">
<div class="form-group">
<label for="fileSelect">选择日志文件</label>
<select id="fileSelect">
<option value="">请选择日志文件...</option>
</select>
</div>
<div class="form-group">
<label for="keyword">关键字搜索</label>
<input type="text" id="keyword" placeholder="输入搜索关键字...">
</div>
<div class="form-group">
<label for="level">日志级别</label>
<select id="level">
<option value="">全部级别</option>
<option value="ERROR">ERROR</option>
<option value="WARN">WARN</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="startTime">开始时间</label>
<input type="datetime-local" id="startTime">
</div>
<div class="form-group">
<label for="endTime">结束时间</label>
<input type="datetime-local" id="endTime">
</div>
<div class="form-group">
<label for="pageSize">每页行数</label>
<select id="pageSize">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</div>
</div>
<div class="form-row">
<button class="btn btn-primary" onclick="searchLogs()">🔍 查询日志</button>
<button class="btn btn-success" onclick="downloadLogs()">📥 下载日志</button>
<label>
<input type="checkbox" id="reverse" checked> 倒序显示
</label>
</div>
</div>
<div class="result-panel" id="resultPanel" style="display: none;">
<div class="result-header">
<div class="result-info" id="resultInfo"></div>
<div class="result-actions">
<button class="btn btn-primary" onclick="refreshLogs()">🔄 刷新</button>
</div>
</div>
<div class="log-content" id="logContent"></div>
<div class="pagination" id="pagination"></div>
</div>
</div>
<script>
// 全局变量
let currentPage = 1;
let totalPages = 1;
let currentQuery = {};
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
loadLogFiles();
// 绑定回车键搜索
document.getElementById('keyword').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchLogs();
}
});
});
// 加载日志文件列表
async function loadLogFiles() {
try {
const response = await fetch('/api/logs/files');
const files = await response.json();
const select = document.getElementById('fileSelect');
select.innerHTML = '<option value="">请选择日志文件...</option>';
files.forEach(file => {
const option = document.createElement('option');
option.value = file.name;
option.textContent = `${file.name} (${formatFileSize(file.size)}, ${formatDate(file.lastModified)})`;
select.appendChild(option);
});
} catch (error) {
console.error('加载文件列表失败:', error);
showError('加载文件列表失败,请检查服务器连接');
}
}
// 搜索日志
async function searchLogs(page = 1) {
const fileName = document.getElementById('fileSelect').value;
if (!fileName) {
alert('请先选择日志文件');
return;
}
const query = {
fileName: fileName,
page: page,
pageSize: parseInt(document.getElementById('pageSize').value),
keyword: document.getElementById('keyword').value,
level: document.getElementById('level').value,
startTime: document.getElementById('startTime').value,
endTime: document.getElementById('endTime').value,
reverse: document.getElementById('reverse').checked
};
// 保存当前查询条件
currentQuery = query;
currentPage = page;
try {
showLoading();
const response = await fetch('/api/logs/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(query)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
displayLogs(result);
} catch (error) {
console.error('查询日志失败:', error);
showError('查询日志失败: ' + error.message);
}
}
// 显示日志内容
function displayLogs(result) {
const resultPanel = document.getElementById('resultPanel');
const resultInfo = document.getElementById('resultInfo');
const logContent = document.getElementById('logContent');
const pagination = document.getElementById('pagination');
// 显示结果面板
resultPanel.style.display = 'block';
// 更新结果信息
resultInfo.innerHTML = `
📄 文件: ${currentQuery.fileName} |
📊 总计: ${result.totalLines} 行 |
📦 大小: ${formatFileSize(result.fileSize)} |
🕒 修改时间: ${formatDateTime(result.lastModified)}
`;
// 显示日志内容
logContent.innerHTML = '';
if (result.lines && result.lines.length > 0) {
result.lines.forEach((line, index) => {
const lineDiv = document.createElement('div');
lineDiv.className = 'log-line ' + getLogLevel(line);
lineDiv.innerHTML = highlightKeyword(escapeHtml(line), currentQuery.keyword);
logContent.appendChild(lineDiv);
});
} else {
logContent.innerHTML = '<div class="loading">没有找到匹配的日志记录</div>';
}
// 更新分页信息
totalPages = result.totalPages;
updatePagination(result);
}
// 更新分页控件
function updatePagination(result) {
const pagination = document.getElementById('pagination');
const paginationInfo = `第 ${result.currentPage} 页,共 ${result.totalPages} 页`;
const prevDisabled = result.currentPage <= 1 ? 'disabled' : '';
const nextDisabled = result.currentPage >= result.totalPages ? 'disabled' : '';
pagination.innerHTML = `
<div class="pagination-info">${paginationInfo}</div>
<div class="pagination-controls">
<button class="btn btn-primary" onclick="searchLogs(1)" ${result.currentPage <= 1 ? 'disabled' : ''}>首页</button>
<button class="btn btn-primary" onclick="searchLogs(${result.currentPage - 1})" ${prevDisabled}>上一页</button>
<button class="btn btn-primary" onclick="searchLogs(${result.currentPage + 1})" ${nextDisabled}>下一页</button>
<button class="btn btn-primary" onclick="searchLogs(${result.totalPages})" ${result.currentPage >= result.totalPages ? 'disabled' : ''}>末页</button>
</div>
`;
}
// 下载日志
function downloadLogs() {
const fileName = document.getElementById('fileSelect').value;
if (!fileName) {
alert('请先选择日志文件');
return;
}
const params = new URLSearchParams();
const keyword = document.getElementById('keyword').value;
const level = document.getElementById('level').value;
const startTime = document.getElementById('startTime').value;
const endTime = document.getElementById('endTime').value;
if (keyword) params.append('keyword', keyword);
if (level) params.append('level', level);
if (startTime) params.append('startTime', startTime);
if (endTime) params.append('endTime', endTime);
const url = `/api/logs/download/${encodeURIComponent(fileName)}?${params.toString()}`;
// 创建隐藏的下载链接
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// 刷新日志
function refreshLogs() {
if (currentQuery.fileName) {
searchLogs(currentPage);
}
}
// 显示加载状态
function showLoading() {
const resultPanel = document.getElementById('resultPanel');
const logContent = document.getElementById('logContent');
resultPanel.style.display = 'block';
logContent.innerHTML = '<div class="loading">🔄 正在加载日志...</div>';
}
// 显示错误信息
function showError(message) {
const resultPanel = document.getElementById('resultPanel');
const logContent = document.getElementById('logContent');
resultPanel.style.display = 'block';
logContent.innerHTML = `<div class="error-message">❌ ${message}</div>`;
}
// 获取日志级别样式
function getLogLevel(line) {
if (line.includes('ERROR')) return 'error';
if (line.includes('WARN')) return 'warn';
if (line.includes('INFO')) return 'info';
return '';
}
// 高亮关键字
function highlightKeyword(text, keyword) {
if (!keyword) return text;
const regex = new RegExp(`(${escapeRegex(keyword)})`, 'gi');
return text.replace(regex, '<span class="highlight">$1</span>');
}
// 转义HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 转义正则表达式特殊字符
function escapeRegex(string) {
return string.replace(/[.*+?^${}()|[]\]/g, '\$&');
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 格式化日期
function formatDate(timestamp) {
return new Date(timestamp).toLocaleDateString('zh-CN');
}
// 格式化日期时间
function formatDateTime(dateTimeStr) {
return new Date(dateTimeStr).toLocaleString('zh-CN');
}
</script>
</body>
</html>
配置文件
application.yml
yaml
server:
port: 8080
servlet:
context-path: /
spring:
application:
name: log-viewer
web:
resources:
static-locations: classpath:/static/
# 日志查看器配置
log:
viewer:
log-path: ./logs # 日志文件目录
allowed-extensions:
- .log
- .txt
max-lines: 1000 # 单次查询最大行数
max-file-size: 100 # 文件最大大小(MB)
enable-security: true # 启用安全检查
# 日志配置
logging:
level:
com.example.logviewer: DEBUG
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"
file:
name: ./logs/app.log
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 30
部署与使用
1. 快速启动
perl
# 克隆项目
git clone <your-repo-url>
cd log-viewer
# 编译打包
mvn clean package -DskipTests
# 启动应用
java -jar target/log-viewer-1.0.0.jar
# 访问系统
open http://localhost:8080
2. Docker 部署
bash
FROM openjdk:11-jre-slim
VOLUME /app/logs
COPY target/log-viewer-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]
perl
# 构建镜像
docker build -t log-viewer .
# 运行容器
docker run -d \
--name log-viewer \
-p 8080:8080 \
-v /path/to/logs:/app/logs \
log-viewer
3. 生产环境配置
yaml
# application-prod.yml
log:
viewer:
log-path: /var/log/myapp
max-file-size: 500
enable-security: true
logging:
level:
root: WARN
com.example.logviewer: INFO
功能特性
已实现功能
- 📁 日志文件列表展示
- 🔍 关键字搜索
- 📅 时间范围过滤
- 🏷️ 日志级别筛选
- 📄 分页浏览
- 💾 文件下载(原文件/筛选结果)
- 🎨 语法高亮
- 📱 响应式设计
- 🔒 安全防护(路径遍历攻击防护)
- 📄 实时日志监控(WebSocket)
后续可扩展功能
- 日志统计分析
- 多文件合并查看
- 正则表达式搜索
- 用户权限管理
- 日志告警功能
性能优化建议
1. 大文件处理
arduino
// 使用 RandomAccessFile 实现大文件分页读取
public List<String> readFileByPage(File file, int page, int pageSize) {
List<String> lines = new ArrayList<>();
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
// 计算起始位置
long startPos = calculateStartPosition(raf, page, pageSize);
raf.seek(startPos);
// 读取指定行数
String line;
int count = 0;
while ((line = raf.readLine()) != null && count < pageSize) {
lines.add(new String(line.getBytes("ISO-8859-1"), "UTF-8"));
count++;
}
} catch (IOException e) {
log.error("读取文件失败", e);
}
return lines;
}
2. 异步处理
kotlin
@Async
public CompletableFuture<LogQueryResponse> queryLogsAsync(LogQueryRequest request) {
return CompletableFuture.supplyAsync(() -> {
return queryLogs(request);
});
}
总结
本文实现了一个功能完整的日志查询系统,具有以下特点:
1. 轻量级:无需复杂的部署,Spring Boot + 静态页面即可运行
2. 功能完整:支持搜索、过滤、分页、下载等核心功能
3. 用户友好:现代化的UI设计,响应式布局
4. 安全可靠:包含安全检查和错误处理
5. 易于扩展:模块化设计,便于添加新功能
这个系统特别适合中小型项目的日志管理需求,可以显著提升问题排查效率。
你可以根据实际需求进行定制和扩展,比如添加实时监控、统计分析等功能。