前言
作为一名资深后端开发,你有没有遇到过这样的场景:服务器上应用出问题了,你需要查看日志排查问题,但又不想或者不能直接登录服务器,只能麻烦运维同学帮忙查看?
今天就来聊聊如何用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" rel="external nofollow" >
</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包即可运行,无需额外依赖
- 功能丰富:支持日志高亮、搜索、清空等实用功能
- 扩展性好:可根据需要添加更多功能
掌握了这个技术,相信你再面对日志查看需求时会更加从容不迫,让你的开发效率更上一层楼!
以上就是使用springboot构建一个轻量级的日志查看器的详细内容,更多关于springboot轻量级日志查看器的资料请关注代码网其它相关文章!
发表评论