接口请求幂等唯一性

web项目暴露接口在前端页面的时候,在前端页面进行使用的时候,无可避免的会出现重复请求 / 恶意请求消耗服务器资源的情况。毕竟你没办法保证网络上的每个人都像个人。

除了需要保证接口请求的幂等性以外,我们还要需要对某些特殊的接口做一定的限制,这些限制的使用场景如下:特别是现在的服务资源有较大的概率是购买第三方的服务来提供的,比如手机短信验证码这一类资源,就更有必要去限制用户的请求。以防资源被盗刷。

解决方案

前端

本质上其实前端从页面和页面脚本能对请求做到的限制不算多,除非将请求的信息存入到本地变量之中,可以做到一定程度的限制(取决于客户端的缓存),但这也是不稳定的(有可能会被客户清空)。因此一般在前端采取的方式,就是调用请求的组件禁用了。

请求禁用

简单的来说,就是在发起请求的时候,将已经发起过请求的组件属性设置为disable为true,使得用户不能在前端页面再次发起请求。解决掉一定程度上的多次请求的问题。

效果

前端通过请求禁用的方式来进行限制,针对多次请求和被刷接口的场景都是有一定的作用的,但是通过这个方式来实现也存在着一些不合理的结果。

首先是针对多次请求来说,由于请求来自于客户的浏览器,往往会出现即使禁用组件,仍然可能会出现用户还是发出了多次的请求的情况。

而针对接口请求刷用的情况来说,由于有一些场景并非应当完全禁止请求,而是应当限制被请求访问的次数和频率,因此前端请求禁用需要进一步修改,改成限定一定的时间内禁止访问,等时限过了再允许进行访问,这样就合理多了。

问题

但即使如此,使用上面已经改进过后的请求禁用,但实际效果还是没办法做到保证限制多次请求和接口被刷。一方面是除了浏览器,客户也有可能用过发起请求的其他工具来进行请求。比如测试工具:apifox、postman、apipost等。而另一方面也在于浏览器禁用生效前,还是有可能已经发出多次请求;在禁用生效后,用户也可能通过修改控制台元素的属性来再次发起请求。因此,仅仅通过前端禁用发起请求来实现,显然是不合理的。

后端

从后端来实现的思路也很简单,可以认为就是通过记录发起过请求的 ip 等信息,进而通过记录的信息来记录发起过多少次请求。从而禁止多次请求和刷接口的问题。以下列举一些解决的方案,来介绍一下一般如何解决这个问题。其中包括有:

  1. 数据库键唯一
  2. 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编写切入点为注解作用方法的条件限制下进行使用,那么实际调用某些方法的时候,就可以顺利的调用到负责拦截的方法。在方法内部对访问次数、访问用户等条件做限制即可。

  1. 首先保证自己的项目能连接到自己所需要连接的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>
  1. 在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
  1. 编写一个简单的统一返回体 (方便对异常情况处理封装,返回友好信息给前端)
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;
    }
}
  1. 配置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;
    }
}
  1. 配置限流的注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RedisAccessLimit {
    long windowsSize() default 5L;    // 窗口滑动时间
    long limitSize() default 5l;      // 窗口时间限制访问次数
}
  1. 配置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;
    }
	··· ···
}
  1. 被调用的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
  1. 调用的接口
@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的配置之中)。

image-20240210205526628

可以看到在规定的窗口时间期间,访问超过三次,就会出现拦截请求的结果返回
image-20240210205713469

而在请求的次数低于限定值的时候,访问则是正常的

image-20240210205829532

接口幂等

接口幂等的实现逻辑更简单了,为了保证一次api请求的唯一性,那只要将请求拆开两个步骤,首次请求一个公共的接口,获得业务接口的访问权,然后再去实际调用业务接口,通过拦截器在调用实际业务代码之前先对唯一性进行检查即可。

以下代码步骤在上面的重复请求代码的基础上进行检查实现

  1. 添加接口幂等的注解类

    
    @Retention(RetentionPolicy.RUNTIME)
    @Target(value = {ElementType.METHOD, ElementType.TYPE})
    public @interface RedisIdempotent {
        String bodyParamName() default "";   // 请求参数名
        String tokenParamName();           // 请求唯一标志 token 的参数名
        Class bodyParamType();            // 请求参数类型
    }
    
  2. 编写唯一全局码生成器 (可以在此修改逻辑,根据TimeStamp、参数、Uri、用户名等条件来生成全局唯一码,这里只实现简单的逻辑)

    public class RequestTokenGenerator {
        public static String generateId() {
            return UUID.randomUUID().toString();
        }
    }
    
  3. 编写一个简单的统一请求体结构 (目的在于规范全局唯一码的位置)

    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;
        }
    }
    
  4. 编写获得全局唯一码的接口

    @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);
        }
    }
    
  5. 编写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!");
            }
        }
    }
    
  6. 编写业务调用的接口

        @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();
        }
    

结果测试:

获取全局唯一码接口

image-20240210205922833

使用全局唯一码进行第一次消费

image-20240210210024505

进行第二次消费

image-20240210210045191

显然的,结果是正确的,但是这种写法存在一个问题,无法处理,如果并发数量足够多可能某一次请求会获得不同的UUID,这个则需要再全局唯一码生成的时候先进行一定的条件过滤处理。具体的实现需要根据不同的业务来进行生成处理。(像支付这种可以使用订单号等信息进行生成)