背景
公司后台后端由多个微服务基础工程组成,基于spring cloud 2 Finchley.SR2 版本构成,整体由三层结构组成,网关工程,服务消费者,服务提供者。大致架构简图如下。所有的请求都是请求到网关工程,再接入各个服务中。
压力测试时,测试同事提到部分接口会出现重复提交的问题。基于项目的整体架构,用户访问后,请求最先到达网关工程,再接入服务。针对该问题,本人认为在各服务进行特殊化处理不是太好,可以在网关工程统一处理。与网关工程负责任人进行沟通后,该方案被否决:并不是所有请求都需要拦截,在网关工程处理,无法区分哪些服务需要拦截。
甩来甩去,这个bug最终回到了自己身上,好,那就解决吧。
解决思路
首先,第一步需要明白的就是,这是分布式系统,不是单体应用,多节点实现线程安全需要通过第三方资源,本方案中采用redis。
第二步,如何认为一个请求是重复请求?可采用url+请求数据,在设定的时间内重复的方案。
第三步,需要拦截哪些请求?可采用的方案有:
- 配置拦截器,需要拦截的url配置到参数中,可实现只针对部分url进行拦截的功能。
- 抽成工具类,在需要拦截的controller中调用。
- 通过注解配置切面,切面中设置拦截,只需要在需要拦截的controller中加上注解,则可以实现功能。
采用第三种方案,第一种方案随着需要拦截的url增加,配置项会越来越长,过于繁琐,第二种方案代码耦合。
方案明细
分布式锁
采用redis实现,setnx命令,当key不存在时,创建并且设置value,并且返回true,否则返回false。
判断哪些是重复提交
url+请求参数得到一个字符串,将该字符串进行MD5,得到一个key,采用redsis的setnx命令存入redis中,只要返回false,则证明在一段时间内有相同请求参数的请求进来了。
url:拼接的字符串中,必须有url,调用不同接口,存在参数相同的情况。
请求参数:本方案中,目前只处理了两种请求参数,一类是application/x-www-form-urlencoded(表单提交)方式,一类是application/json方式,优先判断是否有表单提交参数,如果没有,再判断application/json方式中请求体的json数据。
注解加aop
通过注解加切面的方式,将代码解耦,只需要在需要拦截的方法上加上该注解既可。
代码实现
aop和redis的配置不过多介绍
- 自定义注解
package xx.xxxx.xxxx.xxxx.xxxx.xxxx.filter;
import org.springframework.web.bind.annotation.Mapping;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Mapping
@Documented
public @interface InterceptorReq {
String clazz();//类的全限定名
long overdue() default 5000;//失效时间,默认5秒
}
- aop代码
package xx.xxx.xxxx.xxxx.xxxx.xxxx.filter;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
@Slf4j
public class RequestFilter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around(value = "@annotation(InterceptorReq)")
public Object doBefore(ProceedingJoinPoint joinPoint) {
try {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
//获取到请求对象
HttpServletRequest request = attributes.getRequest();
//优先判断是application/x-www-form-urlencoded,如果取出来的map中没有数据,则当成application/json方式
Map<String, String[]> parameterMap = request.getParameterMap();
Set<Map.Entry<String, String[]>> entries = parameterMap.entrySet();
if (entries == null || entries.isEmpty()) {
//认为是application/json方式
//获取注解中传入的值,该值是需要校验数据的类的全名
InterceptorReq interceptorReq = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(InterceptorReq.class);
String value = interceptorReq.clazz();
long overdue = interceptorReq.overdue();
String json = "";
//获取该切面环绕的方法的形参
Object[] args = joinPoint.getArgs();
if (args == null) {
//请求的方法,没有形参,则该方法根本没有传参,直接拦截直接处理
try {
Object proceed = joinPoint.proceed();
return proceed;
} catch (Throwable throwable) {
throwable.printStackTrace();
log.error("相同请求拦截器异常:{}", throwable.getMessage());
return ReturnUtil.returnErr(throwable.getMessage());
}
}
//方法可能有几个形参,判断哪个形参中的数据需要校验
for (Object arg : args) {
//形参的类名
String name = arg.getClass().getName();
//如果形参中的类型,和注解中传入的值,一样,则该对象的数据需要校验
if (name.equals(value)) {
json = JSON.toJSONString(arg);
}
}
//请求参数为空,不需要校验,直接调用方法执行
if (StringUtils.isBlank(json)) {
try {
Object proceed = joinPoint.proceed();
return proceed;
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
//请求体中数据不为空,则处理数据
String requestURI = request.getRequestURI();
//url和请求的数据,拼接形成一个key,转MD5
String Longkey = requestURI+json;
String key = DigestUtils.md5DigestAsHex(Longkey.getBytes());
//分布式的锁
boolean lock = false;
try {
//判断锁存在不存在,必须设置睡眠时间,默认设置5000
lock = redisTemplate.opsForValue().setIfAbsent(key, "1");
redisTemplate.expire(key, overdue, TimeUnit.MILLISECONDS);
} catch (Exception e) {
redisTemplate.expire(key, overdue, TimeUnit.MILLISECONDS);
}
if (lock != true) {
return ReturnUtil.returnErr("请不要重复提交");
}
}else {
//获取注解中传入的值,该值是需要校验数据的类的全名
InterceptorReq interceptorReq = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(InterceptorReq.class);
long overdue = interceptorReq.overdue();
String json = JSON.toJSONString(parameterMap);
String requestURI = request.getRequestURI();
String Longkey = requestURI+json;
String key = DigestUtils.md5DigestAsHex(Longkey.getBytes());
boolean lock = false;
try {
lock = redisTemplate.opsForValue().setIfAbsent(key, "1");
redisTemplate.expire(key, overdue, TimeUnit.MILLISECONDS);
} catch (Exception e) {
redisTemplate.expire(key, overdue, TimeUnit.MILLISECONDS);
}
if (lock != true) {
return ReturnUtil.returnErr("请不要重复提交");
}
}
}catch (Exception e) {
log.error("相同请求拦截器异常:{}", e.getMessage());
return ReturnUtil.returnErr(e.getMessage());
}
try {
Object proceed = joinPoint.proceed();
return proceed;
} catch (Throwable throwable) {
throwable.printStackTrace();
log.error("相同请求拦截器异常:{}", throwable.getMessage());
return ReturnUtil.returnErr(throwable.getMessage());
}
}
}
ReturnUtil是自定义的返回方式,您可以根据需求自定义自己的统一返回。
- 使用方式
@InterceptorReq(clazz = "cn.net.xxx.base.vo.BaseParas", overdue = 5000)
@ApiOperation("项目修改")
@RequestMapping(value = "/edit", method = RequestMethod.POST)
public String edit (HttpServletRequest request, @RequestBody BaseParas paras) {
try {
} catch (Exception e) {
}
return ReturnUtil.returnSucc();
}
在需要拦截的controller方法上加上InterceptorReq注解既可。clazz参数值,将方法中的这个参数作为请求参数,overdue指限定时间,不传该值默认5s。如本方法中,形参BaseParas前加了@RequestBody,则请求体中的数据会转成BaseParas paras,注解InterceptorReq配置参数则表示将cn.net.xxx.base.vo.BaseParas对象的数据作为拦截依据。
代码结构没有做过多优化,请谅解。
实现过程中的详解
aop代码中,获取request后,试图通过request.getInputStream()获取请求体中数据,结果得到了一个stream cloesd的Execption,仔细一想才反应过来,springMvc将请求封装进@RequestBody中,已使用过该流并且关闭了。
则获取请求体中的数据换了方案,请求体数据已经封装进@RequestBody注解的对象中,直接获取该对象的数据既可
Object[] args = joinPoint.getArgs();//获取方法的所有参数
获取注解中传入的参数
//获取注解中传入的值,该值是需要校验数据的类的全名
InterceptorReq interceptorReq = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(InterceptorReq.class);
String value = interceptorReq.clazz();
long overdue = interceptorReq.overdue();
找到请求参数判断依据对象,并取出该对象的数据
//方法可能有几个形参,判断哪个形参中的数据需要校验
for (Object arg : args) {
//形参的类名
String name = arg.getClass().getName();
//如果形参中的类型,和注解中传入的值,一样,则该对象的数据需要校验
if (name.equals(value)) {
json = JSON.toJSONString(arg);
break;
}
}
拼接url和请求参数,判断短时间内是否有相同请求
//请求体中数据不为空,则处理数据
String requestURI = request.getRequestURI();
//url和请求的数据,拼接形成一个key,转MD5
String Longkey = requestURI+json;
String key = DigestUtils.md5DigestAsHex(Longkey.getBytes());
//分布式的锁
boolean lock = false;
try {
//判断锁存在不存在,必须设置睡眠时间,默认设置5000
lock = redisTemplate.opsForValue().setIfAbsent(key, "1");
redisTemplate.expire(key, overdue, TimeUnit.MILLISECONDS);
} catch (Exception e) {
redisTemplate.expire(key, overdue, TimeUnit.MILLISECONDS);
}
if (lock != true) {
return ReturnUtil.returnErr("请不要重复提交");
}
请求通过,执行方法
try {
Object proceed = joinPoint.proceed();
return proceed;
} catch (Throwable throwable) {
throwable.printStackTrace();
log.error("相同请求拦截器异常:{}", throwable.getMessage());
return ReturnUtil.returnErr(throwable.getMessage());
}