为了账号安全,请及时绑定邮箱和手机立即绑定

RESTful API:如何有效避免重复请求:

这里我要讨论的防止重复请求的方法是,当用户操作一个API数据流或任何数据源时,实际上他们只会进行一次操作,但因某种原因,可能是用户故意为之,也可能是黑客所为,导致系统数据出错。

为了避免这种情况,我们需要构建一个去重解决方案。 在本文中,我将利用Redis和Spring Boot 3来实现去重。

如果是按顺序来做,可以这样理解:

  1. 获取用户发送的请求体中的某些数据字段,目的是创建一个 Redis 键。具体选择哪个字段取决于业务需求和响应系统的架构。
  2. 以某种格式构建键后,可以选择使用 MD5 进行哈希处理(使用 MD5 是可选的,具体取决于您的需求)。如需使用 MD5,建议使用快速 MD5 以加快速度。
  3. 每次用户调用 API 时,都会检查 Redis 键。如果键存在,则返回重复数据错误。如果不存在,则继续处理逻辑。
  4. 在将键插入 Redis 时,必须配置一个过期时间。在本文的示例中,我将过期时间设定为大约 40 秒,以便进行演示。

这就是想法,但实际实现还需要一些额外的技术,我之后会提到。我们先来建立项目并测试一下。

项目的结构(例如:文件夹和文件的组织方式)

在这个项目中,我使用了Spring Boot 3.3.4和Java 17,还有Spring AOP(一种Spring框架中的面向切面编程技术)。

以下是每一部分的具体代码实现

    package com.cafeincode.demo.aop;  
    import java.lang.annotation.Documented;  
    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)  
    @Documented  
    public @interface PreventDuplicateValidator {  

        String[] includeFieldKeys() default {};  

        String[] optionalValues() default {};  

        long expireTime() default 10_000L;  

    }

防止重复验证我把它当作注解来声明,这里有两个数据字段:

includeFieldKeys: 用于指定生成键所需的字段列表。

optionalValues: 是一些可选值的列表,这些值可以添加到键中,以增加灵活性并防止重复。

expireTime: 是键的过期时间,默认为10秒。

    package com.cafeincode.demo.aop;  
    import com.cafeincode.demo.enums.ErrorCode;  
    import com.cafeincode.demo.exception.DuplicationException;  
    import com.cafeincode.demo.exception.HandleGlobalException;  
    import com.cafeincode.demo.utils.Utils;  
    import com.fasterxml.jackson.core.type.TypeReference;  
    import com.fasterxml.jackson.databind.ObjectMapper;  
    import java.util.Arrays;  
    import java.util.Collections;  
    import java.util.Map;  
    import java.util.Objects;  
    import java.util.stream.Collectors;  
    import lombok.RequiredArgsConstructor;  
    import lombok.extern.slf4j.Slf4j;  
    import org.aspectj.lang.ProceedingJoinPoint;  
    import org.aspectj.lang.annotation.Around;  
    import org.aspectj.lang.annotation.Aspect;  
    import org.springframework.data.redis.connection.RedisStringCommands;  
    import org.springframework.data.redis.core.RedisCallback;  
    import org.springframework.data.redis.core.RedisTemplate;  
    import org.springframework.data.redis.core.types.Expiration;  
    import org.springframework.stereotype.Component;  

    /**  

* 作者: hungtv27  

* 邮箱: hungtvk12@gmail.com  

* 博客: cafeincode.com  
     */  

    @Aspect  
    @Component  
    @RequiredArgsConstructor  
    @Slf4j  
    public class PreventDuplicateValidatorAspect {  

        private final RedisTemplate redisTemplate;  
        private final ObjectMapper objectMapper;  

        @Around(value = "@annotation(preventDuplicateValidator)", argNames = "pjp, preventDuplicateValidator")  
        public Object aroundAdvice(ProceedingJoinPoint pjp, PreventDuplicateValidator preventDuplicateValidator)  
            throws Throwable {  

            var includeKeys = preventDuplicateValidator.includeFieldKeys();  
            var optionalValues = preventDuplicateValidator.optionalValues();  
            var expiredTime = preventDuplicateValidator.expireTime();  

            if (includeKeys == null || includeKeys.length == 0) {  
                log.warn("[PreventDuplicateRequestAspect] 忽略,因为注解中没有找到includeKeys");  
                return pjp.proceed();  
            }  

            //从请求体中提取请求数据  
            var requestBody = Utils.extractRequestBody(pjp);  
            if (requestBody == null) {  
                log.warn("[PreventDuplicateRequestAspect] 忽略,因为请求体对象未在方法参数中找到");  
                return pjp.proceed();  
            }  

            //将请求体解析为map<String, Object>  
            var requestBodyMap = convertJsonToMap(requestBody);  

            //根据includeKeys, optionalValues, requestBodyMap构建Redis键  
            var keyRedis = buildKeyRedisByIncludeKeys(includeKeys, optionalValues, requestBodyMap);  

            //将keyRedis哈希为keyRedisMD5:这是一个可选步骤  
            var keyRedisMD5 = Utils.hashMD5(keyRedis);  

            log.info(String.format("[PreventDuplicateRequestAspect] 原始键: [%s],生成的keyRedisMD5: [%s]", keyRedis, keyRedisMD5));  

            //根据Redis键处理逻辑以检查重复请求  
            deduplicateRequestByRedisKey(keyRedisMD5, expiredTime);  

            return pjp.proceed();  
        }  

        private String buildKeyRedisByIncludeKeys(String[] includeKeys, String[] optionalValues, Map<String, Object> requestBodyMap) {  

            var keyWithIncludeKey = Arrays.stream(includeKeys)  
                .map(requestBodyMap::get)  
                .filter(Objects::nonNull)  
                .map(Object::toString)  
                .collect(Collectors.joining(":"));  

            if (optionalValues.length > 0) {  
                return keyWithIncludeKey + ":" + String.join(":", optionalValues);  
            }  
            return keyWithIncludeKey;  
        }  

        public void deduplicateRequestByRedisKey(String key, long expiredTime) {  
            var firstSet = (Boolean) redisTemplate.execute((RedisCallback<Boolean>) connection ->  
                connection.set(key.getBytes(), key.getBytes(), Expiration.milliseconds(expiredTime),  
                    RedisStringCommands.SetOption.SET_IF_ABSENT));  

            if (firstSet != null && firstSet) {  
                log.info(String.format("[PreventDuplicateRequestAspect] key: %s 已成功设置", key));  
                return;  
            }  
            log.warn(String.format("[PreventDuplicateRequestAspect] 已存在key: %s", key));  
            throw new DuplicationException(ErrorCode.ERROR_DUPLICATE.getCode(), ErrorCode.ERROR_DUPLICATE.getMessage());  
        }  

        public Map<String, Object> convertJsonToMap(Object jsonObject) {  
            if (jsonObject == null) {  
                return Collections.emptyMap();  
            }  
            try {  
                return objectMapper.convertValue(jsonObject, new TypeReference<>() {  
                });  
            } catch (Exception ignored) {  
                return Collections.emptyMap();  
            }  
        }  

    }

PreventDuplicateValidatorAspect 是一个切面,实现了针对 _PreventDuplicateValidator_ 注解的逻辑,我使用环绕通知以提高灵活性。

上述代码中的逻辑实现过程如下所示:

  1. 首先,我们需要从API中提取请求体。
  2. 将请求体解析为Map<K, V>格式。
  3. 根据定义的数据字段构建原始密钥。
  4. 生成MD5密钥
  5. 通过密钥检查是否有重复请求
  6. 如果密钥已经存在于Redis中,就抛出一个异常。
  7. 如果密钥不在Redis中,将密钥插入Redis,添加过期时间参数,然后通过pjp.proceed()继续主函数的处理。
package com.cafeincode.demo.config;  
import com.fasterxml.jackson.databind.ObjectMapper;  
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
import org.springframework.context.annotation.Primary;  
import org.springframework.data.redis.connection.RedisConnectionFactory;  
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;  
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;  
import org.springframework.data.redis.core.RedisTemplate;  

@Configuration  
public class BeanConfig {  

    @Value("${redis.host}")  
    private String redisHost;  

    @Value("${redis.port}")  
    private int redisPort;  

    @Bean(name = "objectMapper")  
    @Primary  
    public ObjectMapper objectMapper() {  
        ObjectMapper mapper = new ObjectMapper();  
        mapper.registerModule(new JavaTimeModule());  
        return mapper;  
    }  

    @Bean  
    public RedisConnectionFactory redisConnectionFactory() {  
        var config = new RedisStandaloneConfiguration(redisHost, redisPort);  
        return new LettuceConnectionFactory(config);  
    }  

    @Bean  
    @Primary  
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {  
        var template = new RedisTemplate<>();  
        template.setConnectionFactory(redisConnectionFactory);  
        return template;  
    }  

}

BeanConfig我在配置了ObjectMapper和Redis连接bean的配置

    包 com.cafeincode.demo.dto;  
    导入 java.io.Serializable;  
    导入 lombok.AllArgsConstructor;  
    导入 lombok.Data;  
    导入 lombok.NoArgsConstructor;  
    导入 lombok.experimental.SuperBuilder;  

    @Data  
    @AllArgsConstructor  
    @NoArgsConstructor  
    @SuperBuilder  
    public class BaseResponse<T> implements Serializable {  

        public static final String OK_CODE = "200";  
        public static final String OK_MESSAGE = "成功处理";  
        private String code;  
        private String message;  
        private T data;  

        public static <T> BaseResponse<T> ofSucceeded(T data) {  
            BaseResponse<T> response = new BaseResponse<>();  
            response.code = OK_CODE;  
            response.message = OK_MESSAGE;  
            response.data = data;  
            return response;  
        }  
    }

BaseResponse 用于通过 API 返回结果的响应类。大型企业和标准系统通常在该类中定义字段:codemessagedata(名称可能有所不同,但这无关紧要)。

我们可以根据实际需要添加其他字段,例如 metadata_requestid 等。

    包 com.cafeincode.demo.dto;  
    导入 java.time.Instant;  
    导入 lombok.Data;  

    @Data  
    public class ProductDto {  

        private String productId;  
        private String productName;  
        private String productDescription;  
        private String transactionId;  
        private Instant requestTime;  
        private String requestId;  

    }
    包 com.cafeincode.demo.enums;  
    导入 lombok.AllArgsConstructor;  
    导入 lombok.Getter;  

    @AllArgsConstructor  
    @Getter  
    public enum ErrorCode {  

        ERROR_DUPLICATE("CF_275", "数据重复,请稍后再试");  

        private final String code;  
        private final String message;  
    }
    包 com.cafeincode.demo.exception;  
    import lombok.AllArgsConstructor;  
    import lombok.Builder;  
    import lombok.Getter;  
    import lombok.Setter;  
    import org.springframework.http.HttpStatus;  

    @Getter  
    @Setter  
    @AllArgsConstructor  
    @Builder  
    public class DuplicationException extends RuntimeException {  

        private String code;  
        private String message;  
        private HttpStatus httpStatus;  

        public DuplicationException(String code, String message) {  
            this.code = code;  
            this.message = message;  
            httpStatus = HttpStatus.BAD_REQUEST;  
        }  

    }
    包 com.cafeincode.demo.exception;  
    导入 java.util.HashMap;  
    导入 java.util.Map;  
    导入 org.springframework.http.HttpStatus;  
    导入 org.springframework.http.ResponseEntity;  
    导入 org.springframework.web.bind.annotation.ControllerAdvice;  
    导入 org.springframework.web.bind.annotation.ExceptionHandler;  
    导入 org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;  

    @ControllerAdvice  
    public class HandleGlobalException extends ResponseEntityExceptionHandler {  

        @ExceptionHandler(DuplicationException.class)  
        private void handleError(Exception ex) {  

            //TODO: 你应当在此处自定义更多内容  

            Map<String, String> body = new HashMap<>();  
            body.put("code", ((DuplicationException) ex).getCode());  
            body.put("message", ex.getMessage());  
            return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);  
        }  
    }

HandleGlobalException 类中,我会处理从 PreventDuplicateValidatorAspect 抛出的 DuplicationException

package com.cafeincode.demo.service  
import com.cafeincode.demo.dto.ProductDto  

public interface IProductService {  

    ProductDto createProduct(ProductDto dto);  

}
    包 com.cafeincode.demo.service;  
    导入 com.cafeincode.demo.dto.ProductDto;  
    导入 lombok.RequiredArgsConstructor;  
    导入 lombok.extern.slf4j.Slf4j;  
    导入 org.springframework.stereotype.Component;  

    @Component  
    @Slf4j  
    @RequiredArgsConstructor  
    public class ProductService implements IProductService {  

        @Override  
        public ProductDto createProduct(ProductDto dto) {  
            //TODO: 添加更多逻辑内容  
            return null;  
        }  

    }

你可以如果需要的话添加更多逻辑,只要返回 null 即可实现演示目的。

    包 com.cafeincode.demo.utils;  
    导入 jakarta.xml.bind.DatatypeConverter;  
    导入 java.lang.annotation.Annotation;  
    导入 java.lang.reflect.Method;  
    导入 java.security.MessageDigest;  
    导入 lombok.extern.slf4j.Slf4j;  
    导入 org.aspectj.lang.ProceedingJoinPoint;  
    导入 org.aspectj.lang.reflect.MethodSignature;  
    导入 org.springframework.web.bind.annotation.RequestBody;  

    @Slf4j  
    公共类 Utils {  

        私有() Utils() {  
        }  

        公共 静态 Object extractRequestBody(ProceedingJoinPoint pjp) {  
            尝试 {  
                对于 (int i = 0; i < pjp.getArgs().长度; i++) {  
                    Object arg = pjp.getArgs()[i];  
                    如果 (arg != null 并且 isAnnotatedWithRequestBody(pjp, i)) {  
                        返回 arg;  
                    }  
                }  
            } 抓住 (异常 ex) {  
                log.error("", ex);  
            }  
            返回 null;  
        }  

        私有 静态 boolean isAnnotatedWithRequestBody(ProceedingJoinPoint pjp, int paramIndex) {  
            Method method = getMethod(pjp);  
            var parameterAnnotations = method.getParameterAnnotations();  
            对于 (Annotation annotation : parameterAnnotations[paramIndex]) {  
                如果 (RequestBody.class.isAssignableFrom(annotation.annotationType())) {  
                    返回 真;  
                }  
            }  
            返回 假;  
        }  

        私有 静态 Method getMethod(ProceedingJoinPoint pjp) {  
            MethodSignature methodSignature = (MethodSignature) pjp.getSignature();  
            返回 methodSignature.getMethod();  
        }  

        公共 静态 字符串 hashMD5(字符串 source) {  
            字符串 res = null;  
            尝试 {  
                var messageDigest = MessageDigest.getInstance("MD5");  
                var mdBytes = messageDigest.digest(source.getBytes());  
                res = DatatypeConverter.printHexBinary(mdBytes);  
            } 抓住 (异常 e) {  
                log.error("", e);  
            }  
            返回 res;  
        }  
    }

Utils 包含从 ProceedingJoinPoint 中提取请求体,以及计算MD5哈希值的方法。

    redis:  
      host: localhost  
      port: 6379  
    spring:  
      application:  
        name: 产品服务应用  
    server:  
      端口: 8888

配置一下 application-local.yml 文件

    version: "3.2"  
    services:  
      redis:  
        container_name: demo-service-redis  
        image: redis:6.2.5  
        ports:  
          - '6379:6379'
package com.cafeincode.demo.controller;  
import com.cafeincode.demo.aop.PreventDuplicateValidator;  
import com.cafeincode.demo.dto.BaseResponse;  
import com.cafeincode.demo.dto.ProductDto;  
import com.cafeincode.demo.service.ProductService;  
import lombok.RequiredArgsConstructor;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.web.bind.annotation.PostMapping;  
import org.springframework.web.bind.annotation.RequestBody;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RestController;  

@RestController  
@Slf4j  
@RequestMapping("/products")  
@RequiredArgsConstructor  
public class ProductController {  

    private final ProductService productService;  

    @PostMapping  
    @PreventDuplicateValidator(  
        includeFieldKeys = {"productId", "transactionId"},  
        optionalValues = {"CAFEINCODE"},  
        expireTime = 40_000L)  
    public BaseResponse<?> createProduct(@RequestBody ProductDto request) {  
        return BaseResponse.ofSucceeded(productService.createProduct(request));  
    }  

}

在主控制器中,我声明使用名为PreventDuplicateValidator的注解,其中参数值如上所示。

  1. includeFieldKeys :标记将从请求体中提取两个字段productIdtransactionId作为输入来生成键
  2. optionalValues :这里声明CAFEINCODE
  3. expireTime :Redis缓存中的数据有效期,我设置为40秒。

行了,咱们跑个项目然后试试:

对于 MacOS 和 Windows 用户,首先打开 Docker Desktop,然后在终端中输入 docker-compose up -d 并运行命令。

对于使用 Ubuntu 的系统,您需要先安装 Docker,再运行上述命令。

我用的是 MacBook,它已经开机了,所以我只需要启动它就可以用了

Redis 和 Docker

检查 Redis 连接状态

先检查与 Redis 的连接是否畅通,再启动应用。

本地配置文件,JDK。

启动一下 Spring Boot 应用程序

你打开Postman测试,我把请求体放在下面供你复制和试验使用。

    {  
        "productId": "hungtv27-test-001",  
        "productName": "CAFEINCODE",  
        "productDescription": "威胁识别购买战争管理少许朋友南方真正椅子(注意:此处可能为测试字符串,无实际意义)",  
        "transactionId": "cd076846-ff28-4307-8524-3eb6e1809838",  
        "requestTime": 1696069378367,  
        "requestId": "{{$randomUUID}}"  
    }

点击 发送,看看结果如何。

第一次接电话时的回复

验证成功后,将密钥初始化到redis

查看控制台日志,发现带有MD5密钥 6C518A2B1666005572EDFC8240A130F2 的消息在 Redis 中不存在,因此它将在第一次被初始化,并将过期时间设置为 40 秒。现在我来检查一下 Redis 中的数据。

Redis中的MD5键

6C518A2B1666005572EDFC8240A130F2已在Redis中成功初始化。现在我们将继续再调用一次该API以检查结果。预期会返回错误信息CF_275

第二次呼叫时的响应

查看控制台日志看看键 6C518A2B1666005572EDFC8240A130F2 是否已经在 Redis 里,如果键已存在,会向客户端返回错误 "CF_275"。

所以我们基于 Redis 和 Spring AOP 实现了防重复功能。本文中有一些需要你考虑的结论如下:

  1. 选择请求体中的适当参数字段作为创建键的输入源;应忽略如 createTime 或 updateTime 这样的时间类型字段。
  2. 设置过期时间以符合项目的业务需求。
  3. 考虑是否需要进行 MD5 加密。如果想优化性能,可以选择移除或使用 Fast MD5 的选项(在此文中不使用)。

最后,在完成所有逻辑实现之后,我们只需要在需要使用的控制器里声明注解。数据字段设置非常灵活,所以我们很少需要做进一步的修改。

谢谢,临走前

👏 如果你有更好解决方案,请在下面留言,我们可以一起讨论并学习。

👏 给这个故事点个赞吧,关注一下作者, 👉👉👉 hungtv27

👏 请在下面评论区分享您的问题或想法。

RESTful API:
[2024年免费学习REST API的14门最佳Udemy课程推荐

以下是一些2024年Udemy上值得学习的免费REST API课程](https://medium.com/javarevisited/top-14-free-udemy-courses-to-learn-rest-apis-in-2024-21fca7d2c1ac)

2024年Java开发者必学的6个在线Spring Boot课程

_原发布于https://cafeincode.com 2024年10月1日.

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消