以下转载和参考自:装配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种方式:
- 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入;
- 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
- 运行期:目标对象和切面都是普通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()方法执行前进行一些其它操作的话,那么可以定义一个切面类,在切面类中定义要插入的动作。
本文详细介绍了Spring中的AOP(面向切面编程)概念,包括代理模式的应用,如何通过AOP实现代码插入,如在方法执行前后添加权限检查和日志记录。讲解了AOP的四种拦截器类型,并通过示例展示了如何使用@AspectJ注解进行切点匹配。同时,讨论了AOP的实现原理,包括动态代理和静态代理的区别,以及在实际应用中需要注意的陷阱,如避免直接访问Bean的成员。最后,总结了AOP的重要性和使用场景。
2929

被折叠的 条评论
为什么被折叠?



