面向切面的Spring

面向切面的Spring

什么是面向切面编程

面向切面编程,就是可以在多个不相关的业务功能(方法)中添加相同的功能(切面)。可以使这些功能和业务功能解耦,可以让我们把更多的注意力放在业务代码中。
切面例如有:事务管理、安全、日志等。

横切关注点:就是可以影响程序中多个点的功能。

AOP相关的术语

在这里,我们来了解一下AOP的相关术语。

通知(Advise)

通知,定义了切面是什么和什么时候执行,就是定义了要加入的功能和什么时候执行这个功能。

根据什么时候执行,通知分为一下5类:

  • 前置通知(before):在方法执行前执行通知。
  • 后置通知(after):在方法执行完后执行通知,不关注方法的返回值。
  • 返回通知(after-returning):在方法执行成功后执行通知
  • 异常通知(after-throwing):当方法有异常发生时执行通知。
  • 环绕通知(around):在方法执行前和执行后执行通知
连接点(join point)

连接点,就是可以执行通知的点(地方)

切点(pointcut)

切点:是连接点的子集,是加了通知的连接点。

切面(Aspect)

切面:是通知和切点的组合,两者定义了切面是什么(是什么功能)和什么时候使用,切面应用的地方。

引入(instruction)

引入允许我们向现有类添加属性和方法

织入(weaving)

织入是把切面应用到目标对象中创建代理对象的过程。织入可以发生在目标对象的生命周期的多个点上:

  • 编译期:在目标类编译时把切面织入。这种方式需要特殊的编译器,AspectJ的织入编译器就是使用这种方式织入切面。
  • 类加载期:在目标类加载到JVM时将切面织入,这种方式需要特殊的类加载器,在目标类引入到应用之前增强目标类的字节码,AspectJ 5的加载期织入就支持这种织入切面的方式。
  • 运行期:在应用执行的某个时刻织入切面。在织入切面时,AOP容器会动态地为目标对象生成代理对象。Spring AOP就是使用这种方式织入切面。

Spring对AOP的支持

Spring提供了4种对AOP的支持:

  • Spring经典的基于代理的AOP
  • 纯POJO切面
  • @AspectJ注解驱动的切面
  • 注入AspectJ切面(所有的Spring版本都可以使用)

前三种都是Spring AOP实现的变体。

Spring经典的基于代理的AOP过于笨重,这里就不详细展开。

Spring的通知是用java写的

我们可以使用Java定义通知,而使用AspectJ,我们要学习一些工具和语法。

Spring在运行期通知对象

Spring的AOP是在运行期把通知应用到目标对象中,创建目标对象的代理对象。当拦截到方法调用时,在目标bean的方法执行之前,会先执行切面的逻辑。

Spring只支持方法级别的连接点

连接点有不同的粒度,例如方法级别、字段级别和构造器级别。

而Spring只支持方法级别的连接点,就是只能拦截方法的调用。连接点不支持字段级别和构造方法级别,不能在字段值改变和bean创建时应用通知。

AspectJ和JBoss支持字段和构造器的级别。

方法级别的连接点已经能够满足绝大部分的情况,如果需要字段级别和构造器级别的连接点,可以使用AspectJ作为Spring的补充。

通过切点来选择连接点

切点定义了切面的通知应用在什么地方,所以定义切点是一个重要的事。

在Spring AOP中,切点使用AspectJ的切点表达式语言。Spring支持的AspectJ切点指示器如下:

AspectJ指示器描述
execution()用于匹配执行方法的连接点
args()限制连接点匹配参数是给定类型的执行方法
@args()限制连接点匹配参数由指定注解标注的执行方法
this()限制连接点匹配AOP代理的bean引用是给定的类型
target()限制连接点匹配目标对象是给定的类型
@target()限制连接点匹配可执行对象,这个对象的类有给定类型的注解
within()限制连接点匹配指定的类型
@within()限制连接点匹配有指定注解的类型(当使用Spring AOP时,方法定义在由指定的注解所标注的类里)
@annotation限定匹配具有指定注解的连接点

如果在Spring中使用其他的AspectJ指示器,那么会报IllegalArgumentException。

从表中看到,只有execution()指示器是用于执行匹配,其他的指示器都是限制匹配。所以,在定义切点时,execution()指示器是唯一的指示器,其他的指示器用于限制匹配的连接点。

编写切点

为了编写切点,我先定义一个接口Performance。

package concert;

public interface Performance {
  public void perform();
}

定义切点,当调用Performance的perform()方法时,织入通知。

execution(* concert.Performance.perform(..))
  • *: 表示任意的返回值
  • concert.Performance:全限定类名
  • perform:方法名
  • …:括号的…代表任意参数,表示任意的参数perform都是切点

再定义一个切点,除了上面切点的要求外,再加一个条件,就是限定在concert包中,这里,就要使用within指示器了

execution(* concert.Performance.perform(..)) && within(concert.*)
  • &&:表示与(and)操作符

除了&&,还有或(or)和非(not)操作符,||和!

注意在xml中,&有特殊意义,所以在xml中使用and代替&&,使用or和not代替||和!

在切点中选择bean

除了上面表中列出的指示器,Spring还支持bean指示器,可以限定切点只作用在特定的bean中,bean指示器的参数是bean的id或bean的名称。
语法:

  • bean(bean id)

再定义一个切点,除了上一节中第一个的切点的条件外,再加一个,只作用在id为woodstock的bean。

execution(* concert.Performance.perform(..)) && bean("woodstock")

只有当bean id是Woodstock的bean调用了concert.Performance.perform方法时,才会织入通知。

除了限定一个bean,我们也可以使用非操作符!,限定切点是除了某个bean外的所有bean。

execution(* concert.Performance.perform(..)) && !bean("woodstock")

除了bean id是Woodstock的其他所有bean,调用了concert.Performance.perform方法时,才会织入通知。

创建注解切面

我们使用AspectJ注解来定义切面,在AspectJ 5之前,我们需要学习另外的知识,在AspectJ 5及之后,只要使用少量的注解,我们就可以把任何的类转成切面。

定义切面

我们可以将一个java pojo类转成切面,在类级别上使用AspectJ注解@Aspect,注解@Aspect表明了这个类是切面。

在表演中,如果没有观众,是不可以的,但是如果从表演的功能看,观众不是核心的,所以我们把观众作为通知织入到表演中。

@Aspect
public class Audience {
  @Before("execution(* concert.Performance.perform(..))")
  public void slienceCellphone() {
    System.out.println("slience cellphone");
  }

  @Before("execution(* concert.Performance.perform(..))")
  public void takeSeats() {
    System.out.println("take seats");
  }

  @AfterReturning("execution(* concert.Performance.perform(..))")
  public void applause() {
    System.out.println("applause");
  }

  @AfterThrowing("execution(* concert.Performance.perform(..))")
  public void demandRefund() {
    System.out.println("demand refund");
  }
}

在表演开始前,观众要设置手机静音和坐下,表演好,就鼓掌,表演不符合要求,就要求退款。

在这里插入图片描述

关于方法上的通知注解,说明如下:

注解通知
@Before在目标方法执行前执行通知
@After在目标方法返回或抛出异常时执行通知
@AfterReturning在目标方法返回后执行通知
@AfterThrowing在目标方法抛出异常后执行通知
@Around通知方法环绕着目标方法执行

上面代码中切点表达式作为通知注解的值。

上面的代码,我们有不满意的地方,就是切点都是一样的,但是每写一个增强,我们都要写一次那个长长的切点表达式。我们可以使用注解@Pointcut来使我们只需写一次切点表达式,对于要多次使用的切点表达式,我们应该使用@Pointcut来简化。@Pointcut是方法级别的。
语法:

@Pointcut("切点表达式")

上面的代码可以改成下面这样:

@Aspect
public class Audience {
  @Pointcut("execution(* concert.Performance.perform(..))")
  public void performance() {}

  @Before("performance()")
  public void slienceCellphone() {
    System.out.println("slience cellphone");
  }

  @Before("performance()")
  public void takeSeats() {
    System.out.println("take seats");
  }

  @AfterReturning("performance()")
  public void applause() {
    System.out.println("applause");
  }

  @AfterThrowing("performance()")
  public void demandRefund() {
    System.out.println("demand refund");
  }
}

@Pointcut修饰的方法的方法体应该是空的,然后通知注解的值就是这个方法,我们就不需要写多次长长的切点表达式了。

Audience类就是一个普通的Java pojo类,我们可以把他交给spring管理,在Java config中配置。

@Configuration
@ComponentScan
public class ConcertConfig {

  @Bean
  public Audience audience() {
    return new Audience();
  }
}

这样,就把audience对象交给了Spring来管理,但是会产生一个问题,Spring不会把Audience作为切面处理,把会解析里面的注解。

为了解决这个问题,可以从2个方面解决:

  • 基于Java config
  • 基于xml
基于Java config

如果使用Java Config显示装配bean,那么在javaconfig类中加上注解@EnableAspectJAutoProxy

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class ConcertConfig {

  @Bean
  public Audience audience() {
    return new Audience();
  }
}
基于xml

如果是使用xml显示方式装配bean,那么要使用aop命名空间的标签<aop:aspectj-autoproxy />

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:context="http://www.springframework.org/schema/context"
  xmlns:aop="http://www.springframework.org/schema/aop"
  xsi:schemaLocation="http://www.springframework.org/schema/aop
    http://www.springframework.org/schema/aop/spring-aop.xsd
    http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context.xsd">

  <context:component-scan base-package="concert" />

  <bean class="concert.Audience" />

  <aop:aspectj-autoproxy />

</beans>

实现环绕通知

环绕通知是最强大的通知类型,它可以在目标方法执行之前和之后执行一些逻辑,可以实现之前前置通知和后置通知的效果。

为了使用环绕通知,我们重写Audience切面。

@Aspect
public class Audience {
  @Pointcut("execution(* concert.Performance.perform(..))")
  public void performance() {}

  @Around("performance()")
  public void watchPerformance(ProceedingJoinPoint jp) {
    try {
      System.out.println("silence cell phone");
      System.out.println("take seats");
      jp.proceed();
      System.out.println("applause");
    } catch(Throwable t) {
      System.out.println("demand refund");
    }

  }
}

这样,我们使用一个方法,就实现了之前4个方法的效果。

我们注意到,在通知方法里有一个ProceedingJoinPoint参数,我们使用这个参数调用目标方法,一定要有这个参数,使用ProceedingJoinPoint的proceed() 方法调用目标方法。我们其实也可以不调用,proceed方法,那么,目标方法就会被阻塞,得不到调用。

有意思的是,ProceedingJoinPoint的proceed()方法,我们可以不调用,那么目标方法就会阻塞;我们也可以调用多次,这种情形的使用情景,例如有,当目标方法失败时,重复执行目标方法。

处理通知中的参数

在上面的通知方法中,只有环绕通知使用了一个ProceedingJoinPoint参数,其他的都没有使用参数,可是,其实在通知方法中,是可以使用传给目标方法的参数值的

首先我们定义一个CompactDisc类

public class BlankDisc implements CompactDisc {
	private String title;

	private String artist;

	private List<String> tracks;

	public BlankDisc(String title, String artist, List<String> tracks) {
		this.title = title;
		this.artist = artist;
		this.tracks = tracks;
	}

	@Override
	public void play() {
		for(int i=0; i<tracks.size(); i++) {
      playTrack(i);
    }
	}

  public void playTrack(int trackNum) {
    System.out.println("play " + trackNum + " track");
  }

}

在这里,我们想记录每个磁道播放的次数。

首先我们可以在playTrack()方法中,操作播放的次数,但是,记录播放次数不是播放的关注点,所以,把记录磁道播放次数作为切面比价合适。

现在我们定义一个切面,TrackCounter。

@Aspect
public class TrackCounter {
  Map<Integer, Integer> trackCounts = new HashMap<Integer, Integer>(); //记录磁道播放次数

  @Pointcut("execution(* soundSystem.BlankDisc.playTrack(int))
        && args(trackNumber)")
  public void trackPlayed(int trackNumber) {}

  @Before("trackPlayed(trackNumber)")
  public void countTrack(int trackNumber) {
    int oriCount = getCount(trackNumber);
    trackCounts.put(trackNumber, oriCount + 1);
  }

  public int getCount(int trackNumber) {
    return trackCounts.containsKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
  }

注意:切点表达式中的args参数名trackNumber要和切点方法的参数名一致。

图:C:\Users\Administrator\Desktop\resources\Spring\img\chapter4\处理通知中的参数.png

接着使用Java显示配置将BlankDisc和TrackCounter交给Spring管理。

@Configuration
@EnableAspectJAutoProxy
public class TrackCounterConfig {
  @Bean
  public BlankDisc sgtPeppers() {

    String title = "field of hope";
    String artist = "seed";
    List<String> tracks = new ArrayList<String>();
    tracks.add("field of hope");
    tracks.add("my love");
    tracks.add("love story");
    //...
    BlankDisc bd = new BlankDisc(title, artist, tracks);
  }

  @Bean
  public TrackCounter trackCounter() {
    return new TrackCounter();
  }
}

测试:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes=TrackCounterConfig.class)
public class TrackCounterTest {
  @Autowired
  priavte CompactDisc cd;

  @Autowired
  priavte TrackCounter trackCounter;

  @Test
  public void test() {
    cd.playTrack(1);
    cd.playTrack(2);
    cd.playTrack(1);
    cd.playTrack(5);

    System.out.println(trackCounter.getCounter(1));
    System.out.println(trackCounter.getCounter(2));
    System.out.println(trackCounter.getCounter(3));
    System.out.println(trackCounter.getCounter(4));
    System.out.println(trackCounter.getCounter(5));  
  }
}

通过注解引入功能

在上面的切面中,都是为目标方法加上功能。其实我们可以为目标对象或类添加方法,思路是:

  1. 创建一个有抽象方法的接口;
  2. 把这个接口引入到目标类中;
  3. 把切面应用到这个抽象方法中。

通过上面的3个步骤,就可以为一个目标类添加新的方法。

demo:

  • 接口
public interface Encoreable {
  void performEncore();
}
  • 实现类
public class DefaultEncoreable implements Encoreable {
  public void performEncore() {
      //...
  }
}
  • 切面
@Aspect
public class EncoreableIntroducer {
  @DeclareParents(value="concert.Performance+",
      defaultImpl=DefaultEncoreable.class)
  public static Encoreable encoreable;
}

@DeclareParents由三部分组成:

  1. value:表明这个接口要引入到哪些bean中,其中,最后的一个加号+表示子类型,把这个接口引入到Performance的子类型中。
  2. defaultImpl:提供给引入的默认实现类。
  3. 被@DeclareParents注解的静态属性:是要被引入的接口。

猜测:Encoreable接口有一个实现类DefaultEncoreable。

最后使用Java config或xml来把EncoreableIntroducer放到Spring容器上下文中。

<bean class="concert.EncoreableIntroducer" />

在xml中声明切面

虽然基于注解的切面开发很方便,但是也有情况不能使用注解,例如如果没有源码的情况,这时,就需要使用xml的方式定义切面。

在xml中使用到的标签如下:

标签作用
<aop:config>最顶级的AOP元素,大部分的<aop:*>的标签都定义在里面
<aop:aspect>定义一个切面
<aop:pointcut>定义一个切点
<aop:before>定义一个前置通知
<aop:after>定义一个后置通知(无论被增强方法是否成功返回,都会执行)
<aop:after-returning>返回通知
<aop:after-throwing>异常通知
<aop:around>环绕通知
<aop:advisor>定义一个增强器
<aop:aspectJ-autoProxy>启用@AspectJ的注解驱动
<aop:declare-parents>用透明的方式为被增强的方法引入额外的接口

首先使用上面的观众类作为增强类,不过去掉了注解。

public class Audience {

  public void slienceCellphone() {
    System.out.println("slience cellphone");
  }

  public void takeSeats() {
    System.out.println("take seats");
  }

  public void applause() {
    System.out.println("applause");
  }

  public void demandRefund() {
    System.out.println("demand refund");
  }
}

声明前置通知和后置通知

<bean id="audience" class="cencert.Audience" />

<aop:config>
  <aop:aspect ref="audience">
    <aop:before pointcut="execution(* cencert.Performer.preform(..))"
        method="slienceCellphone" />
    <aop:before pointcut="execution(* cencert.Performer.preform(..))"
        method="takeSeats" />
    <aop:after-returning pointcut="execution(* cencert.Performer.preform(..))"
        method="applause" />
    <aop:after-throwing pointcut="execution(* cencert.Performer.preform(..))"
          method="demandRefund" />
  </aop:aspect>
</aop:config>

上面定义好了通知,但是有个问题,每次都要写一长串的切点表达式,很麻烦,我们可以定义在切点标签中,如下

<aop:config>
  <aop:aspect ref="audience">
    <aop:pointcut id="performence"  expression="execution(* cencert.Performer.preform(..))" />

    <aop:before pointcut-ref="performence" method="slienceCellphone" />
    <aop:before  pointcut-ref="performence" method="takeSeats" />
    <aop:after-returning  pointcut-ref="performence"   method="applause" />
    <aop:after-throwing  pointcut-ref="performence" method="demandRefund" />
  </aop:aspect>
</aop:config>

上面的performence切点只能在这个切面中使用,如果想在多个切面中共用一个切面,把切点标签定义在切面标签的外面。

声明环绕通知

我们使用之前定义的增强类。

public class Audience {
  public void watchPerformance(ProceedingJoinPoint jp) {
    try {
      System.out.println("silence cell phone");
      System.out.println("take seats");
      jp.proceed();
      System.out.println("applause");
    } catch(Throwable t) {
      System.out.println("demand refund");
    }

  }
}
<bean id="audience" class="cencert.Audience" />

<aop:config>
  <aop:pointcut id="perform" expression="execution(* concert.Performer.perform(..))" />
  <aop:aspect ref="audience">
    <aop:around pointcut-ref="perform" method="watchPerformance" />
  </aop:aspect>
</aop:config>

为通知传递参数

我们想让通知方法也能获取到被通知方法的参数值。
这里使用之前的例子,我们要计算每个磁道播放的次数,所以,被通知的方法就是播放歌曲的方法,我们可以在这个播放歌曲的方法里面计算磁道播放的次数,但是正如上面所说的,计算磁道播放次数不应该在播放歌曲的业务逻辑中考虑。
使用之前定义的被通知类和通知类。

public class BlankDisc implements CompactDisc {
	private String title;

	private String artist;

	private List<String> tracks;

	public BlankDisc(String title, String artist, List<String> tracks) {
		this.title = title;
		this.artist = artist;
		this.tracks = tracks;
	}

	@Override
	public void play() {
		for(int i=0; i<tracks.size(); i++) {
      playTrack(i);
    }
	}

  public void playTrack(int trackNum) {
    System.out.println("play " + trackNum + " track");
  }

}
public class TrackCounter {
  Map<Integer, Integer> trackCounts = new HashMap<Integer, Integer>(); //记录磁道播放次数

  public void countTrack(int trackNumber) {
    int oriCount = getCount(trackNumber);
    trackCounts.put(trackNumber, oriCount + 1);
  }

  public int getCount(int trackNumber) {
    return trackCounts.containsKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
  }

使用trackCounts记录每个磁道播放的次数。

接着,我们在xml中定义切面。

<bean id="trackCounter" class="soundsystem.TrackCounter" />
<aop:config>
  <aop:aspect ref="trackCounter">
    <aop:pointcut id="playedTrack" expression="execution(* soundsystem.CompactDisc.playTrack(int) and args(trackNum))" />
    <aop:before pointcut-ref="playedTrack" method="countTrack" />
  <aop:aspect>
</aop:config>

这里写的切点表达式和之前在基于注解的基本是一样的,除了使用and关键字代替了&&,因为在xml中,&代表一个实体的开始。

通过切面引入新的功能

这里使用上面定义的接口和实现类。

  • 接口
public interface Encoreable {
  void performEncore();
}
  • 实现类
public class DefaultEncoreable implements Encoreable {
  public void performEncore() {
      //...
  }
}

接着在xml中配置

<aop:aspect>
  <aop:declareParent
  types-matching="concert.Performance+"
  implement-interface="concert.Encoreable"
  default-impl="concert.DefaultEncoreable"
  />
</aop:aspect>

这个xml的作用是,将匹配types-matching属性值的类都在类继承级别中添加implement-interface属性值作为父接口。剩下的问题就是接口的方法的实现从哪里来,这里是从default-impl属性值得到接口的方法的实现。

default-impl这里的写法是写类的全限定类名,其实,还有另一种写法,如下

<aop:aspect>
  <aop:declareParent
  types-matching="concert.Performance+"
  implement-interface="concert.Encoreable"
  delegate-ref="EncoreableDelegate"
  />
</aop:aspect>

<bean id="EncoreableDelegate" class="concert.DefaultEncoreable" />

使用delegate-ref属性值是实现类的id。

使用default-impl和delegate-ref的区别只有,后者要把bean放入spring上下文中,这个bean就可以进行依赖注入、spring配置等操作。

注入AspectJ切面

使用Spring AOP定义切面,已经可以满足我们大部分的情况,但是,也有一些特殊情况,例如,我们需要在对象创建时添加增强,而Spring AOP不能实现这个功能,这时,我们就需要使用基于AspectJ的切面,相比Spring AOP,AspectJ更为强大。AspectJ的切面和Spring AOP是独立的,使用AspectJ的切面不需要依赖Spring。

现在,举个例子,我们需要一个评论员,在表演结束后作出评论。

package com.spirnginaction.springidol

public class CriticAspect {
    pointcut performance() : execution(* perform(..))
    
    after-returning() : performance() {
        System.out.println(criticismEngine.getCriticsim());
    }
    
    private CriticsimEngine criticsimEngine;
    
    public void setCriticsimEngine(CriticsimEngine criticsimEngine) {
        this.criticsimEngine = criticsimEngine; 
    }
}

pointcut performance() : execution(* perform(…))
这个代码的含义是,

  • pointcut:定义切点。
  • performance():切点的名称。
  • execution(* perform(…)):切点表达式,这里的意思是应用在所有的perform方法中。

after-returning()代码块的含义是:

  • after-returning():通知类型,这是返回通知。
  • performance():切面名。
  • 方法体:增强。

这个切面,用到了CriticsimEngine对象,这里CriticsimEngine是一个接口,有一个getCriticsim方法。

package com.spirnginaction.springidol
public interface CriticsimEngine {
    void getCriticsim();
}
package com.spirnginaction.springidol
public class CriticsimEngineImpl implements CriticsimEngine {
    public String getCriticsim() {
        int i = (int)Math.random() * criticsimPool.length;
        return criticsimPool[i];
    }
    
    private String[] criticsimPool;
    
    public void setCriticsimPool(String[] criticsimPool) {
        this.criticsimPool = criticsimPool; 
    }
}

现在,切面还有一个未解决的问题,就是CriticsimEngine对象从哪里来,在创建切面对象时创建CriticsimEngine对象并且注入进切面对象,是没问题的。但是,还有更好的方法,就是使用Spring的依赖注入,可以降低耦合度。

所以,我们要把切面类和CriticsimEngineImpl配置在Spring容器中,但是有一点要注意,基于AspectJ的切面类,是在AspectJ运行时创建切面对象的。

<bean id="criticsimEngine" class="com.spirnginaction.springidol.CriticsimEngineImpl">
    <property name="criticsimPool">
        <list>
            <value>bad</value>
            <value>well</value>
            <value>ok</value>
            ...
        </list>
    </property>
</bean>

<bean class="com.spirnginaction.springidol.CriticAspect" factory-method="aspectOf">
    <property name="criticsimEngine" ref="criticsimEngine"></property>
</bean>

这个AspectJ的切面的bean标签和我们之前写的基本一样,唯一的不同是这里多了一个factory-method属性,AspectJ为每个AspectJ切面都定义了一个aspectOf()方法,这个方法返回切面类的单例对象,因为基于AspectJ的切面对象不由Spring创建,而是由AspectJ在运行期创建,当spring容器需要注入的时候,切面对象已经创建好了。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值