概述
我的系统采用的是 SpringBoot + Vue 的前后端分离技术。
有一个缺陷:后端的接口暴露后,容易被不怀好意的人拿来刷接口。
为了解决这个问题,我采用了 在后端限制同一 ip 每秒的访问次数上限 的方法暂时性解决。
基本思想:
在后端记录每个 ip 当前秒的访问次数。定义一个拦截器拦截所有请求,每次接收到请求后先判断该 ip 是否已经达到当前秒的请求上限,如果未达到,正常放行且该 ip 当前秒请求数量加一,如果已达到,直接拦截掉并告知前端请求频繁。
前端判断出该问题后会延迟一秒重新发起请求频繁的请求。通过相应拦截器来判断相应状态,如果是请求频繁,则延迟一秒重新发起请求。
后端
后端主要完成对每秒请求次数的限制
存储每个 ip 请求信息的辅助类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| package cn.itsheng.labshow.util;
import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component;
import java.util.Date; import java.util.HashMap; import java.util.Map;
@Component @Slf4j public class IpVisitUtil {
private static Long timesOneSecond;
@Value("${visit.limit}") private void setTimesOneSecond(Long timesOneSecond){ IpVisitUtil.timesOneSecond = timesOneSecond; }
private static final Map<String, Map<Long, Long>> ipLimitMap = new HashMap<>();
public static boolean addVisit(String ipAddr){
log.info("已经获取当前访问者的 ip: {}", ipAddr);
Long second = new Date().getTime()/1000;
log.info("每秒最大访问次数: {}", timesOneSecond); log.info("获取当前秒的值(时间戳/1000):{}", second);
synchronized (IpVisitUtil.class){ Map<Long, Long> ipMap = ipLimitMap.getOrDefault(ipAddr, new HashMap<>()); if (ipMap.containsKey(second)){
log.info("已经访问次数:{}", ipMap.get(second));
Long visitTimes = ipMap.get(second) + 1;
if (visitTimes > timesOneSecond){ return false; }
ipMap.put(second, visitTimes); } else { ipMap.clear(); ipMap.put(second, 1L); }
ipLimitMap.put(ipAddr, ipMap); return true; } } }
|
注意这里使用了 synchronized 来保证线程安全。
配置文件中对每秒请求上限的配置:
拦截器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package cn.itsheng.labshow.interceptor;
import cn.hutool.extra.servlet.ServletUtil; import cn.itsheng.labshow.util.IpVisitUtil; import com.alibaba.fastjson.JSONObject; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;
@Component @Slf4j public class VisitLimitInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
response.setHeader("Content-Type","application/json;charset=UTF-8");
if (!IpVisitUtil.addVisit(ServletUtil.getClientIP(request))){ JSONObject json= new JSONObject(); json.put("msg", "请求过于频繁"); json.put("code",406); response.getWriter().append(json.toJSONString()); log.info("请求过于频繁,未通过身份拦截器"); return false; }; return true; } }
|
这里使用了 Hutool 的 ServletUtil.getClientIP() 方法来获取请求者 ip:Servlet工具-ServletUtil | Hutool
注册拦截器:
1 2 3 4 5 6 7 8 9 10 11 12
| @Configuration public class AppConfig implements WebMvcConfigurer { @Autowired VisitLimitInterceptor visitLimitInterceptor;
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(visitLimitInterceptor).addPathPatterns("/**"); } }
|
到这里已经完成了后端的限制请求次数工作。
但是前端有时的确会在一秒内有较高的请求数量,所以前端也要处理请求频繁失败后的请求重试工作。
前端
前端主要解决因请求频繁而失败时的请求重试工作
axios 的相应拦截器(中间件):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| import axios from 'axios' import router from '@/router' import { Message } from "element-ui"; import { removeUser, setToken, getToken, removeToken } from "@/util/auth"
const service = axios.create({ baseURL: "http://localhost:8889/api", timeout: 50000 })
service.interceptors.request.use( config => { config.headers['token'] = getToken() return config } )
service.interceptors.response.use( resp => {
if (resp.data.code === 406) { console.log("请求过于频繁,一秒后重试请求...");
return new Promise((resolve, reject) => { setTimeout(() => { return service.request(resp.config).then(res => { resolve(res) }) }, 1000) }).then(r => { return r }) }
if (resp.headers['content-type'] !== "application/json;charset=UTF-8" || !resp.config.url.includes("/admin/")) { return resp }
if (resp.data.code === 401) { router.push('/login') removeUser() removeToken() }
if (resp.data.code !== 200) { Message.error(resp.data.msg); }
if (resp.data.code === 200) { Message({ type: 'success', message: resp.data.msg }) }
if ("token" in resp.headers) { setToken(resp.headers["token"]) }
return resp
},
error => { console.log(error) return Promise.reject(error) } )
export default service
|
这里延迟一秒重新发送的核心:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| if (resp.data.code === 406) { console.log("请求过于频繁,一秒后重试请求...");
return new Promise((resolve, reject) => { setTimeout(() => { return service.request(resp.config).then(res => { resolve(res) }) }, 1000) }).then(r => { return r }) }
|
这里使用了 Promise 和 setTimeout。
setTimeout 负责延迟一秒发送请求,Promise 负责处理延迟后的返回值。
最终效果
请求失败:

重复请求成功:
