重复提交是一个很令人头疼的问题,就算是用户没有恶意,当网络不稳定的时候,用户发表一篇博文或者在注册的时候,数据提交到后台,已入库了,但是前台收不到成功的消息,导致用户重复提交导致库中存在两份甚至多份相同的数据,这不是我们希望看到的,所以有了重复提交拦截。
导入jedis依赖:(用于实现分布式锁的依赖)
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency>
它的版本是基于你的Springboot版本的,当然你也可以基于自己的需要,导入自定义的版本。
!!!!注意: 这里需要说明的一点就是如果你的项目中导入了redis的依赖,需要将lettuce排除掉,使用jedis的依赖,否则会报异常,!!!很重要,本人就是在这里踩坑了:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> </exclusion> </exclusions> </dependency>
这里就有人有疑问了,你不是说用redis实现吗,怎么现在又来扯jedis了,这不是扯淡吗?他们之间有什么关系呢?这里推荐一篇博文,描述的还是可以的:https://zhuanlan.zhihu.com/p/134682772
ok,直接代码实现(仅用于参考):
package com.driven.design.nosql; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisCluster; import redis.clients.jedis.commands.JedisCommands; import redis.clients.jedis.params.SetParams; import javax.annotation.Resource; import java.util.ArrayList; import java.util.List; /** * @author driver-design * @description redis lock * @date 2022/3/18 */ @Slf4j @Component public class RedisDistributedLock { @Resource private RedisTemplate<String, Object> redisTemplate; public static final String UNLOCK_LUA; static { StringBuilder sb = new StringBuilder(); sb.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] "); sb.append("then "); sb.append(" return redis.call(\"del\",KEYS[1]) "); sb.append("else "); sb.append(" return 0 "); sb.append("end "); UNLOCK_LUA = sb.toString(); } /** * set * @param key * @param clientId * @param expire 秒 * @return */ public boolean setLock(String key, String clientId, long expire) { try { RedisCallback<String> callback = (connection) -> { JedisCommands commands = (JedisCommands) connection.getNativeConnection(); SetParams setParams = new SetParams(); setParams.nx(); setParams.ex(expire); return commands.set(key, clientId, setParams); }; String result = redisTemplate.execute(callback); return !StringUtils.isEmpty(result); } catch (Exception e) { log.error("set redis occured an exception", e); } return false; } public String get(String key) { try { RedisCallback<String> callback = (connection) -> { JedisCommands commands = (JedisCommands) connection.getNativeConnection(); return commands.get(key); }; String result = redisTemplate.execute(callback); return result; } catch (Exception e) { log.error("get redis occured an exception", e); } return ""; } /** * 释放锁 * @param key * @param requestId * @return */ public boolean releaseLock(String key, String requestId) { // 释放锁的时候,有可能由于持锁以后方法执行时间大于锁的有效期,此时有可能已经被另一个线程持有锁,因此不能直接删除 try { List<String> keys = new ArrayList<>(); keys.add(key); List<String> args = new ArrayList<>(); args.add(requestId); // 使用lua脚本删除redis中匹配value的key,能够避免因为方法执行时间过长而redis锁自动过时失效的时候误删其余线程的锁 // spring自带的执行脚本方法中,集群模式直接抛出不支持执行脚本的异常,因此只能拿到原redis的connection来执行脚本 RedisCallback<Long> callback = (connection) -> { Object nativeConnection = connection.getNativeConnection(); // 集群模式和单机模式虽然执行脚本的方法同样,可是没有共同的接口,因此只能分开执行 if (nativeConnection instanceof JedisCluster) { // 集群模式 return (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA, keys, args); } else if (nativeConnection instanceof Jedis) { // 单机模式 return (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, args); } return 0L; }; Long result = redisTemplate.execute(callback); return result != null && result > 0; } catch (Exception e) { log.error("release lock occured an exception", e); } finally { // 清除掉ThreadLocal中的数据,避免内存溢出 //lockFlag.remove(); } return false; } }
实现我们需要导入切面的pom依赖:
<dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
声明annotation
package com.driven.design.annotation; import java.lang.annotation.*; /** * @author zte * @description 防止重复提交标记注解 * @version: * @since 2022/3/18 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface NoRepeatSubmit { /** * 描述信息 * @return */ String desc() default ""; }
切面实现:
@Component @Aspect @Slf4j public class NoRepeatSubmitAspect { /** * 登录路径 */ private static final String LOGIN_PATH = "/auth/login"; /** * 注册路径 */ private static final String REGISTER_PATH = "/auth/register"; @Pointcut("@annotation(com.driven.design.annotation.NoRepeatSubmit)") public void repeatSubmit(){} @Value("${jwt.header}") private String tokenHeader; @Autowired private HttpServletRequest request; @Autowired private RedisDistributedLock redisLock; @Around("repeatSubmit()") public Object around(ProceedingJoinPoint joinPoint) { // 获取签名 MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // 获取注解信息 NoRepeatSubmit noRepeatSubmit = signature.getMethod().getAnnotation(NoRepeatSubmit.class); // 获取自定义desc信息 String desc = noRepeatSubmit.desc(); String token = request.getHeader(tokenHeader); // 获取请求路径 String path = request.getServletPath(); String key; // 对于注册登录,需要特殊处理 if (LOGIN_PATH.equals(path)) { // 通过登录用户名做检验 String userName = StringUtils.isNotEmpty(request.getParameter("username")) ? request.getParameter("username") : ""; key = getKey(userName, path); } else if (REGISTER_PATH.equals(path)) { String userName = StringUtils.isNotEmpty(request.getParameter("userName")) ? request.getParameter("userName") : ""; key = getKey(userName, path); } else { key = getKey(token, path); } String clientId = getClientId(); boolean isSuccess = redisLock.setLock(key, clientId, 3L); // 如果缓存中有这个url视为重复提交 if (isSuccess) { Object result = null; try { result = joinPoint.proceed(); } catch (Throwable e) { log.error(e.getMessage()); } // finally { // redisLock.releaseLock(key, clientId); // log.info("releaseLock success, key = [{}], clientId = [{}]", key, clientId); // } return result; } else { log.error("重复提交拦截"); if (StringUtils.isNotEmpty(desc)) { return ResultDesign.fail(desc); } else { return ResultDesign.fail(ResultEnum.REPEAT_SUBMIT); } } } private String getKey(String token, String path) { return token + path; } private String getClientId() { return UUID.randomUUID().toString(); } }
其中关于登录注册此类不需要token的验证在注释中已完成了讲解,大家可以关注一下注释内容,到这里我们的切面就实现了,接下里就是通过注解来实现重复提交的验证了:
/** * 用户登录 * @param username * @param password * @return */ @ResultHandler @NoRepeatSubmit(desc = "请勿重复提交") @GetMapping("/login") public ResultDesign<UserDetailDTO> login(@RequestParam(value = "password") String password, @RequestParam(value = "username") String username){ UserLoginQuery userLoginQuery = new UserLoginQuery(); userLoginQuery.setUsername(username); userLoginQuery.setPassword(password); UserDetailDTO userDetailDTO = userService.login(userLoginQuery); return ResultDesign.success(userDetailDTO); } /** * 用户注册 * @param registerUserCommand * @return */ @ResultHandler @NoRepeatSubmit @PostMapping("/register") public ResultDesign<Boolean> register(@RequestBody RegisterUserCommand registerUserCommand) throws Exception { userService.register(registerUserCommand); return ResultDesign.success(Boolean.TRUE); } /** * 新增Topic * @param command * @return * @throws DrivenDesignException */ @ResultHandler @NoRepeatSubmit @PostMapping("/add") public ResultDesign<Boolean> addTopic(@Valid @RequestBody TopicAddCommand command) throws DrivenDesignException { topicService.addTopic(command); return ResultDesign.success(); }
效果演示: