接口请求幂等唯一性
web项目暴露接口在前端页面的时候,在前端页面进行使用的时候,无可避免的会出现重复请求 / 恶意请求消耗服务器资源的情况。毕竟你没办法保证网络上的每个人都像个人。
除了需要保证接口请求的幂等性以外,我们还要需要对某些特殊的接口做一定的限制,这些限制的使用场景如下:特别是现在的服务资源有较大的概率是购买第三方的服务来提供的,比如手机短信验证码这一类资源,就更有必要去限制用户的请求。以防资源被盗刷。
解决方案
前端
本质上其实前端从页面和页面脚本能对请求做到的限制不算多,除非将请求的信息存入到本地变量之中,可以做到一定程度的限制(取决于客户端的缓存),但这也是不稳定的(有可能会被客户清空)。因此一般在前端采取的方式,就是调用请求的组件禁用了。
请求禁用
简单的来说,就是在发起请求的时候,将已经发起过请求的组件属性设置为disable为true,使得用户不能在前端页面再次发起请求。解决掉一定程度上的多次请求的问题。
效果
前端通过请求禁用的方式来进行限制,针对多次请求和被刷接口的场景都是有一定的作用的,但是通过这个方式来实现也存在着一些不合理的结果。
首先是针对多次请求来说,由于请求来自于客户的浏览器,往往会出现即使禁用组件,仍然可能会出现用户还是发出了多次的请求的情况。
而针对接口请求刷用的情况来说,由于有一些场景并非应当完全禁止请求,而是应当限制被请求访问的次数和频率,因此前端请求禁用需要进一步修改,改成限定一定的时间内禁止访问,等时限过了再允许进行访问,这样就合理多了。
问题
但即使如此,使用上面已经改进过后的请求禁用,但实际效果还是没办法做到保证限制多次请求和接口被刷。一方面是除了浏览器,客户也有可能用过发起请求的其他工具来进行请求。比如测试工具:apifox、postman、apipost等。而另一方面也在于浏览器禁用生效前,还是有可能已经发出多次请求;在禁用生效后,用户也可能通过修改控制台元素的属性来再次发起请求。因此,仅仅通过前端禁用发起请求来实现,显然是不合理的。
后端
从后端来实现的思路也很简单,可以认为就是通过记录发起过请求的 ip 等信息,进而通过记录的信息来记录发起过多少次请求。从而禁止多次请求和刷接口的问题。以下列举一些解决的方案,来介绍一下一般如何解决这个问题。其中包括有:
- 数据库键唯一
- Redis + 全局唯一ID + Aspect拦截器(处理重复请求和接口幂等)
数据库全局唯一
由于这种实现方式并不常见于实际使用,这里只介绍一下实现的原理和思路。
重复请求
通过数据库来实现唯一性非常简单,只需要通过利用表格的唯一性索引,也就是Unique即可。因为当你尝试插入一个重复值到具有唯一性的数据库字段的时候,数据库会报错。
SQL 错误 [1062] [23000]: Duplicate entry '1' for key '某个Token / 生成的当前用户或IP生成的唯一键'
Duplicate entry '某个Token / 生成的当前用户或IP生成的唯一键' for key 'uniqueId'
接口防刷
借此就可以根据报错来对判断是否存在着重复请求的问题,但是这种方式只适用于解决重复请求的问题,面对刷接口的请求则毫无办法。
Redis + Token/其他唯一键 + Aspect拦截器
重复请求
为了方便后续解决这一类的问题,我针对这个需求写了一个demo。
(不贴demo的git是因为有一些配置不能公开) 主要的限制条件其实是通过注解的方式来进行实现的。然后再通过AspectJ编写切入点为注解作用方法的条件限制下进行使用,那么实际调用某些方法的时候,就可以顺利的调用到负责拦截的方法。在方法内部对访问次数、访问用户等条件做限制即可。
- 首先保证自己的项目能连接到自己所需要连接的redis服务器
依赖如下:
<!-- 引入Redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
<dependency>
<groupId>org.luaj</groupId>
<artifactId>luaj-jse</artifactId>
<version>3.0.1</version>
</dependency>
- 在yml或者yaml的配置文件之中,添加以下的配置参数 (仅展示需要的部分)
spring:
# redis 服务器配置
redis:
password: redis-connect-password
database: 0
sentinel:
master: mymaster
nodes: redis-server-ip-address
# 连接池属性配置
lettuce:
pool:
# 最小空闲连接数
min-idle: 5
# 最大空闲连接数
max-idle: 10
# 最大活动的连接数
max-active: 10
# 最大等待
max-wait: 3000
- 编写一个简单的统一返回体 (方便对异常情况处理封装,返回友好信息给前端)
public class ResponseDomain {
private int status;
private String msg;
public static ResponseDomain success() {
ResponseDomain responseDomain = new ResponseDomain();
responseDomain.setStatus(200);
responseDomain.setMsg("success");
return responseDomain;
}
public static ResponseDomain success(String msg) {
ResponseDomain responseDomain = new ResponseDomain();
responseDomain.setStatus(200);
responseDomain.setMsg(msg);
return responseDomain;
}
public static ResponseDomain fail(String msg) {
ResponseDomain responseDomain = new ResponseDomain();
responseDomain.setStatus(500);
responseDomain.setMsg(msg);
return responseDomain;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
- 配置RedisTemplate的持久化处理 (Redis脚本 / 命令调用要求必须重写,否则会报错)
@Configuration
public class RedisLettuceTemplate {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){
// 创建RedisTemplate对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工厂
template.setConnectionFactory(connectionFactory);
// 创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 设置Key的序列化
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 设置Value的序列化
template.setValueSerializer(new StringRedisSerializer()); // 必须更改配置,不然无法持久化
template.setHashValueSerializer(jsonRedisSerializer);
// 返回
return template;
}
}
- 配置限流的注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RedisAccessLimit {
long windowsSize() default 5L; // 窗口滑动时间
long limitSize() default 5l; // 窗口时间限制访问次数
}
- 配置Aspect拦截处理逻辑
@Aspect
@Component
public class RedisApiAccessAspect {
@Resource
private RedisTemplate<String,Object> redisTemplate;
private static final Logger logger = LoggerFactory.getLogger(RedisApiAccessAspect.class);
/**
* 重复请求拦截处理逻辑
* 利用 ip + uri 来作为请求是否多次重复的判断标准 (如果需要可以添加 token等条件)
*/
@Pointcut("@annotation(com.leticiafeng.annotation.RedisAccessLimit)")
public void redisApiAccessLimitMethod() {
}
@Pointcut("@within(com.leticiafeng.annotation.RedisAccessLimit)")
public void redisAccessLimitClass() {
}
@Around("redisApiAccessLimitMethod() || redisAccessLimitClass()")
public Object redisAccessLimitAspectMethod(ProceedingJoinPoint joinPoint) throws Throwable {
logger.info("当前拦截类:" + joinPoint.getSignature().getDeclaringTypeName() + "-" + "当前拦截方法" +
joinPoint.getSignature().getName() + "-" + "请求参数:" + Arrays.toString(joinPoint.getArgs()));
// 接收到的请求、请求的信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String requestUri = request.getRequestURI();
String ip = request.getRemoteAddr();
String token = request.getParameter("token");
Long windowSize = 0L;
Long limitSize = 0L;
// 获得当前方法的署名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RedisAccessLimit accessLimitClass = method.getDeclaringClass().getAnnotation(RedisAccessLimit.class);
Optional<RedisAccessLimit> classAccessLimitOptional = Optional.ofNullable(accessLimitClass);
if(classAccessLimitOptional.isPresent()) {
windowSize = accessLimitClass.windowsSize();
limitSize = accessLimitClass.limitSize();;
}
RedisAccessLimit accessLimitMethod = method.getAnnotation(RedisAccessLimit.class);
Optional<Object> methodAccessLimitOptional = Optional.ofNullable(accessLimitMethod);
if (methodAccessLimitOptional.isPresent()) {
// 获取注解配置参数
windowSize = accessLimitMethod.windowsSize();
limitSize = accessLimitMethod.limitSize();
}
if (this.isRequestAllowed(ip, requestUri, token, limitSize, windowSize, TimeUnit.SECONDS)) {
return joinPoint.proceed();
} else {
return ResponseDomain.fail("This user had been block!");
}
}
/**
* 接口防刷 - 如果请求的次数超过了某一定的范围,禁止继续请求
* @param userIpAddress 用户的唯一标识
* @Param userRequestUri 用户请求的URI
* @param apiKey 接口的唯一标识码
* @param limitSize 限制请求的数量
* @param windowSize 限制的时间范围
* @param timeUnit 限制的时间单位
* @return
*/
public boolean isRequestAllowed(String userIpAddress, String userRequestUri, String apiKey, long limitSize, long windowSize, TimeUnit timeUnit) {
long endTimeStamp = System.currentTimeMillis();
long windowSizeInMillis = timeUnit.toMillis(windowSize);
long startTimeStamp = endTimeStamp - windowSizeInMillis;
String redisKey = this.buildLimitRedisKey(userIpAddress, userRequestUri, apiKey);
List<String> keyList = new ArrayList<>();
keyList.add(redisKey);
// 将时间戳转换为字符串
String formattedEndTimeStamp = String.valueOf(endTimeStamp);
String formattedStartTimeStamp = String.valueOf(startTimeStamp);
String limitSizeStr = String.valueOf(limitSize);
// Lua脚本内容
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Boolean.class);
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("accesssLimitLua/redisAccessLimit.lua")));
// 传递参数时,确保参数顺序与Lua脚本中的ARGV数组相对应
Boolean allowed = redisTemplate.execute(redisScript, keyList, formattedEndTimeStamp, formattedStartTimeStamp, limitSizeStr);
return allowed != null && allowed;
}
private String buildLimitRedisKey(String ipAddress, String apiUri, String apiKey) {
return "limit:" + ipAddress + "-" + apiUri;
}
··· ···
}
- 被调用的Lua脚本
local key = KEYS[1]
local endTimeStamp = tonumber(ARGV[1])
local startTimeStamp = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, '-inf', startTimeStamp)
redis.call('ZADD', key, endTimeStamp, endTimeStamp)
local count = redis.call('ZCOUNT', key, startTimeStamp, endTimeStamp)
if count > limit then
return false
else
return true
end
- 调用的接口
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/redisAccessLimit")
@RedisAccessLimit(windowsSize = 100, limitSize = 2)
public ResponseDomain redisAccessLimit() {
try {
Thread.sleep(1000);
return ResponseDomain.success();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
··· ···
}
当然在当前的微服务环境下进行处理的时候,也有一些中间件可以帮助我们做一些限制效果,比如Sentienl就有针对某个接口、具体到某个资源的,对请QPS等进行限制的处理手段。但是一般来说这种限制只能针对整个全局做限制,而不能单独的针对某一个用户进行限制。(除非你将用户的token作为限制参数手动添加到Sentinel的配置之中)。
可以看到在规定的窗口时间期间,访问超过三次,就会出现拦截请求的结果返回
而在请求的次数低于限定值的时候,访问则是正常的
接口幂等
接口幂等的实现逻辑更简单了,为了保证一次api请求的唯一性,那只要将请求拆开两个步骤,首次请求一个公共的接口,获得业务接口的访问权,然后再去实际调用业务接口,通过拦截器在调用实际业务代码之前先对唯一性进行检查即可。
以下代码步骤在上面的重复请求代码的基础上进行检查实现
-
添加接口幂等的注解类
@Retention(RetentionPolicy.RUNTIME) @Target(value = {ElementType.METHOD, ElementType.TYPE}) public @interface RedisIdempotent { String bodyParamName() default ""; // 请求参数名 String tokenParamName(); // 请求唯一标志 token 的参数名 Class bodyParamType(); // 请求参数类型 }
-
编写唯一全局码生成器 (可以在此修改逻辑,根据TimeStamp、参数、Uri、用户名等条件来生成全局唯一码,这里只实现简单的逻辑)
public class RequestTokenGenerator { public static String generateId() { return UUID.randomUUID().toString(); } }
-
编写一个简单的统一请求体结构 (目的在于规范全局唯一码的位置)
public class RequestBody<T> { private RequestHeader header; private T requestClass; public RequestHeader getHeader() { return header; } public void setHeader(RequestHeader header) { this.header = header; } public T getRequestClass() { return requestClass; } public void setRequestClass(T requestClass) { this.requestClass = requestClass; } } public class RequestHeader { private String token; public String getToken() { return token; } public void setToken(String token) { this.token = token; } } public class RequestClass { private String requestParams; public String getRequestParams() { return requestParams; } public void setRequestParams(String requestParams) { this.requestParams = requestParams; } }
-
编写获得全局唯一码的接口
@RestController @RequestMapping("/common") public class CommonController { @Resource private RedisTemplate<String,Object> redisTemplate; /** * 根据某些条件,判断是否允许发放请求唯一标识码 * @return */ @PostMapping("/getRequestAuth") public ResponseDomain getRequestAuth() { String token = RequestTokenGenerator.generateId(); redisTemplate.opsForValue().set(token, token, 10, TimeUnit.MINUTES); return ResponseDomain.success(token); } }
-
编写AspectJ拦截处理逻辑
@Aspect @Component public class RedisApiAccessAspect { @Resource private RedisTemplate<String,Object> redisTemplate; private static final Logger logger = LoggerFactory.getLogger(RedisApiAccessAspect.class); /** * 接口请求幂等处理逻辑 */ @Pointcut("@annotation(com.leticiafeng.annotation.RedisIdempotent)") public void redisIdempotent() { } @Around("redisIdempotent()") public Object redisIdempotentAspectMethod(ProceedingJoinPoint joinPoint) throws Throwable { logger.info("当前拦截类:" + joinPoint.getSignature().getDeclaringTypeName() + "-" + "当前拦截方法" + joinPoint.getSignature().getName() + "-" + "请求参数:" + Arrays.toString(joinPoint.getArgs())); String bodyParamName = null; String tokenParamName = null; Class bodyParamType = null; // 当前Uri的请求 token ,作为当前的请求的唯一判断条件 String token = ""; MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); // 获取当前被拦截方法 类名级别上的注解配置 RedisIdempotent redisIdempotentClass = method.getDeclaringClass().getAnnotation(RedisIdempotent.class); Optional<RedisIdempotent> redisIdempotentClassOptional = Optional.ofNullable(redisIdempotentClass); if (redisIdempotentClassOptional.isPresent()) { bodyParamName = redisIdempotentClass.bodyParamName(); tokenParamName = redisIdempotentClass.tokenParamName(); bodyParamType = redisIdempotentClass.bodyParamType(); } // 获取当前被拦截方法自身上的注解 RedisIdempotent redisIdempotentMethod = method.getAnnotation(RedisIdempotent.class); Optional<RedisIdempotent> redisIdempotentMethodOptional = Optional.ofNullable(redisIdempotentMethod); if (redisIdempotentMethodOptional.isPresent()) { bodyParamName = redisIdempotentMethod.bodyParamName(); tokenParamName = redisIdempotentMethod.tokenParamName(); bodyParamType = redisIdempotentMethod.bodyParamType(); } // 将当前的请求的参数和信息作为条件保存到redis之中,如果不存在则允许执行,如果存在则不允许执行 Constructor constructor = bodyParamType.getDeclaredConstructor(); constructor.setAccessible(true); Object requestParam = constructor.newInstance(); Map<String, Object> paramValue = AopUtils.getParamValue(joinPoint); if ( requestParam instanceof RequestBody ) { RequestBody redisIdempotentBody = (RequestBody) paramValue.get(bodyParamName); token = String.valueOf(AopUtils.getFieldValue(redisIdempotentBody.getHeader(), tokenParamName)); if(redisTemplate.delete(token)) { return joinPoint.proceed(); } else { return ResponseDomain.fail("This request is illegal!"); } } else { return ResponseDomain.fail("Annotation setting is fail, plesae check it!"); } } }
-
编写业务调用的接口
@GetMapping("/redisIdempotent") @RedisIdempotent(bodyParamName = "requestBody", bodyParamType = com.leticiafeng.domain.RequestBody.class, tokenParamName = "token") public ResponseDomain redisIdempotent(@RequestBody com.leticiafeng.domain.RequestBody<RequestClass> requestBody) { return ResponseDomain.success(); }
结果测试:
获取全局唯一码接口
使用全局唯一码进行第一次消费
进行第二次消费
显然的,结果是正确的,但是这种写法存在一个问题,无法处理,如果并发数量足够多可能某一次请求会获得不同的UUID,这个则需要再全局唯一码生成的时候先进行一定的条件过滤处理。具体的实现需要根据不同的业务来进行生成处理。(像支付这种可以使用订单号等信息进行生成)