请求与响应参数日志记录框架
前言
实际开发中,如果项目权限管控比较严格,自己又上不去服务器查看日志文件,怎么办?而且日志文件查看也比较繁琐。就随便搞一个数据库记录请求参数与响应数据的日志框架。方便自己排查问题排查问题。
设计
- 使用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; /** * 功能描述: 日志级别枚举 <br/> */ public enum LoggerLevelEnum { /** 不打印 */ NONE, /** 打印正常 */ PRINT, /** 打印异常 */ ERROR, /** 全部打印 */ ALL, }
3. 日志级别配置
将日志级别放到配置文件中,方便修改调整。
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;全部打印-ALL logger: 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; /** * 功能描述: 日志服务接口 <br/> */ public interface ILoggerInfoService extends IService<LoggerInfoEntity> { /** * 功能描述: 异步保存 <br/> * * @param info 日志信息 */ @Async default 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; /** * 功能描述: 请求工具类 <br/> */ 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<String, String> getHeader() { HttpServletRequest request = getHttpServletRequest(); if(request == null) { return new HashMap<>(); } Map<String, String> headerMap = new HashMap<>(); Enumeration<String> 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)) { // 根据网卡取本机配置的 IP InetAddress iNet = null; try { iNet = InetAddress.getLocalHost(); } catch (UnknownHostException e) { e.printStackTrace(); } if (iNet != null) ip = iNet.getHostAddress(); } } // 对于通过多个代理的情况,分割出第一个 IP if (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; } }
6. 请求日志切面(核心)
前面弄的那么多,都是为了给这个切面类服务的。
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; /** * 功能描述: 日志切面 <br/> */ @Order(99) @Aspect @Component public class RequestLogAspect { @Autowired private LoggerConfig loggerConfig; @Autowired private 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(); // 设置url info.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; } }
7. 启动监听
在项目启动后,需要判断是否需要创建日志表,如果已经存在,则跳过,不存在,则创建日志表。
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; /** * 功能描述: 日志信息启动执行 <br/> * 日志备份,创建日志表等操作 */ @Component public class LoggerInfoApplicationListener implements CommandLineRunner { @Autowired private DataSource dataSource; @Autowired private LoggerConfig loggerConfig; private IDdlSql ddl; @Override public 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(); } } /** * 功能描述: 建表 <br/> */ private void createTable(Statement statement) throws SQLException { statement.execute(ddl.createTable()); } /** * 功能描述: 是否存在表 <br/> */ 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; /** * 功能描述: 数据库语句 <br/> */ public interface IDdlSql { /** 查询表是否存在 */ String queryTable(String date); /** 建表语句 */ String createTable(); /** 备份表 */ String backTable(String date); /** 删除表 */ String dropTable(); }
7.2 SQL 枚举工厂
package com.cah.project.module.logger.sql; import com.cah.project.module.logger.sql.impl.MySQLDdlSql; import lombok.AllArgsConstructor; import lombok.Getter; /** * 功能描述: SQL执行ddl语句工厂 <br/> */ @Getter @AllArgsConstructor public enum DdlSqlFactory { MYSQL(new MySQLDdlSql()), ; private final IDdlSql ddl; }
7.3 SQL 接口实现
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 { @Override public String queryTable(String date) { return "select count(1) from information_schema.tables where table_name ='" + LoggerInfoEntity.TABLE_NAME + (StrUtil.isNotBlank(date) ? "_" + date : "") + "';"; } @Override public 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 BTREE\n" + ") ENGINE=InnoDB COMMENT='日志信息';"; } @Override public String backTable(String date) { return "rename table " + LoggerInfoEntity.TABLE_NAME + " to " + LoggerInfoEntity.TABLE_NAME + "_" + date + ";"; } @Override public 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; /** * 功能描述: 日志备份任务 <br/> */ @Component public class LoggerInfoBackTask { @Autowired private DataSource dataSource; @Autowired private LoggerConfig loggerConfig; private IDdlSql ddl; /** * 功能描述: 每天0点1秒开始执行备份表(确定日期) <br/> */ @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(); } } /** * 功能描述: 每日备份操作 <br/> */ public void backTable(Statement statement, String yesterday) throws SQLException { // 先备份 statement.execute(ddl.backTable(yesterday)); // 再创建表 statement.execute(ddl.createTable()); } /** * 功能描述: 是否存在表 <br/> */ private boolean existTable(Statement statement, String yesterday) throws SQLException { ResultSet resultSet = statement.executeQuery(ddl.queryTable(yesterday)); resultSet.next(); return resultSet.getInt(1) == 1; } }
测试
启动项目后,随便访问访问,看看日志表有没有记录成功就好了。

调整日期,看看有没有进行日志备份

代码
总结
自己项目的日子记录,自己查看起来方便,区别于整体项目框架的日志。方便自己在没有权限的时候排查问题,开个小后门。有条件的话,自己写一个前端,然后做一下权限控制。
#Java##程序员#
查看17道真题和解析
