前言
最近需要做一个新功能,要求在浏览器可以看到服务器上的日志文件的内容,并且实时显示,也就是相当于要在浏览器实现Linux下的tail -f 的功能。
最开始的思路是使用Ajax定时向后端请求数据并进行展示,但是这样做效率不高,而且请求过于频繁,这个方案就被否决掉了;因此就想到了需要一个全双工的通信方式,后端可以直接向前端发送请求,那么采用WebSocket就是再合适不过的方案了。
关于WebSocket如何实现全双工通信的原理在此就不进行赘述,如果不太了解可以自行学习。
本文将采用Spring WebSocket 实现Linux 上tail -f 的效果,在浏览器端进行日志实时显示的功能。
准备
首先需要引入Spring WebSocket的支持
<!-- spring websocket-->
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>1.1</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>4.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
<version>4.2.5.RELEASE</version>
</dependency>
前端同样需要WebSocket的支持,在这里我采用的是sock.js和stomp.js的支持,可以在下面的网址下载对应的JS文件,也可以直接引入:
同时也需要引入Spring的支持,在这里就不具体给出了,可以根据自己项目所用的Spring版本进行选择。
实现
WebSocket配置:
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/gs-guide-websocket").setAllowedOrigins("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setMessageSizeLimit(128 * 1024);
}
}
这里需要进行一下WebSocket的配置,
registerStompEndpoints方法设置一个端点的配置,用于握手连接使用;
configureMessageBroker方法设置广播的前缀地址与订阅的前缀地址信息;
configureWebSocketTransport方法设置消息最大大小。
Controller:
@Controller
@RequestMapping(value = "/websocketDemo")
public class GreetingController {
@Autowired
private WebSocketService webSocketService;
@RequestMapping(value = "/greeting.do", method = RequestMethod.POST)
public ModelAndView greeting(HelloMessage message) throws Exception {
webSocketService.sendMessage();
ModelAndView model = new ModelAndView();
model.setViewName("/demo/webSocketDemo.jsp");
return model;
}
}
Service:
@Service
public class WebSocketService {
@Autowired
private SimpMessageSendingOperations simpMessageSendingOperations;
public void sendMessage() {
// 通过Process和Runtime执行linux命令
String cmd = "tail -f /var/test.log";
try {
Process process = Runtime.getRuntime().exec(cmd);
//需要另外启动线程进行读取,防止输入流阻塞当前线程
Thread inputThread = new Thread(new Runnable() {
@Override
public void run() {
try {
BufferedReader br = new BufferedReader(
new InputStreamReader(process.getInputStream(), "UTF-8"));
StringBuffer line = new StringBuffer();
String lineOne = null;
int count = 0;
int lineNum = 1;
while ((lineOne = br.readLine()) != null) {
if (count == 1000) {
simpMessageSendingOperations.convertAndSend("/topic/greeting", line.toString());
line = new StringBuffer();
count = 0;
//控制线程执行速度 防止推送过快 导致浏览器卡屏
Thread.sleep(1000);
} else {
line.append(lineOne + "</br>");
count++;
lineNum++;
}
}
simpMessageSendingOperations.convertAndSend("/topic/greeting", line.toString());
br.close();
} catch (Exception e) {
e.printStackTrace();
}
}
});
inputThread.start();
//主线程读取错误输出流数据
BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream(), "UTF-8"));
StringBuffer line = new StringBuffer();
String lineOne = null;
int count = 0;
int lineNum = 1;
while ((lineOne = br.readLine()) != null) {
if (count == 100) {
this.simpMessageSendingOperations.convertAndSend("/topic/greeting", line.toString());
line = new StringBuffer();
count = 0;
} else {
line.append(lineOne + "</br>");
count++;
lineNum++;
}
}
this.simpMessageSendingOperations.convertAndSend("/topic/greeting", line.toString());
//等待正常输出流线程读取完成后,统一销毁进程
inputThread.join();
// 返回码 0 表示正常退出 1表示异常退出
int extValue = process.waitFor();
if (0 == extValue) {
logger.info("Exit Success!");
br.close();
process.destroy();
} else {
logger.info("Exit failure!");
br.close();
process.destroy();
}
} catch (Exception e) {
e.printStackTrace();
logger.error("execute shell fail!", e);
logger.info("Exit failure!");
}
}
}
这里使用了Spring的SimpMessageSendingOperations操作类,这个类来自于Spring对Websocket的支持,可以通过此类直接发送请求。
由于tail -f命令的输入流会阻塞当前线程,所以一定要创建一个新的线程来读取tail -f命令的返回结果。
前端页面展示:
<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<script src="/webjars/jquery/jquery.min.js"></script>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<script src="/app.js"></script>
</head>
<body>
<div class="conWrap">
<div id="log-container" style="height: 450px; overflow-y: scroll; background: #333; color: #aaa; padding: 10px;">
<div></div>
</div>
</div>
</body>
</html>
app.js:
var stompClient = null;
function connectWebSocket() {
var socket = new SockJS('/gs-guide-websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/greeting', function (greeting) {
showGreeting(greeting.body);
});
});
}
function disconnectWebSocket() {
if (stompClient !== null) {
stompClient.disconnect();
}
console.log("Disconnected");
alert("Disconnected success");
}
function showGreeting(message) {
$("#log-container div").append(message);
// 滚动条滚动到最低部
$("#log-container").scrollTop($("#log-container div").height() - $("#log-container").height());
}
在JS中需要订阅后端的地址:/topic/greeting,这样后端发送数据到该地址时,JS可以接收到数据。
需要注意的点
在本人进行调试的过程中,遇见两点问题,值得注意
1、当第一次进行WebSocket握手时,出现握手失败的问题,这个问题最后发现原因是因为拦截器导致的,WebSocket请求会包装成一个HTTP发送至服务器,如果拦截器没有拦截到这个请求,就会导致握手失败,因此建议在web.xml中将filter配置成:
<servlet>
<servlet-name>springMvcServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<!-- 加载spring 核心配置文件 -->
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring/spring-mvc.xml</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>springMvcServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
同时需要注意静态资源被拦截的问题,需要在SpringMVC中进行对应的配置
2、读取文件流速的问题,在调试过程中,遇见文件中文本写入速度很快,读取速度也很快,导致了想浏览器发送速度过快,浏览器卡屏的情况,这里建议根据实际情况,设置好流速,可以加大每多少条数据一输出,也可以让线程sleep以达到控制流速的目的。