Spring拦截器+装饰器+过滤器实现幂等校验

        幂等校验一直是系统中极为重要的一环,体现在许多系统架构中。如服务内同步调用,保证多次操作仅产生一个结果(e.g. 用户网络延迟,快速连续点击下单按钮,此时如果不做幂等校验会产生无数条未支付订单);再比如微服务通过消息队列发送消息,消费者异步处理逻辑,如果消息重复发送仅消费一次(e.g. 多次发送支付请求,消费者如果不做幂等校验,会对同一订单多次付款)。

        我们就针对生成订单接口,做一个简易的幂等校验逻辑。

1 幂等校验实现思路

        用户在商品详情页点击下单时,网络出现延迟,导致多次点击没有响应;但实际上服务端在接受到请求后,会为每次请求都创建一个订单,这样用户点击多少次,就会生成多少条待支付订单,这时就需要做幂等校验。

        实现方案很简单,用户在进入商品详情页时,前端自行生成一个唯一ID并保存,在用户点击下单按钮时将ID和订单具体信息一同发送到服务端,服务端在接收到请求后对ID进行校验,如Redis中存在该ID,说明此订单已经创建,直接返回下单成功并返回一个订单支付回调URL;如Redis中不存在该ID,说明还未创建该订单,将订单ID写入Redis并继续处理生成订单逻辑。

         下面我们就用拦截器来实现。

2 拦截器实现

        先来编写拦截器逻辑并将其注册进Spring,主要逻辑就是读取输入流并解析成Json对象,获取其中的唯一ID并在Redis中进行下单次数校验,如果未重复就放行,重复就直接返回下单失败结果。由于我这没有前端页面,偷个懒直接用下单用户名做校验哈。

        这里用到了Redis的setNx方法,并设置了15s的过期时间,一般重复下单也不会超过这个时间。如果setNx设置成功,说明是正常下单;如果设置失败,说明已存在该订单,直接返回错误结果。核心代码如下。

        //入参Json字符串解析
        JSONObject paramJson = JSONObject.parseObject(param.toString());
        String username = paramJson.getString("username");
        if (Strings.isNullOrEmpty(username)) {
            log.error("入参异常");
            Result<Object> error = Result.error("入参异常,请重新发起请求");
            byte[] resultByte = JSONObject.toJSONString(error).getBytes("UTF-8");
            outputStream.write(resultByte);
            return false;
        }

        String redisKey = "addOrderTimesConstraint:".concat(username);
        if (redisUtil.setNx(redisKey, "1", 15, TimeUnit.SECONDS)) {
            log.info("用户下单次数校验成功");
        } else {
            log.error("用户重复下单,直接拒绝");
            Result<Object> error = Result.error("请勿重复下单");
            byte[] resultByte = JSONObject.toJSONString(error).getBytes("UTF-8");
            outputStream.write(resultByte);
            return false;
        }

        return true;

        下面就将该拦截器注册进SpringMVC,重写WebMvcConfigurer中的addInterceptors()方法,将拦截器注入并指定拦截路径,我们只需拦截下单接口。

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //支付相关接口需要用拦截器做幂等校验
        //先仅用用户名进行下单限制,15s内只能下单一次
        registry.addInterceptor(webInterceptor)
                .addPathPatterns("/pay/addOrder");
    }

3 出现的问题及原因

        先来试着连续下单两次,日志如下。

2023-02-23 10:28:18.618  INFO 12440 --- [nio-6666-exec-4] c.framework.interceptor.WebInterceptor   : 请求URL: http://localhost:6666/pay/addOrder
2023-02-23 10:28:18.619  INFO 12440 --- [nio-6666-exec-4] c.framework.interceptor.WebInterceptor   : 支付相关接口入参: {    "username": "abcdefg",    "stuffId": "566996ae8642ded9a005eefc1b506e70",    "stuffCount": "1",    "moneyAmount": "5555.55"}
2023-02-23 10:28:19.559  INFO 12440 --- [nio-6666-exec-4] c.framework.interceptor.WebInterceptor   : 用户下单次数校验成功
2023-02-23 10:28:19.579 ERROR 12440 --- [nio-6666-exec-4] c.f.h.exception.GlobalExceptionHandler   : Exception出现了异常!{}

org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public com.framework.utils.Result<?> com.bank.controller.PayController.addOrder(com.bank.entity.OrderInfo)
2023-02-23 10:33:15.815  INFO 12440 --- [nio-6666-exec-6] c.framework.interceptor.WebInterceptor   : 请求URL: http://localhost:6666/pay/addOrder
2023-02-23 10:33:15.815  INFO 12440 --- [nio-6666-exec-6] c.framework.interceptor.WebInterceptor   : 支付相关接口入参: {    "username": "abcdefg",    "stuffId": "566996ae8642ded9a005eefc1b506e70",    "stuffCount": "1",    "moneyAmount": "5555.55"}
2023-02-23 10:33:15.817 ERROR 12440 --- [nio-6666-exec-6] c.framework.interceptor.WebInterceptor   : 用户重复下单,直接拒绝

        可以看到拦截器和下单次数校验生效了,也返回了正确的Json参数,但是进入下单方法体后却报错了。报错的内容也很直白,方法入参是用@RequestBody来接收的实体类,意思就是Request Body丢失了,实体类没获取到。为何如此呢?下面来分析一下。

Required request body is missing: public com.framework.utils.Result<?> com.bank.controller.PayController.addOrder(com.bank.entity.OrderInfo)

3.1 InputStream的流式设计       

        既然是读取出了问题,可以先去看看InputStream的read()方法,read()一共有3种重载形式,如下面的代码。

public abstract int read() throws IOException;

public int read(byte b[]) throws IOException {
    return read(b, 0, b.length);
}

public int read(byte b[], int off, int len) throws IOException {
    if (b == null) {
        throw new NullPointerException();
    } else if (off < 0 || len < 0 || len > b.length - off) {
        throw new IndexOutOfBoundsException();
    } else if (len == 0) {
        return 0;
    }
    
    int c = read();
    if (c == -1) {
        return -1;
    }
    b[off] = (byte)c;

    int i = 1;
    try {
        for (; i < len ; i++) {
            c = read();
            if (c == -1) {
                break;
            }
            b[off + i] = (byte)c;
        }
    } catch (IOException ee) {
    }
    return i;
}
  1. 无参的抽象方法,有很多种实现。
  2. 单参方法传入byte数组,相当于buffer缓冲区,将流中的数据暂存到buffer中,实际调用了三参方法。
  3. 三参方法传入了byte数组,byte数组写入起始位置,读取长度。该方法真正读取实际还是调用了无参抽象方法,呃啊,还得去找个实现方法看看。

        在read()的243个实现方法中随便点了一个,ByteArrayInputStream中的read()是这样的。主要逻辑就是对缓冲区数组和 0xff做了按位与运算,0xff = 1111 1111,可以保证缓冲区每个字节(8bit位)不受改变,因为与运算需要都为1才等于1,而8bit正构成了一个byte。但这个不是我们要探究的重点,实际问题集中在"pos"和"count"这两个全局变量。

public synchronized int read() {
    return (pos < count) ? (buf[pos++] & 0xff) : -1;
}

       "pos"代表buff缓冲区中当前读取的指针位置,"count"代表buff缓冲区的最大长度,如果当前读取位置 < 最大长度,则能够读取;反之则返回-1,停止读取。"pos"和"count"都只会增大,不会减小,即缓冲区的长度和流读取的指针都是逐渐增大的。因此就导致了流的读取是单向的,只能向前不能向后。

3.2 ServletInputStream

        ServletInputStream继承了InputStream,且没有重写read()方法和reset()方法,所以他们俩的读取逻辑是一致的,都是一次性读取。

        有一种简单的逻辑,就是从ServletInputStream中读取完后,把Body再塞入HttpServletRequest中,设置一个Attribute然后在方法体中获取;但是这样不太优雅,以后每次新增需要校验的接口还要改动方法参数。

        我们使用类似的思路,将Body读取完后缓存起来,让他别再是一次性的了;每次调用getInputStream()并读取时,都通过构造器把缓存的Body塞进去,就实现多次读取了。这种思路需要重写HttpServletRequest,有没有这种方法呢?当然有,使用HttpServletRequestWrapper。

4 解决方案:自定义装饰器 + 过滤器注册

        HttpServletRequestWrapper的顶级接口是ServletRequest,使用了装饰器模式,装饰器模式的作用就是包装原有类,相当于温柔地增强。下面我们只需要自定义一个HttpServletRequestWrapper,重写getInputStream(),并用他代替原有的Servletequest,就完整地实现了Body重复读取。

4.1 自定义ServletRequest装饰器

        构建一个HttpServletRequestWrapper就相当于重写一个ServletRequest,我们能重写任何需要自定义的方法。实现的思路就是读取原有HttpServletRequest中一次性的InputStream,放在本类中缓存,后续调用getInputStream()方法时都读本类缓存。

        我们在类中创建一个变量"byte[] bodyCache = null",将他作为本类缓存。这时有人可能会想到线程安全问题,Servlet本身不是线程安全的,因为他是单例模式的;但是ServletRequest会由Dispatcher调度,每个请求分配一个线程来处理,不会存在多个线程操作同一ServletRequest。因此是线程安全的,放心大胆用。

        接下来就需要把ServletInputStream读取并写入byte数组中,实现起来很简单,从ServletInputStream -> ByteArrayOutputStream再转数组即可。代码如下。

    private byte[] bodyCache = null;

    public BodyDuplicateWrapper(HttpServletRequest request) {
        super(request);
        try {
            ServletInputStream inputStream = request.getInputStream();
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            int len = 0;
            while ((len = inputStream.read(buffer)) > -1) {
                //3参数:     byte b[]              int off                    int len
                //        buffer字节数组,buffer数组写入位置(偏移量),写入字节数(一般等于读取字节数)
                outputStream.write(buffer, 0, len);
            }
            outputStream.flush();

            //复制入本地变量
            bodyCache = outputStream.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

         流复制的逻辑其实可以用StreamUtils.copyToByteArray(inputStream)来实现,但是多写写总没坏处。在构造器运行完后,bodyCache已经存放了输入流的数据,再重写getInputStream()方法,将read()方法重写为读取本地缓存即可。

    /***
     * 重复获取输入流方法
     * @return
     * @throws IOException
     */
    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bodyCache);
        return new ServletInputStream() {
            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }

            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }
        };
    }

    /***
     * 重复获取reader方法
     * @return
     * @throws IOException
     */
    @Override
    public BufferedReader getReader() throws IOException {
        ByteArrayInputStream inputStream = new ByteArrayInputStream(bodyCache);
        return new BufferedReader(new InputStreamReader(inputStream));
    }

4.2 装饰器注入

        装饰器光写好摆在那也不行,得配合过滤器把他注入进去,再配置需要过滤的路径。在使用HttpServletRequest时,就将当前request通过构造器传入,并将增强后的装饰器传入FilterChain中,以后就使用装饰后的HttpServletRequest。

//通过构造器将原request传入
ServletRequest requestWrapper = new BodyDuplicateWrapper((HttpServletRequest) servletRequest);

//放入过滤器链
filterChain.doFilter(requestWrapper, servletResponse);

        最后只需要把过滤器注册进Spring容器即可。

    /***
     * Body输入流复制过滤器
     * @return
     */
    @Bean
    public FilterRegistrationBean filterRegistrationBean(){
        FilterRegistrationBean bean = new FilterRegistrationBean();
        bean.setFilter(new BodyDuplicateFilter());
        bean.addUrlPatterns("/pay/addOrder");
        bean.setOrder(1);

        return bean;
    }

5 结果试验

         我们来试验一下,能否既正常下单又让幂等校验生效。

{
    "code": 200,
    "message": "下单成功,请尽快支付",
    "success": true,
    "timestamp": "1677154192047"
}
{
    "code": 500,
    "message": "请勿重复下单",
    "success": false,
    "timestamp": 1677154195615
}

        结果看来非常成功,圆满结束。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值