实际开发中,如果项目权限管控比较严格,自己又上不去服务器查看日志文件,怎么办?而且日志文件查看也比较繁琐。就随便搞一个数据库记录请求参数与响应数据的日志框架。方便自己排查问题排查问题。
AOP
切面技术,将controller层的入参与出参,还有错误信息输出到数据库表logger_info
中。注: 由于整个项目使用的是mybatis-plus框架,所以添加了service
和mapper
层,可以使用SQL语句替换
既然是记录,当然是有记录表了,入参,出参,请求,类,方法,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;
}
这里自定义日志的打印级别,分别为:不打印,打印正常,打印错误,全部打印。根据自身需要,自行修改就好了。
package com.cah.project.module.logger.conf;
/**
* 功能描述: 日志级别枚举
*/
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;全部打印-ALL
logger:
project:
level: PRINT
在保存日志时,使用@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<LoggerInfoEntity> {
/**
* 功能描述: 异步保存
*
* @param info 日志信息
*/
@Async
default void saveAsync(LoggerInfoEntity info) {
save(info);
}
}
ServiceImpl 和 Mapper 略,直接继承基类就好了。
这个东西,网上找个就好了,主要是为了获取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;
/**
* 功能描述: 请求工具类
*/
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)) {
// 根据网卡取本机配置的 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;
}
}
前面弄的那么多,都是为了给这个切面类服务的。
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;
/**
* 功能描述: 日志切面
*/
@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;
}
}
在项目启动后,需要判断是否需要创建日志表,如果已经存在,则跳过,不存在,则创建日志表。
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;
/**
* 功能描述: 日志信息启动执行
* 日志备份,创建日志表等操作
*/
@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();
}
}
/**
* 功能描述: 建表
*/
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
实现类即可。
queryTable
和backTable
为什么会有入参呢,是因为备份表的命名规则为:原表名+“_”+日期。如果不想集成Mybaties-plus,则可以将insert语句放在这个接口里。
package com.cah.project.module.logger.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语句工厂
*/
@Getter
@AllArgsConstructor
public 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 {
@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 + ";";
}
}
备份日志选择了通过@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;
/**
* 功能描述: 日志备份任务
*/
@Component
public class LoggerInfoBackTask {
@Autowired
private DataSource dataSource;
@Autowired
private 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代码地址
自己项目的日子记录,自己查看起来方便,区别于整体项目框架的日志。方便自己在没有权限的时候排查问题,开个小后门。有条件的话,自己写一个前端,然后做一下权限控制。