Spring源码系列(五)——@Aspect源码解析

首先我们看一下@Aspect的简单实例代码
首先到Config类中添加@EnableAspectJAutoProxy注解打开AOP功能

@ComponentScan(basePackages = "com.kennor.test")
@EnableAspectJAutoProxy
public class Config {
}

自定义注解StudyTrainAnnotation用于标识连接点Joinpoint

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface StudyTrainAnnotation {
}

自定义切面StudyTrainAspect类
在切面方法studyTrain中对目标方法进行增强,具体业务逻辑是增加分数。

@Component
@Aspect
public class StudyTrainAspect {
   /**
    * 设置切点Pointcut
    */
   @Pointcut("@annotation(StudyTrainAnnotation)")
   public void studyTrainPointcut(){
   }
   /**
    * 设置增强方法Advice
    * @param joinPoint
    * @return
    */
   @Around("studyTrainPointcut()")
   public Object studyTrain(ProceedingJoinPoint joinPoint) throws Throwable {
      System.out.println("进行考前强化训练,强化训练后初始分数增加30分");
      Object[] args = joinPoint.getArgs();
      args[0] = (Integer)args[0] + 30;
      Integer score = (Integer) joinPoint.proceed(args);
      System.out.println("考后老师评分失误,多评10分");
      score+=10;
      return score;
   }
}

增加Student接口
在这里插入图片描述
在StudentA和StudentB实现Student接口
在StudentA的examing方法中增加@StudyTrainAnnotation注解,标识StudentA的examing是需要增强的目标对象Target。
在这里插入图片描述
在这里插入图片描述
然后调用
在这里插入图片描述
运行结果如下:
在这里插入图片描述
可以看到StudentA考试成绩增加到了90分,而StudentB没有使用AOP所以仍是50分。
为了做对比,看一下StudentB和StudentA的区别
在这里插入图片描述
通过Debug可以看到,StudentA的引用实际上是代理对象,然后代理对象的targetSource属性保存着StudentA。接下来我们将分析一下整个流程到底是怎么样的。
接下来我们就着这个实例,分析一下源码,看看Spring底层源码是如何实现的。
首先我们看到Config类上的@EnableAspectJAutoProxy注解
在这里插入图片描述
在这里插入图片描述
可以看到EnableAspectJAutoProxy使用了@Import标签导入了AspectJAutoProxyRegistrar类
而关于@Import标签的处理在AnnotationConfigApplicationContext的构造方法中创建的ConfigurationClassPostProcessor中进行的,跟处理@ComponentScan注解一样,具体看下面方法注释的第4点,会将AspectJAutoProxyRegistrar注册到Spring中,这里不再赘述。

@Nullable
protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass)
      throws IOException {
   // 1.处理类上的@Component注解
   if (configClass.getMetadata().isAnnotated(Component.class.getName())) {
      // Recursively process any member (nested) classes first
      processMemberClasses(configClass, sourceClass);
   }
   // Process any @PropertySource annotations
   // 2.处理类上的@PropertySource注解
   for (AnnotationAttributes propertySource : AnnotationConfigUtils.attributesForRepeatable(
         sourceClass.getMetadata(), PropertySources.class,
         org.springframework.context.annotation.PropertySource.class)) {
      if (this.environment instanceof ConfigurableEnvironment) {
         processPropertySource(propertySource);
      }
      ... ...
   }
   // Process any @ComponentScan annotations
   // 3.处理类上的@ComponentScan注解
   Set<AnnotationAttributes> componentScans = AnnotationConfigUtils.attributesForRepeatable(
         sourceClass.getMetadata(), ComponentScans.class, ComponentScan.class);
   if (!componentScans.isEmpty() &&
         !this.conditionEvaluator.shouldSkip(sourceClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN)) {
      ... ...
   }
   // Process any @Import annotations
   // 4.处理@Import注解
   processImports(configClass, sourceClass, getImports(sourceClass), true);
   // Process any @ImportResource annotations
   // 5.处理@ImportResource注解
   AnnotationAttributes importResource =
         AnnotationConfigUtils.attributesFor(sourceClass.getMetadata(), ImportResource.class);
   if (importResource != null) {
      ... ...
   }
   // Process individual @Bean methods
   // 6.处理@Bean注解
   Set<MethodMetadata> beanMethods = retrieveBeanMethodMetadata(sourceClass);
   for (MethodMetadata methodMetadata : beanMethods) {
      configClass.addBeanMethod(new BeanMethod(methodMetadata, configClass));
   }
   ... ...
   return null;
}

接着我们看一下AspectJAutoProxyRegistrar
在这里插入图片描述
可以看到AspectJAutoProxyRegistrar实现了ImportBeanDefinitionRegistrar接口,所以registerBeanDefinitions方法会在ApplicationContext的refresh方法中的invokeBeanFactoryPostProcessors里被调用。
在这里插入图片描述
在AspectJAutoProxyRegistrar的registerBeanDefinitions方法中主要是往Spring注册了AnnotationAwareAspectJAutoProxyCreator

// AOP的处理入口
// 注册AnnotationAwareAspectJAutoProxyCreator,主要用于处理@AspectJ注解
AopConfigUtils.registerAspectJAnnotationAutoProxyCreatorIfNecessary(registry);

在这里插入图片描述
在这里插入图片描述
可以看到最终AnnotationAwareAspectJAutoProxyCreator被封装到BeanDefinition注册到Spring中。
而在AnnotationAwareAspectJAutoProxyCreator所继承的父类AbstractAutoProxyCreator实现了SmartInstantiationAwareBeanPostProcessor接口
在这里插入图片描述
其中实现了一个很重要的方法:postProcessAfterInitialization,这里会创建返回Bean对应的代理对象。
在这里插入图片描述
通过方法的名称我们也可以知道postProcessAfterInitialization会在Bean初始化完成后调用,这是我们回到Bean的创建方法doCreateBean中,我们在之前的文章有提过此方法中的第5点注释处的方法就是处理生成代理对象的。
在这里插入图片描述
接着我们继续看一下initializeBean的具体源码

protected Object initializeBean(String beanName, Object bean, @Nullable RootBeanDefinition mbd) {
   ... ...

   Object wrappedBean = bean;
   if (mbd == null || !mbd.isSynthetic()) {
      // 1.调用BeanPostProcessor实现类的postProcessBeforeInitialization方法
      wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
   }

   try {
      // 2.调用InitializingBean的afterPropertiesSet方法
      invokeInitMethods(beanName, wrappedBean, mbd);
   }
   ... ...
   if (mbd == null || !mbd.isSynthetic()) {
      // 3.调用BeanPostProcessor实现类的postProcessBeforeInitialization方法
      // 如果配置了代理,则AbstractAutoProxyCreator会在此处调用,生成代理对象
      wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
   }

   return wrappedBean;
}

我们可以看到注释第3点处的applyBeanPostProcessorsAfterInitialization方法
在这里插入图片描述
最终调用到我们一开始所介绍的AnnotationAwareAspectJAutoProxyCreator父类AbstractAutoProxyCreator的postProcessAfterInitialization方法中
在这里插入图片描述
可以看到核心流程在wrapIfNecessary里,具体源码如下:

protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
   if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) {
      return bean;
   }
   // 判断当前bean的切面标识
   if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
      return bean;
   }
   if (isInfrastructureClass(bean.getClass()) || shouldSkip(bean.getClass(), beanName)) {
      this.advisedBeans.put(cacheKey, Boolean.FALSE);
      return bean;
   }

   // Create proxy if we have advice.
   // 1.根据当前beanMame对应的bean去扫描匹配bean类中的方法是否有符合Pointcut规则(@Pointcut)的切面方法(@Around、@Before、@After...)
   Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
   if (specificInterceptors != DO_NOT_PROXY) {
      // 将当前Bean标识为有切面方法
      this.advisedBeans.put(cacheKey, Boolean.TRUE);
      // 2.创建代理对象
      Object proxy = createProxy(
            bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
      this.proxyTypes.put(cacheKey, proxy.getClass());
      return proxy;
   }

   // 将当前Bean标识为无切面方法
   this.advisedBeans.put(cacheKey, Boolean.FALSE);
   return bean;
}

首先我们看wrapIfNecessary方法中注释第1点处的getAdvicesAndAdvisorsForBean方法的具体代码
在这里插入图片描述
在这里插入图片描述
继续看findEligibleAdvisors的具体是如何获取Advisor
在这里插入图片描述
这里我们继续看一下aspectJAdvisorsBuilder的buildAspectJAdvisors方法

public List<Advisor> buildAspectJAdvisors() {
   List<String> aspectNames = this.aspectBeanNames;

   if (aspectNames == null) {
      synchronized (this) {
         aspectNames = this.aspectBeanNames;
         if (aspectNames == null) {
            List<Advisor> advisors = new ArrayList<>();
            aspectNames = new ArrayList<>();
            // 获取容器中所有的beanName
            String[] beanNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(
                  this.beanFactory, Object.class, true, false);
            for (String beanName : beanNames) {
               if (!isEligibleBean(beanName)) {
                  continue;
               }
               // We must be careful not to instantiate beans eagerly as in this case they
               // would be cached by the Spring container but would not have been weaved.
               // 获取Class对象
               Class<?> beanType = this.beanFactory.getType(beanName);
               if (beanType == null) {
                  continue;
               }
               // 1.判断类上是否有@Aspect注解
               if (this.advisorFactory.isAspect(beanType)) {
                  aspectNames.add(beanName);
                  // 解析封装@Aspect注解信息
                   AspectMetadata amd = new AspectMetadata(beanType, beanName);
                  if (amd.getAjType().getPerClause().getKind() == PerClauseKind.SINGLETON) {
                     MetadataAwareAspectInstanceFactory factory =
                           new BeanFactoryAspectInstanceFactory(this.beanFactory, beanName);
                     // 2.获取所有切面方法封装类Advisor集合
                     List<Advisor> classAdvisors = this.advisorFactory.getAdvisors(factory);
                     if (this.beanFactory.isSingleton(beanName)) {
                        this.advisorsCache.put(beanName, classAdvisors);
                     }
                     else {
                        this.aspectFactoryCache.put(beanName, factory);
                     }
                     // 3.将Advisor添加到列表中
                     advisors.addAll(classAdvisors);
                  }
                  else {
                     // Per target or per this.
                     if (this.beanFactory.isSingleton(beanName)) {
                        throw new IllegalArgumentException("Bean with name '" + beanName +
                              "' is a singleton, but aspect instantiation model is not singleton");
                     }
                     MetadataAwareAspectInstanceFactory factory =
                           new PrototypeAspectInstanceFactory(this.beanFactory, beanName);
                     this.aspectFactoryCache.put(beanName, factory);
                     advisors.addAll(this.advisorFactory.getAdvisors(factory));
                  }
               }
            }
            this.aspectBeanNames = aspectNames;
            // 4.返回Advisor列表
            return advisors;
         }
      }
   }
   ... ...
   return advisors;
}

此时我们示例代码中的StudyTrainAspect会通过buildAspectJAdvisors()注释1处的判断,走到注释2处的方法里面。

@Override
public List<Advisor> getAdvisors(MetadataAwareAspectInstanceFactory aspectInstanceFactory) {
   Class<?> aspectClass = aspectInstanceFactory.getAspectMetadata().getAspectClass();
   String aspectName = aspectInstanceFactory.getAspectMetadata().getAspectName();
   validate(aspectClass);

   // We need to wrap the MetadataAwareAspectInstanceFactory with a decorator
   // so that it will only instantiate once.
   MetadataAwareAspectInstanceFactory lazySingletonAspectInstanceFactory =
         new LazySingletonAspectInstanceFactoryDecorator(aspectInstanceFactory);

   List<Advisor> advisors = new ArrayList<>();
   // 1. getAdvisorMethods返回没有@Pointcut注解的方法集合
   for (Method method : getAdvisorMethods(aspectClass)) {
      // 2. 创建Advisor
      Advisor advisor = getAdvisor(method, lazySingletonAspectInstanceFactory, advisors.size(), aspectName);
      if (advisor != null) {
         advisors.add(advisor);
      }
   }
   ... ...
   return advisors;
}

首先我们getAdvisors()注释1处getAdvisorMethods方法
在这里插入图片描述
getAdvisorMethods获取到没有@Pointcut的方法,所以method列表中会记录到示例代码StudyTrainAspect的studyTrain方法
接着我们返回getAdvisors()注释2处。
在这里插入图片描述
getPointcut方法会去解析studyTrain上的@Around注解的信息。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

@Nullable
public static <A extends Annotation> A findAnnotation(Method method, @Nullable Class<A> annotationType) {
   Assert.notNull(method, "Method must not be null");
   if (annotationType == null) {
      return null;
   }
   AnnotationCacheKey cacheKey = new AnnotationCacheKey(method, annotationType);
   A result = (A) findAnnotationCache.get(cacheKey);

   if (result == null) {
      Method resolvedMethod = BridgeMethodResolver.findBridgedMethod(method);
      // 从当前方法上寻找annotationType类型的注解信息
      result = findAnnotation((AnnotatedElement) resolvedMethod, annotationType);
      if (result == null) {
         // 从当前类实现的接口中寻找annotationType类型的注解
         result = searchOnInterfaces(method, annotationType, method.getDeclaringClass().getInterfaces());
      }
      // 从父类中寻找annotationType类型的注解信息
      Class<?> clazz = method.getDeclaringClass();
      while (result == null) {
         ... ...
      }

      ... ...
   }

   return result;
}

最后可以看到注释2处将信息封装到AspectJAnnotation中返回到getPointcut方法中,此时getPointcut注释1处获取到的aspectJAnnotation对象如下:
在这里插入图片描述
最后再到注释2处创建AspectJExpressionPointcut返回出去。
在这里插入图片描述
可以看到AspectJExpressionPointcut保存着示例代码StudyTrainAspect的studyTrain方法@Around注解的pointcut表达式:studyTrainPointcut()。
接着我们看回到getAdvisor方法的注释2处
在这里插入图片描述
通过入参我们可以发现这里的InstantiationModelAwarePointcutAdvisorImpl装了示例代码pointcut表达式(studyTrainPointcut())和Advice增强方法(studyTrain())等信息。
接着我们看一下InstantiationModelAwarePointcutAdvisorImpl类
在这里插入图片描述
在这里插入图片描述
可以看到这个类最终实现了Advisor接口,而且类中有几个重要的属性,我们看一下在构造方法中instantiatedAdvice属性是如何赋值的。
在这里插入图片描述
在这里插入图片描述
getAdvice具体代码如下

@Override
@Nullable
public Advice getAdvice(Method candidateAdviceMethod, AspectJExpressionPointcut expressionPointcut,
      MetadataAwareAspectInstanceFactory aspectInstanceFactory, int declarationOrder, String aspectName) {

   Class<?> candidateAspectClass = aspectInstanceFactory.getAspectMetadata().getAspectClass();
   validate(candidateAspectClass);

   // 获取Method方法上的注解
   AspectJAnnotation<?> aspectJAnnotation =
         AbstractAspectJAdvisorFactory.findAspectJAnnotationOnMethod(candidateAdviceMethod);
   if (aspectJAnnotation == null) {
      return null;
   }
   ... ...
   AbstractAspectJAdvice springAdvice;

   switch (aspectJAnnotation.getAnnotationType()) {
      case AtPointcut:
          // 忽略@Pointcut注解
         return null;
      case AtAround:
         // 封装@Around注解
         // AspectJAroundAdvice实现了MethodInterceptor接口
         springAdvice = new AspectJAroundAdvice(
               candidateAdviceMethod, expressionPointcut, aspectInstanceFactory);
         break;
      case AtBefore:
         // 封装@Before注解
         // AspectJMethodBeforeAdvice实现了MethodBeforeAdvice接口
         springAdvice = new AspectJMethodBeforeAdvice(
               candidateAdviceMethod, expressionPointcut, aspectInstanceFactory);
         break;
      case AtAfter:
         // 封装@After注解
         // AspectJAfterAdvice实现了MethodInterceptor接口
         springAdvice = new AspectJAfterAdvice(
               candidateAdviceMethod, expressionPointcut, aspectInstanceFactory);
         break;
      case AtAfterReturning:
         // 封装@afterReturning注解
         // AspectJAfterReturningAdvice实现了AfterReturningAdvice接口
         springAdvice = new AspectJAfterReturningAdvice(
               candidateAdviceMethod, expressionPointcut, aspectInstanceFactory);
         AfterReturning afterReturningAnnotation = (AfterReturning) aspectJAnnotation.getAnnotation();
         if (StringUtils.hasText(afterReturningAnnotation.returning())) {
            springAdvice.setReturningName(afterReturningAnnotation.returning());
         }
         break;
      case AtAfterThrowing:
         // 封装@afterThrowing注解
         // AspectJAfterThrowingAdvice实现了MethodInterceptor接口
         springAdvice = new AspectJAfterThrowingAdvice(
               candidateAdviceMethod, expressionPointcut, aspectInstanceFactory);
         AfterThrowing afterThrowingAnnotation = (AfterThrowing) aspectJAnnotation.getAnnotation();
         if (StringUtils.hasText(afterThrowingAnnotation.throwing())) {
            springAdvice.setThrowingName(afterThrowingAnnotation.throwing());
         }
         break;
      default:
         throw new UnsupportedOperationException(
               "Unsupported advice type on method: " + candidateAdviceMethod);
   }

   // Now to configure the advice...
   springAdvice.setAspectName(aspectName);
   springAdvice.setDeclarationOrder(declarationOrder);
   String[] argNames = this.parameterNameDiscoverer.getParameterNames(candidateAdviceMethod);
   if (argNames != null) {
      springAdvice.setArgumentNamesFromStringArray(argNames);
   }
   springAdvice.calculateArgumentBindings();

   return springAdvice;
}

可以看到在getAdvice方法中根据注解封装成对应的Advice,其中封装Around注解和After注解的Advice实现了MethodInterceptor接口,而封装After注解的Before实现的是MethodBeforeAdvice接口。
所以在我们示例代码中StudyTrainAspect中的带有@Around注解的studyTrain方法会被封装成AspectJAroundAdvice对象
在这里插入图片描述
接着getAdvisor方法将Advisor返回到buildAspectJAdvisors中,buildAspectJAdvisors再将Advisor保存到列表里继续获取其他Advisor,最后将Advisor列表返回到findEligibleAdvisors。
在这里插入图片描述
接着我们继续看findEligibleAdvisors注释第2点处,看看Bean和Advisor是如何匹配,在我们示例代码就是StudentA的examing方法与StudyTrainAspect的studyTrain的匹配过程。
在这里插入图片描述
findEligibleAdvisors这块的匹配比较复杂,我们跟着断点大概看一下
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上面这个方法中的FuzzyBoolean类会记录匹配结果
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这里通过hasAnnotation判断examing方法是否包含StudyTraninAnnotation注解,由于examing是有添加@StudyTraninAnnotation注解的,所以会返回YES
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
然后将match封装到ShadowMatchImpl中返回出去
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
最后通过匹配,把Advisor添加到eligibleAdvisors中返回出去
接着回到findEligibleAdvisors方法中
在这里插入图片描述
此时就将StudentA匹配到的Advisor返回出去
在这里插入图片描述
接着就回到了一开始的wrapIfNecessary中
在这里插入图片描述
我们接下来将用注释1处获取到的Advisor用于注释2处创建StudentA的代理对象。
在这里插入图片描述
在createProxy方法中可以看到创建了代理工厂proxyFacotry,并且将Advisor添加到代理工厂中,然后通过proxyFacotry获取代理对象。
在这里插入图片描述
我们先看一下getProxy中的注释1处创建AOP代理
在这里插入图片描述
在这里插入图片描述
由于我们的StudentA实现了Student接口,所以采用JDK代理模式,因此会创建一个JDKDynamicAopProxy代理对象返回
而JDKDynamicAopProxy实现了动态代理的关键接口InvocationHandler,因此StudentA代理对象调用接口中的examing方法的时候会调用回此类的invoke方法中,这个我们在后面介绍。
接着我们先回到getProxy方法中的注释第2处,看看getProxy方法
在这里插入图片描述
选择JDKDynamicAopProxy对象
在这里插入图片描述
在这里插入图片描述可以看到需要代理的接口中就包含了Student接口,接着继续往下走到Proxy.newProxyInstance方法中,接下来就是我们熟悉的动态代理相关的代码了
在这里插入图片描述
在这里插入图片描述
最后生成动态代理类返回到一开始提到的wrapIfNecessary方法中。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
最后返回到doCreateBean方法中,此时exposedObject持有着StudentA的代理对象。
在这里插入图片描述
最后doCreateBean将exposedObject返回出去
在这里插入图片描述
接着进入到getSingleton中,最终studentA缓存的对象是StudentA的代理对象。
在这里插入图片描述
这也就是为什么在示例代码中我们获取到的StudentA是个代理对象的原因。
接着我们在前面分析的时候已经提到,StudentA代理对象调用接口中的examing方法的时候会调用代理对象的invoke方法,也就是JDKDynamicAopProxy的invoke方法。
接着我们看一下在JDKDynamicAopProxy的invoke方法中是如何调用到StudentA的examing方法和StudyTrainAspect的studyTrain方法

@Override
@Nullable
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
   Object oldProxy = null;
   boolean setProxyContext = false;
       // 从advised中获取targetSource
   // 代理实例就保存在targetSource的target属性中
   TargetSource targetSource = this.advised.targetSource;
   Object target = null;
   try {
      ... ...
      // Get as late as possible to minimize the time we "own" the target,
      // in case it comes from a pool.
      // 获取代理示例
      target = targetSource.getTarget();
      Class<?> targetClass = (target != null ? target.getClass() : null);

      // Get the interception chain for this method.
      // 获取符合当前代理示例方法的切面执行链
      List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);

      if (chain.isEmpty()) {
         Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
         retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
      }
      else {
         // We need to create a method invocation...
         // 创建MethodInvocation
         MethodInvocation invocation =
               new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
         // Proceed to the joinpoint through the interceptor chain.
         // 1.调用切面执行链方法
         retVal = invocation.proceed();
      }
      ... ...
      return retVal;
   }
   ... ...
}

看到注释1处继续调用proceed方法
在这里插入图片描述
调用到我们的@Around注解的方法studyTrain的封装类AspectJAroundAdvice的invoke方法,继续进入到AspectJAroundAdvice的invoke方法中
在这里插入图片描述
在这里插入图片描述
这时就调用到StudyTrainAspect的studyTrain方法中了
在这里插入图片描述
然后我们修改参数后调用了joinPoint的proceed方法
在这里插入图片描述
再次来到proceed方法中
在这里插入图片描述
此时链条已经执行完毕,进入到invokeJoinponit方法中,最后会反射调用到StudentA的examing方法
在这里插入图片描述
在这里插入图片描述
然后返回到proceed方法中
在这里插入图片描述
然后继续返回到StudyTrainAspect的studyTrain方法中的joinPoint.proceed处
在这里插入图片描述
然后执行往studyTrain继续往上返回出去在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
最后执行完毕。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值