SpringBoot源码系列:Environment机制深入分析(一)

概述

Spring的Environment是一个高度抽象的接口。在我们实际开发中可谓是处处都有它的身影,为了彻底弄清它的庐山真面 本文将从源码的角度去分析Environment,让我们更加深入的认识它。以便在工作中遇到各种环境配置问题都能很快追踪排查问题。(本文基于springboot 2.1.6版本。由于Environment机制内容比较多所以将其分为两个部分。本文所讨论的是第一部分:加载配置的流程
下面以一个自定义配置的环境demo进入今天的正文

Springboot 自定义Environment Demo

下面这个自定义Environment主要是加载自定义配置文件。注意这里没有任何的实际的意义 仅仅演示。因为Springboot为我们提供了更简单的方式去加载自定义配置文件。


在项目resources目录下面新建一个customize.yml

	coledy:
	    account: xxxxxx

自定义Environment我们只需要继承StandardEnvironment或者其子类即可。
由于我们现在是web环境所以我选择继承StandardServletEnvironment。

@Slf4j
public class CustomEnvironment extends StandardServletEnvironment {
  @Override
  protected void customizePropertySources(MutablePropertySources propertySources) {
    //这行代码不能少 父类也需要加载一些环境信息 例如StandardServletEnvironment就需要去加载
    //servletContext参数和servletConfig参数 
    super.customizePropertySources(propertySources);
    //这里我们使用默认的ResourceLoad去加载类路径下的配置文件
    Resource resource = new DefaultResourceLoader().getResource("classpath:customize.yml");
    try {
    //这里使用yml解析器解析 加载的Resource 
      List<PropertySource<?>> customYml = new YamlPropertySourceLoader().load("customize_yml", resource);
      for (PropertySource<?> p : customYml){
        propertySources.addLast(p);
      }
    }catch (IOException e){
        log.error("加載custom.yml配置文件異常!!!");
    }
  }
}

由于是自定义Environment所以我们启动类需要修改一下

@SpringBootApplication
public class SpringbootStudyApplication{
  public static void main(String[] args) {
    SpringApplication application = new SpringApplicationBuilder(SpringbootStudyApplication.class).application();
    //这里需要我们手动设置一下自定义变量的实例
    application.setEnvironment(new CustomEnvironment());
    ApplicationContext context = application.run(args);
    ConfigurableEnvironment environment = context.getBean(ConfigurableEnvironment.class);
    System.out.println(environment.getProperty("coledy.account"));
  }
}

我们看到自定义一个Environment流程很简单。
可是问题是刚刚我们提到的 web环境变量 系统环境变量 命令行参数 默认的application.yml 文件是什么时候加载的?我们先不着急回答这个问题 先看一下刚刚的 propertySources.addLast 这一行代码。我们把自定义的 customize.yml手动添加到了一个集合中 结果我们就可以在容器中拿到配置的信息。下面我们看一下 Environment的存储容器MutablePropertySources

Environment的存储容器PropertySources

MutablePropertySources实现了PropertySources接口,并且在Springboot中MutablePropertySources目前是PropertySources的唯一实现。首先来看一下下面两个类的介绍

  • PropertySource:spring存储环境变量属性的基类只有name属性和泛型的source属性.需要关注的有两个点
    第一是该对象重写了hashCode方法和equals方法 并且这两个方法只是对属性name做了处理。说明一个PropertySource对象绑定到一个唯一的name属性上面,这个name有点像hashMap里面的key值,移除,判断都是依据这个name。还有一点它有一个抽象方法 Object getProperty(String name) 留给子类实现。
    常见的实现有:MapPropertySourcePropertiesPropertySourceResourcePropertySourceStubPropertySourceComparisonPropertySource

  • PropertySources:是一个接口,并且实现了Iterable。其默认的实现是MutablePropertySources,这个类存放PropertySource,容器内部是一个CopyOnWriteArrayList集合

我们可以简单理解PropertySource是存储单元 类似Map中的Entry 而PropertySources是一个容器类似于Map

下面是基本的使用

  //构建了一个MutablePropertySources 用于存储配置信息 环境变量信息
 MutablePropertySources propertySources = new MutablePropertySources();
  //创建一个默认配置使用MapPropertySource
  Map<String,Object> config = new HashMap<>();
  config.put("name" , "first_demo");
  config.put("port" , 8080);
  MapPropertySource mapPropertySource = new MapPropertySource("default_config" , config);
  //创建一个环境配置使用 PropertiesPropertySource
  Properties properties = new Properties();
  properties.put("jvm.version" , 1.8);
  properties.put("system.kind" , "mac");
  properties.put("var" , "jvm.version");
  properties.put("byt" , new byte[]{12,3,13});
  PropertiesPropertySource propertiesPropertySource = new PropertiesPropertySource("env_config" , properties);
  //将上诉的配置加入到环境变量容器中
  propertySources.addFirst(mapPropertySource);
  propertySources.addFirst(propertiesPropertySource);

我们的Environment就是这样由一个个的PropertySource构成 而不要把PropertySource理解成了键值对 他是由多个键值对构成的一个组。

springboot Environmen初始化流程

理解了上面的存储结构我们就跟着SpringBoot的源码去深入了解一下 Environment是如何自动解析获取配置文件的。

类的继承关系


   PropertyResolver //提供方法属性的功能 只是提供属性访问
        ConfigurablePropertyResolver //提供类型转换占位符设置  必须属性设置校验等功能
        Environment   //spring运行环境的接口
           ConfigurableEnvironment  //为环境容器提供配置功能
               ConfigurableWebEnvironment   //提供initPropertySources(this.servletContext, (ServletConfig)null)方法 会设置web容器运行参数 该方法是在刷新容器是的prepareRefresh时调用
                   StandardServletEnvironment  
                AbstractEnvironment       //环境变量的核心类 customizePropertySources方法留给子类去填充 运行环境集合propertySources
                    StandardEnvironment  //初始化了系统环境变量,系统参数
                        StandardServletEnvironment //初始化Servlet上下文变量 

乍一看类比较多 但是我们只关心核心的 Environment AbstractEnvironment StandardEnvironment StandardServletEnvironment 这四个类。我们还是一样 从AbstractEnvironment这个抽象类开始分析。上一节也说过Spring大量的使用的 模板方法的设计模式 所以遇到Abstract开头的类 我们要足够重视。


加载系统环境变量 Servlet环境上下文参数
public abstract class AbstractEnvironment implements ConfigurableEnvironment {
    //这个属性缓存被激活配置文件的列表 仅仅是一个缓存的作用  spring.profiles.active指定的属性
    private final Set<String> activeProfiles = new LinkedHashSet<>();
    //这个也是一个缓存作用保存当前默认的配置文件
	private final Set<String> defaultProfiles = new LinkedHashSet<>(getReservedDefaultProfiles());
	//这个集合比较重要 保存了所有Springboot上下文的配置信息 关于MutablePropertySources的结构我们上面也有提到
	private final MutablePropertySources propertySources = new MutablePropertySources();
	//初始化解析器 可以看到AbstractEnvironment实现了ConfigurableEnvironment接口
	//而ConfigurableEnvironment又实现了ConfigurablePropertyResolver接口
	//这里可以看出配置解析器的功能全部交由PropertySourcesPropertyResolver来处理 使用静态代理模式
	private final ConfigurablePropertyResolver propertyResolver =
			new PropertySourcesPropertyResolver(this.propertySources);
    //这个构造器是核心 这就是为什么上面的demo(自定义Environment)重写了customizePropertySources这个方法就能把我们的配置信息放进环境容器
	public AbstractEnvironment() {
			customizePropertySources(this.propertySources);
		}
	//当前的	customizePropertySources是一个空方法完全交由子类去实现 注意这个方法是一个受保护的方法。
	//自定义Environment就是重写这个方法  类当前会把 盛放属性的容器传递到子类。子类字需要向MutablePropertySources 添加PropertySource即可
	protected void customizePropertySources(MutablePropertySources propertySources) {
	}	
}

StandardEnvironment 实现了AbstractEnvironment 并且重写了customizePropertySources 向 MutablePropertySources 容器加入了JVM系统属性 和系统环境变量

@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
//添加System.getProperties()到环境容器
	propertySources.addLast(
			new PropertiesPropertySource(SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME, getSystemProperties()));
//添加System.getenv()到环境容器
//这里还得注意一点  可以看到使用的都是addLast方法加入到环境容器的 所以在检索属性的时候 系统属性的优先级必然高于系统环境变量的优先级。
//也就是说我们在系统属性中配置 一个属性 name=coledy  同时也在系统环境变量中配置name=lili name最终获取name的值是coledy
//后续的添加也是按照这个规律 
	propertySources.addLast(
			new SystemEnvironmentPropertySource(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, getSystemEnvironment()));
}



StandardServletEnvironment web开发最常用的Environment

//添加了servletContextInitParams和servletConfigInitParams 此时只是占位 没有任何属性添加进去
@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
	propertySources.addLast(new StubPropertySource(SERVLET_CONFIG_PROPERTY_SOURCE_NAME));
	propertySources.addLast(new StubPropertySource(SERVLET_CONTEXT_PROPERTY_SOURCE_NAME));
	if (JndiLocatorDelegate.isDefaultJndiEnvironmentAvailable()) {
		propertySources.addLast(new JndiPropertySource(JNDI_PROPERTY_SOURCE_NAME));
	}
	super.customizePropertySources(propertySources);
}

//该方法会重新填充propertySources会将servletContextInitParams和servletConfigInitParams真正的参数放入propertySources
//调用时机 是在容器刷新之前 准备刷新阶段调用   GenericWebApplicationContext的initPropertySources方法调用将会触发该方法的执行  
//这一块 我们放在 Servlet容器再来探讨
@Override
public void initPropertySources(@Nullable ServletContext servletContext, @Nullable ServletConfig servletConfig) {
	WebApplicationContextUtils.initServletPropertySources(getPropertySources(), servletContext, servletConfig);
}

到这里 我们的Servlet环境参数 系统环境变量 都已经加入到我们的Environment。但是没有看到解析配置文件的过程。

解析配置文件

顺着Springboot的启动流程找到初始化环境变量的代码

SpringApplication的run方法 (省略了不相关的代码)

public ConfigurableApplicationContext run(String... args) {
	....	
	//将命令行参数封装成ApplicationArguments   这个类内部其实是由 刚刚我们讲到的PropertySource的一个子类实现的
	//SimpleCommandLinePropertySource 这里不作展开
	ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
	//准备Environment
	ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
	configureIgnoreBeanInfo(environment);
	Banner printedBanner = printBanner(environment);
	context = createApplicationContext();
	....		
}

继续跟进prepareEnvironment

	private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
			ApplicationArguments applicationArguments) {
		//这里是根据我们当前的运行环境去创建一个环境变量容器 这里创建的是StandardServletEnvironment
		//指的注意的是 这个方法会判断当前environment是否为null 如果为null 就会创建 由于我们之前已经设置了一个
		//	application.setEnvironment(new CustomEnvironment()); 所以这里是不会new一个
		//StandardServletEnvironment  当然大多数情况是new StandardServletEnvironment 
		ConfigurableEnvironment environment = getOrCreateEnvironment();
		//配置环境变量 将启动参数加入到Environment容器中 如果以--开头将会被解析
		configureEnvironment(environment, applicationArguments.getSourceArgs());
		//向容器中发送ApplicationEnvironmentPreparedEvent事件
		listeners.environmentPrepared(environment);
		//将spring.main绑定到当前对象 Binder是Springboot2新加的 后续我们还会见到它这里先不做展开
		bindToSpringApplication(environment);
		if (!this.isCustomEnvironment) {
			environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
					deduceEnvironmentClass());
		}
		//向Environment的环境变量容器中增加一个configurationProperties属性  这个属性对应的
		//类型是ConfigurationPropertySourcesPropertySource并且之前加载的所有配置都放在
		//configurationProperties对应的容器中 这里我们在后续也会聊到 和本次内容无关 这里就不展开
		ConfigurationPropertySources.attach(environment);
		return environment;
	}

到这整个Environment的加载已经完成了 可是我们没有找到解析配置文件的地方。
这一次我们主要关注这里面的一行代码listeners.environmentPrepared(environment); 这里向容器中发送了一个ApplicationEnvironmentPreparedEvent事件。这里在SimpleApplicationEventMulticaster中断点查看监听该事件的监听器是ConfigFileApplicationListener

下面看一下这个类主要的方法定义


public class ConfigFileApplicationListener implements EnvironmentPostProcessor, SmartApplicationListener, Ordered {
 
   //可以看到这个类对两个事件做了监听ApplicationEnvironmentPreparedEvent和ApplicationPreparedEvent 今天我们主要讨论第一个事件监听的逻辑处理
	@Override
	public boolean supportsEventType(Class<? extends ApplicationEvent> eventType) {
		return ApplicationEnvironmentPreparedEvent.class.isAssignableFrom(eventType)
				|| ApplicationPreparedEvent.class.isAssignableFrom(eventType);
	}

	@Override
	public void onApplicationEvent(ApplicationEvent event) {
		if (event instanceof ApplicationEnvironmentPreparedEvent) {
	//当发生ApplicationEnvironmentPreparedEvent事件时会调用ApplicationEnvironmentPreparedEvent方法 继续进入该方法
	onApplicationEnvironmentPreparedEvent((ApplicationEnvironmentPreparedEvent) event);
		}
		if (event instanceof ApplicationPreparedEvent) {
			onApplicationPreparedEvent(event);
		}
	}

	private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {      
	//这里找到所有的EnvironmentPostProcessor 执行postProcessEnvironment方法。
	//springboot的惯用手段从spring.factories中找到所有key为EnvironmentPostProcessor的配置。
	//在springboot的启动时候加载监听器的时候也是这样做的
		List<EnvironmentPostProcessor> postProcessors = loadPostProcessors();
		//这里将自己也加入这个集合  可以发现 当前这个监听器也是一个EnvironmentPostProcessor
		postProcessors.add(this);
		AnnotationAwareOrderComparator.sort(postProcessors);
		//循环执行所有的postProcessEnvironment
		for (EnvironmentPostProcessor postProcessor : postProcessors) {
			postProcessor.postProcessEnvironment(event.getEnvironment(), event.getSpringApplication());
		}
	}

	@Override
	public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
		addPropertySources(environment, application.getResourceLoader());
	}

   //最终代码会执行到这。可以看到最终是委托给了Loader类去加载配置文件
	protected void addPropertySources(ConfigurableEnvironment environment, ResourceLoader resourceLoader) {
	   //这里向环境容器添加RandomValuePropertySource 这样我们可以在配置文件中使用随机数
	   //如${random.value} ${random.int}等
		RandomValuePropertySource.addToEnvironment(environment);
		new Loader(environment, resourceLoader).load();
	}
}

为了节约篇幅 上面的代码块中贴了5个方法 但都是比较简单的方法 执行顺序也是从上往下的顺序执行。
我们继续跟进Loader的load方法

		public void load() {
			this.profiles = new LinkedList<>();
			this.processedProfiles = new LinkedList<>();
			this.activatedProfiles = false;
			this.loaded = new LinkedHashMap<>();
//初始化profile  此时是在没有加载配置文件之前 但是环境变量中已经加载了系统变量,启动参数等信息
 //所以如果配置文件中没有设置profile 默认使用启动参数里面的配置 启动参数里面配置的优先级大于配置文件
 //该方法会向profiles中加入一个null元素和一个default 用于解析application.yml和application-default.yml
			initializeProfiles();
			while (!this.profiles.isEmpty()) {
				Profile profile = this.profiles.poll();
				if (profile != null && !profile.isDefaultProfile()) {
				  //进入这个分支里面表示 spring.profiles.active配置过了 检查Environment里面是否有当前的profile 如果没有加入Environment
					addProfileToEnvironment(profile.getName());
				}
		//当profile为null时 该方法会加载application.yml或application.properties文件
				load(profile, this::getPositiveProfileFilter, addToLoaded(MutablePropertySources::addLast, false));
				this.processedProfiles.add(profile);
			}
		//从processedProfiles集合中过滤掉 profile为null和default的	 
		//剩下的设置到Environment的activeProfiles中 剩下的就是我们配置的spring.profiles.active/include指定的
			resetEnvironmentProfiles(this.processedProfiles);
	//再一次load profile为null的配置 和前一次加载 区别是 DocumentFilter的区别 这一部分我们放在下一篇博文中讨论		
			load(null, this::getNegativeProfileFilter, addToLoaded(MutablePropertySources::addFirst, true));
			//将加载的所有的配置文件加入到Environment中 因为加载的文件是暂存在loaded属性中的
			addLoadedPropertySources();
		}

继续跟进
load(profile, this::getPositiveProfileFilter,addToLoaded(MutablePropertySources::addLast, false));

private void load(Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
   //这里查找加载哪个路径下面的配置文件 先看下面的查找方法
		getSearchLocations().forEach((location) -> {
			boolean isFolder = location.endsWith("/");
			Set<String> names = isFolder ? getSearchNames() : NO_SEARCH_NAMES;
			//这里就循环遍历下面getSearchLocations()得到路径下面的文件。继而加载
			names.forEach((name) -> load(location, name, profile, filterFactory, consumer));
		});
	}
//查找配置路径	
private Set<String> getSearchLocations() {
    //如果我们配置了加载路径  优先使用配置的路径  (这个配置是指在系统环境变量 启动命令行参数等,关于配置的优先级我们下次再探讨)
	if (this.environment.containsProperty(CONFIG_LOCATION_PROPERTY)) {
		return getSearchLocations(CONFIG_LOCATION_PROPERTY);
	}
	//如果没有配置 优先使用ConfigFileApplicationListener.this.searchLocations这个属性的配置。如果都没有值 默认使用内置的配置
	//classpath:/,classpath:/config/,file:./,file:./config/  优先级是越来越高的。
	Set<String> locations = getSearchLocations(CONFIG_ADDITIONAL_LOCATION_PROPERTY);
	locations.addAll(
			asResolvedSet(ConfigFileApplicationListener.this.searchLocations, DEFAULT_SEARCH_LOCATIONS));
	return locations;
}

继续进入
load(location, name, profile, filterFactory, consumer));

	private void load(String location, String name, Profile profile, DocumentFilterFactory filterFactory,
				DocumentConsumer consumer) {
	//这里我以当前name为application为例 直接进入下面一个分支			
		if (!StringUtils.hasText(name)) {
			for (PropertySourceLoader loader : this.propertySourceLoaders) {
				if (canLoadFileExtension(loader, location)) {
					load(loader, location, profile, filterFactory.getDocumentFilter(profile), consumer);
					return;
				}
			}
		}
	//存储已处理的文件类型	
		Set<String> processed = new HashSet<>();
	//这里的this.propertySourceLoaders 默认有两个一个是	PropertiesPropertySourceLoader 处理类型为xml和properties
	//另一种类型为YamlPropertySourceLoader处理yml和yaml类型的文件
		for (PropertySourceLoader loader : this.propertySourceLoaders) {
		//从下面的循环逻辑可以我们可以看出  是用当前name和解析器支持的文件类型拼接去加载
		//如当前name为application 分别和xml  properties yml 和yaml做拼接去加载当前文件夹下的文件 如果存在进行加载操作 不存在跳过 
			for (String fileExtension : loader.getFileExtensions()) {
//由上面的分析 我们可以看出processed存在的意义  因为PropertySourceLoaders是可以动态配置的			
//如果我们自定义的处理类型和也支持yml  那么造成了重复加载问题。所以这里add成功就执行加载
				if (processed.add(fileExtension)) {
					loadForFileExtension(loader, location + name, "." + fileExtension, profile, filterFactory,
							consumer);
				}
			}
		}
		}

我们继续跟进loadForFileExtension方法

private void loadForFileExtension(PropertySourceLoader loader, String prefix, String fileExtension,Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
	DocumentFilter defaultFilter = filterFactory.getDocumentFilter(null);
	DocumentFilter profileFilter = filterFactory.getDocumentFilter(profile);
	//这里一般是第二次进入 当我们在application文件中配置了spring.profiles.active或者include 在加载完application配置之后继而会加载 我们配置的文件
	if (profile != null) {
	    //这行代码会拼接完整的文件名 例如 application-default.yml
		String profileSpecificFile = prefix + "-" + profile + fileExtension;
	//下面load方法是加载active或include进去的配置文件 为什么会进行两次load 这两次load的
	//唯一区别就是DocumentFilter的区别 一个是defaultFiler 一个是profilerFilter 关于这一部门内容我们也会在下一篇文章中讨论	
		load(loader, profileSpecificFile, profile, defaultFilter, consumer);
		load(loader, profileSpecificFile, profile, profileFilter, consumer);
		for (Profile processedProfile : this.processedProfiles) {
			if (processedProfile != null) {
				String previouslyLoaded = prefix + "-" + processedProfile + fileExtension;
				load(loader, previouslyLoaded, profile, profileFilter, consumer);
			}
		}
	}
	//第一次进入 当profile为空时解析默认文件
	load(loader, prefix + fileExtension, profile, profileFilter, consumer);
}

进入真正解析配置的方法
load(loader, prefix + fileExtension, profile, profileFilter, consumer); 删除了部分校验的代码只保留了核心的内容

private void load(PropertySourceLoader loader, String location, Profile profile, DocumentFilter filter, DocumentConsumer consumer) {
	try {
		//将配置文件加载成Resource
		Resource resource = this.resourceLoader.getResource(location);
		//解析resource 这个解析的过程比较简单就是利用我们上面讲到的两个解析器解析成
		//List<PropertySource<?>>  然后将PropertySource封装成Document 感兴趣的可以阅读
		//YamlPropertySourceLoader和PropertiesPropertySourceLoader的load方法
		List<Document> documents = loadDocuments(loader, name, resource);
		List<Document> loaded = new ArrayList<>();
		for (Document document : documents) {
		//这个条件判断放在下一篇博文中讨论
			if (filter.match(document)) {
		//如果application配置文件中配置了spring.profiles.active将加入this.profiles中并且将default 从这个集合中移除  
	    //值得注意的是  我们一开始就是从遍历这个集合 取出里面的一个null元素开始到这里。
				addActiveProfiles(document.getActiveProfiles());
		//向this.profiles加入application配置文件的spring.profiles.include属性指定的值
				addIncludedProfiles(document.getIncludeProfiles());
	//将加载出来的文档放入缓存中。等待被放入Environment  可以看到第一次加载流程到这里就结束了。加载指定的active和include 会进入while的下一次循环 
	//因为我们解析之后向队列中加了application配置文件配置的active和include的配置项 所以while会继续往下循环。如果下一个配置文件也有active和include属性 也会继续解析 重复这个流程			
				loaded.add(document);
			}
		}
		Collections.reverse(loaded);
		if (!loaded.isEmpty()) {
			loaded.forEach((document) -> consumer.accept(profile, document));
			if (this.logger.isDebugEnabled()) {
				StringBuilder description = getDescription("Loaded config file ", location, resource, profile);
				this.logger.debug(description);
			}
		}
	}
	catch (Exception ex) {
		throw new IllegalStateException("Failed to load property " + "source from location '" + location + "'",
				ex);
	}
}

到此Environment就加载了配置文件的属性。整个流程load的重载方法比较多 给人感觉不是那么清晰。
那么我们本篇文章中还遗留了几个问题:springBoot2的Binder使用 资源文件优先级 多文档块配置以及和环境变量相关的几个常用的注解等问题 都会在下一篇文章中讨论

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值