【重写SpringFramework】配置类概述(chapter 3-5)

1. 前言

在早期的 Spring 项目中,使用 XML 文件进行配置。如下所示,在 applicationContext.xml 配置文件中,使用 bean 标签注册组件,使用 context:component-scan 标签批量扫描组件,使用 import 标签引入其他的 XML 文件,使用 context:property-placeholder 标签加载配置文件,等等。此外,有的标签带有前缀,需要引入相应的名称空间,操作起来异常繁琐。鉴于此,Spring 提供了一套声明式的解决方案,以配置类为核心,为我们提供了更加方便快捷、灵活多样的配置应用的方式。

 

xml

代码解读

复制代码

<?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" xsi:schemaLocation="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"> <-- 1. 注册组件 !--> <bean id="foo" class="cn.stimd.spring.Foo" > <property name="bar" value="bar"/> </bean> <bean id="bar" class="cn.stimd.spring.Bar" /> <-- 2. 批量扫描 !--> <context:component-scan base-package="cn.stimd.spring" use-default-filters="true"/> <-- 3. 导入配置文件 !--> <import resource="other.xml"/> <-- 4. 加载属性文件 !--> <context:property-placeholder location="classpath:jdbc.properties"/> </beans>

Spring 将配置类分为 Full 模式与 Lite 模式,其中 Lite 模式表示轻量级,仅拥有配置类的部分功能,Full 模式为全量级,拥有配置类的全部功能。两者的区别如下:

  • Full 模式:声明了 @Configuration 注解的类
  • Lite 模式:类上声明了 @Component@ComponentScan@Import 等注解,或者方法上声明了 @Bean 注解,也可认为是配置类

为了简化代码实现,我们不区分轻量级与全量级的配置类,所讨论的配置类也仅以声明了 @Configuration 注解为标准。@Configuration 注解声明了元注解 @Component,因此配置类可以通过扫描的方式来加载。

2. 配置类组件

2.1 继承结构

Spring 为了处理配置类,提供了三个重要的组件,此外还使用 CongigurationClass 来描述已解析的配置类。简单介绍如下:

  • ConfigurationClassPostProcessor:寻找容器中已存在的配置类,并对其进行解析。
  • ConfigurationClassParser:负责具体的解析过程,将已解析的配置类封装成 CongigurationClass 对象。
  • ConfigurationClassBeanDefinitionReader:对 CongigurationClass 集合进行处理,将所有的组件以 BeanDefinition 的方式注册到容器中。

2.2 ConfigurationClass

ConfigurationClass 用于描述一个独立的配置类,并以扁平化的方式管理所有的 BeanMethod。我们首先要明确独立和扁平化这两个概念。其一,当前配置类、内部配置类、导入配置类、组件扫描的配置类,都是独立的配置类。但配置类的父类除外,属于当前配置类的一部分。其二,扁平化是指配置类和父类可能都存在若干 BeanMethod,不管有多少继承层次,都会将所有的 BeanMethod 放在一个集合中管理。这也说明了配置类和父类是一体的。

ConfigurationClass 类定义了一些属性来描述配置类的信息,简单介绍如下:

  • metadata:表示配置类的元数据
  • importedBy:表示配置类是由谁导入的,对于内部类来说是外部类导入的。对于导入类来说,是由声明了 @Import 注解的类导入的,可能是外部类或内部类。
  • beanMethods:缓存了配置类及其父类所有的工厂方法(BeanMethod 相关,待实现)
  • importBeanDefinitionRegistrars:缓存了 ImportBeanDefinitionRegistrar 接口的实现类集合(导入相关,待实现)
 

java

代码解读

复制代码

public class ConfigurationClass { private final AnnotationMetadata metadata; private final Set<ConfigurationClass> importedBy = new LinkedHashSet<>(1); private final Set<BeanMethod> beanMethods = new LinkedHashSet<>(); private final Map<ImportBeanDefinitionRegistrar, AnnotationMetadata> importBeanDefinitionRegistrars = new LinkedHashMap<>(); }

3. ConfigurationClassPostProcessor

3.1 概述

ConfigurationClassPostProcessor 类实现了 BeanDefinitionRegistryPostProcessor 接口,这是用于处理 BeanFactory 的后处理器。beans 模块定义了该接口,当时没有用武之地,所以没有展开来讲。ConfigurationClassPostProcessor 的作用是解析配置类,并注册 BeanDefinition,其重要性不言而喻。

AnnotationConfigUtils 工具类注册 ConfigurationClassPostProcessor 作为默认的组件。这样一来,我们在创建 AnnotationConfigApplicationContext 实例的时候,自动拥有了处理配置类的能力。

整理了一份好像面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

需要全套面试笔记【点击此处即可】免费获取

java

代码解读

复制代码

public class AnnotationConfigUtils { public static final String CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME = "internalConfigurationAnnotationProcessor"; public static void registerAnnotationConfigProcessors(BeanDefinitionRegistry registry) { //注册支持@Configuration注解的组件 if(!registry.containsBeanDefinition(CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)){ RootBeanDefinition def = new RootBeanDefinition(ConfigurationClassPostProcessor.class); registerPostProcessor(registry, def, CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME); } //其余略 } }

3.2 加载流程

从 AnnotationConfigApplicationContext 的创建到配置类的处理,中间经过了哪些流程,我们结合时序图来简单分析一下。整个流程由以下步骤组成:

  1. 在 AnnotationConfigApplicationContext 的构造函数中创建 AnnotatedBeanDefinitionReader 组件的实例
  2. 在构造 AnnotatedBeanDefinitionReader 的过程中,通过工具类 AnnotationConfigUtils 让容器注册一些组件
  3. 将解析 ConfigurationClassPostProcessor 注册到容器中
  4. 回到 AnnotationConfigApplicationContext 的构造函数,调用父类的 refresh 方法,刷新容器
  5. AbstractApplicationContext 在刷新的过程中,调用 invokeBeanFactoryPostProcessors 方法回调 BeanFactoryPostProcessor
  6. ConfigurationClassPostProcessor 的 postProcessBeanDefinitionRegistry 方法完成配置类的解析流程

3.3 代码实现

postProcessBeanDefinitionRegistry 方法是处理配置类的入口,该方法定义了处理配置类的主流程,大体可以分为三步:

  1. 寻找容器中已存在的配置类(引导配置类)

  2. 解析引导配置类,加载所有的组件(包括其他配置类)

  3. 此时已经拿到了所有组件的元数据,包装成 BeanDefinition 注册到容器中

 

java

代码解读

复制代码

public class ConfigurationClassPostProcessor implements BeanDefinitionRegistryPostProcessor, EnvironmentAware { @Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws RuntimeException { //1. 寻找容器中已存在的配置类 List<BeanDefinitionHolder> configCandidates = new ArrayList<>(); String[] names = registry.getBeanDefinitionNames(); for (String name : names) { BeanDefinition definition = registry.getBeanDefinition(name); //检查是否声明了@Configuration等注解,如果是进一步区分full模式和lite模式 if(ConfigurationClassUtils.checkConfigurationClassCandidate(definition, this.metadataReaderFactory)) { configCandidates.add(new BeanDefinitionHolder(definition, name)); } } if(configCandidates.isEmpty()){ return; } //2. 解析引导配置类,加载所有的配置类 Set<BeanDefinitionHolder> candidates = new LinkedHashSet<>(configCandidates); ConfigurationClassParser parser = new ConfigurationClassParser(metadataReaderFactory, environment, resourceLoader, registry); parser.parse(candidates); //3. 注册BeanDefinition if(this.reader == null){ this.reader = new ConfigurationClassBeanDefinitionReader(registry, this.resourceLoader, this.environment); } this.reader.loadBeanDefinitions(parser.getConfigurationClasses()); } }

第二步和第三步有专门的组件来处理,逻辑比较复杂,下边单独介绍,我们先来看第一步。虽然第一步的逻辑并不复杂,关键问题是 ConfigurationClassPostProcessor 组件在 refresh 方法中的执行顺序相当靠前,几乎在 BeanFactory 准备完毕后就被调用,此时容器中不存在配置类。因此我们需要事先注册一个配置类,为了与后来加载的配置类区分开,我们把提前注册的配置类称为引导配置类。所谓引导(bootstrap)是指 Spring 以引导配置类为切入点,加载其他的配置类,从而完成对所有组件的加载。

一般情况下,引导配置类只有一个,主要通过 AnnotatedBeanDefinitionReader 来注册。这里也解决了我们一个疑惑,AnnotatedBeanDefinitionReader 只能注册一个 BeanDefinition,并没有比手动注册 BeanDefinition 方便多少,感觉有些鸡肋。现在我们知道,AnnotatedBeanDefinitionReader 的主要作用就是注册引导配置类,实际上 SpringBoot 的启动类就是如此处理的。

4. ConfigurationClassParser

4.1 配置类解析

在拿到引导配置类的集合之后,交给 ConfigurationClassParser 组件来解析。parse 方法是解析配置类的入口,由于 BeanDefinition 有多种加载方式,需要区分不同的情况,因此需要调用不同的 parse 重载方法,但最终都会调用 processConfigurationClass 方法。我们先不考虑具体的解析流程是如何处理的,经过处理之后,configurationClasses 字段保存了所有已解析的配置类。

 

java

代码解读

复制代码

class ConfigurationClassParser { private final MetadataReaderFactory metadataReaderFactory; //已解析的配置类集合 private final Set<ConfigurationClass> configurationClasses = new LinkedHashSet<>(); /** * 解析引导配置类,唯一入口方法 * @param configCandidates 引导配置类集合 */ public void parse(Set<BeanDefinitionHolder> configCandidates) { for (BeanDefinitionHolder holder : configCandidates) { BeanDefinition bd = holder.getBeanDefinition(); //1. 通过注解声明的方式创建,根据注解元数据(AnnotationMetadata)来加载 if (bd instanceof AnnotatedBeanDefinition) { parse(((AnnotatedBeanDefinition) bd).getMetadata(), holder.getBeanName()); } //2. 通过编程的方式创建,比如RootBeanDefinition,根据class属性来加载 else if (bd instanceof AbstractBeanDefinition && ((AbstractBeanDefinition) bd).hasBeanClass()) { parse(((AbstractBeanDefinition) bd).getBeanClass(), holder.getBeanName()); } //3. 其余情况,BeanDefinition的class属性未确定,根据className来加载 else{ parse(bd.getBeanClassName(), holder.getBeanName()); } } //处理延迟导入(TODO 先略过,详见Import一节) } //注解元数据已知,可能是反射或ASM的方式 protected final void parse(AnnotationMetadata metadata, String beanName) throws IOException { processConfigurationClass(new ConfigurationClass(metadata, beanName)); } //Class信息已知,通过反射的方式解析配置类 protected final void parse(Class<?> clazz, String beanName) throws IOException { processConfigurationClass(new ConfigurationClass(clazz, beanName)); } //类名已知,通过ASM的方式解析配置类 protected final void parse(String className, String beanName) throws IOException { MetadataReader reader = this.metadataReaderFactory.getMetadataReader(className); processConfigurationClass(new ConfigurationClass(reader.getAnnotationMetadata(), beanName)); } }

4.2 processConfigurationClass 方法

processConfigurationClass 方法起到了辅助的作用,首先判断是否应该处理配置类,这一功能与条件判定有关,暂时先跳过。然后以循环的方式处理配置类及其父类,当一个配置类处理完毕,会被加入到缓存中保存起来。在 doProcessConfigurationClass 方法的执行过程中,可能需要解析新的配置类,则以递归的方式调用 processConfigurationClass 方法,也就是说该方法可能多次执行,直到所有的配置类都被处理。

 

java

代码解读

复制代码

/** * 对指定的配置类进行解析 * @param configClass 表示一个配置类,有多种来源,可以是引导配置类、内部配置类、组件扫描的配置类、导入的配置类 */ protected void processConfigurationClass(ConfigurationClass configClass) throws IOException { //根据@Conditional判断是否应该处理配置类(TODO 先略过,详见条件判定一节) //遍历配置类及其父类 SourceClass sourceClass = asSourceClass(configClass); do{ sourceClass = doProcessConfigurationClass(configClass, sourceClass); } while (sourceClass != null); //将已解析的配置类添加到集合中 this.configurationClasses.add(configClass); }

doProcessConfigurationClass 方法是解析配置类的核心方法,实现了大量功能,逻辑也比较复杂,我们将花费大量篇幅对该方法进行解读。配置类的解析主要有六种情况需要处理,如下所示:

  • 内部类:解析声明了 @Configuration 注解的内部类
  • 属性文件:如果配置类声明了 @PropertySource 注解,则加载指定的属性文件
  • 组件扫描:如果配置类声明了 @ComponentScan 注解,则通过扫描包的方式加载 Bean
  • 导入:如果配置类声明了 @Import 注解,则导入相应的组件
  • 工厂方法:如果方法声明了 @Bean 注解,则以工厂方法的方式加载 Bean
  • 父类:如果配置类继承了父类,则尝试解析父类
 

java

代码解读

复制代码

protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass) throws IOException { //1. 处理内部类 //2. 处理配置文件 //3. 组件扫描 //4. 处理导入 //5. 处理工厂方法 //6. 处理父类 }

4.3 SourceClass

SourceClass 是 ConfigurationClassParser 的内部类,作用是以统一地方式来描述配置类。所谓的统一有两层含义,一是来源不同,可以是当前配置类、内部配置类、配置类的父类、导入的配置类,组件扫描加载的配置类等;二是加载方式的不同,可能是基于反射或 ASM 机制加载的。

  • source 属性表示配置类对应的资源,从构造方法可以看出 source 有两种类型,Class 表示通过反射加载的,MetadataReader 表示通过 ASM 加载的
  • metadata 属性表示配置类的相关信息,ConfigurationClass 也有这个属性
 

java

代码解读

复制代码

private class SourceClass { private final Object source; private final AnnotationMetadata metadata; public SourceClass(Object source) { this.source = source; if (source instanceof Class) { this.metadata = new StandardAnnotationMetadata((Class<?>) source, true); } else { this.metadata = ((MetadataReader) source).getAnnotationMetadata(); } } }

SourceClass 还有一些很有用的方法,列举如下。可以发现,这些方法都有一个特点,即返回的类型都是 SourceClass,这一点也印证了 SourceClass 的确是将各种形式的配置类给统一了起来。

  • Set<SourceClass> getAnnotations() : 获取配置类上的所有注解

  • Collection<SourceClass> getAnnotationAttributes(String annType, String attribute):获取指定注解上的指定属性值,比如 @Import 注解的 value 属性

  • SourceClass getSuperClass():获取父类

  • Collection<SourceClass> getMemberClasses():获取所有的内部类

5. ConfigurationClassBeanDefinitionReader

经过对引导配置类的解析,所有组件都加载完毕,接下来以 BeanDefinition 的形式注册到容器中,这一工作是由 ConfigurationClassBeanDefinitionReader 完成的。loadBeanDefinitions方法对传入的 ConfigurationClass 集合进行遍历,然后交由 loadBeanDefinitionsForConfigurationClass 方法处理。

 

java

代码解读

复制代码

//加载所有的BeanDefinition public void loadBeanDefinitions(Set<ConfigurationClass> configurationModel) { for (ConfigurationClass configClass : configurationModel) { loadBeanDefinitionsForConfigurationClass(configClass); } }

loadBeanDefinitionsForConfigurationClass 方法一共处理了三种情况,分别是配置类、工厂方法、导入类的注册。工厂方法和导入类的处理之后再讲,我们先来看配置类是如何注册的。首先需要判断当前配置类是不是「导入的」,我们在讲解 ConfigurationClass 类时说过,内部类和通过 @Import 注解加载的配置类是导入的,也就是说需要注册的是这两种配置类。下面列出了各种配置类的特点,有助于形成较为全面的认识:

  • 引导配置类:非导入的,且已经注册过

  • 通过组件扫描加载的配置类:非导入的,且已经注册过

  • 内部配置类:是通过外部类导入的,尚未注册(本节实现)

  • 导入配置类:通过 @Import 注解导入的,尚未注册(待实现)

 

java

代码解读

复制代码

//从ConfigurationClass中加载BeanDefinition private void loadBeanDefinitionsForConfigurationClass(ConfigurationClass configClass) { //1. 注册配置类 if(configClass.isImported()){ registerBeanDefinitionForImportedConfigurationClass(configClass); } //2. 注册BeanMethod(TODO) //3. 注册导入的类(TODO) } //注册导入的配置类(包括内部配置类) private void registerBeanDefinitionForImportedConfigurationClass(ConfigurationClass configClass) { AnnotationMetadata metadata = configClass.getMetadata(); AnnotatedGenericBeanDefinition configBeanDef = new AnnotatedGenericBeanDefinition(metadata); AnnotationConfigUtils.processCommonDefinitionAnnotations(configBeanDef, metadata); String configBeanName = this.beanNameGenerator.generateBeanName(configBeanDef); configClass.setBeanName(configBeanName); this.registry.registerBeanDefinition(configBeanName, configBeanDef); }

6. 讲解思路

6.1 概述

在一个典型的配置类中,上述六种情况可能同时存在。在实际应用中,Spring Boot 中的 WebMvcAutoConfiguration 涵盖了其中的四种情况。在示例代码中,精简了 WebMvcAutoConfiguration 的实现。此外,第五种和第六种情况在源码中并不存在,这里仅仅是为了模拟完整的使用场景。

 

java

代码解读

复制代码

//5) 组件扫描(源码无,仅展示) @ComponentScan //6) 加载属性文件(源码无,仅展示) @PropertySource("application.properties") @Configuration public class WebMvcAutoConfiguration { //1) 通过工厂方法的方式Bean @Bean @ConditionalOnMissingBean(HiddenHttpMethodFilter.class) public OrderedHiddenHttpMethodFilter hiddenHttpMethodFilter() { return new OrderedHiddenHttpMethodFilter(); } //2) 内部类处理,WebMvcAutoConfigurationAdapter //3)父类处理,WebMvcConfigurerAdapter //4) 使用导入的方式加载配置类 @Configuration @Import(EnableWebMvcConfiguration.class) public static class WebMvcAutoConfigurationAdapter extends WebMvcConfigurerAdapter {} }

根据功能的不同,六种情况还可以分为三类。第一,内部类和父类都属于配置类的结构,不涉及具体的解析过程。第二,属性文件是用于处理配置信息的,与加载组件无关。第三,组件扫描、导入、工厂方法都是加载组件的,是整个解析过程的关键所在。我们将根据从易到难的顺序对各项功能进行介绍,而不是按照代码中原有的顺序,本节先来看最简单的内部类和父类的处理。

6.2 内部类处理

在处理内部类时,首先调用 ConfigurationClass 的 getMemberClass 方法,获取所有的内部类。然后判断内部类是不是一个配置类,如果是则递归调用 processConfigurationClass 方法,进入解析内部配置类的流程。从这里可以看出,内部配置类是作为单独的配置类进行解析的。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass) throws IOException { //1. 处理内部类 processMemberClasses(configClass, sourceClass); ...... } //step-1 处理内部类 private void processMemberClasses(ConfigurationClass configClass, SourceClass sourceClass) throws IOException { for (SourceClass memberClass : sourceClass.getMemberClasses()) { if(ConfigurationClassUtils.isConfigurationCandidate(memberClass.getMetadata())){ //将内部类转换成ConfigurationClass processConfigurationClass(memberClass.asConfigClass(configClass)); } } }

6.3 父类处理

父类的处理是在整个解析流程的末尾,优先级最低。首先检查配置类的全类名是否以 java 开头,由于以 java 开头的包名是 JDK 自用的,是受保护的,因此非 java 开头的全类名代表是用户自定义的类。也就是说,如果父类存在且是自定义的类则返回,由外层方法进行递归处理,即再次执行 doProcessConfigurationClass 方法的逻辑。从这里可以看出,父类是作为当前配置类的一部分存在的,它不是一个单独的配置类。由于父类的低优先级,其作用是提供一些兜底的配置信息,这对框架来说有一定的意义。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass) throws IOException { //1. 处理内部类(略) //6. 处理父类 if(sourceClass.getMetadata().hasSuperClass()){ String superClass = sourceClass.getMetadata().getSuperClassName(); if(!superClass.startsWith("java")){ return sourceClass.getSuperClass(); } } return null; }

7. 测试

首先定义了一个配置类 OuterConfig 作为外部类,其内部类 InnerConfig 也声明了 @Configuration 注解,因此这是一个内部配置类。

 

java

代码解读

复制代码

//测试类:表示一个独立的配置类 @Configuration public class OuterConfig extends AbstractConfig { @Configuration static class InnerConfig { public InnerConfig() { System.out.println("我是配置类的内部类..."); } } }

其次,OuterConfig 还有一个父类,需要注意的是,父类 AbstractConfig 并没有声明 Configuration 注解,说明它与子类是一体的。为了说明父类确实被解析了,我们在父类中也声明了一个内部类 InnerConfig2

 

java

代码解读

复制代码

//测试类:配置类的父类 public abstract class AbstractConfig { @Configuration static class InnerConfig2 { public InnerConfig2() { System.out.println("我是父类的内部类2..."); } } }

本次测试有两个目的,一是验证核心组件 ConfigurationClassPostProcessor 正常执行,二是配置类的内部类和父类确实被解析。测试方法的逻辑很简单,首先将配置类 OuterConfig 注册到 Spring 容器中,然后刷新容器,自动执行配置类的解析操作。

 

java

代码解读

复制代码

//测试方法 @Test public void testInnerAndSuperClass() { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); context.register(OuterConfig.class); //注册引导配置类 context.refresh(); }

从测试结果可以看到,内部类的解析是优先执行的,然后是对父类的解析。

 

erlang

代码解读

复制代码

我是配置类的内部类... 我是父类的内部类2...

8. 总结

本节初步讨论了配置类,典型的配置类使用 @Configuration 注解来标识,具体的解析过程由三个组件类完成。

  • ConfigurationClassPostProcessor 是整个流程的入口,寻找容器中已存在的配置类,并交给 ConfigurationClassParser 组件解析。
  • ConfigurationClassParser 负责具体的解析逻辑,将已解析的配置类包装成 ConfigurationClass 对象。
  • ConfigurationClassBeanDefinitionReader 负责处理 ConfigurationClass 集合,提取出 BeanDefnition 并注册到 Spring 容器中。

配置类解析的过程最为繁杂,为了便于讲解,我们将主要功能分为六种,分别是内部类、父类、属性文件、组件扫描、工厂方法、导入。内部类和父类实际上是配置类的结构,并不涉及实际的解析逻辑,因此代码实现也是最简单的。本节通过对内部类和父类的处理,初步实现了对配置类的解析。

9. 项目结构

新增修改一览,新增(9),修改(1)。

 

scss

代码解读

复制代码

context └─ src ├─ main │ └─ java │ └─ cn.stimd.spring.context │ └─ annotation │ ├─ AnnotationConfigUtils.java (*) │ ├─ Configuration.java (+) │ ├─ ConfigurationClass.java (+) │ ├─ ConfigurationClassBeanDefinitionReader.java (+) │ ├─ ConfigurationClassParser.java (+) │ ├─ ConfigurationClassPostProcessor.java (+) │ └─ ConfigurationClassUtils.java (+) └─ test └─ java └─ context └─ config ├─ AbstractConfig.java (+) ├─ ConfigTest.java (+) └─ OuterConfig.java (+) 注:+号表示新增、*表示修改

  • 6
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值