查看日誌的三種命令分享「linux查看實時日誌命令」

前言

最近在做一個小工具,有個需求是在Web端能實時查看日誌文件,也就是相當於在終端執行tail -f命令,對此沒有找到好的解決方式,一開始想得直接通過FileInputStream來讀取,因為他也能直接跳過n個位元組來讀取,就像下面這樣。

public static void main(String[] args) throws Exception {
    File file = new File("/home/1.txt");
    FileInputStream fin = new FileInputStream(file);

    int ch;
    fin.skip(10);
    while ((ch = fin.read()) != -1){
      System.out.print((char) ch);
    }
  }

如果不跳過的話,那麼每次讀取全部內容並展示顯然不現實,我們要做的是像tail一樣,每次從後n行開始讀取,並且會持續輸出最新的行。

還有一個問題就是對文件的變化要能感知到,所以最後選擇直接調用tail命令,並且通過WebSocket輸出到網頁上。

tail用法

在java中調用tail命令後,拿到它的輸入流並且包裝成BufferedReader,如果通過readLine()讀取不到數據,那麼他會一直阻塞,並不會返回null,這也就代表日誌文件中暫時還沒有新數據寫入,一旦readLine()方法返回,那麼就代表有新數據到達了。另外一個問題就是如何終止,我們不可能讓他一直讀取,要在一個合適的時間終止,答案就是在WebSocket斷開連接時,並且Process類提供了destroy()方法用來終止這個進程,相當於按下了Ctrl+C

 public static void main(String[] args) throws Exception {
     Process exec = Runtime.getRuntime().exec(new String[]{"bash", "-c", "tail -F /home/HouXinLin/test.txt"});
     InputStream inputStream = exec.getInputStream();
     BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
     for (;;){
         System.out.println(bufferedReader.readLine()+"r");
     }
 }

實現過程

在Spring Boot中加入WebSocket功能有很多方式,目前感覺普遍的文章都是介紹以ServerEndpointExporter、@OnOpen、 @OnClose、@OnMessage這種方式來實現的,這種方式需要聲明一個Bean,也就是ServerEndpointExporter,但是我記得如果要打包成war放入Tomcat中運行時,還需要把這個Bean取消掉,否則還會報錯,非常的麻煩,當然也有辦法解決。

還有其他集成的辦法,比如實現WebSocketConfigurer或者
WebSocketMessageBrokerConfigurer接口,而我目前採用的是實現WebSocketMessageBrokerConfigurer接口,並且前端還需要兩個庫,SockJS和Stomp(更具選擇,也可以不使用)。

SockJS提供類似於WebSocket的對象,還有一套跨瀏覽器的API,可以在瀏覽器和Web服務器之間創建了低延遲,全雙工,跨域的通信通道,如果瀏覽器不支持 WebSocket,它還可以模擬對WebSocket的支持。

Stomp即Simple Text Orientated Messaging Protocol,簡單(流)文本定向消息協議,它提供了一個可互操作的連接格式,允許STOMP客戶端與任意STOMP消息代理(Broker)進行交互。

首先看一下連接處理層的邏輯,其中一部分非必要的代碼就不展示了。


@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    private static final Logger log = LoggerFactory.getLogger(WebSocketConfig.class.getName());
    @Autowired
    SimpMessagingTemplate mSimpMessagingTemplate;

    @Autowired
    WebSocketManager mWebSocketManager;

    @Autowired
    TailLog mTailLog;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic/path");
    }

    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
        registration.addDecoratorFactory(new WebSocketHandlerDecoratorFactory() {
            @Override
            public WebSocketHandler decorate(WebSocketHandler webSocketHandler) {
                return new WebSocketHandlerDecorator(webSocketHandler) {
                    @Override
                    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
                        log.info("日誌監控WebSocket連接,sessionId={}", session.getId());
                        mWebSocketManager.add(session);
                        super.afterConnectionEstablished(session);
                    }

                    @Override
                    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
                        mWebSocketManager.remove(session.getId());
                        super.afterConnectionClosed(session, closeStatus);
                    }
                };
            }
        });

    }


    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/socket-log")
                .addInterceptors(new HttpHandshakeInterceptor())
                .setHandshakeHandler(new DefaultHandshakeHandler() {
                    @Override
                    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
                        return new StompPrincipal(UUID.randomUUID().toString());
                    }
                })
                .withSockJS();

    }

    @EventListener
    public void handlerSessionCloseEvent(SessionDisconnectEvent sessionDisconnectEvent) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(sessionDisconnectEvent.getMessage());
        mTailLog.stopMonitor(headerAccessor.getSessionId());
    }

    /**
     * 路徑訂閱
     *
     * @param sessionSubscribeEvent
     */
    @EventListener
    public void handlerSessionSubscribeEvent(SessionSubscribeEvent sessionSubscribeEvent) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(sessionSubscribeEvent.getMessage());

        if (mTailLog.isArriveMaxLog()) {
            mWebSocketManager.sendMessage(headerAccessor.getSessionId(), "監控數量已經達到限制,無法查看"");
            log.info("日誌監控WebSocket連接已經到達最大數量,將斷開sessionId={}", headerAccessor.getSessionId());
            mWebSocketManager.close(headerAccessor.getSessionId());
            return;
        }
        String destination = headerAccessor.getDestination();
        String userId = headerAccessor.getUser().getName();

        if (destination.startsWith("/user/topic/path")) {
            String path = destination.substring("/user/topic/path".length());
            File file = new File(StringUtils.urlDecoder(path));
            if (!file.exists()) {
                mWebSocketManager.sendMessage(headerAccessor.getSessionId(), "what are you 弄啥嘞,文件找不到啊");
                mWebSocketManager.close(headerAccessor.getSessionId());
                return;
            }
            TailLogListenerImpl tailLogListener = new TailLogListenerImpl(mSimpMessagingTemplate, userId);
            mTailLog.addMonitor(new LogMonitorObject(file.getName(), file.getParent(),
                    tailLogListener, "" + headerAccessor.getSessionId(), userId));
        }
    }

}

對於上面的幾個接口可能沒使用過他的人有點蒙,至少我在學習他的時候是這樣的,看上面的代碼,我們先要理清邏輯,才能明白為什麼要這樣寫。

實現registerStompEndpoints方法

首先是
WebSocketMessageBrokerConfigurer接口,Spring Boot提供的一個WebSocket配置接口,只需要簡簡單單地配置兩下,就可以實現一個WebSocket程序,這個接口中有8個方法,而我們只需要用到三個個。

然後就是給出前端連接WebSocket所需要的地址,如果連連接地址都不給,後面步驟怎麼繼續?這個就是通過實現registerStompEndpoints方法來完成,只需要向StompEndpointRegistry中通過addEndpoint添加一個新的”連接點”就可以,還可以設置攔截器,也就是在前端試圖連接的時候,如果後端發現這個連接不對勁,有貓膩,可以拒絕和他連接,這步可以通過addInterceptors來完成。

切記如果使用了SocketJs庫,那麼一定要加入withSockJS。

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/log")
            .addInterceptors(new HttpHandshakeInterceptor())
            .setHandshakeHandler(new DefaultHandshakeHandler() {
                @Override
                protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
                    return new StompPrincipal(UUID.randomUUID().toString());
                }
            })
            .withSockJS();
}

保存SessionId和WebSocketSession對應關係

這一步是為了方便管理,比如主動斷開連接,需要實現
configureWebSocketTransport接口,但是這裡的SessionId並不是服務端生成的會話ID,而是這個WebSocket的會話ID,每個WebSocket連接都是不同的。

這裡主要考慮到如果前端傳過來的文件不存在,那麼服務端要能主動斷開連接。

@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
    registration.addDecoratorFactory(new WebSocketHandlerDecoratorFactory() {
        @Override
        public WebSocketHandler decorate(WebSocketHandler webSocketHandler) {
            return new WebSocketHandlerDecorator(webSocketHandler) {
                @Override
                public void afterConnectionEstablished(WebSocketSession session) throws Exception {
                    log.info("日誌監控WebSocket連接,sessionId={}", session.getId());
                    mWebSocketManager.add(session);
                    super.afterConnectionEstablished(session);
                }
                @Override
                public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
                    mWebSocketManager.remove(session.getId());
                    super.afterConnectionClosed(session, closeStatus);
                }
            };
        }
    });
}

監聽訂閱

接着前端通過Stomp的API來訂閱一個消息,那麼我們怎麼接收訂閱的事件呢?就是通過 @EventListener註解來接收SessionSubscribeEvent事件。

而前端訂閱時就需要傳入要監控的日誌路徑。這時候我們就能拿到這個WebSocket要監聽的日誌路徑了。

@EventListener
public void handlerSessionSubscribeEvent(SessionSubscribeEvent sessionSubscribeEvent) {
    ....
}

開啟tail進程

接着我們要為每個WebSocket都開啟一個線程,用來執行tail命令。

@Component
public class TailLog {
    public static final int MAX_LOG = 3;

    private List<LogMonitorExecute> mLogMonitorExecutes = new CopyOnWriteArrayList<>();

    /**
     * Log線程池
     */
    private ExecutorService mExecutors = Executors.newFixedThreadPool(MAX_LOG);
    public void addMonitor(LogMonitorObject object) {
        LogMonitorExecute logMonitorExecute = new LogMonitorExecute(object);
        mExecutors.execute(logMonitorExecute);
        mLogMonitorExecutes.add(logMonitorExecute);
    }

    public void stopMonitor(String sessionId) {
        if (sessionId == null) {
            return;
        }
        for (LogMonitorExecute logMonitorExecute : mLogMonitorExecutes) {
            if (sessionId.equals(logMonitorExecute.getLogMonitorObject().getSessionId())) {
                logMonitorExecute.stop();
                mLogMonitorExecutes.remove(logMonitorExecute);
            }
        }
    }

    public boolean isArriveMaxLog() {
        return mLogMonitorExecutes.size() == MAX_LOG;
    }
}

最終執行者,其中的stop()方法是在WebSocket斷開連接時執行的。那麼需要事先保存好sessionId和LogMonitorExecute的對應關係。當文件有新變化時,發送給對應的WebSocket。


public class LogMonitorExecute implements Runnable {
    private static final Logger log = LoggerFactory.getLogger(LogMonitorExecute.class.getName());

    /**
     * 監控的對象
     */
    private LogMonitorObject mLogMonitorObject;
    private volatile boolean isStop = false;

    /**
     * tail 進程對象
     */
    private Process mProcess;


    public LogMonitorExecute(LogMonitorObject logMonitorObject) {
        mLogMonitorObject = logMonitorObject;
    }

    public LogMonitorObject getLogMonitorObject() {
        return mLogMonitorObject;
    }

    @Override
    public void run() {
        try {
            String path = Paths.get(mLogMonitorObject.getPath(), mLogMonitorObject.getName()).toString();
            log.info("{}對{}開始進行日誌監控", mLogMonitorObject.getSessionId(), path);
            mProcess = Runtime.getRuntime().exec(new String[]{"bash", "-c", "tail -f " + path});
            InputStream inputStream = mProcess.getInputStream();
            BufferedReader mBufferedReader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
            String buffer = null;
            while (!Thread.currentThread().isInterrupted() && !isStop) {
                buffer = mBufferedReader.readLine();
                if (mLogMonitorObject.getTailLogListener() != null) {
                    mLogMonitorObject.getTailLogListener().onNewLine(mLogMonitorObject.getName(), mLogMonitorObject.getPath(), buffer);
                    continue;
                }
                break;
            }
            mBufferedReader.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        log.info("{}退出對{}的監控", mLogMonitorObject.getSessionId(), mLogMonitorObject.getPath() + "/" + mLogMonitorObject.getName());
    }

    public void stop() {
        mProcess.destroy();
        isStop = true;
    }
}

注意這裡,要發送給指定的WebSocket,而不是訂閱了這個路徑的WebSocket,因為使用SimpMessagingTemplate在發送數據時,他可以給所有訂閱了此路徑的WebSocket,那麼就導致如果一個瀏覽器開了2個監控,而且監控的都是同一個日誌文件,那麼每個監控都會收到兩條同樣的消息。

所以要使用convertAndSendToUser方法而不是convertAndSend,這也就是為什麼前面會通過setHandshakeHandler設置握手處理器為每個WebSocket連接取一個name的原因。

前端

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>日誌監控</title>
    <style>
        body {
            background: #000000;
            color: #ffffff;
        }

        .log-list {
            color: #ffffff;
            font-size: 13px;
            padding: 25px;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="log-list">
    </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="/lib/stomp/stomp.min.js"></script>
<script src="https://lib.sinaapp.com/js/jquery/2.0.2/jquery-2.0.2.min.js"></script>
<script>
    var socket = new SockJS('/socket-log?a=a');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        stompClient.subscribe('/user/topic/path'+getQueryVariable("path"), function (greeting) {
            console.log("a" + greeting)
            let item = $("<div class='log-line'></div>");
            item.text(greeting.body)
            $(".log-list").append(item);
            $("html, body").animate({scrollTop: $(document).height()}, 0);
        });
    });

    function getQueryVariable(variable) {
        var query = window.location.search.substring(1);
        var vars = query.split("&");
        for (var i = 0; i < vars.length; i++) {
            var pair = vars[i].split("=");
            if (pair[0] == variable) {
                return encodeURIComponent(pair[1]);
            }
        }
        return (false);
    }
</script>
</body>
</html>

效果

下面是啟動、關閉Tomcat的日誌。

避坑指南:Web端如何做到日誌文件實時查看,該踩的坑我都踩了

不通過SimpMessagingTemplate如何發送數據

如果不使用SimpMessagingTemplate,那麼首先我們要拿到對應的WebSocketSession,它有個sendMessage方法用來發送數據,但是類型是WebSocketMessage,Spring Boot有幾個默認的實現,比如TextMessage用來發送文本信息。

但是如果使用了Stomp,那麼單純地使用他發送是不行的,數據雖然能過去,但是格式不對,Stomp解析不了,所以我們要按照Stomp的格式發送。

但是經過查找,未能找到相關的資料,所以自己看了一下他的源碼,其中設計到了StompEncoder這個類,看名字就知道他是Stomp編碼的工具。Stomp協議分為三個部分,命令、頭、消息體,命令有如下幾個:

CONNECT
SEND
SUBSCRIBE
UNSUBSCRIBE
BEGIN
COMMIT
ABORT
ACK
NACK
DISCONNECT

緊跟着命令下一行是頭,是鍵值對形式存在的,最後是消息體,末尾以空字符結尾。

下面是發送的必要格式,否則StompEncoder也無法編碼,將拋出異常,至於這個為什麼這麼寫,詳細就得看
StompEncoderde.writeHeaders方法了,裏面有幾個驗證,這種寫完全是被他逼的。

 StompEncoder stompEncoder = new StompEncoder();
 byte[] encode = stompEncoder.encode(createStompMessageHeader(),msg.getBytes());
 webSocketSession.sendMessage(new TextMessage(encode));
 
 private HashMap<String, Object> createStompMessageHeader() {
     HashMap<String, Object> hashMap = new HashMap<>();
     hashMap.put("subscription", createList("sub-0"));
     hashMap.put("content-type", createList("text/plain"));
     HashMap<String, Object> stringObjectHashMap = new HashMap<>();
     stringObjectHashMap.put("simpMessageType", SimpMessageType.MESSAGE);
     stringObjectHashMap.put("stompCommand", StompCommand.MESSAGE);
     stringObjectHashMap.put("subscription", "sub-0");
     stringObjectHashMap.put("nativeHeaders", hashMap);
     return stringObjectHashMap;
}
 private List<String> createList(String value) {
    List<String> list = new ArrayList<>();
    list.add(value);
    return list;
}

tail -f 為什麼會失效

這是偶爾間的一個發現,當執行tail -f命令後,我們通過vim、gedit等工具編輯並保存這個文件,會發現tail -f並不會輸出新的行,反而通過echo test>>xx.txt是正常的。

那這裡的蹊蹺又在哪?

其實,tail -f不管在文件移動、改名都會進行追蹤,因為他跟蹤的是文件描述符,引入維基百科的一句話:

文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞着文件描述符展開。但是文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。

tail -f執行後會產生一個進程,可以在/proc/pid/fd路徑下查看他所打開的文件描述符,下面來看一個GIF。

在這個操作中,首先在終端1中創建一個1.txt,然後進行tail -f跟蹤,接着在終端2中追加一行數據,可以看到終端1中是可以打印出來的。

避坑指南:Web端如何做到日誌文件實時查看,該踩的坑我都踩了

然後再看神奇的一幕,在終端2進行mv改名,接着向被改名後的文件追加新的一行,你會發現,終端1居然還是會打印的。

避坑指南:Web端如何做到日誌文件實時查看,該踩的坑我都踩了

如果查看一下這個進程的文件描述符,就不為奇了,在下面的命令中,顯示了3號描述符追蹤的是
/home/HouXinLin/test/tail/2.txt。

hxl@hxl-PC:/home/HouXinLin/test/tail$ ps -ef |grep 1.txt
hxl       1368 29021  0 09:02 pts/0    00:00:00 grep 1.txt
hxl      20298 29672  0 09:00 pts/6    00:00:00 tail -f 1.txt
hxl@hxl-PC:/home/HouXinLin/test/tail$ ls -l /proc/20298/fd
總用量 0
lrwx------ 1 hxl hxl 64 3月  16 09:02 0 -> /dev/pts/6
lrwx------ 1 hxl hxl 64 3月  16 09:02 1 -> /dev/pts/6
lrwx------ 1 hxl hxl 64 3月  16 09:02 2 -> /dev/pts/6
lr-x------ 1 hxl hxl 64 3月  16 09:02 3 -> /home/HouXinLin/test/tail/2.txt
lr-x------ 1 hxl hxl 64 3月  16 09:02 4 -> anon_inode:inotify
hxl@hxl-PC:/home/HouXinLin/test/tail$ 

但是如果我們通過vim、等工具編輯這個文件後,那麼這個文件描述符中會被記錄為被刪除,即使這個文件確實是存在的,此時在向2.txt文件中追加就會失效。

hxl@hxl-PC:/home/HouXinLin/test/tail$ vim 2.txt 
hxl@hxl-PC:/home/HouXinLin/test/tail$ ls -l /proc/20298/fd
總用量 0
lrwx------ 1 hxl hxl 64 3月  16 09:02 0 -> /dev/pts/6
lrwx------ 1 hxl hxl 64 3月  16 09:02 1 -> /dev/pts/6
lrwx------ 1 hxl hxl 64 3月  16 09:02 2 -> /dev/pts/6
lr-x------ 1 hxl hxl 64 3月  16 09:02 3 -> /home/HouXinLin/test/tail/2.txt~ (deleted)
lr-x------ 1 hxl hxl 64 3月  16 09:02 4 -> anon_inode:inotify
hxl@hxl-PC:/home/HouXinLin/test/tail$ 

原創文章,作者:投稿專員,如若轉載,請註明出處:https://www.506064.com/zh-hk/n/268344.html

(0)
打賞 微信掃一掃 微信掃一掃 支付寶掃一掃 支付寶掃一掃
投稿專員的頭像投稿專員
上一篇 2024-12-16 13:09
下一篇 2024-12-16 13:09

相關推薦

發表回復

登錄後才能評論