五、从零开始-接口幂等处理
对接口幂等问题进行处理
导航
上一章地址:四、从零开始-优化用户管理新增
下一章地址:
说明
- 贴代码时会贴整个类的全量代码,防止找不到我改的是哪个
- 创建的类在哪个包下不会单独说明,因为全量代码里有 package 直接看这个就可以了
- gitee 代码存放地址 https://gitee.com/mxw13579/study-scaffold 可以直接下下来看更加清晰
五、从零开始-接口幂等处理
上一章遗留了一个问题,对多参数校验该怎么处理呢
上一章也忘记说明了
/**
* @param account
* @return java.lang.Boolean
* @Description 校验用户名是否存在
* @author lizelin
* @date 2023-10-13 17:46
**/
@Override
public Integer verifyAccount(String account, HttpServletRequest httpServletRequest) {
//获取客户端 IP
String clientIP = ServletUtil.getClientIP(httpServletRequest);
//计数 key
String countKey = CharSequenceUtil.format(RedisKeyConstant.SYS_USER_SERVICE_IMPL_VERIFY_ACCOUNT_COUNT, clientIP);
Integer redisCount = (Integer) redisTemplate.opsForValue().get(countKey);
if (redisCount == null) {
//为空则初始化,10 分钟有效期
redisTemplate.opsForValue().set(countKey, 0, 10 * 60L, TimeUnit.SECONDS);
}
if (redisCount != null && redisCount >= 10) {
//抛出频繁异常
throw new CustomException(ResultExceptionStateEnum.VERIFY_ACCOUNT_FREQUENTLY_EXCEPTION.getCodeStr());
}
int count = count(Wrappers.lambdaQuery(SysUserEntity.class).eq(SysUserEntity::getAccount, account));
if (count != 0) {
//抛出账号重复异常
throw new CustomException(ResultExceptionStateEnum.VERIFY_ACCOUNT_REPEAT_EXCEPTION.getCodeStr());
}
//随机数 key
String randomCodeKey = CharSequenceUtil.format(RedisKeyConstant.SYS_USER_SERVICE_IMPL_VERIFY_ACCOUNT_RANDOM_CODE, clientIP, account);
Integer randomInt = RandomUtil.randomInt(IntegerCommonConstant.VERIFY_ACCOUNT_RANDOM_CODE_MIN, IntegerCommonConstant.VERIFY_ACCOUNT_RANDOM_CODE_MAX);
//写入随机数 10 分钟有效期
redisTemplate.opsForValue().set(randomCodeKey, randomInt, 10 * 60L, TimeUnit.SECONDS);
//计数加一
redisTemplate.opsForValue().increment(countKey);
return randomInt;
}
这段代码中,为什么要计数,这个主要是为了防止,有人在频繁去刷校验用户名是否存在的接口,这样就可以通过遍历的方式,拿到系统中存在多少个用户
5.1、多参数校验下发校验码问题
我们先看一下建表语句
CREATE TABLE `t_sys_user` (
`id` bigint DEFAULT NULL COMMENT '主键',
`name` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '姓名',
`password` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '密码',
`phone` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号',
`account` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '登录账号',
`sex` int DEFAULT NULL COMMENT '性别,0女1男2未知',
`id_card` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '身份证号码',
`email` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '邮箱',
`dept_id` bigint DEFAULT NULL COMMENT '部门ID,不允许多部门',
`user_post_ids` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '岗位ID集合,允许多岗位',
`role_ids` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '角色ID集合,允许多角色',
`user_status` int DEFAULT NULL COMMENT '用户状态 0为启用1为禁用',
`parent_id` bigint DEFAULT NULL COMMENT '父部门ID',
`password_update_data` datetime DEFAULT NULL COMMENT '最后密码修改时间',
`login_date` datetime DEFAULT NULL COMMENT '最后登录时间',
`login_ip` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最后登录IP',
`remark` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '备注',
`tenant_id` int DEFAULT NULL COMMENT '租户号',
`revision` int DEFAULT NULL COMMENT '乐观锁',
`created_by` bigint DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`updated_by` bigint DEFAULT NULL COMMENT '更新人',
`updated_time` datetime DEFAULT NULL COMMENT '更新时间',
UNIQUE KEY `account_index` (`account`) USING BTREE COMMENT '账号索引',
UNIQUE KEY `phone_index` (`phone`) USING BTREE COMMENT '手机号索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户表';
这里我们可以看到一共是两个唯一索引,一个是 账号,一个是手机号,也就是需要做多参数校验
那么我们来分析一下场景需要考虑的问题
- 这样子的多参数校验场景是否多,或者说需要提前告知用户这个重复的场景是否多
- 我们这个例子是两个字段,如果是三个字段呢,五个字段呢,十个字段呢,是否需要全部都提前告知用户
- 本来正常的写入是一个写入操作,现在是两个查询一个写入操作,后面可能会变成五个查询、十个查询,相对来讲这样肯定是会浪费服务器资源的,我们是不是该这样做
那我们就来展开讲讲
第一点,这种不允许重复的场景肯定是不多的,因为一般实际上存人的才需要这种输入数据不允许重复,其他的基本上场景都会很少,也就是说这个不需要考虑复用问题,我们可以直接拿两个字段去存两个参数的校验码就可以了。
第二点和第三点实际上是同一件事,因为比如说这些存人的信息,假设有五个字段不允许重复,我们如果改成需要五个校验码,虽然是前端直接自动发起的,不需要用户来点击,但是这样吃服务器性能,如果要有的话,肯定在系统中不能出现太多的。
并且我们来想一下,用户名需要校验这个很正常,因为可能人和人的起名习惯是相近的,但是手机号和手机号重复的概率大嘛?
这个不排除按错了这种情况,但是这种情况会有多少呢?我们需不需要为了这种概率而专门加一个校验手机号是否重复的接口呢
那个这个肯定是不需要的对吧,用户名是重复的概率和手机号重复的概率完全就不是同一个量级的,所以给用户名做校验接口很合理,但是给手机号做就不太合理了
所以总的来说
我们如果将所有的数据库唯一索引的地方都提前对用户进行提示的话,那么这个接口的性能就会大幅度降低,所以我们会舍弃掉概率较小的校验,让他直接打到数据库去,在数据库的唯一索引的地方进行判断,只保留部分重复率比较高的字段校验,在性能和用户体验上进行平衡。
5.2、接口幂等问题处理
这个的话实际上做法与上面这个前置校验的做法大致一致,防止用户重复提交或者是新增接口被人攻击,而新增用户接口的话,由于存在前置校验的操作,所以就不需要在幂等问题上在进行处理了,因为说白了思路都是拿个校验码,然后你携带了校验码才允许你操作,而前置校验正好也做了这件事
5.2.1、为什么要做接口幂等
首先我们得看下什么是幂等
假设说我们现在什么都没做就是新建一个项目一个 controller 然后一个前端的组成
然后用户在新增数据的时候,狂点提交,这种情况下,你有办法去分辨这一系列数据是重复数据吗?
当然如果你是用户数据的话,由于存在唯一索引的情况,所以只能提交成功一条,这样还好,但是如果你这个业务压根就没有唯一索引呢,提交过来了你怎么区分呢?
或者是说电商的下订单操作吧,你怎么区分他到底下一次单还是两次单呢?即使是收货地址、商品、数量完全一致,你也没办法区分,但是有没有可能是用户那边设备有问题点一次相当于点两次呢?
5.2.2、怎么做接口幂等
首先前端提交按钮点了之后短时间之内不能点第二次,这个前端好像是有工具的很容易设置
我们来想一下后端应该如何做
为了防止这种情况发送,我们可以做一个接口来提前声明我准备发起下单操作然后生成一个 token 然后通过这个 token 来请求下单接口,如果携带的 token 重复,那么也就意味着请求重复
到这里就可以看出来和上面的前置校验的思路基本上一致了
由于这个不涉及到对参数进行校验是否重复,并且需要保证幂等的地方远比需要前置校验的多,这个就肯定得提出来做公共处理了
5.2.3、实现接口幂等思路
这里实现的话,两种思路,第一个是 AOP 进行切面处理,前置校验是否存在参数,第二个思路是直接走拦截器,在 servlet 反射找执行方法的时候,判断这个方法上面有没有注解,有的话则看请求头中是否存在
我们采用第二种方式来实现,别问为什么,问就是性能
5.3、实现解密幂等
因为我们现在只有一个新增接口,而新增接口之前已经做了前置校验了,所以我们用查询接口来测试也是一样的,当然实际情况是根据需求打到相应的新增修改接口上面去,删除和查询自带幂等的,我们只是看能不能做到拦截
5.3.1、自定义注解
package com.lzl.study.scaffold.studyscaffold.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @ClassName IdempotentAnnotation
* @Author lizelin
* @Description 幂等性校验注解
* @Date 2023-10-15 20:28
* @Version 1.0
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IdempotentAnnotation {
}
先定义一个空的注解,方便标记哪些需要幂等
5.3.2、拦截器
package com.lzl.study.scaffold.studyscaffold.common.web;
import cn.hutool.core.text.CharSequenceUtil;
import com.lzl.study.scaffold.studyscaffold.common.annotation.IdempotentAnnotation;
import com.lzl.study.scaffold.studyscaffold.common.constant.RedisKeyConstant;
import com.lzl.study.scaffold.studyscaffold.common.constant.StringCommonConstant;
import com.lzl.study.scaffold.studyscaffold.common.exception.CustomException;
import com.lzl.study.scaffold.studyscaffold.common.state.ResultExceptionStateEnum;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
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.lang.reflect.Method;
/**
* @ClassName IdemptentInterceptor
* @Author lizelin
* @Description 幂等拦截器
* @Date 2023-10-15 20:29
* @Version 1.0
*/
@Component
@AllArgsConstructor
@Slf4j
public class IdempotentInterceptor implements HandlerInterceptor {
private final RedisTemplate<String, Object> redisTemplate;
/**
* @param request
* @param response
* @param handler
* @return boolean
* @Description 前置操作
* @author lizelin
* @date 2023-10-15 20:46
**/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
IdempotentAnnotation annotation = method.getAnnotation(IdempotentAnnotation.class);
if (annotation != null) {
String idempotentToken = request.getHeader(StringCommonConstant.IDEMPOTENT_HEADER_KEY);
if (CharSequenceUtil.isEmpty(idempotentToken)) {
//为空则代表非法请求,因为正常情况下是不会不带幂等令牌进行请求的
throw new CustomException(ResultExceptionStateEnum.IDEMPOTENT_TOKEN_IS_NULL.getCodeStr());
}
//直接删除 key 如果 key 不存在则会返回 false 代表重复请求或令牌失效或别人乱写的
Boolean delete = redisTemplate.delete(CharSequenceUtil.format(RedisKeyConstant.IDEMPOTENT_TOKEN_KEY, idempotentToken));
if (Boolean.FALSE.equals(delete)) {
//重复请求则抛出
throw new CustomException(ResultExceptionStateEnum.IDEMPOTENT_TOKEN_IS_REPEAT.getCodeStr());
}
}
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
}
对请求进行校验,判断请求的方法上面有没有幂等接口,如果存在则进行校验操作
这里校验是否正确的操作采用的是直接删除的方式,因为查询校验然后删除的话需要两步操作,而直接删除只需要一步,如果 KEY 存在会返回 true,不存在返回 false
5.3.3、修改常量文件
package com.lzl.study.scaffold.studyscaffold.common.constant;
/**
* @ClassName RedisKeyConstant
* @Author lizelin
* @Description redis key 常量
* @Date 2023-10-13 18:12
* @Version 1.0
*/
public class RedisKeyConstant {
//......省略
/**
* 幂等令牌 KEY
*/
public static final String IDEMPOTENT_TOKEN_KEY = "idempotent:token:key:{}";
}
修改 RedisKey 常量
package com.lzl.study.scaffold.studyscaffold.common.constant;
/**
* @ClassName ConmmonEnum
* @Author lizelin
* @Description String 类型常用 常量
* @Date 2023-10-10 16:46
* @Version 1.0
*/
public class StringCommonConstant {
//......省略
/**
* 幂等的头
*/
public static final String IDEMPOTENT_HEADER_KEY = "idempotentToken";
}
修改 String 类型常用 常量
package com.lzl.study.scaffold.studyscaffold.common.state;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName ResultErrorStateEnum
* @Author lizelin
* @Description 异常结果枚举
* @Date 2023-10-13 18:41
* @Version 1.0
*/
public enum ResultExceptionStateEnum {
//......省略
IDEMPOTENT_TOKEN_IS_NULL(2002, "非法参数请求", "幂等令牌为空异常"),
IDEMPOTENT_TOKEN_IS_REPEAT(2003, "重复请求或等待时间过长,请稍后再试", "幂等令牌重复异常"),
METHOD_ARGUMENT_NOTVALID_EXCEPTION(2001, "字段:{},{}", "参数绑定异常");
private static Map<Integer, ResultExceptionStateEnum> resultExceptionStateEnumHashMap = new HashMap<>(64);
/**
* 启动时缓存方便取 枚举
*/
static {
//......省略
resultExceptionStateEnumHashMap.put(IDEMPOTENT_TOKEN_IS_NULL.getCode(), IDEMPOTENT_TOKEN_IS_NULL);
resultExceptionStateEnumHashMap.put(IDEMPOTENT_TOKEN_IS_REPEAT.getCode(), IDEMPOTENT_TOKEN_IS_REPEAT);
}
//......省略
}
5.3.4、Web Mvc 配置
package com.lzl.study.scaffold.studyscaffold.common.web;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @ClassName WebMvcConfg
* @Author lizelin
* @Description Web mvc 配置器
* @Date 2023-10-15 20:55
* @Version 1.0
*/
@Configuration
@AllArgsConstructor
public class WebMvcConfg implements WebMvcConfigurer {
private final IdempotentInterceptor idempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注入幂等拦截器
registry.addInterceptor(idempotentInterceptor);
WebMvcConfigurer.super.addInterceptors(registry);
}
}
将拦截器注入进去
5.3.5、获取幂等令牌接口
5.3.5.1、controller
package com.lzl.study.scaffold.studyscaffold.idempotent.controller;
import com.lzl.study.scaffold.studyscaffold.common.entity.R;
import com.lzl.study.scaffold.studyscaffold.idempotent.service.IdempotentService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @ClassName IdempotentController
* @Author lizelin
* @Description 幂等 controller
* @Date 2023-10-15 21:13
* @Version 1.0
*/
@Slf4j
@RestController
@RequestMapping("idempotent")
@AllArgsConstructor
public class IdempotentController {
private final IdempotentService idempotentService;
/**
* @return com.lzl.study.scaffold.studyscaffold.common.entity.R
* @Description 获取幂等令牌
* @author lizelin
* @date 2023-10-15 21:15
**/
@GetMapping("getIdempotentToken")
public R getIdempotentToken() {
return R.success(idempotentService.getIdempotentToken());
}
}
5.3.5.2、service
package com.lzl.study.scaffold.studyscaffold.idempotent.service;
/**
* @ClassName IdempotentService
* @Author lizelin
* @Description 幂等 service
* @Date 2023-10-15 21:13
* @Version 1.0
*/
public interface IdempotentService {
/**
* @return java.lang.String
* @Description 获取幂等令牌
* @author lizelin
* @date 2023-10-15 21:16
**/
String getIdempotentToken();
}
5.3.5.3、实现类
package com.lzl.study.scaffold.studyscaffold.idempotent.service.impl;
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.text.CharSequenceUtil;
import com.lzl.study.scaffold.studyscaffold.common.constant.RedisKeyConstant;
import com.lzl.study.scaffold.studyscaffold.idempotent.service.IdempotentService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* @ClassName IdempotentServiceImpl
* @Author lizelin
* @Description 幂等 实现类
* @Date 2023-10-15 21:16
* @Version 1.0
*/
@Slf4j
@AllArgsConstructor
@Service
public class IdempotentServiceImpl implements IdempotentService {
private final RedisTemplate<String, Object> redisTemplate;
private final Snowflake snowflake;
/**
* @return java.lang.String
* @Description 获取幂等令牌
* @author lizelin
* @date 2023-10-15 21:39
**/
@Override
public String getIdempotentToken() {
String idempotentToken = snowflake.nextIdStr();
redisTemplate.opsForValue().set(CharSequenceUtil.format(RedisKeyConstant.IDEMPOTENT_TOKEN_KEY, idempotentToken), 1, 10 * 60L, TimeUnit.SECONDS);
return idempotentToken;
}
}
5.3.6、修改测试接口
package com.lzl.study.scaffold.studyscaffold.user.controller;
import com.lzl.study.scaffold.studyscaffold.common.annotation.IdempotentAnnotation;
import com.lzl.study.scaffold.studyscaffold.common.entity.R;
import com.lzl.study.scaffold.studyscaffold.user.entity.dto.SysUserPageDto;
import com.lzl.study.scaffold.studyscaffold.user.entity.dto.SysUserSaveDto;
import com.lzl.study.scaffold.studyscaffold.user.service.SysUserService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
/**
* @ClassName SysUserController
* @Author lizelin
* @Description 用户管理 controller 层
* @Date 2023-10-09 20:00
* @Version 1.0
*/
@Slf4j
@RestController
@RequestMapping("sysUser")
@AllArgsConstructor
public class SysUserController {
private final SysUserService sysUserService;
/**
* @param dto
* @return com.baomidou.mybatisplus.extension.plugins.pagination.Page
* @Description 分页查询
* @author lizelin
* @date 2023-10-10 16:35
**/
@GetMapping("/page")
@IdempotentAnnotation
public R page(SysUserPageDto dto) {
return R.success(sysUserService.dtoPage(dto));
}
//......省略
}
由于没有其他的新增类,实际上测试查询类效果也一样,只要看是不是一个 token 一个请求就可以了
5.3.7、测试
测试一下效果如何
5.3.7.1、幂等接口没有幂等 token,抛出异常
5.3.7.2、幂等接口有幂等 token 但是错误,抛出异常
5.3.7.4、幂等接口有幂等 Token 且 Token 正确
首先获取 Token
然后请求接口
然后同样 Token再次请求,抛出异常
5.3.7.5、非幂等接口
直接请求,无问题
使用重复 Token ,无问题
测试也就完成了,效果非常好