SpringBoot 系列教程(八十四):Spring Boot使用注解控制Api接口幂等性之前后端分离架构设计

一、前言

1. 产生幂等性场景

在传统的web项目比如使用SSMSSH框架开发的时候,涉及表单提交时,可能会产生表单重复提交问题,还有分布式开发中rpc远程调用、MQ消费者幂等(保证唯一)、甚至常见的在网络产生延迟的情况下,都可能产生重复请求,这时候会涉及到表单重复提交,这种情况我们称之为幂等性

2. 关于Api接口幂等性

幂等性,其实说白了就是一次请求的唯一性,按照以前常用的做法是:

  • 第一种是在前端由前端工程师使用JS控制,比如提交完请求之后将提交按钮置灰,让用户不能够再点击发送请求,这样其实是不专业的;
  • 第二种``Token+Redis机制处理,这种做法在大型项目中较为流行,比较专业,其简要原理是后端生成一个唯一的提交令牌(token),并存储在服务端(Redis缓存)。页面提交请求携带这个提交令牌,后端验证,并在第一次验证后删除该令牌(Redis缓存中),保证提交请求的唯一性,这种常见的后端存储是采用NoSQL数据库,比如RedisMongoDB等等缓存中。
  • 回过头来想想,这种方式能够解决幂等性问题,并且从代码开发者的角度看,上述两种方案都是可行的,但是从改动量来看,如果每次请求都要获取token然后进行一统校验,如果一百个接口,要写一百次,代码太过冗余,所以针对Token+Redis方案我们可以通过注解方式,使用AOP技术对注解进行解析,这样就事半功倍了。如果过滤器的话,所有接口都进行了校验。

3. 重复提交的原因

1. 提交按钮点击多次
2. 点击刷新按钮
3. 使用浏览器后退按钮重复之前的操作
4. 使用浏览器历史记录重复提交表单
5. 浏览器重复的HTTP请求
6. nginx重发等情况
7. nginx重发
8. 分布式开发中rpc远程调用重试
9. MQ消费者幂等
10.网络产生延迟

二、幂等性常见解决方案

1. 流行的Redis+Token方案

在传统的项目中,采用redis+token,注意点是:令牌 保证唯一的并且是临时的 过一段时间失效这种方式是解决Api接口幂等性问题比较常使用的一种方式。

(1). 注意在获取Token这种方法代码一定要上分布式锁,需要保证只有一个线程执行 ,否则会造成token不唯一,如果多个线程,造成步骤乱套了;
(2). 调用业务接口前先调用接口获取token,然后前端调用接口发起请求的时候,将该令牌携带放到请求头中 ,后端收到请求后,获取请求头中的Token令牌,拿到该令牌后,去Redis中查找,如果能够从Redis中获取到该令牌 ,立即将当前令牌删除掉,然后执行该方法业务逻辑 ,如果获取不到对应的令牌。返回提示“请不要重复提交!” 信息。

(3). 如果别人获得了你的token ,然后拿去做坏事,采用机器模拟去攻击,这是很危险的,这时候我们要用验证码来解决。

2. 从数据库层次控制

在数据库层次,常见的控制表单重复提交就是给数据库增加唯一键约束,通过设置唯一键,确保数据库只可以添加一条数据,保证相同数据不会插入第二条,但这种方式仅仅是数据库层面,通过数据库加唯一键约束能有效避免数据库重复插入相同数据。但无法阻止恶意用户重复提交表单(攻击网站),服务器大量执行sql插入语句,增加服务器和数据库负荷,并且这种方式不治本。

三、注解+AOP式解决方案

思路:
1. 自定义注解 @NoRepeatSubmit 标记所有Controller中的提交请求
2. 通过AOP对所有标记了 @NoRepeatSubmit的方法拦截解析
3. 在业务方法执行前,获取当前用户的 token(或者JSessionId)+ 当前请求地址,作为一个唯一 KEY,去获取 Redis 分布式锁(如果此时并发获取,只有一个线程会成功获取锁)
4. 业务方法执行后,释放锁

依据上述步骤,我们开始搭建Api接口幂等性解决方案:

四、搭建项目环境

1. 项目完整目录结构在这里插入图片描述在这里插入图片描述

2. 创建springboot-token项目

1. 引入以下依赖

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.2.RELEASE</version>
        <relativePath/> 
    </parent>
    <groupId>com.thinkingcao</groupId>
    <artifactId>springboot-aop-annotation</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>springboot-aop-annotation</name>
    <description>Demo project for Spring Boot</description>

    <!--编码设置-->
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>


    <dependencies>
        <!-- 引入Web组件 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 引入热加载工具 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>

        <!-- 引入Aop组件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- 引入mybatis组件 -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.1</version>
        </dependency>

        <!-- 引入Redis组件 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- 引入Lombok代码简化插件-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 引入mysql链接驱动 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <!--druid连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.9</version>
        </dependency>

        <!--引入spring-jdbc组件  -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
        </dependency>

        <!-- commons-lang组件 -->
        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>

        <!-- alibaba json解析工具类 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
        </dependency>

        <!-- 引入测试组件 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

3. application.yml配置

spring:
  datasource:
    #数据库连接配置
    url: jdbc:mysql://localhost:3306/springboot-aop-annotation?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&useSSL=true
    #数据库连接账号
    username: root
    #数据库连接密码
    password: 123456
    #数据库连接驱动
    driver-class-name: com.mysql.cj.jdbc.Driver
    #连接池配置
    druid:
      #初始化时建立物理连接的个数。初始化发生在显示调用init方法,或者第一次getConnection时
      initialSize: 1
      #最小连接池数量
      minIdle: 1
      #最大连接池数量
      maxActive: 20
      #获取连接时最大等待时间,单位毫秒。
      maxWait: 60000
      #配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
      timeBetweenEvictionRunsMillis: 60000
      #配置一个连接在池中最小生存的时间,单位是毫秒
      minEvictableIdleTimeMillis: 300000
      #验证数据库连接的查询语句
      validationQuery: SELECT 1
      testWhileIdle: true
      testOnBorrow: true
      testOnReturn: false

#Redis连接配置
redis:
  #Redis数据库索引(默认为0)
  database: 1
  #Redis服务器地址
  host: 127.0.0.1
  #Redis服务器连接端口
  port: 6379
  #Redis服务器连接密码(默认为空)
  password:
  jedis:
    pool:
      #连接池最大连接数(使用负值表示没有限制)
      max-active: 8
      #连接池最大阻塞等待时间(使用负值表示没有限制)
      max-wait: -1
      #连接池中的最大空闲连接
      max-idle: 8
      #连接池中的最小空闲连接
      min-idle: 0
      # 连接超时时间(毫秒)
      timeout: 10000

4. 自定义注解

package com.thinkingcao.springboot.aop.annotation;
import java.lang.annotation.*;
/**
 * @desc:  对解决接口幂等性、网络延迟、表单重复提交的注解的封装
 *   type表示token获取方式
 * @author: cao_wencao
 * @date: 2019-12-17 22:01
 */
@Documented
@Inherited
@Target(value = {ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
    String type() default "";
}

5. Redis配置

注意: 项目中引用了Redis,需要编写redis配置类,使用RedisTemplate需要注入Bean到Spring容器,类似Jedis

package com.thinkingcao.springboot.aop.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

/**
 * @desc: redis配置类,使用RedisTemplate需要注入Bean到Spring容器,类似Jedis
 * @author: cao_wencao
 * @date: 2019-12-13 12:17
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory, Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder) {
        RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(redisConnectionFactory);
        Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        jsonRedisSerializer.setObjectMapper(jackson2ObjectMapperBuilder.build());
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        //=======设置下key和value的序列化方式,避免出现二进制数据显示=========
        //key采用String的序列化方式
        template.setKeySerializer(stringSerializer);
        //hash的key也采用String的序列化方式
        template.setValueSerializer(jsonRedisSerializer);
        //value序列化方式采用jackson
        template.setHashKeySerializer(stringSerializer);
        //hash的value序列化方式采用jackson
        template.setHashValueSerializer(jsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

6. 实体类

以订单(Order)为例,作为本次文章研究测试的例子

package com.thinkingcao.springboot.aop.entity;

import lombok.Data;
import java.io.Serializable;

/**
 * @desc: 订单实体类
 * @author: cao_wencao
 * @date: 2019-12-05 14:22
 */
@Data
public class Order implements Serializable {
   
    private int orderId; //订单编号id
    private double orderMoney; //订单金额
    private String receiverAddress; //收货地址
    private String receiverName; //收货姓名
    private String receiverPhone; //手机号

}

7. OrderMapper

订单接口,写一个新增订单的例子

package com.thinkingcao.springboot.aop.mapper;

import com.thinkingcao.springboot.aop.entity.Order;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;

/**
 * @desc: 订单mapper
 * @auth: cao_wencao
 * @date: 2019/12/19 22:20
 */
public interface OrderMapper {
	@Insert("INSERT  INTO `t_order` (order_id,order_money,receiver_address,receiver_name,receiver_phone) VALUES (#{orderId},#{orderMoney},#{receiverAddress},#{receiverName},#{receiverPhone});")
	@Options(keyProperty="order.orderId",keyColumn="order_id",useGeneratedKeys=true)
	public int addOrder(Order order);
}

8. OrderController

因为做测试,这里Service层就不封装了,直接Controller层调Ordermapper接口即可;

package com.thinkingcao.springboot.aop.controller;

import com.thinkingcao.springboot.aop.annotation.NoRepeatSubmit;
import com.thinkingcao.springboot.aop.entity.Order;
import com.thinkingcao.springboot.aop.mapper.OrderMapper;
import com.thinkingcao.springboot.aop.result.ResponseCode;
import com.thinkingcao.springboot.aop.service.OrderService;
import com.thinkingcao.springboot.aop.utils.Constant;
import com.thinkingcao.springboot.aop.utils.RedisUtil;
import com.thinkingcao.springboot.aop.utils.TokenUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @desc: 订单Controller
 * @author: cao_wencao
 * @date: 2019-12-18 0:01
 */
@RestController
public class OrderController {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private RedisUtil redisUtil;


    @RequestMapping(value = "/getToken")
    public ResponseCode getToken(){
        String token = TokenUtils.getToken();
        //将生成的token存进redis   key:token  value: token   time : 30分钟
        redisUtil.set(token, token, redisUtil.TOKEN_EXPIRE_TIME);
        return ResponseCode.success("获取token成功",token);
    }


    /**
     * @desc: 新增订单
     * @auth: cao_wencao
     * @date: 2019/12/19 22:27
     */
    @RequestMapping(value = "/addOrder", produces = "application/json; charset=utf-8")
    @NoRepeatSubmit(type = Constant.EXTAPIHEAD)
    public ResponseCode addOrder(@RequestBody Order order) {
        int result = orderMapper.addOrder(order);
        if (result > 0) {
            return ResponseCode.success("添加成功!");
        }
        return ResponseCode.error("添加失败!");
    }
}

9. 接口响应工具类

封装一个ResponseCode工具类用来返回Api接口统一JSON格式数据;

package com.thinkingcao.springboot.aop.result;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.HashMap;

/**
 * @desc: 接口响应工具类
 * @auth: cao_wencao
 * @date: 2019/11/21 18:00
 */
@Data
@AllArgsConstructor
public class ResponseCode extends HashMap<String, Object> {

    private static final long serialVersionUID = 1L;

    public static final String CODE_TAG = "code";

    public static final String MSG_TAG = "msg";

    public static final String DATA_TAG = "data";

    /**
     * 状态类型
     */
    public enum Type
    {
        /** 成功 */
        SUCCESS(200),
        /** 警告 */
        WARN(400),
        /** 错误 */
        ERROR(500);
        private final int value;

        Type(int value)
        {
            this.value = value;
        }

        public int value()
        {
            return this.value;
        }
    }

    /** 状态类型 */
    private Type type;

    /** 状态码 */
    private int code;

    /** 返回内容 */
    private String msg;

    /** 数据对象 */
    private Object data;


    /**
     * 初始化一个新创建的 AjaxResult 对象
     *
     * @param type 状态类型
     * @param msg 返回内容
     */
    public ResponseCode(Type type, String msg)
    {
        super.put(CODE_TAG, type.value);
        super.put(MSG_TAG, msg);
    }

    /**
     * 初始化一个新创建的 AjaxResult 对象
     *
     * @param type 状态类型
     * @param msg 返回内容
     * @param data 数据对象
     */
    public ResponseCode(Type type, String msg, Object data)
    {
        super.put(CODE_TAG, type.value);
        super.put(MSG_TAG, msg);
        if (data !=null)
        {
            super.put(DATA_TAG, data);
        }
    }

    /**
     * 返回成功消息
     *
     * @return 成功消息
     */
    public static ResponseCode success()
    {
        return ResponseCode.success("操作成功");
    }

    /**
     * 返回成功数据
     *
     * @return 成功消息
     */
    public static ResponseCode success(Object data)
    {
        return ResponseCode.success("操作成功", data);
    }

    /**
     * 返回成功消息
     *
     * @param msg 返回内容
     * @return 成功消息
     */
    public static ResponseCode success(String msg)
    {
        return ResponseCode.success(msg, null);
    }

    /**
     * 返回成功消息
     *
     * @param msg 返回内容
     * @param data 数据对象
     * @return 成功消息
     */
    public static ResponseCode success(String msg, Object data) {
        return new ResponseCode(Type.SUCCESS, msg, data);
    }

    /**
     * 返回警告消息
     *
     * @param msg 返回内容
     * @return 警告消息
     */
    public static ResponseCode warn(String msg)
    {
        return ResponseCode.warn(msg, null);
    }

    /**
     * 返回警告消息
     *
     * @param msg 返回内容
     * @param data 数据对象
     * @return 警告消息
     */
    public static ResponseCode warn(String msg, Object data) {
        return new ResponseCode(Type.WARN, msg, data);
    }

    /**
     * 返回错误消息
     *
     * @return
     */
    public static ResponseCode error() {
        return ResponseCode.error("操作失败");
    }

    /**
     * 返回错误消息
     *
     * @param msg 返回内容
     * @return 警告消息
     */
    public static ResponseCode error(String msg) {
        return ResponseCode.error(msg, null);
    }

    /**
     * 返回错误消息
     *
     * @param msg 返回内容
     * @param data 数据对象
     * @return 警告消息
     */
    public static ResponseCode error(String msg, Object data) {
        return new ResponseCode(Type.ERROR, msg, data);
    }

}

五、工具类封装

1. Constant.java

package com.thinkingcao.springboot.aop.utils;

/**
 * @desc: 表明从请求头取token或者从参数取,二选一
 * @author: cao_wencao
 * @date: 2019-12-17 23:46
 */
public interface Constant {

   public static final String EXTAPIHEAD = "head";

    public static final String EXTAPIFROM = "from";
}

2. HttpServletRequestUtil .java

package com.thinkingcao.springboot.aop.utils;

import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

/**
 * @desc: 获取当前请求的HttpServletRequest对象
 * @author: cao_wencao
 * @date: 2019-12-17 23:16
 */
public class HttpServletRequestUtil {
    
    public static HttpServletRequest getRequest() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        return request;
    }
}

3. HttpServletResponseUtil.java

package com.thinkingcao.springboot.aop.utils;

import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
 * @desc:
 * @author: cao_wencao
 * @date: 2019-12-17 23:18
 */
public class HttpServletResponseUtil {

    public static void response(String msg) throws IOException {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletResponse response = attributes.getResponse();
        response.setHeader("Content-type", "text/html;charset=UTF-8");
        PrintWriter writer = response.getWriter();
        try {
            writer.println(msg);
        } catch (Exception e) {

        } finally {
            writer.close();
        }

    }

}

4. RedisUtil.java

package com.thinkingcao.springboot.aop.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * @desc: redis工具类,使用@Component注解将RedisUtils交给Spring容器实例化,使用时直接注解注入即可。
 * @author: cao_wencao
 * @date: 2019-12-13 14:04
 */
@Component
public class RedisUtil {

   // 默认缓存时间
    public static final Long TOKEN_EXPIRE_TIME = 30 * 60L;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;


    // ============================common=============================

    /**
     * 指定缓存失效时间
     *
     * @param key  键
     * @param time 时间(秒)
     * @return
     */
    public boolean expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 根据key 获取过期时间
     *
     * @param key 键 不能为null
     * @return 时间(秒) 返回0代表为永久有效
     */
    public long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }


    /**
     * 判断key是否存在
     *
     * @param key 键
     * @return true 存在 false不存在
     */
    public boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }


    /**
     * 删除缓存
     *
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("unchecked")
    public boolean del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
            return true;
        }
        return false;
    }

    /**
     * 普通缓存获取
     *
     * @param key 键
     * @return 值
     */
    public Object get(String key) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }


    /**
     * 普通缓存放入
     *
     * @param key   键
     * @param value 值
     * @return true成功 false失败
     */
    public boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }


    /**
     * 普通缓存放入并设置时间
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒) time要大于0 如果time小于等于0 将设置无限期
     * @return true成功 false 失败
     */
    public boolean set(String key, Object value, long time) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
}

5. TokenUtils.java

package com.thinkingcao.springboot.aop.utils;

import sun.misc.BASE64Encoder;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;

/**
 * @desc: token工具类
 * @auth: cao_wencao
 * @date: 2019/12/16 13:54
 */
public class TokenUtils {
	private static final String MEMBER_TOKEN = "member_token";
 
	 //token生成
	 public synchronized static String getToken() {
		String tokenStr =  UUID.randomUUID().toString().replace("-", "");;
		 BASE64Encoder base64 = new BASE64Encoder();
		 String  token = base64.encode(tokenStr.getBytes());
		 return token;
	 }


	public static void main(String[] args) throws NoSuchAlgorithmException {
		for (int i = 0; i < 100; i++) {
			//System.out.println(UUID.randomUUID().toString().replace("-", ""));
			System.out.println(TokenUtils.getToken());
		}
	}
}

六、使用AOP解析注解@NoRepeatSubmit

package com.thinkingcao.springboot.aop.aspect;

import com.thinkingcao.springboot.aop.annotation.NoRepeatSubmit;
import com.thinkingcao.springboot.aop.utils.Constant;
import com.thinkingcao.springboot.aop.utils.HttpServletRequestUtil;
import com.thinkingcao.springboot.aop.utils.HttpServletResponseUtil;
import com.thinkingcao.springboot.aop.utils.RedisUtil;
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.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

/**
 * @desc: 表单重复提交AOP切面,解析注解@NoRepeatSubmit
 * @author: cao_wencao
 * @date: 2019-12-17 22:09
 */
@Aspect
@Component
@Slf4j
public class RepeatSubmitAop {

    //注入Redis工具类
    @Autowired
    private RedisUtil redisUtil;

    //定义切入点, 拦截controller的所有请求
    private  final String POINTCUT = "execution(public * com.thinkingcao.springboot.aop.controller.*.*(..))";


    //前置通知
    /*@Before(POINTCUT)
    public void before(JoinPoint point) {
        MethodSignature signature = (MethodSignature) point.getSignature();
        NoRepeatSubmit noRepeatSubmit = signature.getMethod().getDeclaredAnnotation(NoRepeatSubmit.class);
        if (null != noRepeatSubmit) {
            //获取type参数的value
            String typeValue = noRepeatSubmit.type();

        }
    }*/

    //环绕通知
    @Around(POINTCUT)
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        //1. 使用AOP环绕通知拦截所有请求(controller)
        //POINTCUT
        //2. 判断方法上是否有@NoRepeatSubmit
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        NoRepeatSubmit noRepeatSubmit = signature.getMethod().getDeclaredAnnotation(NoRepeatSubmit.class);
        //3. 如果方法上有@NoRepeatSubmit
        if (null != noRepeatSubmit) {
            //获取type参数的value
            String typeValue = noRepeatSubmit.type();
            String token = null;
            HttpServletRequest request = HttpServletRequestUtil.getRequest();

            //如果存在header中,从头中获取
            if (typeValue.equals(Constant.EXTAPIHEAD)) {
                token = request.getHeader("token");
            } else {
                ////否则从 请求参数获取
                token = request.getParameter("token");
            }
            if (StringUtils.isEmpty(token)) {
                HttpServletResponseUtil.response("参数错误!");
                return null;
            }
            //如果redis中token不存在,则为重复提交
            String redisToken = (String) redisUtil.get(token);
            if (StringUtils.isEmpty(redisToken)) {
                HttpServletResponseUtil.response("请勿重复提交!");
                return null;
            }
            //redis不为空,则为第一次请求
            redisUtil.del(redisToken);
        }
        //放行
        Object proceed = joinPoint.proceed();
        return proceed;
    }


}

七、启动类

注意: 注解式mybatis的sql需要加上@MapperScan扫描,xml形式的mybatis的sql不需要

package com.thinkingcao.springboot.aop;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @desc: 启动类; 注意: 注解式mybatis的sql需要加上@MapperScan扫描,xml形式的mybatis的sql不需要
 * @auth: cao_wencao
 * @date: 2019/12/20 17:14
 */
@SpringBootApplication
@MapperScan(value = "com.thinkingcao.springboot.aop.mapper")
public class SpringbootAopApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootAopApplication.class, args);
    }

}

八、开始测试

思路:

  1. 先调用接口获取token: http://127.0.0.1:8080/getToken
    在这里插入图片描述
    获取token的同时将token存入了redis,打开RedisDesktopManager工具查看下:
    RedisDesktopManager下载地址: https://github.com/uglide/RedisDesktopManager/releases
    在这里插入图片描述

  2. 先调用新增订单接口: http://127.0.0.1:8080/addOrder

注意以下两点:

1. 请求头需要带上token值

2. Post请求JSON数据:
{
“orderId”:10,
“orderMoney”:1.0,
“receiverAddress”:“上海”,
“receiverName”:“曹”,
“receiverPhone”:“13027180989”
}

在测试新增订单之前,数据库的数据:
在这里插入图片描述
然后来调用请求订单接口:
在这里插入图片描述
在这里插入图片描述
在测试新增订单之后,数据库的数据:
在这里插入图片描述
在测试新增订单之后,Redis中存储的token值已经被删除了,如图:
在这里插入图片描述
然后我们短时间内立即将上述新增订单的请求再次请求一次,会显示新增不成功,token值不存在,这样就达到了类似Form表单请求重复提交问题
在这里插入图片描述

九、源码

源码: https://github.com/Thinkingcao/SpringBootLearning/tree/master/springboot-aop-annotation

Thinkingcao CSDN认证博客专家 Java Spring Boot 架构
CSDN2019年度博客之星、博客专家,专注架构、Java、SpringBoot、SpringCloud、Spring、微服务、分布式等领域
©️2020 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客 返回首页
实付9.90元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值