引言
在用户访问的时候,不免因为各种原因导致用户会在短时间内频繁的访问一个接口,而为了限制这种情况带来的危害,通常会约定用户在一段时间内具有固定的访问次数,已达到解决用户短时间内频繁访问接口的问题。
实现方案
这里选择的是 注解 + AOP 的形式去实现,实现过程会使用到,注解、AOP、Redis、锁相关的知识。
代码流程图
实现代码与代码截图
1.注解代码
具体代码图与代码如下:
@Target({ElementType.TYPE,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface APICountLimit { // 设置访问次数 String limitNumber() default "3"; // 设置限制时间 long limitTime() default 1L; // 设置限制的时间单位 TimeUnit timeUnit() default TimeUnit.MINUTES; }
2.切面代码
具体切面与切面截图如下:
@Component @Aspect public class APICountLimitAspect { public static final String LIMIT_REQUIRES = "limit:require:"; @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private HttpServletRequest request; @Around("@annotation(apiCountLimit)") public Object around(ProceedingJoinPoint joinPoint, APICountLimit apiCountLimit) throws Throwable { // 获取对应的真实 ip,用于限制对应 ip 的范围时间内的访问次数 String ipAddress = getIpAddress(request); // 获取拦截的注解中的限制次数参数 String limitNumber = apiCountLimit.limitNumber(); TimeUnit timeUnit = apiCountLimit.timeUnit(); long limitTime = apiCountLimit.limitTime(); // 在 redis 中以 IP 组成 key 的存储信息 String key = LIMIT_REQUIRES + ipAddress; // 根据 IPKey 获取 redis 中的值 String getValueByIp = (String) stringRedisTemplate.opsForHash().get(key,"visitsTime"); // 获取到的值为空值,为对应 IP 设置初始值信息并通过 if( getValueByIp == null){ // 第一次访问的时候的时间戳 long zeroTime = System.currentTimeMillis(); // 第一次访问时初始化 redis 中的 map 信息 HashMap<Object, Object> map = new HashMap<>(); map.put("numberOfVisits","1"); map.put("visitsTime",String.valueOf(zeroTime)); // 存储 map 类型,并设置过期时间。 stringRedisTemplate.opsForHash().putAll(key ,map); stringRedisTemplate.expire(key,limitTime,timeUnit); } // 当数据存在但是访问次数大于限制次数时返回不合法值 else if (Integer.parseInt(stringRedisTemplate.opsForHash().get(key,"numberOfVisits").toString()) >= Integer.parseInt(limitNumber)) { return "当前访问频繁请稍等"; } // 当数据存在并且访问次数小于限制次数 限制次数 +1 并通过 else { // 避免程序在运行时候恰巧 redis 数据过期 if(stringRedisTemplate.opsForHash().get(key,"visitsTime") == null) around(joinPoint,apiCountLimit) ; // 获取当前 IP 的已访问次数 synchronized (this) { int currentNumber = (Integer.parseInt(stringRedisTemplate.opsForHash().get(key,"numberOfVisits").toString())); currentNumber++; // redis 中重写当前 IP 的访问次数 stringRedisTemplate.opsForHash().put(key,"numberOfVisits",String.valueOf(currentNumber)); } // 获取 redis 中记录的访问时间戳 String reportRedisTimestamp = stringRedisTemplate.opsForHash().get(key, "visitsTime").toString(); // 本次访问时间的时间戳 long currentTimestamp = System.currentTimeMillis(); // 计算本次的访问时间距离上次的访问时间的时间间隔 long timeInterval = currentTimestamp - Long.parseLong(reportRedisTimestamp); // redis 中重写当前 IP 近一次访问的时间戳 stringRedisTemplate.opsForHash().put(key,"visitsTime",String.valueOf(currentTimestamp)); // 重新设置 redis 中 对应 IP 的生存时间( IPKey 的剩余时间 = 当前所剩时间 - 访问的间隔时间) stringRedisTemplate.expire(key,(stringRedisTemplate.getExpire(key)*1000 - timeInterval)/1000 ,TimeUnit.SECONDS); } return joinPoint.proceed(); }
代码思路与解释
代码的限制方案是根据访问者的 IP 去进行实现的,实际中可以根据真实的参数去进行限制,比如,token、cookie、JWT等。
在注解代码中,开发者可以自定义的配置实际的限制次数、限制时间、限制时间单位。
在切面代码中,当用户开始访问时,获取到对应的注解参数,并获取对应的请求 IP,并结合约定的 redis 中的 key 前缀组成完整的 key 参数,根据完整的 key 信息去获取 redis 中的数据,如果数据不存在的话,以 key 和 map 组成一条数据信息,在 map 中存储第一次的访问时间戳以及访问次数并为 key 设置注解中的生存时间,如果数据存在的话,则获取 map 中记录访问次数的信息,判断是否大于了注解中的约定次数,大于的话返回错误信息,代码结束;小于的话,获取 redis 中存储的访问次数并且 +1 存回 redis 中(切记这里要加锁,可以使用 Jmeter 测试一下不加锁的情况造成的问题)之后获取本次访问的时间戳并且从 redis 中获取上次访问时间的时间戳,两个时间戳的差作为两次访问的时间差并且将当前 key 的 生存时间 - 访问时间 作为 key 的新生存时间(注意这里的单位转换)并且将本次的访问时间戳记录到 redis 中 用于作为下次访问的上次时间戳。
总结
本次问题的解决就结束了,实现这个需求的方案有很多种,实现过程中有很些细节问题需要解决,也希望大佬能斧正提出更好的解决方案。