Spring AOP

目录

2. AOP基础

2.1 AOP概述

2.2 Spring AOP快速入门

2.3 AOP核心概念

2.3.1 AOP的执行流程

3. AOP进阶

3.1 通知类型

在使用通知时的注意事项:

怎么来解决这个切入点表达式重复的问题?

3.2 通知顺序

如果我们想控制通知的执行顺序有两种方式:

通知的执行顺序总结:

3.3 切入点表达式

常见形式:

3.3.1 execution-切入点表达式

3.3.2 @annotation-切入点表达式

两种常见的切入点表达式总结:

3.4 连接点

4. AOP案例 - 记录操作日志

4.1 需求

4.2 分析

4.3 步骤

4.4 实现

4.4.1 准备工作

4.4.2 编码实现

5. Spring AOP底层动态代理的方式 

5.1 JDK动态代理 

5.2 CGLIB动态代理 

5.3 JDK动态代理与Cglib动态代理的本质区别

总结:


Spring框架的第一大核心:IOC控制反转,Spring框架的第二大核心:AOP面向切面编程 

2. AOP基础

在AOP基础这个阶段,我们首先介绍一下什么是AOP,再通过一个快速入门程序,让大家快速体验AOP程序的开发。最后再介绍AOP当中所涉及到的一些核心的概念

2.1 AOP概述

什么是AOP?

  • AOP英文全称:Aspect Oriented Programming(面向切面编程、面向方面编程),其实说白了,面向切面编程就是面向特定的方法进行编程

那什么又是面向方法编程呢,为什么又需要面向方法编程呢?

来我们举个例子做一个说明:

  • 比如,我们这里有一个项目,项目中开发了很多的业务功能。

然而有一些业务功能执行效率比较低,执行耗时较长,我们需要针对于这些业务方法进行优化那首先第一步就需要定位出执行耗时比较长的业务方法,再针对于业务方法再来进行优化。

此时我们就需要统计当前这个项目当中每一个业务方法的执行耗时。

那么统计每一个业务方法的执行耗时该怎么实现呢?

可能多数人首先想到的就是在每一个业务方法运行之前,记录这个方法运行的开始时间。在这个方法运行完毕之后,再来记录这个方法运行的结束时间。拿结束时间减去开始时间,不就是这个方法的执行耗时吗? 

以上分析的实现方式是可以解决需求问题的。

但是对于一个项目来讲,里面会包含很多的业务模块,每个业务模块又包含很多增删改查的方法,如果我们要在每一个模块下的业务方法中,添加记录开始时间、结束时间、计算执行耗时的代码,就会让程序员的工作变得非常繁琐。  

AOP面向方法编程,就可以做到在不改动这些原始方法的基础上,针对特定的方法进行功能的增强。

AOP的作用:在程序运行期间在不修改源代码的基础上对已有方法进行增强(无侵入性: 解耦) 

我们要想完成统计各个业务方法执行耗时的需求我们只需要定义一个模板方法将记录方法执行耗时这一部分公共的逻辑代码,定义在模板方法当中,在这个方法开始运行之前,来记录这个方法运行的开始时间,在方法结束运行的时候,再来记录方法运行的结束时间,中间就来运行原始的业务方法。

而中间运行的原始业务方法,可能是其中的一个业务方法,也可能是一个业务模块当中的多个业务方法。

面向这样的指定的一个或多个方法进行编程,我们就称之为 面向切面编程。

那此时,当我们再调用部门管理的 list 业务方法时,并不会直接执行原始业务方法的逻辑 list 方法的逻辑,而是会执行我们所定义的 模板方法 , 然后再模板方法中:

  • 记录方法运行开始时间

  • 运行原始的业务方法(那此时原始的业务方法,就是 list 方法)

  • 记录方法运行结束时间,计算方法执行耗时

不论,我们运行的是哪个业务方法,最后其实运行的就是我们定义的模板方法,而在模板方法中,就完成了原始方法执行耗时的统计操作 。(那这样呢,我们就通过一个模板方法就完成了指定的一个或多个业务方法执行耗时的统计)

而大家会发现,这个流程,我们是不是似曾相识啊?

对了,就是和我们之前所学习的动态代理技术是非常类似的。 我们所说的模板方法,其实就是代理对象中所定义的方法,那代理对象中的方法以及根据对应的业务需要, 完成了对应的业务功能,当运行原始业务方法时,就会运行代理对象中的方法,从而实现统计业务方法执行耗时的操作。

其实,AOP面向切面编程和OOP面向对象编程一样,它们都仅仅是一种编程思想,而动态代理技术是这种思想最主流的实现方式。而Spring的AOP是Spring框架的高级技术,旨在管理bean对象的过程中底层使用动态代理机制,对特定的方法进行编程(功能增强)。

AOP的优势:

  1. 减少重复代码

  2. 提高开发效率

  3. 维护方便

2.2 Spring AOP快速入门

在了解了什么是AOP后,我们下面通过一个快速入门程序,体验下AOP的开发,并掌握Spring中AOP的开发步骤。   

需求:统计各个业务层方法执行耗时。

实现步骤:

  1. 导入依赖:在pom.xml中导入AOP的依赖

  2. 编写AOP程序:针对于特定方法根据业务需要进行编程

1. 由于当前是SpringBoot环境,所以我们要引入SpringBoot AOP的起步依赖:pom.xml

<!-- SpringBoot AOP的起步依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

2. 编写AOP程序:TimeAspect 

package com.gch.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Slf4j
@Component // 当前类的实例交给Spring的IOC容器管理,成为IOC容器当中的bean对象
@Aspect // 表示当前类为AOP切面类
public class TimeAspect {
    /**
     * 记录方法的执行时间
     * @param joinPoint ProceedingJoinPoint的形参
     * @return 返回原始方法执行后的返回值
     * @throws Throwable
     * 通过@Around注解当中的表达式来指定当前我们所编写的AOP程序(当前共性的功能)要针对于哪些特定的方法来进行编程
     * 第一个*表示返回值,任意  第二个*表示类名/接口名  第三个*表示方法名
     * 代表当我们在运行com.gch.service这个包下所有的接口或者是类当中所有的方法时,都会运行这个方法当中所封装的公共的逻辑代码
     */
    @Around("execution(* com.gch.service.*.*(..))") // 切入点表达式
    public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1.记录开始时间
        long begin = System.currentTimeMillis();

        // 2.调用原始方法运行 - 借助AOP当中提供的AIP - ProceedingJoinPoint - 封装了原始方法的相关信息
        // ProceedingJoinPoint的proceed()实例方法就可以来调用原始方法运行  result指的是原始方法的返回值
        Object result = joinPoint.proceed();

        // 3.记录结束时间.计算方法的执行耗时
        long end = System.currentTimeMillis();

        // 4.记录日志:ProceedingJoinPoint的getSignature()实例方法就可以拿到方法的签名
        log.info(joinPoint.getSignature() + "方法执行耗时:{}ms",end - begin);

        return result;
    }
}

我们通过AOP入门程序完成了业务方法执行耗时的统计,那其实AOP的功能远不止于此,常见的应用场景如下:

  • 记录系统的操作日志

  • 完成权限的控制

  • 完成事务管理:我们前面所学习的Spring事务管理,底层其实也是通过AOP来实现的,只要添加@Transactional注解之后,AOP程序自动会在原始方法运行前先来开启事务,在原始方法运行完毕之后提交或回滚事务

这些都是AOP应用的典型场景。

通过入门程序,我们也应该感受到了AOP面向切面编程的一些优势:

  • 代码无侵入:在不修改原始的业务方法的前提下,就已经对原始的业务方法进行了功能的增强或者是功能的改变

  • 减少了重复代码

  • 提高开发效率

  • 维护方便:因为如果说共性的逻辑我需要改变,此时我只需要去改变AOP当中的模板方法就可以了,所有的原始方法都不需要改动

2.3 AOP核心概念

1. 连接点:JoinPoint指的是可以被AOP控制的方法,其实所有的方法都是连接点暗含方法执行时的相关信息  通过连接点来获取方法执行时的相关信息!

例如:入门程序当中所有的业务方法都是可以被aop控制的方法。

在SpringAOP提供的JoinPoint当中,封装了连接点方法在执行时的相关信息。

2. 通知:Advice指的就是那些抽取出来的重复的逻辑,也就是共性功能(最终体现形式为一个方法,定义了要做什么)  

  • 封装共性功能的方法就是通知。

在AOP面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。

3. 切入点:Pointcut切入点指的是匹配连接点的条件通知仅会在切入点方法执行时被应用 

在通知当中,我们所定义的共性功能到底要应用在哪些方法上?

  • 此时就涉及到了切入点Pointcut概念
  • 切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。  
  • 简单理解,切入点就是实际被AOP控制的方法或者理解为需要被增强的方法。
  • 在AOP的开发当中,我们通常会通过一个切入点表达式来描述切入点
  • 切入点定义了要在什么地方来应用这个通知!
  • 通常在AOP的开发当中,通过切入点表达式来描述切入点。

假如:切入点表达式改为DeptServiceImpl.list(),此时就代表仅仅只有list这一个方法是切入点。只有list()方法在运行的时候才会应用通知。  

@Around注解代表的是环绕, 

4. 切面:Aspect切面描述通知与切入点的对应关系每个切面由切入点和通知组成(通知+切入点)  

  • 当通知和切入点结合在一起,就形成了一个切面。
  • 通过切面就能够描述当前aop程序需要针对于哪个原始方法,在什么时候执行什么样的操作。
  • 而切面所在的类,也就是被@Aspect注解标识的类,我们一般称为切面类

5. 目标对象:Target目标对象指的就是通知所应用 / 作用的对象,我们就称之为目标对象

2.3.1 AOP的执行流程

分析:我们所定义的通知是如何与目标对象结合在一起,对目标对象当中的方法进行功能增强的?  

  • Spring的AOP底层是基于动态代理技术来实现的,Spring AOP它的底层是基于动态代理技术来实现的,也就是说在程序运行的时候,会自动的基于动态代理技术为目标对象生成一个对应的代理对象。在代理对象当中就会对目标对象当中的原始方法进行功能的增强。
  • 也就是说,一旦我们进行了AOP程序的开发,最终运行的就不再是原始的目标对象,而是基于目标对象所生成的代理对象。
  • 至于如何来增强,以及增强的逻辑,其实就是与我们所定义的通知相关。

Spring AOP底层的另外一种动态代理技术:Cglib动态代理

3. AOP进阶

AOP的基础知识学习完之后,下面我们对AOP当中的各个细节进行详细的学习。主要分为4个部分:

  1. 通知类型:Spring AOP当中支持的通知类型

  2. 通知顺序:多个通知之间,通知的执行顺序

  3. 切入点表达式的写法 

  4. 连接点JoinPoint:通过连接点来获取方法执行时的相关信息

3.1 通知类型

在入门程序当中,我们已经使用了一种功能最为强大的通知类型:Around环绕通知

  • 只要我们在通知方法上加上了@Around注解,就代表当前通知是一个环绕通知。

Spring AOP当中所支持的五种通知类型:

  1. @Around:环绕通知 Around Advice此注解标注的通知方法在目标方法前、后都被执行,都会来执行通知方法当中的逻辑,而中间运行的就是原始方法。@Around环绕通知中如果原始方法在调用时出现异常,那么通知方法中环绕之后的代码逻辑就不会再执行了(因为原始方法调用已经出异常了)

  2. @Before:前置通知此注解标注的通知方法在目标方法运行之前被执行

  3. @After :后置通知 / 最终通知此注解标注的通知方法在目标方法运行之后被执行无论目标方法在执行的过程中是否有异常都会执行,因此也叫最终通知。

  4. @AfterReturning : 返回后通知此注解标注的通知方法在目标方法运行之后被执行如果目标方法执行过程中有异常,@AfterReturning标识的通知方法 则不会执行;如果目标方法在执行的过程当中抛出了异常,那这个返回后通知就不会执行;只有目标方法正常执行返回,它才会执行

  5. @AfterThrowing : 异常后通知此注解标注的通知方法会在目标方法运行发生异常后执行,正常情况下@AfterThrowing通知类型它是不会被执行的。原始方法(程序)没有发生异常的情况下,@AfterThrowing标识的通知方法是不会执行的。

@AfterReturning与@AfterThrowing两个之间是互斥的!啥意思?就是其中一个执行,另外一个必然不会执行。 

注意:目标方法指的就是原始方法! 

在使用通知时的注意事项:
  • @Around环绕通知需要自己调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行

  • @Around环绕通知方法的返回值,必须指定为Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的。

五种常见的通知类型,我们已经测试完毕了,此时我们再来看一下刚才所编写的代码,有什么问题吗?  

//前置通知
@Before("execution(* com.itheima.service.*.*(..))")

//环绕通知
@Around("execution(* com.itheima.service.*.*(..))")
  
//后置通知
@After("execution(* com.itheima.service.*.*(..))")

//返回后通知(程序在正常执行的情况下,会执行的后置通知)
@AfterReturning("execution(* com.itheima.service.*.*(..))")

//异常通知(程序在出现异常的情况下,执行的后置通知)
@AfterThrowing("execution(* com.itheima.service.*.*(..))")

我们发现啊,每一个注解里面都指定了切入点表达式,而且这些切入点表达式都一模一样。此时我们的代码当中就存在了大量的重复性的切入点表达式;假如此时切入点表达式需要变动,就需要将所有的切入点表达式一个一个的来改动,就变得非常繁琐了。

怎么来解决这个切入点表达式重复的问题?
  • 答案就是:抽取  
  • Spring提供了@Pointcut注解该注解的作用是将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可。通过@Pointcut注解来指定公共的切入点表达式。  
  • Pointcut直译过来是切入点的意思。
package com.gch.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Aspect // 标识当前类为AOP切面类
public class MyAspect1 {
    /**
     * 切入点方法
     * 通过@Pointcut注解来指定公共的切入点表达式
     */
    @Pointcut("execution(* com.gch.service.*.*(..))")
    private void pt(){

    }

    /**
     * @Before 前置通知
     * @param joinPoint 用于表示正在执行的连接点,通过JoinPoint可以访问方法的参数、方法签名、目标对象等信息
     */
    @Before("pt()") // 类似于方法调用的形式来引入切入点表达式
    public void before(JoinPoint joinPoint){
        log.info("before ...");

    }
    
    /**
     * @Around 环绕通知
     * @param proceedingJoinPoint(Interface) extends JoinPoint(Interface)
     * @return 原始方法的返回值
     * @throws Throwable
     */
    @Around("pt()") // 引入切入点
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        log.info("around before ...");

        //调用目标对象的原始方法执行
        Object result = proceedingJoinPoint.proceed();
        //原始方法在执行时:发生异常,后续代码将不再执行

        log.info("around after ...");
        return result;
    }

    /**
     * @After 后置通知
     * @param joinPoint 用于表示正在执行的连接点
     */
    @After("pt()") // 引入切入点
    public void after(JoinPoint joinPoint){
        log.info("after ...");
    }

    /**
     * @AfterReturning 返回后通知(程序在正常执行返回的情况下,会执行的后置通知)
     * @param joinPoint 用于表示正在执行的连接点
     */
    @AfterReturning("pt()") // 引入切入点
    public void afterReturning(JoinPoint joinPoint){
        log.info("afterReturning ...");
    }


    /**
     * @AfterThrowing 异常通知(程序在出现异常的情况下,执行的后置通知)
     * @param joinPoint 用于表示正在执行的连接点
     */
    @AfterThrowing("pt()") // 引入切入点
    public void afterThrowing(JoinPoint joinPoint){
        log.info("afterThrowing ...");
    }
}

需要注意的是:当切入点方法使用private修饰时,仅能在当前切面类中引用该表达式, 当外部其他切面类中也要引用当前类中的切入点表达式,就需要把private改为public,而在引用的时候,具体的语法为:全类名.方法名()

具体形式如下:

package com.gch.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Aspect
/**
   切面类
 */
public class MyAspect2 {
    /**
     * 引入MyAspect1切面类中的切入点表达式,引用的具体语法:全类名.方法名()
     */
    @Before("com.gch.aop.MyAspect1.pt()")
    public void before(){
        log.info("MyAspect2 -> before...");
    }
}

3.2 通知顺序

学习完了Spring中AOP所支持的5种通知类型之后,接下来我们再来研究通知的执行顺序

当在项目开发当中,我们定义了多个切面类, 而多个切面类中多个切入点都匹配到了同一个目标方法此时当目标方法在运行的时候,这多个切面类当中的这些通知方法都会运行。

此时我们就有一个疑问,这多个通知方法到底哪个先运行,哪个后运行?

注意:我们研究的是多个切面类当中的通知执行顺序!

同一个切面类当中不同类型的通知执行顺序这里我们就不再研究了。

下面我们通过程序来验证(这里呢,在这三个切面类当中,我们就定义两种类型的通知进行测试,一种是前置通知@Before,一种是后置通知@After,只是输出的日志标识bbu不一样

定义多个切面类:

package com.gch.aops;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Aspect
public class MyAspect2 {
    /**
     * 前置通知
     */
    @Before("execution(* com.gch.service.*.*(..))")
    public void before(){
        log.info("MyAspect2 -> before ...");
    }

    /**
     * 后置通知
     */
    @After("execution(* com.gch.service.*.*(..))")
    public void after(){
        log.info("MyAspect2 -> after ...");
    }
}
package com.gch.aops;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Aspect
public class MyAspect3 {
    /**
     * 前置通知
     */
    @Before("execution(* com.gch.service.*.*(..))")
    public void before(){
        log.info("MyAspect3 -> before ...");
    }
    
    /**
     * 后置通知
     */
    @After("execution(* com.gch.service.*.*(..))")
    public void after(){
        log.info("MyAspect3 ->  after ...");
    }
}
package com.gch.aops;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Aspect
public class MyAspect4 {
    /**
     * 前置通知
     */
    @Before("execution(* com.gch.service.*.*(..))")
    public void before(){
        log.info("MyAspect4 -> before ...");
    }


    /**
     * 后置通知
     */
    @After("execution(* com.gch.service.*.*(..))")
    public void after(){
        log.info("MyAspect4 -> after ...");
    }
}

重新启动SpringBoot服务,测试通知的执行顺序:

备注:

  1. 把DeptServiceImpl实现类中模拟异常的代码删除或注释掉。

  2. 注释掉其他切面类(把@Aspect注释即可),仅保留MyAspect2、MyAspect3、MyAspect4 ,这样就可以清晰看到执行的结果,而不被其他切面类干扰。

这三个前置通知到底哪个先执行,哪个后执行?以及这三个后置通知到底哪个先执行,哪个后执行?

  • 查看idea中控制台日志输出

通过以上程序运行可以看出在不同切面类中,默认按照切面类的类名字母排序:

  • 在目标 / 原始方法运行之前的运行的通知方法:类名 / 字母排名越靠前的越先执行

  • 在目标 / 原始方法运行之后的运行的通知方法:类名 / 字母排名靠前的后执行

如果我们想控制通知的执行顺序有两种方式:
  1. 修改切面类的类名(这种方式非常繁琐、而且不便管理)

  2. 使用Spring提供的@Order注解

用@Order(数字)加在切面类上来控制顺序

  • 在目标 / 原始方法运行之前运行的通知方法:数字小的先执行
  • 在目标 / 原始方法运行之后运行的通知方法:数字大的先执行;数字小的后执行 
通知的执行顺序总结:
  1. 在不同的切面类当中,默认情况下通知的执行顺序是与切面类的类名字母排序是有关系的!
  2. 我们可以在切面类上加上@Order注解,来控制不同的切面类通知的执行顺序!

3.3 切入点表达式

从AOP的入门程序到现在,我们一直都在使用切入点表达式来描述切入点。下面我们就来详细的介绍一下切入点表达式的具体写法。  

切入点表达式:

  • 就是用来描述切入点方法的一种表达式

  • 作用:主要用来决定项目当中,的哪些目标 / 原始方法需要加入 / 应用我们所定义的通知

常见形式:

1. execution(……):根据所指定方法的签名来匹配切入点方法

2. @annotation(……) :根据所指定的注解匹配切入点方法

首先我们先学习第一种最为常见的execution切入点表达式。  

3.3.1 execution-切入点表达式

execution主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配切入点方法,语法为:  

execution(访问修饰符?  返回值  包名.类名.?方法名(方法参数类型的全类名) throws 异常?)

其中带?的表示可以省略的部分

  • 访问修饰符:可省略(比如: public、protected)

  • 包名.类名: 可省略,一般不建议省略(因为一旦将包名.类名直接省略了,那这个切入点表达式匹配的范围就会过大,就会影响匹配的效率)

  • throws 异常:可省略(注意是方法声明上抛出的异常,不是实际抛出的异常),一般不指定

示例:  

@Before("execution(public void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))")

可以使用通配符描述切入点

  • * :单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分

execution(* com.*.service.*.update*(*))

*Service:以Service结尾     delete*:以delete开头 

  • .. :多个连续的任意符号可以通配任意层级的包,或任意类型、任意个数的参数

execution(* com.itheima..DeptService.*(..))

切入点表达式的语法规则:

  1. 方法的访问修饰符可以省略

  2. 返回值可以使用*号代替(任意返回值类型)

  3. 包名可以使用*号代替,代表任意包(一层包使用一个*

  4. 使用..配置包名,标识此包以及此包下的所有子包

  5. 类名可以使用*号代替,标识任意类

  6. 方法名可以使用*号代替,表示任意方法

  7. 可以使用 * 配置参数,一个任意类型的参数

  8. 可以使用.. 配置参数,任意个任意类型的参数

切入点表达式示例

  • 省略方法的修饰符号

    execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用*代替返回值类型

    execution(* com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用*代替包名(一层包使用一个*

    execution(* com.itheima.*.*.DeptServiceImpl.delete(java.lang.Integer))
  • 使用..省略包名

    execution(* com..DeptServiceImpl.delete(java.lang.Integer))  
  • 使用*代替类名

    execution(* com..*.delete(java.lang.Integer))   
  • 使用*代替方法名

    execution(* com..*.*(java.lang.Integer))   
  • 使用 * 代替参数

    execution(* com.itheima.service.impl.DeptServiceImpl.delete(*))
  • 使用..省略参数

    execution(* com..*.*(..))

注意事项:

  • 根据业务需要,可以使用 且 /双与(&&)、双或(||)、非(!) 来组合比较复杂的切入点表达式。匹配多个无规则的方法

execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))

切入点表达式的书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是update开头

  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性

  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 ..来匹配任意层级的包,使用 * 匹配单个包

3.3.2 @annotation-切入点表达式

如果我们要匹配多个无规则的方法,比如:list()和 delete()这两个方法。这个时候我们基于execution这种切入点表达式来描述就不是很方便了。而在之前我们是将两个切入点表达式组合在了一起完成的需求,这个是比较繁琐的。

我们可以借助于另一种切入点表达式annotation来描述这一类的切入点,从而来简化切入点表达式的书写。

  • @annotation切入点表达式,用于匹配标识有特定注解的方法的。 
  • @annotation切入点表达式 - 根据指定的注解来匹配切入点方法
  • @annotation(注解的全类名)

实现步骤:

  1. 编写自定义注解

  2. 在业务类要做为连接点的方法上添加自定义注解

自定义注解:MyLog 

package com.gch.annotation.pointcut;

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 MyLog {

}

业务类:DeptServiceImpl  

package com.gch.service.impl;

@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    @MyLog // 自定义注解,表示当前方法属于目标方法
    @Override
    public List<Dept> list() {
        List<Dept> deptList = deptMapper.list();
        return deptList;
    }

    @MyLog // 自定义注解,表示当前方法属于目标方法
    @Override
    public void delete(Integer id) {
        //1. 删除部门
        deptMapper.delete(id);
    }
}

切面类

package com.gch.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Aspect
/**
 * 切面类
 * 针对list方法、delete方法进行前置通知和后置通知
 */
public class MyAnnotaionAspect {
    /**
     * 前置通知
     */
    @Before("@annotation(com.gch.annotation.pointcut.MyLog)")
    public void before() {
        log.info("MyAnnotaionAspect -> before...");
    }

    /**
     * 后置通知
     */
    @After("@annotation(com.gch.annotation.pointcut.MyLog)")
    public void after() {
        log.info("MyAnnotation -> After...");
    }
}

编写测试类,查看控制台日志:

注意:基于@annotation通过注解的方式来匹配切入点方法,会非常的灵活,你想匹配哪个方法,就只需要在哪个方法上加入对应的注解就可以了。

两种常见的切入点表达式总结:
  • execution切入点表达式

    • execution(修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?)

    • 根据我们所指定的方法的描述信息来匹配切入点方法,这种方式也是最为常用的一种方式

    • 如果我们要匹配的切入点方法的方法名不规则,或者有一些比较特殊的需求,通过execution切入点表达式描述比较繁琐

  • @annotation 切入点表达式

    • @annotation(注解全类名)

    • 基于注解的方式来匹配切入点方法这种方式虽然多一步操作,我们需要自定义一个注解,但是相对来比较灵活。我们需要匹配哪个方法,就在方法上加上对应的注解就可以了

3.4 连接点

我们前面在学习AOP核心概念的时候,我们提到过什么是连接点,连接点可以简单理解为可以被AOP控制的方法。 其实我们目标对象当中所有的方法都是可以被AOP控制的方法,也就是其实所有的方法都是连接点。

而在SpringAOP当中,连接点又特指方法的执行。  

在Spring当中用JoinPoint抽象了连接点,用它 / 通过JoinPoint连接点对象就可以获得方法执行时的相关信息,如目标对象的类名、目标对象的方法名、目标方法的方法参数等信息。  

对于不同的通知类型,获取方式略有不同:

  • 对于@Around环绕通知,获取连接点信息只能使用ProceedingJoinPoint类型

  • 对于其他四种通知,获取连接点信息只能使用JoinPoint,它是ProceedingJoinPoint的父类型  [ProceedingJoinPoint是JoinPoint的子接口]

 示例代码:

package com.gch.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import java.util.Arrays;

@Slf4j
@Component
@Aspect
public class ProceedingJoinPointDemo {
    @Around("execution(* com.gch.service.impl.DeptServiceImpl.list(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        log.info("Around Before...");
        // 获取目标方法的全类名
        String className = pjp.getTarget().getClass().getName();
        log.info("目标方法的类名:{}",className);
        // 获取目标方法签名
        Signature signature = pjp.getSignature();
        log.info("目标方法签名:{}",signature);
        // 获取目标方法名
        String method = pjp.getSignature().getName();
        log.info("目标方法名:{}",method);
        // 获取目标方法运行参数
        Object[] args = pjp.getArgs();
        log.info("目标方法运行参数:{}", Arrays.toString(args));
        // 执行原始方法,获取返回值(环绕通知)
        Object result = pjp.proceed();
        log.info("方法返回值:{}",result);
        log.info("Around After...");
        return result;
    }
}

运行测试类后: 

注意:如果用JoinPoint,由于无需运行原始方法,所以不用获取方法的返回值! 

getTarget()方法用于获取目标对象。 

4. AOP案例 - 记录操作日志

4.1 需求

需求:将案例中增、删、改相关接口的操作日志记录到数据库表中

  • 就是当访问部门管理和员工管理当中的增、删、改相关功能接口时,需要详细的操作日志,并保存在数据表中,便于后期数据追踪。

操作日志信息包含:

  • 操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长

所记录的日志信息包括当前接口的操作人是谁操作的,什么时间点操作的,以及访问的是哪个类当中的哪个方法,在访问这个方法的时候传入进来的参数是什么,访问这个方法最终拿到的返回值是什么,以及整个接口方法的运行时长是多长时间。

4.2 分析

问题1:项目当中增删改相关的方法是不是有很多?

  • 很多

问题2:我们需要针对每一个功能接口方法进行修改,在每一个功能接口当中都来记录这些操作日志吗?

  • 这种做法比较繁琐

以上两个问题的解决方案:可以使用AOP解决(每一个增删改功能接口中要实现的记录操作日志的逻辑代码是相同)。

可以把这部分记录操作日志的通用的、重复性的逻辑代码抽取出来定义在一个通知方法当中,我们通过AOP面向切面编程的方式,在不改动原始功能的基础上来对原始的功能进行增强。目前我们所增强的功能就是来记录操作日志,所以也可以使用AOP的技术来实现。使用AOP的技术来实现也是最为简单,最为方便的。

问题3:既然要基于AOP面向切面编程的方式来完成的功能,那么我们要使用 AOP五种通知类型当中的哪种通知类型?

  • 答案:@Around环绕通知

所记录的操作日志当中包括:操作人、操作时间,访问的是哪个类、哪个方法、方法运行时参数、方法的返回值、方法的运行时长

  • 方法返回值,是在原始方法执行后才能获取到的。
  • 方法的运行时长,需要原始方法运行之前记录开始时间,原始方法运行之后记录结束时间。通过计算获得方法的执行耗时。

基于以上的分析我们确定要使用@Around环绕通知。

问题4:最后一个问题,切入点表达式我们该怎么写?

  • 答案:使用@annotation来描述切入点表达式

要匹配业务接口当中所有的增删改的方法,而增删改方法在命名上没有共同的前缀或后缀。此时如果使用execution切入点表达式也可以,但是会比较繁琐。 当遇到增删改的方法名没有规律时,就可以使用 @annotation切入点表达式。

4.3 步骤

简单分析了一下大概的实现思路后,接下来我们就要来完成案例了。案例的实现步骤其实就两步:

  • 准备工作

    1. 引入AOP的起步依赖

    2. 导入资料中准备好的数据库表结构,并引入对应的实体类

  • 编码实现

    1. 自定义注解@Log

    2. 定义切面类,在切面类当中来定义通知方法来完成记录操作日志的逻辑

4.4 实现

4.4.1 准备工作
  1. AOP起步依赖

<!--SpringBoot AOP的起步依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
  1. 导入资料中准备好的数据库表结构,并引入对应的实体类

数据库表

-- 操作日志表
create table operate_log(
    id int unsigned primary key auto_increment comment 'ID',
    operate_user int unsigned comment '操作人',
    operate_time datetime comment '操作时间',
    class_name varchar(100) comment '操作的类名',
    method_name varchar(100) comment '操作的方法名',
    method_params varchar(1000) comment '方法参数',
    return_value varchar(2000) comment '返回值',
    cost_time bigint comment '方法执行耗时, 单位:ms'
) comment '操作日志表';

实体类  

package com.gch.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
 * 操作日志实体类
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OperateLog {
    /** 主键ID int - Integer */
    private Integer id;
    /** 操作人ID int - Integer */
    private Integer operateUser;
    /** 操作时间 datetime - LocalDateTime */
    private LocalDateTime operateTime;
    /** 操作的类名 varchar - String */
    private String className;
    /** 操作的方法名 varchar - String */
    private String methodName;
    /** 方法参数 varchar - String */
    private String methodParams;
    /** 返回值 varchar - String */
    private String returnValue;
    /** 方法执行耗时 bigint - Long */
    private Long costTime;
}

 Mapper接口

package com.gch.mapper;

import com.gch.pojo.OperateLog;
import org.apache.ibatis.annotations.Insert;

@Mapper
public interface OperateLogMapper {
    /**
     * 插入日志数据
     * @param operateLog 操作日志的实体类对象
     */
    @Insert("insert into aop.operate_log(*) VALUES(#{id},#{operateUser},#{operateTime},#{className},#{methodName},#{methodParams},#{returnValue},#{costTime})")
    public void insert(OperateLog operateLog);
}
4.4.2 编码实现
  • 自定义注解@Log

package com.gch.anno;

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

/**
 * 自定义@Log注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Log {

}
  • 定义切面类,完成记录操作日志的逻辑
package com.gch.aop;

import com.alibaba.fastjson2.JSONObject;
import com.gch.mapper.OperateLogMapper;
import com.gch.pojo.OperateLog;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.Arrays;

@Slf4j
@Component
@Aspect // AOP切面类
public class LogAspect {
    @Autowired
    private HttpServletRequest request;

    @Autowired
    private OperateLogMapper operateLogMapper;

    /**
     * 该通知方法用来记录日志
     * @return
     */
    @Around("@annotation(com.gch.anno.Log)")
    public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
        // 操作人ID - 当前登录员工的ID
        // 获取请求头中的jwt令牌,解析jwt令牌
        String jwt = request.getHeader("jwt");
        Claims claims = JwtUtils.parseJWT(jwt);
        Integer operateUser = claims.get("id");

        // 操作时间
        LocalDateTime operateTime = LocalDateTime.now();

        // 操作的类名
        String className = joinPoint.getTarget().getClass().getName();

        // 操作的方法名
        String methodName = joinPoint.getSignature().getName();

        // 方法参数
        Object[] args = joinPoint.getArgs();
        String methodParams = Arrays.toString(args);

        // 记录方法执行的开始时间
        long begin = System.currentTimeMillis();
        // 调用原始目标方法运行
        Object result = joinPoint.proceed();
        // 记录方法执行的结束时间
        long end = System.currentTimeMillis();

        // 将方法返回值转成JSON格式的字符串
        String returnValue = JSONObject.toJSONString(result);

        // 方法执行耗时
        Long costTime = end - begin;

        // 构建操作日志实体类对象
        OperateLog operateLog = new OperateLog(null,operateUser,operateTime,className,methodName,methodParams,returnValue,costTime);

        // 记录操作日志
        operateLogMapper.insert(operateLog);
        log.info("AOP记录操作日志:{}",operateLog);

        return result;
    }
}

代码实现细节: 获取request请求对象,从请求头中获取到jwt令牌,解析令牌获取出当前用户的id。  

  • 修改业务实现类,在增删改业务方法上添加@Log注解(省略)

重启SpringBoot服务,测试操作日志记录功能:

  • 添加一个新的部门

  • 数据表 

5. Spring AOP底层动态代理的方式 

在Spring  AOP的底层,它的原理就是基于动态代理来的,它底层采用的动态代理机制其实有两种,第一种就是JDK的动态代理,第二种叫CGLIB的动态代理。

5.1 JDK动态代理 

通过JDK的动态代理,怎么样来对我们的原始的业务方法进行增强?

基于JDK当中提供的动态代理技术,如何在不修改原始业务功能的情况下,对原有的这些功能进行增强?

目标对象: 

动态代理的接口: 

代理类: 

package com.gch.jdkproxy;

import com.gch.service.DeptService;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

/**
 * 通过动态代理技术,对Service业务层的方法统计执行耗时
 */
public class JDKProxyUtils {

    /**
     * 基于JDK动态代理技术来为我们的目标对象生成代理对象,通过代理对象对原始的目标对象进行功能增强
     * @param targetObject 原始的目标对象
     * @return 为目标对象所生成的代理对象
     */
    public static DeptService createProxy(Object targetObject) {
        /**
         * 为目标对象生成代理对象
         * 在Java中,动态代理是通过Proxy.newProxyInstance()方法来实现的,它需要传入被动态代理的接口
         * 代理对象实现的接口与目标对象实现的接口相同
         */
        DeptService deptService = (DeptService) Proxy.newProxyInstance(
                // 参数一:用于指定哪个类加载器,去加载生成的代理类
                JDKProxyUtils.class.getClassLoader(),
                // 参数二:指定接口,这个就代表我们所生成的这个代理,也就是中介 拿到目标对象所实现的接口数组
                // 代理对象实现的接口与目标对象所实现的接口相同
                targetObject.getClass().getInterfaces(), // 也可以这样new Class[]{DeptService.class}
                // 参数三:用来指定生成的代理对象要干什么事情,也就是对原有的方法要进行什么样的增强
                new InvocationHandler() {
                    /**
                     * 用来指定生成的代理对象要干什么事情
                     * @param proxy 参数一:表示代理的对象{代理对象}
                     * @param method 参数二:表示要运行的原始方法对象{原始方法对象}
                     * @param args 参数三:调用要运行的原始方法时,传递的实参{原始方法参数}
                     */
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        String methodName = method.getName();
                        // 只对增删改方法进行功能增强
                        if (methodName.equals("save") || methodName.equals("delete") || methodName.equals("update")) {
                            // 记录方法执行的开始时间
                            long begin = System.currentTimeMillis();
                            // 让原始方法运行(反射)
                            Object result = method.invoke(targetObject, args);
                            // 记录方法执行的结束时间
                            long end = System.currentTimeMillis();
                            System.out.println("方法执行耗时:" + (end - begin) + "ms");
                            return result;
                        }
                        // 如果不是增删改方法,只让原始目标方法运行即可
                        return method.invoke(targetObject, args);
                    }
                }
        );
        return deptService;
    }
}

主函数: 

package com.gch.jdkproxy;

import com.gch.service.DeptService;
import com.gch.service.impl.DeptServiceImpl;

/**
    主函数
 */
public class JDKProxyDemo {
    public static void main(String[] args) {
        // 1.创建原始目标对象
        DeptService deptService = new DeptServiceImpl();

        // 2.获取代理对象
        DeptService proxy = JDKProxyUtils.createProxy(deptService);

        // 3.调用代理对象的方法
        proxy.delete(4);
    }
}

JDK动态代理和Spring AOP中代码对比:

  • 在AOP中让原始方法运行是通过调用ProceedingJoinPoint / JoinPoint的proceed()方法,并且该方法会抛出Throwable异常;而在JDK动态代理中让原始方法运行是通过反射中的invoke()方法。 
  • 在框架给我们封装的AOP的技术当中,目标方法的控制会非常方便,直接通过切入点表达式就可以来控制目标方法,AOP的底层就是基于动态代理实现的
  • JDK的动态代理技术是基于接口做的代理,也就是说目标对象与代理对象实现的是同一个接口,JDK动态代理技术是有限制的,JDK的动态代理技术不能为没有接口的类做代理的。

JDK的动态代理,创建出来的代理对象和目标对象之间是什么关系?

  • 实现了同一个接口,是兄弟关系, 所以代理对象与目标对象之间不能进行类型强转,运行时会报错:java.lang.ClassCastException
5.2 CGLIB动态代理 

CGLIB不是JDK提供的的,是第三方的技术,所以如果要使用CGLIB动态代理,首先要导入第三方的jar包:

<!-- CGLIB动态代理的依赖-->
        <dependency>
            <groupId>cglib</groupId>
            <artifactId>cglib</artifactId>
            <version>3.3.0</version>
        </dependency>

代理类:

package com.gch.cglibproxy;

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

/**
 * Cglib动态代理
 */
public class CglibProxyUtils {
    /**
     * 基于Cglib动态代理技术为目标对象生成代理对象
     * @param targetObject 原始的目标对象
     * @return 生成的代理对象
     */
    public static Object createProxy(Object targetObject) {
        // 1.创建一个Enhancer,相当于在内存当中动态的创建出来了一个类(注意不要导错包,不是SpringFrameWork中的)
        Enhancer enhancer = new Enhancer();

        // 2.需要指定创建出来的代理类的父类是谁? -> 目标对象对应的类 相当于让创建出来的代理类extends继承了原始的目标对象[设置继承关系]
        enhancer.setSuperclass(targetObject.getClass());

        // 3.增强{Callback接口下面的子接口:new MethodInterceptor()}
        enhancer.setCallback(new MethodInterceptor() {
            /**
             * 用来指定生成的代理对象要干什么事情,也就是要对原始的目标方法做什么样的增强
             * @param obj "this", the enhanced object{代理对象enhancer}
             * @param method intercepted Method{原始方法对象}
             * @param args argument array; primitive types are wrapped{原始方法参数}
             * @param proxy used to invoke super (non-intercepted method); may be called
             * as many times as needed
             * @return
             * @throws Throwable
             */
            @Override
            public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                // 获取原始方法的方法名
                String methodName = method.getName();
                // 只对list查询方法进行功能增强
                if(methodName.equals("list")){
                    // 记录方法执行的开始时间
                    long begin = System.currentTimeMillis();
                    // 让原始方法运行(反射)
                    Object result = method.invoke(targetObject,args);
                    // 记录方法执行的结束时间
                    long end = System.currentTimeMillis();
                    System.out.println("方法执行耗时:" + (end - begin) + "ms...");
                    return result;
                }
                // 如果不是目标方法,则只让该方法运行即可
                return method.invoke(targetObject,args);
            }
        });

        // 4.创建代理对象(因为是动态创建出来的类,所以就不能new了,要调用Enhancer当中的实例方法create(),返回值就是代理对象的实例)
        Object proxy = enhancer.create(); // 返回值类型为Object
        return proxy;
    }
}

 主函数:

package com.gch.cglibproxy;

import com.gch.service.DeptService;
import com.gch.service.impl.DeptServiceImpl;

public class CglibProxyDemo {
    public static void main(String[] args) {
        // 1.创建原始目标对象
        DeptServiceImpl deptService = new DeptServiceImpl();

        // 2.获取代理对象,把代理对象强转为原始的目标对象
        DeptServiceImpl proxy = (DeptServiceImpl) CglibProxyUtils.createProxy(deptService);
    }
}

运行后不会报错,但会报警告:

Cglib底层基于反射的方式来创建的找个代理对象不安全!

5.3 JDK动态代理与Cglib动态代理的本质区别
  • JDK动态代理它是基于接口,代理对象与被代理对象implements实现了同一个接口【implements】,因此代理对象与被代理对象(原始的目标对象)之间是兄弟关系,两者之间不能进行类型转换,否则会报类型转换异常。
  • Cglib动态代理它是基于继承,而Cglib动态代理中,被代理对象也就是原始的目标对象它并没有实现接口,就是一个普通类,Cglib动态代理所生成的代理对象与被代理对象,即原始的目标对象是父子关系,代理对象extends继承了原始的目标对象【extends】,因此两者之间可以进行类型转换。
  • 实现原理不同:Cglib动态代理需要依赖Cglib库,通过继承目标类来创建代理对象,Cglib底层生成代理类是通过asm生成class字节码,Cglib底层直接采用建立fastclass索引的方式;而JDK动态代理则是使用Java自带的反射机制,通过实现目标类的接口来创建代理对象。
  • 性能不同:JDK 7之前,Cglib动态代理效率是要比我们的JDK动态代理效率高非常多的,JDK 7开始JDK 动态代理是要比我们的Cglib动态代理效率高的,一般而言,Cglib动态代理相对于JDK动态代理来说会稍微慢一些,因此Cglib动态代理的代理过程需要生成目标类的子类;而JDK动态代理则直接通过接口方法的反射调用,相对更快。

为什么在Spring的AOP当中它还要引入第二种Cglib动态代理的方式?

  • 就是因为JDK动态代理没有办法为没有实现接口的类做代理,JDK动态代理只能代理有实现接口的类,而Cglib动态代理它所创建出来的代理对象和目标对象是父子关系。 

通过在application.yml中配置AOP中的proxy-target-class,来配置Spring AOP中的代理方式:

  aop:
    proxy-target-class: true #属性默认值为true,代表使用Cglib创建代理对象;改为false代表有接口,就走JDK动态代理;如果没有接口,就走Cglib动态代理
总结:

1. JDK动态代理中,代理对象和被代理对象的关系?

  • 代理对象与被代理对象实现了同一个接口,是兄弟关系,因此代理对象与原始的目标对象之间不能进行类型转换,否则会报类型转换异常。

2. CGLIB动态代理中,代理对象和被代理对象的关系?

  • 代理对象继承了原始的目标对象,因此代理对象与被代理对象是父子关系,可以进行类型转换。 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Surpass余sheng军

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值