Spring AOP【转】

本文详细介绍了Spring中的AOP(面向切面编程)概念,包括代理模式的应用,如何通过AOP实现代码插入,如在方法执行前后添加权限检查和日志记录。讲解了AOP的四种拦截器类型,并通过示例展示了如何使用@AspectJ注解进行切点匹配。同时,讨论了AOP的实现原理,包括动态代理和静态代理的区别,以及在实际应用中需要注意的陷阱,如避免直接访问Bean的成员。最后,总结了AOP的重要性和使用场景。

  以下转载和参考自:装配AOP - 廖雪峰的官方网站

1、代理模式和AOP

    前面说过Proxy(代理)模式,比如下面的Foo类中的func1和func2在执行前后需要验证权限和记录日志,那么实际上适合将这些动作提取到一个代理类中去,因为验证权限、日志记录这些功能实际上并不属于Foo类主逻辑功能,这样能降低代码耦合度。

public class Foo {
    public void func1() {
        securityCheck(); //权限检查
        ......
        log("log");//日志记录
    }

    public void func2() {
        securityCheck(); //权限检查
        ......
        log("log");//日志记录
    }
}

public class FooProxy implements Foo{
    private final Foo target;

    public FooProxy(Foo target) {
        this.target = target;
    }

    public void func1() {
        securityCheck(); //权限检查
        target.func1();
        log("log");//日志记录
    }

    public void func2() {
        securityCheck(); //权限检查
        target.func2();
        log("log");//日志记录
    }
}

    在Spring中,可以通过AOP(Aspect Oriented Programming),即面向切面编程来实现代码插入的功能。在AOP中,上面的Foo类中的func1、func2称为“核心逻辑”,权限验证、记录日志这些称为“切面逻辑”,AOP要实现的就是把切面插入到核心逻辑中,通过Proxy模式来实现。

2、实现AOP

    如下通过AOP实现了调用Foo中任何方法之前会先执行MyAspect 类中的doAccessCheck()方法,调用Foo中任何方法之后会执行MyAspect 类中的doLogging()方法,调用Bar中任何方法之前之后会执行MyAspect中的beforeBarFun()、afterBarFun()方法。实现AOP的方法是在切面类中添加@Component和@Aspect注解,在@Configuration类(Ioc配置类)加上一个@EnableAspectJAutoProxy注解:

@Component
public class Foo {
    public void fun(){
        System.out.println("Foo func()");
    }
}

@Component
public class Bar {
    public void fun(){
        System.out.println("Bar func()"); //err.print会以红色显示输出
    }
}

@Aspect
@Component
public class MyAspect {
    // 在执行Foo的每个方法前执行doAccessCheck()
    @Before("execution(public * xsl.Foo.*(..))")
    public void doAccessCheck() {}

    // 在执行Foo的每个方法后执行doLogging()
    @After("execution(public * xsl.Foo.*(..))")
    public void doLogging() {}

    // 在执行Bar的每个方法前执行beforeBarFun()、每个方法后执行afterBarFun
    @Around("execution(public * xsl.Bar.*(..))")
    public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
        beforeBarFun();
        Object retVal = pjp.proceed();
        afterBarFun();

        return retVal;
    }

    private void beforeBarFun(){}
    private void afterBarFun(){}
}

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class App {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(App.class);

        Bar b = context.getBean(Bar.class);
        b.fun();
        
        Foo f = context.getBean(Foo.class);
        f.fun();
    }
}

  AOP的相关依赖:

    <properties>
        <project.build.srouceEncoding>UTF-8</project.build.srouceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <java.version>11</java.version>

        <spring.version>5.2.1.RELEASE</spring.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
            <version>${spring.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
        </dependency>
    </dependencies>

  如果是SprintBoot项目的话,直接使用下面的依赖:

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

  AOP中的拦截器有以下类型:

  • @Before:这种拦截器先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;

  • @After:这种拦截器先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;

  • @AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;

  • @AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;

  • @Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。

  拦截器中的注解值使用AspectJ注入语法,其它的示例还有:

@Before("execution(public * xsl.Foo.fun1(..))") //只有Foo类的fun1()方法才会被插入

@Before("execution(public * xsl.*.*(..))") //xsl包下的所有方法都会被插入

@Around("execution(public * update*(..))") //update开头的方法都会被插入

  可以只对使用了指定注解的方法才进行Before、Around等插入操作。比如下面@Around的注解值表示只有在使用了@MetricTime注解的方法调用的时候才会插入执行metric()中代码:

@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime { //自定义的注解
    String value();
}

@Component
public class Foo {

    @MetricTime("TimeCost") //使用@MetricTime注解
    public void fun(){
        System.out.println("Foo func()");
    }
}

@Aspect
@Component
public class LoggingAspect {
    @Around("@annotation(metricTime)")
    public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
        String name = metricTime.value(); //注解的value值"TimeCost"
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long t = System.currentTimeMillis() - start;
            System.out.println(name + ": " + t + "ms");
        }
    }
}


@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class App {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(App.class);

        Foo f = context.getBean(Foo.class);
        f.fun();
    }
}

  

3、利用AOP实现接口限流

  一般登录接口为了防止暴力破解密码都会设置限流,比如一个IP对接口的访问频次限制为1分钟最多2次,可以利用redis的key过期来实现:当首次请求接口的时候,key不存在,所以创建key并设置其过期时间为1分钟,key的名称可以由访问的IP地址生成,key的值设为1。如果一分钟内相同的IP再次请求接口的话,将key值加1,一分钟内第三次请求的话由于key已经等于最多次数2,拒绝访问。一分钟后key到期被自动删除。

  可以利用AOP在接口执行前进行上面的频次判断。

  ①、创建@RateLimit注解,用于标记需要限流的接口:

package xsl.entity;

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

@Target(ElementType.METHOD) // 作用在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时生效
public @interface RateLimit {
    // 时间窗口(秒),默认60秒
    int windowSeconds() default 60;

    // 时间窗口内最大请求次数,默认2次
    int maxCount() default 2;
}

  ②、实现限流切面,通过 AOP 拦截接口请求

package xsl.entity;

import io.micrometer.core.instrument.util.StringUtils;
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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import xsl.service.RedisService;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.time.Duration;

@Aspect
@Component
public class RateLimitAspect {

    @Autowired
    RedisService redisService;

    @Around("@annotation(xsl.entity.RateLimit)")     // 拦截带有@RateLimit注解的方法
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 获取当前请求的上下文信息
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        RateLimit rateLimit = method.getAnnotation(RateLimit.class);

        // 2. 生成唯一标识(用户IP + 接口路径)
        String ip = getClientIp(request); // 获取客户端真实IP
        String key = "rate_limit:" + ip + ":" + request.getRequestURI();

        // 3. 从Redis获取当前请求次数
        Integer count = redisService.getInt(key);
        if (count == null) {
            // 首次请求:初始化次数为1,设置过期时间为60秒
            redisService.setInt(key, 1, Duration.ofSeconds(60));
        } else if (count < rateLimit.maxCount()) {
            // 未超过限制:次数+1
            redisService.incr(key);
        } else {
            // 超过限制:抛出异常
            throw new RuntimeException("请求过于频繁,请在" + rateLimit.windowSeconds() + "秒后重试");
        }

        // 4. 正常执行接口逻辑
        return joinPoint.proceed();
    }

    // 获取客户端真实IP(处理代理情况)
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (StringUtils.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }
        if (StringUtils.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        // 多个代理时,取第一个IP
        if (ip != null && ip.contains(",")) {
            ip = ip.split(",")[0].trim();
        }
        return ip;
    }
}

  ③、在需要限制请求频率的 Controller 方法上添加@RateLimit注解

@RateLimit(windowSeconds = 60, maxCount = 1) // 限制:该接口1分钟内最多请求1次
@GetMapping("/api/limited")
public String limitedApi() {
	return "请求成功";
}

  如果服务不是分布式的,也可以不使用redis,使用Google Guava 的RateLimiter工具类来实现接口限流。RateLimiter是基于令牌桶算法实现的限流工具,其核心思想:系统以固定速率生成 “令牌”,请求接口需要先获取令牌才能执行,若令牌不足则需等待或被拒绝,从而实现流量控制。比如接口限制一秒请求1次的话则设置令牌生成速率为每秒1个,接口限制一分钟请求2次的话则设置令牌生成速率为每分钟2/60个。如下修改前面的切面类,使用RateLimiter来限流:

依赖:

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>32.1.3-jre</version>
        </dependency>
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 获取当前请求的上下文信息
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        RateLimit rateLimit = method.getAnnotation(RateLimit.class);

        // 2. 生成唯一标识(用户IP + 接口路径)
        String ip = getClientIp(request); // 获取客户端真实IP
        String key = "rate_limit:" + ip + ":" + request.getRequestURI();

        // 3. 获取或创建限流器来控制限流
        double permitsPerSecond = (double) rateLimit.maxCount() / rateLimit.windowSeconds(); //令牌生成速率(1分钟2次 → 2/60个令牌/秒)
        //rateLimiters是ConcurrentHashMap<String, RateLimiter>类型的map,用来保存各个IP和对应的RateLimiter
        RateLimiter limiter = rateLimiters.computeIfAbsent(key, k ->
                RateLimiter.create(permitsPerSecond)
        );
        if (!limiter.tryAcquire()) { // 尝试获取令牌(非阻塞,立即返回)
            throw new RuntimeException("请求过于频繁,请在" + rateLimit.windowSeconds() + "秒后重试");
        }

        // 4. 正常执行接口逻辑
        return joinPoint.proceed();
    }

4、AOP原理

   AOP的原理其实就是编写核心逻辑类的子类,并持有原始实例的引用,如下所示。使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式

public FooProxy extends Foo {
private Foo target;
private LoggingAspect aspect;

public FooProxy(Foo target, LoggingAspect aspect) {
        this.target = target;
        this.aspect = aspect;
        }

public void fun() {
        // 先执行Aspect的代码:
        aspect.doAccessCheck();
        // 再执行UserService的逻辑:
        return target.fun();
        }
}

  在Java平台上,对于AOP的织入,有3种方式:

  1. 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入;
  2. 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
  3. 运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。

最简单的方式是第三种,Spring的AOP实现就是基于JVM的动态代理。关于动态代理,可以参见JAVA之反射_milanleon的博客-CSDN博客_类的反射 中相关的内容。JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。Spring中也是支持AspectJ,但仅仅是支持AspectJ的切点解析和匹配,即使用@AspectJ注解来做AOP,但并没有使用AspectJ其中的实现AOP的方法,Spring具体的AOP实现还是JDK动态代理与CGLIB(Spring对接口类型使用JDK动态代理,对普通类使用CGLIB创建子类。如果一个Bean的class是final,Spring将无法为其创建子类)。

 5、AOP避坑

  ①、访问使用AOP的Bean时,总是调用方法而非直接访问成员。

  ②、如果Bean有可能使用AOP的话,不要编写public final成员方法。

  如下所示的Foo类,在使用了AOP后,从ApplicationContext中获取的Foo实例实际上是代理类FooProxy对象,而代理类的构造方法中并没有调用super()来执行父类Foo的构造方法,所以成员zoneId实际上并没有被初始化。虽然Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器会自动加上,但Spring使用CGLIB构造的FooProxy类是直接生成字节码文件,绕过了编译器编译字节码这个步骤。所以直接引用Foo类成员获得的是null。调用final成员方法获得的zoneId成员也为null,因为FooProxy类无法覆写final方法,调用该方法就相当于是直接访问ZoneId成员。

@Component
public class Foo {
    public  ZoneId zoneId = ZoneId.systemDefault(); //该初始化实际上是在Foo类的构造方法中执行
    public final ZoneId getFinalZoneId() { return zoneId; }

    public ZoneId getZoneId() { return zoneId; }
}

@Aspect
@Component
public class LoggingAspect {
    @Before("execution(public * xsl.Foo.*(..))") //对Foo类使用AOP:执行其方法前先调用metric()方法
    public void metric() {
        System.out.println("metric func()");
    }
}

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class App {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(App.class);

        Foo f = context.getBean(Foo.class);
        ZoneId zoneId = f.zoneId; // zoneId为null
        zoneId = f.getFinalZoneId(); // zoneId为null
        zoneId = f.getZoneId(); //zoneId不为null
    }
}

6、总结

   AOP称为“面向切面编程”,使用AOP可以实现在指定类的方法执行前或执行后插入其它操作,比如想要在Foo的func()方法执行前进行一些其它操作的话,那么可以定义一个切面类,在切面类中定义要插入的动作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值