前言
實際開發中,如果項目權限管控比較嚴格,自己又上不去服務器查看日志文件,怎么辦?而且日志文件查看也比較繁瑣。就隨便搞一個數據庫記錄請求參數與響應數據的日志框架。方便自己排查問題排查問題。
設計
- 使用AOP切面技術,將controller層的入參與出參,還有錯誤信息輸出到數據庫表logger_info中。
- 配合日志級別,使得如果不需要,則不開啟,或者只輸出特定級別的操作。
- 啟動時,創建日志表。不需要手動創建。
- 定時備份日志表,減少單表數據量過大。
根據以上內容工程分成設計如下:
注:由于整個項目使用的是MyBatis-plus框架,所以添加了service和mApper層,可以使用SQL語句替換
正文1. 日志實體(日志表)
既然是記錄,當然是有記錄表了,入參,出參,請求,類,方法,IP,執行時間,都是基本記錄。所以就有如下的實體設計。
package com.cah.project.module.logger.domain.entity;import com.baomidou.mybatisplus.annotation.FieldFill;import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableField;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import io.swagger.annotations.ApiModel;import io.swagger.annotations.ApiModelProperty;import lombok.Data;import JAVA.time.LocalDate;@Data@TableName(LoggerInfoEntity.TABLE_NAME)@ApiModel("日志信息")public class LoggerInfoEntity {public static final String TABLE_NAME = "logger_info";@ApiModelProperty("主鍵ID")@TableId(value = "id", type = IdType.AUTO)private Long id;@ApiModelProperty("訪問的url")@TableField("url")private String url;@ApiModelProperty("類名")@TableField("class_name")private String className;@ApiModelProperty("方法名")@TableField("method_name")private String methodName;@ApiModelProperty("請求的ip地址")@TableField("req_ip_adr")private String reqIpAdr;@ApiModelProperty("響應的ip地址(集群提供)")@TableField("rsp_ip_adr")private String rspIpAdr;@ApiModelProperty("成功標志")@TableField("success_ind")private Boolean successInd;@ApiModelProperty("請求報文頭")@TableField("req_header")private String reqHeader;@ApiModelProperty("請求報文體")@TableField("req_body")private String reqBody;@ApiModelProperty("響應報文體")@TableField("rsp_body")private String rspBody;@ApiModelProperty("錯誤信息")@TableField("error_msg")private String errorMsg;@ApiModelProperty("總耗時")@TableField("total_time")private Long totalTime;@ApiModelProperty("創建時間")@TableField(value = "create_time", fill = FieldFill.INSERT)private LocalDate createTime;
2. 日志打印級別
這里自定義日志的打印級別,分別為:不打印,打印正常,打印錯誤,全部打印。根據自身需要,自行修改就好了。
package com.cah.project.module.logger.conf;* 功能描述: 日志級別枚舉
3. 日志級別配置
public enum LoggerLevelEnum {/** 不打印 */NONE,/** 打印正常 */PRINT,/** 打印異常 */ERROR,/** 全部打印 */ALL,
將日志級別放到配置文件中,方便修改調整。
package com.cah.project.module.logger.conf;import lombok.Data;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;@Data@Component@ConfigurationProperties(prefix = "logger.project")public class LoggerConfig {/** 定義日志級別 */private LoggerLevelEnum level = LoggerLevelEnum.NONE;復制代碼
默認級別為:不打印。如果需要調整級別,則在application.yml配置文件中,添加如下配置即可生效。
# 日志打印級別:不打印-NONE;打印正常-PRINT;打印異常-ERROR;全部打印-ALLlogger:project:level: PRINT
4. 日志保存服務
在保存日志時,使用@Async注解,達到異步效果,不影響主流程。在接口直接使用default關鍵字,省事。
package com.cah.project.module.logger.service;import com.baomidou.mybatisplus.extension.service.IService;import com.cah.project.module.logger.domain.entity.LoggerInfoEntity;import org.springframework.scheduling.annotation.Async;* 功能描述: 日志服務接口
public interface ILoggerInfoService extends IService {* 功能描述: 異步保存
* @param info 日志信息@Asyncdefault void saveAsync(LoggerInfoEntity info) {save(info);
ServiceImpl 和 Mapper 略,直接繼承基類就好了。
5. 請求工具類
這個東西,網上找個就好了,主要是為了獲取HttpServletRequest的請求頭和 IP地址的作用。如果不需要記錄,都可以刪除了。
package com.cah.project.module.logger.util;import org.springframework.web.context.Request.RequestContextHolder;import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;import java.NET.InetAddress;import java.net.UnknownHostException;import java.util.Enumeration;import java.util.HashMap;import java.util.Map;* 功能描述: 請求工具類
6. 請求日志切面(核心)
public class HttpRequestUtil {private static final String UNKNOWN = "unknown";private static final String LOCALHOST_IP = "127.0.0.1";// 客戶端與服務器同為一臺機器,獲取的 ip 有時候是 ipv6 格式private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";private static final String SEPARATOR = ",";public static HttpServletRequest getHttpServletRequest() {ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if(servletRequestAttributes == null) {return null;return servletRequestAttributes.getRequest();public static Map getHeader() {HttpServletRequest request = getHttpServletRequest();if(request == null) {return new HashMap<>();Map headerMap = new HashMap<>();Enumeration headerNames = request.getHeaderNames();while (headerNames.hasMoreElements()) {String headerName = headerNames.nextElement();headerMap.put(headerName, request.getHeader(headerName));return headerMap;public static String getRealIpAddress() {HttpServletRequest request = getHttpServletRequest();if (request == null) {return "";String ip = request.getHeader("x-forwarded-for");if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {ip = request.getHeader("Proxy-Client-IP");if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {ip = request.getHeader("X-Forwarded-For");if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {ip = request.getHeader("WL-Proxy-Client-IP");if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {ip = request.getHeader("X-Real-IP");if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {ip = request.getRemoteAddr();if (LOCALHOST_IP.equalsIgnoreCase(ip) || LOCALHOST_IPV6.equalsIgnoreCase(ip)) {// 根據網卡取本機配置的 IPInetAddress iNet = null;try {iNet = InetAddress.getLocalHost();} catch (UnknownHostException e) {e.printStackTrace();if (iNet != null)ip = iNet.getHostAddress();// 對于通過多個代理的情況,分割出第一個 IPif (ip != null && ip.length() > 15) {if (ip.indexOf(SEPARATOR) > 0) {ip = ip.substring(0, ip.indexOf(SEPARATOR));return LOCALHOST_IPV6.equals(ip) ? LOCALHOST_IP : ip;
前面弄的那么多,都是為了給這個切面類服務的。
package com.cah.project.module.logger.aspect;import cn.hutool.core.net.NetUtil;import cn.hutool.json.JSONUtil;import com.cah.project.module.logger.conf.LoggerConfig;import com.cah.project.module.logger.conf.LoggerLevelEnum;import com.cah.project.module.logger.domain.entity.LoggerInfoEntity;import com.cah.project.module.logger.service.ILoggerInfoService;import com.cah.project.module.logger.util.HttpRequestUtil;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import org.springframework.web.context.request.RequestContextHolder;import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;import java.util.Optional;* 功能描述: 日志切面
7. 啟動監聽
@Order(99)@Aspect@Componentpublic class RequestLogAspect {@Autowiredprivate LoggerConfig loggerConfig;@Autowiredprivate ILoggerInfoService loggerInfoService;@Around("execution(* com.cah.project..*.controller..*.*(..))")public Object doAround(ProceedingJoinPoint point) throws Throwable {// 如果沒有開啟,則直接返回if(LoggerLevelEnum.NONE.equals(loggerConfig.getLevel())) {return point.proceed();long startTime = System.currentTimeMillis();LoggerInfoEntity info = new LoggerInfoEntity();// 設置urlinfo.setUrl(Optional.ofNullable((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).map(ServletRequestAttributes::getRequest).map(HttpServletRequest::getRequestURI).orElse(""));// 設置類名info.setClassName(point.getTarget().getClass().getName());// 設置方法名info.setMethodName(point.getSignature().getName());// 設置請求IP地址info.setReqIpAdr(HttpRequestUtil.getRealIpAddress());// 設置響應IP地址info.setRspIpAdr(NetUtil.getLocalhostStr());// 設置請求頭info.setReqHeader(JSONUtil.toJsonStr(HttpRequestUtil.getHeader()));// 設置請求體info.setReqBody(JSONUtil.toJsonStr(point.getArgs()));// 設置請求成功info.setSuccessInd(Boolean.TRUE);// 定義返回值Object obj;try {Object result = point.proceed();info.setRspBody(JSONUtil.toJsonStr(result));obj = result;} catch (Exception e) {// 設置請求異常info.setSuccessInd(Boolean.FALSE);// 設置異常信息info.setErrorMsg(e.getLocalizedMessage());throw e;} finally {// 計算處理時間info.setTotalTime(System.currentTimeMillis() - startTime);// 如果為全部打印或正常打印,并且為正常標志,記錄if(LoggerLevelEnum.ALL.equals(loggerConfig.getLevel()) || (LoggerLevelEnum.PRINT.equals(loggerConfig.getLevel()) && info.getSuccessInd())) {loggerInfoService.saveAsync(info);// 如果為全部打印或者異常打印,并且為異常標志,記錄if(LoggerLevelEnum.ALL.equals(loggerConfig.getLevel()) || (LoggerLevelEnum.ERROR.equals(loggerConfig.getLevel())) && !info.getSuccessInd()) {loggerInfoService.saveAsync(info);return obj;
在項目啟動后,需要判斷是否需要創建日志表,如果已經存在,則跳過,不存在,則創建日志表。
package com.cah.project.module.logger;import com.baomidou.mybatisplus.annotation.DbType;import com.baomidou.mybatisplus.extension.toolkit.JdbcUtils;import com.cah.project.module.logger.conf.LoggerConfig;import com.cah.project.module.logger.sql.DdlSqlFactory;import com.cah.project.module.logger.sql.IDdlSql;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.CommandLineRunner;import org.springframework.stereotype.Component;import javax.sql.DataSource;import java.sql.Connection;import java.sql.ResultSet;import java.sql.SQLException;import java.sql.Statement;* 功能描述: 日志信息啟動執行
* 日志備份,創建日志表等操作@Componentpublic class LoggerInfoApplicationListener implements CommandLineRunner {@Autowiredprivate DataSource dataSource;@Autowiredprivate LoggerConfig loggerConfig;private IDdlSql ddl;@Overridepublic void run(String... args) throws Exception {// 判斷數據庫類型Connection conn = dataSource.getConnection();try (Statement statement = conn.createStatement()) {DbType dbType = JdbcUtils.getDbType(conn.getMetaData().getURL());ddl = DdlSqlFactory.valueOf(dbType.name()).getDdl();// 查詢表有沒有存在if(!existTable(statement)) {createTable(statement);} catch (Exception e) {e.printStackTrace();* 功能描述: 建表
private void createTable(Statement statement) throws SQLException {statement.execute(ddl.createTable());* 功能描述: 是否存在表
private boolean existTable(Statement statement) throws SQLException {ResultSet resultSet = statement.executeQuery(ddl.queryTable(""));resultSet.next();return resultSet.getInt(1) == 1;
介紹一下 SQL 語句的設計思路。因為可能會擴展到不同的數據庫(正常也沒那么多屁事)使用枚舉,實現單利單利工廠模式,如果真的有需要擴展,則只要修改DdlSqlFactory類和添加一個擴展的IDdlSql實現類即可。
7.1 SQL 接口
queryTable和backTable為什么會有入參呢,是因為備份表的命名規則為:原表名+"_"+日期。如果不想集成Mybaties-plus,則可以將insert語句放在這個接口里。
package com.cah.project.module.logger.sql;* 功能描述: 數據庫語句
7.2 SQL 枚舉工廠
public interface IDdlSql {/** 查詢表是否存在 */String queryTable(String date);/** 建表語句 */String createTable();/** 備份表 */String backTable(String date);/** 刪除表 */String dropTable();package com.cah.project.module.logger.sql;import com.cah.project.module.logger.sql.impl.MySQLDdlSql;import lombok.AllArgsConstructor;import lombok.Getter;* 功能描述: SQL執行ddl語句工廠
7.3 SQL 接口實現
@Getter@AllArgsConstructorpublic enum DdlSqlFactory {MYSQL(new MySQLDdlSql()),private final IDdlSql ddl;package com.cah.project.module.logger.sql.impl;import cn.hutool.core.util.StrUtil;import com.cah.project.module.logger.domain.entity.LoggerInfoEntity;import com.cah.project.module.logger.sql.IDdlSql;public class MySQLDdlSql implements IDdlSql {@Overridepublic String queryTable(String date) {return "select count(1) from information_schema.tables where table_name ='" + LoggerInfoEntity.TABLE_NAME + (StrUtil.isNotBlank(date) ? "_" + date : "") + "';";@Overridepublic String createTable() {return "create table " + LoggerInfoEntity.TABLE_NAME + "(" +" `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',n" +" `url` varchar(500) COMMENT '訪問的url',n" +" `class_name` varchar(500) COMMENT '類名',n" +" `method_name` varchar(100) COMMENT '方法名',n" +" `req_ip_adr` varchar(20) COMMENT '請求的ip地址',n" +" `rsp_ip_adr` varchar(20) COMMENT '響應的ip地址',n" +" `success_ind` tinyint COMMENT '成功標志',n" +" `req_header` text COMMENT '請求報文頭',n" +" `req_body` text COMMENT '請求報文體',n" +" `rsp_body` text COMMENT '響應報文體',n" +" `error_msg` text COMMENT '錯誤信息',n" +" `total_time` Long COMMENT '總耗時',n" +" `create_time` datetime DEFAULT NULL COMMENT '創建時間',n" +" PRIMARY KEY (`id`) USING BTREE,n" +" KEY `idx_name` (`url`) USING BTREEn" +") ENGINE=InnoDB COMMENT='日志信息';";@Overridepublic String backTable(String date) {return "rename table " + LoggerInfoEntity.TABLE_NAME + " to " + LoggerInfoEntity.TABLE_NAME + "_" + date + ";";@Overridepublic String dropTable() {return "drop table " + LoggerInfoEntity.TABLE_NAME + ";";
8. 日志備份
備份日志選擇了通過@Scheduled定時器來處理。使用時,需要在啟動類上面添加@EnableScheduling來開啟。
這里定時是每天的 00:00:01 秒開始備份。為啥是1秒呢?沒有為啥。
package com.cah.project.module.logger;import cn.hutool.core.date.DatePattern;import cn.hutool.core.date.DateUtil;import com.baomidou.mybatisplus.annotation.DbType;import com.baomidou.mybatisplus.extension.toolkit.JdbcUtils;import com.cah.project.module.logger.conf.LoggerConfig;import com.cah.project.module.logger.conf.LoggerLevelEnum;import com.cah.project.module.logger.sql.DdlSqlFactory;import com.cah.project.module.logger.sql.IDdlSql;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;import javax.sql.DataSource;import java.sql.Connection;import java.sql.ResultSet;import java.sql.SQLException;import java.sql.Statement;* 功能描述: 日志備份任務
測試
@Componentpublic class LoggerInfoBackTask {@Autowiredprivate DataSource dataSource;@Autowiredprivate LoggerConfig loggerConfig;private IDdlSql ddl;* 功能描述: 每天0點1秒開始執行備份表(確定日期)
@Scheduled(cron = "1 0 0 * * ?")public void backTask() throws Throwable {if(LoggerLevelEnum.NONE.equals(loggerConfig.getLevel())) {return;Connection conn = dataSource.getConnection();try (Statement statement = conn.createStatement()) {DbType dbType = JdbcUtils.getDbType(conn.getMetaData().getURL());ddl = DdlSqlFactory.valueOf(dbType.name()).getDdl();String yesterday = DateUtil.format(DateUtil.yesterday(), DatePattern.PURE_DATE_FORMAT);// 查詢表有沒有存在if(!existTable(statement, yesterday)) {backTable(statement, yesterday);} catch (Exception e) {e.printStackTrace();* 功能描述: 每日備份操作
public void backTable(Statement statement, String yesterday) throws SQLException {// 先備份statement.execute(ddl.backTable(yesterday));// 再創建表statement.execute(ddl.createTable());* 功能描述: 是否存在表
private boolean existTable(Statement statement, String yesterday) throws SQLException {ResultSet resultSet = statement.executeQuery(ddl.queryTable(yesterday));resultSet.next();return resultSet.getInt(1) == 1;
啟動項目后,隨便訪問訪問,看看日志表有沒有記錄成功就好了。
調整日期,看看有沒有進行日志備份
代碼
project-logger代碼地址
總結
自己項目的日子記錄,自己查看起來方便,區別于整體項目框架的日志。方便自己在沒有權限的時候排查問題,開個小后門。有條件的話,自己寫一個前端,然后做一下權限控制。