
1. 先把AOP这层窗户纸捅破在Java后端干久了你会发现一个特别拧巴的现象业务代码里塞满了跟业务八竿子打不着的逻辑。比如每个Controller接口都要写日志、统计耗时、校验权限、处理异常这些代码和方法本身的增删改查没有半点关系但你不得不写而且写一遍两遍还行写到几十上百个接口还带复制粘贴迟早要出事。Spring MVC里的AOP就是专门解决这件事的。它的全称是Aspect Oriented Programming面向切面编程。没有它之前横切逻辑散落得到处都是有了它之后这些东西都被抽出来统一管理业务代码干干净净横切逻辑整整齐齐。一句话讲透AOP就是把“到处重复做的事”抽成独立的模块在Spring MVC框架替你执行到对应方法时自动插一脚。它适合谁来学有Spring MVC基础、写过Controller和Service、被重复代码恶心过的开发都属于目标受众。门槛不高但第一次接触会觉得概念多——切点、通知、切面、连接点、织入一堆术语。这篇文章不搞术语轰炸我直接把核心概念、配置方式、实战场景和踩坑记录全部拆开讲3分钟未必够但读完一篇你能跑通一个真实的AOP日志切面顺手避开最常见的几个天坑。2. 为什么Spring MVC非要有AOP2.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 groupIdorg.springframework.boot/groupId artifactIdspring-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 idlogAspect classcom.example.aspect.LogAspect / aop:config aop:aspect reflogAspect aop:pointcut idcontrollerPointcut expressionexecution(* com.example.controller.*.*(..)) / aop:around methodaroundController pointcut-refcontrollerPointcut / /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只需要专注业务代码日志和监控自动跟上代码可读性高了一个档次。希望这篇内容能帮你少走一些弯路尤其避掉自调用失效和表达式白写的这两个大坑。