一、背景描述
最近做了一个项目,通过调用短信网关实现手机号和验证码登录。但是如果有人恶意调用,会发出很多无谓的短信,而且会增加短信服务的费用。于是对接口做了请求频率限制 Rate limiting。例如限制一个用户1分钟内最多可以范围100次。这里简要的复现一下实现思路。
思路:以类名+调用方法名+ip作为key
- 当用户调用接口的时候,先查询redis中是否有存在该key,获取该key所对应的value,比较value和frequency,如果小于frequency,则在原来的基础上value++;如果大于则返回访问频率过于频繁。
- 如果不存在,则将该key存入redis,value为1,设置过期时间。
二、代码演示
1. pom文件
<!--引入web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--spring boot 测试--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> <!--redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--分布式锁--> <dependency> <groupId>net.javacrumbs.shedlock</groupId> <artifactId>shedlock-provider-redis-spring</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>net.javacrumbs.shedlock</groupId> <artifactId>shedlock-spring</artifactId> <version>2.3.0</version> </dependency> <!--连接池--> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.0</version> </dependency> <!--lombok--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!-- swagger --> <!-- https://mvnrepository.com/artifact/io.springfox/springfox-swagger-ui --> <dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>swagger-bootstrap-ui</artifactId> <version>1.9.6</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger2</artifactId> <version>2.9.2</version> </dependency> <!--aop--> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>1.9.2</version> </dependency>
2、redis的配置
- 配置文件
#redis redis.host=192.168.1.6 redis.password= redis.port=6379 redis.taskScheduler.poolSize=100 redis.taskScheduler.defaultLockMaxDurationMinutes=10 redis.default.timeout=10 redisCache.expireTimeInMilliseconds=1200000
- 配置类
package com.example.redis_demo_limit.redis; import io.lettuce.core.ClientOptions; import io.lettuce.core.resource.ClientResources; import io.lettuce.core.resource.DefaultClientResources; import net.javacrumbs.shedlock.core.LockProvider; import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider; import net.javacrumbs.shedlock.spring.ScheduledLockConfiguration; import net.javacrumbs.shedlock.spring.ScheduledLockConfigurationBuilder; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisPassword; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration; import org.springframework.data.redis.core.RedisTemplate; import java.time.Duration; @Configuration public class RedisConfig { @Value("${redis.host}") private String redisHost; @Value("${redis.port}") private int redisPort; @Value("${redis.password}") private String password; @Value("${redis.taskScheduler.poolSize}") private int tasksPoolSize; @Value("${redis.taskScheduler.defaultLockMaxDurationMinutes}") private int lockMaxDuration; @Bean(destroyMethod = "shutdown") ClientResources clientResources() { return DefaultClientResources.create(); } @Bean public RedisStandaloneConfiguration redisStandaloneConfiguration() { RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(redisHost, redisPort); if (password != null && !password.trim().equals("")) { RedisPassword redisPassword = RedisPassword.of(password); redisStandaloneConfiguration.setPassword(redisPassword); } return redisStandaloneConfiguration; } @Bean public ClientOptions clientOptions() { return ClientOptions.builder() .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS) .autoReconnect(true).build(); } @Bean LettucePoolingClientConfiguration lettucePoolConfig(ClientOptions options, ClientResources dcr) { return LettucePoolingClientConfiguration.builder().poolConfig(new GenericObjectPoolConfig()) .clientOptions(options).clientResources(dcr).build(); } @Bean public RedisConnectionFactory connectionFactory( RedisStandaloneConfiguration redisStandaloneConfiguration, LettucePoolingClientConfiguration lettucePoolConfig) { return new LettuceConnectionFactory(redisStandaloneConfiguration, lettucePoolConfig); } @Bean @ConditionalOnMissingBean(name = "redisTemplate") @Primary public RedisTemplate<Object, Object> redisTemplate( RedisConnectionFactory redisConnectionFactory) { RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory); return template; } @Bean public LockProvider lockProvider(RedisConnectionFactory connectionFactory) { return new RedisLockProvider(connectionFactory); } @Bean public ScheduledLockConfiguration taskSchedulerLocker(LockProvider lockProvider) { return ScheduledLockConfigurationBuilder.withLockProvider(lockProvider) .withPoolSize(tasksPoolSize).withDefaultLockAtMostFor(Duration.ofMinutes(lockMaxDuration)) .build(); } }
3. redis操作工具类
- 接口类
package com.example.redis_demo_limit.redis; public interface DataCacheRepository<T> { boolean add(String collection, String hkey, T object, Long timeout); boolean delete(String collection, String hkey); T find(String collection, String hkey, Class<T> tClass); Boolean isAvailable(); /** * redis 加锁 * * @param key * @param second * @return */ Boolean lock(String key, String value, Long second); Object getValue(String key); /** * redis 解锁 * * @param key * @return */ void unLock(String key); void setIfAbsent(String key, long value, long ttl); void increment(String key); Long get(String key); void set(String key, long value, long ttl); void set(Object key, Object value, long ttl); Object getByKey(String key); void getLock(String key, String clientID) throws Exception; void releaseLock(String key, String clientID); }
- 实现类
package com.example.redis_demo_limit.redis; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.support.atomic.RedisAtomicLong; import org.springframework.stereotype.Repository; import java.time.Duration; import java.util.TimeZone; import java.util.concurrent.TimeUnit; @Slf4j @Repository public class CacheRepository<T> implements com.example.redis_demo_limit.redis.DataCacheRepository<T> { private static final ObjectMapper OBJECT_MAPPER; private static final TimeZone DEFAULT_TIMEZONE = TimeZone.getTimeZone("UTC"); static { OBJECT_MAPPER = new ObjectMapper(); OBJECT_MAPPER.setTimeZone(DEFAULT_TIMEZONE); } Logger logger = LoggerFactory.getLogger(CacheRepository.class); @Autowired RedisTemplate template; // and we're in business @Value("${redis.default.timeout}00") Long defaultTimeOut; public boolean addPermentValue(String collection, String hkey, T object) { try { String jsonObject = OBJECT_MAPPER.writeValueAsString(object); template.opsForHash().put(collection, hkey, jsonObject); return true; } catch (Exception e) { logger.error("Unable to add object of key {} to cache collection '{}': {}", hkey, collection, e.getMessage()); return false; } } @Override public boolean add(String collection, String hkey, T object, Long timeout) { Long localTimeout; if (timeout == null) { localTimeout = defaultTimeOut; } else { localTimeout = timeout; } try { String jsonObject = OBJECT_MAPPER.writeValueAsString(object); template.opsForHash().put(collection, hkey, jsonObject); template.expire(collection, localTimeout, TimeUnit.SECONDS); return true; } catch (Exception e) { logger.error("Unable to add object of key {} to cache collection '{}': {}", hkey, collection, e.getMessage()); return false; } } @Override public boolean delete(String collection, String hkey) { try { template.opsForHash().delete(collection, hkey); return true; } catch (Exception e) { logger.error("Unable to delete entry {} from cache collection '{}': {}", hkey, collection, e.getMessage()); return false; } } @Override public T find(String collection, String hkey, Class<T> tClass) { try { String jsonObj = String.valueOf(template.opsForHash().get(collection, hkey)); return OBJECT_MAPPER.readValue(jsonObj, tClass); } catch (Exception e) { if (e.getMessage() == null) { logger.error("Entry '{}' does not exist in cache", hkey); } else { logger.error("Unable to find entry '{}' in cache collection '{}': {}", hkey, collection, e.getMessage()); } return null; } } @Override public Boolean isAvailable() { try { return template.getConnectionFactory().getConnection().ping() != null; } catch (Exception e) { logger.warn("Redis server is not available at the moment."); } return false; } @Override public Boolean lock(String key, String value, Long second) { Boolean absent = template.opsForValue().setIfAbsent(key, value, second, TimeUnit.SECONDS); return absent; } @Override public Object getValue(String key) { return template.opsForValue().get(key); } @Override public void unLock(String key) { template.delete(key); } @Override public void increment(String key) { RedisAtomicLong counter = new RedisAtomicLong(key, template.getConnectionFactory()); counter.incrementAndGet(); } @Override public void setIfAbsent(String key, long value, long ttl) { ValueOperations<String, Object> ops = template.opsForValue(); ops.setIfAbsent(key, value, Duration.ofSeconds(ttl)); } @Override public Long get(String key) { RedisAtomicLong counter = new RedisAtomicLong(key, template.getConnectionFactory()); return counter.get(); } @Override public void set(String key, long value, long ttl) { RedisAtomicLong counter = new RedisAtomicLong(key, template.getConnectionFactory()); counter.set(value); counter.expire(ttl, TimeUnit.SECONDS); } @Override public void set(Object key, Object value, long ttl) { template.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); } @Override public Object getByKey(String key) { return template.opsForValue().get(key); } @Override public void getLock(String key, String clientID) throws Exception { Boolean lock = false; // 重试3次,每间隔1秒重试1次 for (int j = 0; j <= 3; j++) { lock = lock(key, clientID, 10L); if (lock) { log.info("获得锁》》》" + key); break; } try { Thread.sleep(5000); } catch (InterruptedException e) { log.error("线程休眠异常", e); break; } } // 重试3次依然没有获取到锁,那么返回服务器繁忙,请稍后重试 if (!lock) { throw new Exception("服务繁忙"); } } @Override public void releaseLock(String key, String clientID) { if (clientID.equals(getByKey(key))) { unLock(key); } } }
4. 访问频次实现核心逻辑
- 注解
package com.example.redis_demo_limit.annotation; import java.lang.annotation.*; @Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LimitedAccess { /** * 从第一次访问接口的时间到周期时间内,最大访问频率次,默认60次 * @return */ long frequency() default 60; /** * 周期时间,默认30分钟内 * @return */ long second() default 30*60; }
- 切面类
package com.example.redis_demo_limit.annotation; import com.example.redis_demo_limit.redis.DataCacheRepository; import lombok.extern.log4j.Log4j2; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; @Aspect @Component @Log4j2 //@Order public class LimitedAccessAspect { public static String LIMITED_ACCESS_ASPECT_COLLECTION = "LIMITED_ACCESS_ASPECT_COLLECTION"; @Autowired private DataCacheRepository redisCacheService; @Pointcut("@annotation(limitedAccess)") public void limitAccessPointCut(LimitedAccess limitedAccess) { // 限制接口调用切面类 } @Around(value = "limitAccessPointCut(limitedAccess)", argNames = "point,limitedAccess") public Object doAround(ProceedingJoinPoint point, LimitedAccess limitedAccess) throws Throwable { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (null != attributes) { String className = point.getTarget().getClass().getName(); String methodName = point.getSignature().getName(); HttpServletRequest request = attributes.getRequest(); String remoteAddr = request.getRemoteAddr(); log.info("remoteAddr地址:" + remoteAddr); //String realRequestIps = request.getHeader("X-Forwarded-For"); String key = LIMITED_ACCESS_ASPECT_COLLECTION + className + "." + methodName + "#" + remoteAddr; try { long limit = redisCacheService.get(key); if (limit > 0) { // 时间段内超过访问频次上限 - 阻断 if (limit >= limitedAccess.frequency()) { log.info("接口调用过于频繁 {}", key); // return "接口调用过于频繁!!!"; } redisCacheService.increment(key); } else { redisCacheService.set(key, 1, limitedAccess.second()); } } catch (Exception e) { log.debug(e.getStackTrace()); } } return point.proceed(); } }
三、调用方法
package com.example.redis_demo_limit.controller; import com.example.redis_demo_limit.annotation.LimitedAccess; import com.example.redis_demo_limit.redis.DataCacheRepository; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; @RestController @RequestMapping("/redis") public class RedisController { @Resource private DataCacheRepository dataCacheRepository; //这个设置为1秒1次,方便测试 @LimitedAccess(frequency = 1,second = 1) @PostMapping("/add") public String add(String str){ dataCacheRepository.set("str","add success",200L); return "success"; } }
四、测试结果
测试
2020-09-22 00:24:21.786 INFO 30552 --- [nio-8080-exec-3] c.e.r.annotation.LimitedAccessAspect : remoteAddr地址:0:0:0:0:0:0:0:1 2020-09-22 00:24:22.417 INFO 30552 --- [nio-8080-exec-9] c.e.r.annotation.LimitedAccessAspect : remoteAddr地址:0:0:0:0:0:0:0:1 2020-09-22 00:24:22.419 INFO 30552 --- [nio-8080-exec-9] c.e.r.annotation.LimitedAccessAspect : 接口调用过于频繁 LIMITED_ACCESS_ASPECT_COLLECTIONcom.example.redis_demo_limit.controller.RedisController.add#0:0:0:0:0:0:0:1
从测试结果来看,它第二次访问的时候,直接返回了接口访问过于频繁。所以实现了接口访问频次的控制。