一、什么面向切面编程
众所周知,Spring 最核心的两个功能 IOC 和 AOP,即控制反转和面向切面编程。
所谓的控制反转(Inversion of Control),其核心思想是将对象的控制权转移到容器中,比如大家所熟知的 Bean 工厂容器,可以有效提高代码的可重用性,在面向对象的编程中经常使用。
而面向切面编程(Aspect Oriented Programming),是软件开发中的常用编程思路,也是 Spring 框架中的一个重要内容。开发者利用 AOP 可以对业务逻辑的各个部分进行隔离,降低各个部分之间的耦合度,进一步提高程序的可重用性,极大减少重复代码。
面向切面编程可以看作是面向对象编程的补充,例如记录每个接口的请求日志,通常有两种实现逻辑。
-
第一种:采用面向对象的编程思路,在每个接口方法中增加记录日志的逻辑,如果有 100 个接口方法,就需要写 100 次记录日志的代码
-
第二种,采用面向切面的编程思路,在每个接口方法中织入一个动态代理对象,拦截方法的前后请求,并记录相关日志,只需要写1次记录日志的逻辑
很显然,采用面向切面的编程思想,既能减少重复代码,也能节省开发时间。
实际的业务开发中,AOP 应用场景非常广泛,比如日志记录、性能统计、安全控制、权限管理、事务处理、异常处理等等。
说了这么多的优点,如何在 Spring Boot 中使用 AOP 功能呢?
下面我们一起来看一个简单的应用示例。
二、简单应用示例
以记录接口方法请求的面向切面处理为例,创建一个 Spring Boot 工程,实现过程如下!
2.1、添加依赖包
首先,在pom.xml
文件中,添加spring-boot-starter-aop
依赖包,内容如下!
<!--aop 切面-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2.2、定义一个接口方法
然后,定义一个用户登陆的接口方法,内容如下!
package com.example.basic.aop.web;
@RestController
publicclass UserController {
@RequestMapping(value = "/login")
public Object login(@RequestParam("userName") String userName,
@RequestParam("userPwd") String userPwd){
System.out.println("-------->收到登陆请求<--------");
if(!("sys".equals(userName) && "123456".equals(userPwd))){
thrownew RuntimeException("用户密码不正确!");
}
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "登陆成功!");
return result;
}
}
2.3、编写切面处理类
接着,编写一个切面处理类,对com.example.basic.aop.web
包下的所有类的public
方法进行代理通知,内容如下!
package com.example.basic.aop.aspect;
@Order(1)
@Component
@Aspect
publicclass ControllerAspect {
/***
* 定义切入点
*/
@Pointcut("execution(public * com.example.basic.aop.web..*.*(..))")
public void methodAdvice(){}
/**
* 方法调用前通知
*/
@Before(value = "methodAdvice()")
public void before(JoinPoint joinPoint){
System.out.println("代理-> 来自Before通知,方法名称:" + joinPoint.getSignature().getName());
}
/**
* 方法调用后通知
*/
@After(value = "methodAdvice()")
public void after(JoinPoint joinPoint){
System.out.println("代理-> 来自After通知,方法名称:" + joinPoint.getSignature().getName());
}
/**
* 方法调用后通知,方法正常执行后,会通知;如果抛异常,不会通知
*/
@AfterReturning(value = "methodAdvice()", returning = "returnVal")
public void afterReturning(JoinPoint joinPoint,Object returnVal){
System.out.println("代理-> 来自AfterReturning通知,方法名称:" + joinPoint.getSignature().getName() + ",返回值:" + returnVal);
}
/**
* 方法环绕通知
*/
@Around(value = "methodAdvice()")
public Object around(ProceedingJoinPoint joinPoint) {
System.out.println("代理-> 来自Around环绕前置通知,方法名称:" + joinPoint.getSignature().getName());
Object returnValue = joinPoint.proceed();
System.out.println("代理-> 来自Around环绕后置通知,方法名称:" + joinPoint.getSignature().getName());
return returnValue;
}
/**
* 抛出异常通知
*/
@AfterThrowing(value = "methodAdvice()", throwing = "ex")
public void afterThrowing(JoinPoint joinPoint, Exception ex) {
System.out.println("代理-> 来自AfterThrowing通知,方法名称:" + joinPoint.getSignature().getName() + ",错误信息:" + ex.getMessage());
}
}
2.4、验证服务
最后,将服务启动起来,验证一下代码的正确性。
在浏览器中发起/login?userName=sys&userPwd=123456
,输出结果如下!
代理-> 来自Around环绕前置通知,方法名称:login
代理-> 来自Before通知,方法名称:login
-------->收到登陆请求<--------
代理-> 来自Around环绕后置通知,方法名称:login
代理-> 来自After通知,方法名称:login
代理-> 来自AfterReturning通知,方法名称:login,返回值:{msg=登陆成功!, code=200}
在浏览器中发起/login?userName=sys&userPwd=123
,输出结果如下!
代理-> 来自Around环绕前置通知,方法名称:login
代理-> 来自Before通知,方法名称:login
-------->收到登陆请求<--------
代理-> 来自After通知,方法名称:login
代理-> 来自AfterThrowing通知,方法名称:login,错误信息:用户密码不正确!
三、技术要点解读
从以上的示例中可以初步的了解到,在 Spring Boot 中使用 AOP 功能,主要有三个要点。
-
定义切面类
-
定义切入点
-
定义通知方法
3.1、定义切面类
定义切面类,顾名思义,当采用面向切面编程的时候,需要在类上引入@Aspect
注解,以便告知 Spring Boot,它是一个切面容器,以便更好的进行管理。
通常与@Order
注解搭配使用,当一个方法同时有多个切面处理类的时候,会根据@Order
中指定的顺序来以依次执行,值越低,优先级越高,如果没有,默认值为Integer
最大值。
3.2、定义切入点
定义切入点,简单的说就是,用于告知切面容器,在什么地方切入,由@Pointcut
注解和切点表达式组成。
其中,切点表达式支持的方式很多,常用的有以下三种定义方式。
-
execution(public * com.example.*.*(..))
表达式:可以实现对某个包、某个方法或者某个参数进行切入,其中参数*
表示任意的意思,实际应用最为广泛,同时不会侵入目标方法; -
@within(com.example.A)
表达式:如果某个类上标注了注解@A
,该类下所有非private
方法,都会被匹配为切入点,与execution
方式相比,这种方式更加灵活; -
@annotation(com.example.A)
表达式:如果某个方法上标注了注解@A
,该方法就会被匹配为切入点,与@within
注解方式类似,支持的颗粒度更细;需要注意的是,如果标注在private
方法上,代理方法会无效。
实际业务中,@within
和@annotation
注解通常会搭配使用,以便实现对某个方法进行更精准的切入,比较经典的应用有@Transactional
事务注解。
3.3、定义通知方法
定义通知方法,这个很好理解,用于在切入点中引入对应的通知方法。
比如业务逻辑中需要事务支持、日志记录等操作,这个逻辑就在此处编写定义。
Spring 支持的通知方法有 5 种,通过以下注解来定义。
-
@Before
:表示在切点方法之前执行; -
@After
:表示在切点方法之后执行; -
@AfterReturning
:表示在切点方法之后执行,带返回值,如果抛异常,不会执行; -
@AfterThrowing
:表示在切点方法抛异常时执行; -
@Around
:表示环绕增强,能控制切点执行前和执行后的逻辑处理,当切点方法抛异常,如果环绕通知逻辑吃掉了异常,@AfterThrowing
注解不会生效;
其中@Around
注解在实际上开发中最常用,可以实现其它四个注解的功能。
3.4、切面的执行顺序
可能有的人会发出一个疑问,如果一个方法上有多个切面处理器,此时是如何运行的呢?
可以用如下流程图来概括,其中Method
指的是目标方法!
图片出自csdn - TheBiiigBlue
四、案例场景介绍
回到文章开头介绍的案例,下面我们以日志记录为例,采用注解的切入方式,定制化代理目标方法实现请求日志的打印操作。
实现过程如下!
4.1、定义系统日志注解
首先,自定义一个系统日志注解,用于代理指定的类或方法,示例如下:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
@Documented
public @interface SystemLog {
/**
* 日志描述
* @return
*/
String desc() default "";
}
4.2、定义日志实体类
然后,自定义一个日志实体类,用于记录接口的请求信息,示例如下:
public class SystemLogEntity {
/**
* 类名称
*/
private String className;
/**
* 方法名称
*/
private String methodName;
/**
* 请求参数
*/
private String param;
/**
* 请求结果,true:成功,false:失败
*/
private Boolean result;
/**
* 错误原因
*/
private String message;
/**
* 方法描述
*/
private String desc;
// set、get方法等...
}
4.3、编写切面处理类
接着,编写一个切面处理类,用于代理目标方法,示例如下:
@Order(2)
@Component
@Aspect
publicclass SystemLogAspect {
/**
* 定义切入点,切入所有标注此注解的类和方法
*/
@Pointcut("@within(com.example.basic.aop.aspect.SystemLog)|| @annotation(com.example.basic.aop.aspect.SystemLog)")
public void methodAdvice() {}
/**
* 方法环绕通知
*/
@Around(value = "methodAdvice()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 记录请求日志
SystemLogEntity logEntity = buildSystemLogEntity(joinPoint);
try {
// 执行目标方法
return joinPoint.proceed();
} catch (Throwable throwable) {
logEntity.setResult(false);
logEntity.setMessage(throwable.getMessage());
throw throwable;
} finally {
System.out.println("代理-> 来自Around环绕通知,请求日志:" + logEntity.toString());
}
}
/**
* 封装日志请求
* @param joinPoint
* @return
* @throws Exception
*/
private SystemLogEntity buildSystemLogEntity(ProceedingJoinPoint joinPoint) throws Exception {
// 获取目标类名
String targetName = joinPoint.getTarget().getClass().getSimpleName();
// 获取目标方法
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
// 获取目标请求参数值
Object[] args = joinPoint.getArgs();
// 获取目标方法上的注解配置
SystemLog systemLog = getSystemLogConfig(method);
// 封装系统日志信息
SystemLogEntity logEntity = new SystemLogEntity();
logEntity.setClassName(targetName);
logEntity.setMethodName(method.getName());
logEntity.setParam(getParametersLog(methodSignature, args));
logEntity.setResult(true);
logEntity.setDesc(systemLog.desc());
return logEntity;
}
/**
* 获取类或者方法上的注解配置
* @param method
* @return
*/
private SystemLog getSystemLogConfig(Method method){
// 优先从方法上获取目标注解
if(method.isAnnotationPresent(SystemLog.class)){
return method.getAnnotation(SystemLog.class);
}
// 如果方法上找不到,则从类上获取注解
Class<?> declaringClass = method.getDeclaringClass();
return declaringClass.getAnnotation(SystemLog.class);
}
/**
* 获取请求参数详情
* @param args
* @return
*/
private String getParametersLog(MethodSignature methodSignature, Object[] args) {
// 获取参数名称
String[] parameterNames = methodSignature.getParameterNames();
StringBuilder stringBuilder = new StringBuilder();
if (parameterNames != null && parameterNames.length > 0) {
for (int i = 0; i < parameterNames.length; i++) {
stringBuilder.append(parameterNames[i])
.append(":")
.append(args[i])
.append(";");
}
}
return stringBuilder.toString();
}
}
4.4、标注要切入的方法
最后,在需要记录日志的方法上增加@SystemLog
注解即可。
例如上文介绍的login()
方法。
@RestController
public class UserController {
@SystemLog(desc = "用户登陆")
@RequestMapping(value = "/login")
public Object login(@RequestParam("userName") String userName,
@RequestParam("userPwd") String userPwd){
// 省略...
}
}
可以将@SystemLog
标注在类上,此时方法就无需标注了。
如果@SystemLog
同时出现在类和方法上,会优先取方法上的注解配置。
4.5、验证服务
完成以上步骤之后,将服务启动,一起来验证一下代码的正确性。
在浏览器中发起/login?userName=sys&userPwd=123456
,输出结果如下!
-------->收到登陆请求<--------
代理-> 来自Around环绕通知,请求日志:SystemLogEntity{className='UserController', methodName='login', param='userName:sys;userPwd:123456;', result=true, message=null, desc='用户登陆'}
在浏览器中发起/login?userName=sys&userPwd=123
,输出结果如下!