
1. 重复的表达式迟早出事提取切点前先看清痛点我见过太多项目里的切面代码是这么写的每个切面里都压着一行长长的execution(public * com.example.order.service..*.*(..))LogAspect里拷一份MetricsAspect里再拷一份PermissionCheckAspect里还拷一份。刚开始项目规模小切面只有一两个你还没什么感觉。等切面多起来规则稍变你就得全局搜这串表达式一处一处替换漏掉一个就只能等线上事故来找你。Spring AOP 的切面由两部分构成切点Pointcut和通知Advice。大多数人把注意力放在Before、Around这些通知注解上却低估了切点表达式的权重。实际上对 Spring AOP 这类“声明式”编程来说切点才是切面的灵魂表达式写错了通知逻辑再漂亮也无处施展。这篇文章我专门聊聊切点表达式的提取和复用为什么要提取、怎么提取、提取之后怎么组合复用、参数怎么透传以及我在实践中踩过的一些坑。1.1 一个典型场景三个切面三份拷贝先看一个反例。假设订单服务有个 Service 包你希望对这个包下所有公开方法做三件事记录请求日志、统计耗时、校准参数。Aspect Component public class LogAspect { Before(execution(public * com.example.order.service..*.*(..))) public void recordLog(JoinPoint joinPoint) { // 记录入参和签名 } } Aspect Component public class MetricsAspect { Around(execution(public * com.example.order.service..*.*(..))) public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); Object result joinPoint.proceed(); System.out.println(cost: (System.currentTimeMillis() - start)); return result; } } Aspect Component public class PermissionAspect { Before(execution(public * com.example.order.service..*.*(..))) public void checkPermission(JoinPoint joinPoint) { // 权限校验 } }这段代码的问题一眼可见三个切面同一个表达式抄了三遍。更麻烦的是如果哪天你想把匹配范围从service..*收窄到某个子包或者从“所有公开方法”变成“排除只读方法”你必须记得去改每一处。凡是靠“记得”去维护的规则都有漏掉的那一天。1.2 不提取的代价直接用长表达式还有一个隐蔽问题语义模糊。execution(public * com.example.order.service..*.*(..))这段字符串谁能一眼看懂新来的同事要读半天才能反应过来“哦是拦截 service 包下所有类的公开方法”。而如果你把这段表达式命名为serviceLayer()代码的意图就一目了然。复制粘贴带来的另一个隐患是静默失效。Spring 在启动阶段会解析切点表达式表达式语法错误会在启动时报错但如果是“语法正确但实际匹配范围跟预期不一致”的情况Spring 不会给你任何提示。比如把service..*误写成service.*它只匹配 service 包直接子类、不匹配子包中的类你的日志突然少了检查半天发现是少了一个点。这种“正确但不正确”的表达式在生产环境是最坑的。1.3 提取的核心思路与收益提取的关键做法是把切点表达式从每个切面中抽出来集中到一个公共类里用Pointcut注解标注一个空方法方法名就是表达式的“别名”。Aspect Component public class CommonPointcuts { Pointcut(execution(public * com.example.order.service..*.*(..))) public void serviceLayer() {} }其他切面引用时直接写Before(com.example.config.CommonPointcuts.serviceLayer())即可。这样做的收益很直观规则只维护一份改一处所有引用它的切面同步生效。表达式的业务含义通过方法名表达serviceLayer比那串执行表达式容易读得多。可以在此基础上做组合多个切点互相拼接出更细粒度的规则。这里有一个容易误解的细节Pointcut标注的方法不需要有方法体它的意义不在执行逻辑而在于把这个方法名注册成表达式的代名词。AOP 框架在解析切点引用时看到CommonPointcuts.serviceLayer()就会解析到对应的表达式。2. execution表达式逐段拆解先读懂再谈复用想提取和复用切点表达式第一步是能把表达式读通。Spring AOP 的切点表达式基于 AspectJ 的匹配语法但只支持它的一个子集最常用的还是execution本文所有例子也以它为主。2.1 完整的语法骨架execution表达式的完整结构看起来复杂拆开其实就六个部分execution(修饰符? 返回类型 声明类型? 方法名(参数模式) 异常模式?)常见的实际写法大多省略修饰符和异常模式。拿一个完整例子来说execution(public * com.example.order.service..*.*(..))可以分四段看片段含义public匹配公开方法省略则匹配所有修饰符*返回类型为任意类型com.example.order.service..*声明类型即方法所属的类这里表示 service 包及其子包下所有类.*(..)方法名任意参数任意再举一个具体的execution(* com.example.order.service.OrderService.createOrder(Long, ..))这个表达式匹配OrderService.createOrder方法第一个参数必须是Long后面可以有任意参数。..在参数列表里表示“零个或多个任意参数”。我建议所有刚接触切点表达式的人都先把这个骨架背下来后续再遇到复杂的表达式一边读一边在心里给它分段很快就知道匹配范围是怎么界定的。2.2 三个通配符的使用边界AspectJ 里的通配符主要就三个*、..、。看似简单用起来容易出岔子。*表示任意数量的字符。用于返回类型、类型名、方法名、参数的类型名。比如save*匹配开头为 save 的方法*Service匹配结尾为 Service 的类。注意*在包名中只能匹配“一个包段”不能跨包。..用在两个位置。一是包名中表示任意子包二是参数列表中表示任意个参数。表示类型本身及其子类。比如OrderService表示OrderService及其所有子类子接口。通常用不到但部分场景很实用。区分service.*.*(..)和service..*.*(..)是非常经典的问题execution(* com.example.service.*.*(..)) // 只匹配 service 包下的类子包不匹配 execution(* com.example.service..*.*(..)) // 匹配 service 包及其所有子包下的类前者少一个点匹配范围天差地别。我在代码 review 时多次抓到过这类笔误。2.3 典型匹配场景对照表结合日常开发我整理了几个常见需求对应的写法可以直接参考需求表达式匹配包下所有类所有方法execution(* com.example.service..*.*(..))只匹配包里直接类、不含子包execution(* com.example.service.*.*(..))匹配指定类所有方法execution(* com.example.service.OrderService.*(..))匹配指定类及其子类所有方法execution(* com.example.service.OrderService.*(..))匹配所有无参方法execution(* *.*())匹配第一个参数为 Long 的方法execution(* *.*(Long, ..))匹配返回值为 List 的方法execution(java.util.List *.*(..))匹配以 save 或 update 开头的方法execution(*.(save*(..)))这里有个细节方法名中也可以直接用*但类名的通配要放在声明类型的位置上别把类名和包名混着写在同一个位置。2.4 宽泛匹配的隐性成本我知道不少同学图省事直接写execution(* *.*(..))意思是一切方法都拦截。这在 Spring AOP 下能跑但有两个问题。第一匹配范围过宽会把所有 Spring Bean 的方法都纳入匹配视角。Spring AOP 在启动时会对候选 Bean 做切点匹配过宽的表达式会让很多本来不需要代理的 Bean 也被创建成代理对象启动时间变长运行时每个方法调用都要经过额外的代理判断。第二误伤面大。within(com.example..*)这种写法会把你项目里定时任务、监听器、工具类全部纳入只要哪个切面逻辑里不小心改了参数或者抛了异常排查起来就非常困难。所以我的建议一直是切点表达式宁可多写几个段也不要过度通配。把包名、类名前缀写清楚牺牲掉的只是一点字符串长度换来的却是可预期的行为边界。3. Pointcut命名切点提取与复用的标准姿势基础语法懂了下面进入正题怎么用Pointcut提取和复用。这是全文最核心的部分。3.1 空方法即切点名本质是什么Pointcut注解修饰的方法有几个特征方法体为空、返回类型为 void、参数列表就是表达式要绑定的参数。这个方法本身不会被调用它只是个“名字”名字所代表的就是注解里的表达式。Aspect Component public class CommonPointcuts { Pointcut(execution(* com.example.service.OrderService.*(..))) public void orderServiceMethods() {} Pointcut(execution(* com.example.service.UserService.*(..))) public void userServiceMethods() {} }定义好后当前切面内部直接使用方法名引用Aspect Component public class LogAspect { Before(orderServiceMethods()) public void logOrderService(JoinPoint joinPoint) { // ... } }引用时末尾一定要带括号因为它代表的是一种“可调用的切点标识”即便没有参数括号也是语法的一部分。很多人写成Before(orderServiceMethods)会直接解析失败。3.2 切点可见性控制private、public 和跨切面引用切点方法和其他 Java 方法一样有访问修饰符可见性规则直接影响你在哪里能引用这个切点private仅当前切面类可用。protected在当前切面类、同包及子类中可用。public可以被任意切面通过全限定类名引用。用private的典型场景是切点规则是某个切面内部专属的比如“只拦截本切面关心的某种业务异常”。这种情况下没必要暴露给其他切面。用public的典型场景就是上面说的“公共切点中心”。把表达式提取成公共切点后其他切面引用时用全限定类名Before(com.example.config.CommonPointcuts.orderServiceMethods()) public void logOrder(JoinPoint joinPoint) { // ... }注意这里写出的是类的全限定名加方法名如果类在 Java 包下有重名编译器是不会帮你检查的写错只能等 Spring 启动阶段报解析错误。3.3 组合切点用逻辑运算符组合出精确规则命名切点最大的价值在于组合。比如你有一个serviceLayer()匹配所有 Service 方法又有一个readOperation()匹配所有以 find/get 开头的方法两者用组合就成了“Service 层下的只读方法”Pointcut(execution(* com.example.service..*.*(..))) public void serviceLayer() {} Pointcut(execution(* com.example.service..*.(find*(..) || get*(..)))) public void readOperation() {} Pointcut(serviceLayer() readOperation()) public void serviceReadOperation() {}Spring AOP 支持三个逻辑运算符运算符含义注解模式写法与交集或并集||非排除!优先级和 Java 一致!大于大于||。为了可读性组合切点时务必用括号把参与方括起来。例如Pointcut(serviceLayer() (readOperation() || writeOperation())) public void serviceDataOperation() {}如果不加括号A B || C会被解析成(A B) || C结果和你想要的往往不同。组合切点的好处是可以像搭积木一样按需拼接。我习惯在公共切点中心维护一套“基础块”切点再用几个“组合切点”表达业务能力serviceReadOperation、serviceWriteOperation、auditOperation等。切面引用时只面对这层组合名字不再面对原始表达式。4. 参数绑定让切点从“拦截位置”变成“取值入口”提取切点后最容易踩坑的是参数绑定。很多人不知道切点不仅能定义“拦截哪里”还能把方法参数、注解属性直接传进通知方法。这块对实战非常重要单独拿出来讲。4.1 JoinPoint拿参数最基础的一版最简单的做法是通知方法里声明JoinPoint参数通过它拿方法签名、参数列表、目标对象和代理对象Before(com.example.config.CommonPointcuts.serviceLayer()) public void logMethod(JoinPoint joinPoint) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Object[] args joinPoint.getArgs(); Object target joinPoint.getTarget(); // ... }这段代码不需要额外的切点参数绑定切入哪个方法getArgs()就直接给哪个方法的实参列表。优点是通用缺点是比较“钝”——你拿到的是一个数组还得自己去判断顺序和类型而且没法用它过滤特定参数类型的方法。4.2 args()双向作用过滤与绑定更精细的做法是用args()。它同时干两件事过滤方法和绑定参数。先看写法Pointcut(execution(* com.example.service.OrderService.*(..)) args(orderId)) public void orderOperation(Long orderId) {} Before(orderOperation(orderId)) public void checkOrderId(Long orderId) { System.out.println(order id orderId); }拆开来看 args(orderId)里的orderId对应切点方法的形参Long orderId。Spring 会在运行时判断被拦截方法的第一个参数类型是否为Long如果是就把实参值绑定给orderId。所以它同时起到了“参数类型过滤”和“参数值传入”两个作用。这里有一个容易搞混的细节args(orderId)中的orderId本质是绑定变量名它的类型由切点方法形参决定。写成args(Long)则纯粹是类型过滤不产生变量。两种写法目的不同实际开发中以前者为主。基本类型和包装类型也要注意。args(long)匹配的是基本类型long参数args(Long)匹配的是包装类型Long参数二者并不等价。如果一个接口方法定义的是long参数切点里写Long是匹配不到的。4.3 annotation绑定注解属性除了参数把方法上的自定义注解属性传进通知方法也是常见需求。比如我们有个AuditLog注解需要根据注解里的action字段记录操作类型Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AuditLog { String action(); }切点这样定义Pointcut(annotation(audit)) public void auditOperation(AuditLog audit) {} Around(auditOperation(audit)) public Object doAudit(ProceedingJoinPoint joinPoint, AuditLog audit) throws Throwable { System.out.println(audit action: audit.action()); return joinPoint.proceed(); }关键点仍然在变量名annotation(audit)里的audit对应切点方法形参AuditLog audit。参数绑定对的通知方法也要声明同样的AuditLog audit参数Spring 会按照变量名把注解实例“喂”进来。这个特性在实现统一审计、统一鉴权时非常实用强烈建议掌握。4.4 参数名编译问题的那个坑参数绑定依赖变量名而 Java 的变量名在编译后默认是拿不到的Spring 需要借助-parameters编译参数才能在运行时读取形参名。如果项目没开这个选项切点里的orderId对应的形参名可能变成arg0绑定就会失败。Spring Boot 项目的spring-boot-starter-parent默认配置了-parameters所以大多数 Boot 项目不会遇到这个问题。但如果你的项目是手工配置的 Maven或者用了老版本 Spring就需要在pom.xml里显式加上plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin遇到“切点表达式明明匹配了但参数绑定不了”的情况优先排查编译参数这比怀疑 Spring 的解析逻辑靠谱得多。5. 进阶切点类型与高频误用排查切点表达式不止execution一种。Spring AOP 还支持within、this、target、args、annotation、within、target、bean等。逐个讲容易变成手册我只挑高频场景和容易搞混的几组说。5.1 快速分清 within、this、target、within先给一个对照表切点匹配对象典型写法within类型内的所有方法只要类匹配即可within(com.example.service..*)this当前代理对象类型匹配this(com.example.service.OrderService)target目标对象类型匹配target(com.example.service.OrderService)within类上标注了指定注解的所有方法within(org.springframework.stereotype.Service)annotation方法上标注了指定注解的方法annotation(AuditLog)bean按 Bean 名精确匹配Spring 特有bean(orderService)this和target的区分比较有代表性this匹配的是 AOP 代理对象target匹配的是被代理的真实对象。在 JDK 动态代理模式下真实类实现了接口代理类实现了同一接口但类型不同于真实类两者可能表现不同。Spring Boot 2.x 之后默认使用 CGLIB代理类继承目标类大多数场景下this和target结果相近但如果你还在维护老项目这两种代理模式下的差异值得留意。within和execution的区分也常被问within(com.example.service..*)只关心类在哪个包不关心方法签名所以无法针对方法名做过滤想精确到方法必须配合execution或在execution中写方法名。5.2 自调用为何不生效这是切点复用时最容易翻车的一个问题。假设OrderService内部有一个方法调用另一个方法而另一个方法才是你切点真正要拦的Service public class OrderService { public void createOrder() { // 调用了本类方法 this.updateStock(); } public void updateStock() { // 这里是切点要拦截的方法 } }如果切点匹配的是updateStock()外部调用createOrder()时this.updateStock()这一行并不会触发切点。原因很简单Spring AOP 是基于代理的外部拿到的是代理对象但this是真实对象真实对象方法内部再调用自己的方法不会经过代理自然不会被切面拦到。常见的解决办法有几种用ApplicationContext重新获取代理对象再去调方法。在类内部注入自身Autowired private OrderService self;然后self.updateStock()。开启exposeProxy通过AopContext.currentProxy()获取当前代理。开启方式是在EnableAspectJAutoProxy(exposeProxy true)中配置。如果是切面内部调用增强方法还要小心导致切面自己递归进入切面逻辑这个问题比较复杂一般通过控制切点范围来避免不展开。5.3 JDK代理和CGLIB对切点的影响Spring Boot 2.x 默认proxyTargetClasstrue用的是 CGLIB 代理。在这种模式下类不能是final有final方法也无法被代理。如果你用了接口 JDK 动态代理则目标类必须实现接口且只有接口方法能被拦截目标类自己的独有方法不会命中切点。这带来的实际问题是同样的切点表达式在不同代理模式下拦截范围可能不同。排查切点不生效时先确认代理模式。一个笨办法是在通知方法里打日志打印joinPoint.getThis().getClass()和joinPoint.getTarget().getClass()一眼就能分辨当前属于哪种代理方式。5.4 多切面顺序Order与表达式覆盖范围控制多个切面的切点表达式可以匹配同一个连接点。比如事务切面、日志切面都拦serviceLayer()谁先执行就需要明确。默认顺序不确定需要显式指定。Order注解数字越小优先级越高越先执行。对于Before先执行的是优先级高的对于Around优先级高的先进入也就意味着它的proceed()调用会把后面的切面包在“内层”。这一点很多人记反导致事务切面和日志切面的嵌套关系搞错。结合切点复用我习惯给每个切面一个固定顺序基础设施类切面如日志、埋点数字小业务规则类切面数字大。顺序一旦定了尽量不要靠临时改数字来“调通”否则多个切面之间会形成难以追踪的隐式依赖。6. 落地示例切点中心的工程化组织方式理论讲完这部分给一个相对完整的工程化组织方式。很多团队对切点复用的理解只停留在“抽一个公共类”但没想清楚公共类放哪、切点怎么分层、切面怎么引用。6.1 CommonPointcuts的设计我习惯建一个CommonPointcuts类专门放切点不写任何通知逻辑Aspect Component public class CommonPointcuts { // 基础包切点 Pointcut(execution(public * com.example.order.service..*.*(..))) public void serviceLayer() {} Pointcut(execution(public * com.example.order.controller..*.*(..))) public void webLayer() {} // 基础动作切点 Pointcut(execution(* com.example.order.service..*.(find*(..) || get*(..)))) public void readOperation() {} Pointcut(execution(* com.example.order.service..*.(save*(..) || update*(..) || delete*(..)))) public void writeOperation() {} // 组合切点 Pointcut(serviceLayer() readOperation()) public void serviceReadOperation() {} Pointcut(serviceLayer() writeOperation()) public void serviceWriteOperation() {} // 注解相关切点 Pointcut(annotation(audit)) public void auditOperation(AuditLog audit) {} }注意几个细节Aspect注解保留因为 Spring 默认会用 AspectJ 注解后置处理器解析Pointcut。它本身不产生拦截逻辑但没有Aspect标记就不会被识别。基础切点按“层级 动作”拆分组合切点表达业务含义。方法命名用动词开头一看就知道是“服务层读写操作”还是“审计操作”避免直接用表达式细节命名。6.2 一个实际的AOP配置骨架在 Spring Boot 入口或配置类打开 AOPConfiguration EnableAspectJAutoProxy(proxyTargetClass true) public class AopConfig { }日志切面和耗时切面都引用公共切点Aspect Component Order(1) public class RequestLogAspect { Before(com.example.config.CommonPointcuts.serviceLayer()) public void logEntry(JoinPoint joinPoint) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); String method signature.getDeclaringTypeName() . signature.getName(); System.out.println([ENTRY] method , args Arrays.toString(joinPoint.getArgs())); } } Aspect Component Order(2) public class PerformanceAspect { Around(com.example.config.CommonPointcuts.serviceWriteOperation()) public Object measure(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { System.out.println([COST] (System.currentTimeMillis() - start) ms); } } }这样以后调整匹配范围只改CommonPointcuts里的基础表达式两个切面自动同步不会出现改了日志范围忘了改耗时范围的尴尬。6.3 如果项目还在用XML虽然现在基本都是注解驱动但总有些老项目还在用 XML 配置 AOP。XML 和注解的表达式书写有一个明显差异XML 里不能直接用要写成and。aop:config aop:pointcut idserviceOperation expressionexecution(* com.example.service..*.*(..)) and !execution(* com.example.service..*.find*(..))/ aop:aspect reflogAspect aop:before methodlogEntry pointcut-refserviceOperation/ /aop:aspect /aop:config如果你同时维护注解切面和 XML 切面两套规则叠加时很难理清最终生效的匹配范围我建议尽量迁移到注解方案至少保证项目里只有一种切点组织方式。最后聊聊我的使用习惯切点表达式这块网上文章很多但大多数人只停留在语法层面。我自己的实践体会是切点表达式的“提取与复用”本质上是一种代码组织能力和写业务代码没有区别。表达式短还好一旦复杂起来命名和分层比堆语法重要得多。我会把切点当成项目的公共接口来设计基础切点只描述“物理位置”组合切点才表达“业务语义”切面永远不直接出现长表达式。还有一个习惯值得分享每定义一个新的组合切点我都会在测试类里写一个最小验证用例用来确认它真的匹配到了预期的方法。切点表达式不像普通代码那样有清晰的编译期反馈不写测试验证的规则上线后能不能命中全凭运气。一个简单的测试类通过调用目标方法、断言切面日志是否出现就能把“表达式写错但启动正常”这类问题堵在发布前。参数绑定的那套方式尤其是annotation绑定注解属性是我在审计日志和操作记录场景里用得最顺的。如果项目里有统一的业务注解强烈建议把绑定逻辑沉淀下来后续每个新接口只需要加注解切面自动扩展业务方基本感知不到 AOP 的存在。这大概就是切点表达式“提取、复用”最大的回报规则集中职责清晰改动一小片收益一大片。