关于java:接口防刷处理方案太优雅了

2次阅读

共计 10328 个字符,预计需要花费 26 分钟才能阅读完成。

起源:juejin.cn/post/7200366809407750181


前言

本文为形容通过 Interceptor 以及 Redis 实现接口拜访防刷 Demo

这里会通过逐渐找问题,逐渐去欠缺的模式展现

原理

  • 通过 ip 地址 +uri 拼接用以作为访问者拜访接口辨别
  • 通过在 Interceptor 中拦挡申请,从 Redis 中统计用户拜访接口次数从而达到接口防刷目标

如下图所示

工程

举荐一个开源收费的 Spring Boot 实战我的项目:

https://github.com/javastacks/spring-boot-best-practice

其中,Interceptor 处代码解决逻辑最为重要

/**
 * @author: Zero
 * @time: 2023/2/14
 * @description: 接口防刷拦挡解决
 */
@Slf4j
public class AccessLimintInterceptor  implements HandlerInterceptor {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 多长时间内
     */
    @Value("${interfaceAccess.second}")
    private Long second = 10L;

    /**
     * 拜访次数
     */
    @Value("${interfaceAccess.time}")
    private Long time = 3L;

    /**
     * 禁用时长 -- 单位 / 秒
     */
    @Value("${interfaceAccess.lockTime}")
    private Long lockTime = 60L;

    /**
     * 锁住时的 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 {String uri = request.getRequestURI();
        String ip = request.getRemoteAddr(); // 这里疏忽代理软件形式拜访,默认间接拜访,也就是获取失去的就是访问者实在 ip 地址
        String lockKey = LOCK_PREFIX + ip + uri;
        Object isLock = redisTemplate.opsForValue().get(lockKey);
        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 < time){
                    // 放行,拜访次数 + 1
                    redisTemplate.opsForValue().increment(countKey);
                }else{log.info("{}禁用拜访{}",ip, uri);
                    // 禁用
                    redisTemplate.opsForValue().set(lockKey, 1,lockTime, TimeUnit.SECONDS);
                    // 删除统计
                    redisTemplate.delete(countKey);
                    throw new CommonException(ResultCode.ACCESS_FREQUENT);
                }
            }
        }else{
            // 此用户拜访此接口已被禁用
            throw new CommonException(ResultCode.ACCESS_FREQUENT);
        }
        return true;
    }
}

在多长时间内拜访接口多少次,以及禁用的时长,则是通过与配置文件配合动静设置

当处于禁用时间接抛异样则是通过在 ControllerAdvice 处对立解决(这里代码写的有点俊俏)

上面是一些测试(能够把我的项目通过 Git 还原到“【初始化】”状态进行测试)

  • 失常拜访时

  • 拜访次数过于频繁时

自我发问

上述实现就如同就曾经达到了咱们的接口防刷目标了

然而,还不够

为不便后续形容,我的项目中新增补充Controller,如下所示

简略来说就是

  • PassCotrollerRefuseController
  • 每个 Controller 别离有对应的 get,post,put,delete 类型的办法,其映射门路与办法名称统一

接口自在

  • 对于上述实现,不晓得你们有没有发现一个问题
  • 就是当初咱们的接口防刷解决,针对是所有的接口(我的项目案例中我只是写的接口比拟少)
  • 而在理论开发中,说对于所有的接口都要做防刷解决,感觉上也不太可能(写此文时目前大四,理论工作教训较少,这里不敢肯定)
  • 那么问题有了,该如何解决呢?目前来说想到两个解决方案
拦截器映射规定

我的项目通过 Git 还原到 ”【Interceptor 设置映射规定实现接口自在】” 版本即可失去此案例实现

咱们都晓得拦截器是能够设置拦挡规定的,从而达到拦挡解决目标

1. 这个 AccessInterfaceInterceptor 是专门用来进行防刷解决的,那么实际上咱们能够通过设置它的映射规定去匹配须要进行【接口防刷】的接口即可

2. 比如说上面的映射配置

3. 这样就初步达到了咱们的目标,通过映射规定的配置,只针对那些须要进行【接口防刷】的接口才会进行解决

4. 至于为啥说是初步呢?上面我就说说目前我想到的应用这种形式进行【接口防刷】的有余点:

所有要进行防刷解决的接口对立都是配置成了 x 秒内 y 次访问次数,禁用时长为 z 秒

  • 要晓得就是要进行防刷解决的接口,其 x, y, z 的值也是并不一定会对立的
  • 某些防刷接口解决比拟耗费性能的,我就把 x, y, z 设置的紧一点
  • 而某些防刷接口解决相对来说比拟快,我就把 x, y, z 设置的松一点
  • 这没问题吧
  • 然而当初呢?x, y, z 值全都统一了,这就不行了
  • 这就是其中一个有余点
  • 当然,其实针对以后这种状况也有解决方案
  • 那就是弄多个拦截器
  • 每个拦截器的【接口防刷】解决逻辑跟上述统一,并去映射对应要解决的防刷接口
  • 惟一不同的就是在每个拦截器外部,去批改对应防刷接口须要的 x, y, z 值
  • 这样就是感觉会比拟麻烦

防刷接口映射门路批改后保护问题

  • 尽管说防刷接口的映射门路基本上定下来后就不会扭转
  • 但实际上前后端联调开发我的项目时,不会有那么谨严的 Api 文档给咱们用(这个在实习中倒是碰到过,公司不是很大,开发起来也就不那么谨严,啥都要本人搞,性能能实现就好)
  • 也就是说还是会有那种要批改接口的映射门路需要
  • 当防刷接口数量特地多,前面的接手人员就很苦楚了
  • 就算是我的项目是本人从 0 到 1 实现的,其实有时候我的项目开发到前面,本人也会遗记本人后面是如何设计的
  • 而应用以后这种形式的话,谁保护谁蛋疼
自定义注解 + 反射

咋说呢

  • 就是通过自定义注解中定义 x 秒内 y 次访问次数,禁用时长为 z 秒
  • 自定义注解 + 在须要进行防刷解决的各个接口办法上
  • 在拦截器中通过反射获取到各个接口中的 x, y, z 值即可达到咱们想要的接口自在目标

上面做个实现

申明自定义注解

Controlller 中办法中应用

Interceptor 处逻辑批改(最重要是通过反射判断此接口是否须要进行防刷解决,以及获取到 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 {
//        自定义注解 + 反射 实现
        // 判断拜访的是否是接口办法
        if(handler instanceof HandlerMethod){
            // 拜访的是接口办法,转化为待拜访的指标办法对象
            HandlerMethod targetMethod = (HandlerMethod) handler;
            // 取出指标办法中的 AccessLimit 注解
            AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
            // 判断此办法接口是否要进行防刷解决(办法上没有对应注解就代表不须要,不需要的话进行放行)if(!Objects.isNull(accessLimit)){
                // 须要进行防刷解决,接下来是解决逻辑
                String ip = request.getRemoteAddr();
                String uri = request.getRequestURI();
                String lockKey = LOCK_PREFIX + ip + uri;
                Object isLock = redisTemplate.opsForValue().get(lockKey);
                // 判断此 ip 用户拜访此接口是否曾经被禁用
                if (Objects.isNull(isLock)) {
                    // 还未被禁用
                    String countKey = COUNT_PREFIX + ip + uri;
                    Object count = redisTemplate.opsForValue().get(countKey);
                    long second = accessLimit.second();
                    long maxTime = accessLimit.maxTime();

                    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);
                            long forbiddenTime = accessLimit.forbiddenTime();
                            // 禁用
                            redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
                            // 删除统计 -- 曾经禁用了就没必要存在了
                            redisTemplate.delete(countKey);
                            throw new CommonException(ResultCode.ACCESS_FREQUENT);
                        }
                    }
                } else {
                    // 此用户拜访此接口已被禁用
                    throw new CommonException(ResultCode.ACCESS_FREQUENT);
                }
            }
        }
        return  true;
    }
}

因为不好演示成果,这里就不贴测试后果图片了

我的项目通过 Git 还原到 ”【自定义主键 + 反射实现接口自在 ” 版本即可失去此案例实现,前面本人能够针对接口做下测试看看是否如同我所说的那样实现自定义 x, y, z 的成果

嗯,当初看起来,能够针对每个要进行防刷解决的接口进行针对性自定义多长时间内的最大拜访次数,以及禁用时长,哪个接口须要,就间接 + 在那个接口办法出即可

感觉还不错的样子,当初网上挺多材料也都是这样实现的

然而还是能够有改善的中央

先举一个例子,以咱们的 PassController 为例,如下是其实现

下图是其映射门路关系

同一个 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 次,以上面例子为例

底下的小圆圈代表此刻申请拜访接口

依照咱们之前所有做法的逻辑走

  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,操作系统的货色。

近期热文举荐:

1.1,000+ 道 Java 面试题及答案整顿(2022 最新版)

2. 劲爆!Java 协程要来了。。。

3.Spring Boot 2.x 教程,太全了!

4. 别再写满屏的爆爆爆炸类了,试试装璜器模式,这才是优雅的形式!!

5.《Java 开发手册(嵩山版)》最新公布,速速下载!

感觉不错,别忘了顺手点赞 + 转发哦!

正文完
 0