springboot推送实时日志到前端的两种方式
环境:
后端框架:springboot
前端框架:layui(没有太大影响)
通信方式:websocket
导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
第一种方式
说明:通过WebSocket的方法,来传输日志
前提:spring boot配置logback
配置WebSocketController
@ServerEndpoint(value = "/websocket/logging")
@Slf4j
@RestController
public class WebSocketController {
private static Session session;
/**
* 连接集合
*/
private static Map<String, Session> sessionMap = new ConcurrentHashMap<String, Session>();
private static Map<String, Integer> lengthMap = new ConcurrentHashMap<String, Integer>();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session) {
//添加到集合中
sessionMap.put(session.getId(), session);
lengthMap.put(session.getId(), 1);//默认从第一行开始
//获取日志信息
new Thread(() -> {
log.info("LoggingWebSocketServer 任务开始");
boolean first = true;
while (sessionMap.get(session.getId()) != null) {
BufferedReader reader = null;
try {
//日志文件路径,获取最新的
String filePath = "D:/mylogs/debug.log";
//字符流
reader = new BufferedReader(new FileReader(filePath));
Object[] lines = reader.lines().toArray();
//只取从上次之后产生的日志
Object[] copyOfRange = Arrays.copyOfRange(lines, lengthMap.get(session.getId()), lines.length);
//对日志进行着色,更加美观 PS:注意,这里要根据日志生成规则来操作
for (int i = 0; i < copyOfRange.length; i++) {
String line = (String) copyOfRange[i];
//先转义
line = line.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\"", """);
//处理等级
line = line.replace("DEBUG", "<span style='color: blue;'>DEBUG</span>");
line = line.replace("INFO", "<span style='color: green;'>INFO</span>");
line = line.replace("WARN", "<span style='color: orange;'>WARN</span>");
line = line.replace("ERROR", "<span style='color: red;'>ERROR</span>");
//处理类名
String[] split = line.split("]");
if (split.length >= 2) {
String[] split1 = split[1].split("-");
if (split1.length >= 2) {
line = split[0] + "]" + "<span style='color: #298a8a;'>" + split1[0] + "</span>" + "-" + split1[1];
}
}
copyOfRange[i] = line;
}
//存储最新一行开始
lengthMap.put(session.getId(), lines.length);
//第一次如果太大,截取最新的200行就够了,避免传输的数据太大
if(first && copyOfRange.length > 200){
copyOfRange = Arrays.copyOfRange(copyOfRange, copyOfRange.length - 200, copyOfRange.length);
first = false;
}
String result = StringUtils.join(copyOfRange, "<br/>");
//发送
send(session, result);
//休眠一秒
Thread.sleep(1000);
} catch (Exception e) {
//捕获但不处理
e.printStackTrace();
} finally {
try {
reader.close();
} catch (IOException ignored) {
}
}
}
log.info("LoggingWebSocketServer 任务结束");
}).start();
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(Session session) {
//从集合中删除
sessionMap.remove(session.getId());
lengthMap.remove(session.getId());
}
/**
* 发生错误时调用
*/
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
/**
* 服务器接收到客户端消息时调用的方法
*/
@OnMessage
public void onMessage(String message, Session session) {
}
/**
* 封装一个send方法,发送消息到前端
*/
private void send(Session session, String message) {
try {
session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
注入Bean
@Configuration
public class WebSocketConfig {
/**
* 服务器节点
* 如果使用独立的servlet容器,而不是直接使用springboot 的内置容器,就不要注入ServerEndPoint
* @return
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
前端引用
使用websocket的方法
<script th:inline="javascript">
//websocket对象
let websocket = null;
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
websocket = new WebSocket("ws://localhost:8088/websocket/logging");
} else {
console.error("不支持WebSocket");
}
//连接发生错误的回调方法
websocket.onerror = function (e) {
console.error("WebSocket连接发生错误");
};
//连接成功建立的回调方法
websocket.onopen = function () {
console.log("WebSocket连接成功")
};
//接收到消息的回调方法
websocket.onmessage = function (event) {
//追加
if (event.data) {
//日志内容
let $loggingText = $("#loggingText");
$loggingText.append(event.data);
//是否开启自动底部
if (window.loggingAutoBottom) {
//滚动条自动到最底部
$loggingText.scrollTop($loggingText[0].scrollHeight);
}
}
}
//连接关闭的回调方法
websocket.onclose = function () {
console.log("WebSocket连接关闭")
};
</script>
第二种方式
说明:通过配置logback.xml的filter,来直接获取输出到控制台的日志(这里对日志几级别不做处理,一般都是获取全部,然后展示时处理),然后获取的内容用一个实体类存,再将实体类放入队列,websocketconfig配置消息代理端点,即stomp服务端(spring boot自带的webSocket模块提供stomp的服务端),通过stomp来作为服务端去发送log。
首先在项目的日志配置文件配置filter(在你的appender中加入就好)
<!-- 控制台设置 -->
<appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${log.pattern}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="com.unismc.springbootudcap.powersecurity.util.LogFilter"></filter>(加入这个)
</appender>
<root level="info">
<appender-ref ref="consoleAppender"/>
<!--<appender-ref ref="STDOUT" />-->
</root>
还可以这样
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!--encoder 默认配置为PatternLayoutEncoder-->
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
<filter class="com.xxxx.LogFilter"></filter>
</appender>
日志过滤器都一致
@Service
public class LogFilter extends Filter<ILoggingEvent> {
//获取logback的日志,塞入日志队列中
@Override
public FilterReply decide(ILoggingEvent event) {
String exception = "";
IThrowableProxy iThrowableProxy1 = event.getThrowableProxy();
if(iThrowableProxy1!=null){
exception = "<span class='excehtext'>"+iThrowableProxy1.getClassName()+" "+iThrowableProxy1.getMessage()+"</span></br>";
for(int i=0; i<iThrowableProxy1.getStackTraceElementProxyArray().length;i++){
exception += "<span class='excetext'>"+iThrowableProxy1.getStackTraceElementProxyArray()[i].toString()+"</span></br>";
}
}
LoggerMessage loggerMessage = new LoggerMessage(
event.getMessage()
, DateFormat.getDateTimeInstance().format(new Date(event.getTimeStamp())),
event.getThreadName(),
event.getLoggerName(),
event.getLevel().levelStr,
exception,
""
);
LoggerQueue.getInstance().push(loggerMessage);
return FilterReply.ACCEPT;
}
}
创建一个阻塞队列,作为日志系统输出的日志的一个临时载体,同样一致
public class LoggerQueue {
//队列大小
public static final int QUEUE_MAX_SIZE = 10000;
private static LoggerQueue alarmMessageQueue = new LoggerQueue();
//阻塞队列
private BlockingQueue blockingQueue = new LinkedBlockingQueue<>(QUEUE_MAX_SIZE);
private LoggerQueue() {
}
public static LoggerQueue getInstance() {
return alarmMessageQueue;
}
/**
* @Description: 消息入队
* @Return: boolean
* @Author: leijun
* @Date: 2019/11/26
**/
public boolean push(LoggerMessage log) {
//System.out.println("消息入队的信息===="+log);
return this.blockingQueue.add(log);//队列满了就抛出异常,不阻塞
}
/**
* @Description: 消息出队
* @Return: com.unismc.springbootudcap.powersecurity.entity.LoggerMessage
* @Author: leijun
* @Date: 2019/11/26
**/
public LoggerMessage poll() {
LoggerMessage result = null;
try {
//System.out.println("输出:"+this.blockingQueue);
result = (LoggerMessage) this.blockingQueue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
return result;
}
}
然后是日志实体
public class LoggerMessage {
private String body;
private String timestamp;
private String threadName;
private String className;
private String level;
private String exception;
private String cause;
}
重要的是WebSocketConfig
@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Autowired
private SimpMessagingTemplate messagingTemplate;
//配置WebSocket消息代理端点,即stomp服务端;spring boot自带的webSocket模块提供stomp的服务端
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket")
.setAllowedOrigins("*")
.withSockJS();
}
/**
* 推送日志到/topic/pullLogger
*/
@PostConstruct
public void pushLogger(){
ExecutorService executorService= Executors.newFixedThreadPool(2);
Runnable runnable=new Runnable() {
@Override
public void run() {
while (true) {
try {
LoggerMessage log = LoggerQueue.getInstance().poll();
if(log!=null){
if(messagingTemplate!=null)
//服务端发送
messagingTemplate.convertAndSend("/topic/pullLogger",log);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
};
executorService.submit(runnable);
}
}
需要主义的是:在对应的类上面加入@EnableWebSocketMessageBroker注解,如果不加该注解在运行程序的时候SimpMessagingTemplate类将抛出空指针异常,无法注入。
给一个后端程序猿一个简单的模板,之前看前面一些博主的文章到了前端自己做起来还是有些小问题
<!DOCTYPE>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="js/jquery-v1.12.4.min.js"></script>
</head>
<body>
<!-- 标题 -->
<h1 style="text-align: center;">实时日志</h1>
<!-- 显示区 -->
<div id="log-container" contenteditable="true"
style="width:100%;height: 500px;background-color: ghostwhite; overflow: auto;"></div>
<!-- 操作栏 -->
<div style="text-align: center;">
<button onclick="openSocket()" style="color: black; height: 35px;">连接</button>
<button onclick="closeSocket()" style="color: black; height: 35px;">关闭</button>
<button onclick="$('#log-container').text('')" style="color: green; height: 35px;">清屏</button>
</div>
<!--开启并使用SockJS后,它会优先选用Websocket协议作为传输协议-->
<script type="application/javascript" src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script>
<script type="application/javascript" src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
<script type="text/javascript">
var stompClient = null;
function openSocket() {
if (stompClient == null) {
if($("#log-container").find("span").length==0){
$("#log-container").append("<div style='color: #18d035;font-size: 14px'>通道连接成功,静默等待....</div>");
}
var socket = new SockJS('http://localhost:8088/websocket?token=kl');
stompClient = Stomp.over(socket);
stompClient.connect({token: "kl"}, function (frame) {
//订阅/topic/pullLogger的消息
stompClient.subscribe('/topic/pullLogger', function (event) {
var content = JSON.parse(event.body);
var leverhtml = '';
var className = '<span class="classnametext">' + content.className + '</span>';
switch (content.level) {
case 'INFO':
leverhtml = "<span style='color: #90ad2b'>" +content.level + "</span>";
break;
case 'DEBUG':
leverhtml = "<span style='color: #A8C023'>" +content.level + "</span>";
break;
case 'WARN':
leverhtml = "<span style='color: #fffa1c'>" +content.level + "</span>";
break;
case 'ERROR':
leverhtml = "<span style='color: #e3270e'>" +content.level + "</span>";
break;
}
$("#log-container").append("<p class='logp'>" + content.timestamp + " " + leverhtml + " --- [" + content.threadName + "] " + className + " :" + content.body + "</p>");
if (content.exception != "") {
$("#log-container").append("<p class='logp'>" + content.exception + "</p>");
}
if (content.cause != "") {
$("#log-container").append("<p class='logp'>" + content.cause + "</p>");
}
//自适应高度
//$("#log-container").scrollTop($("#log-container").height() - $("#log-container").height());
}, {
token: "kltoen"
});
});
}
}
function closeSocket() {
if (stompClient != null) {
stompClient.disconnect();
stompClient = null;
}
}
</script>
</body>
</html>
页面就不展示了
参考地址:
stomp.js客户端:http://jmesnil.net/stomp-websocket/doc/
scok.js客户端:https://github.com/sockjs/sockjs-client
spring webSocket:https://docs.spring.io/spring/docs/