- 发布日期
后端Web进阶:AOP、切点与操作日志
- 作者
- 姓名
- 缘
- 社交账号
文章目录
从一段重复代码认识 AOP
假设需要统计所有业务层方法的执行耗时。最直接的写法,是在每个方法中分别记录开始时间、调用业务代码、计算结束时间并输出日志:
public void delete(Integer id) {
long begin = System.currentTimeMillis();
deptMapper.delete(id);
long end = System.currentTimeMillis();
log.info("删除部门耗时:{} ms", end - begin);
}
这样能实现功能,却把同一段“计时逻辑”散落在很多业务方法中。以后要调整日志格式、统计规则,或者增加异常记录,就得逐个修改。
这类横跨多个模块、与核心业务无关但又反复出现的功能,通常称为横切关注点。日志、性能统计、事务、权限校验都属于这一类。
AOP(Aspect Oriented Programming,面向切面编程)就是把横切关注点抽出来,统一应用到符合条件的方法上。业务方法只保留业务本身,公共逻辑放在切面中维护。
Controller
↓
代理对象
↓ 前置 / 环绕等通知
目标 Service 方法
↓
通知继续处理结果、异常或耗时
AOP 是一种思想;Spring AOP 是 Spring 对这套思想的实现。它会为符合条件的 Bean 创建代理对象,在调用目标方法的合适时机执行通知代码。
快速实现:统一统计业务方法耗时
Spring Boot 项目使用 AOP 时,通常引入对应起步依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
然后声明一个切面。@Aspect 表示这是切面类,@Component 让它被 Spring 扫描并交给 IOC 容器管理。
@Slf4j
@Aspect
@Component
public class RecordTimeAspect {
@Around("execution(* com.example.service..*(..))")
public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
long begin = System.currentTimeMillis();
Object result = joinPoint.proceed();
long end = System.currentTimeMillis();
log.info("{}.{} 执行耗时:{} ms",
joinPoint.getTarget().getClass().getSimpleName(),
joinPoint.getSignature().getName(),
end - begin);
return result;
}
}
joinPoint.proceed() 很关键:它负责调用原始方法,并取得原始方法的返回值。漏掉它,目标方法就不会真正执行;漏掉 return result,有返回值的接口可能得到错误结果。
System.currentTimeMillis() 足以演示业务计时。若需要精细的耗时分析,通常改用 System.nanoTime(),并结合监控系统聚合数据,而不是只依赖单条日志。
五个核心概念
| 概念 | 含义 | 在计时案例中的对应内容 |
|---|---|---|
| 目标对象(Target) | 被增强的原始对象 | DeptServiceImpl |
| 连接点(JoinPoint) | 可以被 AOP 控制的方法执行位置 | 一次 delete() 调用 |
| 切入点(Pointcut) | 用来筛选连接点的规则 | execution(* com.example.service..*(..)) |
| 通知(Advice) | 在特定时机执行的公共逻辑 | 计时、记录日志的方法 |
| 切面(Aspect) | 切入点与通知的组合 | RecordTimeAspect |
可以把切面理解为一句完整的话:当某类方法被调用时,在指定时机执行某段公共逻辑。
Spring AOP 运行时,Controller 注入和调用的往往不是原始 Service,而是代理对象。代理对象先判断当前调用是否匹配切入点;匹配时执行通知,并在合适位置调用原始目标对象的方法。
通知类型:什么时候执行公共逻辑
Spring AOP 中最常见的通知有五种:
| 注解 | 执行时机 | 典型用途 |
|---|---|---|
@Before | 目标方法前 | 参数校验、记录开始日志 |
@After | 目标方法结束后,无论是否抛异常 | 资源清理 |
@AfterReturning | 正常返回后 | 记录返回结果 |
@AfterThrowing | 抛出异常后 | 异常告警、异常日志 |
@Around | 目标方法前后均可控制 | 计时、统一日志、权限控制 |
环绕通知的控制能力最完整,因此最常用于“既要拿到参数,又要拿到返回值和耗时”的场景。它的参数必须是 ProceedingJoinPoint,因为只有这个类型能够执行 proceed();其余四类通知一般使用 JoinPoint 获取方法信息即可。
多个切面同时匹配一个方法时,可以用 @Order 明确顺序:
@Order(1)
@Aspect
@Component
public class LogAspect {
}
数字越小,目标方法执行前越早进入;目标方法执行后越晚退出。不要依赖类名字母顺序,顺序有业务意义时应显式标注。
切入点表达式:准确找到需要增强的方法
execution:按方法签名匹配
execution 可以按访问修饰符、返回值、包名、类名、方法名和参数匹配:
execution(访问修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?)
常用通配符:
| 通配符 | 含义 |
|---|---|
* | 一个任意片段,例如任意返回值、类名的一部分或一个参数 |
.. | 多个连续片段,例如任意层级包名或任意个参数 |
例如:
// 匹配 service 包及其子包中的所有方法
@Around("execution(* com.example.service..*(..))")
// 匹配名称以 update 开头、且有一个参数的方法
@Around("execution(* com.example.service.*.update*(*)")
实际书写时应遵循三个原则:业务方法命名规范、优先面向接口描述、在满足需求的前提下缩小匹配范围。特别是不要轻易用一个很宽的 .. 把无关方法也纳入切面。
@annotation:按标记匹配
有些操作并不适合靠方法名区分,例如“哪些接口需要记录操作日志”。这时可以定义一个标记注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
}
在需要记录的接口上标记:
@Log
@DeleteMapping("/depts")
public Result delete(Integer id) {
deptService.delete(id);
return Result.success();
}
切面按注解匹配:
@Around("@annotation(com.example.anno.Log)")
这比把 save、update、delete 写成一长串方法名更直观,也更不容易漏记新增的业务接口。
重复使用的表达式可以用 @Pointcut 抽取:
@Pointcut("@annotation(com.example.anno.Log)")
private void logPointcut() {
}
@Around("logPointcut()")
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
// ...
}
private 切入点只在当前切面复用;若多个切面需要共享,可以声明为 public。
从连接点中取得方法运行信息
连接点对象不仅用于调用原方法,还能取得本次调用的上下文:
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
这三个信息分别对应目标类、目标方法和运行参数。配合 proceed() 的返回值、开始结束时间,就可以构造一条完整的操作日志。
需要注意参数和返回值可能含有密码、令牌、文件内容等敏感信息。生产项目不能不加区分地序列化全部对象;应当脱敏、截断超长内容,并明确哪些字段禁止进入日志。
实战:记录增删改操作日志
一个操作日志通常包含:操作人、操作时间、类名、方法名、参数、返回值和执行时长。下面的切面展示核心流程:
@Slf4j
@Aspect
@Component
public class OperateLogAspect {
@Around("@annotation(com.example.anno.Log)")
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
long begin = System.currentTimeMillis();
Object result = joinPoint.proceed();
long costTime = System.currentTimeMillis() - begin;
OperateLog operateLog = new OperateLog();
operateLog.setOperateEmpId(CurrentHolder.get());
operateLog.setOperateTime(LocalDateTime.now());
operateLog.setClassName(joinPoint.getTarget().getClass().getName());
operateLog.setMethodName(joinPoint.getSignature().getName());
operateLog.setMethodParams(JSON.toJSONString(joinPoint.getArgs()));
operateLog.setReturnValue(JSON.toJSONString(result));
operateLog.setCostTime(costTime);
operateLogMapper.insert(operateLog);
return result;
}
}
这里的示例省略了异常日志策略和敏感字段脱敏。若希望失败操作也入库,可以在 try / catch / finally 中分别记录成功、异常和最终清理状态,不能让“记录日志失败”掩盖原业务异常。
ThreadLocal:在同一次请求中传递当前用户
登录过滤器已经解析了 JWT,却不能把用户 ID 当作参数逐层传给 Controller、Service 和 AOP。ThreadLocal 很适合这种“同一请求线程内共享上下文”的需求。
public final class CurrentHolder {
private static final ThreadLocal<Long> CURRENT_EMP_ID = new ThreadLocal<>();
private CurrentHolder() {
}
public static void set(Long empId) {
CURRENT_EMP_ID.set(empId);
}
public static Long get() {
return CURRENT_EMP_ID.get();
}
public static void remove() {
CURRENT_EMP_ID.remove();
}
}
过滤器在令牌校验成功后写入,在请求结束时删除:
try {
Long empId = parseEmpId(token);
CurrentHolder.set(empId);
filterChain.doFilter(request, response);
} finally {
CurrentHolder.remove();
}
remove() 不能省略。Web 容器通常复用工作线程;如果旧请求的数据残留在线程中,后续请求可能读取到错误用户,既是功能错误也可能造成安全问题。
ThreadLocal 提供的是线程隔离存储,不是跨线程传参工具。异步任务、线程池、消息消费等场景会切换线程,不能期待它自动把当前用户带过去,应使用明确的上下文传递方案。
小结
AOP 适合处理日志、性能统计、事务和权限这类公共逻辑,但不该替代清晰的业务分层。一次完整的 AOP 实践,可以按下面的顺序检查:
- 确认公共逻辑与业务代码是否应该分离。
- 选定合适的通知类型;需要控制原方法、返回值和耗时时优先考虑
@Around。 - 用尽量精确的
execution或语义清晰的@annotation定义切入点。 - 通过
JoinPoint获取必要上下文,并注意日志脱敏。 - 使用 ThreadLocal 传递请求级数据时,在
finally中清理。
掌握这些内容后,AOP 就不再只是几个注解,而是一种让业务代码保持专注、让横切能力集中治理的组织方式。