从@ComponentScan注解配置包扫描路径到IoC容器中的BeanDefinition,经历了什么(三)?

题记

做为分析Spring是如何将@ComponentScan注解属性basepackages指定的包路径下的资源通过I/O来读取,通过ASM来解析类中的注解元数据(MetadataReader→AnnotationMetadata)并构造成BeanDe-finition这个过程的最后最后一篇文章。

在文章开始前先总结下前面两篇文章讲了些什么。

从@ComponentScan注解配置包扫描路径到IoC容器中的BeanDefinition,经历了什么(一)?

在第一篇文章中,我们分析了在AbstractApplicationContext的refresh方法中是如何完成指定包路径下的资源加载,这里面涉及到了JDK提供的I/O流,以及递归实现。另外和大家所想象的直接利用JVM提供的反射技术来获取类元信息不同的是,Spring是基于ASM技术来读取字节码数据来获取自己所需要的信息。在读取.class文件时候,Spring使用了NIO技术,在FileSystemResource类的getInputStrea-m方法有所体现,感兴趣的小伙伴可以阅读下。

获取到类元数据后,接下来就是进行两次筛选,把接口和没有添加@Lookup注解的抽象类以及内部类和未添加标识注解的类过滤掉。最后把筛选过后的BeanDefinition返回。

从@ComponentScan注解配置包扫描路径到IoC容器中的BeanDefinition,经历了什么(二)?(https://www.notion.so/ComponentScan-IoC-BeanDefinition-f846a8c8a8d049a1a9be7b1121d0890e)

在第二篇文章中,我们基于上一步返回的BeanDefinition来分析Spring接下来的处理逻辑,还有为什么同样的方法会执行N次。主要是因为对于筛选过后的类,Spring还没有解析它们中的注解信息,因此需要重复执行执行。

还解释了如果在父类中添加应用上下文提供的注解,Spring是否支持?如果在接口中定义默认方法,在这些默认方法上添加@Bean注解,Spring是否支持?如果支持,在源码中是如何体现的以及Confi-gurationClass和SourceClass的区别。

第二篇文章的重点是Spring动态可插拔组件的基石-@Import注解。我们自定义注解中@Import注解是如何被找到的,以及@Import注解可以导入的三种类型。这有助于我们去理解Spring AOP和声明式事务以及MyBatis和Spring是如何整合的。该注解也属于Spring元注解中的一部分。

由于本篇文章是基于前面两篇文章的基础来进行分析,因此小伙伴们先阅读前面两篇文章,会更便于理解。

源码分析

总结完毕,我们把目光回到梦开始的地方-ConfigurationClassPostProcessor的processConfigBeanDe-finitions方法中。

可以说前面两篇文章都是围绕在该方法中调用ConfigurationClassParser的parse方法来分析,接下来就是分析这之后的代码。

public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) {
   List<BeanDefinitionHolder> configCandidates = new ArrayList<>();
   String[] candidateNames = registry.getBeanDefinitionNames();

   for (String beanName : candidateNames) {
      BeanDefinition beanDef = registry.getBeanDefinition(beanName);
      if (beanDef.getAttribute(ConfigurationClassUtils.CONFIGURATION_CLASS_ATTRIBUTE) != null) {
         if (logger.isDebugEnabled()) {
            logger.debug("Bean definition has already been processed as a configuration class: " + beanDef);
         }
      }
      else if (ConfigurationClassUtils.checkConfigurationClassCandidate(beanDef, this.metadataReaderFactory)) {
         configCandidates.add(new BeanDefinitionHolder(beanDef, beanName));
      }
   }

   // Return immediately if no @Configuration classes were found
   if (configCandidates.isEmpty()) {
      return;
   }

   // Sort by previously determined @Order value, if applicable
   configCandidates.sort((bd1, bd2) -> {
      int i1 = ConfigurationClassUtils.getOrder(bd1.getBeanDefinition());
      int i2 = ConfigurationClassUtils.getOrder(bd2.getBeanDefinition());
      return Integer.compare(i1, i2);
   });

   // Detect any custom bean name generation strategy supplied through the enclosing application context
   SingletonBeanRegistry sbr = null;
   if (registry instanceof SingletonBeanRegistry) {
      sbr = (SingletonBeanRegistry) registry;
      if (!this.localBeanNameGeneratorSet) {
         BeanNameGenerator generator = (BeanNameGenerator) sbr.getSingleton(
               AnnotationConfigUtils.CONFIGURATION_BEAN_NAME_GENERATOR);
         if (generator != null) {
            this.componentScanBeanNameGenerator = generator;
            this.importBeanNameGenerator = generator;
         }
      }
   }

   if (this.environment == null) {
      this.environment = new StandardEnvironment();
   }

   // Parse each @Configuration class
   ConfigurationClassParser parser = new ConfigurationClassParser(
         this.metadataReaderFactory, this.problemReporter, this.environment,
         this.resourceLoader, this.componentScanBeanNameGenerator, registry);

   Set<BeanDefinitionHolder> candidates = new LinkedHashSet<>(configCandidates);
   Set<ConfigurationClass> alreadyParsed = new HashSet<>(configCandidates.size());
   do {
      parser.parse(candidates);
      parser.validate();

      Set<ConfigurationClass> configClasses = new LinkedHashSet<>(parser.getConfigurationClasses());
      configClasses.removeAll(alreadyParsed);

      // Read the model and create bean definitions based on its content
      if (this.reader == null) {
         this.reader = new ConfigurationClassBeanDefinitionReader(
               registry, this.sourceExtractor, this.resourceLoader, this.environment,
               this.importBeanNameGenerator, parser.getImportRegistry());
      }
      this.reader.loadBeanDefinitions(configClasses);
      alreadyParsed.addAll(configClasses);

      candidates.clear();
      if (registry.getBeanDefinitionCount() > candidateNames.length) {
         String[] newCandidateNames = registry.getBeanDefinitionNames();
         Set<String> oldCandidateNames = new HashSet<>(Arrays.asList(candidateNames));
         Set<String> alreadyParsedClasses = new HashSet<>();
         for (ConfigurationClass configurationClass : alreadyParsed) {
            alreadyParsedClasses.add(configurationClass.getMetadata().getClassName());
         }
         for (String candidateName : newCandidateNames) {
            if (!oldCandidateNames.contains(candidateName)) {
               BeanDefinition bd = registry.getBeanDefinition(candidateName);
               if (ConfigurationClassUtils.checkConfigurationClassCandidate(bd, this.metadataReaderFactory) &&
                     !alreadyParsedClasses.contains(bd.getBeanClassName())) {
                  candidates.add(new BeanDefinitionHolder(bd, candidateName));
               }
            }
         }
         candidateNames = newCandidateNames;
      }
   }
   while (!candidates.isEmpty());

   // Register the ImportRegistry as a bean in order to support ImportAware @Configuration classes
   if (sbr != null && !sbr.containsSingleton(IMPORT_REGISTRY_BEAN_NAME)) {
      sbr.registerSingleton(IMPORT_REGISTRY_BEAN_NAME, parser.getImportRegistry());
   }

   if (this.metadataReaderFactory instanceof CachingMetadataReaderFactory) {
      // Clear cache in externally provided MetadataReaderFactory; this is a no-op
      // for a shared cache since it'll be cleared by the ApplicationContext.
      ((CachingMetadataReaderFactory) this.metadataReaderFactory).clearCache();
   }
}

执行完ConfigurationClassParser的parse方法后,首先从parser中获取所有的ConfigurationClass,然后构造成一个Set集合,从该集合中移除掉哪些已经解析过的ConfigurationClass。

判断当前成员属性reader是否为空(注意这段代码是位于一个do…while循环中,第一次循环时为空),如果为空,则直接创建一个ConfigurationClassBeanDefinitionReader实例。需注意的是,这里创建的不是什么AnnotationBeanDefinitionReader,也和其没有任何关系,这点可以从类关系图中看出。值得重点分析的就是接下来调用的reader的loadeBeanDefinitions方法。

private ConfigurationClassBeanDefinitionReader reader;
// ConfigurationClassPostProcessor#processConfigBeanDefinitions 代码片段
Set<ConfigurationClass> configClasses = new LinkedHashSet<>(parser.getConfigurationClasses());
configClasses.removeAll(alreadyParsed);

// Read the model and create bean definitions based on its content
if (this.reader == null) {
   this.reader = new ConfigurationClassBeanDefinitionReader(
         registry, this.sourceExtractor, this.resourceLoader, this.environment,
         this.importBeanNameGenerator, parser.getImportRegistry());
}
this.reader.loadBeanDefinitions(configClasses);

在这里插入图片描述
可以看到在该方法中首先创建了TrackedConditionEvaluator实例,然后遍历传入的ConfigurationCla-ss调用loadBeanDefinitionForConfigurationClass方法。

// ConfigurationClassBeanDefinitionReader#loadBeanDefinitions
public void loadBeanDefinitions(Set<ConfigurationClass> configurationModel) {
   TrackedConditionEvaluator trackedConditionEvaluator = new TrackedConditionEvaluator();
   for (ConfigurationClass configClass : configurationModel) {
      loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator);
   }
}

在该方法中首先调用传入的TrackedConditionEvaluator实例的shouldSkip方法来判断当前Configurat-ionClass是否需要跳过处理。如果在这里判定为需要跳过,那么将会从IoC容器中移除该BeanDefiniti-on。

如果判定不需要跳过,接下来判断当前ConfigurationClass是否是通过@Import注解导入的,如果判断成立,调用registerBeanDefinitionForImportedConfiguration方法。获取当前ConfigurationClass中的beanMethods属性,这是一个集合,遍历该集合,调用loadBeanDefinitionForBeanMethod方法。

调用loadBeanDefinitionsFromImportedResources方法来处理当前ConfigurationClass中@ImportRes-ource注解导入的资源;

调用loadBeanDefinitionsFromRegistrars方法来处理当前ConfigurationClass中@Import注解导入的I-mportBeanDefinitionRegistrar实现类。

// ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass
private void loadBeanDefinitionsForConfigurationClass(
      ConfigurationClass configClass, TrackedConditionEvaluator trackedConditionEvaluator) {

   if (trackedConditionEvaluator.shouldSkip(configClass)) {
      String beanName = configClass.getBeanName();
      if (StringUtils.hasLength(beanName) && this.registry.containsBeanDefinition(beanName)) {
         this.registry.removeBeanDefinition(beanName);
      }
      this.importRegistry.removeImportingClass(configClass.getMetadata().getClassName());
      return;
   }

   if (configClass.isImported()) {
      registerBeanDefinitionForImportedConfigurationClass(configClass);
   }
   for (BeanMethod beanMethod : configClass.getBeanMethods()) {
      loadBeanDefinitionsForBeanMethod(beanMethod);
   }

   loadBeanDefinitionsFromImportedResources(configClass.getImportedResources());
   loadBeanDefinitionsFromRegistrars(configClass.getImportBeanDefinitionRegistrars());
}

一个个分析这些调用到的方法,首先来看TrackedConditionEvalutor类。该类是ConfigurationClassB-eanDefinitionReader的一个内部类,比较简单。只有一个成员属性skipped,这是一个HashMap,用来存储对应的ConfigurationClass是否需要跳过的布尔值。

来分析下该类唯一的一个方法-shouldSkip。首先是从成员属性skipped中根据传入的ConfigurationC-lass来获取对应的布尔值,如果不为null直接返回。

如果为null,那么首先判断传入的ConfigurationClass是否是被导入的,如果是被导入的,那么则获取所有导入该ConfigurationClass的ConfigurationClass,递归调用,如果有一个方法返回false,则将all-Skipped修改为false,该值默认为true。如果遍历完所有的ConfigurationClass,allSkipped属性依旧为true,那么则将skip设为true,将处理结果保存进skipped这个Map中,返回。

可能有不少小伙伴没明白这是什么意思,要搞明白这个就需要搞明白@Import注解的作用以及解析过程 从@ComponentScan注解配置包扫描路径到IoC容器中的BeanDefinition,经历了什么(二)? 就很简单了。

TrackedConditionEvaluator的shouldSkip方法只是用来解析被导入Bean和导入被导入Bean的Bean的关系,是否需要跳过的逻辑判断还是交由ConditionEvaluator的shouldSkip方法来完成。

另外需要注意的一点是Spring认为通过内部类也是被导入的类,因此这套规则同样适用。

// ConfigurationClassBeanDefinitionReader.TrackedConditionEvaluator
private class TrackedConditionEvaluator {

   private final Map<ConfigurationClass, Boolean> skipped = new HashMap<>();

   public boolean shouldSkip(ConfigurationClass configClass) {
      Boolean skip = this.skipped.get(configClass);
      if (skip == null) {
         if (configClass.isImported()) {
            boolean allSkipped = true;
            for (ConfigurationClass importedBy : configClass.getImportedBy()) {
               if (!shouldSkip(importedBy)) {
                  allSkipped = false;
                  break;
               }
            }
            if (allSkipped) {
               // The config classes that imported this one were all skipped, therefore we are skipped...
               skip = true;
            }
         }
         if (skip == null) {
            skip = conditionEvaluator.shouldSkip(configClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN);
         }
         this.skipped.put(configClass, skip);
      }
      return skip;
   }
}

假设现在有三个类要交给IoC容器管理,它们分别为A、B、ImportBean,A和B都是正常的通过@Co-mponent或者其派生注解来标识的,而ImportBean则是在在类A和类B中添加@Import注解导入。

那么当shouldSkip方法解析到ImportBean的ConfigurationClass时,首先判断是否是被导入的,判断成立,然后获取是哪些ConfigurationClass导入的ImportBean,这里获取到就是A和B的Configuratio-nClass。

获取到A的ConfigurationClass递归调用shouldSkip方法时,还是先从skipped缓存中获取对应的处理结果,如果有直接返回,但如果没有的话,这时的是否是被导入的判断就不成立了,然后直接执行ConditionEvaluator的shouldSkip方法。

这里Spring设计的很有意思的一点在于。如果在调用ConditionEvaluator的shouldSkip方法处理A的时候返回值是false,那么这个循环将会结束,不会再去处理B,也不会去修改allSkipped变量的值。这时候就会调用ConditionEvaluator的shouldSkip方法去计算ImportBean。

而如果调用ConditionEvaluator的shouldSkip方法计算出来的A和B的返回值都是true,那么直接将skip变量设为true,不会去计算ImportBean。

也就是说ImportBean也可以添加@Conditional注解来指定自己是否装配,但ImportBean通过@Con-ditional注解指定的Condition实现类实现的mtaches不一定会被调用,这取决于A和B。

要想测试出这种效果需要将A和B中@Conditional注解指定的类实现ConfigurationCondition接口,而不是直接实现Condition接口,并修改getConfigurationPhase返回值为ConfigurationPhase.REGISTE-R_BEAN。

@Component
@Conditional(ACondition.class)
@Import(ImportBean.class)
public class A {}

public class ACondition implements ConfigurationCondition {

	@Override
	public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
		return false;
	}

	@Override
	public ConfigurationPhase getConfigurationPhase() {
		return ConfigurationPhase.REGISTER_BEAN;
	}
}

@Component
@Import(ImportBean.class)
@Conditional(BCondition.class)
public class B {}

public class BCondition implements ConfigurationCondition {
	@Override
	public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
		return false;
	}

	@Override
	public ConfigurationPhase getConfigurationPhase() {
		return ConfigurationPhase.REGISTER_BEAN;
	}
}

@Conditional(ImportBeanCondition.class)
public class ImportBean {
}

public class ImportBeanCondition implements ConfigurationCondition {
	@Override
	public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
		return true;
	}

	@Override
	public ConfigurationPhase getConfigurationPhase() {
		return ConfigurationPhase.REGISTER_BEAN;
	}
}

由此可以得出一个结论,如果导入类(可能有多个)认为被导入类应该导入,那么到底要不要被导入就交由被导入类自己决定。而如果导入类(可能有多个)认为被导入类不应该被导入,那么被导入类就不能决定自己到底要不要被导入。

接下来分析regitserBeanDefinitionForImportedConfigurationClass方法,该方法的功能的便是为通过@Import注解导入的普通类生成BeanDefinition然后注册进IoC容器中。

可以看到这里为通过@Import注解导入的普通类创建的BeanDefinition类型为AnnotatedGnericBean-Definition,而不是前面对于从磁盘扫描的Class创建的ScannedGenericBeanDefinition。

调用ScopeMetadataResolver的resolveScopeMetadata方法来解析类的作用域并设置到BeanDefiniti-on中。

private void registerBeanDefinitionForImportedConfigurationClass(ConfigurationClass configClass) {
   AnnotationMetadata metadata = configClass.getMetadata();
   AnnotatedGenericBeanDefinition configBeanDef = new AnnotatedGenericBeanDefinition(metadata);

   ScopeMetadata scopeMetadata = scopeMetadataResolver.resolveScopeMetadata(configBeanDef);
   configBeanDef.setScope(scopeMetadata.getScopeName());
   String configBeanName = this.importBeanNameGenerator.generateBeanName(configBeanDef, this.registry);
   AnnotationConfigUtils.processCommonDefinitionAnnotations(configBeanDef, metadata);

   BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(configBeanDef, configBeanName);
   definitionHolder = AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
   this.registry.registerBeanDefinition(definitionHolder.getBeanName(), definitionHolder.getBeanDefinition());
   configClass.setBeanName(configBeanName);

   if (logger.isTraceEnabled()) {
      logger.trace("Registered bean definition for imported class '" + configBeanName + "'");
   }
}

调用ImportBeanNameGenerator根据BeanDefinition来生成BeanName。虽然这里使用的是FullyQual-ifiedAnnotationBeanNameGenerator,但该类并未实现generateBeanName方法,所以最终调用的还是AnnotationBeanNameGenerator的方法。

但通常情况下通过@Import注解导入的类都不会在类上面添加Spring IoC或Context相关注解,所以这里最终还是调用buildDefaultBeanName方法,而FullyQualifiedAnnotationBeanNameGenerator重写了该方法,这也是其实现的唯一一个方法。在其实现中,直接返回全限定名,并没有像父类的build-DefaultBeanName方法那样通过Java的内省机制来将类名首字母小写。

因此,通过@Import注解导入的普通Bean(未实现ImportSelector和ImportBeanDefinitionRegistry接口的Bean)其在IoC容器中beanName通常都是全限定名。

public class FullyQualifiedAnnotationBeanNameGenerator extends AnnotationBeanNameGenerator {

   @Override
   protected String buildDefaultBeanName(BeanDefinition definition) {
      String beanClassName = definition.getBeanClassName();
      Assert.state(beanClassName != null, "No bean class name set");
      return beanClassName;
   }

}

调用AnnotationConfigUtils的processCommonDefinitionAnnotations方法来解析通用注解,这里说的通用注解指@Lazy、@Primary、@DependsOn、@Role、@Description这五个注解。

static void processCommonDefinitionAnnotations(AnnotatedBeanDefinition abd, AnnotatedTypeMetadata metadata) {
   AnnotationAttributes lazy = attributesFor(metadata, Lazy.class);
   if (lazy != null) {
      abd.setLazyInit(lazy.getBoolean("value"));
   }
   else if (abd.getMetadata() != metadata) {
      lazy = attributesFor(abd.getMetadata(), Lazy.class);
      if (lazy != null) {
         abd.setLazyInit(lazy.getBoolean("value"));
      }
   }

   if (metadata.isAnnotated(Primary.class.getName())) {
      abd.setPrimary(true);
   }
   AnnotationAttributes dependsOn = attributesFor(metadata, DependsOn.class);
   if (dependsOn != null) {
      abd.setDependsOn(dependsOn.getStringArray("value"));
   }

   AnnotationAttributes role = attributesFor(metadata, Role.class);
   if (role != null) {
      abd.setRole(role.getNumber("value").intValue());
   }
   AnnotationAttributes description = attributesFor(metadata, Description.class);
   if (description != null) {
      abd.setDescription(description.getString("value"));
   }
}

之后是设置作用域代理模型,最重要的是调用BeanDefinitionRegistry的registerBeanDefinition方法将BeanDefinition信息注册到IoC容器中。

处理完通过@Import或者内部类方式注册的Bean后,接下来便是处理通过@Bean方法注册的Bean。获取当前ConfigurationClass的beanMethods属性,然后遍历调用loadBeanDefinitionForBeanMethod方法。

// ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass 方法片段
for (BeanMethod beanMethod : configClass.getBeanMethods()) {
   loadBeanDefinitionsForBeanMethod(beanMethod);
}

这个方法太长,并且大部分代码都是从@Bean注解中获取信息设置BeanDefinition中去,所以就不逐行分析了,我就把值得分析的一部分代码复制出来。

首先可以看到对于@Bean注解注册的Bean,Spring使用的是BeanDefinition类型为ConfigurationClas-sBeanDefinition。

private void loadBeanDefinitionsForBeanMethod(BeanMethod beanMethod) {
	 // 
   AnnotationAttributes bean = AnnotationConfigUtils.attributesFor(metadata, Bean.class);
   Assert.state(bean != null, "No @Bean annotation attributes");

   // Consider name and any aliases
   List<String> names = new ArrayList<>(Arrays.asList(bean.getStringArray("name")));
   String beanName = (!names.isEmpty() ? names.remove(0) : methodName);

   ConfigurationClassBeanDefinition beanDef = new ConfigurationClassBeanDefinition(configClass, metadata);
   beanDef.setSource(this.sourceExtractor.extractSource(metadata, configClass.getResource()));

   if (metadata.isStatic()) {
      // static @Bean method
      if (configClass.getMetadata() instanceof StandardAnnotationMetadata) {
         beanDef.setBeanClass(((StandardAnnotationMetadata) configClass.getMetadata()).getIntrospectedClass());
      }
      else {
         beanDef.setBeanClassName(configClass.getMetadata().getClassName());
      }
      beanDef.setUniqueFactoryMethodName(methodName);
   }
   else {
      // instance @Bean method
      beanDef.setFactoryBeanName(configClass.getBeanName());
      beanDef.setUniqueFactoryMethodName(methodName);
   }
	
   this.registry.registerBeanDefinition(beanName, beanDefToRegister);
}

另一个值得分析的点就是static和非static,可以看到对于static修饰的并标记了@Bean的方法设置其BeanClass属性以及uniqueFactoryMethodName属性,而对于非static修饰的并且标记了@Bean注解的方法则设置BeanDefinition的factoryBeanName属性。

因为这涉及到Bean的实例化以及@Configuration注解,这里就不分析原因,对于static修饰的并且标记了@Bean注解的方法,无论定义该方法的类中有没有添加@Configuration注解,每次调用这个方法都会产生不同实例。

而如果是非static修饰的并且添加了@Bean注解的方法,并且定义这个方法的类也添加了@Configur-ation注解,那么不管调用这个方法多少次,返回的实例都是同一个。

loadBeanDefinitionsFromImportedResources这个方法就不展开分析了,因为这部分是使用XmlBean-DefinitionReader来解析通过@ImportResource导入的资源。

// ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass
loadBeanDefinitionsFromImportedResources(configClass.getImportedResources());

最后就是loadBeanDefinitionsFromRegistrars方法,在第二篇文章中讲到@Import注解的时候,曾分析通过实现ImportSelector导入的类会被立即解析,而对于实现ImportBeanDefinitionRegistrar接口来注册的BeanDefinition到这里才会被注册。

// ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass
loadBeanDefinitionsFromRegistrars(configClass.getImportBeanDefinitionRegistrars());

// ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsFromRegistrars
private void loadBeanDefinitionsFromRegistrars(Map<ImportBeanDefinitionRegistrar, AnnotationMetadata> registrars) {
		registrars.forEach((registrar, metadata) ->
				registrar.registerBeanDefinitions(metadata, this.registry, this.importBeanNameGenerator));
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值