分布式系统,自定义注解,实现拦截重复请求(重复提交)

背景

公司后台后端由多个微服务基础工程组成,基于spring cloud 2 Finchley.SR2 版本构成,整体由三层结构组成,网关工程,服务消费者,服务提供者。大致架构简图如下。所有的请求都是请求到网关工程,再接入各个服务中。
压力测试时,测试同事提到部分接口会出现重复提交的问题。基于项目的整体架构,用户访问后,请求最先到达网关工程,再接入服务。针对该问题,本人认为在各服务进行特殊化处理不是太好,可以在网关工程统一处理。与网关工程负责任人进行沟通后,该方案被否决:并不是所有请求都需要拦截,在网关工程处理,无法区分哪些服务需要拦截。
甩来甩去,这个bug最终回到了自己身上,好,那就解决吧。
系统架构简图.png

解决思路

首先,第一步需要明白的就是,这是分布式系统,不是单体应用,多节点实现线程安全需要通过第三方资源,本方案中采用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());
        }
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值