拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring MVC AOP实战:从切点到通知,掌握日志与权限切面

Spring MVC AOP实战:从切点到通知,掌握日志与权限切面

1. 先把AOP这层窗户纸捅破

在Java后端干久了,你会发现一个特别拧巴的现象:业务代码里塞满了跟业务八竿子打不着的逻辑。比如每个Controller接口都要写日志、统计耗时、校验权限、处理异常,这些代码和方法本身的增删改查没有半点关系,但你不得不写,而且写一遍两遍还行,写到几十上百个接口还带复制粘贴,迟早要出事。

Spring MVC里的AOP,就是专门解决这件事的。它的全称是Aspect Oriented Programming,面向切面编程。没有它之前,横切逻辑散落得到处都是;有了它之后,这些东西都被抽出来统一管理,业务代码干干净净,横切逻辑整整齐齐。

一句话讲透:AOP就是把“到处重复做的事”抽成独立的模块,在Spring MVC框架替你执行到对应方法时,自动插一脚。

它适合谁来学?有Spring MVC基础、写过Controller和Service、被重复代码恶心过的开发,都属于目标受众。门槛不高,但第一次接触会觉得概念多——切点、通知、切面、连接点、织入,一堆术语。这篇文章不搞术语轰炸,我直接把核心概念、配置方式、实战场景和踩坑记录全部拆开讲,3分钟未必够,但读完一篇,你能跑通一个真实的AOP日志切面,顺手避开最常见的几个天坑。

2. 为什么Spring MVC非要有AOP

2.1 横切逻辑的蔓延困境

假设你正在维护一个订单系统,Controller层有十几个接口,包括创建订单、取消订单、查询订单、修改收货地址等等。产品经理说,从今天开始所有接口都要加日志,记录入参、出参、耗时。

正常人的第一反应是写个工具类叫LogUtil,然后挨个改方法:

@PostMapping("/order/create") public Result createOrder(@RequestBody OrderDTO dto) { long start = System.currentTimeMillis(); log.info("createOrder 入参:{}", JSON.toJSONString(dto)); try { Result result = orderService.create(dto); log.info("createOrder 出参:{}", JSON.toJSONString(result)); return result; } catch (Exception e) { log.error("createOrder 异常", e); throw e; } finally { log.info("createOrder 耗时:{}ms", System.currentTimeMillis() - start); } }

这一个接口还好,改完十几个接口试试。你会发现大量复制粘贴,将来日志格式要调整,十几处一起改,漏掉一处就出问题。更烦人的是,业务逻辑被日志代码淹没,维护的人看到一堆log.info,根本不关心方法真正做什么。

这就是横切逻辑蔓延。日志、事务、权限、异常处理、性能监控,这些逻辑横着切过所有业务方法,通常被称作横切关注点。业务代码追求的是纵向的业务流程,横切逻辑是横向的统一切入,两者天然不在一个维度上。

2.2 AOP的切入思路

AOP的思路不是让你在每个方法里手动调用工具类,而是通过一个切面类,把“记录日志”这件事整个写在一处,然后告诉Spring框架:凡是满足某个条件的Controller方法,都在执行前记录入参、执行后记录出参、用Around包住算耗时。

框架层面替你搞定一切,业务方法保持原样,一行日志代码都不用写。

这个思想往小了说是减少重复代码,往大了说是职责分离。核心业务逻辑只关心自身流程,日志和监控这类事交给切面旁路处理,互不干扰。

2.3 生活中的类比

这个道理放在生活里也很好理解。小区每一栋楼都装了消防管道,物业在每层统一检修消防设施,而不是每一户业主自己买灭火器、自己每月上楼顶检查水压。物业干的就是AOP切面的活儿——统一管理横切事务,住户安心过日子,消防问题也不会遗漏。

更贴近开发场景的例子是机场安检。所有旅客都必须过安检,不管你是头等舱还是经济舱,不管你是去北京还是去上海。安检通道就是切面,旅客的路径就是业务流程,安检规则是横切逻辑。你不需要在机场门口排队时自己给自己开包检查,过安检是一个统一的装置,框架已经替你安排好了。

3. 核心概念拆解:切点、通知、切面的关系

3.1 一堆术语快速对齐

AOP的术语初看很吓人,其实知道就行,不需要每一个都背得滚瓜烂熟。

  • 连接点:理论上可以插入横切逻辑的地方。Spring里通常指某个具体的方法执行。所有Controller方法、Service方法都是潜在连接点。
  • 切点:和连接点的关系好比SQL的WHERE条件,一组连接点经过匹配筛选,命中规则的方法就是切点覆盖范围。execution表达式就是干这个的。
  • 通知:具体要执行的横切逻辑,也就是日志代码本身。它又细分为前置通知、后置通知、环绕通知、异常通知、最终通知。
  • 切面:切点加通知的组合体,用@Aspect注解定义的类。
  • 织入:框架把通知逻辑编织进目标方法的过程,Spring默认在运行时通过代理完成。

用一句话梳理:切点决定了对哪些方法下手,通知决定了这些方法前后做什么,切面把两者打包,织入是框架在背后完成的动作。

3.2 五种通知:各有各的位置

通知类型人如其名:

前置通知在方法执行前运行,适合做入参校验、初始化上下文、写开始日志。后置通知在方法正常结束之后运行,拿不到最终的返回值。环绕通知最灵活,可以自己控制前置和后置的时机,也能修改返回值,几乎什么事情都能干。异常通知在方法抛出异常后执行,专门用于记录错误或兜底处理。最终通知类似finally块,无论正常还是异常都会执行。

实战中最常用的就是环绕通知。日志记录需要算耗时,必须在方法执行前后各取一次时间,再塞上下文的userId、请求路径等信息,这些操作只有Around能完整承载。

3.3 切点表达式怎么写

execution表达式是AOP配置里最容易写错的地方,也是必须掌握的技能。标准格式是:

execution(修饰符 返回类型 包名.类名.方法名(参数类型))

具体例子:

@Pointcut("execution(* com.example.controller.*.*(..))")

这里的星号按位置分别代表:第一个星号是返回类型任意,包名后的星号是类名任意,最后的星号是方法名任意,括号里的两个点表示参数任意。

锁定到具体方法也很简单:

@Pointcut("execution(public Result com.example.controller.OrderController.create(OrderDTO))")

这种写法就精确匹配了create方法,参数必须是OrderDTO类型。

3.4 自定义注解:给切点打标记

如果你想切得更优雅,不按包名和类名匹配,而是标注性的方式,那么自定义注解是最佳方案。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperLog { String value() default ""; }

然后给需要日志的方法加上注解:

@OperLog("创建订单") @PostMapping("/order/create") public Result create(@RequestBody OrderDTO dto) { // 业务逻辑 }

切点表达式直接瞄准注解:

@Pointcut("@annotation(com.example.annotation.OperLog)") public void operLogPointcut() { }

这么做的优势非常明显:业务方法完全不需要改动,日志场景通过注解一眼可见,将来取消日志也只需要去掉注解。数据变更留痕、操作审计这类需求,用自定义注解配合AOP是业界最经典的做法。

4. 配置AOP的三种姿势:注解优先,XML绕行

4.1 引入依赖这一步不能漏

Spring Boot项目先引入AOP依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>

这一步很多人会漏。application.yml配置里什么都不用写,Spring Boot的自动配置已经默认开启@EnableAspectJAutoProxy,切面类只要标注了@Aspect且被Spring容器管理,就会自动生效。

如果不是Spring Boot项目,在Spring MVC的XML配置里手动开启:

<aop:aspectj-autoproxy />

或者在Java配置类上添加@EnableAspectJAutoProxy注解。后者更推荐,因为显式声明了代理机制,看代码的人不会一头雾水。

4.2 注解式切面:最推荐的方案

写一个日志切面类:

@Component @Aspect @Slf4j public class LogAspect { @Pointcut("execution(* com.example.controller.*.*(..))") public void controllerPointcut() { } private String buildRequestInfo(JoinPoint joinPoint) { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null) { HttpServletRequest request = attributes.getRequest(); return String.format("URI=%s | Method=%s | IP=%s", request.getRequestURI(), request.getMethod(), request.getRemoteAddr()); } return "非HTTP请求"; } @Around("controllerPointcut()") public Object aroundController(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); String methodName = joinPoint.getSignature().getName(); log.info("请求开始:{},方法={},参数={}", buildRequestInfo(joinPoint), methodName, JSON.toJSONString(joinPoint.getArgs())); try { Object result = joinPoint.proceed(); log.info("请求结束:{},结果={},耗时={}ms", methodName, JSON.toJSONString(result), System.currentTimeMillis() - start); return result; } catch (Exception e) { log.error("请求异常:{},耗时={}ms", methodName, System.currentTimeMillis() - start, e); throw e; } } }

这个切面的核心功力在ProceedingJoinPoint。它提供了继续执行目标方法的能力,调用joinPoint.proceed()才真正进入Controller的业务代码,前后夹击完成日志记录。用JSON序列化入参和出参,方便问题排查的时候直接看日志链路。

4.3 XML配置:老项目的无奈之选

老项目维护经常遇到XML风格配置,写法如下:

<bean id="logAspect" class="com.example.aspect.LogAspect" /> <aop:config> <aop:aspect ref="logAspect"> <aop:pointcut id="controllerPointcut" expression="execution(* com.example.controller.*.*(..))" /> <aop:around method="aroundController" pointcut-ref="controllerPointcut" /> </aop:aspect> </aop:config>

推荐所有人优先使用注解式配置,尤其是新项目。代码即配置,看得见摸得着,IDE能帮你检查语法错误,XML一个括号写错可能就是启动时的一行深水炸弹。

4.4 配置优先级与执行顺序

多个切面同时作用于同一个方法时,执行顺序怎么定?

Spring 5.2.7版本开始,流程优先执行@Order数值小或者@Ordered接口实现级别高的切面。比如权限校验切面用@Order(1),日志切面用@Order(2),那么请求会先进权限校验,再进日志前置逻辑,然后才进入目标方法。返回时顺序反转,日志后置先执行,权限后置后执行。

这个顺序在电商或后台管理系统中非常关键。如果日志切面先于权限切面执行,那么一个未授权的请求也会被记进日志,同时入参信息还可能泄露给无权访问的人。

5. 实战三大场景:日志、性能监控、权限校验

5.1 操作日志与审计留痕

后台管理系统的每一个操作都该有记录。谁在什么时间做了什么操作、改了什么数据,这是硬性合规要求。

实战做法是自定义注解加切面:

@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface AuditLog { String action() default ""; String module() default ""; }

在切面中获取当前登录用户信息(通常放在Spring Security的SecurityContext或自定义的ThreadLocal中)、方法参数变化前后的对比值,然后异步写入数据库。

异步这一步很关键。日志写入要是同步执行,会拖慢接口响应时间,而且在日志故障时可能拖垮主流程。用一个线程池异步落库,是成熟项目的常规操作。

5.2 接口性能监控与慢查询分析

性能优化最烦的是不知道瓶颈在哪里。与其事后靠压测工具,不如切面直接统计每个接口的耗时,超过阈值的输出告警日志:

@Around("@annotation(com.example.annotation.PerfLog)") public Object recordPerformance(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.nanoTime(); Object result = joinPoint.proceed(); long cost = (System.nanoTime() - start) / 1_000_000; if (cost > 1000) { log.warn("慢接口告警:{} 耗时:{}ms", joinPoint.getSignature(), cost); } return result; }

超过1秒的接口会被单独标记,不管是数据库慢查询、第三方接口响应慢还是代码问题,都能第一时间从日志里揪出来。代码还可以配合actuator的metrics功能,把耗时数据上报到Prometheus做可视化监控。

5.3 权限校验与访问控制

权限校验是AOP最经典的用途之一。很多场景只需要校验用户是否已登录,或者是够操作某些资源。

@Aspect @Component @Order(1) public class PermissionAspect { @Before("@annotation(requiresPermission)") public void checkPermission(JoinPoint joinPoint, RequiresPermission requiresPermission) { // 从SecurityContext中获取当前用户 LoginUser user = SecurityUtils.getCurrentUser(); if (user == null) { throw new UnauthorizedException("未登录"); } String required = requiresPermission.value(); if (!user.getPermissions().contains(required)) { throw new ForbiddenException("无权限执行该操作"); } } }

这种方式比在每个方法里手动校验的优势在于,校验逻辑完全与业务方法解耦,新写一个需要权限的接口,只需要加上注解,权限校验自动生效,不会像传统逐方法校验那样容易写出漏网之鱼。

5.4 事务管理:人人都用但未必意识到的AOP

其实每个Spring MVC项目都在用AOP,只是很多人没有意识到。@Transactional能够声明式控制事务,背后机制就是Spring容器生成了目标类的代理对象,在进入目标方法前开启事务,在正常返回后提交事务,在抛出异常时回滚。这就是标准的前置通知加异常通知组合。

6. 常见天坑与排查技巧实录

6.1 同类内部方法调用导致AOP失效

AOP基于动态代理实现。外部调用Controller方法时,调用的是代理对象的方法,切面逻辑跟着执行。但在同一个类内部,一个方法直接调用另一个方法,属于this.method()的形式,走的是原始对象而不是代理对象,切面自然不生效。

@Service public class OrderService { public void createOrder(OrderDTO dto) { validateStock(dto); saveOrder(dto); } @AuditLog(action = "保存订单") public void saveOrder(OrderDTO dto) { // 无论何时直接调saveOrder,这个切面都不会生效 } }

自行调用时注解形同虚设。解决办法有几个:把被切入的方法拆到另一个Spring管理的Bean里,通过对象注入调用;或者拿到代理对象再调,obj.getProxy().saveOrder(dto);或者直接调整架构,避免同类的自调用。最省心的还是第一个方案,拆类拆职责本身就是好习惯。

6.2 切面类没被Spring接管

@Aspect注解只声明这是一个切面类,但Spring不一定会去管它。Spring IOC容器只会管理被@Component等注解注册进来的Bean。漏加了@Component或XML配置,Spring就不会实例化这个切面类,AOP逻辑完全不执行,业务倒是正常的。

排查方法很简单:启动日志里查看是否有类似“Registered @AspectJ aspect”的提示,或者直接往切面类里的构造方法加一行输出,看控制台有没有打印。没有打印,十有八九是没注册。

6.3 execution表达式写错导致切面静默失效

表达式写出来不等于表达式写对了。常见错误包括:全限定类名写错、方法名大小写不符、参数类型写反、返回类型星号丢失。

比如execution(* com.example.controller.*(..))就没有匹配类名,正确的格式是execution(* com.example.controller.*.*(..)),类名和方法名都需要占位符。表达式写错时Spring不会报错,只是简单的不匹配,AOP像不存在一样,没有日志、没有异常。

建议实际业务中对切面加一段自我检查逻辑:

@Around("controllerPointcut()") public Object aroundController(ProceedingJoinPoint joinPoint) { log.info("AOP切面生效,切入方法:{}", joinPoint.getSignature()); return joinPoint.proceed(); }

启动后调用任意Controller接口,如果控制台看不到“AOP切面生效”,说明切点表达式没有命中,抓紧排查。

6.4 JDK代理与CGLIB代理的选择

Spring AOP的底层代理有两种方式。Spring Boot 2.x默认情况下,如果目标类实现了接口,使用JDK动态代理,代理对象只能通过接口调用;如果目标类没有实现接口,使用CGLIB生成子类代理。这意味着Controller如果实现了某个接口,注入的时候最好用接口类型声明,否则可能出现类型转换异常。

Spring Boot 2.x开始,spring.aop.proxy-target-class属性默认设置为true,强制走CGLIB,这个坑才少了一些。但老项目升级时,这个属性排查优先级极高。

6.5 多个切面的顺序昏招

小程序里加一个权限校验切面、一个日志切面、一个性能监控切面,顺序搞乱了,日志里看到的可能是一串莫名其妙的嵌套记录,更糟糕的是权限校验失效。

上面已经提到用@Order控制顺序。这里的经验是:越偏向框架底层的切面,Order数值越小。权限第一,日志第二,性能监控第三。顺序不对时查看日志会发现,耗时统计包含了权限校验的逻辑,业务耗时失真。

6.6 循环依赖在AOP场景下突然暴露

有些老项目AOP正常,某天加了一个新切面,启动时直接报循环依赖错误。

原因在于代理对象的创建时机和普通Bean不同。原来是构造器注入可以绕开问题,引入AOP后,代理创建需要目标对象信息,给循环依赖处理增加了复杂度。新代码不再推荐用@Autowired互相注入Bean,构造器注入加事务切面也保持足够警惕,必要时用@Lazy延迟注入帮忙。

7. 我个人在实际操作中的体会

写AOP切面这几年,最大的体会是:AOP是分层架构的一把好刀,但砍错地方反而伤到自身。它最擅长处理那些旁路的、通用的、不想污染业务代码的逻辑,比如日志、权限、监控;不该把核心业务规则塞进切面,否则排查问题时你会在切面和业务代码之间反复横跳,维护成本反而飙升。

日志切面的入参出参记录我会建议慎记全量数据。有些接口的入参包含身份证号、密码、手机号等敏感字段,直接JSON序列化落日志,就是数据安全上的一个定时炸弹。常规做法是日志脱敏,密码字段直接置为***,手机号中间四位打码。凡是要长期落库的日志,脱敏必须作为默认策略。

@Around里面用joinPoint.proceed()包住的代码,务必要重构好异常的分支路径。先决定好“记录异常日志并抛出”还是“吞掉异常并返回兜底结果”,避免责任不清,后续排查时两套代码互相矛盾。

AOP一旦配置正确,收益是长期且潜移默化的。新写的Controller只需要专注业务代码,日志和监控自动跟上,代码可读性高了一个档次。希望这篇内容能帮你少走一些弯路,尤其避掉自调用失效和表达式白写的这两个大坑。

返回列表