springboot将报错信息推送到飞书[logback+webhook]

900 阅读4分钟

我正在参加「掘金·启航计划」

前言

在web项目中,日志文件是一个非常重要的文档,它可以在程序运行情况和报错情况进行留存,方便我们在程序报错时进行问题的查找与确认。在线上运行的服务中,如果我想实时得到报错信息该怎么解决呢?下面我会介绍一种实时错误推送的方式:logback+自定义appender+飞书捷径+webhook,效果如下: image.png

步骤1:配置pom.xml文件

<!-- 环境 -->
<profiles>
    <!-- 开发环境 -->
    <profile>
        <id>dev</id>
        <activation>
            <!-- 默认激活配置 -->
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <!-- 当前环境 -->
            <profile.name>dev</profile.name>
            <!-- 飞书告警开关 -->
            <feishu.alarm>false</feishu.alarm>
            <!-- 飞书告警webhook -->
            <feishu.webhook>https://hzebin.cn</feishu.webhook>
        </properties>
    </profile>
    <!-- 测试环境 -->
    <profile>
        <!-- 测试环境的相关配置 -->
    </profile>
    <!-- 生产环境 -->
    <profile>
        <!-- 生产环境的相关配置 -->
    </profile>
</profiles>

pom.xml可以配置多个环境的配置,以dev开发环境为例,在properties标签里,我自己定义了<feishu.alarm>是否进行飞书告警和<feishu.webhook>飞书告警的webhook。

步骤2:配置logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="false" scan="false">
    <springProperty scop="context" name="spring.application.name" source="spring.application.name" defaultValue="logs"/>
    <property name="log.path" value="logs/${spring.application.name}"/>
    <!-- Console log output -->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符-->
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{50}) - %highlight(%msg) %n</pattern>
        </encoder>
    </appender>

    <!-- Log file debug output -->
    <appender name="debug" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${log.path}/debug.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${log.path}/%d{yyyy-MM}/debug.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>50MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Log ferrorsrror output -->
    <appender name="error" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${log.path}/error.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${log.path}/%d{yyyy-MM}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>50MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
    </appender>
    
    <!--name表示环境,用逗号分隔-->
    <springProfile name="test,product">
        <!--推送ERROR信息到飞书-->
        <appender name="FeiShuLogbackAppender" class="cn.hzebin.common.appender.FeiShuLogbackAppender">
            <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
                <!-- 日志收集最低日志级别 -->
                <level>ERROR</level>
            </filter>
            <feishuAlarm>@feishu.alarm@</feishuAlarm>
            <feishuWebhook>@feishu.webhook@</feishuWebhook>
            <appName>uaa</appName>
        </appender>
    </springProfile>

    <!--开发环境:打印控制台-->
    <springProfile name="dev">
        <logger name="cn.hzebin" level="debug"/>
        <logger name="java.sql.Connection" level="debug"/>
        <logger name="java.sql.Statement" level="debug"/>
        <logger name="java.sql.PreparedStatement" level="debug"/>
        <logger name="com.alibaba.nacos.client.naming" level="off" />
    </springProfile>

    <!--测试环境:打印控制台-->
    <springProfile name="test">
        <logger name="cn.hzebin" level="debug"/>
        <logger name="java.sql.Connection" level="debug"/>
        <logger name="java.sql.Statement" level="debug"/>
        <logger name="java.sql.PreparedStatement" level="debug"/>
        <logger name="com.alibaba.nacos.client.naming" level="off" />
    </springProfile>

    <!--生产环境:打印控制台-->
    <springProfile name="product">
        <logger name="cn.hzebin" level="debug"/>
        <logger name="java.sql.Connection" level="debug"/>
        <logger name="java.sql.Statement" level="debug"/>
        <logger name="java.sql.PreparedStatement" level="debug"/>
        <logger name="com.alibaba.nacos.client.naming" level="off" />
    </springProfile>

    <!-- Level: FATAL 0  ERROR 3  WARN 4  INFO 6  DEBUG 7 -->
    <root level="info">
        <appender-ref ref="console"/>
        <appender-ref ref="debug"/>
        <appender-ref ref="error"/>
        
        <!--特定环境生效的appender-->
        <springProfile name="test,product">
            <appender-ref ref="FeiShuLogbackAppender" />
        </springProfile>
    </root>
</configuration>

先来讲解一下上面配置的logback信息:

configuration

  • scan:当此属性设置为true时,配置文件如果发生改变,将会被重新加载,默认值为true。
  • scanPeriod:设置监测配置文件是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。当scan为true时,此属性生效。默认的时间间隔为1分钟。
  • debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。

property

用来定义变量值的标签,property标签有两个属性,namevalue;其中name的值是变量的名称,value的值时变量定义的值。通过property定义的值会被插入到logger上下文中。定义变量后,可以使“${name}”来使用变量。如上面的xml所示。

root

根logger,也是一种logger,且只有一个level属性

appender

负责写日志的组件,appender 有两个属性 nameclass;name指定appender名称,class指定appender的全限定名。上面声明的是名为GLMAPPER-LOGGERONEclassch.qos.logback.core.rolling.RollingFileAppender的一个appender

appender 的种类

  • ConsoleAppender:把日志添加到控制台
  • FileAppender:把日志添加到文件
  • RollingFileAppender:滚动记录文件,先将日志记录到指定文件,当符合某个条件时,将日志记录到其他文件。它是FileAppender的子类

appender 子标签

<append>true</append>

如果是 true,日志被追加到文件结尾,如果是false,清空现存文件,默认是true

filter 子标签

在简介中提到了filter;作用就是上面说的。可以为appender 添加一个或多个过滤器,可以用任意条件对日志进行过滤。appender 有多个过滤器时,按照配置顺序执行。

ThresholdFilter

临界值过滤器,过滤掉低于指定临界值的日志。当日志级别等于或高于临界值时,过滤器返回NEUTRAL;当日志级别低于临界值时,日志会被拒绝。

<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
    <level>INFO</level>
</filter>

LevelFilter

级别过滤器,根据日志级别进行过滤。如果日志级别等于配置级别,过滤器会根据onMath(用于配置符合过滤条件的操作) 和 onMismatch(用于配置不符合过滤条件的操作)接收或拒绝日志。

<filter class="ch.qos.logback.classic.filter.LevelFilter">   
  <level>INFO</level>   
  <onMatch>ACCEPT</onMatch>   
  <onMismatch>DENY</onMismatch>   
</filter> 

关于NEUTRALACCEPTDENY 见上文简介中关于filter的介绍。

file 子标签

file 标签用于指定被写入的文件名,可以是相对目录,也可以是绝对目录,如果上级目录不存在会自动创建,没有默认值。

<file>
    ${logging.path}/glmapper-spring-boot/glmapper-loggerone.log
</file>

这个表示当前appender将会将日志写入到${logging.path}/glmapper-spring-boot/glmapper-loggerone.log这个目录下。

rollingPolicy 子标签

这个子标签用来描述滚动策略的。这个只有appenderclassRollingFileAppender时才需要配置。这个也会涉及文件的移动和重命名(a.log->a.log.2018.07.22)。

TimeBasedRollingPolicy

最常用的滚动策略,它根据时间来制定滚动策略,既负责滚动也负责出发滚动。这个下面又包括了两个属性:

  • FileNamePattern
  • maxHistory
<rollingPolicy 
    class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
    <!--日志文件输出的文件名:按天回滚 daily -->
    <FileNamePattern>
        ${logging.path}/glmapper-spring-boot/glmapper-loggerone.log.%d{yyyy-MM-dd}
    </FileNamePattern>
    <!--日志文件保留天数-->
    <MaxHistory>30</MaxHistory>
</rollingPolicy>

上面的这段配置表明每天生成一个日志文件,保存30天的日志文件

FixedWindowRollingPolicy

根据固定窗口算法重命名文件的滚动策略。

encoder 子标签

对记录事件进行格式化。它干了两件事:

  • 把日志信息转换成字节数组
  • 把字节数组写入到输出流
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
    <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}
    - %msg%n</pattern>
    <charset>UTF-8</charset>
</encoder>

目前encoder只有PatternLayoutEncoder一种类型。

步骤3:自定义一个告警推送的appender

public class FeiShuLogbackAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {

    Layout<ILoggingEvent> layout;

    /**
     * 自定义配置
     */
    String printString;

    /**
     * 飞书告警开关
     */
    private Boolean feishuAlarm;

    /**
     * 飞书webhook
     */
    private String feishuWebhook;
    
    private String appName;
    
    @Override
    public void start() {
        //这里可以做些初始化判断 比如layout不能为null ,
        if (layout == null) {
            addWarn("Layout was not defined");
        }
        //或者写入数据库 或者redis时 初始化连接等等
        super.start();
    }


    @Override
    public void stop() {
        //释放相关资源,如数据库连接,redis线程池等等
        System.out.println("logback-stop方法被调用");
        if (!isStarted()) {
            return;
        }
        super.stop();
    }

    @Override
    public void append(ILoggingEvent event) {
        if (event == null || !isStarted()) {
            return;
        }
        
        if (feishuAlarm != null && feishuAlarm && event.getLevel() == Level.ERROR) {
            IThrowableProxy iThrowableProxy = event.getThrowableProxy();
            StringBuilder sb = new StringBuilder();
            if (iThrowableProxy != null && iThrowableProxy instanceof ThrowableProxy) {
                ThrowableProxy throwableProxy = (ThrowableProxy) iThrowableProxy;
                Throwable throwable = throwableProxy.getThrowable();
                String throwableMsg = throwable.getMessage();
                StackTraceElementProxy[] stackTraceElementProxy = iThrowableProxy.getStackTraceElementProxyArray();
                //sb.append(event.getMessage()).append("\n");
                if (StringUtils.isNotEmpty(throwableMsg)) {
                    sb.append(throwableMsg).append("\n");
                }
                int i = 0;
                for (StackTraceElementProxy proxy : stackTraceElementProxy) {
                    //只打印30行的堆栈
                    sb.append(proxy.getSTEAsString()).append("\n");
                    if (++i > 30) {
                        break;
                    }
                }
            } else {
                sb.append(event.getMessage());
            }
            String msg = sb.toString();
            if (StringUtils.isNotEmpty(msg)) {
                sendFeishuMsg(event.getFormattedMessage() + "\n" + msg);
                System.out.println();
            }
        }
    }
    
    void sendFeishuMsg(String msg) {
        msg = parseMsg(msg);
        
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Map map = new HashMap<String, Object>();
        map.put("datetime", dateFormat.format(Calendar.getInstance().getTime()));
        map.put("appName", appName);
        map.put("errorInfo", msg);
        
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
        ResponseEntity<JSONObject> responseEntity = new RestTemplate().postForEntity(feishuWebhook, new HttpEntity<>(JSON.toJSON(map).toString(), headers), JSONObject.class);
    }
    
    private String parseMsg(String s) {
        StringBuffer sb = new StringBuffer();
        for (int i=0; i<s.length(); i++) {
            char c = s.charAt(i);
            switch (c){
                case '"':
                    sb.append("\"");
                    break;
                case '\':
                    sb.append("\\");
                    break;
                case '/':
                    sb.append("\/");
                    break;
                case '\b':
                    sb.append("\b");
                    break;
                case '\f':
                    sb.append("\f");
                    break;
                case '\n':
                    sb.append("\n");
                    break;
                case '\r':
                    sb.append("\r");
                    break;
                case '\t':
                    sb.append("\t");
                    break;
                default:
                    sb.append(c);
            }
        }
        return sb.toString();
    }

    public Layout<ILoggingEvent> getLayout() {
        return layout;
    }

    public void setLayout(Layout<ILoggingEvent> layout) {
        this.layout = layout;
    }

    public String getPrintString() {
        return printString;
    }

    public void setPrintString(String printString) {
        this.printString = printString;
    }

    public Boolean getFeishuAlarm() {
        return feishuAlarm;
    }

    public void setFeishuAlarm(Boolean feishuAlarm) {
        this.feishuAlarm = feishuAlarm;
    }

    public String getFeishuWebhook() {
        return feishuWebhook;
    }

    public void setFeishuWebhook(String feishuWebhook) {
        this.feishuWebhook = feishuWebhook;
    }

    public String getAppName() {
        return appName;
    }

    public void setAppName(String appName) {
        this.appName = appName;
    }
}

自定义appender需要集成UnsynchronizedAppenderBase,并实现对应方法,在lobabck需要增加<appender-ref ref="FeiShuLogbackAppender" />才能生效。

步骤4:添加飞书捷径和群机器人

image.png