在日常的开发中,找问题时,看日志常常是不可或缺的一件事件。对于错误日志,咱们更是但愿可以立马悉知,迅速对错误追本溯源,而后对错误进行修正。钉钉机器人的出现,无疑为咱们第一时间对错误日志进行响应,提供了绝妙的工具。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