Springboot中使用Redisson+AOP+自定義注解實(shí)現(xiàn)訪問限流與黑名單攔截
前言
在開發(fā)高并發(fā)系統(tǒng)時(shí)有三把利器用來保護(hù)系統(tǒng):緩存、降級(jí)和限流。 限流的目的是通過對(duì)并發(fā)訪問請(qǐng)求進(jìn)行限速或者一個(gè)時(shí)間窗口內(nèi)的的請(qǐng)求數(shù)量進(jìn)行限速來保護(hù)系統(tǒng),一旦達(dá)到限制速率則可以拒絕服務(wù)、排隊(duì)或等待
我們上次講解了如何使用Sentinel來實(shí)現(xiàn)服務(wù)限流,今天我們來講解下如何使用Redisson+AOP+自定義注解+反射優(yōu)雅的實(shí)現(xiàn)服務(wù)限流,本文講解的限流實(shí)現(xiàn)支持針對(duì)用戶IP限流,整個(gè)接口的訪問限流,以及對(duì)某個(gè)參數(shù)字段的限流,并且支持請(qǐng)求限流后處理回調(diào)
1.導(dǎo)入Redisson
引入依賴
我們首先導(dǎo)入Redisson所需要的依賴,我們這里的springboot版本為2.7.12
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.4</version> </dependency>
編寫配置
# Redisson客戶端 redis: sdk: config: host: redis服務(wù)IP port: 6379 password: redis密碼,沒有可刪掉這行 pool-size: 10 min-idle-size: 5 idle-timeout: 30000 connect-timeout: 5000 retry-attempts: 3 retry-interval: 1000 ping-interval: 60000 keep-alive: true
聲明Redisson客戶端Bean
配置映射類RedisCientConfigProperties
@Data @ConfigurationProperties(prefix = "redis.sdk.config", ignoreInvalidFields = true) public class RedisCientConfigProperties { /** host:ip */ private String host; /** 端口 */ private int port; /** 賬密 */ private String password; /** 設(shè)置連接池的大小,默認(rèn)為64 */ private int poolSize = 64; /** 設(shè)置連接池的最小空閑連接數(shù),默認(rèn)為10 */ private int minIdleSize = 10; /** 設(shè)置連接的最大空閑時(shí)間(單位:毫秒),超過該時(shí)間的空閑連接將被關(guān)閉,默認(rèn)為10000 */ private int idleTimeout = 10000; /** 設(shè)置連接超時(shí)時(shí)間(單位:毫秒),默認(rèn)為10000 */ private int connectTimeout = 10000; /** 設(shè)置連接重試次數(shù),默認(rèn)為3 */ private int retryAttempts = 3; /** 設(shè)置連接重試的間隔時(shí)間(單位:毫秒),默認(rèn)為1000 */ private int retryInterval = 1000; /** 設(shè)置定期檢查連接是否可用的時(shí)間間隔(單位:毫秒),默認(rèn)為0,表示不進(jìn)行定期檢查 */ private int pingInterval = 0; /** 設(shè)置是否保持長連接,默認(rèn)為true */ private boolean keepAlive = true; }
Configuration @EnableConfigurationProperties(RedisCientConfigProperties.class) public class RedisClientConfig { @Bean("redissonClient") public RedissonClient redissonClient(ConfigurableApplicationContext applicationContext, RedisCientConfigProperties properties) { Config config = new Config(); // 根據(jù)需要可以設(shè)定編解碼器;https://github.com/redisson/redisson/wiki/4.-%E6%95%B0%E6%8D%AE%E5%BA%8F%E5%88%97%E5%8C%96 // config.setCodec(new RedisCodec()); config.useSingleServer() .setAddress("redis://" + properties.getHost() + ":" + properties.getPort()) .setPassword(properties.getPassword()) .setConnectionPoolSize(properties.getPoolSize()) .setConnectionMinimumIdleSize(properties.getMinIdleSize()) .setIdleConnectionTimeout(properties.getIdleTimeout()) .setConnectTimeout(properties.getConnectTimeout()) .setRetryAttempts(properties.getRetryAttempts()) .setRetryInterval(properties.getRetryInterval()) .setPingConnectionInterval(properties.getPingInterval()) .setKeepAlive(properties.isKeepAlive()) ; RedissonClient redissonClient = Redisson.create(config); // 注冊(cè)消息發(fā)布訂閱主題Topic // 找到所有實(shí)現(xiàn)了Redisson中MessageListener接口的bean名字 String[] beanNamesForType = applicationContext.getBeanNamesForType(MessageListener.class); for (String beanName : beanNamesForType) { // 通過bean名字獲取到監(jiān)聽bean MessageListener bean = applicationContext.getBean(beanName, MessageListener.class); Class<? extends MessageListener> beanClass = bean.getClass(); // 如果bean的注解里包含我們的自定義注解RedisTopic.class,則以RedisTopic注解的值作為name將該bean注冊(cè)到bean工廠,方便在別處注入 if (beanClass.isAnnotationPresent(RedisTopic.class)) { RedisTopic redisTopic = beanClass.getAnnotation(RedisTopic.class); RTopic topic = redissonClient.getTopic(redisTopic.topic()); topic.addListener(String.class, bean); ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); beanFactory.registerSingleton(redisTopic.topic(), topic); } } return redissonClient; } static class RedisCodec extends BaseCodec { private final Encoder encoder = in -> { ByteBuf out = ByteBufAllocator.DEFAULT.buffer(); try { ByteBufOutputStream os = new ByteBufOutputStream(out); JSON.writeJSONString(os, in, SerializerFeature.WriteClassName); return os.buffer(); } catch (IOException e) { out.release(); throw e; } catch (Exception e) { out.release(); throw new IOException(e); } }; private final Decoder<Object> decoder = (buf, state) -> JSON.parseObject(new ByteBufInputStream(buf), Object.class); @Override public Decoder<Object> getValueDecoder() { return decoder; } @Override public Encoder getValueEncoder() { return encoder; } } }
2.自定義注解
我們這里自定義一個(gè)注解來作為后續(xù)AOP切面編程的切點(diǎn)
根據(jù)注解Key屬性的值,我們會(huì)有如下情況
all:針對(duì)整個(gè)接口限流
request_ip:針對(duì)各個(gè)用戶的訪問IP限流
其他str:根據(jù)參數(shù)作為標(biāo)識(shí)符限流,比如我這里key=userid,那么我會(huì)根據(jù)參數(shù)中的userid來限流
@Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) public @interface AccessInterceptor { /** 用哪個(gè)字段作為攔截標(biāo)識(shí)符,配置all則是對(duì)整個(gè)接口限流,配置request_ip, * 則是對(duì)訪問ip限流,配置其他str,則會(huì)到參數(shù)中尋找對(duì)應(yīng)名稱的屬性值(包括對(duì)象內(nèi)部屬性) */ String key() default "all"; /** 限制頻次(每秒請(qǐng)求次數(shù)) */ long permitsPerSecond(); /** 黑名單攔截(多少次限制后加入黑名單)0 不限制 */ double blacklistCount() default 0; /** 攔截后的執(zhí)行方法 */ String fallbackMethod(); }
3.AOP切面編程
導(dǎo)入依賴
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
編寫AOP限流代碼
我們doRouter切面函數(shù)以AccessInterceptor注解為切點(diǎn),根據(jù)注解的各類配置來執(zhí)行整個(gè)限流過程。
我們通過使用Redisson的RRateLimiter限流器,基于令牌桶實(shí)現(xiàn)訪問限流,并對(duì)已經(jīng)限流的訪問記錄黑名單次數(shù),超過設(shè)置的黑名單閾值就會(huì)被加入黑名單中,較長時(shí)間無法訪問
代碼較長,為了縮短篇幅就一次性放上來了,各處已經(jīng)打上了詳細(xì)注解,若有疑問可評(píng)論區(qū)留言。
@Slf4j @Aspect public class RateLimiterAOP { // 注入我們聲明的redisson客戶端 @Resource private RedissonClient redissonClient; // 限流RateLimiter緩存前綴 private static final String rateLimiterName = "test:RateLimiter:"; // 黑名單原子計(jì)數(shù)器緩存前綴 private static final String blacklistPrefix = "test:RateBlockList:"; @Around("@annotation(accessInterceptor)") public Object doRouter(ProceedingJoinPoint jp, AccessInterceptor accessInterceptor) throws Throwable { // 獲取注解配置的字段key String key = accessInterceptor.key(); if (StringUtils.isBlank(key)) { log.error("限流RateLimiter注解中的 Key 屬性為空!"); throw new RuntimeException("RateLimiter注解中的 Key 屬性為空!"); } log.info("限流攔截關(guān)鍵字為 {}", key); // 根據(jù)key獲取攔截標(biāo)識(shí)符字段 String keyAttr = getAttrValue(key, jp.getArgs()); // 黑名單攔截,非法訪問次數(shù)超過黑名單閾值 if (!"all".equals(keyAttr) && accessInterceptor.blacklistCount() != 0 && redissonClient.getAtomicLong(blacklistPrefix + keyAttr).get() > accessInterceptor.blacklistCount()) { log.info("限流-黑名單攔截:{}", keyAttr); return fallbackMethodResult(jp, accessInterceptor.fallbackMethod()); } // 獲取限流器 -> Redisson RRateLimiter RRateLimiter rateLimiter = redissonClient.getRateLimiter(rateLimiterName + keyAttr); if (!rateLimiter.isExists()) { // 創(chuàng)建令牌桶數(shù)據(jù)模型,單位時(shí)間內(nèi)產(chǎn)生多少令牌 rateLimiter.trySetRate(RateType.PER_CLIENT,1, accessInterceptor.permitsPerSecond(), RateIntervalUnit.MINUTES); } // 限流判斷,沒有獲取到令牌,超出頻率 if (!rateLimiter.tryAcquire()) { // 如果開啟了黑名單限制,那么就記錄當(dāng)前的非法訪問次數(shù) if (accessInterceptor.blacklistCount() != 0) { RAtomicLong atomicLong = redissonClient.getAtomicLong(blacklistPrefix + keyAttr); atomicLong.incrementAndGet(); // 原子自增 atomicLong.expire(24, TimeUnit.HOURS); // 刷新黑名單原子計(jì)數(shù)器器過期時(shí)間為24小時(shí) } log.info("限流-頻率過高攔截:{}", keyAttr); return fallbackMethodResult(jp, accessInterceptor.fallbackMethod()); } // 返回結(jié)果 return jp.proceed(); } /** * 調(diào)用用戶配置的回調(diào)方法,使用反射機(jī)制實(shí)現(xiàn)。 */ private Object fallbackMethodResult(JoinPoint jp, String fallbackMethod) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { // 通過JoinPoint對(duì)象獲取方法的簽名(Signature) Signature sig = jp.getSignature(); // 將方法簽名轉(zhuǎn)換為MethodSignature對(duì)象,以便獲取方法的詳細(xì)信息 MethodSignature methodSignature = (MethodSignature) sig; // 獲取到具體的方法對(duì)象,通過方法名和參數(shù)(所以回調(diào)函數(shù)參數(shù)一定要和原方法一致) Method method = jp.getTarget().getClass().getMethod(fallbackMethod, methodSignature.getParameterTypes()); // 調(diào)用目標(biāo)對(duì)象的方法,并傳入當(dāng)前對(duì)象(jp.getThis())和方法的參數(shù)(jp.getArgs())。 return method.invoke(jp.getThis(), jp.getArgs()); } /** * 根據(jù)JoinPoint對(duì)象獲取其所代表的方法對(duì)象 */ private Method getMethod(JoinPoint jp) throws NoSuchMethodException { Signature sig = jp.getSignature(); MethodSignature methodSignature = (MethodSignature) sig; return jp.getTarget().getClass().getMethod(methodSignature.getName(), methodSignature.getParameterTypes()); } /** * 實(shí)際根據(jù)自身業(yè)務(wù)調(diào)整,主要是為了獲取通過某個(gè)值做攔截 */ public String getAttrValue(String attr, Object[] args) { String filedValue = null; for (Object arg : args) { try { // 找到HttpServletRequest對(duì)象來獲取請(qǐng)求IP地址(如果是根據(jù)IP攔截的話) if ("request_ip".equals(attr) && arg instanceof HttpServletRequest) { HttpServletRequest request = (HttpServletRequest) arg; filedValue = IPUtils.getIpAddr(request); } // 找到了值,返回 if (StringUtils.isNotBlank(filedValue)) { break; } // fix: 使用lombok時(shí),uId這種字段的get方法與idea生成的get方法不同,會(huì)導(dǎo)致獲取不到屬性值,改成反射獲取解決 filedValue = String.valueOf(this.getValueByName(arg, attr)); } catch (Exception e) { log.error("獲取路由屬性值失敗 attr:{}", attr, e); } } return filedValue; } /** * 獲取對(duì)象的特定屬性值(反射) * * @param item 對(duì)象 * @param name 屬性名 * @return 屬性值 * @author tang */ private Object getValueByName(Object item, String name) { try { // 獲取指定對(duì)象中對(duì)應(yīng)屬性名的Field對(duì)象 Field field = getFieldByName(item, name); // 獲取到的Field對(duì)象為null,表示屬性不存在,直接返回null。 if (field == null) { return null; } // 將Field對(duì)象設(shè)置為可訪問,以便獲取私有屬性的值。 field.setAccessible(true); // 獲取屬性值,并將其賦值給變量o。 Object o = field.get(item); // 將Field對(duì)象設(shè)置為不可訪問,以保持對(duì)象的封裝性。 field.setAccessible(false); return o; } catch (IllegalAccessException e) { return null; } } /** * 根據(jù)名稱獲取方法,該方法同時(shí)兼顧繼承類獲取父類的屬性 * * @param item 對(duì)象 * @param name 屬性名 * @return 該屬性對(duì)應(yīng)方法 * @author tang */ private Field getFieldByName(Object item, String name) { try { Field field; try { // 獲取指定對(duì)象中對(duì)應(yīng)屬性名的Field對(duì)象。 field = item.getClass().getDeclaredField(name); } catch (NoSuchFieldException e) { // 沒有找到,拋出NoSuchFieldException異常,嘗試獲取父類中對(duì)應(yīng)屬性名的Field對(duì)象 field = item.getClass().getSuperclass().getDeclaredField(name); } return field; } catch (NoSuchFieldException e) { // 父類也沒找到對(duì)應(yīng)屬性名的Field對(duì)象,寄,返回null return null; } } }
以上代碼用到了自己寫的一個(gè)工具類IPUtils來獲取請(qǐng)求的IP地址,內(nèi)容如下
public class IPUtils { private static Logger logger = LoggerFactory.getLogger(IPUtils.class); private static final String IP_UTILS_FLAG = ","; private static final String UNKNOWN = "unknown"; private static final String LOCALHOST_IP = "0:0:0:0:0:0:0:1"; private static final String LOCALHOST_IP1 = "127.0.0.1"; /** * 獲取IP地址 * <p> * 使用Nginx等反向代理軟件, 則不能通過request.getRemoteAddr()獲取IP地址 * 如果使用了多級(jí)反向代理的話,X-Forwarded-For的值并不止一個(gè),而是一串IP地址,X-Forwarded-For中第一個(gè)非unknown的有效IP字符串,則為真實(shí)IP地址 */ public static String getIpAddr(HttpServletRequest request) { String ip = null; try { //以下兩個(gè)獲取在k8s中,將真實(shí)的客戶端IP,放到了x-Original-Forwarded-For。而將WAF的回源地址放到了 x-Forwarded-For了。 ip = request.getHeader("X-Original-Forwarded-For"); if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("X-Forwarded-For"); } //獲取nginx等代理的ip if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("x-forwarded-for"); } if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (StringUtils.isEmpty(ip) || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_CLIENT_IP"); } if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } //兼容k8s集群獲取ip if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); if (LOCALHOST_IP1.equalsIgnoreCase(ip) || LOCALHOST_IP.equalsIgnoreCase(ip)) { //根據(jù)網(wǎng)卡取本機(jī)配置的IP InetAddress iNet = null; try { iNet = InetAddress.getLocalHost(); } catch (UnknownHostException e) { logger.error("getClientIp error: {}", e); } ip = iNet.getHostAddress(); } } } catch (Exception e) { logger.error("IPUtils ERROR ", e); } //使用代理,則獲取第一個(gè)IP地址 if (!StringUtils.isEmpty(ip) && ip.indexOf(IP_UTILS_FLAG) > 0) { ip = ip.substring(0, ip.indexOf(IP_UTILS_FLAG)); } return ip; } }
4.接口使用自定義注解實(shí)現(xiàn)限流
使用自定義限流注解
比如我在用戶controller層的登錄接口上使用注解,key為request_ip,表示根據(jù)用戶IP限流,回調(diào)函數(shù)為fallbackMethod,每分鐘訪問限制10次
@PostMapping(value = "/login") @AccessInterceptor(key = "request_ip", fallbackMethod = "loginErr", permitsPerSecond = 1L, blacklistCount = 10) public Response<String> doLogin(@RequestParam String code, HttpServletRequest request){
綁定限流回調(diào)函數(shù)
這里需要注意的是,回調(diào)函數(shù)的參數(shù)必須和你使用限流注解的方法參數(shù)一致,否則報(bào)對(duì)應(yīng)方法找不到的錯(cuò)誤(因?yàn)檫@里是通過反射機(jī)制找到回調(diào)函數(shù)執(zhí)行的)
public Response<String> loginErr(String code, HttpServletRequest request) { System.out.println("限流觸發(fā)回調(diào),參數(shù)信息:" + code); return Response.<String>builder() .code(Constants.ResponseCode.FREQUENCY_LIMITED.getCode()) .info(Constants.ResponseCode.FREQUENCY_LIMITED.getInfo()) .data(code) .build(); }
總結(jié)
以上通過Redission+自定義注解+AOP+反射實(shí)現(xiàn)了對(duì)不同標(biāo)識(shí)符的限流和黑名單攔截,并且可以綁定限流回調(diào)函數(shù)來處理限流后的邏輯,代碼篇幅較長,各位小伙伴也可以嘗試?yán)^續(xù)優(yōu)化一下這里的設(shè)計(jì),減少request_ip這種魔法值(實(shí)在懶得改了),更多相關(guān)Springboot 訪問限流與黑名單攔截內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
MyBatis解決Update動(dòng)態(tài)SQL逗號(hào)的問題
這篇文章主要介紹了MyBatis解決Update動(dòng)態(tài)SQL逗號(hào)的問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-01-01Spring Boot 2.7.6整合redis與低版本的區(qū)別
這篇文章主要介紹了Spring Boot 2.7.6整合redis與低版本的區(qū)別,文中補(bǔ)充介紹了SpringBoot各個(gè)版本使用Redis之間的區(qū)別實(shí)例講解,需要的朋友可以參考下2023-02-02dm.jdbc.driver.DMException網(wǎng)絡(luò)通信異常的解決過程
最近一個(gè)項(xiàng)目里面出現(xiàn)了一個(gè)比較詭異的問題,給大家分享下,這篇文章主要給大家介紹了關(guān)于dm.jdbc.driver.DMException網(wǎng)絡(luò)通信異常的解決過程,需要的朋友可以參考下2023-02-02SpringBoot通過@Value實(shí)現(xiàn)給靜態(tài)變量注入值詳解
這篇文章主要介紹了springboot如何通過@Value給靜態(tài)變量注入值,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-07-07Spring Boot實(shí)戰(zhàn)之靜態(tài)資源處理
這篇文章主要介紹了Spring Boot實(shí)戰(zhàn)之靜態(tài)資源處理,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-01-01SpringCache緩存自定義配置的實(shí)現(xiàn)
本文主要介紹了SpringCache緩存自定義配置的實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-01-01Springcloud Alibaba超詳細(xì)使用詳解
SpringCloudAlibaba是一款優(yōu)秀的微服務(wù)架構(gòu),在市面上有著廣泛的應(yīng)用,這篇文章介紹了SpringCloudAlibaba的一些基本使用,適合初學(xué)者,希望能夠給大家?guī)韼椭?/div> 2024-08-08解決java-jar報(bào)錯(cuò):xxx.jar 中沒有主清單屬性的方法
在使用 java -jar xxx.jar 命令運(yùn)行 Java 應(yīng)用程序時(shí),遇到了以下錯(cuò)誤:xxx.jar 中沒有主清單屬性,這個(gè)錯(cuò)誤表示 JAR 文件缺少必要的啟動(dòng)信息,本文將介紹該錯(cuò)誤的原因以及如何通過修改 pom.xml 文件來解決,需要的朋友可以參考下2024-11-11最新評(píng)論