防重提交通常在需要防止用户重复提交表单或执行某些敏感操作时使用,以确保系统的数据一致性和安全性,本文章集结了通用场景下防重提交(参数形式&Token令牌),采用Java的特性(注解和AOP),配合Redis进行实现,使用方便有效。
注解介绍及使用
什么是注解
自JDK 1.5起,Java引入了对元数据(MetaData)的支持,即注解(Annotation)。注解实质上是代码中的特殊标记,用于取代繁琐的配置文件。常见的包括`@Override`、`@Deprecated`等。
什么是元注解
注解的注解,比如当我们需要自定义注解时,会需要一些元注解(meta-annotation),如@Target和@Retention。
java内置4种元注解
@Target 表示该注解用于什么地方
ElementType.CONSTRUCTOR 用在构造器
ElementType.FIELD 用于描述域-属性上
ElementType.METHOD 用在方法上
ElementType.TYPE 用在类或接口上
ElementType.PACKAGE 用于描述包
@Retention 表示在什么级别保存该注解信息
RetentionPolicy.SOURCE 保留到源码上
RetentionPolicy.CLASS 保留到字节码上
RetentionPolicy.RUNTIME 保留到虚拟机运行时(最多,可通过反射获取)
@Documented 将此注解包含在 javadoc 中
@Inherited 是否允许子类继承父类中的注解
@interface
用来声明一个注解,可以通过default来声明参数的默认值,自定义注解时,自动继承了java.lang.annotation.Annotation接口,通过反射可以获取自定义注解
具体代码
import java.lang.annotation.*;
/**
* 自定义防重提交
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSumbit {
/**
* 防重提交,支持两种,一种是方法参数,一个令牌
*/
enum Type{PARAM,TOKEN}
/**
* 默认防重提交,是方法参数
* @return
*/
Type limitType() default Type.PARAM;
/**
* 加锁过期实际,默认是5秒
* @return
*/
long lockTime() default 5;
}
AOP的介绍和使用
切面作用
利用AOP(面向切面编程),我们可以在不改变原有逻辑的情况下,增加额外的功能。AOP思想将系统的功能分为两个部分,从而分离各种关注点,降低了代码的耦合性,减少了代码侵入性。通过AOP,我们能够统一处理横切逻辑,这使得添加和删除横切逻辑变得更加方便。
AOP里面常见的概念
横切关注点
对哪些方法进行拦截,拦截后怎么处理,这些就叫横切关注点,比如 权限认证、日志、事物。
通知 Advice
在特定的切入点上执行的增强处理做什么? 比如你需要记录日志,控制事务 ,提前编写好通用的模块,需要的地方直接调用,比如重复提交判断逻辑
@Before前置通知,在执行目标方法之前运行
@After后置通知,在目标方法运行结束之后
@AfterReturning返回通知,在目标方法正常返回值后运行
@AfterThrowing异常通知,在目标方法出现异常后运行
@Around环绕通知,在目标方法完成前、后做增强处理 ,环绕通知是最重要的通知类型 ,像事务,日志等都是环绕通知,注意编程中核心是一个ProceedingJoinPoint,需要手动执joinPoint.procced()
连接点 JointPoint
要用通知的地方,业务流程在运行过程中需要插入切面的具体位置,一般是方法的调用前后,全部方法都可以是连接点。只是概念,没啥特殊
切入点 Pointcut
不能全部方法都是连接点,通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法,在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点,过滤出相应的 Advice 将要发生的joinpoint地方
切面 Aspect
通常是一个类,里面定义切入点+通知, 定义在什么地方; 什么时间点、做什么事情,通知 advice指明了时间和做的事情(前置、后置等),切入点 pointcut 指定在什么地方干这个事情,web接口设计中,web层->网关层->服务层->数据层,每一层之间也是一个切面,对象和对象,方法和方法之间都是一个个切面
目标 target
目标类,真正的业务逻辑,可以在目标类不知情的条件下,增加新的功能到目标类的链路上
织入 Weaving
把切面(某个类)应用到目标函数的过程称为织入
// 目标类
BookOrderService{
//新增订单;
addOrder(){};
//查询订单;
findOrderById();
//删除订单;
deleteOrderById();
//更新订单
updateOrder(){};
}
JoinPoint连接点:addOrder、findOrderById、deleteOrderById、updateOrder;
PointCut切入点:过滤出哪些JoinPoint连接点中哪些函数进行切入;
Advice通知:在切入点的函数上执行的动作,如权限校验,日志记录等等;
Aspect切面:由PointCut切入点和Advice通知组合而成,定义通知应用到哪些切入点;
Weaving织入:把切面的代码,应用到目标函数的过程;
具体代码
/**
* 定义一个切面类
*/
@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
/**
* 要在哪里执行该方法
* 方式一:@annotation:当执行的方法上拥有指定的注解时生效(我们采用这)
* 方式二:execution:一般用于指定方法的执行
*/
@Pointcut("@annotation(repeatSumbit)")
public void pointCutNoRepeatSubmit(RepeatSumbit repeatSumbit) {
}
/**
* 环绕通知, 围绕着方法执行
*
* @param joinPoint
* @param noRepeatSubmit
* @return
* @throws Throwable
* @Around 可以用来在调用一个具体方法前和调用后来完成一些具体的任务。
* <p>
* 方式一:单用 @Around("execution(* net.xdclass.controller.*.*(..))")可以
* 方式二:用@Pointcut和@Around联合注解也可以(我们采用这个)
* <p>
* <p>
* 两种方式
* 方式一:加锁 固定时间内不能重复提交
* <p>
* 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
*/
@Around("pointCutNoRepeatSubmit(noRepeatSubmit)")
public Object around(ProceedingJoinPoint joinPoint, RepeatSumbit noRepeatSubmit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
//用于记录成功或者失败
boolean res = false;
/**
* 防重提交类型
*/
String type = noRepeatSubmit.limitType().name();
if (type.equalsIgnoreCase(RepeatSumbit.Type.PARAM.name())) {
//方式1,参数形式防重提交 TODO
long lockTime = noRepeatSubmit.lockTime();
String ipAddr = CommonUtil.getIpAddr(request);
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
String className = method.getDeclaringClass().getName();
String key = "order-server:repeat_submit:"+CommonUtil.MD5(String.format("%s-%s-%s-%s", ipAddr, className, method, accountNo));
//加锁
// res = redisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
RLock lock = redissonClient.getLock(key);
// 尝试加锁,最多等待2秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义]
res = lock.tryLock(0, lockTime, TimeUnit.SECONDS);
} else {
//方式2,令牌形式防重提交 TODO
String requestToken = request.getHeader("request-token");
if (StringUtils.isBlank(requestToken)) {
throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
}
String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
/**
* 提交表单的token key,根据删除知道它是成功还是失败
* 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
* 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
*/
res = redisTemplate.delete(key);
}
if (!res) {
// throw new BizException(BizCodeEnum.ORDER_CONFIRM_REPEAT);
log.error("请求重复提交");
return null;
}
log.info("环绕通知执行前");
Object obj = joinPoint.proceed();
log.info("环绕通知执行后");
return obj;
}
}
防重提交业务流程
Token令牌校验
下单前获取一个token,使用一次后失效,不可重复使用,对业务有一定侵入性,需在下单业务获取token,并将token存储到页面中,提交订单时,连同token一并提交;
/**
* 下单前获取令牌用于防重提交
* @return
*/
@GetMapping("token")
public JsonData getOrderToken() {
long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
String token = CommonUtil.getStringNumRandom(32);
String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, token);
//令牌有效时间是30分钟
redisTemplate.opsForValue().set(key, String.valueOf(Thread.currentThread().getId()), 30, TimeUnit.MINUTES);
return JsonData.buildSuccess(token);
}
@Data
public class ConfirmOrderRequest {
/**
* 订单类型
*/
private Long productId;
/**
* 购买数量
*/
private Integer buyNum;
/**
* 终端类型
*/
private String clientType;
/**
* 支付类型,微信-银行-支付宝
*/
private String payType;
/**
* 订单总金额
*/
private BigDecimal totalAmount;
/**
* 订单实际支付价格
*/
private BigDecimal payAmount;
/**
* 防重令牌
*/
private String token;
/**
* 发票类型:0->不开发票;1->电子发票;2->纸质发票
*/
private String billType;
/**
* 发票抬头
*/
private String billHeader;
/**
* 发票内容
*/
private String billContent;
/**
* 发票收票人电话
*/
private String billReceiverPhone;
/**
* 发票收票人邮箱
*/
private String billReceiverEmail;
}
参数形式
均在通知中处理,根据方法名|ip|用户id生成摘要作为key,设置过期时间(防止重复提交时间)存储至redis,对业务无侵入。通过设置过期时间,防止在一定时间内重复提交。
@PostMapping("confirm")
// @RepeatSumbit(limitType=RepeatSumbit.Type.TOKEN)
public void confirmOrder(@RequestBody ConfirmOrderRequest orderRequest, HttpServletResponse response) {
JsonData jsonData = productOrderService.confirmOrder(orderRequest);
if (jsonData.getCode() == 0) {
//端类型
String clientType = orderRequest.getClientType();
//支付类型
String payType = orderRequest.getPayType();
//如果是支付宝支付,跳转网页,sdk除外
if (payType.equalsIgnoreCase(ProductOrderPayTypeEnum.ALI_PAY.name())) {
if (clientType.equalsIgnoreCase(ClientTypeEnum.PC.name())) {
CommonUtil.sendHtmlMessage(response, jsonData);
} else if (clientType.equalsIgnoreCase(ClientTypeEnum.APP.name())) {
} else if (clientType.equalsIgnoreCase(ClientTypeEnum.H5.name())) {
}
} else if (payType.equalsIgnoreCase(ProductOrderPayTypeEnum.WECHAT_PAY.name())) {
//微信支付
CommonUtil.sendJsonMessage(response, jsonData);
}
} else {
log.error("创建订单失败{}", jsonData.toString());
CommonUtil.sendJsonMessage(response, jsonData);
}
}