问题:
开发中经常遇到因为某些原因导致接口重复提交,引发一系列的数据问题,因此在日常开发中必须规避这类的重复请求操作。
处理方式:
拦截器/AOP + Redis
处理思路:
在请求到达接口之前,判断当前用户是否在某个指定的时间周期内(比如5秒)已访问过此接口,如在时间内已访问此接口,则返回重复请求信息给用户。
那么如何确定当前接口已被当前用户访问了呢?
将用户信息+接口url+客户端IP地址+参数等等(结合项目实际情况自定义,一个或多个不同组合,保证唯一性)存入到Redis中,设置过期时间,在这个时间未过期以前,将会对此用户此接口进行阻断操作。
知识点:先了解一下拦截器在Spring Boot中如何自定义以及配置:
本文基于的Spring Boot的版本是2.3.0.RELEASE。
1、什么是拦截器:
Java⾥的拦截器是动态拦截Action调⽤的对象,它提供了⼀种机制可以使开发者在⼀个Action执⾏的前后执⾏⼀段代码,也可以在⼀个Action执⾏前阻⽌其执⾏,同时也提供了⼀种可以提取Action中可重⽤部分代码的⽅式。例如通过拦截器可以进行权限验证、记录请求信息的日志、判断用户是否登录等。
2、拦截器的实现方式:
AOP切面编程
Aspect Oriented Programming ,即面向切面编程。
AOP是对面向对象编程的一个补充。它的目的是将复杂的需求分解为不同的切面,将散布在系统中的公共功能集中解决。它的实际含义是在运行时将代码切入到类的指定方法、指定位置上,将不同方法的同一个位置抽象为一个切面对象,并对该对象进行编程。
核心:
- Aspect(切面):一个关注点的模块化,这个关注点可能会横切多个对象。事务管理是J2EE应用中一个关于横切关注点的很好的例子。在Spring AOP中,切面可以基于@Aspect注解的方式来实现。
- Joinpoint(连接点):在程序执行过程中某个特定的点,比如某方法调用的时候或者处理异常的时候。在Spring AOP中,一个连接点总是表示一个方法的执行。
- Advice(通知):在切面的某个特定的连接点上执行的动作。其中包括了“around”、“before”和“after”等不同类型的通知(通知的类型将在后面部分进行讨论)。许多AOP框架(包括Spring)都是以拦截器做通知模型,并维护一个以连接点为中心的拦截器链。
- Pointcut(切入点):匹配连接点的断言。通知和一个切入点表达式关联,并在满足这个切入点的连接点上运行(例如,当执行某个特定名称的方法时)。切入点表达式如何和连接点匹配是AOP的核心:Spring缺省使用AspectJ切入点语法。
- Introduction(引入):用来给一个类型声明额外的方法或属性(也被称为连接类型声明(inter-type declaration))。Spring允许引入新的接口(以及一个对应的实现)到任何被代理的对象。例如,你可以使用引入来使一个bean实现IsModified接口,以便简化缓存机制。
- Target Object(目标对象): 被一个或者多个切面所通知的对象。也被称做被通知(advised)对象。 既然Spring AOP是通过运行时代理实现的,这个对象永远是一个被代理(proxied)对象。
- Weaving(织入):把切面连接到其它的应用程序类型或者对象上,并创建一个被通知的对象。这些可以在编译时(例如使用AspectJ编译器),类加载时和运行时完成。Spring和其他纯Java AOP框架一样,在运行时完成织入。
通知方式
- 前置通知(Before advice):在某连接点之前执行的通知,但这个通知不能阻止连接点之前的执行流程(除非它抛出一个异常)。
- 后置通知(After returning advice):在某连接点正常完成后执行的通知:例如,一个方法没有抛出任何异常,正常返回。
- 异常通知(After throwing advice):在方法抛出异常退出时执行的通知。
- 最终通知(After (finally) advice):当某连接点退出的时候执行的通知(不论是正常返回还是异常退出)。
- 环绕通知(Around Advice):包围一个连接点的通知,如方法调用。这是最强大的一种通知类型。环绕通知可以在方法调用前后完成自定义的行为。它也会选择是否继续执行连接点或直接返回它自己的返回值或抛出异常来结束执行。
实现WebMvcConfigurer接⼝
WebMvcConfigurer配置类其实是Spring内部的⼀种配置⽅式,采⽤JavaBean的形式来代替传统的xml配置⽂件形式进⾏针对框架个性化定制,可以⾃定义⼀些Handler,Interceptor,ViewResolver,MessageConverter。
基于Java-based⽅式的spring mvc配置,需要创建⼀个配置类并实现WebMvcConfigurer 接⼝。在Spring Boot 1.5版本都是靠重写WebMvcConfigurerAdapter的⽅法来添加⾃定义拦截器,消息转换器等。SpringBoot 2.0 后,该类被标记为@Deprecated(弃⽤)。官⽅推荐直接实现WebMvcConfigurer或者直接继承WebMvcConfigurationSupport。
简单介绍⼀下WebMvcConfigurer中⽐较重要的⼏个⽅法:
- addInterceptors:添加拦截器
- addCorsMappings:跨域
- addViewControllers:页⾯跳转(不⽤像现在我们要写⼀个Controller进⾏映射就可实现跳转)
- addResourceHandlers:静态资源(⾃定义静态资源映射⽬录)
- configureDefaultServletHandling:默认静态资源处理器
- configureViewResolvers:视图解析器(配置请求视图映射,配置了以后我们返回⼀个页⾯路径的字符串时,解析器会帮我们拼装前缀和后缀等信息)
- configureMessageConverters:信息转换器(⽐如我们⼊参的信息直接转换成json或者转换成对应的bean类就具体在这⾥配置)
这⾥我们主要应⽤的是前两个⽅法,添加拦截器和跨域配置。
如何自定义拦截器:
自定义一个拦截器非常简单,只需要实现HandlerInterceptor
这个接口即可,该接口有三个可以实现的方法,如下:
-
preHandle()
方法:该方法会在控制器方法前执行,其返回值表示是否知道如何写一个接口。中断后续操作。当其返回值为true
时,表示继续向下执行;当其返回值为false
时,会中断后续的所有操作(包括调用下一个拦截器和控制器类中的方法执行等)。 -
postHandle()
方法:该方法会在控制器方法调用之后,且解析视图之前执行。可以通过此方法对请求域中的模型和视图做出进一步的修改。 -
afterCompletion()
方法:该方法会在整个请求完成,即视图渲染结束之后执行。可以通过此方法实现一些资源清理、记录日志信息等工作。
处理流程:
1、创建自定义注解,命名为Idempotency,添加@Target注解,设置作用域为类和方法,添加时间参数,单位为秒,默认为5秒,引用时的配置可覆盖此默认值,如下:
package com.wanganui.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author xtwang
* @Description 防止重复提交自定义注解(保证接口幂等性)
* @Date 2022/5/26 14:48
**/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotency {
/**
* 默认接口请求间隔(5秒)
*
* @return
*/
long seconds() default 5;
}
AOP方式:
创建IdempotencyAspect切面类,采用@Around+@Pointcut,@Pointcut指定切入点作用域,只作用添加了@Idempotency注解的方法或类,如下所示:
package com.wanganui.aspect;
import cn.hutool.extra.servlet.ServletUtil;
import com.wanganui.annotation.Idempotency;
import com.wanganui.constant.RedisConstant;
import com.wanganui.exception.RepeatSubmitException;
import com.wanganui.utils.SecurityUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* @author xtwang
* @Description Aop幂等性拦截器
* @Date 2022/5/26 16:22
**/
@Slf4j
@Aspect
@Component
public class IdempotencyAspect {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Pointcut("@annotation(com.wanganui.annotation.Idempotency)")
public void pointCut() {
}
@Around("@annotation(com.wanganui.annotation.Idempotency)")
public Object checkRequest(ProceedingJoinPoint joinPoint) {
try {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
// 标注在方法上的@Idempotency
Idempotency repeatSubmitByMethod = AnnotationUtils.findAnnotation(methodSignature.getMethod(), Idempotency.class);
// 标注在controler类上的@Idempotency
Idempotency repeatSubmitByCls = AnnotationUtils.findAnnotation(methodSignature.getMethod().getDeclaringClass(), Idempotency.class);
// 只处理添加了注解的请求
if (!Objects.isNull(repeatSubmitByMethod) || !Objects.isNull(repeatSubmitByCls)) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 组合判断条件(userId + clientIp + url)
// 请求的URI
String uri = request.getRequestURI();
// 请求的IP
String clientIp = ServletUtil.getClientIP(request);
// 当前subject
Long userId = SecurityUtil.getSecurityUser().getUserId();
// 构建redis key
String redisKey = RedisConstant.REPATE_SUBMIT_KEY + RedisConstant.KEY_SPLIT_CHAR + userId + RedisConstant.KEY_SPLIT_CHAR + clientIp + uri;
// 检查redis是否存在当前记录,存在即返回false,不存在即返回true
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, "", Objects.nonNull(repeatSubmitByMethod) ? repeatSubmitByMethod.seconds() : repeatSubmitByCls.seconds(), TimeUnit.SECONDS);
//如果存在,表示已经请求过了,直接抛出异常,由全局异常进行处理返回指定信息
if (ifAbsent != null && !ifAbsent) {
throw new RepeatSubmitException("请勿重复提交!");
}
}
// 继续执行后续操作
return joinPoint.proceed();
} catch (Exception e) {
throw new RepeatSubmitException(e.getMessage());
} catch (Throwable throwable) {
throw new SecurityException(throwable);
}
}
}
拦截器方式:
1、创建IdempotencyInterceptor拦截器实现HandlerInterceptor类的preHandle()方法,如下所示:
package com.wanganui.Interceptor;
import cn.hutool.extra.servlet.ServletUtil;
import com.wanganui.annotation.Idempotency;
import com.wanganui.constant.RedisConstant;
import com.wanganui.exception.RepeatSubmitException;
import com.wanganui.utils.SecurityUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* @author xtwang
* @Description 接口幂等性拦截器
* @Date 2022/5/26 14:53
**/
@Component
public class IdempotencyInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
// 只拦截标注了@Idempotency注解的请求
HandlerMethod method = (HandlerMethod) handler;
// 标注在方法上的@Idempotency
Idempotency repeatSubmitByMethod = AnnotationUtils.findAnnotation(method.getMethod(), Idempotency.class);
// 标注在controler类上的@Idempotency
Idempotency repeatSubmitByCls = AnnotationUtils.findAnnotation(method.getMethod().getDeclaringClass(), Idempotency.class);
// 没有限制重复提交,直接跳过
if (Objects.isNull(repeatSubmitByMethod) && Objects.isNull(repeatSubmitByCls)) {
return true;
}
// 组合判断条件(userId + clientIp + url)
// 请求的URI
String uri = request.getRequestURI();
// 请求的IP
String clientIp = ServletUtil.getClientIP(request);
// 当前subject
Long userId = SecurityUtil.getSecurityUser().getUserId();
// 构建redis key
String redisKey = RedisConstant.REPATE_SUBMIT_KEY + RedisConstant.KEY_SPLIT_CHAR + userId + RedisConstant.KEY_SPLIT_CHAR + clientIp + uri;
// 检查redis是否存在当前记录,存在即返回false,不存在即返回true
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, "", Objects.nonNull(repeatSubmitByMethod) ? repeatSubmitByMethod.seconds() : repeatSubmitByCls.seconds(), TimeUnit.SECONDS);
//如果存在,表示已经请求过了,直接抛出异常,由全局异常进行处理返回指定信息
if (ifAbsent != null && !ifAbsent) {
throw new RepeatSubmitException("请勿重复提交!");
}
}
return true;
}
}
2、创建WebConfig类,添加@Configuration注解,并实现WebMvcConfigurer类下的addCorsMappings()方法和addInterceptors()方法,注入拦截器。如下所示:
package com.wanganui.config;
import com.wanganui.Interceptor.IdempotencyInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author xtwang
* @Description web配置
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private IdempotencyInterceptor idempotencyInterceptor;
/**
* 处理跨域请求
*
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "OPTIONS", "DELETE", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
/**
* 配置拦截器
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(idempotencyInterceptor);
}
}
说明:两种方式实现逻辑基本相同,如何选择请结合项目实际情况。
测试:
1、新建一个Controller,编写一个简单的测试接口,接口上添加上面创建的@Idempotency注解,设置间隔时间为10秒,如下所示:
package com.wanganui.controller.user;
import com.wanganui.annotation.Idempotency;
import com.wanganui.baseModel.APIResult;
import com.wanganui.service.user.UserInfoService;
import com.wanganui.utils.SecurityUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户controller
*
* @author xtwang
*/
@RestController
@RequestMapping("/admin-user")
public class UserController {
@Autowired
private UserInfoService userInfoService;
@Idempotency(seconds = 10)
@PostMapping("/test")
public APIResult test() {
return APIResult.success("ok", SecurityUtil.getSecurityUser());
}
}
2、打开接口测试工具,以Postman为例,输入接口地址,多次点击请求,如下所示:
第一次请求接口,返回正常信息,后续请求如下所示:
如出现此消息则代码功能正常。
结论:
以上为解决接口幂等性的几种方式,当然还有前端按钮禁用、接口请求版本号、数据库控制等多种手段,此处就不一一讲解,如有不懂可以联系[email protected]