Log4j 结合钉钉打造日志机器人

在平常的开发中,找问题时,看日志经常是不可或缺的一件事件。对于错误日志,我们更是希望能够立马悉知,迅速对错误追本溯源,然后对错误进行修正。钉钉机器人的出现,无疑为我们第一时间对错误日志进行响应,提供了绝妙的工具。

自定义钉钉机器人

创建钉钉机器人

钉钉机器人只支持在群聊中创建,因而首先我们需要拥有一个群聊,然后在 “聊天设置” 中,找到 “智能群助手”,点击 “添加更多”,选择 “自定义”:

webhook

点击 “添加” 后,设置机器人名称(和头像),便完成了机器人的自定义,然后你会获得一个 webhook

日志机器人

这个 webhook 是一个 URL,我们可以向这个 URL 发起 POST 请求,从而将我们的日志数据,发送给日志机器人,然后日志机器人产出消息提醒。钉钉支持多种消息类型,包括:text 类型、link 类型、markdown 类型等等,详细可见 钉钉开发平台。对于我们的日志消息来说,一般 text 类型就行。

Text 类型

text 类型的消息的格式如下:

{
    "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 方法实现为异步发送:

/**
 * 钉钉机器人消息发送工具
 */
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 来测试一下:

@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,抛出异常,然后日志机器人发出提醒:

机器人提醒异常

因为我设置了要 at 的人为我的号码,所以我被小机器人 at 了:

我被 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,639评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,093评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,079评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,329评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,343评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,047评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,645评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,565评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,095评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,201评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,338评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,014评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,701评论 3 332
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,194评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,320评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,685评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,345评论 2 358

推荐阅读更多精彩内容

  • 在应用程序中添加日志记录总的来说基于三个目的:监视代码中变量的变化情况,周期性的记录到文件中供其他应用进行统计分析...
    时待吾阅读 5,053评论 1 13
  • 在应用程序中添加日志记录总的来说基于三个目的:监视代码中变量的变化情况,周期性的记录到文件中供其他应用进行统计分析...
    时待吾阅读 4,990评论 0 6
  • from:https://www.cnblogs.com/ITtangtang/p/3926665.html一、L...
    enshunyan阅读 3,294评论 0 0
  • 大家好,我是IT修真院北京分院第22期的学员,一枚正直纯洁善良的JAVA程序员 今天给大家分享一下,修真院官网JA...
    林氏名义阅读 1,250评论 0 2
  • 最近一直跟踪的纠纷告一段落,这个过程充斥着成年人的各种小心机。天下熙熙,皆为利来,天下熙熙,皆为利往。在某天晚上各...
    美梦成真_ed71阅读 730评论 0 0