AOP 切面的进化:使用 SpEL 表达式打造更智能、更灵活的自定义注解
在 Spring 开发中,AOP(Aspect-Oriented Programming,面向切面编程)是一个强大的工具,它允许我们将那些贯穿于多个业务模块的通用功能(如日志记录、权限校验、事务管理等)抽离出来,形成所谓的“切面”。这样做极大地提升了代码的模块化程度,降低了业务逻辑和通用功能之间的耦合度。
然而,在传统的 AOP 应用中,我们常常会遇到一个棘手的问题:如何优雅地将业务方法的动态参数传递给切面?
传统 AOP 的传参困境
让我们设想一个场景:我们需要编写一个切面,用于校验用户在执行某个操作时是否拥有足够的“权益”(Entitlement)。一个自然而然的想法是创建一个自定义注解,比如 @CheckEntitlement,然后将其标记在需要校验的业务方法上。
一个简单的注解可能长这样:
public @interface CheckEntitlement {
long value(); // 用于指定权益ID
}
我们可以这样使用它:
@CheckEntitlement(1L) // 1L 代表“简历生成”权益
public void generateResume() {
// ... 业务逻辑
}
这在权益类型固定的情况下工作得很好。但如果校验逻辑还需要业务方法的某个参数呢?例如,在“修改简历”的场景下,我们的权益规则可能是“用户在7天内可以免费修改同一份简历”。这时,切面不仅需要知道权益ID,还需要知道当前正在操作的 resumeId。
传统的做法可能是在切面逻辑中,通过 ProceedingJoinPoint 对象获取方法的所有参数 joinPoint.getArgs(),然后遍历这个数组,通过参数的类型或者顺序来猜测哪一个是 resumeId。这种方式非常笨拙且脆弱:
- 强耦合:切面代码与业务方法的参数列表顺序、类型紧密耦合。一旦业务方法签名变更,切面代码就可能出错。
- 可读性差:无法从注解上直观地看出切面到底需要哪个参数。
- 不易维护:如果多个方法都需要被该切面拦截,但它们的
resumeId参数位置或类型不同,切面逻辑将变得异常复杂。
有没有一种更优雅、更灵活的方式来解决这个问题呢?答案就是 SpEL(Spring Expression Language)。
SpEL:让注解“活”起来
SpEL 是一个功能强大的表达式语言,它能够在运行时查询和操纵对象图。当它与 AOP 结合时,我们可以将 SpEL 表达式直接写在注解中,从而让注解拥有动态获取方法上下文信息的能力。
接下来,我们将通过一个真实的代码示例,一步步展示如何利用 SpEL 打造一个智能的权益校验切面。
实战演练:三步构建基于 SpEL 的动态 AOP 切面
第一步:定义一个支持 SpEL 的自定义注解
首先,我们修改 CheckEntitlement 注解,增加一个 resumeId 字段,它将用于接收 SpEL 表达式字符串。
CheckEntitlement.java
package org.dztyykxx.back.aspect;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义注解:用于标记需要进行权益校验的方法。
*/
@Target(ElementType.METHOD) // 注解作用于方法上
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时可见,以便AOP能够读取
public @interface CheckEntitlement {
/**
* 权益ID (entitlement_id)。
* 用于指定本次操作需要消耗哪一种类型的权益。
*/
long value();
/**
* (可选) 用于获取简历ID的SpEL表达式。
* AOP会解析此表达式,从方法参数中动态获取 resumeId。
* 例如: "#resumeId" 或 "#request.resumeId"
*/
String resumeId() default "";
}
这里的关键是 resumeId() 字段,它是一个字符串,我们约定用它来存放 SpEL 表达式。
第二步:在业务方法上应用注解
现在,我们可以在业务方法上使用这个增强版的注解了。
/*
* 对简历的特定字段进行主动提问
*/
@Override
@CheckEntitlement(value = ENTITLEMENT_ID_MODIFY, resumeId = "#resumeId")
public Flux<SaResult> askAboutResumeFieldStream(long userId, Long resumeId, ModelResumeRequest request) throws JsonProcessingException {
// ... 业务逻辑
}
请注意 resumeId = "#resumeId" 这部分。这行代码清晰地表达了一个意图:“对于本次权益校验,所需的 resumeId 值,请从该方法的名为 resumeId 的参数中获取。”
这里的 # 符号是 SpEL 的语法,#resumeId 就代表了对方法上下文中名为 resumeId 的变量的引用。如果我们的 resumeId 封装在一个 DTO 对象中,表达式也可以写成 resumeId = "#request.resumeId",SpEL 会自动调用 request 对象的 getResumeId() 方法。
这种声明式的方式,让代码意图一目了然。
第三步:编写 AOP 切面来解析 SpEL 表达式
最后,也是最核心的一步,我们需要在 AOP 切面中捕获这个注解,并解析其中的 SpEL 表达式来获取真实的值。
EntitlementCheckAspect.java (重点关注 check 和 parseResumeId 方法)
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class EntitlementCheckAspect {
private final IEntitlementService entitlementService;
// SpEL解析器
private final ExpressionParser expressionParser = new SpelExpressionParser();
// 用于获取方法参数名
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
@Around("@annotation(org.dztyykxx.back.aspect.CheckEntitlement)")
public Object check(ProceedingJoinPoint joinPoint) throws Throwable {
// ... 其他逻辑
// 1. 从注解中解析出权益ID和SpEL表达式
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
CheckEntitlement annotation = method.getAnnotation(CheckEntitlement.class);
long entitlementId = annotation.value();
// 2. 使用SpEL从方法参数中动态解析 resumeId
Long resumeId = parseResumeId(joinPoint, annotation.resumeId());
// ... 后续的权益校验和业务执行逻辑
}
/**
* 使用SpEL解析器从方法参数中提取resumeId。
*
* @param joinPoint 切点,包含方法签名和参数信息
* @param resumeIdSpel SpEL表达式字符串,例如 "#resumeId" 或 "#request.resumeId"
* @return 解析出的 resumeId,如果表达式为空或解析失败则返回 null
*/
private Long parseResumeId(ProceedingJoinPoint joinPoint, String resumeIdSpel) {
if (StrUtil.isBlank(resumeIdSpel)) {
return null;
}
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
// 1. 创建SpEL的评估上下文
EvaluationContext context = new MethodBasedEvaluationContext(
joinPoint.getTarget(), // rootObject
method, // method
args, // arguments
parameterNameDiscoverer // parameterNameDiscoverer
);
// 2. 解析表达式并从上下文中获取值
Expression expression = expressionParser.parseExpression(resumeIdSpel);
Object value = expression.getValue(context, Object.class);
// 3. 对解析出的值进行类型转换
if (value instanceof Long) {
return (Long) value;
} else if (value instanceof Number) {
return ((Number) value).longValue();
} else if (value instanceof String) {
return Long.parseLong((String) value);
}
log.warn("SpEL表达式 '{}' 的解析结果类型不正确: {}", resumeIdSpel, value != null ? value.getClass().getName() : "null");
return null;
} catch (Exception e) {
log.error("解析SpEL表达式 '{}' 时发生错误", resumeIdSpel, e);
return null;
}
}
}
parseResumeId 方法是整个魔法的核心,让我们拆解一下它的步骤:
- 创建评估上下文 (
EvaluationContext):这是 SpEL 的关键。MethodBasedEvaluationContext将方法的实例、方法本身、参数数组以及一个ParameterNameDiscoverer(参数名发现器)绑定在一起。正是ParameterNameDiscoverer的存在,使得 SpEL 能够通过参数名(如#resumeId)而不是下标来找到对应的参数值。 - 解析与求值:
expressionParser.parseExpression(resumeIdSpel)将注解中的字符串表达式编译成一个Expression对象。随后,expression.getValue(context, Object.class)在我们刚刚创建的上下文中执行这个表达式,并返回结果。 - 类型转换:最后,对返回的
Object进行安全的类型转换,得到我们需要的Long型resumeId。
至此,我们成功地实现了一个动态、灵活且解耦的 AOP 权益校验方案。
总结
通过将 SpEL 表达式引入自定义注解,我们极大地增强了 AOP 的能力:
- 高度解耦:切面代码不再关心业务方法的具体签名,它只负责解析和执行注解中声明的“指令”。
- 意图明确:注解的使用方式(如
resumeId = "#resumeId")极具表现力,代码即文档,清晰地表明了切面所需的数据来源。 - 灵活强大:SpEL 支持访问参数、对象属性、方法调用,甚至可以引用其他的 Spring Bean,为编写通用切面提供了无限可能。
下次当您需要构建一个需要动态参数的 AOP 切面时,不妨试试 SpEL,它将为您的代码带来意想不到的优雅和简洁。