在日常的開發中,找問題時,看日誌常常是不可或缺的一件事件。對於錯誤日誌,咱們更是但願可以立馬悉知,迅速對錯誤追本溯源,而後對錯誤進行修正。釘釘機器人的出現,無疑爲咱們第一時間對錯誤日誌進行響應,提供了絕妙的工具。java
釘釘機器人只支持在羣聊中建立,於是首先咱們須要擁有一個羣聊,而後在 「聊天設置」 中,找到 「智能羣助手」,點擊 「添加更多」,選擇 「自定義」:git
點擊 「添加」 後,設置機器人名稱(和頭像),便完成了機器人的自定義,而後你會得到一個 webhook
:程序員
這個 webhook
是一個 URL,咱們能夠向這個 URL 發起 POST 請求,從而將咱們的日誌數據,發送給日誌機器人,而後日誌機器人產出消息提醒。釘釘支持多種消息類型,包括:text 類型、link 類型、markdown 類型等等,詳細可見 釘釘開發平臺。對於咱們的日誌消息來講,通常 text 類型就行。github
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 來演示如何發送 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 請求相對來講是個較爲耗時的操做,因此咱們基於 CompletableFuture
將 send
方法實現爲異步發送: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
到這裏,咱們已經成功實現了經過釘釘來第一時間知道錯誤的日誌信息。
總以爲有什麼地方仍是不夠好 —— 對的,感受咱們像是記錄了兩遍日誌:使用 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 的 {}
。但是 使用 {}
不只僅是由於好用,更由於 {}
處理起來是基於 String
的 indexOf
進行替換操做,效率遠高於使用正則表達式的 String.format
方法。因此,必須安排!
咱們知道 Log4j 提供了各類 Appender,下面 2 個最經常使用:
而且咱們在配置 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
日誌只會輸出到 CONSOLE
,INFO
及以上級別的日誌會輸出到 CONSOLE
和 PROJECT_FILE
,ERROR
及以上級別的日誌會輸出到 CONSOLE
、PROJECT_FILE
和 ERROR_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
,日誌機器人第一時間響應:
如今,咱們不再須要 sendErrorMsg
這樣的方法,也不須要使用 String.format
這種難用且效率低的字符串格式化方法,記錄錯誤信息的時候直接一個 logger.error
搞定~
本文的示例項目地址:log-robot