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

资讯详情

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

Spring AOP注解实战:从切面编写到代理失效排查

Spring AOP注解实战:从切面编写到代理失效排查 Spring AOP这一块网上讲基础的教程一抓一大把但大多数都停在“怎么配”“怎么用”的层面很少有人把“为什么这么写”“哪些坑我踩过”讲明白。今天这篇我打算换个路子从我实际项目里基于注解的AOP实现出发把从概念选型、依赖配置、切面写法到绕过代理失效、定位切面不生效这些实操细节全部过一遍。如果你刚接触Spring AOP或者已经用了但碰到“切面就是不执行”“Transactional莫名失效”这类问题这篇应该能帮你少走弯路。1. 先搞清楚AOP到底在解决什么问题1.1 当你的代码开始重复打日志先说个最典型的场景。我接手过一个支付相关的服务里面几十个接口每个接口进来都要记录请求参数、计算耗时、捕获异常打错误日志。一开始大家都很老实每个方法里手写一遍public PayResult pay(PayRequest request) { long start System.currentTimeMillis(); log.info(pay request: {}, request); try { // 核心业务逻辑 PayResult result doPay(request); log.info(pay response: {}, cost: {}ms, result, System.currentTimeMillis() - start); return result; } catch (Exception e) { log.error(pay error, request: {}, request, e); throw e; } }一个两个方法还好等到几十个方法都是这个套路的时候你就会发现问题很大大量重复代码每次新增接口都要“复制粘贴再改改”日志格式不统一有的人记了参数没记耗时有的人只打异常不打请求哪天要加一个“性能监控”等于再把所有方法翻一遍这种横切在所有业务方法上的逻辑就是典型的AOP应用场景。1.2 面向切面把横切逻辑单独拎出来AOP的全称是Aspect Oriented Programming面向切面编程。它要解决的本质问题是让“日志、事务、权限、性能统计”这类跟核心业务无关、但又需要横跨多个模块的逻辑从业务代码里剥离出来。你可以把业务方法想象成一条条纵向的业务链路而AOP则是从侧面切进去在链路的不同位置插入统一的处理逻辑。这样做的好处非常直接业务代码干净只关心自己的逻辑横切逻辑集中管理改一处全局生效新增关注点时不需要动原有业务代码Spring AOP的落地方式有三种XML配置、编程式、注解式。而现在的主流项目里基于注解的方式几乎是绝对的主角。原因也很简单配置量小、直观、跟Spring Boot的自动化配置理念契合。后面我会把三种方式做个对比。2. 为什么基于注解的AOP成为主流方案2.1 三种实现方式对比Servlet的XML配置时代AOP写起来相当啰嗦。我最早接触Spring AOP时得在XML里声明aop:config、aop:aspect、aop:pointcut切点表达式还经常因为逃逸字符写错排查半天。后来有了注解整个世界清爽了。实现方式配置位置优点缺点XML配置applicationContext.xml对代码无侵入配置繁琐不易查错编程式Java类灵活可控代码侵入不适合大批量应用注解式业务类上简洁直观与Spring Boot契合对代码有一定侵入性需要加注解我在实际项目中选注解式还有一个重要原因可读性。当你review代码时看到一个方法上标注了CustomLog或者Transactional立刻知道这个方法有哪些附加行为。而如果这些逻辑埋在XML里不看配置文件根本不知道这里会有事务或日志。2.2 注解式AOP的底层动态代理虽然我们今天聊的是“注解”但AOP能够生效本质上依赖的是代理模式。Spring AOP默认在运行时使用动态代理来织入切面逻辑当目标类实现了接口时Spring使用JDK动态代理代理类和目标类实现相同的接口当目标类没有实现接口时Spring使用CGLIB生成目标类的子类作为代理这个知识点后面很多坑都跟它有关尤其是“方法内部调用不走代理”的问题就是这里埋下的伏笔。理解代理你才能真正排查AOP失效的问题。3. 环境准备依赖和基础配置3.1 Maven依赖引入如果你用的是Spring Boot事情很简单只需引入一个起步依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency这个starter会帮你把Spring AOP和AspectJ Weaver都拉进来。如果你是非Spring Boot项目需要手动配置dependency groupIdorg.aspectj/groupId artifactIdaspectjweaver/artifactId version1.9.19/version /dependency注意版本要和你的Spring版本兼容我用的是Spring Boot 3.x对应AspectJ 1.9.x以上都没问题。3.2 开启AOP的关键配置Spring Boot自动配置已经默认开启了AOP支持所以你在大多数情况下不需要额外加任何注解。但如果是传统Spring项目需要在配置类上加上Configuration EnableAspectJAutoProxy public class AppConfig { }这里有个小细节值得说下。EnableAspectJAutoProxy有一个属性叫做proxyTargetClass默认是false。什么意思呢就是默认优先使用JDK动态代理只有当目标类没有接口时才用CGLIB。在Spring Boot 2.x以上的自动配置里proxyTargetClass实际上被设置为了true强制使用CGLIB。这个差异会导致一个隐藏问题——如果你的代码里通过接口类型注入了某个Bean而代理又是CGLIB生成的子类某些场景下类型转换会不那么舒服。所以实际项目中我一般建议显式确认自己的配置策略别稀里糊涂靠默认值。4. 注解AOP的核心概念与五个通知类型4.1 基本概念切面、切点、连接点、通知学AOP最绕的就是术语多。我当年上课时也被概念搞得头大后来结合代码才真正看懂。这里我尽量用大白话讲一遍概念类比我的理解切面Aspect一个通用处理模块就是Aspect标注的类里面装着切点和通知切点Pointcut一个筛选条件定义哪些方法会被拦截比如“所有controller包下的方法”连接点JoinPoint具体的某个方法被切点匹配到的某个具体方法执行的某个时机通知Advice在连接点上执行的逻辑比如方法执行前记录日志4.2 五种通知类型Spring AOP提供了五种通知对应不同的执行时机Aspect Component public class LogAspect { // 前置通知目标方法执行前 Before(execution(* com.example.service.*.*(..))) public void beforeAdvice(JoinPoint joinPoint) { // 记录请求参数等 } // 后置通知目标方法执行后无论是否异常 After(execution(* com.example.service.*.*(..))) public void afterAdvice(JoinPoint joinPoint) { // 记录方法结束 } // 返回通知目标方法正常返回后 AfterReturning(pointcut execution(* com.example.service.*.*(..)), returning result) public void afterReturningAdvice(JoinPoint joinPoint, Object result) { // 记录返回值 } // 异常通知目标方法抛出异常后 AfterThrowing(pointcut execution(* com.example.service.*.*(..)), throwing ex) public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) { // 记录异常 } // 环绕通知在方法执行前后都可通过proceed控制 Around(execution(* com.example.service.*.*(..))) public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { Object result joinPoint.proceed(); return result; } finally { log.info({} cost {}ms, joinPoint.getSignature(), System.currentTimeMillis() - start); } } }这里必须提醒一点Around是最灵活但也是最“危险”的通知。因为你把目标方法的执行权拿在了手里joinPoint.proceed()不复用目标方法根本不会执行。有时候网上代码抄过来发现方法没执行或者重复执行先检查proceed()调了几次。4.3 切点表达式常见写法和我的习惯切点表达式也就是execution()里面的内容是AOP配置中出错频率最高的地方。常用格式execution(修饰符? 返回类型 类名?方法名(参数) 异常?)几个典型案例// 拦截某个包下所有类的所有方法 execution(* com.example.service.*.*(..)) // 拦截某个包下所有类及其子包的类的所有方法 execution(* com.example.service..*.*(..)) // 拦截任意返回值为String的方法 execution(String com.example.service.*.*(..)) // 拦截只有一个String参数的方法 execution(* com.example.service.*.*(String))注意那个点号的区别service.*指的是service下一层目录service..*指的是service及其子包。实际开发中“拦截service子包下所有方法”这种需求我就是因为这个点号的差异没分清导致切面只生效了一半。除了execution还有within、annotation等指示符。其中annotation是我们做自定义注解AOP的关键后面会看到。5. 实战落地一个接口日志切面从零实现5.1 场景设定我们进入实操环节。我准备做一个“接口日志与耗时统计”的切面这是AOP最典型的应用场景之一。需求很简单所有加了指定注解的方法自动记录入参、出参、耗时方法抛出异常时记录错误日志不改变现有业务代码原本的逻辑这里的核心思路是用自定义注解标记要拦截的方法再用切面拦截这个注解。这种方式的优势在于研发人员可以精确控制哪些方法需要日志而不是动不动就把一个包全部拦截导致日志量爆炸。5.2 第一步自定义一个业务注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OperationLog { String module() default ; String action() default ; }Target(ElementType.METHOD)表示只能标在方法上Retention(RetentionPolicy.RUNTIME)保证运行时可以通过反射读取。这一步决定了自己做的注解能不能被AOP识别很多人注解写了却没用就要检查下Retention。5.3 第二步编写切面类切面类的核心逻辑我拆成几步来做Aspect Component public class OperationLogAspect { private static final Logger log LoggerFactory.getLogger(OperationLogAspect.class); // 切点只要方法上标了 OperationLog Pointcut(annotation(com.example.annotation.OperationLog)) public void operationLogPointcut() { } Around(operationLogPointcut()) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { Method method getCurrentMethod(joinPoint); OperationLog operationLog method.getAnnotation(OperationLog.class); long start System.currentTimeMillis(); Object result null; try { result joinPoint.proceed(); return result; } catch (Throwable throwable) { // 记录异常信息 throw throwable; } finally { long cost System.currentTimeMillis() - start; log.info(module{}, action{}, method{}, args{}, result{}, cost{}ms, operationLog.module(), operationLog.action(), method.getName(), JSON.toJSONString(joinPoint.getArgs()), JSON.toJSONString(result), cost); } } private Method getCurrentMethod(ProceedingJoinPoint joinPoint) throws NoSuchMethodException { // 这里有个大坑如果目标类被CGLIB代理getDeclaredMethods拿不到接口默认方法要用下面这种方法区处理 MethodSignature signature (MethodSignature) joinPoint.getSignature(); return signature.getMethod(); } }这个切面已经能完成基本的日志记录了。但注意我在getCurrentMethod里留了个小坑的引子。当目标方法是接口的默认方法或者代理类继承过来的方法时直接getMethod()可能拿不到方法上的注解。更稳妥的做法是先从目标类里查找private Method getRealMethod(ProceedingJoinPoint joinPoint) throws NoSuchMethodException { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); Class? targetClass joinPoint.getTarget().getClass(); Method realMethod targetClass.getMethod(method.getName(), method.getParameterTypes()); return realMethod; }5.4 第三步在业务代码上使用Service public class OrderService { OperationLog(module order, action create) public Order createOrder(OrderDTO orderDTO) { // 创建订单逻辑 return order; } }完成。以后这个createOrder方法被调用时切面会自动记录参数、返回值和耗时业务代码里一行日志都不用写。新同事接手也只要看一眼注解就知道这个方法有日志行为。5.5 为什么我在方案里选择了Around有朋友可能会问既然Before加AfterReturning也能做到记录日志为什么用Around我的选择逻辑有三个需要同时拿到方法的入参、出参、和最终耗时Before拿不到结果AfterReturning拿不到开始时间分散在多个通知里会让数据拼装很麻烦还得用ThreadLocal传值。Around的执行顺序最直观一个方法搞定所有步骤后续如果需要加缓存或者幂等等逻辑扩展起来也最容易。在同一个切面里用Around可以统一捕获异常避免使用多个通知时的执行顺序和异常处理边界问题。当然如果你的业务场景只需要在某一步做点事儿比如只做参数校验那用Before更合理没必要为用而用。6. 内置的注解型AOP从Transactional说起6.1 Transactional本质也是AOPSpring里最常用的注解型AOP例子其实是Transactional。很多人每天都用它却未必意识到它背后就是AOP方法开启事务、正常提交、异常回滚这些逻辑跟业务代码本身无关由Spring通过代理自动织入。但正因为Transactional是AOP它也继承了AOP的所有坑。尤其是代理失效问题可以说是面试高频也是生产事故高发区。6.2 经典陷阱同一个类内部方法调用导致事务失效Service public class OrderService { public void processOrder(OrderDTO dto) { this.updateStock(dto); this.createOrder(dto); } Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { // 保存订单 } }上面的写法里processOrder调用this.createOrder()时Transactional是不生效的。原因就是我前面提到的代理机制this是目标类的原始对象不是Spring生成的代理对象所以对createOrder方法的调用只是普通方法调用切面根本感知不到。解决办法有几个把createOrder挪到另一个Service里通过注入的代理对象调用在类内部注入自身代理Autowired private OrderService self;然后用self.createOrder()调用或者用AopContext.currentProxy()需要开启exposeProxy我个人最推荐第一种把事务边界放在独立的Service方法上职责清晰也避免以后有人误改内部调用逻辑。6.3 事务不回滚的隐藏原因还有一个高频问题抛出异常但事务没回滚。很多人第一反应是“我的代码没问题”其实大概率是回滚条件不对。Transactional // 默认只回滚 RuntimeException 和 Error public void createOrder() throws Exception { // 业务代码 throw new Exception(业务异常); }这个代码里事务不会回滚因为Spring默认只对RuntimeException和Error回滚。你抛了一个受检异常事务直接提交了数据就写进去了。正确写法是明确指定回滚条件Transactional(rollbackFor Exception.class) public void createOrder() throws Exception { // 业务代码 throw new Exception(业务异常); }网上很多教程会建议加上rollbackFor Exception.class这确实是最稳妥的做法推荐大家也这样做。6.4 事务传播行为的选择Transactional的propagation属性也是经常被讨论的点。我在具体业务中用得最多的是REQUIRED和REQUIRES_NEW。REQUIRED是默认值如果当前有事务就直接加入没有则新建REQUIRES_NEW会挂起当前事务开启一个新事务举个例子我们做订单和库存分离时如果希望库存扣减失败不影响订单主流程就可以把库存扣减方法的事务传播行为设置为REQUIRES_NEW。但这种设计会带来新的问题——库存扣减成功了订单创建失败了最终数据不一致怎么办这时候就需要引入补偿机制。AOP帮我们解决了“事务逻辑与业务解耦”的问题但没有解决最终一致性的问题这也是事务边界设计时要想清楚的。7. 那些年我踩过的坑AOP排错与排查实录7.1 切面不生效先检查这五个地方工作中被问得最多的就是“我切面写了怎么不执行”每次我基本按下面这个顺序排查切面类有没有被Spring管理Aspect只是标记还要配合Component或者在配置类注册Bean。有没有开启AOPSpring Boot项目一般自动开了传统项目记得加EnableAspectJAutoProxy。切点表达式有没有写错建议先用最简单的表达式比如execution(* com.example.*.*(..))测试确认环境通后再改成复杂表达式。拦截的方法是不是通过代理调用的重点检查类内部this.xxx()调用以及new出来的对象调方法。Spring版本是否有兼容问题老版本的Spring对AspectJ注解支持不完全可以考虑升级Spring Boot版本。7.2 多个切面同时生效时顺序怎么控制当一个方法被多个切面同时拦截时执行顺序是个很微妙的问题。有时候你会遇到“切面A想获取切面B设置的上下文结果拿不到”的情况。控制方式有两个在切面类上加Order(1)、Order(2)数字越小优先级越高实现Ordered接口getOrder()返回的数值越小越先执行这里要提一点Order控制的是切面本身的执行顺序而Before和After的执行顺序是相反的。Before按Order从小到大的顺序执行After按Order从大到小的顺序执行。如果你在多个切面间有严格的上下文依赖一定要梳理清楚。7.3 切面内使用this导致二次代理问题我在一个很有迷惑性的场景里踩过坑。我在切面里想调用同一个类的另一个切面方法直接用了this结果发现被调用的切面逻辑没有执行这就是代理失效问题。解决方式跟业务类一样注入自身代理或者把公共逻辑提取到另一个Bean中。这个坑的迷惑性在于日志上看起来代码是“走下去”了但就是没有切面效果非常难排查。7.4 性能问题切面里的耗时统计别做成负担切面代码本身如果在性能上有问题会对所有被拦截方法产生放大效应。我曾经见过有人把JSON.toJSONString(joinPoint.getArgs())写在Around里结果某个方法的入参里有个巨大的对象日志直接把接口拖慢了。我的建议是记录参数前先判断日志级别比如log.isInfoEnabled()避免无谓的序列化尽量避免在切面里做重IO操作比如写文件、远程调用如果是全链路追踪建议把耗时数据异步发送不要阻塞业务线程7.5 高频问题速查表现象可能原因解决建议切面完全不执行未注册Aspect类/未开启AOP检查注解和配置切面只对部分方法生效切点表达式包路径不对用最简单的表达式验证方法内部调用导致AOP失效this调用不经过代理注入代理对象或拆分Bean事务没回滚抛出的是受检异常明确指定rollbackFor一个方法被多个切面拦截且顺序错乱未配置Order按业务优先级配置Order切面里拿不到方法上的自定义注解getDeclaredMethod获取问题从targetClass中查找真实方法8. 最后分享一些实操心得玩多了Spring AOP之后我慢慢总结出几个自己的使用规范这里分享给有需要的朋友。第一个心得是关于切点表达式的。我尽量不用大范围的包拦截来做日志因为这样会误伤很多无关方法日志量暴涨排错也麻烦。现在很多公司会有请求日志中间件也是基于这个思路。自定义注解配合annotation来精确定位需要拦截的方法虽然要在业务方法上多写一个注解但收益是代码的可读性和可控性都上来了。第二个心得跟团队协作有关。AOP虽然能帮我们“偷懒”但它也有一个坏处隐式逻辑太多新人接手时如果不知道这个切面存在会看不懂代码流程。所以在团队里推广自定义AOP时我会特别强调注释和命名规范。切面类名要直观比如OperationLogAspect、DataPermissionAspect不要用TestAspect这种谁都看不懂的名字切点方法名也要有业务含义最好能自解释。第三个心得是关于“什么时候不用AOP”。很多人学了AOP之后什么都想往切面里塞但这东西不是万能的。如果某个横切逻辑只在一两个方法里使用直接写普通方法调用反而更清晰如果这个逻辑需要依赖很多业务状态把它放切面里反而让数据流动变得难以追踪。我曾经遇到过一个项目权限校验逻辑写进切面后由于某些方法需要绕过权限结果不得不写一堆排除表达式最后代码比不用AOP还复杂。所以我的原则是横切逻辑覆盖范围足够大、逻辑足够独立时才适合用AOP。最后一个小技巧当你调试AOP相关问题时可以在启动类临时加一个启动配置打印代理类的实际类型System.out.println(beanClass); // 观察是$$EnhancerBySpringCGLIB还是JDK代理看到$$EnhancerBySpringCGLIB$$基本就能确认代理已经生成了。如果你发现目标Bean并未被代理那就说明Spring AOP没有作用到这个Bean上后面的排查思路完全不一样。AOP这个东西理论很多真正干活的时候考验的是细节。希望这篇基于注解的AOP实现复盘能让你在实际项目里少踩几个坑。
返回列表