Spring Boot 3 集成Spring AOP实现系统日志记录
前言
在Spring AOP中,JoinPoint和ProceedingJoinPoint都是关键的接口,用于在切面中获取方法的相关信息以及控制方法的执行。它们的主要区别在于它们在AOP通知中的使用方式和功能。
- 功能定位:
- JoinPoint:代表了程序执行流程中的一个特定点,如方法的调用、异常的抛出等。JoinPoint主要用于获取连接点的信息,如方法名、参数、目标对象等,但不能控制方法的执行。
- ProceedingJoinPoint:是JoinPoint的子接口,除了能获取连接点的信息外,还能控制方法的执行。它主要用于环绕通知(Around advice),通过调用
proceed()
方法来继续执行原始方法。
- 应用场景:
- JoinPoint:适用于四大通知类型(前置通知、后置通知、异常通知和最终通知),主要用于记录运行的数据和获取连接点的基本信息。
- ProceedingJoinPoint:主要用于环绕通知,因为它提供了
proceed()
方法来继续执行原始方法。在环绕通知中,你可以在执行原始方法前后添加额外的逻辑。
使用方法
- JoinPoint:
- 获取方法调用时传入的参数:使用
Object[] args = joinPoint.getArgs();
。 - 获取被通知的目标对象:使用
Object target = joinPoint.getTarget();
。 - 获取代理方法的信息:使用
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
。 - 获取增强方法中的返回值:在
@AfterReturning
注解的方法中使用Object ret
参数。 - 获取增强方法中的异常对象:在
@AfterThrowing
注解的方法中使用Throwable e
参数。
- ProceedingJoinPoint:
- 继续执行被通知的方法:在环绕通知中,使用
Object result = joinPoint.proceed();
来继续执行原始方法。 - 获取方法参数和目标对象:使用与JoinPoint相同的方法,如
Object[] args = joinPoint.getArgs();
和Object target = joinPoint.getTarget();
。 - 在环绕通知中执行额外逻辑:在调用
proceed()
方法前后添加任何你需要的逻辑,如性能测量、安全检查等。
示例代码
// JoinPoint 示例
@Before("execution(* com.example.myapp.MyService.*(..))")
public void logBefore(JoinPoint joinPoint) {
System.out.println("Method " + joinPoint.getSignature().getName() + " called with arguments: " + Arrays.toString(joinPoint.getArgs()));
}
// ProceedingJoinPoint 示例
@Around("execution(* com.example.myapp.MyService.*(..))")
public Object measurePerformance(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed(); // 继续执行原始方法
long executionTime = System.currentTimeMillis() - start;
System.out.println("Method " + joinPoint.getSignature().getName() + " executed in " + executionTime + "ms");
return result;
}
ProceedingJoinPoint 不能接收异常通知。在Spring AOP中,异常通知(@AfterThrowing)用于在目标方法抛出异常时执行的通知,它通常接收一个JoinPoint对象来获取连接点的信息,但不能接收ProceedingJoinPoint对象。只有环绕通知(@Around)可以接收ProceedingJoinPoint对象,该对象提供了继续执行原始方法的能力(通过调用proceed()
方法),并且可以在方法执行前后添加额外的逻辑,包括异常处理。因此,对于异常通知来说,它只能使用JoinPoint来获取相关信息,而不能使用ProceedingJoinPoint。要统计方法的执行时间,通常需要使用环绕通知(@Around
),因为它允许你在方法执行前后都添加逻辑。
然而,我即想要记录异常,又想统计执行时间。我们可以使用JoinPoint来获取相关信息,分别使用 @AfterReturning
和 @AfterThrowing
来统计执行时间,利用 @Pointcut
来定义切点,你需要一种方法来在方法开始时记录开始时间,并在方法结束时(无论是正常结束还是异常结束)记录结束时间。由于 @AfterReturning
和 @AfterThrowing
是独立的通知,它们无法直接共享状态,因此你需要一种机制来在它们之间传递开始时间。一个常见的做法是使用 ThreadLocal
变量来存储开始时间,因为 ThreadLocal
变量对于每个线程都是独立的,所以可以在不同的通知之间共享数据而不会发生冲突。
下面是一个使用 @Pointcut
、@AfterReturning
和 @AfterThrowing
来统计执行时间的示例,其中使用了 ThreadLocal
来存储开始时间:
/**
* 日志切面
*
* @author harry
* @公众号 Harry技术
*/
@Slf4j
@Aspect
@Component
public class WebLogAspect {
/**
* 线程绑定变量,用于记录请求的开始时间
*/
private static final ThreadLocal<Long> START_TIME_THREAD_LOCAL = new ThreadLocal<>();
/**
* 配置织入点
*/
@Pointcut("@annotation(cn.harry.common.annotation.SysLog)")
public void logPointCut() {
}
@Before("logPointCut()")
public void recordStartTime(JoinPoint joinPoint) {
START_TIME_THREAD_LOCAL.set(System.currentTimeMillis());
}
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Object jsonResult) {
// 执行时间
long time = System.currentTimeMillis() - START_TIME_THREAD_LOCAL.get();
handleLog(joinPoint, null, jsonResult, time);
START_TIME_THREAD_LOCAL.remove();
}
/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "logPointCut()", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
// 执行时间
long time = System.currentTimeMillis() - START_TIME_THREAD_LOCAL.get();
handleLog(joinPoint, e, null, time);
START_TIME_THREAD_LOCAL.remove();
}
在这个示例中:
@Pointcut
定义了一个切点表达式logPointCut()
,它匹配@annotation(cn.harry.common.annotation.SysLog)
注解下的所有方法。@Before
注解的方法recordStartTime
在切点匹配的方法执行之前被调用,用于记录开始时间,并将其存储在ThreadLocal
变量中。@AfterReturning
注解的方法doAfterReturning
在切点匹配的方法正常执行后被调用,用于计算并打印执行时间,并从ThreadLocal
变量中清除开始时间。@AfterThrowing
注解的方法doAfterThrowing
在切点匹配的方法抛出异常后被调用,同样用于计算并打印执行时间(包括异常发生前的时间),并从ThreadLocal
变量中清除开始时间。
请注意,
ThreadLocal
变量必须在使用完毕后清除,以避免潜在的内存泄漏和错误的数据共享。在这个示例中,我们在每个通知的末尾都调用了startTimeThreadLocal.remove()
来清除开始时间。
项目实战
以Harry技术
为例: https://gitee.com/harry-tech/harry.git
引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
定义日志表
CREATE TABLE `sys_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '模块标题',
`method` varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '方法名称',
`request_method` varchar(10) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '请求方式',
`operator_type` varchar(10) COLLATE utf8mb4_general_ci DEFAULT '1' COMMENT '操作类别(0 其它 1 后台用户 2 移动端用户)',
`username` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '操作人员',
`url` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '请求URL',
`ip` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '主机地址',
`location` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '操作地点',
`param` varchar(2000) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '请求参数',
`json_result` varchar(2000) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '返回参数',
`status` char(1) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '0' COMMENT '操作状态(0正常 1异常)',
`error_msg` varchar(2000) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT '' COMMENT '错误消息',
`execution_time` bigint DEFAULT NULL COMMENT '执行时间(ms)',
`browser` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '浏览器',
`browser_version` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '浏览器版本',
`os` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '终端系统',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`valid` int DEFAULT '1' COMMENT '有效状态,0:无效 1:有效',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='系统日志表';
创建SysLog注解
/**
* 系统日志注解
*
* @author harry
* @公众号 Harry技术
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
/**
* 模块
*/
String title() default "";
/**
* 功能
*/
BusinessType businessType() default BusinessType.OTHER;
/**
* 是否保存请求的参数
*/
boolean isSaveRequestData() default true;
}
日志切面
/**
* 日志切面
*
* @author harry
* @公众号 Harry技术
*/
@Slf4j
@Aspect
@Component
public class WebLogAspect {
/**
* 线程绑定变量,用于记录请求的开始时间
*/
private static final ThreadLocal<Long> START_TIME_THREAD_LOCAL = new ThreadLocal<>();
/**
* 配置织入点
*/
@Pointcut("@annotation(cn.harry.common.annotation.SysLog)")
public void logPointCut() {
}
@Before("logPointCut()")
public void recordStartTime(JoinPoint joinPoint) {
START_TIME_THREAD_LOCAL.set(System.currentTimeMillis());
}
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Object jsonResult) {
long time = System.currentTimeMillis() - START_TIME_THREAD_LOCAL.get();
handleLog(joinPoint, null, jsonResult, time);
START_TIME_THREAD_LOCAL.remove();
}
/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "logPointCut()", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
long time = System.currentTimeMillis() - START_TIME_THREAD_LOCAL.get();
handleLog(joinPoint, e, null, time);
START_TIME_THREAD_LOCAL.remove();
}
protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult, Long executionTime) {
try {
// 获得注解
cn.harry.common.annotation.SysLog log = getAnnotationLog(joinPoint);
if (log == null) {
return;
}
// 获取当前的用户
SysUser loginUser = SecurityUtils.getSysUser();
// *========数据库日志=========*//
SysLog sysLog = new SysLog();
sysLog.setStatus(StatusEnums.ENABLE.getKey());
HttpServletRequest request = ServletUtils.getRequest();
// 请求的IP地址
String ip = IpUtil.getIp(request);
sysLog.setIp(ip);
// 返回参数
sysLog.setJsonResult(JSONUtil.toJsonStr(jsonResult));
// 请求URL
sysLog.setUrl(request.getRequestURI());
if (loginUser != null) {
sysLog.setUsername(loginUser.getUsername());
}
if (e != null) {
sysLog.setStatus(StatusEnums.DISABLE.getKey());
sysLog.setErrorMsg(StrUtil.sub(e.getMessage(), 0, 2000));
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
sysLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
sysLog.setRequestMethod(ServletUtils.getRequest().getMethod());
sysLog.setCreateTime(DateUtil.date());
// 获取浏览器和终端系统信息
String userAgentString = request.getHeader("User-Agent");
UserAgent userAgent = UserAgentUtil.parse(userAgentString);
// 系统信息
sysLog.setOs(userAgent.getOs().getName());
// 浏览器信息
sysLog.setBrowser(userAgent.getBrowser().getName());
sysLog.setBrowserVersion(userAgent.getBrowser().getVersion(userAgentString));
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, log, sysLog);
// 执行时长
sysLog.setExecutionTime(executionTime);
// 保存数据库
AsyncManager.me().execute(AsyncFactory.logTask(sysLog));
} catch (Exception exp) {
// 记录本地异常日志
log.error("==前置通知异常==");
log.error("异常信息:{}", exp.getMessage());
}
}
/**
* 获取注解中对方法的描述信息 用于Controller层注解
*
* @param log 日志
* @param sysLog 操作日志
*/
public void getControllerMethodDescription(JoinPoint joinPoint, cn.harry.common.annotation.SysLog log, SysLog sysLog) throws Exception {
// 设置标题
sysLog.setTitle(log.title());
// 是否需要保存request,参数和值
if (log.isSaveRequestData()) {
// 获取参数的信息,传入到数据库中。
setRequestValue(joinPoint, sysLog);
}
}
/**
* 获取请求的参数,放到log中
*
* @param sysLog 操作日志
* @throws Exception 异常
*/
private void setRequestValue(JoinPoint joinPoint, SysLog sysLog) throws Exception {
String requestMethod = sysLog.getRequestMethod();
if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
String params = argsArrayToString(joinPoint.getArgs());
sysLog.setParam(StrUtil.sub(params, 0, 2000));
} else {
Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
sysLog.setParam(StrUtil.sub(paramsMap.toString(), 0, 2000));
}
}
/**
* 是否存在注解,如果存在就获取
*/
private cn.harry.common.annotation.SysLog getAnnotationLog(JoinPoint joinPoint) throws Exception {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
if (method != null) {
return method.getAnnotation(cn.harry.common.annotation.SysLog.class);
}
return null;
}
/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray) {
StringBuilder params = new StringBuilder();
if (paramsArray != null) {
for (Object o : paramsArray) {
if (!isFilterObject(o)) {
Object jsonObj = JSONUtil.toJsonStr(o);
params.append(jsonObj.toString()).append(" ");
}
}
}
return params.toString().trim();
}
/**
* 判断是否需要过滤的对象。
*
* @param o 对象信息。
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
public boolean isFilterObject(final Object o) {
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse;
}
}
使用
@Operation(summary = "更新")
@PreAuthorize("@ss.hasPermission('sys_user_edit')")
@SysLog(title = "sys_user", businessType = BusinessType.UPDATE)
@PutMapping
public R<Integer> update(@RequestBody SysUser sysUser) {
return sysUserService.updateUser(sysUser) ? R.success() : R.failed();
}
操作记录信息
此功能在Harry技术中已经完成整合,后续会不断拓展新的功能,如邮件发送,公众号整合,大屏展示等
基于SpringBoot3+Vue3前后端分离的Java快速开发框架
平台简介
基于 JDK 17、Spring Boot 3、Spring Security 6、JWT、Redis、Mybatis-Plus、Knife4j等构建后端,基于Vue 3、Element-Plus 、TypeScript等构建前端的分离单体权限管理系统。
- 🚀 开发框架: 使用 Spring Boot 3 和 Vue 3,以及 Element-Plus 等主流技术栈,实时更新。
- 🔐 安全认证: 结合 Spring Security 和 JWT 提供安全、无状态、分布式友好的身份验证和授权机制。
- 🔑 权限管理: 基于 RBAC 模型,实现细粒度的权限控制,涵盖接口方法和按钮级别。
- 🛠️ 功能模块: 包括用户管理、角色管理、菜单管理、部门管理、字典管理等多个功能。
- 📘 接口文档: 自动生成接口文档,支持在线调试,提高开发效率。
内置功能
- 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
- 部门管理:配置系统组织机构(公司、部门、小组),树结构展现支持数据权限。
- 菜单管理:配置系统菜单,操作权限,按钮权限标识等。
- 角色管理:角色菜单权限分配、设置角色按机构进行数据范围权限划分。
- 字典管理:对系统中经常使用的一些较为固定的数据进行维护。
- 参数管理:对系统动态配置常用参数。
- 操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。
- 登录日志:系统登录日志记录查询包含登录异常。
- 系统接口:根据业务代码自动生成相关的api接口文档,引入swagger接口文档服务的工具(Knife4j)。
- 代码生成:完善的代码生成机制.减少基础代码的编写,专注于业务逻辑实现
后端开发
Gitee仓库地址: https://gitee.com/harry-tech/harry.git
前端开发
- 本项目是前后端分离的,还需要部署前端,才能运行起来
Gitee仓库地址: https://gitee.com/harry-tech/harry-vue.git
效果展示
Watermark 水印
暗黑模式
觉着有帮助,给个Star再走呗 ~~~~
公众号搜“Harry技术”,关注我,带你看不一样的人间烟火!