表单重复提交,是我们开发中经常遇到的一个问题。最简单的做法是让前端调用者去处理,但是一旦项目庞大,用户量增加的时候,或者网络延迟的情况,多次点击提交的情况,或者补偿重发接口等等,这种方式显然就不靠谱了。
防止 API 接口幂等,就是为了解决 API 接口重复提交的问题。接下来模拟表单重复提交的场景。
先下载本次案例的脚手架代码:https://pan.baidu.com/s/1dLFNDaoGt60iRNne2vOXLQ 提取码:tygb
创建表:t_order
CREATE TABLE `t_order` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`product_name` varchar(255) DEFAULT NULL COMMENT '产品名称',
`price` decimal(10,2) DEFAULT NULL COMMENT '价格',
`order_no` varchar(64) DEFAULT NULL COMMENT '订单号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
脚手架代码主要按照常规的做法进行订单的提交。
启动服务,浏览器地址输入:http://127.0.0.1/
去数据库查看:
这是正常情况的提交。然后我们继续刷新页面。
又提交了一次表单,数据库查看:
解决思路:前端在提交表单前,先调用后台接口获取唯一的、临时的 token(后台生成 token,并存入缓存中,如 Redis,可以设置有效期为30分钟),拿到 token 并存入到头部信息 header,然后再提交表单。后台接收到表单提交的请求,先判断头部的 token 是否在缓存中,如果在缓存中,则继续处理业务逻辑(处理完业务后,必须删除掉缓存中的 token)。如果不存在缓存,说明无效或者重复提交。
OK,我们先创建生成 token 的工具类:
封装的 Redis 操作类:
package com.study.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* @author biandan
* @description 封装的 Redis 操作类
* @signature 让天下没有难写的代码
* @create 2021-06-02 上午 11:46
*/
@Component
public class MyRedisTemplate {
@Autowired
private StringRedisTemplate stringRedisTemplate;
//设置 String 对象
public Boolean setString(String key,Object data,Long timeout){
if(data instanceof String){
if(null != timeout){
stringRedisTemplate.opsForValue().set(key,(String)data,timeout, TimeUnit.SECONDS);
}else{
stringRedisTemplate.opsForValue().set(key,(String)data);
}
return true;
}else{
return false;
}
}
//获取 String 对象
public Object getString(String key){
return stringRedisTemplate.opsForValue().get(key);
}
//删除某个 key
public void delKey(String key){
stringRedisTemplate.delete(key);
}
}
token 工具类:
package com.study.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Objects;
import java.util.UUID;
/**
* @author biandan
* @description 临时 token 的工具类
* @signature 让天下没有难写的代码
* @create 2021-06-02 上午 11:44
*/
@Component
public class TokenUtil {
@Autowired
private MyRedisTemplate myRedisTemplate;
private static final Long TIMEOUT = 60 * 30L;
//生成 token
public String getToken() {
String token = "token_" + UUID.randomUUID().toString().replaceAll("-","");
myRedisTemplate.setString(token, token, TIMEOUT);
return token;
}
//判断是否有 token
public Boolean findToken(String tokenKey) {
if(Objects.nonNull(myRedisTemplate.getString(tokenKey))){
String token = (String)myRedisTemplate.getString(tokenKey);
if(!StringUtils.isEmpty(token)){
return true;
}
}
return false;
}
//删除某个 key
public void deleteKey(String key){
myRedisTemplate.delKey(key);
}
}
修改 OrderController,增加 getToken 接口,增加从 header 获取 token 参数,以及提交订单成功后,删除 token。
package com.study.controller;
import com.study.entity.OrderEntity;
import com.study.service.OrderService;
import com.study.util.TokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Random;
/**
* @author biandan
* @description
* @signature 让天下没有难写的代码
* @create 2021-06-01 下午 11:19
*/
@Controller
public class OrderController {
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyyMMddHHmmss");
@Autowired
private OrderService orderService;
@Autowired
private TokenUtil tokenUtil;
//新增订单
@RequestMapping(value = "/addOrder",method = RequestMethod.POST)
public ModelAndView index(HttpServletRequest request) {
ModelAndView view = new ModelAndView();
String productName = request.getParameter("productName");
Float price = Float.parseFloat(request.getParameter("price"));
String token = request.getHeader("token");
if(StringUtils.isEmpty(token) || !tokenUtil.findToken(token)){
view.setViewName("fail");
return view;
}
OrderEntity orderEntity = new OrderEntity();
orderEntity.setProductName(productName);
orderEntity.setPrice(price);
String orderNo = SDF.format(new Date())+ new Random().nextInt(1000);
orderEntity.setOrderNo(orderNo);
if(orderService.addOrder(orderEntity) > 0){
view.setViewName("success");
view.addObject("orderNo",orderNo);
//删除 token
tokenUtil.deleteKey(token);
}else {
view.setViewName("fail");
}
return view;
}
//获取token
@RequestMapping(value = "/getToken",method = RequestMethod.GET)
@ResponseBody
public String getToken(){
String token = tokenUtil.getToken();
return token;
}
}
重启服务,并启动 Redis,可以查看博客:https://blog.csdn.net/BiandanLoveyou/article/details/116422555
这次使用 postman 测试。
先获取 token
然后把 token 放到 header 中,请求新增订单接口。(注意把参数放到 Params 中)
如果我们点击再次提交,提示我们订单已经重复。
OK,解决表单重复提交的思路和方案大致为上面所说的。
但是有一个问题,如果每次请求都要判断是否有 token,代码就显得臃肿。我们在下一篇博客尝试着封装成一个注解,只需要在方法上增加注解,就可以自动判断 token 是否有效。
本篇博客代码:https://pan.baidu.com/s/1HkGWnhE_lsP3iZ2K5kMq3A 提取码:qs55