開發一個二方包,優雅地為系統接入ELK(elasticsearch+logstash+kibana)

2023-04-01 21:00:53

去年公司由於不斷髮展,內部自研系統越來越多,所以後來搭建了一個紀錄檔收集平臺,並將紀錄檔收集功能以二方包形式引入各個自研系統,避免每個自研系統都要建立一套自己的紀錄檔模組,節約了開發時間,管理起來也更加容易。
這篇文章主要介紹如何編寫二方包,並整合到各個系統中。

先介紹整個ELK紀錄檔平臺的架構。其中xiaobawang-log就是今天的主角。

xiaobawang-log主要收集三種紀錄檔型別:

  1. 系統級別紀錄檔: 收集系統執行時產生的各個級別的紀錄檔(ERROR、INFO、WARN、DEBUG和TRACER),其中ERROR級別紀錄檔是我們最關心的。
  2. 使用者請求紀錄檔: 主要用於controller層的請求,捕獲使用者請求資訊和響應資訊、以及來源ip等,便於分析使用者行為。
  3. 自定義操作紀錄檔: 顧名思義,就是收集手動打的紀錄檔。比如定時器執行開始,都會習慣性寫一個log.info("定時器執行開始!")的描述,這種就是屬於自定義操作紀錄檔的型別。

二方包開發

先看目錄結構

廢話不多說,上程式碼。
1、首先建立一個springboot專案,引入如下包:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.0.1</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-core</artifactId>
    <version>1.2.10</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.10</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-access</artifactId>
    <version>1.2.10</version>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.18</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
    <version>1.18.26</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

SysLog實體類

public class SysLog {

    /**
     * 紀錄檔名稱
     */
    private String logName;

    /**
     * ip地址
     */
    private String ip;

    /**
     * 請求引數
     */
    private String requestParams;

    /**
     * 請求地址
     */
    private String requestUrl;

    /**
     * 使用者ua資訊
     */
    private String userAgent;

    /**
     * 請求時間
     */
    private Long useTime;

    /**
     * 請求時間
     */
    private String exceptionInfo;

    /**
     * 響應資訊
     */
    private String responseInfo;

    /**
     * 使用者名稱稱
     */
    private String username;

    /**
     * 請求方式
     */
    private String requestMethod;

}

LogAction

建立一個列舉類,包含三種紀錄檔型別。

public enum LogAction {

    USER_ACTION("使用者紀錄檔", "user-action"),
    SYS_ACTION("系統紀錄檔", "sys-action"),
    CUSTON_ACTION("其他紀錄檔", "custom-action");

    private final String action;

    private final String actionName;

    LogAction(String action,String actionName) {
        this.action = action;
        this.actionName = actionName;
    }

    public String getAction() {
        return action;
    }

    public String getActionName() {
        return actionName;
    }

}

設定logstash

更改logstash組態檔,將index名稱更改為log-%{[appname]}-%{+YYYY.MM.dd}-%{[action]},其中appname為系統名稱,action為紀錄檔型別。
整個es索引名稱是以「系統名稱+日期+紀錄檔型別」的形式。比如「mySystem-2023.03.05-system-action」表示這個索引,是由mySystem在2023年3月5日產生的系統級別的紀錄檔。

# 輸入端
input {
  stdin { } 
  #為logstash增加tcp輸入口,後面springboot接入會用到
  tcp {
      mode => "server"
      host => "0.0.0.0"
      port => 5043
      codec => json_lines
  }
}
 
#輸出端
output {
  stdout {
    codec => rubydebug
  }
  elasticsearch {
    hosts => ["http://你的虛擬機器器ip地址:9200"]
    # 輸出至elasticsearch中的自定義index名稱
    index => "log-%{[appname]}-%{+YYYY.MM.dd}-%{[action]}"
  }
  stdout { codec => rubydebug }
}

AppenderBuilder

使用程式設計式設定logback,AppenderBuilder用於建立appender。

  • 這裡會建立兩種appender。consoleAppender負責將紀錄檔列印到控制檯,這對開發來說是十分有用的。而LogstashTcpSocketAppender則負責將紀錄檔儲存到ELK中。
  • setCustomFields中的引數,對應上面logstash組態檔的引數[appname]和[action]。
@Component
public class AppenderBuilder {

    public static final String SOCKET_ADDRESS = "你的虛擬機器器ip地址";

    public static final Integer PORT = 5043;//logstash tcp輸入埠

    /**
     * logstash通訊Appender
     * @param name
     * @param action
     * @param level
     * @return
     */
    public LogstashTcpSocketAppender logAppenderBuild(String name, String action, Level level) {
        LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
        LogstashTcpSocketAppender appender = new LogstashTcpSocketAppender();
        appender.setContext(context);
        //設定logstash通訊地址
        InetSocketAddress inetSocketAddress = new InetSocketAddress(SOCKET_ADDRESS, PORT);
        appender.addDestinations(inetSocketAddress);
        LogstashEncoder logstashEncoder = new LogstashEncoder();
        //對應前面logstash組態檔裡的引數
        logstashEncoder.setCustomFields("{\"appname\":\"" + name + "\",\"action\":\"" + action + "\"}");
        appender.setEncoder(logstashEncoder);

        //這裡設定級別過濾器
        LevelFilter levelFilter = new LevelFilter();
        levelFilter.setLevel(level);
        levelFilter.setOnMatch(ACCEPT);
        levelFilter.setOnMismatch(DENY);
        levelFilter.start();
        appender.addFilter(levelFilter);
        appender.start();

        return appender;
    }
    
    
    /**
     * 控制列印Appender
     * @return
     */
    public ConsoleAppender consoleAppenderBuild() {
        ConsoleAppender consoleAppender = new ConsoleAppender();
        LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
        PatternLayoutEncoder encoder = new PatternLayoutEncoder();
        encoder.setContext(context);
        //設定格式
        encoder.setPattern("%red(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta(%logger) - %cyan(%msg%n)");
        encoder.start();
        consoleAppender.setEncoder(encoder);
        consoleAppender.start();
        return consoleAppender;

    }

LoggerBuilder

LoggerBuilder主要用於建立logger類。建立步驟如下:

  1. 獲取logger上下文。
  2. 從上下文獲取logger物件。建立過的logger會儲存在LOGCONTAINER中,保證下次獲取logger不會重複建立。這裡使用ConcurrentHashMap防止出現並行問題。
  3. 建立appender,並將appender加入logger物件中。
@Component
public class LoggerBuilder {
    @Autowired
    AppenderBuilder appenderBuilder;

    @Value("${spring.application.name:unknow-system}")
    private String appName;

    private static final Map<String, Logger> LOGCONTAINER = new ConcurrentHashMap<>();

    public Logger getLogger(LogAction logAction) {
        Logger logger = LOGCONTAINER.get(logAction.getActionName() + "-" + appName);
        if (logger != null) {
            return logger;
        }
        logger = build(logAction);
        LOGCONTAINER.put(logAction.getActionName() + "-" + appName, logger);

        return logger;
    }

    public Logger getLogger() {
        return getLogger(LogAction.CUSTON_ACTION);
    }

    private Logger build(LogAction logAction) {
        //建立紀錄檔appender
        List<LogstashTcpSocketAppender> list = createAppender(appName, logAction.getActionName());
        LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
        Logger logger = context.getLogger(logAction.getActionName() + "-" + appName);
        logger.setAdditive(false);
        //列印控制檯appender
        ConsoleAppender consoleAppender = appenderBuilder.consoleAppenderBuild();
        logger.addAppender(consoleAppender);
        list.forEach(appender -> {
            logger.addAppender(appender);
        });
        return logger;
    }

    /**
     * LoggerContext上下文中的紀錄檔物件加入appender
     */
    public void addContextAppender() {
        //建立四種型別紀錄檔
        String action = LogAction.SYS_ACTION.getActionName();
        List<LogstashTcpSocketAppender> list = createAppender(appName, action);
        LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
        //列印控制檯
        ConsoleAppender consoleAppender = appenderBuilder.consoleAppenderBuild();
        context.getLoggerList().forEach(logger -> {
            logger.setAdditive(false);
            logger.addAppender(consoleAppender);
            list.forEach(appender -> {
                logger.addAppender(appender);
            });
        });
    }

    /**
     * 建立連線elk的appender,每一種級別紀錄檔建立一個appender
     *
     * @param name
     * @param action
     * @return
     */
    public List<LogstashTcpSocketAppender> createAppender(String name, String action) {
        List<LogstashTcpSocketAppender> list = new ArrayList<>();
        LogstashTcpSocketAppender errorAppender = appenderBuilder.logAppenderBuild(name, action, Level.ERROR);
        LogstashTcpSocketAppender infoAppender = appenderBuilder.logAppenderBuild(name, action, Level.INFO);
        LogstashTcpSocketAppender warnAppender = appenderBuilder.logAppenderBuild(name, action, Level.WARN);
        LogstashTcpSocketAppender debugAppender = appenderBuilder.logAppenderBuild(name, action, Level.DEBUG);
        LogstashTcpSocketAppender traceAppender = appenderBuilder.logAppenderBuild(name, action, Level.TRACE);
        list.add(errorAppender);
        list.add(infoAppender);
        list.add(warnAppender);
        list.add(debugAppender);
        list.add(traceAppender);
        return list;
    }
}

LogAspect

使用spring aop,實現攔截使用者請求,記錄使用者紀錄檔。比如ip、請求引數、請求使用者等資訊,需要配合下面的XiaoBaWangLog註解使用。
這裡攔截上面所說的第二種紀錄檔型別。

@Aspect
@Component
public class LogAspect {

    @Autowired
    LoggerBuilder loggerBuilder;

    private ThreadLocal<Long> startTime = new ThreadLocal<>();

    private SysLog sysLog;

    @Pointcut("@annotation(com.xiaobawang.common.log.annotation.XiaoBaWangLog)")
    public void pointcut() {
    }

    /**
     * 前置方法執行
     *
     * @param joinPoint
     */
    @Before("pointcut()")
    public void before(JoinPoint joinPoint) {
        startTime.set(System.currentTimeMillis());
        //獲取請求的request
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        String clientIP = ServletUtil.getClientIP(request, null);
        if ("0.0.0.0".equals(clientIP) || "0:0:0:0:0:0:0:1".equals(clientIP) || "localhost".equals(clientIP) || "127.0.0.1".equals(clientIP)) {
            clientIP = "127.0.0.1";
        }
        sysLog = new SysLog();
        sysLog.setIp(clientIP);
        String requestParams = JSONUtil.toJsonStr(getRequestParams(request));
        sysLog.setRequestParams(requestParams.length() > 5000 ? ("請求引數過長,引數長度為:" + requestParams.length()) : requestParams);
        MethodSignature ms = (MethodSignature) joinPoint.getSignature();
        Method method = ms.getMethod();
        String logName = method.getAnnotation(XiaoBaWangLog.class).value();
        sysLog.setLogName(logName);
        sysLog.setUserAgent(request.getHeader("User-Agent"));
        String fullUrl = request.getRequestURL().toString();
        if (request.getQueryString() != null && !"".equals(request.getQueryString())) {
            fullUrl = request.getRequestURL().toString() + "?" + request.getQueryString();
        }
        sysLog.setRequestUrl(fullUrl);
        sysLog.setRequestMethod(request.getMethod());
        //tkSysLog.setUsername(JwtUtils.getUsername());
    }

    /**
     * 方法返回後執行
     *
     * @param ret
     */
    @AfterReturning(returning = "ret", pointcut = "pointcut()")
    public void after(Object ret) {
        Logger logger = loggerBuilder.getLogger(LogAction.USER_ACTION);
        String retJsonStr = JSONUtil.toJsonStr(ret);
        if (retJsonStr != null) {
            sysLog.setResponseInfo(retJsonStr.length() > 5000 ? ("響應引數過長,引數長度為:" + retJsonStr.length()) : retJsonStr);
        }
        sysLog.setUseTime(System.currentTimeMillis() - startTime.get());
        logger.info(JSONUtil.toJsonStr(sysLog));
    }

    /**
     * 環繞通知,收集方法執行期間的錯誤資訊
     *
     * @param proceedingJoinPoint
     * @return
     * @throws Throwable
     */
    @Around("pointcut()")
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {

        try {
            Object obj = proceedingJoinPoint.proceed();
            return obj;
        } catch (Exception e) {
            e.printStackTrace();
            sysLog.setExceptionInfo(e.getMessage());
            Logger logger = loggerBuilder.getLogger(LogAction.USER_ACTION);
            logger.error(JSONUtil.toJsonStr(sysLog));
            throw e;
        }
    }

    /**
     * 獲取請求的引數
     *
     * @param request
     * @return
     */
    private Map getRequestParams(HttpServletRequest request) {
        Map map = new HashMap();
        Enumeration paramNames = request.getParameterNames();
        while (paramNames.hasMoreElements()) {
            String paramName = (String) paramNames.nextElement();
            String[] paramValues = request.getParameterValues(paramName);
            if (paramValues.length == 1) {
                String paramValue = paramValues[0];
                if (paramValue.length() != 0) {
                    map.put(paramName, paramValue);
                }
            }
        }
        return map;
    }


}

XiaoBaWangLog

LoggerLoad主要是實現使用者級別紀錄檔的收集功能。
這裡定義了一個註解,在controller方法上加上@XiaoBaWangLog("操作內容"),即可攔截並生成請求紀錄檔。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface XiaoBaWangLog {

    String value() default "";

}

LoggerLoad

LoggerLoad主要是實現系統級別紀錄檔的收集功能。
繼承ApplicationRunner,可以在springboot執行後,自動建立系統級別紀錄檔logger物件。

@Component
@Order(value = 1)
@Slf4j
public class LoggerLoad implements ApplicationRunner {
    @Autowired
    LoggerBuilder loggerBuilder;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        loggerBuilder.addContextAppender();
        log.info("載入紀錄檔模組成功");
    }
}

LogConfig

LogConfig主要實現自定義級別紀錄檔的收集功能。
生成一個logger物件交給spring容器管理。後面直接從容器取就可以了。

@Configuration
public class LogConfig {

    @Autowired
    LoggerBuilder loggerBuilder;

    @Bean
    public Logger loggerBean(){
        return loggerBuilder.getLogger();
    }
}

程式碼到現在已經全部完成,怎麼將上述的所有Bean加入到spring呢?這個時候就需要用到spring.factories了。

spring.factories

在EnableAutoConfiguration中加入類的全路徑名,在專案啟動的時候,SpringFactoriesLoader會初始化spring.factories,包括pom中引入的jar包中的設定類。
注意,spring.factories在2.7開始已經不推薦使用,3.X版本的springBoot是不支援使用的。

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.xiaobawang.common.log.config.AppenderBuilder,\
  com.xiaobawang.common.log.config.LoggerBuilder,\
  com.xiaobawang.common.log.load.LoggerLoad,\
  com.xiaobawang.common.log.aspect.LogAspect,\
  com.xiaobawang.common.log.config.LogConfig

測試

先將xiaobawang進行打包
新建一個springboot專案,引入打包好的xiaobawang-log.


執行springboot,出現「載入紀錄檔模組成功」表示紀錄檔模組啟動成功。

接著新建一個controller請求

存取請求後,可以看到了三種不同型別的索引了

結束

還有很多需要優化的地方,比如ELK設定使用者名稱密碼登入等,對ELK比較瞭解的童鞋可以自己嘗試優化!
如果這篇文章對你有幫助,記得一鍵三連~