背景
在项目开发过程中,日志记录是一个至关重要的环节,它能够帮助开发人员追踪用户的重要操作,如新增、删除、修改等,从而有效监控系统的运行状态。通过日志记录,我们可以深入了解系统的运行情况,及时发现并解决问题,优化性能,提高用户体验。
为了满足不同项目的需求,我们期望设计一个可移植性强、易于使用的日志记录系统。这样的系统应该能够轻松集成到各个项目中,而不需要对原有业务代码进行大量修改,在这种场景下,Spring AOP(面向切面编程)为我们提供了一个优雅的解决方案。
Spring AOP 允许我们在不改变原有业务逻辑的情况下,通过添加注解的方式,在关键方法或类上插入日志记录代码。这样,我们就能在不影响业务逻辑的前提下,实现对用户操作的监控和记录。
基于Spring AOP的日志记录系统不仅具有高度的可移植性,而且易于扩展和维护,通过简单的配置和少量的代码编写,我们就能轻松地将日志记录功能集成到其他Spring Boot项目中,这样的设计使得我们能够在多个项目之间共享和复用日志记录逻辑,提高了开发效率和质量。
准备工作
依赖引入
<!-- 版本控制 -->
<hutool.versin>5.8.25</hutool.versin>
<lombok.versin>1.18.22</lombok.versin>
<mybatis-plus.version>3.5.4</mybatis-plus.version>
<!-- hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.versin}</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.versin}</version>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
数据表建立
-- MySQL dump 10.13 Distrib 8.0.26, for Win64 (x86_64)
--
-- Host: 127.0.0.1 Database: logger
-- ------------------------------------------------------
-- Server version 8.0.26
DROP TABLE IF EXISTS `logger`;
CREATE TABLE `logger` (
`id` bigint NOT NULL COMMENT '日志id',
`operation_user` varchar(32) DEFAULT NULL COMMENT '用户名',
`operation_ip` varchar(64) DEFAULT NULL COMMENT 'ip地址',
`request_method` varchar(255) DEFAULT NULL COMMENT '请求方法',
`operation` varchar(255) DEFAULT NULL COMMENT '用户操作',
`params` text COMMENT '请求参数',
`run_time` bigint DEFAULT NULL COMMENT '运行时间',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='日志表';
LOCK TABLES `logger` WRITE;
UNLOCK TABLES;
其他
为了方便代码管理,我们先建好相关包文件夹
- annotation目录:包含自定义注解
- aspect目录:包含自定义切面类
- controller目录:后台日志管理Controller层
- entity目录:日志记录实体类
- mapper目录:日志管理Mapper层
- service目录:日志管理Service层
- utils目录:包含一些工具类
代码实现
自定义注解-SystemLogger
import java.lang.annotation.*;
/**
* 该注解用于标记在系统日志中需要记录的方法
* 注解被定义为 @interface,表示它是一个自定义注解
*
* @author b16mt
*/
@Target(ElementType.METHOD) // 该注解仅可用于方法声明
@Retention(RetentionPolicy.RUNTIME) // 该注解在运行时可见
@Documented // 该注解将包含在 Javadoc 中
public @interface SystemLogger {
/**
* 用于指定记录的系统日志的描述信息
* 默认值为空字符串,可以在使用注解时提供自定义的描述信息
* 例如:@SystemLogger(value = "员工管理-新增员工数据")
*/
String value() default "";
/**
* 开启控制台输出日志,默认关闭
* 例如:@SystemLogger(value = "员工管理-新增员工数据", consoleLog = true)
*/
boolean consoleLog() default false;
}
自定义切面类-LoggerAspect
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.date.SystemClock;
import com.xjl.sys.log.entity.Logger;
import com.xjl.sys.log.annotation.SystemLogger;
import com.xjl.sys.log.service.SystemLoggerService;
import com.xjl.sys.log.utils.IpAddressUtil;
import com.xjl.sys.log.utils.JsonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 切面类,用于记录系统日志信息
*
* @author b16mt
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class LoggerAspect {
private final SystemLoggerService systemLoggerService;
/**
* 环绕通知,用于在目标方法执行前后进行日志记录
*/
@Around("@annotation(systemLogger)")
public Object around(ProceedingJoinPoint joinPoint, SystemLogger systemLogger) throws Throwable {
// 记录方法开始执行的时间
long beginTime = SystemClock.now();
// 执行目标方法
Object result = joinPoint.proceed();
// 记录方法执行时长(毫秒)
long time = SystemClock.now() - beginTime;
// 创建系统日志实体
Logger logger = new Logger();
// 从注解中获取操作描述
if (systemLogger != null) {
logger.setOperation(systemLogger.value());
}
// 获取请求的方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
logger.setRequestMethod(className + "." + methodName + "()");
// 获取请求的参数
Object[] args = joinPoint.getArgs();
String params = JsonUtil.toJsonString(args[0]);
logger.setParams(params);
// 设置IP地址
logger.setOperationIp(IpAddressUtil.getIpAddr());
// 设置用户名
String username = getUsername();
logger.setOperationUser(username);
// 设置日志记录时间
logger.setCreateDate(LocalDateTime.now());
logger.setRunTime(time);
// 保存系统日志
systemLoggerService.save(logger);
// 控制台输出日志,这块默认是不开启滴,一般也就在项目测试的时候开启一下
if (systemLogger != null && systemLogger.consoleLog()) {
log.info("用户【{}】进行了【{}】操作,请求参数为:{}", username, systemLogger.value(), params);
}
return result;
}
// TODO 获取当前登录用户需根据自己项目的登录方法修改,当前采用的方案为Sa-Token
/**
* 获取用户名
*
* @return 用户名
*/
private String getUsername() {
try {
// 尝试获取登录用户名
return (String) StpUtil.getLoginId();
} catch (Exception e) {
// 获取登录用户名失败,获取用户ip地址
return IpAddressUtil.getIpAddr();
}
}
}
工具类-HttpContextUtil
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* @author b16mt
*/
public class HttpContextUtil {
/**
* 获取当前请求的 HttpServletRequest
*
* @return HttpServletRequest 对象
*/
public static HttpServletRequest getHttpServletRequest() {
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}
/**
* 获取当前请求的域名
*
* @return 当前请求的域名
*/
public static String getDomain() {
HttpServletRequest request = getHttpServletRequest();
StringBuilder url = new StringBuilder(request.getRequestURL());
// 移除 URI 部分,保留域名
int lastSlashIndex = url.lastIndexOf(request.getRequestURI());
if (lastSlashIndex > 0) {
url.delete(lastSlashIndex, url.length());
}
return url.toString();
}
/**
* 获取当前请求的 Origin 头信息
*
* @return Origin 头信息
*/
public static String getOrigin() {
HttpServletRequest request = getHttpServletRequest();
return request.getHeader("Origin");
}
}
工具类-IpAddressUtil
import javax.servlet.http.HttpServletRequest;
/**
* IP工具
*
* @author b16mt
*/
public class IpAddressUtil {
private static final String UNKNOWN = "unknown";
/**
* 获取用户的真实地址,如果有多个地址则返回第一个
*
* @return 用户真实地址,如果无法获取则返回 null
*/
public static String getIpAddr() {
// 获取 HttpServletRequest 对象
HttpServletRequest request = HttpContextUtil.getHttpServletRequest();
// 如果 HttpServletRequest 对象为空,则无法获取地址,返回 null
if (request == null) {
return null;
}
// 尝试从请求头中获取用户 IP 地址
String[] headersToCheck = {"x-forwarded-for", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
String ip = null;
for (String header : headersToCheck) {
ip = request.getHeader(header);
if (!isEmptyIp(ip)) {
break;
}
}
// 如果仍然无法获取 IP 地址,则使用默认的 RemoteAddr
if (isEmptyIp(ip)) {
ip = request.getRemoteAddr();
}
// 处理可能存在的多个 IP 地址,只返回第一个
String[] ips = ip.split(",");
return ips[0].trim();
}
/**
* 检查IP地址是否为空或为unknown
*
* @param ip 待检查的IP地址
* @return 如果IP地址为空或为unknown,则返回true;否则返回false
*/
private static boolean isEmptyIp(String ip) {
return ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip);
}
}
工具类-JsonUtil
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.json.JsonWriteFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import lombok.extern.slf4j.Slf4j;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* JSON工具类,提供对象与JSON字符串之间的转换
*
* @author b16mt
*/
@Slf4j
public class JsonUtil {
/** 使用 Jackson ObjectMapper 处理 JSON 的序列化和反序列化 */
private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
// 在序列化时,排除空属性
.serializationInclusion(JsonInclude.Include.NON_EMPTY)
// 在序列化时,禁用空bean的异常
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
// 在序列化时,禁用将日期写为时间戳的功能
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
// 在反序列化时,禁用未知属性的异常
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
// 配置禁用非ASCII字符的转义
.configure(JsonWriteFeature.ESCAPE_NON_ASCII.mappedFeature(), false)
.build();
/**
* 将对象转换为JSON字符串
*
* @param object 要转换的对象
* @return 对象的JSON字符串表示形式
*/
public static String toJsonString(Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
} catch (JsonProcessingException e) {
log.error("对象转json错误:", e);
return null;
}
}
/**
* 将JSON字符串转换为指定类型的对象
*
* @param json 要解析的JSON字符串
* @param clazz 目标对象的类型
* @return 解析后的对象
*/
public static <T> T parseObject(String json, Class<T> clazz) {
try {
return OBJECT_MAPPER.readValue(json, clazz);
} catch (Exception e) {
log.error("Json转换错误:", e);
return null;
}
}
/**
* 将JSON字符串转换为指定类型的对象列表
*
* @param json 要解析的JSON字符串
* @param clazz 目标对象的数组类型
* @return 解析后的对象列表
*/
public static <T> List<T> parseArray(String json, Class<T[]> clazz) {
try {
return Arrays.asList(OBJECT_MAPPER.readValue(json, clazz));
} catch (Exception e) {
log.error("Json转换错误:", e);
return Collections.emptyList();
}
}
/**
* 将JSON字符串转换为JsonNode对象
*
* @param jsonStr 要解析的JSON字符串
* @return JsonNode对象
*/
public static JsonNode parseJson(String jsonStr) {
try {
return OBJECT_MAPPER.readTree(jsonStr);
} catch (Exception e) {
log.error("Json转换错误:", e);
return null;
}
}
/**
* 将JSON字符串转换为指定键的JsonNode对象
*
* @param jsonStr 要解析的JSON字符串
* @param key 要获取的键
* @return 指定键对应的JsonNode对象
*/
public static JsonNode parseJson(String jsonStr, String key) {
JsonNode jsonNode = parseJson(jsonStr);
if (jsonNode != null) {
return jsonNode.get(key);
} else {
return null;
}
}
/**
* 获取全局的ObjectMapper实例
*
* @return ObjectMapper实例
*/
public static ObjectMapper getObjectMapper() {
return OBJECT_MAPPER;
}
}
实体类-Logger
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 日志表logger-实体类
*
* @author b16mt
*/
@Data
@TableName("logger")
public class Logger implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 日志id
*/
private Long id;
/**
* 用户名
*/
private String operationUser;
/**
* IP地址
*/
private String operationIp;
/**
* 请求方法
*/
private String requestMethod;
/**
* 用户操作
*/
private String operation;
/**
* 请求参数
*/
private String params;
/**
* 执行时长(毫秒)
*/
private Long runTime;
/**
* 创建时间
*/
private LocalDateTime createDate;
}
控制层-LoggerController
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xjl.common.result.Result;
import com.xjl.sys.log.service.SystemLoggerService;
import com.xjl.sys.log.entity.Logger;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
/**
* @author b16mt
*/
@Slf4j
@RestController
@RequestMapping("/sysLogger")
public class LoggerController {
private final SystemLoggerService systemLoggerService;
public LoggerController(SystemLoggerService systemLoggerService) {
this.systemLoggerService = systemLoggerService;
}
/**
* 分页查询-日志信息
*
* @param page 查询页面
* @param pageSize 每页展示最大条数
* @param username 用户名
* @param ip IP地址
* @param operation 用户操作
*
* @return Page<SystemLogger>对象
*/
@GetMapping("/getLogInformation")
public Result<Page<Logger>> loggerList(@RequestParam Integer page,
@RequestParam Integer pageSize,
@RequestParam String username,
@RequestParam String ip,
@RequestParam String operation){
// 参数校验
if (page == null || page < 1) {
// 如果页码为null或小于1,将页码设为默认值1
page = 1;
}
if (pageSize == null || pageSize < 1) {
// 如果每页展示的最大条数为null或小于1,将其设为默认值20
pageSize = 20;
}
// 构造分页构造器,即Page对象
Page<Logger> pageInfo = new Page<>(page, pageSize);
// 构造条件构造器 queryWrapper
LambdaQueryWrapper<Logger> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.like(StringUtils.isNotEmpty(operation), Logger::getOperation, operation)
.like(StringUtils.isNotEmpty(username), Logger::getOperationUser, username)
.like(StringUtils.isNotEmpty(ip), Logger::getOperationIp, ip);
// 调用部门服务的分页查询方法,将查询结果封装到pageInfo中
systemLoggerService.page(pageInfo, queryWrapper);
// 将查询结果包装成成功的Result对象并返回
return Result.success(pageInfo);
}
/**
* 删除-日志信息
*
* @param ids 日志id,支持批量删除
* @return 响应结果-success/error
*/
@DeleteMapping("/delLogInformation")
public Result<String> loggerDelete(@RequestParam Long[] ids){
if (ids == null || ids.length == 0) {
return Result.error("请提供有效的日志ID~");
}
systemLoggerService.removeByIds(Arrays.asList(ids));
return Result.success("删除日志信息成功~");
}
}
Service层
interface-SystemLoggerService
import com.baomidou.mybatisplus.extension.service.IService;
import com.xjl.sys.log.entity.Logger;
/**
* 系统日志
*
* @author b16mt
*/
public interface SystemLoggerService extends IService<Logger> {
}
impl-SystemLoggerServiceImpl
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xjl.sys.log.entity.Logger;
import com.xjl.sys.log.mapper.SystemLoggerMapper;
import com.xjl.sys.log.service.SystemLoggerService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
/**
* @author b16mt
*/
@Service("LoggerService")
public class SystemLoggerServiceImpl extends ServiceImpl<SystemLoggerMapper, Logger> implements SystemLoggerService {
}
Mapper层-SystemLoggerMapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xjl.sys.log.entity.Logger;
/**
* 系统日志
*
* @author b16mt
*/
public interface SystemLoggerMapper extends BaseMapper<Logger> {
}
注意
上述控制层使用的分页插件是mp的分页插件,建议自己根据项目实际需求更换,上述代码是为了进行简单演示而使用。如果坚持使用mp的分页插件,那么请你添加如下mp配置类MybatisPlusConfig:
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis Plus 配置类,配置分页插件
*/
@Configuration
public class MybatisPlusConfig {
/**
* 配置 MyBatis Plus 的分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}
测试
先发送几个测试请求
在POST接口中,开启了控制台日志输出,即consoleLog = true,所以在控制台中我们可以看到输出结果:
在数据库中,我们也可以看到记录了相关操作日志:
查询日志记录:
写在最后
该demo基于Spring AOP完成了一个日志记录模块,大家可以基于自己项目实际进行简单的修改(比如拓展控制层的其他接口功能)使用,可以使用在一些竞赛项目系统、毕业设计XX管理系统等等,代码中我已经添加了详细的注释,如有任何疑问或建议,欢迎随时与我交流,期待与你共同学习进步!