# 简介
由于前端会莫名其妙的对同一接口请求多次,从而占用后端资源造成浪费。所以采用了后端拦截相关重复请求的方案。此方案会将请求用户 id 加接口 url 加参数作为 key,请求时间作为 value,使用 ConcurrentHashMap 进行缓存。如果下次相同的请求和上次请求的时间在指定的范围内则认为此请求属于重复请求。
# 实现细节
# 自定义可重复读 Request
request 的 body 只能读取一次,所以对其进行封装。
package xxx.support; | |
import com.alibaba.fastjson.JSON; | |
import lombok.extern.slf4j.Slf4j; | |
import javax.servlet.ReadListener; | |
import javax.servlet.ServletInputStream; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletRequestWrapper; | |
import java.io.*; | |
import java.nio.charset.StandardCharsets; | |
import java.util.Enumeration; | |
import java.util.Map; | |
import java.util.Objects; | |
import java.util.TreeMap; | |
@Slf4j | |
public class RepeatableReadHttpServletRequestWrapper extends HttpServletRequestWrapper { | |
private final byte[] requestBody; | |
public RepeatableReadHttpServletRequestWrapper(HttpServletRequest request) throws IOException { | |
super(request); | |
this.requestBody = readRequestBody(request); | |
} | |
private byte[] readRequestBody(HttpServletRequest request) throws IOException { | |
try (InputStream inputStream = request.getInputStream(); | |
ByteArrayOutputStream result = new ByteArrayOutputStream()) { | |
byte[] buffer = new byte[1024]; | |
int length; | |
while ((length = inputStream.read(buffer)) != -1) { | |
result.write(buffer, 0, length); | |
} | |
return result.toByteArray(); | |
} | |
} | |
@Override | |
public ServletInputStream getInputStream() throws IOException { | |
// 直接使用 ByteArrayInputStream,它提供可重复读取的输入流 | |
return new ServletInputStream() { | |
private final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestBody); | |
@Override | |
public int read() throws IOException { | |
return byteArrayInputStream.read(); | |
} | |
@Override | |
public boolean isFinished() { | |
return byteArrayInputStream.available() == 0; | |
} | |
@Override | |
public boolean isReady() { | |
return true; | |
} | |
@Override | |
public void setReadListener(ReadListener readListener) { | |
// 不需要实现,可以留空 | |
} | |
}; | |
} | |
@Override | |
public BufferedReader getReader() throws IOException { | |
// 使用 InputStreamReader 包装 ByteArrayInputStream,提供可重复读取的字符流 | |
return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(requestBody))); | |
} | |
/** | |
* 获取 json 格式的参数 | |
* @return | |
*/ | |
public String getParamsToJSONString() { | |
String jsonStr = ""; | |
if ("POST".equals(this.getMethod().toUpperCase()) && this.isJsonRequest()) { | |
try { | |
jsonStr = this.readJsonData(); | |
} catch (Exception e) { | |
log.error(e.getMessage()); | |
} | |
} else { | |
Enumeration<String> parameterNames = this.getParameterNames(); | |
if (Objects.nonNull(parameterNames) && parameterNames.hasMoreElements()) { | |
// 将参数排序后转为 json | |
Map<String, String> paramsMap = new TreeMap<>(); | |
while (parameterNames.hasMoreElements()) { | |
String paramName = parameterNames.nextElement(); | |
paramsMap.put(paramName, this.getParameter(paramName)); | |
} | |
jsonStr = JSON.toJSONString(paramsMap); | |
} | |
} | |
return jsonStr; | |
} | |
/** | |
* 判断是否 json 请求 | |
* @return | |
*/ | |
private boolean isJsonRequest() { | |
String contentType = this.getContentType(); | |
return contentType != null && contentType.toLowerCase().contains("application/json"); | |
} | |
/** | |
* 获取 json 格式的参数 | |
* @return | |
* @throws IOException | |
*/ | |
private String readJsonData() throws IOException { | |
return new String(this.readRequestBody(this), StandardCharsets.UTF_8); | |
} | |
} |
# 重复请求过滤器
package xxx.filter; | |
import cn.hutool.core.collection.CollectionUtil; | |
import xxx.RepeatableReadHttpServletRequestWrapper; | |
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; | |
import org.springframework.security.web.util.matcher.RequestMatcher; | |
import org.springframework.web.filter.OncePerRequestFilter; | |
import javax.servlet.FilterChain; | |
import javax.servlet.ServletException; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletResponse; | |
import java.io.IOException; | |
import java.util.List; | |
import java.util.Map; | |
import java.util.Objects; | |
import java.util.concurrent.ConcurrentHashMap; | |
public class DuplicateRequestFilter extends OncePerRequestFilter { | |
// 是否启用 | |
private Boolean duplicateRequestFilter; | |
// 间隔时间(毫秒) | |
private Long intervalTime; | |
// 清除缓存时间(毫秒) | |
private Long clearCachetime; | |
// 放行 url | |
private List<RequestMatcher> permitAll; | |
public DuplicateRequestFilter(Boolean duplicateRequestFilter, List<RequestMatcher> permitAll, Long intervalTime, | |
Long clearCachetime) { | |
this.duplicateRequestFilter = duplicateRequestFilter; | |
this.permitAll = permitAll; | |
this.intervalTime = intervalTime; | |
this.clearCachetime = clearCachetime; | |
} | |
// 存储参数和请求时间 | |
private Map<String, Long> requestCache = new ConcurrentHashMap<>(); | |
@Override | |
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) | |
throws ServletException, IOException { | |
boolean doFilter = true; | |
// 使用 ContentCachingRequestWrapper 包装原始请求 | |
RepeatableReadHttpServletRequestWrapper wrappedRequest = null; | |
if (this.duplicateRequestFilter) { | |
// 判断请求路径是否需要放行 | |
boolean permit = false; | |
if (CollectionUtil.isNotEmpty(this.permitAll)) { | |
for (RequestMatcher matcher: this.permitAll) { | |
if (matcher.matches(request)) { | |
permit = true; | |
break; | |
} | |
} | |
} | |
if (!permit) { | |
if (request instanceof RepeatableReadHttpServletRequestWrapper) { | |
wrappedRequest = (RepeatableReadHttpServletRequestWrapper) request; | |
} else { | |
wrappedRequest = new RepeatableReadHttpServletRequestWrapper(request); | |
} | |
doFilter = this.isValid(wrappedRequest); | |
} | |
} | |
if (doFilter) { | |
// 继续处理请求 | |
filterChain.doFilter(Objects.nonNull(wrappedRequest) ? wrappedRequest : request, response); | |
} else { | |
response.setContentType("application/json"); | |
response.setStatus(WebEndpointResponse.STATUS_TOO_MANY_REQUESTS); | |
// response.setStatus(HttpServletResponse.SC_OK); | |
// ObjectMapper mapper = new ObjectMapper(); | |
// mapper.writeValue (response.getOutputStream (), R.error (WebEndpointResponse.STATUS_TOO_MANY_REQUESTS, "重复的请求")); | |
} | |
} | |
/** | |
* 验证请求的有效性(判断是否重复请求) | |
* @param request | |
* @return | |
*/ | |
private boolean isValid(RepeatableReadHttpServletRequestWrapper request) { | |
boolean valid = true; | |
// 缓存的 key | |
String key = TokenUtil.getUidByToken() + "_" + request.getServletPath() + "_" + request.getParamsToJSONString(); | |
// 获取之前的请求时间 | |
Long previousRequestTime = requestCache.get(key); | |
if (previousRequestTime != null) { | |
// 如果距离上次请求时间很短(例如 1 秒),则拒绝当前请求 | |
if (System.currentTimeMillis() - previousRequestTime < this.intervalTime) { | |
valid = false; | |
} | |
} | |
this.clearOldRequests(); | |
// 缓存当前请求时间 | |
requestCache.put(key, System.currentTimeMillis()); | |
return valid; | |
} | |
// 用于清除缓存中的旧请求数据,防止缓存无限增长 | |
private void clearOldRequests() { | |
requestCache.entrySet().removeIf(entry -> System.currentTimeMillis() - entry.getValue() > this.clearCachetime); | |
} | |
} |
# 配置 OAuth2 资源
package xxx.config; | |
import xxx.AuthExceptionEntryPoint; | |
import xxx.CustomAccessDeniedHandler; | |
import xxx.DuplicateRequestFilter; | |
import org.slf4j.Logger; | |
import org.slf4j.LoggerFactory; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.beans.factory.annotation.Qualifier; | |
import org.springframework.beans.factory.annotation.Value; | |
import org.springframework.context.annotation.Bean; | |
import org.springframework.context.annotation.Configuration; | |
import org.springframework.security.config.annotation.web.builders.HttpSecurity; | |
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; | |
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; | |
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; | |
import org.springframework.security.oauth2.provider.token.TokenStore; | |
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; | |
import org.springframework.security.web.util.matcher.AntPathRequestMatcher; | |
import javax.servlet.Filter; | |
import java.util.Arrays; | |
import java.util.stream.Collectors; | |
@Configuration | |
@EnableResourceServer | |
public class ResourceServerConfig extends ResourceServerConfigurerAdapter { | |
Logger log = LoggerFactory.getLogger(ResourceServerConfig.class); | |
@Autowired | |
private TokenStore tokenStore; | |
/** | |
* 是否开放所有接口 | |
*/ | |
@Value("${http.security.permitAll:false}") | |
private Boolean isPermitAll; | |
/** | |
* 是否启用重复请求过滤 | |
*/ | |
@Value("${request.duplicateFilter.enabled:true}") | |
private Boolean duplicateRequestFilter; | |
/** | |
* 间隔时间 (毫秒) | |
*/ | |
@Value("${request.duplicateFilter.interval_time:1000}") | |
private Long intervalTime; | |
/** | |
* 清除缓存时间 (毫秒) | |
*/ | |
@Value("${request.duplicateFilter.clear_cache_time:30000}") | |
private Long clearCachetime; | |
/** | |
* 不需要验证权限的接口 | |
*/ | |
private String[] permitAll = new String[] { | |
"/auth/getVCode", "/auth/login" | |
}; | |
/** | |
* 通行规则 | |
* @param http | |
* @throws Exception | |
*/ | |
@Override | |
public void configure(HttpSecurity http) throws Exception { | |
HttpSecurity httpSecurity = http.csrf().disable(); | |
if (isPermitAll) { | |
httpSecurity.authorizeRequests().antMatchers("/**").permitAll(); | |
} else { | |
httpSecurity.authorizeRequests() | |
.antMatchers(permitAll).permitAll() | |
.antMatchers("/**").authenticated(); | |
} | |
if (this.duplicateRequestFilter) { | |
httpSecurity.addFilterAfter(duplicateRequestFilter(), AbstractPreAuthenticatedProcessingFilter.class); | |
} | |
// 让 X-frame-options 失效,去除 iframe 限制 | |
http.headers().frameOptions().disable(); | |
} | |
@Override | |
public void configure(ResourceServerSecurityConfigurer resources) throws Exception { | |
resources.tokenStore(tokenStore).authenticationEntryPoint(new AuthExceptionEntryPoint()) | |
.accessDeniedHandler(new CustomAccessDeniedHandler()); | |
} | |
@Bean | |
public Filter duplicateRequestFilter() { | |
return new DuplicateRequestFilter(this.duplicateRequestFilter, Arrays.asList(this.permitAll) | |
.stream().map(AntPathRequestMatcher::new).collect(Collectors.toList()), this.intervalTime, | |
this.clearCachetime); | |
} | |
} |