在企业应用开发中,操作日志记录是确保系统安全性、可追溯性以及调试分析的重要手段之一。通过记录用户的操作行为,不仅可以帮助开发者快速定位问题,还能满足审计和合规需求。本文旨在探讨如何在SpringBoot应用程序中通过AOP(面向切面编程)和自定义注解实现操作日志记录,并将日志存储到数据库中。我们将详细介绍实现这一功能的完整流程,包括项目环境搭建、数据库设计、代码实现及测试验证等步骤。
操作日志在企业应用中扮演着至关重要的角色。它不仅能够记录用户的操作行为,还能帮助开发和运维人员快速定位和解决问题,提升系统的稳定性和安全性。通过记录操作日志,企业可以:
在SpringBoot项目中,通过AOP(面向切面编程)和自定义注解来实现操作日志记录具有诸多好处:
CREATE TABLE `sys_oper_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志主键', `title` varchar(50) DEFAULT '' COMMENT '模块标题', `business_type` varchar(20) DEFAULT '0' COMMENT '业务类型(0其它 1新增 2修改 3删除)', `method` varchar(100) DEFAULT '' COMMENT '方法名称', `request_method` varchar(10) DEFAULT '' COMMENT '请求方式', `oper_name` varchar(50) DEFAULT '' COMMENT '操作人员', `oper_url` varchar(255) DEFAULT '' COMMENT '请求URL', `oper_ip` varchar(128) DEFAULT '' COMMENT '主机地址', `oper_param` varchar(2000) DEFAULT '' COMMENT '请求参数', `json_result` varchar(2000) DEFAULT '' COMMENT '返回参数', `status` int(1) DEFAULT '0' COMMENT '操作状态(1正常 0异常)', `error_msg` varchar(2000) DEFAULT '' COMMENT '错误消息', `oper_time` datetime DEFAULT NULL COMMENT '操作时间', `execute_time` bigint(20) NOT NULL DEFAULT '0' COMMENT '执行时长(毫秒)', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=64 DEFAULT CHARSET=utf8 COMMENT='操作日志记录';
/** * 操作日志记录 * * @date 2024/07/14 */ @Data @Schema(description = "操作日志记录") @TableName(value = "sys_oper_log") public class SysOperLog implements Serializable { @TableField(exist = false) private static final long serialVersionUID = 1L; @TableId(type = IdType.AUTO) @Schema(description = "日志主键") private Long id; @Schema(description = "模块标题") private String title; @Schema(description = "业务类型(0其它 1新增 2修改 3删除)") private String businessType; @Schema(description = "方法名称") private String method; @Schema(description = "请求方式") private String requestMethod; @Schema(description = "操作类别(0其它 1后台用户 2手机端用户)") private String operatorType; @Schema(description = "操作人员") private String operName; @Schema(description = "请求URL") private String operUrl; @Schema(description = "主机地址") private String operIp; @Schema(description = "请求参数") private String operParam; @Schema(description = "返回参数") private String jsonResult; @Schema(description = "操作状态(1正常 0异常)") private Integer status; @Schema(description = "错误消息") private String errorMsg; @Schema(description = "操作时间") private Date operTime; @Schema(description = "执行时长") private long executeTime; }
/** * 业务操作类型 * */ public enum BusinessType { /** * 其他类型 */ OTHER, /** * 新增 */ INSERT, /** * 修改 */ UPDATE, /** * 删除 */ DELETE, /** * 更新状态 */ STATUS, /** * 授权 */ ASSIGN }
/** * 自定义操作日志记录注解 * */ @Target({ElementType.PARAMETER, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Log { /** * 模块名称 */ String title() default ""; /** * 业务操作类型 */ BusinessType businessType() default BusinessType.OTHER; /** * 是否保存请求参数 */ boolean isSaveRequestData() default true; /** * 是否保存响应数据 */ boolean isSaveResponseData() default true; /** * 排除指定的请求参数 */ public String[] excludeParamNames() default {}; }
/** * 操作状态 * */ public enum BusinessStatus { /** * 成功 */ SUCCESS, /** * 失败 */ FAIL, }
/** * IP工具类 */ public class IpUtil { /** * 获取ip * @param request 请求 * @return {@link String } */ public static String getIpAddress(HttpServletRequest request) { String ipAddress = null; try { ipAddress = request.getHeader("x-forwarded-for"); if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("Proxy-Client-IP"); } if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getHeader("WL-Proxy-Client-IP"); } if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) { ipAddress = request.getRemoteAddr(); if (ipAddress.equals("127.0.0.1")) { // 根据网卡取本机配置的IP InetAddress inet = null; try { inet = InetAddress.getLocalHost(); } catch (UnknownHostException e) { e.printStackTrace(); } ipAddress = inet.getHostAddress(); } } // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割 if (ipAddress != null && ipAddress.length() > 15) { // "***.***.***.***".length() // = 15 if (ipAddress.indexOf(",") > 0) { ipAddress = ipAddress.substring(0, ipAddress.indexOf(",")); } } } catch (Exception e) { ipAddress=""; } // ipAddress = this.getRequest().getRemoteAddr(); return ipAddress; } /** * 获取网关ip * @param request 请求 * @return {@link String } */ public static String getGatwayIpAddress(ServerHttpRequest request) { HttpHeaders headers = request.getHeaders(); String ip = headers.getFirst("x-forwarded-for"); if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) { // 多次反向代理后会有多个ip值,第一个ip才是真实ip if (ip.indexOf(",") != -1) { ip = ip.split(",")[0]; } } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = headers.getFirst("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = headers.getFirst("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = headers.getFirst("HTTP_CLIENT_IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = headers.getFirst("HTTP_X_FORWARDED_FOR"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = headers.getFirst("X-Real-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddress().getAddress().getHostAddress(); } return ip; } }
注意:这里不同的spring-web依赖版本
ServletRequestAttributes
的getResponse()
返回结果是不同的,我这里使用的spring-web:3.2.2
,返回值为jakarta包下面的HttpServletResponse
,而一些旧版本的就会返回javax包下的,因此要根据自身版本进行修改。
import cn.hutool.core.thread.threadlocal.NamedThreadLocal; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.support.spring.PropertyPreFilters; import com.voyager.annotation.Log; import com.voyager.domain.entity.SysOperLog; import com.voyager.domain.enums.BusinessStatus; import com.voyager.entity.User; import com.voyager.service.SysOperLogService; import com.voyager.utils.IpUtil; import com.voyager.utils.UserHolder; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ArrayUtils; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.AfterThrowing; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.validation.BindingResult; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.multipart.MultipartFile; import java.util.Collection; import java.util.Date; import java.util.Map; /** * 日志切面 */ @Aspect @Component @RequiredArgsConstructor public class LogAspect { /** * 定义需要排除在日志记录之外的属性名称数组 */ private static final String[] EXCLUDE_PROPERTIES = {"password", "oldPassword", "newPassword", "confirmPassword"}; private final SysOperLogService sysOperLogService; /** * 使用ThreadLocal维护一个线程局部变量,用于记录操作的耗时 */ private static final ThreadLocal TIME_THREADLOCAL = new NamedThreadLocal("Cost Time"); /** * 返回通知 * * @param joinPoint 切点 */ @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult") public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) { //调用处理日志的方法 handleLog(joinPoint, controllerLog, null, jsonResult); } /** * 异常通知 * * @param joinPoint 切点 * @param e 异常 */ @AfterThrowing(pointcut = "@annotation(controllerLog)", throwing = "e") public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) { handleLog(joinPoint, controllerLog, e, null); } /** * 处理请求前执行,此方法旨在记录方法的开始时间。 * * @param joinPoint 切点 * @param controllerLog 一个注解对象,表示目标方法上标注的注解。这里用于判断方法是否应该被此切面处理。 */ @Before(value = "@annotation(controllerLog)") public void boBefore(JoinPoint joinPoint, Log controllerLog) { TIME_THREADLOCAL.set(System.currentTimeMillis()); } /** * 处理操作日志的逻辑。 * 当方法执行完毕或发生异常时,此方法用于封装和记录操作日志。 * * @param joinPoint 切点,用于获取目标方法的信息。 * @param controllerLog 控制器上的日志注解,用于获取方法描述等信息。 * @param e 异常对象,如果方法执行过程中抛出异常。 * @param jsonResult 方法返回的对象,用于日志记录,此参数可能为null。 */ private void handleLog(JoinPoint joinPoint, Log controllerLog, Exception e, Object jsonResult) { try { // 获取当前请求的属性,包括HttpServletRequest对象。 RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); // 如果请求属性为空,则直接返回,不处理日志。 if (requestAttributes == null) { return; } // 将请求属性转换为ServletRequestAttributes,以便获取HttpServletRequest对象。 ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; // 获取HttpServletRequest对象。 HttpServletRequest request = servletRequestAttributes.getRequest(); // 重新获取请求属性,目的是为了后续获取请求方法等信息。 RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes http = (ServletRequestAttributes) attributes; // 再次获取HttpServletRequest对象。 HttpServletRequest httpServletRequest = http.getRequest(); // 创建SysOperLog对象,用于存储操作日志的信息。 SysOperLog sysOperLog = new SysOperLog(); // 默认设置操作状态为正常。 sysOperLog.setStatus(BusinessStatus.SUCCESS.ordinal()); // 如果方法执行过程中抛出异常,则将操作状态设置为异常。 if (e != null) { // 设置状态为异常 sysOperLog.setStatus(BusinessStatus.FAIL.ordinal()); // 设置异常信息。 sysOperLog.setErrorMsg(e.getMessage()); } // 获取ip地址 String ipAddress = IpUtil.getIpAddress(request); // 设置ip地址 sysOperLog.setOperIp(ipAddress); // 设置请求地址 sysOperLog.setOperUrl(request.getRequestURI()); // 获取当前登录的用户信息。 User user = UserHolder.getUser(); // 获取用户名 String username = UserHolder.getUser().getUserName(); // 设置操作者名称。 // 设置操作人员 sysOperLog.setOperName(username); // 获取并设置请求方法,例如GET、POST等。 sysOperLog.setRequestMethod(request.getMethod()); // 获取目标对象的类名。 String className = joinPoint.getTarget().getClass().getName(); // 获取方法名 String methodName = joinPoint.getSignature().getName(); // 设置方法名称 sysOperLog.setMethod(className + "." + methodName + "()"); // 获取注解中对方法的描述信息 getControllerMethodDescription(joinPoint, controllerLog, jsonResult, sysOperLog); // 计算执行时长(毫秒) long executeTime = System.currentTimeMillis() - TIME_THREADLOCAL.get(); sysOperLog.setExecuteTime(executeTime); // 设置操作时间。 sysOperLog.setOperTime(new Date()); // 保存操作日志 sysOperLogService.save(sysOperLog); } catch (Exception ex) { // 记录处理日志过程中发生的异常。 ex.printStackTrace(); } } /** * 从注解中获取控制器方法的描述信息,并填充到操作日志对象中。 * * @param joinPoint 切点对象,用于获取方法名和参数信息。 * @param controllerLog 控制器日志注解对象,包含标题、业务类型等配置信息。 * @param jsonResult 方法的返回结果,用于判断是否需要记录响应数据。 * @param sysOperLog 系统操作日志对象,此处将从controllerLog中获取的信息填充到该对象中。 */ private void getControllerMethodDescription(JoinPoint joinPoint, Log controllerLog, Object jsonResult, SysOperLog sysOperLog) { //设置操作模块 sysOperLog.setTitle(controllerLog.title()); //设置业务类型 sysOperLog.setBusinessType(controllerLog.businessType().name()); // 判断是否需要保存请求数据,如果需要,则调用setRequestValue方法进行处理 if (controllerLog.isSaveRequestData()) { //调用设置请求数据的方法 setRequestValue(joinPoint, sysOperLog, controllerLog.excludeParamNames()); } // 判断是否需要保存响应数据且返回结果不为空,如果满足条件,则将返回结果转为JSON字符串并保存到操作日志中 if (controllerLog.isSaveResponseData() && !StringUtils.isEmpty(jsonResult)) { //设置响应数据 sysOperLog.setJsonResult(JSON.toJSONString(jsonResult)); } } /** * 设置操作日志的请求参数信息。 * * @param joinPoint 切点,用于获取方法参数。 * @param operLog 操作日志对象,用于设置请求参数信息。 * @param excludeParamNames 需要排除的参数名数组,这些参数不会被记录在日志中。 */ private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog, String[] excludeParamNames) { // 获取当前请求的属性 Map parameterMap = getParameterMap(); // 如果参数不为空且不为空集合 if (parameterMap != null && !parameterMap.isEmpty()) { // 将参数转换为JSON字符串,通过excludePropertyPreFilter过滤掉不需要记录的参数 String params = JSONObject.toJSONString(parameterMap, excludePropertyPreFilter(excludeParamNames)); // 设置操作日志的请求参数,截取前2000个字符以防止过长 operLog.setOperParam(org.apache.commons.lang3.StringUtils.substring(params, 0, 2000)); } else { // 如果请求参数为空,尝试从方法参数中获取信息 Object args = joinPoint.getArgs(); // 如果方法参数不为空 if (args != null) { // 将方法参数转换为字符串,同样支持排除某些参数名 String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames); // 设置操作日志的请求参数,同样截取前2000个字符 operLog.setOperParam(org.apache.commons.lang3.StringUtils.substring(params, 0, 2000)); } } } /** * 获取当前HTTP请求的参数 * * @return 一个Map,映射参数名称到参数值数组。这允许处理多值参数。 */ private static Map getParameterMap() { // 从Spring的RequestContextHolder中获取当前请求的属性 RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); // 将RequestAttributes强制转换为ServletRequestAttributes,以便访问HTTP请求特定的属性 ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; // 从ServletRequestAttributes中获取当前HTTP请求对象 HttpServletRequest request = (HttpServletRequest) servletRequestAttributes.getRequest(); // 获取请求的所有参数 Map parameterMap = request.getParameterMap(); return parameterMap; } /** * 忽略敏感属性 * * @param excludeParamNames 需要排除的参数名数组 * @return {@link PropertyPreFilters.MySimplePropertyPreFilter } */ public PropertyPreFilters.MySimplePropertyPreFilter excludePropertyPreFilter(String[] excludeParamNames) { return new PropertyPreFilters().addFilter().addExcludes(ArrayUtils.addAll(EXCLUDE_PROPERTIES, excludeParamNames)); } /** * 将对象数组转换为字符串,排除指定的参数名(敏感参数)。 * * @param paramsArray 参数数组,可以包含任意类型的对象。 * @param excludeParamNames 需要排除的参数名数组,这些参数不会被转换为字符串。 * @return 返回转换后的参数字符串,各参数间以空格分隔。 */ private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) { // 使用StringBuilder来构建最终的参数字符串 StringBuilder params = new StringBuilder(); // 检查参数数组是否为空或长度为0,避免不必要的处理 if (paramsArray != null) { // 遍历参数数组中的每个对象 for (Object o : paramsArray) { // 检查对象是否为空且不属于被过滤的类型 if (o != null && !isFilterObject(o)) { try { // 将对象转换为JSON字符串,排除指定的属性 Object jsonObj = JSONObject.toJSONString(o, excludePropertyPreFilter(excludeParamNames)); // 将转换后的JSON字符串追加到参数字符串中,并以空格分隔各个参数 params.append(jsonObj).append(" "); } catch (Exception ignored) { // 忽略转换过程中的异常,确保方法的健壮性 } } } } return params.toString().trim(); } /** * 判断传入的对象是否需要被过滤。 * 这个方法主要用于处理上传文件时,判断接收的参数是否为文件类型或其他特定类型。 * * @param o 待检查的对象 * @return 如果对象需要被过滤(即对象为MultipartFile或其他特定类型),则返回true;否则返回false。 */ @SuppressWarnings("rawtypes") public boolean isFilterObject(final Object o) { // 获取对象的类类型 Class> clazz = o.getClass(); // 检查对象是否为数组类型 if (clazz.isArray()) { // 如果数组的组件类型可以被MultipartFile类转换,则返回true return clazz.getComponentType().isAssignableFrom(MultipartFile.class); } else if (Collection.class.isAssignableFrom(clazz)) { // 如果对象是集合类型,将其转换为Collection接口实例 Collection collection = (Collection) o; // 遍历集合中的每个元素,如果任意元素是MultipartFile实例,则返回true for (Object value : collection) { return value instanceof MultipartFile; } } else if (Map.class.isAssignableFrom(clazz)) { // 如果对象是Map类型,将其转换为Map接口实例 Map map = (Map) o; // 遍历Map中的每个条目,如果任意条目的值是MultipartFile实例,则返回true for (Object value : map.entrySet()) { Map.Entry entry = (Map.Entry) value; return entry.getValue() instanceof MultipartFile; } } // 如果对象不是数组、集合或Map类型,检查它是否为MultipartFile、HttpServletRequest、HttpServletResponse或BindingResult实例 return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse || o instanceof BindingResult; } }
执行流程分析:
请求到达:当一个请求到达目标方法时,切面会首先执行
boBefore
方法,记录方法的开始时间。这个时间被存储在一个ThreadLocal
对象中,用于后续计算方法的执行时长。@Before(value = "@annotation(controllerLog)") public void boBefore(JoinPoint joinPoint, Log controllerLog) { TIME_THREADLOCAL.set(System.currentTimeMillis()); }
方法执行:
- 正常返回:如果目标方法执行成功并返回结果,切面会执行
doAfterReturning
方法。这个方法会调用handleLog
方法来处理操作日志。@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult") public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) { handleLog(joinPoint, controllerLog, null, jsonResult); }
- 异常返回:如果目标方法执行过程中抛出异常,切面会执行
doAfterThrowing
方法。这个方法也会调用handleLog
方法来处理操作日志,并记录异常信息。@AfterThrowing(pointcut = "@annotation(controllerLog)", throwing = "e") public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) { handleLog(joinPoint, controllerLog, e, null); }
日志处理:在
handleLog
方法中,切面会收集各种请求信息、方法信息、执行时长等数据,并将这些数据封装到一个SysOperLog
对象中,最后通过sysOperLogService
保存该日志对象。获取和设置日志信息:在
handleLog
方法内部,通过调用一些辅助方法来获取和设置日志的详细信息,包括请求参数、响应数据等。
/** * 获取用户信息 * * @param id 用户id * @return {@link Result }<{@link UserInfo }> */ @Log(title = "获取用户信息", businessType = BusinessType.OTHER) @Operation(description = "获取用户信息") @GetMapping("/{id}") public Result getUser(@PathVariable Long id) { return Result.success(userInfoService.getById(id)); } /** * 插入用户信息 * * @param userInfo 用户信息 * @return {@link Result }<{@link String }> */ @Log(title = "插入用户信息", businessType = BusinessType.INSERT) @Operation(description = "插入用户信息") @PostMapping public Result insertUser(@RequestBody UserInfo userInfo) { boolean saved = userInfoService.save(userInfo); if (!saved) { return Result.error("插入失败"); } return Result.success(); } /** * 更新用户信息 * * @param userInfo 用户信息 * @return {@link Result }<{@link String }> */ @Log(title = "更新用户信息", businessType = BusinessType.UPDATE) @Operation(description = "更新用户信息") @PutMapping public Result updateUser(@RequestBody UserInfo userInfo) { boolean updated = userInfoService.updateById(userInfo); if (!updated) { return Result.error("更新失败"); } return Result.success(); } /** * 删除用户信息 * @param id i用户id * @return {@link Result }<{@link String }> */ @Log(title = "删除用户信息", businessType = BusinessType.DELETE) @Operation(description = "删除用户信息") @DeleteMapping("/{id}") public Result deleteUser(@PathVariable Long id) { boolean deleted = userInfoService.removeById(id); if (!deleted) { return Result.error("删除失败"); } return Result.success(); }
本文主要参考了若依框架的操作日志记录功能的实现,记录了操作日志记录功能的实现和其中遇到的一些问题(比如:getResponse()
返回值的问题)。在文章的开始,我们探讨了在SpringBoot应用程序中实现日志操作日志记录的重要性,随后采用基于AOP+注解的解决方案,以将日志数据存储到数据库中。通过这个方案,我们能够有效地记录用户的操作行为,从而方便后续的审计和分析,希望对大家有所帮助😊。
附录:
若依仓库地址