当前位置:   article > 正文

【SpringBoot】基于自定义注解实现接口幂等性方法_接口幂等性通过注解实现

接口幂等性通过注解实现

近期需要对接口进行幂等性的改造,特此记录下。

一、背景

微服务架构中,幂等是一致性方面的一个重要概念。

一个幂等操作的特点是指其多次执行所产生的影响均与一次执行的影响相同。在业务中也就是指的,多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

二、常见场景

1.用户重复操作

在产品订购下单过程中,由于网络延迟或者用户误操作等原因,导致多次提交。这时就会在后台执行多条重复请求,导致脏数据或执行错误等。

2.分布式消息重复消费

消息队列中由于某种原因消息二次发送或者被二次消费的时候,导致程序多次执行,从而导致数据重复,资源冲突等。

3.接口超时重试

由于网络波动,引起的重复请求,导致数据的重复等。

三、常见解决方案

1.token机制实现

由客户端发送请求获取Token,服务端生成全局唯一的ID作为token,并保存在redis中,同时返回ID给客户端。

客户端调用业务端的请求的时候需要携带token,由服务端进行校验,校验成功,则允许执行业务,不成功则表示重复操作,直接返回给客户端。

2.mysql去重

建立一个去重表,当客户端请求的时候,将请求信息存入去重表进行判断。由于去重表带有唯一索引,如果插入成功则表示可以执行。如果失败则表示已经执行过当前请求,直接返回。

3.基于redis锁机制

在redis中,SETNX表示 SET IF NOT EXIST的缩写,表示只有不存在的时候才可以进行设置,可以利用它实现锁的效果。

客户端请求服务端时,通过计算拿到代表这次业务请求的唯一字段,将该值存入redis,如果设置成功表示可以执行。失败则表示已经执行过当前请求,直接返回。

四、实现方法

基于种种考虑,本文将基于方法3实现幂等性方法。其中有两个需要注意的地方:

1.如何实现唯一请求编号进行去重?

本文将采用用户ID:接口名:请求参数进行请求参数的MD5摘要,同时考虑到请求时间参数的干扰性(同一个请求,除了请求参数都相同可以认为为同一次请求),排除请求时间参数进行摘要,可以在短时间内保证唯一的请求编号。

2.如何保证最小的代码侵入性?

本文将采用自定义注解,同时采用切面AOP的方式,最大化的减少代码的侵入,同时保证了方法的易用性。

五、代码实现 

1.自定义注解

实现自定义注解,同时设置超时时间作为重复间隔时间。在需要使用幂等性校验的方法上面加上注解即可实现幂等性。

  1. import java.lang.annotation.ElementType;
  2. import java.lang.annotation.Retention;
  3. import java.lang.annotation.RetentionPolicy;
  4. import java.lang.annotation.Target;
  5. /**
  6. * @create 2021-01-18 16:40
  7. * 实现接口幂等性注解
  8. **/
  9. @Target({ElementType.METHOD})
  10. @Retention(RetentionPolicy.RUNTIME)
  11. public @interface AutoIdempotent {
  12. long expireTime() default 10000;
  13. }

2.MD5摘要辅助类

通过传入的参数进行MD5摘要,同时去除需要排除的干扰参数生成唯一的请求ID。

  1. import com.google.gson.Gson;
  2. import com.hhu.consumerdemo.model.User;
  3. import lombok.extern.slf4j.Slf4j;
  4. import javax.xml.bind.DatatypeConverter;
  5. import java.security.MessageDigest;
  6. import java.util.*;
  7. /**
  8. * @create 2021-01-14 10:12
  9. **/
  10. @Slf4j
  11. public class ReqDedupHelper {
  12. private Gson gson = new Gson();
  13. /**
  14. *
  15. * @param reqJSON 请求的参数,这里通常是JSON
  16. * @param excludeKeys 请求参数里面要去除哪些字段再求摘要
  17. * @return 去除参数的MD5摘要
  18. */
  19. public String dedupParamMD5(final String reqJSON, String... excludeKeys) {
  20. String decreptParam = reqJSON;
  21. TreeMap paramTreeMap = gson.fromJson(decreptParam, TreeMap.class);
  22. if (excludeKeys!=null) {
  23. List<String> dedupExcludeKeys = Arrays.asList(excludeKeys);
  24. if (!dedupExcludeKeys.isEmpty()) {
  25. for (String dedupExcludeKey : dedupExcludeKeys) {
  26. paramTreeMap.remove(dedupExcludeKey);
  27. }
  28. }
  29. }
  30. String paramTreeMapJSON = gson.toJson(paramTreeMap);
  31. String md5deDupParam = jdkMD5(paramTreeMapJSON);
  32. log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON);
  33. return md5deDupParam;
  34. }
  35. private static String jdkMD5(String src) {
  36. String res = null;
  37. try {
  38. MessageDigest messageDigest = MessageDigest.getInstance("MD5");
  39. byte[] mdBytes = messageDigest.digest(src.getBytes());
  40. res = DatatypeConverter.printHexBinary(mdBytes);
  41. } catch (Exception e) {
  42. log.error("",e);
  43. }
  44. return res;
  45. }
  46. //测试方法
  47. public static void main(String[] args) {
  48. Gson gson = new Gson();
  49. User user1 = new User("1","2",18);
  50. Object[] objects = new Object[]{"sss",11,user1};
  51. Map<String, Object> maps = new HashMap<>();
  52. maps.put("参数1",objects[0]);
  53. maps.put("参数2",objects[1]);
  54. maps.put("参数3",objects[2]);
  55. String json1 = gson.toJson(maps);
  56. System.out.println(json1);
  57. TreeMap paramTreeMap = gson.fromJson(json1, TreeMap.class);
  58. System.out.println(gson.toJson(paramTreeMap));
  59. }
  60. }

3.redis辅助Service

生成唯一的请求ID作为token存入redis,同时设置好超时时间,在超时时间内的请求参数将作为重复请求返回,而校验成功插入redis的请求Token将作为首次请求,进行放通。

本文采用的spring-redis版本为2.0以上,使用2.0以下版本的需要主要没有setIfAbsent方法,需要自己实现。

  1. import com.xxx.xxx.utils.ReqDedupHelper;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.springframework.beans.factory.annotation.Autowired;
  4. import org.springframework.data.redis.core.StringRedisTemplate;
  5. import org.springframework.stereotype.Service;
  6. import java.util.concurrent.TimeUnit;
  7. /**
  8. * @create 2021-01-18 17:44
  9. **/
  10. @Service
  11. @Slf4j
  12. public class TokenService {
  13. private static final String TOKEN_NAME = "request_token";
  14. @Autowired
  15. private StringRedisTemplate stringRedisTemplate;
  16. public boolean checkRequest(String userId, String methodName, long expireTime, String reqJsonParam, String... excludeKeys){
  17. final boolean isConsiderDup;
  18. String dedupMD5 = new ReqDedupHelper().dedupParamMD5(reqJsonParam, excludeKeys);
  19. String redisKey = "dedup:U="+userId+ "M="+methodName+"P="+dedupMD5;
  20. log.info("redisKey:{}", redisKey);
  21. long expireAt = System.currentTimeMillis() + expireTime;
  22. String val = "expireAt@" + expireAt;
  23. // NOTE:直接SETNX不支持带过期时间,所以设置+过期不是原子操作,极端情况下可能设置了就不过期了
  24. if (stringRedisTemplate.opsForValue().setIfAbsent(redisKey, val)) {
  25. if (stringRedisTemplate.expire(redisKey, expireTime, TimeUnit.MILLISECONDS)) {
  26. isConsiderDup = false;
  27. } else {
  28. isConsiderDup = true;
  29. }
  30. } else {
  31. log.info("加锁失败 failed!!key:{},value:{}",redisKey,val);
  32. return true;
  33. }
  34. return isConsiderDup;
  35. }
  36. }

4.AOP切面辅助类

aop切面,切住所有带有幂等注解的方法。进行幂等性的操作。

  1. import com.google.gson.Gson;
  2. import com.xxx.xxx.annotation.AutoIdempotent;
  3. import com.xxx.xxx.service.TokenService;
  4. import lombok.extern.slf4j.Slf4j;
  5. import org.aspectj.lang.ProceedingJoinPoint;
  6. import org.aspectj.lang.annotation.*;
  7. import org.aspectj.lang.reflect.MethodSignature;
  8. import org.springframework.beans.factory.annotation.Autowired;
  9. import org.springframework.stereotype.Component;
  10. import java.util.HashMap;
  11. import java.util.Map;
  12. /**
  13. * @author:
  14. * @date: 2020-04-28 14:20
  15. */
  16. @Aspect
  17. @Component
  18. @Slf4j
  19. public class AutoIdempontentHandler {
  20. private Gson gson = new Gson();
  21. private static final String excludeKey = "";
  22. private static final String methodName = "methodName";
  23. @Autowired
  24. private TokenService tokenService;
  25. @Pointcut("@annotation(com.xxx.xxx.annotation.AutoIdempotent)")
  26. public void autoIdempontentHandler() {
  27. }
  28. @Before("autoIdempontentHandler()")
  29. public void doBefore() throws Throwable {
  30. log.info("idempontentHandler..doBefore()");
  31. }
  32. @Around("autoIdempontentHandler()")
  33. public Object doAround(ProceedingJoinPoint joinpoint) throws Throwable {
  34. boolean checkres = this.handleRequest(joinpoint);
  35. if(checkres){
  36. //重复请求,提示重复 报错
  37. log.info("重复性请求..");
  38. throw new Exception();
  39. }
  40. return joinpoint.proceed();
  41. }
  42. private Boolean handleRequest(ProceedingJoinPoint joinpoint) {
  43. Boolean result = false;
  44. log.info("========判断是否是重复请求=======");
  45. MethodSignature methodSignature = (MethodSignature) joinpoint.getSignature();
  46. //获取自定义注解值
  47. AutoIdempotent autoIdempotent = methodSignature.getMethod().getDeclaredAnnotation(AutoIdempotent.class);
  48. long expireTime = autoIdempotent.expireTime();
  49. // 获取参数名称
  50. String methodsName = methodSignature.getMethod().getName();
  51. String[] params = methodSignature.getParameterNames();
  52. //获取参数值
  53. Object[] args = joinpoint.getArgs();
  54. Map<String, Object> reqMaps = new HashMap<>();
  55. for(int i=0; i<params.length; i++){
  56. reqMaps.put(params[i], args[i]);
  57. }
  58. String reqJSON = gson.toJson(reqMaps);
  59. result = tokenService.checkRequest("user1", methodsName, expireTime, reqJSON, excludeKey);
  60. return result;
  61. }
  62. @AfterReturning(returning = "retVal", pointcut = "autoIdempontentHandler()")
  63. public void doAfter(Object retVal) throws Throwable {
  64. log.debug("{}", retVal);
  65. }
  66. }

5.注解的使用

在需要幂等性的方法上进行注解,同时设置参数保证各个接口的超时时间的不一致性。可以看到在5秒内是无法再次请求方法1的。

  1. import com.xxx.xxx.annotation.AutoIdempotent;
  2. import org.springframework.beans.factory.annotation.Value;
  3. import org.springframework.web.bind.annotation.GetMapping;
  4. import org.springframework.web.bind.annotation.PathVariable;
  5. import org.springframework.web.bind.annotation.RestController;
  6. /**
  7. * @author
  8. * @Date: 2020-01-03 14:16
  9. */
  10. @RestController
  11. public class ConsumerController {
  12. @AutoIdempotent(expireTime = 5000)
  13. @GetMapping("/start/{index}")
  14. public String setValue( @PathVariable("index") String index){
  15. return index + "1";
  16. }
  17. @GetMapping("/start2/{index}")
  18. public String setValue2( @PathVariable("index") String index){
  19. return index + "2";
  20. }
  21. }

六、思考与不足

微服务架构中,幂等操作的特点是指任意多次执行所产生的影响均与一次执行的影响相同。但在实际设计的时候,却简单的进行所有请求进行重复。

然而,重试是降低微服务失败率的重要手段。因为网络波动、系统资源的分配不确定等因素会导致部分请求的失败。而这部分的请求中大部分实际上只需要进行简单的重试就可以保证成功。这才是幂等性真正需要实现的。暂时我并没有更好的解决方法,只能通过短时间的禁用,以及人为的决定何种方法进行幂等性校验来达到目的。欢迎有想法的和我一起探讨交流~

七、参考文献

https://mp.weixin.qq.com/s/xq2ks76hTU0Df-z2EzxyHQ

https://mp.weixin.qq.com/s/GNfIHIIDwncHLfw5TJiC0Q

 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/凡人多烦事01/article/detail/499269
推荐阅读
相关标签
  

闽ICP备14008679号