Log4j 結合釘釘打造日誌機器人

在日常的開發中,找問題時,看日誌常常是不可或缺的一件事件。對於錯誤日誌,咱們更是但願可以立馬悉知,迅速對錯誤追本溯源,而後對錯誤進行修正。釘釘機器人的出現,無疑爲咱們第一時間對錯誤日誌進行響應,提供了絕妙的工具。java

自定義釘釘機器人

建立釘釘機器人

釘釘機器人只支持在羣聊中建立,於是首先咱們須要擁有一個羣聊,而後在 「聊天設置」 中,找到 「智能羣助手」,點擊 「添加更多」,選擇 「自定義」:git

webhook

點擊 「添加」 後,設置機器人名稱(和頭像),便完成了機器人的自定義,而後你會得到一個 webhook程序員

日誌機器人

這個 webhook 是一個 URL,咱們能夠向這個 URL 發起 POST 請求,從而將咱們的日誌數據,發送給日誌機器人,而後日誌機器人產出消息提醒。釘釘支持多種消息類型,包括:text 類型、link 類型、markdown 類型等等,詳細可見 釘釘開發平臺。對於咱們的日誌消息來講,通常 text 類型就行。github

Text 類型

text 類型的消息的格式以下:web

{
    "msgtype": "text", 
    "text": {
        "content": "我就是我, 是不同的煙火@156xxxx8827"
    }, 
    "at": {
        "atMobiles": [
            "156xxxx8827", 
            "189xxxx8325"
        ], 
        "isAtAll": false
    }
}
參數 參數類型 必須 說明
msgtype String 消息類型,此時固定爲:text
content String 消息內容
atMobiles Array 被@人的手機號
isAtAll bool @全部人時:true,不然爲:false

使用 okHttp 發送消息

下面基於 okHttp 來演示如何發送 text 類型消息。首先咱們定義消息的結構:正則表達式

/**
 * 抽象消息類型(方便未來擴展其餘類型的消息)
 */
public abstract class BaseMessage {

    private List<String> atMobiles;

    private boolean atAll;

    /**
     * 轉爲 JSON 格式的請求體
     *
     * @return 當前消息對應的請求體
     */
    public abstract String toRequestBody();

    public void addAtMobile(String atMobile) {
        if (atMobiles == null) {
            atMobiles = new ArrayList<>(1);
        }
        
        atMobiles.add(atMobile);
    }

    public void setAtAll(boolean atAll) {
        this.atAll = atAll;
    }

    public List<String> getAtMobiles() {
        return atMobiles != null ? atMobiles : Collections.emptyList();
    }

    public boolean isAtAll() {
        return atAll;
    }
}

/**
 * 文本消息
 */
public class TextMessage extends BaseMessage {

    /**
     * 消息內容
     */
    private final String content;

    public TextMessage(String content) {
        super();
        this.content = content;
    }

    @Override
    public String toRequestBody() {
        // 消息體
        JSONObject msgBody = new JSONObject(3);

        // 消息類型爲 text
        msgBody.put("msgtype", "text");

        // 消息內容
        JSONObject text = new JSONObject(1);
        text.put("content", content);
        msgBody.put("text", text);

        // 要 at 的人的電話號碼
        JSONObject at = new JSONObject(2);
        at.put("isAtAll", isAtAll());
        at.put("atMobiles", getAtMobiles());
        msgBody.put("at", at);

        return msgBody.toJSONString();
    }
}

而後定義消息發送工具,由於 HTTP 請求相對來講是個較爲耗時的操做,因此咱們基於 CompletableFuturesend 方法實現爲異步發送:apache

/**
 * 釘釘機器人消息發送工具
 */
public class DingTalkTool {

    private static final Logger logger = LoggerFactory.getLogger(DingTalkTool.class);

    /**
     * OK 響應碼
     */
    private static final int CODE_OK = 200;

    /**
     * OkHttpClient 可複用
     */
    private static final OkHttpClient HTTP_CLIENT = new OkHttpClient();

    /**
     * 修改成你的 webhook
     */
    private static final String WEBHOOK = "https://oapi.dingtalk.com/robot/send?access_token=your_access_token";

    /**
     * 異步發送消息
     *
     * @param message 消息
     */
    public static void send(BaseMessage message) {
        CompletableFuture.completedFuture(message)
                         .thenAcceptAsync(DingTalkTool::sendSync);
    }

    /**
     * 同步發送消息
     *
     * @param message 消息
     */
    private static void sendSync(BaseMessage message) {
        // HTTP 消息體(編碼必須爲 utf-8)
        MediaType   mediaType   = MediaType.parse("application/json; charset=utf-8");
        RequestBody requestBody = RequestBody.create(mediaType, message.toRequestBody());

        // 建立 POST 請求
        Request request = new Request.Builder()
                .url(WEBHOOK)
                .post(requestBody)
                .build();

        // 經過 HTTP 客戶端發送請求
        HTTP_CLIENT.newCall(request).enqueue(new Callback() {

            @Override
            public void onFailure(Call c, IOException e) {
                logger.error("發送消息失敗,請查看異常信息", e);
            }

            @Override
            public void onResponse(Call c, Response r) throws IOException {
                int code = r.code();
                if (code != CODE_OK) {
                    logger.error("發送消息失敗,code={}", code);
                    return;
                }

                ResponseBody responseBody = r.body();
                if (responseBody != null) {
                    JSONObject body = JSON.parseObject(responseBody.string());

                    int errCode = body.getIntValue("errcode");
                    if (errCode != 0) {
                        String errMsg = body.getString("errmsg");
                        logger.error("發送消息出現錯誤,errCode={}, errMsg={}", errCode, errMsg);
                    }
                }
            }
        });
    }
}

OK,寫個 Controller 來測試一下:json

@RestController
public class SimpleController {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @GetMapping("/divide/{a}/{b}")
    public int divide(@PathVariable int a, @PathVariable int b) {
        logger.info("SimpleController.divide start, a = {}, b = {}", a, b);

        try {
            return a / b;
        } catch (Exception ex) {
            String errMsg = String.format("SimpleController.divide error, a = %d, b = %d", a, b);
            // 日誌記錄錯誤信息
            logger.error(errMsg, ex);
            // 發送到釘釘羣
            sendErrorMsg(errMsg, ex);
        }

        return Integer.MIN_VALUE;
    }

    private void sendErrorMsg(String errorMsg, Exception ex) {
        String stackTrace = ExceptionUtils.getStackTrace(ex);
        String content = errorMsg + LF + stackTrace;

        TextMessage message = new TextMessage(content);
        message.addAtMobile("要 at 的人的電話號碼");
        
        DingTalkTool.send(message);
    }
}

訪問一下 http://localhost:9090/divide/4/0,拋出異常,而後日誌機器人發出提醒:c#

機器人提醒異常

由於我設置了要 at 的人爲個人號碼,因此我被小機器人 at 了:api

我被 at 了

到這裏,咱們已經成功實現了經過釘釘來第一時間知道錯誤的日誌信息。

甜

結合 Log4j

Why

總以爲有什麼地方仍是不夠好 —— 對的,感受咱們像是記錄了兩遍日誌:使用 SLF4J (本文 SLF4J 的實現爲 Log4j1.2)記錄了一次,又使用 DingTalkTool 記錄一次。程序員都是懶的,寫重複代碼對咱們來講:

不可能

固然,咱們能夠封裝一個以下的方式來解決問題,就是不怎麼優雅:

public static void sendErrorMsg(Logger logger, String errorMsg, Exception ex) {
    String stackTrace = ExceptionUtils.getStackTrace(ex);
    String content = errorMsg + LF + stackTrace;

    logger.error(content);

    TextMessage message = new TextMessage(content);
    message.addAtMobile("要 at 的人的電話號碼");

    DingTalkTool.send(message);
}

而後錯誤信息得這樣來記錄:

String errMsg = String.format("SimpleController.divide error, a = %d, b = %d", a, b);
// 記錄併發送錯誤信息
sendErrorMsg(logger, errMsg, ex);

同時,由於咱們要把錯誤級別的日誌同時使用 SLF4J 和 DingTalkTool 記錄,因此當日志中存在參數的時候,咱們只能使用 String.format 來進行蹩腳的字符串格式化,而不能使用 SLF4J 的 {}。但是 使用 {} 不只僅是由於好用,更由於 {} 處理起來是基於 StringindexOf 進行替換操做,效率遠高於使用正則表達式的 String.format 方法。因此,必須安排!

必須安排

How

咱們知道 Log4j 提供了各類 Appender,下面 2 個最經常使用:

  1. org.apache.log4j.ConsoleAppender(控制檯)
  2. org.apache.log4j.DailyRollingFileAppender(天天產生一個日誌文件)

而且咱們在配置 Log4j 時,能夠提供多個 Appender,好比對於下面的配置文件:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE log4j:configuration SYSTEM "http://toolkit.alibaba-inc.com/dtd/log4j/log4j.dtd">

<log4j:configuration>

    <!-- DEBUG 及以上級別的日誌 輸出到控制檯 -->
    <appender name="CONSOLE" class="org.apache.log4j.ConsoleAppender">
        <param name="threshold" value="DEBUG"/>
        <param name="encoding" value="UTF-8"/>
        <layout class="org.apache.log4j.PatternLayout">
            <param name="ConversionPattern" value="%n%d %p %c{2} - %m%n"/>
        </layout>
    </appender>
    
    <!-- INFO 及以上級別的日誌 按天輸出到 logs/project.log -->
    <appender name="PROJECT_FILE" class="org.apache.log4j.DailyRollingFileAppender">
        <param name="threshold" value="INFO"/>
        <param name="file" value="logs/project.log"/>
        <param name="encoding" value="UTF-8"/>
        <param name="append" value="true"/>
        <layout class="org.apache.log4j.PatternLayout">
            <param name="ConversionPattern" value="%n%d %p %c{2} - %m%n"/>
        </layout>
    </appender>

    <!-- ERROR 及以上級別的日誌 按天輸出到 logs/error.log -->
    <appender name="ERROR_FILE" class="org.apache.log4j.DailyRollingFileAppender">
        <param name="file" value="logs/error.log"/>
        <param name="append" value="true"/>
        <param name="encoding" value="UTF-8"/>
        <param name="threshold" value="ERROR"/>
        <layout class="org.apache.log4j.PatternLayout">
            <param name="ConversionPattern" value="%n%d %p %c{2} - %m%n"/>
        </layout>
    </appender>
    
    <!-- 根 Logger -->
    <root>
        <level value="DEBUG"/>
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="PROJECT_FILE"/>
        <appender-ref ref="ERROR_FILE" />
    </root>

</log4j:configuration>

根 Logger 至關於建立了一個管道,而後管道上有三個 Appender。當使用 Logger 記錄日誌時,日誌通過管道,而後根據本身的級別選擇能夠輸出哪一個 Appender(一個日誌能夠進入多個 Appender)。對於咱們的配置,DEBUG 日誌只會輸出到 CONSOLEINFO 及以上級別的日誌會輸出到 CONSOLEPROJECT_FILEERROR 及以上級別的日誌會輸出到 CONSOLEPROJECT_FILEERROR_FILE

既然 Log4j 提供了 Appender 這樣的管道機制,那麼天然其也提供了能夠自定義 Appender 的功能。因此咱們能夠實現一個輸出到釘釘的 Appender,而後放到根 Logger 裏面,並讓其只輸出 ERROR 及以上級別的日誌到這個 Appender。經過實現 Log4j 已經提供的 AppenderSkeleton 抽象類,自定義的 Appender 只須要關心在 append 方法裏面實現日誌輸出邏輯便可:

public class DingTalkAppender extends AppenderSkeleton {

    @Override
    protected void append(LoggingEvent event) {
        // 得到調用的位置信息
        LocationInfo loc = event.getLocationInformation();

        String className = loc.getClassName();
        // 若是是 DingTalkTool 的日誌,不進行輸出,不然網絡出錯時會引發無限遞歸
        if (DingTalkTool.class.getName().equals(className)) { return; }

        StringBuilder content = new StringBuilder(1024);
        content.append("級別:").append(event.getLevel()).append(LF)
               .append("位置:").append(className).append('.').append(loc.getMethodName())
               .append("(行號=").append(loc.getLineNumber()).append(')').append(LF)
               .append("信息:").append(event.getMessage());

        Throwable ex = Optional.of(event)
                               .map(LoggingEvent::getThrowableInformation)
                               .map(ThrowableInformation::getThrowable)
                               .orElse(null);
        // 存在異常信息
        if (ex != null) {
            String stackTrace = ExceptionUtils.getStackTrace(ex);
            content.append(LF).append("異常:").append(stackTrace);
        }

        TextMessage message = new TextMessage(content.toString());
        DingTalkTool.send(message);
    }

    @Override
    public void close() { }

    @Override
    public boolean requiresLayout() { return false; }

}

而後在 Log4j 的配置文件中加入咱們的 DingTalkAppender,設置爲 Error 及以上級別的日誌可輸出到該 Appender

<log4j:configuration>

    ......

    <appender name="ERROR_DINGTALK" class="xyz.mizhoux.logrobot.DingTalkAppender">
        <param name="threshold" value="ERROR"/>
    </appender>

    <!-- 根 Logger -->
    <root>
        <level value="DEBUG"/>
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="PROJECT_FILE"/>
        <appender-ref ref="ERROR_FILE" />
        <appender-ref ref="ERROR_DINGTALK"/>
    </root>

</log4j:configuration>

測試一下,首先修改 SimpleController:

@RestController
public class SimpleController {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @GetMapping("/divide/{a}/{b}")
    public int divide(@PathVariable int a, @PathVariable int b) {
        logger.info("SimpleController.divide start, a = {}, b = {}", a, b);

        try {
            return a / b;
        } catch (Exception ex) {
            logger.error("SimpleController.divide start, a = {}, b = {}", a, b, ex);
        }

        return Integer.MIN_VALUE;
    }
}

而後咱們在瀏覽器中輸入 localhost:9090/divide/2/0,日誌機器人第一時間響應:

自定義 Appender

如今,咱們不再須要 sendErrorMsg 這樣的方法,也不須要使用 String.format 這種難用且效率低的字符串格式化方法,記錄錯誤信息的時候直接一個 logger.error 搞定~

甜

本文的示例項目地址:log-robot

相關文章
相關標籤/搜索