AOP
AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是Spring框架中的一个重要内容,它通过对既有程序定义一个切入点,然后在其前后切入不同的执行内容,比如常见的有:打开数据库连接/关闭数据库连接、打开事务/关闭事务、记录日志等。基于AOP不会破坏原来程序逻辑,因此它可以很好的对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
为什么要使用 AOP
在实际的开发过程中,我们的应用程序会被分为很多层。通常来讲一个 Java 的 Web 程序会拥有以下几个层次:
- Web 层:主要是暴露一些 Restful API 供前端调用。
- 业务层:主要是处理具体的业务逻辑。
- 数据持久层:主要负责数据库的相关操作(增删改查)。
虽然看起来每一层都做着全然不同的事情,但是实际上总会有一些类似的代码,比如日志打印和安全验证等等相关的代码。如果我们选择在每一层都独立编写这部分代码,那么久而久之代码将变的很难维护。所以我们提供了另外的一种解决方案: AOP。这样可以保证这些通用的代码被聚合在一起维护,而且我们可以灵活的选择何处需要使用这些代码。
AOP 的核心概念
- 切面(Aspect):通常是一个类,在里面可以定义切入点和通知。
- 连接点(Joint Point):被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在 Spring 中连接点指的就是被拦截的到的方法,实际上连接点还可以是字段或者构造器。
- 切入点(Pointcut):对连接点进行拦截的定义。
- 通知(Advice):拦截到连接点之后所要执行的代码,通知分为前置、后置、异常、最终、环绕通知五类。
- AOP 代理:AOP 框架创建的对象,代理就是目标对象的加强。Spring 中的 AOP 代理可以使 JDK 动态代理,也可以是 CGLIB 代理,前者基于接口,后者基于子类。
Spring AOP
Spring 中的 AOP 代理还是离不开 Spring 的 IOC 容器,代理的生成,管理及其依赖关系都是由 IOC 容器负责,Spring 默认使用 JDK 动态代理,在需要代理类而不是代理接口的时候,Spring 会自动切换为使用 CGLIB 代理,不过现在的项目都是面向接口编程,所以 JDK 动态代理相对来说用的还是多一些。在本文中,我们将以注解结合 AOP 的方式来分别实现 Web 日志处理和分布式锁。
Spring AOP 相关注解
@Aspect
: 将一个 java 类定义为切面类。@Pointcut
:定义一个切入点,可以是一个规则表达式,比如下例中某个package
下的所有函数,也可以是一个注解等。@Before
:在切入点开始处切入内容。@After
:在切入点结尾处切入内容。@AfterReturning
:在切入点 return 内容之后切入内容(可以用来对处理返回值做一些加工处理)。@Around
:在切入点前后切入内容,并自己控制何时执行切入点自身的内容。@AfterThrowing
:用来处理当切入内容部分抛出异常之后的处理逻辑。
其中 @Before
、@After
、@AfterReturning
、@Around
、@AfterThrowing
都属于通知。
AOP 顺序问题
在实际情况下,我们对同一个接口做多个切面,比如日志打印、分布式锁、权限校验等等。这时候我们就会面临一个优先级的问题,这么多的切面该如何告知 Spring 执行顺序呢?这就需要我们定义每个切面的优先级,我们可以使用 @Order(i)
注解来标识切面的优先级, i
的值越小,优先级越高。假设现在我们一共有两个切面,一个 WebLogAspect
,我们为其设置@Order(100)
;而另外一个切面 DistributeLockAspect
设置为 @Order(99)
,所以 DistributeLockAspect
有更高的优先级,这个时候执行顺序是这样的:在 @Before
中优先执行 @Order(99)
的内容,再执行 @Order(100)
的内容。而在 @After
和 @AfterReturning
中则优先执行 @Order(100)
的内容,再执行 @Order(99)
的内容,可以理解为先进后出的原则。
基于注解的 AOP 配置
使用注解一方面可以减少我们的配置,另一方面注解在编译期间就可以验证正确性,查错相对比较容易,而且配置起来也相当方便。相信大家也都有所了解,我们现在的 Spring 项目里面使用了非常多的注解替代了之前的 xml 配置。而将注解与 AOP 配合使用也是我们最常用的方式,在本文中我们将以这种模式实现 Web 日志统一处理和分布式锁两个注解。下面就让我们从准备工作开始吧。
添加依赖
我们需要添加 Web 依赖和 AOP 相关依赖,只需要在 pom.xml 中添加如下内容即可:
清单 1. 添加 web 依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
清单 2. 添加 AOP 相关依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
确保Spring AOP已被启用,通过@EnableAspectJAutoProxy注解在启动类中启用它。
应用场景1:WEB日志切面
为什么要实现 Web日志统一处理
在实际的开发过程中,我们会需要将接口的出请求参数、返回数据甚至接口的消耗时间都以日志的形式打印出来以便排查问题,有些比较重要的接口甚至还需要将这些信息写入到数据库。而这部分代码相对来讲比较相似,为了提高代码的复用率,我们可以以 AOP 的方式将这种类似的代码封装起来。
import java.util.Arrays;
import javax.servlet.http.HttpServletRequest;
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.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;@Aspect
@Component
public class WebLogAspect {private final Logger logger = LoggerFactory.getLogger(WebLogAspect.class);ThreadLocal<Long> startTime = new ThreadLocal<>();/*** 以 controller 包下定义的所有请求为切入点*/@Pointcut("execution(public * com.didispace.web.controller..*.*(..))")public void webLog(){}@Before("webLog()")public void doBefore(JoinPoint joinPoint) throws Throwable {// 接收到请求,记录请求内容ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();// 记录下请求内容logger.info("URL : " + request.getRequestURL().toString());logger.info("HTTP_METHOD : " + request.getMethod());logger.info("IP : " + request.getRemoteAddr());logger.info("CLASS_METHOD : " + joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName());logger.info("ARGS : " + Arrays.toString(joinPoint.getArgs()));startTime.set(System.currentTimeMillis());}@AfterReturning(returning = "ret", pointcut = "webLog()")public void doAfterReturning(Object ret) throws Throwable {// 处理完请求,返回内容logger.info("RESPONSE : " + ret);logger.info("SPEND TIME : " + (System.currentTimeMillis() - startTime.get()));}}
应用场景2:Redis的分布式锁
为什么要使用分布式锁
我们程序中多多少少会有一些共享的资源或者数据,在某些时候我们需要保证同一时间只能有一个线程访问或者操作它们。在传统的单机部署的情况下,我们简单的使用 Java 提供的并发相关的 API 处理即可。但是现在大多数服务都采用分布式的部署方式,我们就需要提供一个跨进程的互斥机制来控制共享资源的访问,这种互斥机制就是我们所说的分布式锁。
代码实现:SPRING BOOT 动态定时任务_springboot 动态定时任务-CSDN博客
应用场景3:字符串首尾空格去除
我们的程序接口,提交的参数列表需要去除字符串首尾空格。Java中经常使用VO等类接收传参,通过切面统一处理
自定义注解定义
package com.iris.websocket.annotation;import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TrimSpace {
}
切面实现
import java.beans.Introspector;
import java.lang.reflect.Field;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;/*** @author hudy* @date 2024/8/7 16:18*/
@Aspect
@Component
public class TrimSpaceAnnotationAspect {@Pointcut("@annotation(com.iris.websocket.annotation.TrimSpace)")public void trimSpaceAnnotatedSetter() {}@Before("trimSpaceAnnotatedSetter()")public void trimSpacesBeforeSet(JoinPoint joinPoint) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException {MethodSignature signature = (MethodSignature) joinPoint.getSignature();String propertyName = Introspector.decapitalize(signature.getName().replace("set", ""));Object target = joinPoint.getTarget();Field field = target.getClass().getDeclaredField(propertyName);field.setAccessible(true);Object value = field.get(target);log.info("从{}中取出的属性:{},设置的原值:{}", signature.getName(), propertyName, value);if (value instanceof String) {String trimmedValue = ((String) value).trim();field.set(target, trimmedValue);}}
}
上述代码中,@Pointcut定义了切点,即标注了@TrimSpace的方法。@Before通知则定义了在这些方法执行前需要执行的操作——去除字符串参数的首尾空格。
VO对象应用
这个实现方式允许您通过在VO对象的属性上添加@TrimSpace注解,精确控制哪些字符串属性需要去除首尾空格。这样既保持了代码的清晰度,又提高了可维护性和扩展性。注意:set方法和属性名定义需要遵循驼峰规则,不支持lombok替换set。
public class UserVO {@TrimSpaceprivate String name;public String getName() {return name;}public void setName(String name) {this.name = name;}
}
测试场景以验证
@RunWith(SpringRunner.class)
@SpringBootTest
public class TrimSpaceAspectTest {@Autowiredprivate UserVO userVo; // 假设UserVo已被Spring管理@Testpublic void testTrimSpaceOnSetName() {String nameWithSpaces = " Harry Potter ";userVo.setName(nameWithSpaces);assertEquals("Harry Potter", userVo.getName()); // 验证空格被去除}
}