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> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</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 默认proxyTargetClass=true,用的是 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 入口或配置类打开 AOP:
@Configuration @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 id="serviceOperation" expression="execution(* com.example.service..*.*(..)) and !execution(* com.example.service..*.find*(..))"/> <aop:aspect ref="logAspect"> <aop:before method="logEntry" pointcut-ref="serviceOperation"/> </aop:aspect> </aop:config>如果你同时维护注解切面和 XML 切面,两套规则叠加时很难理清最终生效的匹配范围,我建议尽量迁移到注解方案,至少保证项目里只有一种切点组织方式。
最后:聊聊我的使用习惯
切点表达式这块,网上文章很多,但大多数人只停留在语法层面。我自己的实践体会是:切点表达式的“提取与复用”本质上是一种代码组织能力,和写业务代码没有区别。表达式短还好,一旦复杂起来,命名和分层比堆语法重要得多。我会把切点当成项目的公共接口来设计:基础切点只描述“物理位置”,组合切点才表达“业务语义”,切面永远不直接出现长表达式。
还有一个习惯值得分享:每定义一个新的组合切点,我都会在测试类里写一个最小验证用例,用来确认它真的匹配到了预期的方法。切点表达式不像普通代码那样有清晰的编译期反馈,不写测试验证的规则,上线后能不能命中全凭运气。一个简单的测试类,通过调用目标方法、断言切面日志是否出现,就能把“表达式写错但启动正常”这类问题堵在发布前。
参数绑定的那套方式,尤其是@annotation绑定注解属性,是我在审计日志和操作记录场景里用得最顺的。如果项目里有统一的业务注解,强烈建议把绑定逻辑沉淀下来,后续每个新接口只需要加注解,切面自动扩展,业务方基本感知不到 AOP 的存在。这大概就是切点表达式“提取、复用”最大的回报:规则集中,职责清晰,改动一小片,收益一大片。