ARTICLE DETAIL

资讯详情

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

Spring AOP切入点表达式全解析:从execution到@annotation的实战指南

Spring AOP切入点表达式全解析:从execution到@annotation的实战指南 1. 切入点是AOP的命门而表达式是切入点的灵魂先说个现象。我见过不少项目里AOP用的很“野”日志切面、权限切面、耗时统计切面一大堆但是很多人对切入点表达式Pointcut Expression的理解基本停留在“照着网上的模板抄一个execution开头的方法签名”这个层面。表达式稍微复杂点比如要匹配特定注解、要排除某些方法、要按参数类型切就开始靠试错来改配置试对了就完事试不对就烦躁。这里需要先明确一个概念AOPAspect Oriented Programming面向切面编程解决的核心问题是把业务代码里那些横切逻辑——日志、事务、安全校验、性能监控——从主流程中抽离出来统一在“某个时机”和“某些方法”上执行。而这其中“某些方法”到底指哪些不是靠拍脑袋也不是靠全盘拦截而是靠一段精确定位的表达式来圈定。这段表达式就是Spring AOP框架里真正的“上帝视角”它决定了一个切面会不会触发、在哪些地方触发、以及在什么时候触发。也就是说切入点表达式是连接“切面逻辑”和“业务方法”之间唯一的桥梁。写对了切面就像装了瞄准镜指哪打哪写错了要么该切的没切到切面静默失效要么不该切的被误伤系统里出现莫名其妙的增强逻辑。这篇文章我打算从最常用的execution开始把Spring AOP里切入点表达式的核心语法、常见写法、参数绑定、组合逻辑、常见坑点都掰开了揉碎了一遍。同时结合Spring框架里最典型的AOP应用场景——声明式事务Transactional的切入点原理来讲清楚为什么切入点表达式的优先级如此之高。内容面向两类人一类是刚接触Spring AOP、被切入点表达式绕晕的新手另一类是写了几年业务代码、想系统梳理AOP边界和事务边界的老开发。2. Spring AOP切入点表达式的完整语法结构2.1 先说清楚Spring AOP的“底层视角”很多初学者容易把AspectJ和Spring AOP搞混。Spring AOP并没有完整实现AspectJ的整套语言规范它用的是AspectJ的注解风格也就是Aspect、Pointcut、Before这种写法但底层拦截机制是“基于代理”的——默认对接口使用JDK动态代理对类使用CGLIB子类代理。这就引出了一个重要限制Spring AOP只能拦截通过Spring容器管理的Bean的公共方法调用。自己new出来的对象、final方法、静态方法、private方法Spring AOP一概管不着。这一点放到切入点表达式里特别关键——你就算表达式写得再精准命中了这些场景切面也不会执行。另外Spring AOP里切入点表达式虽然没有AspectJ那套原生的pointcut完整语法那么庞大但它支持AspectJ切入点指示器Pointcut Designators简称PCD里的绝大多数核心成员覆盖日常开发绰绰有余。2.2 表达式基础格式execution指示器要说切入点表达式里出镜率最高、也是最重要的一个非execution莫属。它的完整语法格式如下execution(修饰符? 返回类型 类路径?方法名(参数列表) 异常?)这个格式长得吓人拆开讲其实没那么复杂。括号里要填的是一段“方法签名匹配规则”从上到下依次是修饰符可选比如public、protected、private。注意Spring AOP里这个修饰符匹配比较僵硬通常大家都不写让它匹配所有可见性。如果只写public那非public方法就算表达式其他部分匹配也不会被拦截。返回类型必填。可以是具体类型如String、void也可以通配符*。类路径可选指方法所在类的全限定名可以写完整也可以带通配符也可以省略。方法名必填。可以用*匹配任意方法名。参数列表必填。可以写具体类型如(String, Integer)也可以写..表示任意参数或者写*表示单个任意参数。异常可选平时基本用不到标了也不会对拦截范围产生实质影响大多忽略。举几个最常见的例子// 匹配所有public方法 execution(public * *(..)) // 匹配com.example.service包下所有类的所有方法 execution(* com.example.service.*.*(..)) // 匹配com.example.service包及其子包下所有类的所有方法 execution(* com.example.service..*.*(..)) // 匹配UserService类中所有无参方法 execution(* com.example.service.UserService.*()) // 匹配save开头的方法且第一个参数是User类型后面参数任意 execution(* com.example.service.*.save*(User, ..))这里有个初学者常见困惑com.example.service.*和com.example.service..*的区别。单.*只匹配service包下一层的类不包含子包..*则代表service包本身以及所有子孙包下的类。这个点点点..的规则在参数列表里也适用后面专门讲通配符的时候再展开。2.3 其他常用指示器within、this、target、args、annotationexecution虽然强大但只用它来限定切面范围在真实业务场景里往往不够灵活。比如我想匹配所有标注了NeedLogin注解的方法用execution写就非常难受因为你得列出所有用了这个注解的方法签名。而Spring AOP提供的其他指示器就是为了应付这类“按类型特征而非按方法签名”的匹配需求。within匹配指定类型内部的所有方法粒度比execution粗只关心“在哪个类里”不关心方法签名。用来做包级拦截非常方便// 匹配service包及子包下所有类的所有方法 within(com.example.service..*)this和target这两个经常让人头大。官方定义里this匹配的是“AOP代理对象的类型”target匹配的是“被代理的目标对象的类型”。在默认使用JDK动态代理或CGLIB的情况下两者的区别在日常配置中很难体现出来大多数时候你写this(SomeService.class)还是target(SomeService.class)效果差不多。但有一个场景能明显看出区别如果目标类实现了接口JDK代理生成的代理对象是接口类型不是目标类类型。比如UserServiceImpl implements UserService代理对象是这个接口的代理实现你用this(UserServiceImpl.class)去匹配可能匹配不到但target(UserServiceImpl.class)能匹配到。这个细节在接口和实现类分离的项目里尤其容易踩坑后面调试篇会专门提。args按“方法运行时传入的参数类型”来匹配。它和execution里参数列表的最大区别在于execution匹配的是方法签名里声明的参数类型编译期静态信息而args匹配的是运行时实际传入的参数类型。看个经典例子// 这个方法签名没变但参数类型匹配的是运行时对象类型 args(com.example.model.User)如果有个方法声明为handle(Object obj)但调用时传入的参数实际是User对象那么args(User.class)能匹配上而execution(* handle(User))匹配不上因为方法签名里写的是Object。这在做参数级拦截、比如统一处理某种入参时非常实用。annotation匹配标注了指定注解的方法这是做自定义注解切面的核心比如权限注解、幂等注解、操作日志注解。用法如下// 匹配任何标注了NeedLogin注解的方法 annotation(com.example.annotation.NeedLogin)这种写法的好处在于新加一个需要被拦截的方法只要往方法上打一个注解就算完事完全不用去改AOP配置里的表达式。我以前维护过一个老项目里面拦截范围全靠execution写死每次新增一个需要记日志的接口都得去翻切面代码改一遍表达式后来统一换成自定义注解加annotation匹配清净很多。bean按Spring Bean名称匹配平时用得少但在一些多数据源、多实现类切换的场景里很有价值// 匹配bean名称为userService的bean的所有方法 bean(userService) // 匹配名称以Service结尾的bean bean(*Service)3. 把通配符和逻辑组合玩明白表达式才真正“能打”3.1 三个核心通配符的坑与实战切入点表达式里的通配符看上去简单但很多细节不实际写一遍根本发现不了。*匹配任意数量的字符注意这里与正则里的含义不同。它作用于“类名、方法名、返回类型、参数类型”这类标识符的某一段。比如*Service能匹配UserService、OrderService不能匹配UserServiceImpl因为*只匹配一段不能跨“层级”。在参数列表里单独写一个*表示“任意一个类型的参数”但只有一个参数时才匹配比如execution(* save(*))只匹配save方法且只接收一个参数的情况。..这个两点的含义要分场景。用在类路径上表示“包及其所有子包”用在参数列表里表示“任意个数的参数”可以0个也可以多个。比如execution(* com.example..*(..))第一个..匹配包及其子包括号里的..匹配任意参数。这个写法几乎是通用匹配的标配。匹配类本身及其子类比如execution(* com.example.service.UserService.*(..))会匹配UserService以及它所有子类的任何方法。在一些基类做抽象业务、子类分别实现的架构里很有用但平时使用频率较低。3.2 逻辑运算符让组合更灵活Spring AOP的切入点表达式支持三种逻辑组合且、||或、!非。组合之后可以构造出非常精细的匹配条件。一个典型场景拦截service包里所有方法但要排除掉查询类操作。Pointcut(execution(* com.example.service..*.*(..))) public void serviceLayer() {} Pointcut(!execution(* com.example.service..*.query*(..))) public void excludeQuery() {} Aspect public class MyAspect { Around(serviceLayer() excludeQuery()) public Object around(ProceedingJoinPoint pjp) throws Throwable { // 只对非query开头的service方法做增强 return pjp.proceed(); } }另一个高发场景是组合注解匹配。比如要拦截同时标注了RequestMapping和NeedLogin的方法直接用annotation(org.springframework.web.bind.annotation.RequestMapping) annotation(com.example.annotation.NeedLogin)就能实现双重约束。这时候要注意如果两个annotation里的注解类型太“宽泛”比如一个匹配所有Controller注解方法一个匹配特定注解方法那最终效果基本由更严格的约束决定。还有一点多个切面之间的执行顺序和切入点表达式无关它是由Order控制的别搞混了。切入点表达式只决定“切不切”不决定“谁先切”。3.3 定义公共切点Pointcut注解的工程化用法生产项目里很少有切面只处理一个拦截点更常见的做法是把切入点表达式抽出来定义成公共方法然后在不同通知Before、After、Around等里复用。这个公共方法本身不需要任何实质逻辑它的作用就是给表达式一个名字。Aspect Component public class LogAspect { Pointcut(execution(* com.example.service..*.*(..))) public void servicePointcut() {} Pointcut(annotation(com.example.annotation.OperateLog)) public void operateLogPointcut() {} Pointcut(servicePointcut() operateLogPointcut()) public void combinedPointcut() {} Before(combinedPointcut()) public void beforeAdvice(JoinPoint joinPoint) { // 前置增强逻辑 } }这种写法还有一个额外的好处如果一个类里定义了多个切面表达式只用写一遍避免维护多份同样的字符串。老项目里最常见的低级错误就是同一个表达式复制了三份后来加了一个包改了第一处忘了第二处切面莫名其妙开始失效或者误伤。4. 从通知到参数绑定切入点表达式经常和这一步一起用4.1 通知方法里拿到方法的元信息光能定位到目标方法还不够实际增强逻辑里往往还需要知道“切到的到底是谁”。Spring AOP为通知方法提供了几个注入点JoinPoint用于Before、After、AfterReturning、AfterThrowing。可以拿目标方法签名、参数列表、目标对象。ProceedingJoinPoint只能用于Around它继承了JoinPoint并且多了proceed()方法用于手动放行目标方法。Around(servicePointcut()) public Object around(ProceedingJoinPoint pjp) throws Throwable { // 拿到当前拦截方法的签名 MethodSignature signature (MethodSignature) pjp.getSignature(); Method method signature.getMethod(); // 拿到目标方法和入参 Object target pjp.getTarget(); Object[] args pjp.getArgs(); long start System.currentTimeMillis(); Object result pjp.proceed(); long cost System.currentTimeMillis() - start; System.out.println(方法: method.getName() 耗时: cost ms); return result; }4.2 通知参数绑定让表达式里的类型直接“流入”方法这是Spring AOP里一个非常提效但不少人不会用的特性。切入点表达式里的args、annotation等指示器不止能做匹配还能把匹配到的值直接绑定到通知方法的参数上。比如// 切点匹配标注了MyLog注解的方法 Around(annotation(myLog)) public Object aroundLog(ProceedingJoinPoint pjp, MyLog myLog) throws Throwable { // 这里myLog就是被拦截方法上的那个注解实例 String bizType myLog.bizType(); // 用注解上的配置参数来区分日志类型 return pjp.proceed(); }再看args绑定Before(execution(* com.example.service.*.handle(..)) args(user, ..)) public void beforeHandle(JoinPoint joinPoint, User user) { // 第一个参数如果是User类型会直接绑定进来 System.out.println(handle方法的User参数 user.getName()); }这种写法的威力在于如果不绑定你得自己从joinPoint.getArgs()[0]里强转类型不仅代码啰嗦还有类型转换风险。4.3 环绕通知最容易被忽略的细节Around是所有通知里最灵活的因为它掌控了目标方法的调用权——可以决定是否放行、是否替换返回值、是否吞掉异常。但越灵活越容易出错尤其要注意两点第一在Around里如果调用了pjp.proceed()却既不接收返回结果也不返回目标方法的返回值就会变成null调用方拿到的结果就是空的。这在写统一返回值包装逻辑时特别容易踩。第二如果在Around里捕获了异常但没往外抛业务代码的异常处理逻辑就完全感知不到了。比如你做统一异常兜底把异常吞掉后返回一个错误码这时一定要想清楚这个行为是否符合业务预期。5. 结合Spring事务原理看清Transactional背后的切入点逻辑5.1 声明式事务本质上是AOP的一个“官方大型切面”热搜词里“springaop与事物原理分析”这个点其实非常值得展开。Spring的声明式事务Transactional之所以能这么简洁本质上就因为它是一个内置的AOP切面。Spring容器在启动时会扫描Bean上标注了Transactional的方法然后动态生成代理对象在方法调用之前开启事务、方法正常返回后提交事务、方法抛出异常后回滚事务。这个内置切面的“切入点表达式”逻辑就可以理解为匹配到所有标注了Transactional的方法。所以它同样受Spring AOP代理机制的限制——自调用this调用同一个类的其他Transactional方法不会触发事务增强private方法上加Transactional也不会生效。这两个问题是生产环境里事务失效最常见的两类原因而它们的根因都是“切入点没切中”。5.2 事务失效案例与切入点命中范围的关联我遇到过不少这样的报障系统里某个Service的方法明明加了Transactional但操作数据库一半报错了数据却照样写进去了。排查后十有八九是下面几种情况之一方法被同类内部调用并非通过代理对象调用AOP切面压根没机会介入。比如saveUser()内部调用了this.updateUser()updateUser上的Transactional自然失效。方法不是public的。Spring的AOP默认只能拦截public方法private、protected方法上的事务注解形同虚设。异常被方法内部catch掉了事务拦截器看不到异常信号自然不会回滚。这种严格说不算切入点问题但排查事务问题时经常和前面两类混在一起。这三个问题本质上都和“AOP切入点是否能命中”有直接关系。像自调用这种就算你在切入点表达式里把方法签名写得一个不差也命中不了因为调用根本没经过代理对象。理解了这一点再看网上那些“Spring事务不生效的十个原因”之类的文章会发现它们归根结底都绕不开代理边界和切入点边界。5.3 从事务切面反推切入点设计经验Spring内置事务切面给了我们一个非常好的切入点表达式设计范本尽量“收着切”不要“放开切”。内置事务切面默认只对标注了注解的方法生效这本身就保证了作用范围足够可控。如果你在项目里负责写自定义切面这条经验同样适用——用自定义注解匹配annotation来划定边界比用包路径加方法名前缀execution...save*要安全得多。方法名前缀这种命名约定很难长期维持新来的同事不一定遵守哪天写了个update开头但实际上是删除逻辑的方法就妥妥被漏掉了。6. 切入点表达式实战从日志切面到接口鉴权6.1 实战一统一的接口耗时日志切面这是一个入门级但使用率极高的切面。目标是拦截Controller层所有接口也就是标注了RestController的类的方法打印请求路径、耗时和异常信息。切入点写法Aspect Component public class ApiLogAspect { Around(within(org.springframework.web.bind.annotation.RestController *)) public Object apiLog(ProceedingJoinPoint pjp) throws Throwable { MethodSignature methodSignature (MethodSignature) pjp.getSignature(); Method method methodSignature.getMethod(); String className methodSignature.getDeclaringType().getSimpleName(); String methodName method.getName(); Object[] args pjp.getArgs(); long start System.currentTimeMillis(); Object result null; try { result pjp.proceed(); return result; } finally { long cost System.currentTimeMillis() - start; System.out.println(请求: className . methodName 耗时: cost ms); } } }注意within(org.springframework.web.bind.annotation.RestController *)这个写法RestController注解前面加符号后面跟*代表所有标注了这个注解的类。这个方法有一个妙处它匹配的是“类上有RestController注解”不管类里的方法是什么签名所以Controller里新加接口无需改动任何配置。6.2 实战二自定义注解实现接口防重复提交防重复提交是个经典需求。最容易想到的方案是加一个切面在方法执行前尝试获取分布式锁获取失败就直接返回“操作太频繁”。用自定义注解加annotation做切入点是这个方案的“标准答案”。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface NoRepeatSubmit { long timeout() default 3000L; }Aspect Component public class NoRepeatSubmitAspect { Around(annotation(noRepeatSubmit)) public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable { // 拿到当前用户标识和方法全限定名拼接成唯一key // 尝试获取锁可以用Redis SETNX实现 // 获取不到锁直接返回获取到锁则执行目标方法并在finally中释放 return pjp.proceed(); } }这里有一个细节Around(annotation(noRepeatSubmit))后面这个参数名必须和通知方法参数名保持一致Spring才能把匹配到的注解实例绑进来。如果写成annotation(anno)但方法参数叫noRepeatSubmit运行时会直接报参数绑定失败。6.3 实战三多维度组合拦截的权限校验实际业务中我们往往要同时满足多个条件才决定是否增强。比如订单模块的写操作需要校验权限但查询操作不需要。切入点可以写成这样Pointcut(execution(* com.example.order..*.*(..))) public void orderModule() {} Pointcut((execution(* com.example.order..*.create*(..)) || execution(* com.example.order..*.update*(..)) || execution(* com.example.order..*.delete*(..)))) public void writeOperations() {} Pointcut(orderModule() writeOperations()) public void permissionRequired() {} Before(permissionRequired()) public void checkPermission(JoinPoint joinPoint) { // 校验当前用户是否有权执行这个写操作 }注意一个细节逻辑组合里的括号别省。尤其当||和同时出现时运算符优先级容易搞出意外。虽然网上大部分模板没写括号但我在生产环境里吃过亏——不写括号时AspectJ解析出的优先级跟直觉不太一样。所有复杂表达式一律用括号显式标出优先级。7. 常见问题与排查技巧实录7.1 表达式看起来正确但切面就是没生效这类问题在社区里问的人最多排查路径其实比较固定。第一确认切面类被Spring容器扫描到。漏掉Component注解是最常见的原因或者切面类放在了ComponentScan扫描范围之外。第二确认目标Bean是否走的代理调用。被切的方法如果是同类里this.xxx()这种自调用AOP不会拦截。解决办法是把被调方法拆分到另一个Bean里或者注入自身代理到当前Bean中。第三确认Spring Boot的AOP依赖是否引入完整。spring-boot-starter-aop已经默认包含aspectjweaver但老项目里如果只用spring-context手工搭忘记引入AOP相关依赖注解和表达式都会“静默被忽略”。第四确认切入点表达式本身没有拼写问题。全角符号、多余空格、类路径写错都会让人排查半天。我习惯先在本地写一个最小的AOP测试用例表达式只挂一个简单方法跑通之后再复制到真实切面里这样能快速区分是表达式问题还是环境问题。7.2 多个AOP切面同时命中时执行顺序怎么确定当一个方法同时被日志切面、事务切面、权限切面命中的时候执行顺序不是随机的而是由优先级决定的。Spring提供了Order注解数字越小优先级越高前置通知的优先级越高越先执行后置通知则正好相反。比如Order(1)的权限校验切面会先于Order(100)的日志切面执行。生产环境里我建议对所有自定义切面都显式声明Order不然多个切面之间如果有依赖关系比如权限校验要在记录日志之前顺序一旦乱掉日志里就会出现一堆“未授权操作被记录下来”的日志排查起来非常痛苦。7.3 事务切面和自定义切面的顺序问题这个坑很隐蔽但踩到一次就很难忘。假设一个Service方法上面标了Transactional同时又被你的自定义日志切面匹配到了。默认情况下Spring内置事务切面的优先级比自定义切面要低。也就是说自定义切面的Before会先执行然后才进入事务切面之后才真正调用目标方法。反之如果你在自定义切面里做了一些前置校验而校验结果会影响是否调用目标方法比如用Around短路拦截此时你实际上把业务逻辑拦在了事务边界的外面。这种场景下某些前置校验抛异常时事务还没开启数据不会回滚。如果要让自定义逻辑在事务保护之内执行就得把自定义切面的Order值调小优先级更高或者调整事务切面的order。7.4 切入点表达式的最佳实践速查表问题场景推荐写法说明拦截指定包下所有类所有方法execution(* com.example.service...(..))两个..分别匹配子包和任意参数拦截Controller层所有接口within(RestController *)类级别注解匹配接口无侵入拦截标注了指定注解的方法annotation(com.example.annotation.Demo)配合自定义注解使用扩展性最强拦截第一个参数是User的任何方法args(com.example.model.User)运行时参数类型使用灵活按Bean名称精确匹配bean(userServiceImpl)多实现类替换时可用排除指定方法!execution(* com.example.service...query(..))逻辑非常与多个切点组合组合多种匹配条件Pointcut(表达式A !表达式B)显式加括号避免优先级陷阱8. 最后的小技巧别把表达式写成一次性代码做技术设计时最容易忽略的一点是把切入点表达式当成一段“写一次就完事”的配置。但事实上只要项目还在演进表达式就需要持续维护。在我看来最合理的做法是所有表达式集中定义、命名清晰、通过组合来复用像上面的Pointcut公共方法那样。这样即便后来接口路径变了、包结构调整了也只需要改一处。另外建议在表达式旁边加注释写清楚这个切入点解决的是哪个业务场景避免半年后会看代码的自己或者同事对着一段复杂的execution表达式发呆。我自己在实际项目里的习惯是每个自定义切面都配一个单元测试直接用AspectJ代理去验证表达式命中了哪些方法。虽然Spring AOP的测试写起来稍微费点事但比起某天线上突然发现“切面该生效没生效”这点前期投入绝对是值得的。你如果也在维护切面较多的项目不妨试一次大概率会回来感谢这个习惯。
返回列表