侧边栏壁纸
  • 累计撰写 49 篇文章
  • 累计创建 23 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

SpringBoot 实现美团 GTIS 防重提交

阿砖
2024-06-30 / 0 评论 / 0 点赞 / 357 阅读 / 9008 字

既然要实现美团 GTIS,所以要先来探究一下 美团的 GTIS 是个什么。 它是一个轻量的重复操作关卡系统,它能够确保在分布式环境中操作的唯一性。我们可以用它来间接保证每个操作的幂等性。它具有如下特点:

  • 高效:低延时,单个方法平均响应时间在 2ms 内,几乎不会对业务造成影响;

  • 可靠:提供降级策略,以应对外部存储引擎故障所造成的影响;提供应用鉴权,提供集群配置自定义,降低不同业务之间的干扰;

  • 简单:接入简捷方便,学习成本低。只需简单的配置,在代码中进行两个方法的调用即可完成所有的接入工作;

  • 灵活:提供多种接口参数、使用策略,以满足不同的业务需求。 以上是美团对于 GTIS 的解释。 美团对于生成全局唯一 ID 的存储采用的是阿里的 Tair实现的,这里不做考究。改为使用 Redis 作为全局唯一 ID 的存储。以下代码参考了 RuoYi-Vue-Plus 防重提代码,在原基础上简化而来。改 SpringBoot 版本为 2.6.x ,使用阿里的云原生应用脚手架搭建。

需要的 POM

	<properties>
		<java.version>1.8</java.version>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<spring-boot.version>2.6.13</spring-boot.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>
		
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-aop</artifactId>
		</dependency>

		<dependency>
			<groupId>cn.hutool</groupId>
			<artifactId>hutool-all</artifactId>
			<version>5.8.16</version>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
		</dependency>

	</dependencies>

准备一个注解 RepeatSubmit.java

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeUnit;

/**
 * 自定义注解防止表单重复提交
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {

	/**
	 * 间隔时间(ms),小于此时间视为重复提交
	 */
	int interval() default 5000;

	TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}

准备一个 ServletUtil

import javax.servlet.http.HttpServletRequest;

import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

public class ServletUtils {

	public static ServletRequestAttributes getRequestAttributes() {
		try {
			RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
			return (ServletRequestAttributes) attributes;
		} catch (Exception e) {
			return null;
		}
	}

	public static HttpServletRequest getRequest() {
		try {
			return getRequestAttributes().getRequest();
		} catch (Exception e) {
			return null;
		}
	}
}

准备 AOP 代码


import java.time.Duration;
import java.util.StringJoiner;

import javax.servlet.http.HttpServletRequest;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import com.runbrick.demo.annotation.RepeatSubmit;
import com.runbrick.demo.utils.ServletUtils;

import cn.hutool.crypto.SecureUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;

@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {

	@Autowired
	private RedisTemplate<String, Object> redisTemplate;

	private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();

	@Before("@annotation(repeatSubmit)")
	public void doBefore(JoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Exception {

		// 间隔时间
		long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());

		if (interval < 1000) {
			throw new Exception("重复提交间隔时间不能小于'1'秒");
		}

		HttpServletRequest request = ServletUtils.getRequest();

		// 获取请求参数
		String nowParams = argsArrayToString(joinPoint.getArgs());

		// request.getRequestURL() 返回的是全路径。
		// request.getRequestURI() 返回的是除去host(域名或者ip)部分的路径
		String url = request.getRequestURI();
		// TODO:这个 key 在业务中要根据每个人去判断,我这里是为了测试所以简化了代码
		String submitKey = SecureUtil.md5("RepeatKey" + ":" + nowParams);

		// 唯一标识(指定key + url + 消息头)
		String cacheRepeatKey = "repeat_submit:" + url + submitKey;

		if (Boolean.TRUE.equals(
				redisTemplate.opsForValue().setIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval)))) {
			KEY_CACHE.set(cacheRepeatKey);
		} else {
			throw new RuntimeException("请勿重复点击");
		}
	}

	/**
	 * 处理完请求后执行
	 *
	 * @param joinPoint 切点
	 */
	@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "result")
	public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object result) {
	// TODO:需要自行根据返回内容判断是否需要删除这个 key 值
//		redisTemplate.delete(KEY_CACHE.get());
//		KEY_CACHE.remove();
		log.info("最后返回的结果{}", result);
	}

	/**
	 * 拦截异常操作
	 *
	 * @param joinPoint 切点
	 * @param e         异常
	 */
	@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
	public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
	// TODO:如果出现了异常是需要清理的,这里为了演示并没有删除
//		redisTemplate.delete(KEY_CACHE.get());
//		KEY_CACHE.remove();
	}

	private String argsArrayToString(Object[] paramsArray) {
		StringJoiner params = new StringJoiner(" ");

		for (Object o : paramsArray) {
			// 去除文件判断
			params.add(JSONUtil.toJsonStr(o));
		}
		return params.toString();
	}
}

准备一个控制器

这个控制器在创建的时候就带着,加上 @RepeatSubmit 注解即可。

@Controller
public class BasicController {

	@RequestMapping("/hello")
	@ResponseBody
	@RepeatSubmit(interval = 30000)
	public String hello(@RequestParam(name = "name", defaultValue = "unknown user") String name) {
		return name;
	}
}

@RepeatSubmit 中 interval 的时间是 30000 ms ,是为了测试的时候更好观察 redis 客户端中的内容。如果不看可以自己调整一下时间。

启动服务测试下

访问 http://127.0.0.1:9802/hello?name=张三 之后第一次是成功的,如果连续点击就会出现下面的错误。过了设定的时间就又能访问了。

java.lang.RuntimeException: 请勿重复点击
	at com.runbrick.demo.aspect.RepeatSubmitAspect.doBefore(RepeatSubmitAspect.java:63) ~[classes/:na]
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_333]
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_333]
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_333]
	at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_333]
	...

以上就是基于 GTIS 实现的简单防重复提交的代码,如果需要生产去配置可以自行下载 RuoYi-Vue-Plus 去看下里面的代码逻辑。

注:

[1] 美团技术团队关于 GTIS 的介绍(这里面不止介绍了 GTIS,需要往下滑滑): https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html

[2] RuoYi-Vue-Plus 地址: https://github.com/dromara/RuoYi-Vue-Plus

0

评论区