Spring源码深度解析(郝佳)-学习-资源加载classpath和classpath*区别

################## classpath 和 classpath* 区别:
classpath:只会到你的class路径中查找找文件;
classpath*:不仅包含class路径,还包括jar文件中(class路径)进行查找.
上述结论何以见得,那我们来测试一下吧
################## 1.引入pom.xml

<dependencies>
	<dependency>
        <groupId>org.example</groupId>
        <artifactId>spring_jar</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    
    <dependency>
        <groupId>org.example1</groupId>
        <artifactId>spring_jar1</artifactId>
        <version>2.0-SNAPSHOT</version>
    </dependency>
</dependencies>

引入两个 jar 包,包中都包含spring_test4.xml文件
在这里插入图片描述

我们先用 classpath* 来测试一下

@Test
public void test6(){
    ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
    try {
        Resource[] metaInfResources = resourcePatternResolver
                .getResources("classpath*:spring_1_100/**/spring_test4.xml");
        System.out.println(metaInfResources.length);
        for(Resource r : metaInfResources){
            System.out.println("URL:" + r.getURL());
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

【输出结果】
3

URL:file:/Users/quyixiao/project/spring_tiny/target/classes/spring_1_100/config_1_10/spring_test1/a/spring_test4.xml

URL:jar:file:/Users/quyixiao/.m2/repository/org/example/spring_jar/1.0-SNAPSHOT/spring_jar-1.0-SNAPSHOT.jar!/spring_1_100/bbb/spring_test4.xml

URL:jar:file:/Users/quyixiao/.m2/repository/org/example1/spring_jar1/2.0-SNAPSHOT/spring_jar1-2.0-SNAPSHOT.jar!/spring_1_100/bbb/spring_test4.xml

再用 classpath 来测试一下

@Test
public void test6(){
    ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
    try {
        Resource[] metaInfResources = resourcePatternResolver
                .getResources("classpath:spring_1_100/**/spring_test4.xml");
        System.out.println(metaInfResources.length);
        for(Resource r : metaInfResources){
            System.out.println("URL:" + r.getURL());
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

【输出结果】
1

URL:file:/Users/quyixiao/project/spring_tiny/target/classes/spring_1_100/config_1_10/spring_test1/a/spring_test4.xml

classpath 和classpath* 的区别是什么呢?

classpath: 表示从类路径中加载资源,classpath:和classpath:/是等价的,都是相对于类的根路径。
classpath*: 假设多个JAR包或文件系统类路径都有一个相同的配置文件,classpath只会在第一个加载的类路径下查找,而classpath* 会扫描所有这些JAR包及类路径下出现的同名文件。

Spring 源码又是如何实现的呢?那直接上源码
Spring 对xml 资源的加载用的是PathMatchingResourcePatternResolver类,这个类中有一个getResources方法。
对于查找 resources 下的所有spring_test4.xml 文件的测试,我们来看看classpath 和 classpath* 是如何实现的。

我们打断点进入下面代码,对于classpath的情况,资源文件中没有配置classpath* ,但是除去classpath* 后,还包含 * 或者? ,因此如截图所示。

在这里插入图片描述

PathMatchingResourcePatternResolver.java
 @Override
 public Resource[] getResources(String locationPattern) throws IOException {
 	 //如果是classpath*:开头
     if (locationPattern.startsWith("classpath*:")) {
         //路径中去除前缀classpath:* 后,路径中还包含 * 或者 ?
         if (getPathMatcher().isPattern(locationPattern.substring("classpath*:".length()))) {
             return findPathMatchingResources(locationPattern);
         }
         else {
             return findAllClassPathResources(locationPattern.substring("classpath*:".length()));
         }
     }
     else {
         int prefixEnd = locationPattern.indexOf(":") + 1;
         //如果并不是以classpath*:开头,并且去除分号 【:】前的字符串后,资源路径中仍然有 * 或者 ? 
         if (getPathMatcher().isPattern(locationPattern.substring(prefixEnd))) {
             return findPathMatchingResources(locationPattern);
         }
         else {
         	 //否则直接调用ResourceLoader的getResource方法
             return new Resource[] {getResourceLoader().getResource(locationPattern)};
         }
     }
 }

我们来看一下findPathMatchingResources方法

PathMatchingResourcePatternResolver.java
protected Resource[] findPathMatchingResources(String locationPattern) throws IOException {
	//这个方法,对字符串的处理,就是只要有 * 或者 ?,则向前截取,直到没有为止,
	//所以 classpath:spring_1_100/**/spring_test4.xml 最后被截取成 
	//得到字符串前缀 classpath:spring_1_100/
    String rootDirPath = determineRootDir(locationPattern);
    //得到字符串后缀 **/spring_test4.xml
    String subPattern = locationPattern.substring(rootDirPath.length());
    // 通过字符串前缀递归获取resouces前缀下的所有resouce资源,后面再来具体分析这一块代码了
    Resource[] rootDirResources = getResources(rootDirPath);
    Set<Resource> result = new LinkedHashSet<Resource>(16);
    
    for (Resource rootDirResource : rootDirResources) {
        //转化一下资源,目前没有看到这一块的使用
        rootDirResource = resolveRootDirResource(rootDirResource);
        if (rootDirResource.getURL().getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) {
         	//如果文件是vfs协义
            result.addAll(PathMatchingResourcePatternResolver.VfsResourceMatchingDelegate.findMatchingResources(rootDirResource, subPattern, getPathMatcher()));
        }
        else if (isJarResource(rootDirResource)) {
        	//如果文件协义是jar包
            result.addAll(doFindPathMatchingJarResources(rootDirResource, subPattern));
        }
        else {
			//因此,在这里我们拿到了rootDirResource下所有匹配的文件
            result.addAll(doFindPathMatchingFileResources(rootDirResource, subPattern));
        }
    }
    if (logger.isDebugEnabled()) {
        logger.debug("Resolved location pattern [" + locationPattern + "] to resources " + result);
    }
    return result.toArray(new Resource[result.size()]);
}
PathMatchingResourcePatternResolver.java
 protected Set<Resource> doFindPathMatchingFileResources(Resource rootDirResource, String subPattern)
         throws IOException {

     File rootDir;
     try {
     	//获取文件的绝对路径
         rootDir = rootDirResource.getFile().getAbsoluteFile();
     }
     catch (IOException ex) {
         if (logger.isWarnEnabled()) {
             logger.warn("Cannot search for matching files underneath " + rootDirResource +
                     " because it does not correspond to a directory in the file system", ex);
         }
         return Collections.emptySet();
     }
     //获取到所有匹配的文件
     return doFindMatchingFileSystemResources(rootDir, subPattern);
 }
PathMatchingResourcePatternResolver.java
protected Set<Resource> doFindMatchingFileSystemResources(File rootDir, String subPattern) throws IOException {
    if (logger.isDebugEnabled()) {
        logger.debug("Looking for matching resources in directory tree [" + rootDir.getPath() + "]");
    }
    //尝试获取到所有匹配的文件
    Set matchingFiles = retrieveMatchingFiles(rootDir, subPattern);
    Set<Resource> result = new LinkedHashSet<Resource>(matchingFiles.size());
    for (File file : matchingFiles) {
        result.add(new FileSystemResource(file));
    }
    return result;
}
PathMatchingResourcePatternResolver.java
protected Set<File> retrieveMatchingFiles(File rootDir, String pattern) throws IOException {
    if (!rootDir.exists()) {
        // Silently skip non-existing directories.
        if (logger.isDebugEnabled()) {
            logger.debug("Skipping [" + rootDir.getAbsolutePath() + "] because it does not exist");
        }
        return Collections.emptySet();
    }
    if (!rootDir.isDirectory()) {
        // Complain louder if it exists but is no directory.
        if (logger.isWarnEnabled()) {
            logger.warn("Skipping [" + rootDir.getAbsolutePath() + "] because it does not denote a directory");
        }
        return Collections.emptySet();
    }
    //目录是不是可读
    if (!rootDir.canRead()) {
        if (logger.isWarnEnabled()) {
            logger.warn("Cannot search for matching files underneath directory [" + rootDir.getAbsolutePath() +
                    "] because the application is not allowed to read the directory");
        }
        return Collections.emptySet();
    }
    String fullPattern = StringUtils.replace(rootDir.getAbsolutePath(), File.separator, "/");
    if (!pattern.startsWith("/")) {
        fullPattern += "/";
    }
    // /Users/quyixiao/project/spring_tiny/target/classes/spring_1_100/**/spring_test4.xml,
    // 资源文件路径字符串重新拼接
    fullPattern = fullPattern + StringUtils.replace(pattern, File.separator, "/");
    Set result = new LinkedHashSet(8);
    doRetrieveMatchingFiles(fullPattern, rootDir, result);
    return result;
}
PathMatchingResourcePatternResolver.java
protected void doRetrieveMatchingFiles(String fullPattern, File dir, Set result) throws IOException {
    if (logger.isDebugEnabled()) {
        logger.debug("Searching directory [" + dir.getAbsolutePath() +
                "] for files matching pattern [" + fullPattern + "]");
    }
    //获取目录下的所有文件
    File[] dirContents = dir.listFiles();
    if (dirContents == null) {
        if (logger.isWarnEnabled()) {
            logger.warn("Could not retrieve contents of directory [" + dir.getAbsolutePath() + "]");
        }
        return;
    }
    
    for (File content : dirContents) {
        String currPath = StringUtils.replace(content.getAbsolutePath(), File.separator, "/");
        if (content.isDirectory() && getPathMatcher().matchStart(fullPattern, currPath + "/")) {
            if (!content.canRead()) {
                if (logger.isDebugEnabled()) {
                    logger.debug("Skipping subdirectory [" + dir.getAbsolutePath() +
                            "] because the application is not allowed to read the directory");
                }
            }
            else {
            	//如果是目录,并且可读,则递归调用获取目录下的所有的目录及文件
                doRetrieveMatchingFiles(fullPattern, content, result);
            }
        }
        // 如果是文件,则进行匹配,看是不是符合要求,文件匹配在另一篇博客中,有兴趣的同学可以去看看,
        // https://blog.csdn.net/quyixiao/article/details/108482096
        // 如果匹配成功,则加入到结果集合中
        if (getPathMatcher().match(fullPattern, currPath)) {
            result.add(content);
        }
    }
}

如果是 classpath: 前缀开头,当递归调用 getResources 时,直接调用 getResource()方法
在这里插入图片描述

DefaultResourceLoader.java
@Override
public Resource getResource(String location) {
    Assert.notNull(location, "Location must not be null");
    if (location.startsWith("/")) {
        return getResourceByPath(location);
    }
    //如果以classpath: 开头,到这里就结束了
    else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
        return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader());
    }
    else {
        try {
            //尝试封装UrlResource返回
            URL url = new URL(location);
            return new UrlResource(url);
        }
        catch (MalformedURLException ex) {
            // 如果路径中包含 / 开头,或者包含 /../ 这些文件路径将使用下面的方法解析
            // 例如:spring_1_100/config_1_10/spring_test1/a/b/c/../../spring_test3.xml 如这种配置的,走下面方法
            return getResourceByPath(location);
        }
    }
}

在这里插入图片描述
对于这种路径的,返回的是一个ClassPathContextResource对象,当然这个对象代码实现如下

protected Resource getResourceByPath(String path) {
	return new ClassPathContextResource(path, getClassLoader());
}
	
protected static class ClassPathContextResource extends ClassPathResource implements ContextResource {

	public ClassPathContextResource(String path, ClassLoader classLoader) {
		super(path, classLoader);
	}
	@Override
	public String getPathWithinContext() {
		return getPath();
	}

	@Override
	public Resource createRelative(String relativePath) {
		String pathToUse = StringUtils.applyRelativePath(getPath(), relativePath);
		return new ClassPathContextResource(pathToUse, getClassLoader());
	}
}

ContextResource继承Resource的,ClassPathContextResource实现ContextResource,因此,返回一个 Resource 对象
而在 new ClassPathContextResource对象时,调用了父类的ClassPathResource构造函数

public ClassPathResource(String path, ClassLoader classLoader) {
	Assert.notNull(path, "Path must not be null");
	// 在这个方法中,重新解析了替换了 path
	// 在另一篇博客详细说明 https://blog.csdn.net/quyixiao/article/details/108484935 如何解析 
	String pathToUse = StringUtils.cleanPath(path);
	if (pathToUse.startsWith("/")) {
		pathToUse = pathToUse.substring(1);
	}
	this.path = pathToUse;
	this.classLoader = (classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader());
}

到这里,我们己经完成了对 classpath: 和文件路径中包含了/. ./的解析,下面,我们来看看classpath*:是如何解析的
凡是路径中包含了 * 或者 ? 的都会调用findPathMatchingResources方法,上面己经解析过了,这里将不再缀述 ,在findPathMatchingResources方法中,会根据路径 /**/ 之前的前缀 path 递归调用 getResources 方法,而递归调用的前缀中己经没有 * 或者 ?了,所以直接进入到
在这里插入图片描述

findAllClassPathResources方法

PathMatchingResourcePatternResolver.java
//此时传递进来的 location 是去除前缀 classpath*: 的路径
protected Resource[] findAllClassPathResources(String location) throws IOException {
    String path = location;
    if (path.startsWith("/")) {
        path = path.substring(1);
    }
    Set result = doFindAllClassPathResources(path);
    return result.toArray(new Resource[result.size()]);
}

在这里插入图片描述
我们继续跟进代码,因为ClassLoader不为空,所以,根据 cl.getResources(path) 得到了所有的jar 包及自己类路径下的资源文件。

在这里插入图片描述

 protected Set doFindAllClassPathResources(String path) throws IOException {
     Set<Resource> result = new LinkedHashSet<Resource>(16);
     
     ClassLoader cl = getClassLoader();
     //加载所有的 resource
     Enumeration resourceUrls = (cl != null ? cl.getResources(path) : ClassLoader.getSystemResources(path));
     while (resourceUrls.hasMoreElements()) {
         URL url = resourceUrls.nextElement();
         //转换成UrlResource对象并加入到集合中
         result.add(convertClassLoaderURL(url));
     }
     if ("".equals(path)) {
         // The above result is likely to be incomplete, i.e. only containing file system references.
         // We need to have pointers to each of the jar files on the classpath as well...
         addAllClassLoaderJarRoots(cl, result);
     }
     return result;
 }

到此findPathMatchingResources 方法中得到了3个 rootDirResources 对象,循环遍历rootDirResource 目录下的所有文件,将符合条件的resource加入到result集合中,
在这里插入图片描述

对于rootDirResources[0],我们刚刚己经分析过了,这里不再缀述,对于jar 包中的配置文件获取,这里分析一下。
在这里插入图片描述
我们重点分析一下doFindPathMatchingJarResources方法,我们进入方法内部
在这里插入图片描述

PathMatchingResourcePatternResolver.java
protected Set<Resource> doFindPathMatchingJarResources(Resource rootDirResource, String subPattern)
        throws IOException {

    URLConnection con = rootDirResource.getURL().openConnection();
    JarFile jarFile;
    String jarFileUrl;
    String rootEntryPath;
    boolean newJarFile = false;

    if (con instanceof JarURLConnection) {
    	//获取jarFile,jarFileUrl,rootEntryPath 等相关值
        JarURLConnection jarCon = (JarURLConnection) con;
        ResourceUtils.useCachesIfNecessary(jarCon);
        jarFile = jarCon.getJarFile();
        jarFileUrl = jarCon.getJarFileURL().toExternalForm();
        JarEntry jarEntry = jarCon.getJarEntry();
        rootEntryPath = (jarEntry != null ? jarEntry.getName() : "");
    }
    else {
        // No JarURLConnection -> need to resort to URL file parsing.
        // We'll assume URLs of the format "jar:path!/entry", with the protocol
        // being arbitrary as long as following the entry format.
        // We'll also handle paths with and without leading "file:" prefix.
        String urlFile = rootDirResource.getURL().getFile();
        try {
            int separatorIndex = urlFile.indexOf(ResourceUtils.JAR_URL_SEPARATOR);
            if (separatorIndex != -1) {
                jarFileUrl = urlFile.substring(0, separatorIndex);
                rootEntryPath = urlFile.substring(separatorIndex + ResourceUtils.JAR_URL_SEPARATOR.length());
                jarFile = getJarFile(jarFileUrl);
            }
            else {
                jarFile = new JarFile(urlFile);
                jarFileUrl = urlFile;
                rootEntryPath = "";
            }
            newJarFile = true;
        }
        catch (ZipException ex) {
            if (logger.isDebugEnabled()) {
                logger.debug("Skipping invalid jar classpath entry [" + urlFile + "]");
            }
            return Collections.emptySet();
        }
    }

    try {
        if (logger.isDebugEnabled()) {
            logger.debug("Looking for matching resources in jar file [" + jarFileUrl + "]");
        }
        if (!"".equals(rootEntryPath) && !rootEntryPath.endsWith("/")) {
            // Root entry path must end with slash to allow for proper matching.
            // The Sun JRE does not return a slash here, but BEA JRockit does.
            rootEntryPath = rootEntryPath + "/";
        }
        Set result = new LinkedHashSet(8);
        //对jar中所有的文件进行遍历,匹配出符合后缀pattern的文件
        for (Enumeration entries = jarFile.entries(); entries.hasMoreElements();) {
            JarEntry entry = entries.nextElement();
            String entryPath = entry.getName();
            if (entryPath.startsWith(rootEntryPath)) {
                String relativePath = entryPath.substring(rootEntryPath.length());
                if (getPathMatcher().match(subPattern, relativePath)) {
                    result.add(rootDirResource.createRelative(relativePath));
                }
            }
        }
        return result;
    }
    finally {
        // Close jar file, but only if freshly obtained -
        // not from JarURLConnection, which might cache the file reference.
        if (newJarFile) {
            jarFile.close();
        }
    }
}

在这里插入图片描述
匹配jar包所有的文件,找到适配的资源文件。
这里,我们己对资源路径中配置了classpath 和classpath* : 的完成解析,当然,比如对zip 文件解析,这些还需要读者自行研究,博客只起到抛砖引玉的使用。

classpath : 对于classpath而言,如果没有配置*或者?的,直接返回ClasspathResource对象,如果路径中配置了 * 或者?的,先截取没有包含 * 或者?的前缀,然后以此前缀为目录,递归找到目录下所有文件,和我们xml所配置的资源路径进行匹配(这种匹配类似于正则,但不完全是,在https://blog.csdn.net/quyixiao/article/details/108482096这篇博客中有详细举例说明) 。匹配成功,加入集合并返回。

classpath* : 对于classpath*而言,如果路径中配置了 * 或者 ? ,同样截取前缀,然后找到当前ClassLoader下的所有资源目录及jar包,如果是目录,则直接遍历目录下的所有的资源文件与配置文件中资源路径进行匹配,匹配成功,加入集合,如果文件协义是zip 或 jar 或vfszip或wsjar的资源文件,直接调用doFindPathMatchingJarResources方法,遍历jar 下的所有文件,匹配出符合要求的资源文件。加入到集合中。

在上面,完成了对资源文件路径中配置了classpath ,classpath*,以及路径中包含 /../ 的匹配,如果资源文件中包含 ${user.dir}这种情况,该怎么办呢?
我们来举例看看。

@Test
public void test3() {
    ApplicationContext ctx = new ClassPathXmlApplicationContext("file:${user.dir}/src/main/resources/spring_1_100/config_1_10/spring_test1/a/*.xml");
    UserService userService = (UserService) ctx.getBean("userService");
    userService.query();
}
ClassPathXmlApplicationContext.java
public ClassPathXmlApplicationContext(String configLocation) throws BeansException {
	this(new String[] {configLocation}, true, null);
}
ClassPathXmlApplicationContext.java
public ClassPathXmlApplicationContext(String[] configLocations, boolean refresh, ApplicationContext parent)
        throws BeansException {

    super(parent);
    setConfigLocations(configLocations);
    if (refresh) {
        refresh();
    }
}
AbstractRefreshableConfigApplicationContext.java
public void setConfigLocations(String... locations) {
    if (locations != null) {
        Assert.noNullElements(locations, "Config locations must not be null");
        this.configLocations = new String[locations.length];
        for (int i = 0; i < locations.length; i++) {
            this.configLocations[i] = resolvePath(locations[i]).trim();
        }
    }
    else {
        this.configLocations = null;
    }
}
AbstractRefreshableConfigApplicationContext.java
protected String resolvePath(String path) {
	return getEnvironment().resolveRequiredPlaceholders(path);
}
AbstractApplicationContext.java
@Override
public ConfigurableEnvironment getEnvironment() {
    if (this.environment == null) {
        this.environment = createEnvironment();
    }
    return this.environment;
}
protected ConfigurableEnvironment createEnvironment() {
    return new StandardEnvironment();
} 

在 new StandardEnvironment()方法时,实际调用了父类AbstractEnvironment

AbstractEnvironment.java
public AbstractEnvironment() {
    customizePropertySources(this.propertySources);
    if (this.logger.isDebugEnabled()) {
        this.logger.debug(format(
                "Initialized %s with PropertySources %s", getClass().getSimpleName(), this.propertySources));
    }
}

从而初始化

AbstractEnvironment.java
public AbstractEnvironment() {
    customizePropertySources(this.propertySources);
    if (this.logger.isDebugEnabled()) {
        this.logger.debug(format(
                "Initialized %s with PropertySources %s", getClass().getSimpleName(), this.propertySources));
    }
}
StandardEnvironment.java
@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
    propertySources.addLast(new MapPropertySource("systemProperties", getSystemProperties()));
    propertySources.addLast(new SystemEnvironmentPropertySource("systemEnvironment", getSystemEnvironment()));
} 

因此环境变量中己经有了系统属性 ,systemProperties 和systemEnvironment
继续上面的代码

AbstractEnvironment.java
@Override
public String resolveRequiredPlaceholders(String text) throws IllegalArgumentException {
    return this.propertyResolver.resolveRequiredPlaceholders(text);
}

而propertyResolver 为

private final ConfigurablePropertyResolver propertyResolver =
		new PropertySourcesPropertyResolver(this.propertySources);

最终调用PropertySourcesPropertyResolver的resolveRequiredPlaceholders方法,并将当前环境资源propertySources传入到PropertySourcesPropertyResolver类中。

AbstractEnvironment.java
@Override
public String resolveRequiredPlaceholders(String text) throws IllegalArgumentException {
    if (this.strictHelper == null) {
        this.strictHelper = createPlaceholderHelper(false);
    }
    return doResolvePlaceholders(text, this.strictHelper);
}
AbstractPropertyResolver.java
private String doResolvePlaceholders(String text, PropertyPlaceholderHelper helper) {
    return helper.replacePlaceholders(text, new PropertyPlaceholderHelper.PlaceholderResolver() {
        @Override
        public String resolvePlaceholder(String placeholderName) {
            return getPropertyAsRawString(placeholderName);
        }
    });
}

protected abstract String getPropertyAsRawString(String key);

面对于replacePlaceholders方法,在我的另一篇博客中做了详细说明https://blog.csdn.net/quyixiao/article/details/108482668
在这里插入图片描述

到此,我们己经对Spring 资源加载己经分析完成,对于配置classpath* 资源路径,当多个jar包中配置了相同的beanName ,但不同的class, 有些会报错,有些会因为在pom.xml中配置的先后位置不同,出现不同的结果,这一块的博客,我们在后面的博客中,再来分析了吧。

本文的 github 地址
https://github.com/quyixiao/spring_jar
https://github.com/quyixiao/spring_tiny/blob/master/src/main/java/com/spring_1_100/test_1_10/test/SpringTest1.java

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值