这里我要讨论的防止重复请求的方法是,当用户操作一个API数据流或任何数据源时,实际上他们只会进行一次操作,但因某种原因,可能是用户故意为之,也可能是黑客所为,导致系统数据出错。
为了避免这种情况,我们需要构建一个去重解决方案。 在本文中,我将利用Redis和Spring Boot 3来实现去重。
如果是按顺序来做,可以这样理解:
- 获取用户发送的请求体中的某些数据字段,目的是创建一个 Redis 键。具体选择哪个字段取决于业务需求和响应系统的架构。
- 以某种格式构建键后,可以选择使用 MD5 进行哈希处理(使用 MD5 是可选的,具体取决于您的需求)。如需使用 MD5,建议使用快速 MD5 以加快速度。
- 每次用户调用 API 时,都会检查 Redis 键。如果键存在,则返回重复数据错误。如果不存在,则继续处理逻辑。
- 在将键插入 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_
注解的逻辑,我使用环绕通知以提高灵活性。
上述代码中的逻辑实现过程如下所示:
- 首先,我们需要从API中提取请求体。
- 将请求体解析为Map<K, V>格式。
- 根据定义的数据字段构建原始密钥。
- 生成MD5密钥
- 通过密钥检查是否有重复请求
- 如果密钥已经存在于Redis中,就抛出一个异常。
- 如果密钥不在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 返回结果的响应类。大型企业和标准系统通常在该类中定义字段:code,message 和 data(名称可能有所不同,但这无关紧要)。
我们可以根据实际需要添加其他字段,例如 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
的注解,其中参数值如上所示。
- includeFieldKeys :标记将从请求体中提取两个字段
productId
和transactionId
作为输入来生成键 - optionalValues :这里声明
CAFEINCODE
- 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 实现了防重复功能。本文中有一些需要你考虑的结论如下:
- 选择请求体中的适当参数字段作为创建键的输入源;应忽略如 createTime 或 updateTime 这样的时间类型字段。
- 设置过期时间以符合项目的业务需求。
- 考虑是否需要进行 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课程