Bootstrap

JAVA | 通过自定义注解与AOP防止接口重复提交

关注:CodingTechWork

引言

  在Web应用开发中,特别是在处理表单提交或API调用时,可能会遇到用户因网络延迟、按钮多次点击等原因导致的重复提交问题。为了解决这一问题,通常的做法是在前端禁用提交按钮,或者在后端使用唯一令牌(Token)机制来确保请求的唯一性。然而,这些方法往往需要针对每个可能的重复提交场景单独处理,增加了代码的复杂性和维护成本。
  本文将介绍一种更加优雅和通用的解决方案:通过Java自定义注解和面向切面编程(AOP),结合Redis实现防重操作。这种方法不仅可以有效地防止短时间内重复提交,而且可以通过配置灵活调整防重策略,适用于各种业务场景。接下来,我们将详细介绍该方案的设计思路、具体实现以及最佳实践。

方案概述

自定义注解

  为了简化防重逻辑的实现,我们首先定义了一个名为@Resubmit的自定义注解,它用于标记那些需要防止重复调用的方法。此注解包含了几个重要的属性:

  • keyPrefix():指定生成防重Key的前缀规则。
  • key():允许开发者自定义Key值,或者从请求参数中动态获取。
  • limitation():设定两次相同请求之间最短时间间隔,单位为秒。
  • timeout():设置最长限制时间,以避免由于系统原因导致无法再次调用的问题,默认300秒。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
  *为了避免短时间内重复调用同一方法,特别是相同参数的调用,我们通过一个 key 来判断是否为重复请求。key 由调用方提供,用于标识每次调用。只有当两次调用的 key 相同,才会被视为重复请求。
  */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Resubmit {
    /**
     * key 的前缀类型
     */
    ResubmitKeyPrefixTypeEnum keyPrefix() default ResubmitKeyPrefixTypeEnum.URL_PATH;

    /**
     * 如果指定 key ,则通过${}方式从参数中进行取值;如果未指定 key,则从 ApiRequestKey#getKey 中取值
     */
    String key() default "";

    /**
     * 允许两次相同调用的最短时间间隔,单位 - 秒
     */
    long limitation() default 0;

    /**
     * 超时时间,单位 - 秒;超过这个时间,解除限制,默认 300 秒
     */
    long timeout() default 300;
}

ResubmitKeyPrefixTypeEnum

  为了简化keyPrefix的配置,我们还定义了枚举类型ResubmitKeyPrefixTypeEnum,提供了三种不同的前缀生成方式:URL路径、类名以及类加方法名。


public enum ResubmitKeyPrefixTypeEnum {
    /**
     * url 路径
     */
    URL_PATH,
    /**
     * 类名
     */
    CLASS,
    /**
     * 类 + 方法名
     */
    CLASS_METHOD;
}

AOP切面逻辑

  接下来是核心部分——AOP切面逻辑。ResubmitAspect类负责拦截所有带有@Resubmit注解的方法,并根据配置执行相应的防重检查。主要功能包括:

  1. 前置处理:在方法执行之前,构造出唯一的Key,并检查该Key是否已经存在于Redis中。如果存在,则抛出异常阻止方法继续执行;否则,将Key存入Redis并设置过期时间。
  2. 后置处理:当方法正常执行完毕后,清理ThreadLocal中的数据,确保不会影响其他线程。对于不限制调用次数的情况(即limitation设为0),还会立即删除Redis中的Key。
    此外,还有辅助方法用于解析请求参数、构建完整的Key字符串等。
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Aspect
@Component
@Slf4j
public class ResubmitAspect {

    private static final ThreadLocal<String> KEY_HOLDER = new ThreadLocal<>();
    private static final String COMMON_KEY_PREFIX = "XXXWEB:BIZ:RESUBMIT";
    private static final String COLON = ":";

    @Autowired
    private RedisService redisService;

    /**
     * 定义切入点,匹配所有带有 {@link Resubmit} 注解的方法。
     */
    @Pointcut("@annotation(com.demo.commons.annotations.Resubmit)")
    public void resubmitPointcut() {
        // 空方法体,仅用于定义切入点
    }

    /**
     * 在方法执行前检查是否为重复调用。如果检测到重复调用,则抛出异常阻止方法执行。
     *
     * @param joinPoint 切入点信息
     * @param resubmit  防止重提交的注解配置
     */
    @Before("resubmitPointcut() && @annotation(resubmit)")
    public void before(JoinPoint joinPoint, Resubmit resubmit) {
        try {
            // 构造唯一key,并添加公共前缀
            String key = constructUniqueKey(joinPoint, resubmit);
            log.info("Resubmit check with key {}", key);

            // 检查Redis中是否存在相同key,若存在则拒绝请求
            if (redisService.exists(key)) {
                throw new BusinessException("请不要频繁操作!");
            }

            // 将key存入Redis并设置过期时间(以秒为单位)
            int expireTime = Math.max(resubmit.limitation(), resubmit.timeout());
            redisService.setExpiry(key, expireTime);
            KEY_HOLDER.set(key);

        } catch (Exception e) {
            log.error("Error during resubmit check", e);
        }
    }

    /**
     * 方法执行后清理线程局部变量中的key。
     *
     * @param resubmit 防止重提交的注解配置
     */
    @After("resubmitPointcut() && @annotation(resubmit)")
    public void after(Resubmit resubmit) {
        String key = KEY_HOLDER.get();
        if (key == null || !resubmit.limitation() > 0) return;

        // 如果没有限制调用次数,则立即删除key
        if (resubmit.limitation() == 0) {
            redisService.delete(key);
        }

        KEY_HOLDER.remove();
    }

    /**
     * 构造唯一的key值,用于标识一次请求。
     *
     * @param joinPoint 切入点信息
     * @param resubmit  防止重提交的注解配置
     * @return 唯一的key字符串
     */
    private String constructUniqueKey(JoinPoint joinPoint, Resubmit resubmit) {
        String customKey = extractCustomKey(joinPoint, resubmit);
        String prefix = buildKeyPrefix(joinPoint, resubmit);

        // 如果自定义key为空,则返回空字符串
        if (customKey == null || customKey.isEmpty()) {
            return "";
        }

        // 组装完整的key,避免重复拼接符号
        return COMMON_KEY_PREFIX + COLON + prefix + COLON + customKey;
    }

    /**
     * 提取自定义key或从参数中获取。
     *
     * @param joinPoint 切入点信息
     * @param resubmit  防止重提交的注解配置
     * @return 自定义key字符串
     */
    private String extractCustomKey(JoinPoint joinPoint, Resubmit resubmit) {
        // 优先尝试解析注解中的key表达式
        if (!resubmit.key().isEmpty()) {
            return parseExpressionKey(joinPoint, resubmit.key());
        }

        // 否则从参数中查找实现了ApiRequestKey接口的对象
        for (Object arg : joinPoint.getArgs()) {
            if (arg instanceof ApiRequestKey) {
                return DigestUtil.md5Hex(((ApiRequestKey) arg).getKey());
            }
        }

        return "";
    }

    /**
     * 根据注解配置构建key的前缀部分。
     *
     * @param joinPoint 切入点信息
     * @param resubmit  防止重提交的注解配置
     * @return key前缀字符串
     */
    private String buildKeyPrefix(JoinPoint joinPoint, Resubmit resubmit) {
        switch (resubmit.keyPrefix()) {
            case URL_PATH:
                HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
                return request.getRequestURI().replace("/", COLON) + COLON + request.getMethod().toLowerCase();
            case CLASS:
                return joinPoint.getSignature().getDeclaringTypeName().toLowerCase();
            case CLASS_METHOD:
                MethodSignature signature = (MethodSignature) joinPoint.getSignature();
                return signature.getDeclaringTypeName().toLowerCase() + COLON + signature.getName().toLowerCase();
            default:
                return "";
        }
    }

    /**
     * 解析key表达式,支持从方法参数中提取属性值。
     *
     * @param joinPoint      切入点信息
     * @param keyExpression  key表达式字符串
     * @return 解析后的key字符串
     */
    private String parseExpressionKey(JoinPoint joinPoint, String keyExpression) {
        Pattern pattern = Pattern.compile("\\$\\{(.*?)\\}");
        Matcher matcher = pattern.matcher(keyExpression);

        if (matcher.find()) {
            String groupName = matcher.group(1);
            Object value = BeanUtil.getProperty(obtainMethodArgumentsMap(joinPoint), groupName);
            return value != null ? value.toString() : null;
        }

        return null;
    }

    /**
     * 获取方法参数并转换为Map形式,便于后续处理。
     *
     * @param joinPoint 切入点信息
     * @return 参数映射表
     */
    private Map<String, Object> obtainMethodArgumentsMap(JoinPoint joinPoint) {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        String[] argNames = methodSignature.getParameterNames();
        Object[] argValues = joinPoint.getArgs();

        Map<String, Object> args = Maps.newHashMapWithExpectedSize(argValues.length);
        for (int i = 0; i < argNames.length; i++) {
            args.put(argNames[i], shouldIgnoreArgument(argValues[i]) ? "[ignore]" : argValues[i]);
        }

        return args;
    }

    /**
     * 判断是否应该忽略某些类型的参数,例如文件上传、HTTP请求/响应等。
     *
     * @param object 参数对象
     * @return 是否忽略该参数
     */
    private boolean shouldIgnoreArgument(Object object) {
        if (object == null) return false;

        Class<?> clazz = object.getClass();
        return clazz.isArray() && IntStream.range(0, Array.getLength(object)).anyMatch(index -> shouldIgnoreArgument(Array.get(object, index)))
               || Collection.class.isAssignableFrom(clazz) && ((Collection<?>) object).stream().anyMatch(this::shouldIgnoreArgument)
               || Map.class.isAssignableFrom(clazz) && shouldIgnoreArgument(((Map<?, ?>) object).values())
               || object instanceof MultipartFile
               || object instanceof HttpServletRequest
               || object instanceof HttpServletResponse
               || object instanceof BindingResult;
    }
}

Controller层应用示例

  为了展示如何在Spring MVC的Controller层中使用@Resubmit注解来防止接口重复提交,我们将创建一个简单的例子。这个例子假设我们有一个用户注册的功能,为了避免用户多次点击“注册”按钮导致重复注册的问题,我们可以使用@Resubmit注解。

定义业务模型

首先,我们需要定义一个用于表单提交的数据传输对象(DTO),例如UserRegistrationDTO,它包含了用户注册所需的信息。

package com.example.demo.dto;

import lombok.Data;

@Data
public class UserRegistrationDTO {
    private String username;
    private String password;
    private String confirmPassword;
}

创建Controller

接下来,我们在Controller中添加一个处理用户注册请求的方法,并为其添加@Resubmit注解。

package com.example.demo.controller;

import com.demo.commons.annotations.Resubmit;
import com.example.demo.dto.UserRegistrationDTO;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1")
public class UserController {

    /**
     * 用户注册接口。
     *
     * @param userRegistrationDTO 用户注册信息
     * @return 返回注册结果
     */
    @PostMapping("/register")
    @Resubmit(keyPrefix = ResubmitKeyPrefixTypeEnum.CLASS_METHOD, key = "${username}", limitation = 60, timeout = 300)
    public ResponseEntity<String> register(@RequestBody UserRegistrationDTO userRegistrationDTO) {
        // 这里可以加入实际的业务逻辑,比如保存用户信息到数据库等
        // 为简化示例,直接返回成功消息
        return ResponseEntity.ok("注册成功!");
    }
}

解释注解配置

  1. keyPrefix = ResubmitKeyPrefixTypeEnum.CLASS_METHOD:指定了生成防重Key时使用的前缀是类名加方法名。这有助于确保不同控制器中的同名方法不会相互影响。
  2. key = " u s e r n a m e " :这里使用了表达式 {username}":这里使用了表达式 username":这里使用了表达式{username},表示从请求体中的UserRegistrationDTO对象提取username属性作为唯一标识的一部分。这意味着同一个用户名在同一时间段内不能再次调用此方法。
  3. limitation = 60:设置两次相同调用之间最短时间间隔为60秒。如果用户在60秒内尝试再次注册相同的用户名,则会收到提示信息:“请不要频繁操作!”。
  4. timeout = 300:设定最长限制时间为5分钟(300秒)。超过这个时间后,即使没有新的调用,旧的限制也会被自动解除,允许再次执行该方法。

测试接口

当启动应用程序并访问上述API端点时,如果尝试在60秒内以相同的用户名发起多次注册请求,将会看到如下的响应:

{
  "timestamp": "2025-01-07T06:09:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "请不要频繁操作!",
  "path": "/api/v1/register"
}

而首次调用或是在限制时间之外的请求将正常工作,并返回成功的响应:

{
  "status": 200,
  "body": "注册成功!"
}

通过这种方式,我们可以有效地避免由于用户的误操作或者网络延迟等原因造成的重复提交问题,同时保持代码的简洁性和可维护性。此外,还可以根据具体的业务需求调整@Resubmit注解的参数,灵活地控制防重策略。

结论

  通过引入自定义注解和AOP技术,我们可以轻松地为项目添加防止接口重复提交的功能,同时保持代码的简洁性和可维护性。这种方式不仅能够有效减少不必要的数据库操作和其他资源消耗,还能提高用户体验,避免因为误操作而引发的数据不一致性问题。

;