𝓝𝓮𝓶𝓸&博客

【Spring】面向切面编程AOP,自定义注解

AOP

面向切面编程(AOP, Aspect Oriented Programming)

概念

  1. 什么是 AOP
    1. 面向切面编程(方面),利用 AOP 可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
    2. 通俗描述:不通过修改源代码方式,在主干功能里面添加新功能
    3. 使用登录例子说明 AOP

底层原理

  1. AOP 底层使用动态代理
    有两种情况动态代理
    1. 第一种 有接口情况,使用 JDK 动态代理

    创建接口实现类代理对象,增强类的方法

    1. 第二种 没有接口情况,使用 CGLIB 动态代理

    创建子类的代理对象,增强类的方法

JDK 动态代理

  1. 使用 JDK 动态代理,使用 Proxy 类里面的方法创建代理对象
    1. 调用 newProxyInstance 方法
      方法有三个参数:
      1. 第一参数,类加载器
      2. 第二参数,增强方法所在的类,这个类实现的接口,支持多个接口
      3. 第三参数,实现这个接口 InvocationHandler,创建代理对象,写增强的部分
  2. 编写 JDK 动态代理代码
    1. 创建接口,定义方法

      public interface UserDao {
          public int add(int a,int b);
          public String update(String id);
      }
      
    2. 创建接口实现类,实现方法

      public class UserDaoImpl implements UserDao {
          @Override
          public int add(int a, int b) {
              return a+b;
          }
          @Override
          public String update(String id) {
              return id;
          } 
      }
      
    3. 使用 Proxy 类创建接口代理对象

      public class JDKProxy {
          public static void main(String[] args) {
              //创建接口实现类代理对象
              Class[] interfaces = {UserDao.class};
          // Proxy.newProxyInstance(JDKProxy.class.getClassLoader(), interfaces, new InvocationHandler() {
          //     @Override
          //     public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
          //          return null;
          //     }
          // });
              UserDaoImpl userDao = new UserDaoImpl();
              UserDao dao = 
                  (UserDao)Proxy.newProxyInstance(JDKProxy.class.getClassLoader(), interfaces, new UserDaoProxy(userDao));
              int result = dao.add(1, 2);
               System.out.println("result:"+result);
          } 
      }
      
      //创建代理对象代码
      class UserDaoProxy implements InvocationHandler {
          //1 把创建的是谁的代理对象,把谁传递过来
          //有参数构造传递
          private Object obj;
          public UserDaoProxy(Object obj) {
              this.obj = obj;
          }
          //增强的逻辑
          @Override
          public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
              //方法之前
              System.out.println("方法之前执行...."+method.getName()+" :传递的参数..."+ Arrays.toString(args));
              //被增强的方法执行
              Object res = method.invoke(obj, args);
              //方法之后
              System.out.println("方法之后执行...."+obj);
              return res;
          } 
      }
      

术语

  1. 连接点:类里面哪些方法可以被增强,这些方法称为连接点
  2. 切入点:实际被真正增强的方法,称为切入点
  3. 通知(增强):
    1. 实际增强的逻辑部分称为通知(增强)
    2. 通知有多种类型
      • 前置通知
      • 后置通知
      • 环绕通知
      • 异常通知
      • 最终通知
  4. 切面:是动作,把通知应用到切入点的过程

AOP 操作

准备工作

  1. Spring 框架一般都是基于 AspectJ 实现 AOP 操作
    1. AspectJ 不是 Spring 组成部分,独立 AOP 框架,一般把 AspectJ 和 Spirng 框架一起使用,进行 AOP 操作
  2. 基于 AspectJ 实现 AOP 操作
    1. 基于 xml 配置文件实现
    2. 基于注解方式实现(使用)
  3. 在项目工程里面引入 AOP 相关依赖
  4. 切入点表达式
    1. 切入点表达式作用:知道对哪个类里面的哪个方法进行增强
    2. 语法结构:execution([权限修饰符] [返回类型] [类全路径] [方法名称]([参数列表]))

    举例 1:对 com.nemo.dao.BookDao 类里面的 add 进行增强
    execution(* com.nemo.dao.BookDao.add(..))
    举例 2:对 com.nemo.dao.BookDao 类里面的所有的方法进行增强
    execution(* com.nemo.dao.BookDao.* (..))
    举例 3:对 com.nemo.dao 包里面所有类,类里面所有方法进行增强
    execution(* com.nemo.dao.*.* (..))

AspectJ 注解

  1. 创建类,在类里面定义方法

    public class User {
        public void add() {
            System.out.println("add.......");
        } 
    }
    
  2. 创建增强类(编写增强逻辑)

    1. 在增强类里面,创建方法,让不同方法代表不同通知类型

      //增强的类
      public class UserProxy {
          public void before() {//前置通知
              System.out.println("before......");
          } 
      }
      
  3. 进行通知的配置

    1. 在 spring 配置文件中,开启注解扫描

      <?xml version="1.0" encoding="UTF-8"?>
      <beans xmlns="http://www.springframework.org/schema/beans" 
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
             xmlns:context="http://www.springframework.org/schema/context" 
             xmlns:aop="http://www.springframework.org/schema/aop" 
             xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd 
             http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd 
             http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">
       <!-- 开启注解扫描 -->
       <context:component-scan base￾package="com.nemo.spring5.aopanno"></context:component-scan>
      
    2. 使用注解创建 User 和 UserProxy 对象

    3. 在增强类上面添加注解 @Aspect

      //增强的类
      @Component
      @Aspect //生成代理对象
      public class UserProxy {
      
    4. 在 spring 配置文件中开启生成代理对象

      <!-- 开启 Aspect 生成代理对象-->
      <aop:aspectj-autoproxy></aop:aspectj-autoproxy>
      
  4. 配置不同类型的通知

    1. 在增强类的里面,在作为通知方法上面添加通知类型注解,使用切入点表达式配置

      //增强的类
      @Component
      @Aspect //生成代理对象
      public class UserProxy {
          //前置通知
          //@Before 注解表示作为前置通知
          @Before(value = "execution(* com.nemo.spring5.aopanno.User.add(..))")
          public void before() {
              System.out.println("before.........");
          }
          //后置通知(返回通知)
          @AfterReturning(value = "execution(* com.nemo.spring5.aopanno.User.add(..))")
          public void afterReturning() {
              System.out.println("afterReturning.........");
          }
          //最终通知
          @After(value = "execution(* com.nemo.spring5.aopanno.User.add(..))")
          public void after() {
              System.out.println("after.........");
          }
          //异常通知
          @AfterThrowing(value = "execution(* com.nemo.spring5.aopanno.User.add(..))")
          public void afterThrowing() {
              System.out.println("afterThrowing.........");
          }
          //环绕通知
          @Around(value = "execution(* com.nemo.spring5.aopanno.User.add(..))")
          public void around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
              System.out.println("环绕之前.........");
              //被增强的方法执行
              proceedingJoinPoint.proceed();
              System.out.println("环绕之后.........");
          }
      }
      
  5. 相同的切入点抽取

    //相同切入点抽取
    @Pointcut(value = "execution(* com.nemo.spring5.aopanno.User.add(..))")
    public void pointdemo() {
    }
    //前置通知
    //@Before 注解表示作为前置通知
    @Before(value = "pointdemo()")
    public void before() {
        System.out.println("before.........");
    }
    
  6. 有多个增强类多同一个方法进行增强,设置增强类优先级

    1. 在增强类上面添加注解 @Order(数字类型值),数字类型值越小优先级越高

      @Component
      @Aspect
      @Order(1)
      public class PersonProxy
      
  7. 完全使用注解开发

    1. 创建配置类,不需要创建 xml 配置文件

      @Configuration
      @ComponentScan(basePackages = {"com.nemo"})
      @EnableAspectJAutoProxy(proxyTargetClass = true)
      public class ConfigAop {
      }
      

AspectJ 配置文件

  1. 创建两个类,增强类和被增强类,创建方法

  2. 在 spring 配置文件中创建两个类对象

    <!--创建对象-->
    <bean id="book" class="com.nemo.spring5.aopxml.Book"></bean>
    <bean id="bookProxy" class="com.nemo.spring5.aopxml.BookProxy"></bean>
    
  3. 在 spring 配置文件中配置切入点

    <!--配置 aop 增强--> <aop:config>
        <!--切入点-->
        <aop:pointcut id="p" expression="execution(* com.nemo.spring5.aopxml.Book.buy(..))"/>
        <!--配置切面-->
        <aop:aspect ref="bookProxy">
            <!--增强作用在具体的方法上-->
            <aop:before method="before" pointcut-ref="p"/>
        </aop:aspect>
    </aop:config>
    

应用场景

使用Component进行全局方法截取输出日志

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Objects;

/**
 * @author nemo
 */
@Slf4j
@Component
@Aspect
public class RequestContentLogAspect {
    /**
     * log请求内容
     *
     * @param joinPoint
     */
    @Before("within(com.plat.controller.*)")
    public void before(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        log.info("{}.{} request content: ({})",
                method.getDeclaringClass(), method.getName(), StringUtils.join(args, ", "));
    }

    /**
     * log返回内容
     *
     * @param joinPoint
     * @param response
     */
    @AfterReturning(value = "within(com.plat.controller.*)",
            returning = "response")
    public void after(JoinPoint joinPoint, Object response) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        log.info("{}.{} return: {}",
                method.getDeclaringClass(), method.getName(),
                Objects.isNull(response) ? "null" : response.toString());
    }
}

输出日志:

2021-08-30 14:04:35.545  INFO 70296 [http-nio-8480-exec-7] --- c.b.a.p.d.p.a.RequestContentLogAspect    : [][][RequestContentLogAspect.java:before:33] class com.plat.controller.FaqController.list request content: (, 1, , [-1], [-1], createTime, asc, 1, 10)

通过注解记录方法处理时长输出日志

注解:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 注解在方法上,方法需要在日志里输出处理时长
 *
 * @author nemo
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CostTime {
}

Component:

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.reflect.MethodSignature;
import org.springframework.stereotype.Component;

/**
 * 记录log的切面
 *
 * @author nemo
 */
@Slf4j
@Aspect
@Component
public class PerformanceLogAspect {
    /**
     * 记录性能,处理时长
     *
     * @param joinPoint
     * @return
     * @throws Throwable
     */
    @Around(value =
            "@annotation(com.plat.annotation.CostTime)"
    )
    public Object recordPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        var result = joinPoint.proceed();   // 执行切点的方法
        long costTime = System.currentTimeMillis() - startTime;

        var targetMethod = ((MethodSignature) joinPoint.getSignature()).getMethod();

        log.info("{}.{} process cost {}ms", targetMethod.getDeclaringClass(), targetMethod.getName(), costTime);
        return result;
    }
}

使用:

/**
 * @author nemo
 */
public class Test {

    /**
     * 计算图片内容的md5来做作为新的文件名
     *
     * @param request
     * @return
     * @throws NoSuchAlgorithmException
     */
    @CostTime
    public String newFileName(Request request) {

        var bytesOfMessage = request.getImageBase64().getBytes();

        String fileName;
        try {
            var messageDigest = MessageDigest.getInstance("MD5");

            byte[] digest = messageDigest.digest(bytesOfMessage);
            String contentDigest = DatatypeConverter
                    .printHexBinary(digest).toUpperCase();

            String[] fragment = new String[] {
                    contentDigest, String.valueOf(System.currentTimeMillis())
            };
            fileName = StringUtils.join(fragment, "-");
        } catch (NoSuchAlgorithmException e) {
            fileName = UUID.randomUUID().toString();
            log.warn("Fail to use md5 algorithm to generate file name, replace with uuid: {}", fileName, e);
        }

        return fileName + "." + StringUtils.substringAfterLast(request.getName(), ".");
    }
}

输出日志:

2021-12-28 15:35:03.051  INFO 19539 [http-nio-8480-exec-4] --- c.b.a.p.d.p.aspect.PerformanceLogAspect  : [][][PerformanceLogAspect.java:recordPerformance:36] class com.plat.controller.Test.newFileName process cost 1ms

注解限制重复提交

处理的方式有两种,本次介绍普适性方案,解决问题1。

  1. 正常业务请求的防止限制
  2. 极端情况下的请求,rediskey过期带来的重复请求。

代码结构比较清晰,直接贴代码啦:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface LimitSubmit {
    String key() ;
    /**
     * 默认 10s
     */
    int limit() default 10;

    /**
     * 请求完成后 是否一直等待
     * true则等待
     * @return
     */
    boolean needAllWait() default true;
}
@Component
@Aspect
@Slf4j
public class LimitSubmitAspect {
    //封装了redis操作各种方法
    @Autowired
    private RedisUtil redisUtil;

    @Pointcut("@annotation(org.jeecg.common.aspect.annotation.LimitSubmit)")
    private void pointcut() {}

    @Around("pointcut()")
    public Object handleSubmit(ProceedingJoinPoint joinPoint) throws Throwable {
        LoginUser sysUser = (LoginUser)SecurityUtils.getSubject().getPrincipal();
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        //获取注解信息
        LimitSubmit limitSubmit = method.getAnnotation(LimitSubmit.class);
        int submitTimeLimiter = limitSubmit.limit();
        String redisKey = limitSubmit.key();
        boolean needAllWait = limitSubmit.needAllWait();
        String key =  getRedisKey(sysUser,joinPoint, redisKey);
        Object result = redisUtil.get(key);
        if (result != null) {
            throw new JeecgBootException("请勿重复访问!");
        }
        redisUtil.set(key, sysUser.getId(), submitTimeLimiter);
        try {
            Object proceed = joinPoint.proceed();
            return proceed;
        } catch (Throwable e) {
            log.error("Exception in {}.{}() with cause = \'{}\' and exception = \'{}\'", joinPoint.getSignature().getDeclaringTypeName(),
                joinPoint.getSignature().getName(), e.getCause() != null? e.getCause() : "NULL", e.getMessage(), e);
            throw e;
        }finally {
            if(!needAllWait) {
                redisUtil.del(redisKey);
            }
        }
    }

    /**
     * 支持多参数,从请求参数进行处理
     */
    private String getRedisKey(LoginUser sysUser, ProceedingJoinPoint joinPoint, String key){
        if(key.contains("%s")) {
            key = String.format(key, sysUser.getId());
        }
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();

        LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
        String[] parameterNames = discoverer.getParameterNames(method);
        if (parameterNames != null) {
            for (int i = 0; i < parameterNames.length; i++) {
                String item = parameterNames[i];
                if(key.contains("#"+item)){
                    key = key.replace("#"+item, joinPoint.getArgs()[i].toString());
                }
            }
        }
        return key.toString();
    }
}

使用效果:
image

/* 
使用:
	%s 代表当前登录人
	#参数 代表从参数中获取,支持多个参数
*/
@LimitSubmit(key = "testLimit:%s:#orderId",limit = 10,needAllWait = true)
// 生成的redis key: testLimit:e9ca23d68d884d4ebb19d07889727dae:order1123123
  1. 限制对某个接口的访问,针对所有人,则去除%s
  2. 限制某个人对某个接口的访问,则 %s
  3. 限制某个人对某个接口的业务参数的访问,则 %s:#参数1:#参数2

image
image

通用的接口限流、防重、防抖机制

最近上了一个新项目,考虑到一个问题,在高并发场景下,我们无法控制前端的请求频率和次数,这就可能导致服务器压力过大,响应速度变慢,甚至引发系统崩溃等严重问题。为了解决这些问题,我们需要在后端实现一些机制,如接口限流、防重复提交和接口防抖,而这些是保证接口安全、稳定提供服务,以及防止错误数据 和 脏数据产生的重要手段。

而AOP适合在在不改变业务代码的情况下,灵活地添加各种横切关注点,实现一些通用公共的业务场景,例如日志记录、事务管理、安全检查、性能监控、缓存管理、限流、防重复提交等功能。这样不仅提高了代码的可维护性,还使得业务逻辑更加清晰专注

防重、防抖的实现机制大同小异

接口限流

接口限流是一种控制访问频率的技术,通过限制在一定时间内允许的最大请求数来保护系统免受过载。限流可以在应用的多个层面实现,比如在网关层、应用层甚至数据库层。常用的限流算法有漏桶算法(Leaky Bucket)、令牌桶算法(Token Bucket)等。限流不仅可以防止系统过载,还可以防止恶意用户的请求攻击。

限流框架大概有

  • spring cloud gateway集成redis限流,但属于网关层限流
  • 阿里Sentinel,功能强大、带监控平台
  • srping cloud hystrix,属于接口层限流,提供线程池与信号量两种方式
  • 其他:redisson、redis手撸代码

本文主要是通过 Redisson 的分布式计数来实现的 固定窗口 模式的限流,也可以通过 Redisson 分布式限流方案(令牌桶)的的方式RRateLimiter。

在高并发场景下,合理地实施接口限流对于保障系统的稳定性和可用性至关重要。

自定义接口限流注解类 @AccessLimit

/**
 * 接口限流
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {

    /**
     * 限制时间窗口间隔长度,默认10秒
     */
    int times() default 10;

    /**
     * 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 上述时间窗口内允许的最大请求数量,默认为5次
     */
    int maxCount() default 5;

    /**
     * redis key 的前缀
     */
    String preKey();

    /**
     * 提示语
     */
    String msg() default "服务请求达到最大限制,请求被拒绝!";
}

利用AOP实现接口限流

/**
 * 通过AOP实现接口限流
 */
@Component
@Aspect
@Slf4j
@RequiredArgsConstructor
public class AccessLimitAspect {

    private static final String ACCESS_LIMIT_LOCK_KEY = "ACCESS_LIMIT_LOCK_KEY";

    private final RedissonClient redissonClient;

    @Around("@annotation(accessLimit)")
    public Object around(ProceedingJoinPoint point, AccessLimit accessLimit) throws Throwable {

        String prefix = accessLimit.preKey();
        String key = generateRedisKey(point, prefix);

        //限制窗口时间
        int time = accessLimit.times();
        //获取注解中的令牌数
        int maxCount = accessLimit.maxCount();
        //获取注解中的时间单位
        TimeUnit timeUnit = accessLimit.timeUnit();

        //分布式计数器
        RAtomicLong atomicLong = redissonClient.getAtomicLong(key);

        if (!atomicLong.isExists() || atomicLong.remainTimeToLive() <= 0) {
	    	atomicLong.set(0);
            atomicLong.expire(time, timeUnit);
        }

        long count = atomicLong.incrementAndGet();
        
        if (count > maxCount) {
            throw new LimitException(accessLimit.msg());
        }

        // 继续执行目标方法
        return point.proceed();
    }

    public String generateRedisKey(ProceedingJoinPoint point, String prefix) {
        //获取方法签名
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        //获取方法
        Method method = methodSignature.getMethod();
        //获取全类名
        String className = method.getDeclaringClass().getName();

        // 构建Redis中的key,加入类名、方法名以区分不同接口的限制
        return String.format("%s:%s:%s", ACCESS_LIMIT_LOCK_KEY, prefix, DigestUtil.md5Hex(String.format("%s-%s", className, method)));
    }
}

调用示例实现

@GetMapping("/getUser")
@AccessLimit(times = 10, timeUnit = TimeUnit.SECONDS, maxCount = 5, preKey = "getUser", msg = "服务请求达到最大限制,请求被拒绝!")
public Result getUser() {
    return Result.success("成功访问");
}

防重复提交

在一些业务场景中,重复提交同一个请求可能会导致数据的不一致,甚至严重影响业务逻辑的正确性。例如,在提交订单的场景中,重复提交可能会导致用户被多次扣款。为了避免这种情况,可以使用防重复提交技术,这对于保护数据一致性、避免资源浪费非常重要

自定义接口防重注解类 @RepeatSubmit

/**
* 自定义接口防重注解类
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {
    /**
     * 定义了两种防止重复提交的方式,PARAM 表示基于方法参数来防止重复,TOKEN 则可能涉及生成和验证token的机制
     */
    enum Type { PARAM, TOKEN }
    /**
     * 设置默认的防重提交方式为基于方法参数。开发者可以不指定此参数,使用默认值。
     * @return Type
     */
    Type limitType() default Type.PARAM;
 
    /**
     * 允许设置加锁的过期时间,默认为5秒。这意味着在第一次请求之后的5秒内,相同的请求将被视为重复并被阻止
     */
    long lockTime() default 5;
    
    //提供了一个可选的服务ID参数,通过token时用作KEY计算
    String serviceId() default ""; 
    
    /**
     * 提示语
     */
    String msg() default "请求重复提交!";
}

利用AOP实现接口防重处理

/**
 * 利用AOP实现接口防重处理
 */
@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RepeatSubmitAspect {

    private final String REPEAT_SUBMIT_LOCK_KEY_PARAM = "REPEAT_SUBMIT_LOCK_KEY_PARAM";

    private final String REPEAT_SUBMIT_LOCK_KEY_TOKEN = "REPEAT_SUBMIT_LOCK_KEY_TOKEN";

    private final RedissonClient redissonClient;

    private final RedisRepository redisRepository;

    @Pointcut("@annotation(repeatSubmit)")
    public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {

    }

    /**
     * 环绕通知, 围绕着方法执行
     * 两种方式
     * 方式一:加锁 固定时间内不能重复提交
     * 方式二:先请求获取token,再删除token,删除成功则是第一次提交
     */
    @Around("pointCutNoRepeatSubmit(repeatSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        //用于记录成功或者失败
        boolean res = false;

        //获取防重提交类型
        String type = repeatSubmit.limitType().name();
        if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
            //方式一,参数形式防重提交
            //通过 redissonClient 获取分布式锁,基于IP地址、类名、方法名生成唯一key
            String ipAddr = IPUtil.getIpAddr(request);
            String preKey = repeatSubmit.preKey();
            String key = generateTokenRedisKey(joinPoint, ipAddr, preKey);

            //获取注解中的锁时间
            long lockTime = repeatSubmit.lockTime();
            //获取注解中的时间单位
            TimeUnit timeUnit = repeatSubmit.timeUnit();

            //使用 tryLock 尝试获取锁,如果无法获取(即锁已被其他请求持有),则认为是重复提交,直接返回null
            RLock lock = redissonClient.getLock(key);
            //锁自动过期时间为 lockTime 秒,确保即使程序异常也不会永久锁定资源,尝试加锁,最多等待0秒,上锁以后 lockTime 秒自动解锁 [lockTime默认为5s, 可以自定义]
            res = lock.tryLock(0, lockTime, timeUnit);

        } else {
            //方式二,令牌形式防重提交
            //从请求头中获取 request-token,如果不存在,则抛出异常
            String requestToken = request.getHeader("request-token");
            if (StringUtils.isBlank(requestToken)) {
                throw new LimitException("请求未包含令牌");
            }
            //使用 request-token 和 serviceId 构造Redis的key,尝试从Redis中删除这个键。如果删除成功,说明是首次提交;否则认为是重复提交
            String key = String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_TOKEN, repeatSubmit.serviceId(), requestToken);
            res = redisRepository.del(key);
        }

        if (!res) {
            log.error("请求重复提交");
            throw new LimitException(repeatSubmit.msg());
        }

        return joinPoint.proceed();
    }

    private String generateTokenRedisKey(ProceedingJoinPoint joinPoint, String ipAddr, String preKey) {
        //根据ip地址、用户id、类名方法名、生成唯一的key
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String className = method.getDeclaringClass().getName();
        String userId = "seven";
        return String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_PARAM, preKey, DigestUtil.md5Hex(String.format("%s-%s-%s-%s", ipAddr, className, method, userId)));
    }
}

调用示例

@PostMapping("/saveUser")
@RepeatSubmit(limitType = RepeatSubmit.Type.PARAM,lockTime = 5,timeUnit = TimeUnit.SECONDS,preKey = "saveUser",msg = "请求重复提交")
public Result saveUser() {
    return Result.success("成功保存");
}

接口防抖

接口防抖是一种优化用户操作体验的技术,主要用于减少短时间内高频率触发的操作。例如,当用户快速点击按钮时,我们可以通过防抖机制,只处理最后一次触发的操作,而忽略前面短时间内的多次操作。防抖技术常用于输入框文本变化事件、按钮点击事件等场景,以提高系统的性能和用户体验。

后端接口防抖处理主要是为了避免在短时间内接收到大量相同的请求,特别是由于前端操作(如快速点击按钮)、网络重试或异常情况导致的重复请求。后端接口防抖通常涉及记录最近的请求信息,并在特定时间窗口内拒绝处理相同或相似的请求。

定义自定义注解 @AntiShake

// 该注解只能用于方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)// 运行时保留,这样才能在AOP中被检测到
public @interface AntiShake {

    String preKey() default "";

    // 默认防抖时间1秒
    long value() default 1000L;

    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}

实现AOP切面处理防抖

@Aspect // 标记为切面类
@Component // 让Spring管理这个Bean
@RequiredArgsConstructor // 通过构造方法注入依赖
public class AntiShakeAspect {

    private final String ANTI_SHAKE_LOCK_KEY = "ANTI_SHAKE_LOCK_KEY";

    private final RedissonClient redissonClient;

    @Around("@annotation(antiShake)") // 拦截所有标记了@AntiShake的方法
    public Object aroundAdvice(ProceedingJoinPoint joinPoint, AntiShake antiShake) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        long currentTime = System.currentTimeMillis();

        // 获取方法签名和参数作为 Redis 键
        String ipAddr = IPUtil.getIpAddr(request);
        String key = generateTokenRedisKey(joinPoint, ipAddr, antiShake.preKey());

        RBucket<Long> bucket = redissonClient.getBucket(key);
        Long lastTime = bucket.get();

        if (lastTime != null && currentTime - lastTime < antiShake.value()) {
            // 如果距离上次调用时间小于指定的防抖时间,则直接返回,不执行方法
            return null; // 根据业务需要返回特定值
        }

        // 更新 Redis 中的时间戳
        bucket.set(currentTime, antiShake.value(), antiShake.timeUnit());
        return joinPoint.proceed(); // 执行原方法
    }

    private String generateTokenRedisKey(ProceedingJoinPoint joinPoint, String ipAddr, String preKey) {
        //根据ip地址、用户id、类名方法名、生成唯一的key
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String className = method.getDeclaringClass().getName();
        String userId = "seven";
        return String.format("%s:%s:%s", ANTI_SHAKE_LOCK_KEY, preKey, DigestUtil.md5Hex(String.format("%s-%s-%s-%s", ipAddr, className, method, userId)));
    }
}

调用示例代码

@PostMapping("/clickButton")
@AntiShake(value = 1000, timeUnit = TimeUnit.MILLISECONDS, preKey = "clickButton")
public Result clickButton() {
    return Result.success("成功点击按钮");
}

用于文件验证(大小、扩展名、MIME类型)

SpringBoot中经常需要处理文件上传的功能。为了确保上传的文件满足特定的要求(如扩展名、MIME类型和文件大小),我们可以创建一个自定义注解来简化验证过程。

    1. 自定义文件验证注解
    1. 实现约束验证器
    1. 使用注解

1. 自定义文件验证注解

首先定义一个注解,用于标记需要校验的文件字段。这个注解包含验证所需的参数:允许的扩展名、MIME类型和最大文件大小。

import javax.validation.Constraint;
import javax.validation.Payload;
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;


@Documented
@Constraint(validatedBy = FileValidator.class)
@Target({ ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidFile {


    String message() default "{constraints.ValidFileMimeType.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};


    String[] extensions() default {};
    String[] mimeTypes() default {};
    long maxSize() default 1024 * 1024; // 默认最大1MB
}

注解的组成部分:

maxSize: 文件大小限制,默认1M

mimeTypes:MIME类型

extensions:允许的扩展名

message():验证失败时的默认错误消息

constraint(validatedBy = FileValidator.class):自定义的约束器实现

2. 实现约束验证器

接下来,创建一个类来实现 ConstraintValidator接口,具体实现文件的扩展名、类型、大小的校验逻辑。

import org.springframework.web.multipart.MultipartFile;


import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.io.IOException;


public class FileValidator implements ConstraintValidator<ValidFile, MultipartFile> {
    private final Tika tika = new Tika();
    private List<String> extensions;
    private List<String> mimeTypes;
    private long maxSize;


    @Override
    public void initialize(ValidFile constraintAnnotation) {
        extensions = List.of(constraintAnnotation.extensions());
        mimeTypes = List.of(constraintAnnotation.mimeTypes());
        maxSize = constraintAnnotation.maxSize();
    }


    @Override
    public boolean isValid(MultipartFile file, ConstraintValidatorContext context) {
        if (file == null || file.isEmpty()) {
            return true;
        }
        // 1. 文件大小验证
        if (file.getSize() > maxSize) {
            return false;
        }
        // 2. 文件扩展名验证
        // String fileName = file.getOriginalFilename();
        // String fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1);

        String fileExtension = FilenameUtils.getExtension(file.getOriginalFilename());
        if( StringUtils.isNotBlank(fileExtension) && extensions.contains(fileExtension .toLowerCase())){
            retrun true;
        }
        // 3. 这里使用apache tika验证文件mime,实际是通过文件头内容中的魔法数来验证的
        var detect = tika.detect(TikaInputStream.get(file.getInputStream()));
        return mimeTypes.contains(detect);
    }
}

注: apache tika 是一个开源的文档识别工具,它可以自动检测文件类型并提取文件内容。使用 Tika,可以方便地确定文件类型和拓展名,从而根据文件类型来执行相应的操作,具体使用不是本文内容不再介绍了。

3. 使用注解

最后在Spring Boot的Controller中使用这个注解来校验文件。

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;


import javax.validation.constraints.NotNull;


@RestController
public class FileUploadController {


    @PostMapping("/upload")
    public ResponseEntity<?> uploadFile(@RequestParam("file") @NotNull @ValidFile(extensions = {"jpg", "png"}, mimeTypes = {"image/jpeg", "image/png"}, maxSize = 2 * 1024 * 1024) MultipartFile file) {
        // 文件处理逻辑
        return ResponseEntity.ok("File uploaded successfully");
    }
}

@ValidFile注解验证文件的扩展名是否为"jpg"或"png",MIME类型是否为"image/jpeg"或"image/png",以及文件大小是否不超过2MB。如果文件不符合这些要求,Spring将自动返回400 Bad Request响应。

  • 以上注解合并了三个验证逻辑, 会导致验证失败是提示语不具体, 如有该需求,可以将其拆分成为三个注解.

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

posted @   Nemo&  阅读(4360)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示