3分钟彻底吃透 Spring AOP 切面编程技术!

一、什么面向切面编程

众所周知,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,输出结果如下!


                
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值