Java后端防止频繁请求、重复提交的方案设计

一、前言:

Java接口防重点击是指在接口调用时,防止同一接口在短时间内被重复点击或请求,以防止系统资源被浪费、系统性能下降,以及防止恶意攻击。

在实现Java接口防重点击时,可以采用多种方式,例如使用令牌桶/漏桶算法、分布式锁、Redis等内存数据库,或在数据库层面实现。其中,使用Redis等内存数据库是较为常见的方式,可以将用户的操作信息存储在Redis中,并设置一个过期时间,以达到防重的效果。

在公司某些项目上如果前端和后端都没有做防止用户重复点击的校验,或者只有其中一个做了校验,那么就可能遇到数据库存在一些除了主键不一样,其他数据一模一样的数据。这个时候通常的解决方法就是写脚本去修改数据,但是这个都是在问题出现后再去手动解决,如果不想办法阻止这种数据的产生,很有可能会出现比较严重的生产事故。

二、场景复现:

用户对于新增保存/修改保存,往往会进行点击。但是部分接口后端逻辑处理时间稍长,那么就会出现当前数据已经在处理了,但是没有返回执行完成数据或者页面跳转时候,依然停留在原页面上,用户不由自主的进行反复点击。

那怎么解决这种问题呢?首先我们可以在前端加上校验,用户点击一次提交按钮以后,在后端接口执行完之前提交按钮变为不可点击。这种方法是可行的,但在某些特殊情况下并不能完全保证这种问题不再发生。所以我们还可以同时在后端接口加上防重复提交的校验。

这里使用防重 Token 令牌方案,该方案能保证在不同请求动作下的幂等性,接下来写下实现这个逻辑的方式。

三、Token 令牌验证:

方案描述:

针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。

适用操作:

  • 插入操作
  • 更新操作
  • 删除操作

使用限制:

  • 需要生成全局唯一 Token 串;
  • 需要使用第三方组件 Redis 进行数据效验;

主要流程:

Java后端防止频繁请求、重复提交的方案设计
  •  服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。

  •  客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。

  •  然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。

  • 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。

  •  客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。

  • 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。

  • 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。

四、实际代码演示:

1.自定义注解

/**
 * 幂等注解
 *
 */
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 参数名,表示将从哪个参数中获取属性值。
     *
     * @return 参数名
     */
    String name() default "";

    /**
     * 属性,表示将获取哪个属性的值。
     *
     * @return 属性
     */
    String field() default "";

    /**
     * 参数类型
     *
     * @return 参数类型
     */
    Class type();
}

2.统一的请求入参对象

/**
 * 订单
 */
@Data
public class Order implements Serializable {
    /**
     * 订单编号
     */
    private String orderNo;

    /**
     * 序列化版本UID
     */
    private static final long serialVersionUID = 3927020576675110843L;
}
/**
 * 请求数据
 *
 */
@Data
public class RequestData<T> {

    /**
     * 请求数据的值
     */
    private String value;

    /**
     * 请求数据的主体
     */
    private T body;

}

3.AOP处理

/**
 * @description: 幂等性切面
 */
@Aspect
@Component
public class IdempotentAspect {
    @Resource
    private RedisIdempotentVerify redisIdempotentStorage;

    /**
     * 定义一个切面方法,用于匹配带有org.example.api.idempotence.Idempotent注解的方法。
     */
    @Pointcut("@annotation(org.example.api.idempotence.Idempotent)")
    public void idempotent() {
        // 方法体
    }


    /**
     * 该方法使用@Around注解,表示在指定的方法调用前执行该方法。
     *
     * @param joinPoint ProceedingJoinPoint类型的参数,表示当前正在执行的方法调用点
     * @return Object类型的值,表示执行方法的返回值
     * @throws Throwable 表示可能抛出任何类型的异常
     */
    @Around("idempotent()")
    public Object methodAround(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取当前请求的HttpServletRequest对象
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // 获取请求头中的token
        String token = request.getHeader("token");
        // 获取目标方法的MethodSignature对象和Method对象
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        // 获取目标方法上的@Idempotent注解
        Idempotent idempotent = method.getAnnotation(Idempotent.class);

        // 获取注解中的name字段值
        String name = idempotent.name();
        // 获取注解中的type字段值,并根据类型实例化对象
        Class clazzType = idempotent.type();
        // 获取注解中的field字段值
        String field = idempotent.field();

        // 初始化value变量并赋值为token的值
        String value = token;

        // 创建实例对象
        Object object = clazzType.newInstance();
        // 获取方法参数值
        Map<String, Object> paramValue = AopUtils.getParamValue(joinPoint);
        // 判断对象是否是RequestData的实例
        if (object instanceof RequestData) {
            // 将name字段对应的参数值强制转换为RequestData类型,并赋值给idempotentEntity变量
            RequestData idempotentEntity = (RequestData) paramValue.get(name);
            // 通过idempotentEntity对象获取field字段的值,并将其转换为字符串类型赋值给value变量
            value = String.valueOf(AopUtils.getFieldValue(idempotentEntity, field));
        }
        // 判断token是否有效
        if (redisIdempotentStorage.validToken(token, value)) {
            // 执行目标方法并返回结果
            return joinPoint.proceed();
        }
        // 如果token无效,则返回"重复请求"字符串
        return "重复请求";
    }
}

/**
     * 获取对象的字段值
     *
     * @param obj  目标对象
     * @param name 字段名
     * @return 字段值
     * @throws Exception 如果获取字段值失败
     */
    public static Object getFieldValue(Object obj, String name) throws Exception {
        Field[] fields = obj.getClass().getDeclaredFields();
        Object object = null;
        for (Field field : fields) {
            field.setAccessible(true);
            if (field.getName().equalsIgnoreCase(name)) {
                object = field.get(obj);
                break;
            }
        }
        return object;
    }

    /**
     * 获取方法参数的值
     *
     * @param joinPoint 调用点
     * @return 参数值的映射,键为参数名,值为参数值
     */
    public static Map<String, Object> getParamValue(ProceedingJoinPoint joinPoint) {
        Object[] paramValues = joinPoint.getArgs();
        String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
        Map<String, Object> param = new HashMap<>(paramNames.length);

        for (int i = 0; i < paramNames.length; i++) {
            param.put(paramNames[i], paramValues[i]);
        }
        return param;
    }
}

4.Token值生成及校验逻辑

@RestController
@RequestMapping("/idGenerator")
public class IdGeneratorController {

    @Resource
    private RedisIdempotentVerify redisIdempotentStorage;

    /**
     * 获取ID生成器的令牌
     *
     * @param value 令牌的值
     * @return 生成的令牌
     */
    @RequestMapping("/getIdGeneratorToken")
    public String getIdGeneratorToken(String value) {
        String generateId = IdGeneratorUtil.generateId();
        redisIdempotentStorage.save(generateId, value);
        return generateId;
    }
}
/**
 * 用于生成唯一标识符的工具类
 *
 */
public class IdGeneratorUtil {
    /**
     * 用于生成唯一的标识符的工具类
     */
    private IdGeneratorUtil() {
        // 私有化构造方法,不允许直接实例化
    }

    /**
     * 生成一个唯一标识符
     *
     * @return 唯一标识符的字符串
     */
    public static String generateId() {
        return UUID.randomUUID().toString();
    }

public interface IdempotentVerify {
    /**
     * 保存
     *
     * @param value        令牌的值
     * @param idempotentId 唯一标识
     */
    void save(String idempotentId, String value);

    /**
     * 删除
     *
     * @param idempotentId 唯一标识
     */
    boolean delete(String idempotentId);

    /**
     * 验证令牌
     *
     * @param token 令牌
     * @param value 令牌的值
     * @return 令牌是否有效
     */
    boolean validToken(String token, String value);

}

@Slf4j
@Component
public class RedisIdempotentVerify implements IdempotentVerify {

    @Resource
    private RedisTemplate<String, Serializable> redisTemplate;

    /**
     * 存入 Redis 的 Token 键的前缀
     */
    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";

    /**
     * 保存  10分钟过期
     *
     * @param value        令牌的值
     * @param idempotentId 唯一标识
     */
    @Override
    public void save(String idempotentId, String value) {
        String token = IDEMPOTENT_TOKEN_PREFIX + idempotentId;
        if (StringUtils.isBlank(value)) {
            value = token;
        }
        redisTemplate.opsForValue().set(token, value, 10, TimeUnit.MINUTES);
    }

    /**
     * 删除 boolean
     *
     * @param idempotentId 唯一标识
     * @return boolean
     */
    @Override
    public boolean delete(String idempotentId) {
        String token = IDEMPOTENT_TOKEN_PREFIX + idempotentId;
        return Boolean.TRUE.equals(redisTemplate.delete(token));
    }

    /**
     * 验证 Token
     *
     * @param token 令牌
     * @param value 令牌的值
     * @return boolean
     */
    @Override
    public boolean validToken(String token, String value) {
        // 设置 Lua 脚本,其中 KEYS[1] 是 key,ARGV[1] 是 value
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        // 根据 Key 前缀拼接 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        if (StringUtils.isBlank(value)) {
            value = key;
        }
        // 执行 Lua 脚本
        Long result = redisTemplate.execute(redisScript, Collections.singletonList(key), value);
        // 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
        if (Objects.nonNull(result) && result > 0L) {
            log.info("验证 token=[{}],key=[{}],value=[{}] 成功", token, key, value);
            return true;
        }
        log.info("验证 token=[{}],key=[{}],value=[{}] 失败", token, key, value);
        return false;
    }
}

5.请求示例

调用接口之前,先申请一个token,然后带着服务端返回的token值,再去请求。

@RestController
@RequestMapping("/order")
public class OrderController {

    @RequestMapping("/createOrderInfo")
    @Idempotent(name = "requestData"type = RequestData.class, field = "value")
    public String createOrderInfo(@RequestBody RequestData<Order> requestData) {
        return "success";
    }

}

先调用获取token的接口:

Java后端防止频繁请求、重复提交的方案设计拿到返回的token再去请求业务接口:

Java后端防止频繁请求、重复提交的方案设计重复请求会返回失败!

Java后端防止频繁请求、重复提交的方案设计

五、总结:

本文总结了Java接口防重点击的Token验证方式。通过先请求一个接口获取Token,再携带Token访问接口的方式,可以有效地防止重复请求和恶意攻击,同时提高系统性能和稳定性。Token的生成和验证都在服务器端进行,增加了安全性。总之,防重点击的Token验证方式是一种简单而有效的接口防重技术,适用于各种应用场景。


原文始发于微信公众号(明月予我):Java后端防止频繁请求、重复提交的方案设计

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/272746.html

(0)
明月予我的头像明月予我bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!