优雅的接口防刷处理方案 下

本文涉及的产品
云原生内存数据库 Tair,内存型 2GB
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Redis 版,经济版 1GB 1个月
简介: 优雅的接口防刷处理方案 下

同一个Controller的所有接口方法映射路径的前缀都包含了/pass

我们在类上通过注解@ReqeustMapping标记映射路径/pass,这样所有的接口方法前缀都包含了/pass,并且以致于后面要修改映射路径前缀时只需改这一块地方即可

这也是我们使用SpringMVC最常见的用法

那么,我们的自定义注解也可不可以这样做呢?先无中生有个需求

假设PassController中所有接口都是要进行防刷处理的,并且他们的x, y, z值就一样

如果我们的自定义注解还是只能加载方法上的话,一个一个接口加,那么无疑这是一种很呆的做法

要改的话,其实也很简单,首先是修改自定义注解,让其可以作用在类上

接着就是修改AccessLimitInterceptor的处理逻辑

AccessLimitInterceptor中代码修改的有点多,主要逻辑如下

与之前实现比较,不同点在于x, y, z的值要首先尝试在目标类中获取

其次,一旦类中标有此注解,即代表此类下所有接口方法都要进行防刷处理

如果其接口方法同样也标有此注解,根据就近优先原则,以接口方法中的注解标明的值为准

/**
 * @author: Zero
 * @time: 2023/2/14
 * @description: 接口防刷拦截处理
 */
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
    /**
     * 锁住时的key前缀
     */
    public static final String LOCK_PREFIX = "LOCK";
    /**
     * 统计次数时的key前缀
     */
    public static final String COUNT_PREFIX = "COUNT";
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//      自定义注解 + 反射 实现, 版本 2.0
        if (handler instanceof HandlerMethod) {
            // 访问的是接口方法,转化为待访问的目标方法对象
            HandlerMethod targetMethod = (HandlerMethod) handler;
            // 获取目标接口方法所在类的注解@AccessLimit
            AccessLimit targetClassAnnotation = targetMethod.getMethod().getDeclaringClass().getAnnotation(AccessLimit.class);
            // 特别注意不能采用下面这条语句来获取,因为 Spring 采用的代理方式来代理目标方法
            //  也就是说targetMethod.getClass()获得是class org.springframework.web.method.HandlerMethod ,而不知我们真正想要的 Controller
//            AccessLimit targetClassAnnotation = targetMethod.getClass().getAnnotation(AccessLimit.class);
            // 定义标记位,标记此类是否加了@AccessLimit注解
            boolean isBrushForAllInterface = false;
            String ip = request.getRemoteAddr();
            String uri = request.getRequestURI();
            long second = 0L;
            long maxTime = 0L;
            long forbiddenTime = 0L;
            if (!Objects.isNull(targetClassAnnotation)) {
                log.info("目标接口方法所在类上有@AccessLimit注解");
                isBrushForAllInterface = true;
                second = targetClassAnnotation.second();
                maxTime = targetClassAnnotation.maxTime();
                forbiddenTime = targetClassAnnotation.forbiddenTime();
            }
            // 取出目标方法中的 AccessLimit 注解
            AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
            // 判断此方法接口是否要进行防刷处理
            if (!Objects.isNull(accessLimit)) {
                // 需要进行防刷处理,接下来是处理逻辑
                second = accessLimit.second();
                maxTime = accessLimit.maxTime();
                forbiddenTime = accessLimit.forbiddenTime();
                if (isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
                    throw new CommonException(ResultCode.ACCESS_FREQUENT);
                }
            } else {
                // 目标接口方法处无@AccessLimit注解,但还要看看其类上是否加了(类上有加,代表针对此类下所有接口方法都要进行防刷处理)
                if (isBrushForAllInterface && isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
                    throw new CommonException(ResultCode.ACCESS_FREQUENT);
                }
            }
        }
        return true;
    }
    /**
     * 判断某用户访问某接口是否已经被禁用/是否需要禁用
     *
     * @param second        多长时间  单位/秒
     * @param maxTime       最大访问次数
     * @param forbiddenTime 禁用时长 单位/秒
     * @param ip            访问者ip地址
     * @param uri           访问的uri
     * @return ture为需要禁用
     */
    private boolean isForbindden(long second, long maxTime, long forbiddenTime, String ip, String uri) {
        String lockKey = LOCK_PREFIX + ip + uri; //如果此ip访问此uri被禁用时的存在Redis中的 key
        Object isLock = redisTemplate.opsForValue().get(lockKey);
        // 判断此ip用户访问此接口是否已经被禁用
        if (Objects.isNull(isLock)) {
            // 还未被禁用
            String countKey = COUNT_PREFIX + ip + uri;
            Object count = redisTemplate.opsForValue().get(countKey);
            if (Objects.isNull(count)) {
                // 首次访问
                log.info("首次访问");
                redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
            } else {
                // 此用户前一点时间就访问过该接口,且频率没超过设置
                if ((Integer) count < maxTime) {
                    redisTemplate.opsForValue().increment(countKey);
                } else {
                    log.info("{}禁用访问{}", ip, uri);
                    // 禁用
                    redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
                    // 删除统计--已经禁用了就没必要存在了
                    redisTemplate.delete(countKey);
                    return true;
                }
            }
        } else {
            // 此用户访问此接口已被禁用
            return true;
        }
        return false;
    }
}

好了,这样就达到我们想要的效果了

项目通过Git还原到"【自定义注解+反射实现接口自由-版本2.0】"版本即可得到此案例实现,自己可以测试万一下

这是目前来说比较理想的做法,至于其他做法,暂时没啥了解到

时间逻辑漏洞

这是我一开始都有留意到的问题

也是一直搞不懂,就是我们现在的所有做法其实感觉都不是严格意义上的x秒内y次访问次数

特别注意这个x秒,它是连续,任意的(代表这个x秒时间片段其实是可以发生在任意一个时间轴上)

我下面尝试表达我的意思,但是我不知道能不能表达清楚

假设我们固定某个接口5秒内只能访问3次,以下面例子为例

image.png

底下的小圆圈代表此刻请求访问接口

按照我们之前所有做法的逻辑走

  1. 第2秒请求到,为首次访问,Redis中统计次数为1(过期时间为5秒)
  2. 第7秒,此时有两个动作,一是请求到,二是刚刚第二秒Redis存的值现在过期
  3. 我们先假设这一刻,请求处理完后,Redis存的值才过期
  4. 按照这样的逻辑走
  5. 第七秒请求到,Redis存在对应key,且不大于3, 次数+1
  6. 接着这个key立马过期
  7. 再继续往后走,第8秒又当做新的一个起始,就不往下说了,反正就是不会出现禁用的情况

按照上述逻辑走,实际上也就是说当出现首次访问时,当做这5秒时间片段的起始

第2秒是,第8秒也是

但是有没有想过,实际上这个5秒时间片段实际上是可以放置在时间轴上任意区域的

上述情况我们是根据请求的到来情况人为的把它放在【2-7】,【8-13】上

而实际上这5秒时间片段是可以放在任意区域的

那么,这样的话,【7-12】也可以放置

而【7-12】这段时间有4次请求,就达到了我们禁用的条件了

是不是感觉怪怪的

想过其他做法,但是好像严格意义上真的做不到我所说的那样(至少目前来说想不到)

之前我们的做法,正常来说也够用,至少说有达到防刷的作用

后面有机会的话再看看,不知道我是不是钻牛角尖了

路径参数问题

假设现在PassController中有如下接口方法

也就是我们在接口方法中常用的在请求路径中获取参数的套路

但是使用路径参数的话,就会发生问题

那就是同一个ip地址访问此接口时,我携带的参数值不同

按照我们之前那种前缀+ip+uri拼接的形式作为key的话,其实是区分不了的

下图是访问此接口,携带不同参数值时获取的uri状况

这样的话在我们之前拦截器的处理逻辑中,会认为是此ip用户访问的是不同的接口方法,而实际上访问的是同一个接口方法

也就导致了【接口防刷】失效

接下来就是解决它,目前来说有两种

  1. 不要使用路径参数

这算是比较理想的做法,相当于没这个问题

但有一定局限性,有时候接手别的项目,或者自己根本没这个权限说不能使用路径参数

  1. 替换uri
  • 我们获取uri的目的,其实就是为了区别访问接口
  • 而把uri替换成另一种可以区分访问接口方法的标识即可
  • 最容易想到的就是通过反射获取到接口方法名称,使用接口方法名称替换成uri即可
  • 当然,其实不同的Controller中,其接口方法名称也有可能是相同的
  • 实际上可以再获取接口方法所在类类名,使用类名 + 方法名称替换uri即可
  • 实际解决方案有很多,看个人需求吧

真实ip获取

在之前的代码中,我们获取代码都是通过request.getRemoteAddr()获取的

但是后续有了解到,如果说通过代理软件方式访问的话,这样是获取不到来访者的真实ip的

至于如何获取,后续我再研究下http再说,这里先提个醒

总结

说实话,挺有意思的,一开始自己想【接口防刷】的时候,感觉也就是转化成统计下访问次数的问题摆了。后面到网上看别人的写法,又再自己给自己找点问题出来,后面会衍生出来一推东西出来,诸如自定义注解+反射这种实现方式。

以前其实对注解 + 反射其实有点不太懂干嘛用的,而从之前的数据报表导出,再到基本权限控制实现,最后到今天的【接口防刷】一点点来进步去补充自己的知识点,而且,感觉写博客真的是件挺有意义的事情,它会让你去更深入的了解某个点,并且知识是相关联的,探索的过程中会牵扯到其他别的知识点,就像之前的写的【单例模式】实现,一开始就了解到懒汉式,饿汉式

后面深入的话就知道其实会还有序列化/反序列化,反射调用生成实例,对象克隆这几种方式回去破坏单例模式,又是如何解决的,这也是一个进步的点,后续为了保证线程安全问题,牵扯到的synchronized,voliate关键字,继而又关联到JVM,JUC,操作系统的东西。



相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
1月前
|
NoSQL 关系型数据库 MySQL
接口防刷 && 接口幂等性问题
接口防刷 && 接口幂等性问题
39 0
|
1月前
|
小程序 API 开发者
【产品上新】接口不报错=API成功接入?不妨“验”一下
【产品上新】接口不报错=API成功接入?不妨“验”一下
35 0
|
7月前
|
安全 NoSQL API
互联网并发与安全系列教程(08) - API接口幂等设计与实现
互联网并发与安全系列教程(08) - API接口幂等设计与实现
51 0
|
1月前
|
缓存 安全 API
【亿级数据专题】「高并发架构」盘点本年度探索对外服务的百万请求量的API网关设计实现
公司对外开放的OpenAPI-Server服务,作为核心内部系统与外部系统之间的重要通讯枢纽,每天处理数百万次的API调用、亿级别的消息推送以及TB/PB级别的数据同步。经过多年流量的持续增长,该服务体系依然稳固可靠,展现出强大的负载能力。
102 9
【亿级数据专题】「高并发架构」盘点本年度探索对外服务的百万请求量的API网关设计实现
|
8月前
|
监控 安全 测试技术
敏感接口防刷规范及定责
在工作中有时候会遇到敏感接口被刷的情况,主要是这些敏感接口没有做防刷处理
|
9天前
|
缓存 NoSQL 前端开发
《优化接口设计的思路》系列:第六篇—接口防抖(防重复提交)的一些方式
本文探讨了后端开发中的接口防抖策略,作者是一名有六年经验的Java开发者,分享了如何防止重复提交导致的问题。防抖主要用于避免用户误操作或网络波动引起的多次请求,作者提出理想防抖机制应具备正确性、响应速度、易集成和用户反馈。文章详细分析了哪些接口需要防抖(如用户输入、按钮点击、滚动加载)以及如何识别重复接口,提出了使用共享缓存和分布式锁两种实现方式,并展示了基于Redis的Java代码示例。作者通过注解实现请求锁,并提供了测试截图证明防抖效果。然而,实现完全幂等性还需要业务层面的补充措施。
39 6
|
1月前
|
消息中间件 监控 Java
接口请求重试策略:保障稳定性的必杀技
接口请求重试策略:保障稳定性的必杀技
118 0
|
1月前
|
数据采集 存储 NoSQL
如何优雅实现接口防刷
一文讲清楚如何用redis实现接口防刷
39 0
如何优雅实现接口防刷
|
1月前
|
缓存 监控 测试技术
ERP系统对接方案与API接口封装系列(高并发)
企业资源规划(ERP)系统是现代企业管理的核心,它集成了企业内部的各个部门和业务流程。为了实现ERP系统与其他外部系统或应用程序之间的数据交换和协作,需要对接方案。API(应用程序编程接口)是实现系统对接的常用方法之一。
|
1月前
|
监控 算法 Java
保卫你的API:深入了解接口限流
保卫你的API:深入了解接口限流
190 0