【重写SpringFramework】配置类4:导入机制(chapter 3-8)

1. 前言

前边提到,组件扫描和 BeanMethod 是加载组件的主要方式,涵盖了大多数使用场景。如果想在一个配置类中引入另外一个配置类,该如何处理?我们能想到的是通过 BeanMethod 来实现,但配置类没有需要设置的属性,这种做法固然可行,但不够简洁。示例代码如下,在配置类 AConfig 的工厂方法中创建了 BConfig 实例。

 

java

代码解读

复制代码

//示例代码 @Configration public class AConfig { @Bean public BConfig bConfig() { return new BConfig(); } }

一般来说,工厂方法主要解决的是复杂对象的创建问题,用来创建配置类有点大材小用。鉴于此,Spring 提供导入机制,用来解决配置类的加载问题。在配置类上声明 @Import 注解开启导入功能,这种方式替代了 Spring 配置文件中的 import 标签。此外,导入机制还可以实现更复杂的功能,比如根据条件来决定导入哪些组件。

2. 导入组件

2.1 @Import

@Import 注解只有一个默认参数,value 属性的类型是 Class 数组,一共处理了三种情况,一是自定义的配置类,二是 ImportSelector 接口类型,三是 ImportBeanDefinitionRegistrar 接口类型。

 

java

代码解读

复制代码

@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Import { Class<?>[] value(); }

对于开发者来说,最常用的方式是第一种,也就是直接导入一个配置类,示例代码如下。

 

java

代码解读

复制代码

//示例代码:直接导入配置类 @Configuration @Import(BConfig.class) public class AConfig {}

注:第一种情况当然可以是非配置类,但对于组件类我们有多种加载方式可以替代,比如扫描加载、工厂方法等多种形式。更为重要的是,@Import 注解最终注册的是 BeanDefinition,也就是通过无参构造器创建对象,这对于组件类来说,远没有其他方式灵活。因此第一种情况主要用于配置类。

2.2 ImportSelector

ImportSelector 接口的作用是根据一定的条件选择导入哪些类,通常来说这些条件来自注解的属性。selectImports 方法可以实现比较复杂的导入逻辑,返回的字符串数组表示一组全类名。这里有个问题,为什么返回的是 String 数组而不是 Class 数组?这是因为 ImportSelector 接口可以通过 SPI 机制加载组件,相关信息保存在特殊的配置文件中,得到的正是一组全类名。这个功能与自动配置有关,我们将在第二部 Spring Boot 的自动配置章节进行介绍。

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

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

java

代码解读

复制代码

public interface ImportSelector { //获取需要导入的全类名数组 String[] selectImports(AnnotationMetadata importingClassMetadata); }

示例代码如下,首先解析配置类的 @Import 注解,然后执行 XxxImportSelector 的 selectImports 方法,这里我们模拟 SPI 机制,直接返回了全类名数组。接下来,全类名数组会被解析成一组 BeanDefinition,并注册到 Spring 容器中。

 

java

代码解读

复制代码

//示例代码 @Configuration @Import(XxxImportSelector.class) public class ImportConfig {} public class XxxImportSelector implements ImportSelector { @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { return new String[] { "cn.stimd.context.test.XxxConfig" }; } }

2.3 ImportBeanDefinitionRegistrar

ImportBeanDefinitionRegistrar 接口的作用是直接注册 BeanDefinition,这是与 ImportSelector 接口的主要区别。registerBeanDefinitions 方法也可以执行一些复杂的逻辑,根据条件来注册 BeanDefinition

 

java

代码解读

复制代码

public interface ImportBeanDefinitionRegistrar { void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry); }

ImportBeanDefinitionRegistrar 接口的使用比较复杂,我们通过一个例子来了解。之前我们讲过 AnnotationConfigUtils 工具类可以注册一些常用的 BeanPostProcessor 组件,包括依赖注入、配置类解析、生命周期处理等,但是不能直接注册 AOP 组件,因为需要考虑多种情况。其一,创建代理的方式是 JDK 接口代理,还是 CGLIB 代理。其二,AOP 的构建模式是 Spring AOP,还是 AspecetJ 框架。这些信息都保存在注解上,通过配置类来解析注解,然后由 ImportBeanDefinitionRegistrar 接口的实现类注册 AOP 组件。

在 Spring 源码中,@EnableTransactionManagement 注解的作用是开启事务管理,而 Spring 事务是建立在 AOP 的基础之上。该注解稍后会介绍,这里使用自定义的 @EnableAopProxy 注解来模拟。@EnableAopProxy 注解定义了两个属性,其中 proxyTargetClass 属性表示创建代理的方式,mode 属性表示 AOP 的构建方式。

 

java

代码解读

复制代码

//示例代码,模拟@EnableTransactionManagement @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Import(AutoProxyRegistrar.class) public @interface EnableAopProxy { //指定代理对象的生成模式,为true时表示使用CGLIB的子类代理,为false表示使用JDK接口代理。 boolean proxyTargetClass() default false; //指定事务的通知模式,默认为JDK动态代理 AdviceMode mode() default AdviceMode.PROXY; }

@EnableAopProxy 注解还声明了 @Import 注解,引入了 AutoProxyRegistrar,这是 ImportBeanDefinitionRegistrar 接口的重要实现类,作用是自动注册 AOP 相关的组件。registerBeanDefinitions 方法可以分为三步,如下所示:

  1. 获取配置类声明的所有注解,annTypes 为全类名的集合。
  2. 遍历注解集合,根据 mode 属性决定使用基于 Spring AOP 还是 AspectJ 构建的切面,AutoProxyRegistrar 只实现了 Spring AOP 的构建方式。
  3. 通过 AopConfigUtils 注册 InfrastructureAdvisorAutoProxyCreator 组件,如果 proxyTargetClass 属性为 true,则指定代理创建方式为 CGLIB 子类代理。
 

java

代码解读

复制代码

public class AutoProxyRegistrar implements ImportBeanDefinitionRegistrar{ @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { //1. 获取声明的所有注解,形式为全类名数组 Set<String> annTypes = importingClassMetadata.getAnnotationTypes(); for (String annType : annTypes) { //获取某个注解的所有属性值 AnnotationAttributes candidate = AnnotationConfigUtils.attributesFor(importingClassMetadata, annType); if (candidate == null) { continue; } Object mode = candidate.get("mode"); Object proxyTargetClass = candidate.get("proxyTargetClass"); //2. 基于 Spring AOP 构建的切面 if (mode == AdviceMode.PROXY) { //3. 注册InfrastructureAdvisorAutoProxyCreator AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry); //如果proxyTargetClass为true,即使用Cglib创建代理对象,则需要修改属性 if((Boolean)proxyTargetClass){ AopConfigUtils.forceAutoProxyCreatorToUseClassProxying(registry); } } } } }

总的来说,@EnableAopProxy 注解的作用是注册 InfrastructureAdvisorAutoProxyCreator 组件,用于创建代理对象。该组件需要设置一些属性,前提是根据注解信息进行判断。由此可见,ImportBeanDefinitionRegistrar 接口的特点之一就是能实现比较复杂的逻辑。

3. 代码实现

3.1 解析@Import 注解

回到 ConfigurationClassParser 类的 doProcessConfigurationClass 方法,第四步处理了导入流程。首先由 getImports 方法处理,获取配置类上声明的所有 @Import 注解。然后由 processImports 方法进行具体的处理。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] protected final SourceClass doProcessConfigurationClass(ConfigurationClass configClass, SourceClass sourceClass) throws IOException { //1. 处理内部类(略) //2. 处理配置文件(略) //3. 组件扫描(略) //4. 处理导入@Import processImports(configClass, sourceClass, getImports(sourceClass)); }

第一步,获取待导入的类。 先来看 getImports 方法。变量 imports 表示最终导入类的集合,变量 visited 表示已经处理的配置类或注解。这两个变量都是 Set 类型,目的是为了去重。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] //获取所有需要导入的类,以SourceClass的形式表示 private Set<SourceClass> getImports(SourceClass sourceClass) throws IOException { Set<SourceClass> imports = new LinkedHashSet<>(); Set<SourceClass> visited = new LinkedHashSet<>(); collectImports(sourceClass, imports, visited); return imports; }

在 collectImports 方法中,首先遍历配置类声明的所有注解,如果注解的全类名不是以 java 开头或者 @Import 注解本身,那么递归地调用 collectImports 方法,尝试解析嵌套注解。需要注意的是,@Import 注解并不一定直接声明在配置类上,而是作为嵌套注解声明在另外一个注解上,比如前边提到的 @EnableAopProxy 注解。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] //解析@Import注解,获取value属性的值 private void collectImports(SourceClass sourceClass, Set<SourceClass> imports, Set<SourceClass> visited) throws IOException{ // 尝试添加到set集合中,防止可能出现重复导入的情况 if(visited.add(sourceClass)){ for (SourceClass annotation : sourceClass.getAnnotations()) { String annName = annotation.getMetadata().getClassName(); //遍历类的注解,如果注解不是Java注解或@Import,则尝试获取嵌套注解(即注解上的注解) if (!annName.startsWith("java") && !annName.equals(Import.class.getName())) { collectImports(annotation, imports, visited); } } //将@Import注解指定的Class添加到集合中 imports.addAll(sourceClass.getAnnotationAttributes(Import.class.getName(), "value")); } }

第二步,处理待导入的类。 此时已经得到了当前配置类声明的所有 @Import 注解的 value 属性的集合,之前我们讲过,value 属性的类型有三种。processImports 方法处理了这三种导入类的情况,如下所示:

  • 首先是对 ImportSelector 接口的处理,这里又分为三小步。第一步加载类、实例化、调用感知接口设置相关的组件。第二步将需要延迟加载的实例缓存起来,稍后处理。第三步回调 selectImports 方法,将返回的全类名数组转换成 SourceClass 集合,递归调用 processImports 方法。由此可见,该分支流程只是中间过程,最终会以另外两种形式处理
  • 然后是 ImportBeanDefinitionRegistrar 接口,这一步比较简单,先实例化对象,然后调用接口方法即可。注册 BeanDefinition 的操作是在 addImportBeanDefinitionRegistrar 方法内部执行的,因此不需要额外的操作。
  • 最后是自定义配置类,递归调用 processConfigurationClass 方法当成新的配置类处理。
 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] private void processImports(ConfigurationClass configClass, SourceClass currentSourceClass, Collection<SourceClass> importCandidates) { for (SourceClass candidate : importCandidates) { //1. 实现ImportSelector接口,返回全限定类名的数组,需要递归地调用processImports方法,直到以2或3的的方式来处理 if (candidate.isAssignable(ImportSelector.class)) { //1) 加载类、实例化、调用感知接口设置相关的组件 Class<?> candidateClass = candidate.loadClass(); ImportSelector selector = (ImportSelector) BeanUtils.instantiateClass(candidateClass); invokeAwareMethods(selector); //2) 将DeferredImportSelector加入缓存,稍后处理 if (this.deferredImportSelectors != null && selector instanceof DeferredImportSelector) { this.deferredImportSelectors.add(new DeferredImportSelectorHolder(configClass, (DeferredImportSelector) selector)); } else{ //3) 回调selectImports方法 String[] importClassNames = selector.selectImports(currentSourceClass.getMetadata()); Collection<SourceClass> importSourceClasses = asSourceClasses(importClassNames); //递归处理返回的全限定类名数组 processImports(configClass, currentSourceClass, importSourceClasses); } } //2. 实现ImportBeanDefinitionRegistrar接口的,缓存到ConfigurationClass稍后处理 else if (candidate.isAssignable(ImportBeanDefinitionRegistrar.class)) { Class<?> candidateClass = candidate.loadClass(); ImportBeanDefinitionRegistrar registrar = (ImportBeanDefinitionRegistrar) BeanUtils.instantiateClass(candidateClass); invokeAwareMethods(registrar); configClass.addImportBeanDefinitionRegistrar(registrar, currentSourceClass.getMetadata()); } //3. 当作普通配置类,这里也是递归处理 else{ processConfigurationClass(candidate.asConfigClass(configClass)); } } }

3.2 注册 BeanDefinition

解析完配置类之后,来到 ConfigurationClassBeanDefinitionReader 类的 loadBeanDefinitionsForConfigurationClass 方法,接下来需要注册通过导入加载的类。导入分为三种情况,处理逻辑是不同的,区别如下所示:

  • ImportSelector 接口:返回全类名数组,已经转换成其他两种情况了
  • ImportBeanDefinitionRegistrar 接口:存储在 ConfigurationClass 的 importBeanDefinitionRegistrars 字段中,由第三步处理
  • 配置类:内部类和导入的配置类保存在 ConfigurationClass 的 importedBy 字段中,由第一步处理(已实现)
 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassBeanDefinitionReader] private void loadBeanDefinitionsForConfigurationClass(ConfigurationClass configClass) { //1. 注入配置类(略) //2. 注册BeanMethod(略) //3. 注册导入的类 loadBeanDefinitionsFromRegistrars(configClass.getImportBeanDefinitionRegistrars()); }

loadBeanDefinitionsFromRegistrars 方法的逻辑比较简单,遍历 ConfigurationClass 的 importBeanDefinitionRegistrars 字段,回调 ImportBeanDefinitionRegistrar 接口的 registerBeanDefinitions 方法,比如 AutoProxyRegistrar 完成了 AOP 组件的注册工作。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassBeanDefinitionReader] private void loadBeanDefinitionsFromRegistrars(Map<ImportBeanDefinitionRegistrar, AnnotationMetadata> registrars) { for (Map.Entry<ImportBeanDefinitionRegistrar, AnnotationMetadata> entry : registrars.entrySet()) { //key为ImportBeanDefinitionRegistrar接口的实现类,value为待注册组件的元数据 entry.getKey().registerBeanDefinitions(entry.getValue(), this.registry); } }

3.3 流程复盘

导入的处理逻辑比较复杂,涉及多个分支以及递归调用,我们需要从全局来审视一下整个流程。如下图所示,导入的三种情况分别使用不同的颜色来标识。事实上,无论流程上如何变化,三种导入情况最终的处理是确定的,如下所示:

  • 配置类:最终都会递归调用 processConfigurationClass 方法进行解析
  • ImportBeanDefinitionRegistrar 接口:直接处理,无循环逻辑
  • ImportSelector 接口:仅作为中间过程存在,最终转换为上述两种情况

8.1 处理导入的流程图.png

4. 延迟导入

4.1 DeferredImportSelector

DeferredImportSelector 是一个标记接口,没有实际内容,延迟导入的选择器在解析时的优先级最低。在实际使用中,Spring Boot 的自动配置使用了延迟导入的功能,原因有二。一是自动配置作为兜底选项,可以在用户没有指定某些组件时提供默认选项。二是为了配合条件判定的相关注解,比如 @ConditionalOnBean 注解需要确保某个 Bean 在容器中,那么自动配置出现的位置越靠后越好。

 

java

代码解读

复制代码

public interface DeferredImportSelector extends ImportSelector { //标识接口 }

4.2 代码实现

ConfigurationClassParser 的 deferredImportSelectors 字段表示一组延迟导入的选择器。DeferredImportSelectorHolder 作为内部类,用于临时存储正在解析的配置类与选择器。

 

java

代码解读

复制代码

class ConfigurationClassParser { private List<DeferredImportSelectorHolder> deferredImportSelectors; //内部类,临时保存延迟导入的类 private static class DeferredImportSelectorHolder { private final ConfigurationClass configurationClass; private final DeferredImportSelector importSelector; } }

在 processImports 方法中,我们将 DeferredImportSelector 临时缓存到 deferredImportSelectors 字段中。当所有的配置类都处理完毕,回到 parse 方法,最后一步处理延迟加载。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] //处理配置类的入口方法 public void parse(Set<BeanDefinitionHolder> configCandidates) { this.deferredImportSelectors = new LinkedList<>(); for (BeanDefinitionHolder holder : configCandidates) { //略 } //等到所有的配置类都处理完毕,最后再执行需要延迟导入的选择器 processDeferredImportSelectors(); }

processDeferredImportSelectors 方法分为两步,首先对延迟导入的集合进行排序,然后遍历集合并调用 processImports 方法完成导入的处理。

 

java

代码解读

复制代码

//所属类[cn.stimd.spring.context.annotation.ConfigurationClassParser] //处理需要延迟导入的组件 private void processDeferredImportSelectors() { List<DeferredImportSelectorHolder> deferredImports = this.deferredImportSelectors; this.deferredImportSelectors = null; deferredImports.sort(DEFERRED_IMPORT_COMPARATOR); //排序 for (DeferredImportSelectorHolder deferredImport : deferredImports) { ConfigurationClass configClass = deferredImport.getConfigurationClass(); String[] imports = deferredImport.getImportSelector().selectImports(configClass.getMetadata()); //导入类的处理 processImports(configClass, asSourceClass(configClass), asSourceClasses(imports)); } }

5. 测试

5.1 自动注册 AOP 组件

本测试模拟了自动注册 AOP 组件的流程,目的是为了验证 ImportBeanDefinitionRegistrar 接口加载指定 BeanDefinition 的功能。@EnableAopProxy 注解见上文示例代码,配置类 AopConfig 声明了该注解。

 

java

代码解读

复制代码

//测试类 @Configuration @EnableAopProxy(proxyTargetClass = true) public class AopConfig { }

测试方法的逻辑较为简单,在解析配置类的过程中,由于 @EnableAopProxy 存在嵌套注解 @Import,因此会进入处理导入的流程。导入类为 AutoProxyRegistrar,自动注册了 InfrastructureAdvisorAutoProxyCreator 组件。

 

java

代码解读

复制代码

//测试方法 @Test public void testImportBeanDefinitionRegistrar() { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AopConfig.class); InfrastructureAdvisorAutoProxyCreator creator = context.getBean(InfrastructureAdvisorAutoProxyCreator.class); System.out.println("测试ImportBeanDefinitionRegistrar接口:" + creator); }

从测试结果中可以看到,InfrastructureAdvisorAutoProxyCreator 的确存在于 Spring 容器中。

 

css

代码解读

复制代码

测试ImportBeanDefinitionRegistrar接口:cn.stimd.spring.aop.framework.autoproxy.InfrastructureAdvisorAutoProxyCreator@5abca1e0

5.2 ImportSelector

本测试模拟 Spring Boot 应用的简单加载流程,使用到的技术包括 @SpringBootApplication 注解作为应用启动的入口,@Import 注解的解析,延迟导入,以及使用 ImportSelector 接口和 SPI 机制加载配置类。相关的测试类一种有五个,比较重要的有三个,如下所示:

  • @SpringBootApplication 注解需要注意两点,一是声明了 @Configuration 注解,说明该注解可以标识配置类。二是声明了 @Import 注解,会执行导入流程。

  • AutoConfigurationImportSelector 实现了 DeferredImportSelector 接口,在 selectImports 方法中,模拟 SPI 加载了两个配置类。这两个配置类无实质内容,仅打印日志。

  • AppStartup 模拟应用启动类,声明了 @SpringBootApplication 注解,会被当做配置类来解析。此外还导入了一个自定义的配置类 CustomConfig,目的有二,一是验证直接导入配置类的功能,二是验证延迟导入的配置类是最后加载的。

综上所述,本次测试一共验证了三个功能。一是普通配置类的导入,二是 ImportSelector 接口的机制,三是延迟导入,这一点可以通过与普通配置类的加载顺序比较得出。

 

java

代码解读

复制代码

//测试类 @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Configuration @Import({AutoConfigurationImportSelector.class}) @Inherited public @interface SpringBootApplication { } public class AutoConfigurationImportSelector implements DeferredImportSelector { @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { //模拟SPI加载配置类 return new String[]{"context.config.imports.WebMvcAutoConfiguration", "context.config.imports.DataSourceAutoConfiguration"}; } } @SpringBootApplication @Import(CustomConfig.class) public class AppStartup { }

在测试方法中,AppStartup 作为配置类被解析。首先检测到嵌套注解 @Import 存在,开始处理导入的流程。在导入类 AutoConfigurationImportSelector 的 selectImports 方法中,返回了全类名的数组,继而向容器中注册 WebMvcAutoConfiguration 和 DataSourceAutoConfiguration 这两个配置类。

 

java

代码解读

复制代码

//测试方法 @Test public void testImportSelector(){ new AnnotationConfigApplicationContext(AppStartup.class); }

从测试结果可以看到,自定义配置类优先加载,另外两个配置类是通过延迟导入的方式加载的。

 

代码解读

复制代码

加载配置类CustomConfig 加载配置类WebMvcAutoConfiguration 加载配置类DataSourceAutoConfiguration

6. 总结

本节我们讨论了配置类的导入机制。导入提供了多种灵活的加载组件的方式,主要是框架内部使用。导入功能的入口点是 @Import 注解,value 属性的类型是 Class 数组,表示待导入的类。Spring 处理了三种类型的导入,一是普通类,主要是导入新的配置类。二是 ImportBeanDefinitionRegistrar 接口,可以直接注册 BeanDefinition。三是 ImportSelector 接口,返回全类名数组,最终需要通过另外两种方式解析。在实际使用中,这三种情况可以互相配合,比如 Spring Boot 通过它们实现了非常灵活的自动配置功能。

此外还提供了延迟导入的功能,Spring Boot 的自动配置就用到了延迟导入。目的是优先加载用户定义的组件,最后导入框架默认的组件作为兜底措施,而这正符合 Spring Boot 标榜的约定大于配置的理念。

8.2 导入机制脑图.png

7. 项目信息

新增修改一览,新增(14),修改(4)。

 

scss

代码解读

复制代码

context └─ src ├─ main │ └─ java │ └─ cn.stimd.spring.context │ └─ annotation │ ├─ AdviceMode.java (+) │ ├─ AutoProxyRegistrar.java (+) │ ├─ ConfigurationClass.java (*) │ ├─ ConfigurationClassBeanDefinitionReader.java (*) │ ├─ ConfigurationClassParser.java (*) │ ├─ DeferredImportSelector.java (+) │ ├─ Import.java (+) │ ├─ ImportBeanDefinitionRegistrar.java (+) │ └─ ImportSelector.java (+) └─ test └─ java └─ context └─ config ├─ imports │ ├─ AopConfig.java(+) │ ├─ AppStartup.java(+) │ ├─ AutoConfigurationImportSelector.java(+) │ ├─ CustomConfig.java(+) │ ├─ DataSourceAutoConfiguration.java(+) │ ├─ EnableAopProxy.java(+) │ ├─ SpringBootApplication.java(+) │ └─ WebMvcAutoConfiguration.java(+) └─ ConfigTest.java (*) 注:+号表示新增、*表示修改

注:项目的 master 分支会跟随教程的进度不断更新,如果想查看某一节的代码,请选择对应小节的分支代码。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值