Administrator
Administrator
发布于 2025-09-28 / 4 阅读
0
0

AOP 切面的进化:使用 SpEL 表达式打造更智能、更灵活的自定义注解

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。这种方式非常笨拙且脆弱:

  1. 强耦合:切面代码与业务方法的参数列表顺序、类型紧密耦合。一旦业务方法签名变更,切面代码就可能出错。
  2. 可读性差:无法从注解上直观地看出切面到底需要哪个参数。
  3. 不易维护:如果多个方法都需要被该切面拦截,但它们的 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 (重点关注 checkparseResumeId 方法)

@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 方法是整个魔法的核心,让我们拆解一下它的步骤:

  1. 创建评估上下文 (EvaluationContext):这是 SpEL 的关键。MethodBasedEvaluationContext 将方法的实例、方法本身、参数数组以及一个 ParameterNameDiscoverer(参数名发现器)绑定在一起。正是 ParameterNameDiscoverer 的存在,使得 SpEL 能够通过参数名(如 #resumeId)而不是下标来找到对应的参数值。
  2. 解析与求值expressionParser.parseExpression(resumeIdSpel) 将注解中的字符串表达式编译成一个 Expression 对象。随后,expression.getValue(context, Object.class) 在我们刚刚创建的上下文中执行这个表达式,并返回结果。
  3. 类型转换:最后,对返回的 Object 进行安全的类型转换,得到我们需要的 LongresumeId

至此,我们成功地实现了一个动态、灵活且解耦的 AOP 权益校验方案。

总结

通过将 SpEL 表达式引入自定义注解,我们极大地增强了 AOP 的能力:

  • 高度解耦:切面代码不再关心业务方法的具体签名,它只负责解析和执行注解中声明的“指令”。
  • 意图明确:注解的使用方式(如 resumeId = "#resumeId")极具表现力,代码即文档,清晰地表明了切面所需的数据来源。
  • 灵活强大:SpEL 支持访问参数、对象属性、方法调用,甚至可以引用其他的 Spring Bean,为编写通用切面提供了无限可能。

下次当您需要构建一个需要动态参数的 AOP 切面时,不妨试试 SpEL,它将为您的代码带来意想不到的优雅和简洁。


评论