Resource体系

Spring对资源进行了抽象,统一了Resource接口,屏蔽了资源类型的差异。ResourceLoader负责加载资源,ResourcePatternResolver可搜索类路径下的资源。PathMatcher用于路径模式匹配,如AntPathMatcher。文章深入探讨了这些组件的工作原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Resource

在Spring中,对资源进行了抽象,从而屏蔽了资源类型和来源的区别,使得内部对于操作这些资源的API更加统一。下图为Spring中Resource的继承体系图:

Resource继承关系图

根据继承关系图可以看到,整个Resource体系中,进行了一定层级的抽象,通过顶层的借口定义资源常见的操作,然后给出一个通用的抽象实现,最后各个不同类型的资源各自实现各自特殊的处理方法。

最顶层的Resource接口继承自InputStreamSource接口,而InputStreamSource接口中仅定义了一个方法:getInputStream。凡是可以获取输入流的资源都可以是一个InputStreamSource类型。而对于大多数资源文件来说,不一定可写但一般是可读的。因此这里专门为可写的资源类型定义了WritableResource接口,此接口中定义了两个和写操作相关的方法:

  • isWritable
  • getOutputStream

可写的资源一般也包含可读资源的各种特性,因此WritableResource接口继承自Resource接口。在Resource接口中定义了一些通用的方法:

public interface Resource extends InputStreamSource {

    // 判断资源是否存在
    boolean exists();

    // 判断资源是否可读,只有在返回true的时候,getInputStream方法才可用
    boolean isReadable();

    // 判断资源是否已打开,如果已打开则资源不能多次读写,资源应该在读完成之后关闭。
    boolean isOpen();

    // 获取资源对象的URL,如果该资源不能表示为URL形式则抛出异常
    URL getURL() throws IOException;

    // 获取资源对象的URI,如果该资源不能表示为URI形式则抛出异常
    URI getURI() throws IOException;

    // 获取资源的File表示对象,如果资源不能表示为File对象则抛出异常
    File getFile() throws IOException;

    // 获取资源内容的长度,如果资源无法解析则抛出异常
    long contentLength() throws IOException;

    // 获取资源最后修改时间戳,如果资源无法解析则抛出异常
    long lastModified() throws IOException;

    // 相对当前资源创建新的资源对象,如果相对的资源无法解析则抛出异常
    Resource createRelative(String relativePath) throws IOException;

    // 获取当前资源的文件名,如果当前资源没有文件名则返回null
    String getFilename();

    // 获取当对资源的描述信息
    String getDescription();
}

Resource接口中定义的方法,并不需要每一种实际资源类型都必须实现,各个实际资源类型根据自身的情况决定要实现哪些方法。例如基于文件的资源一般会实现getFile方法,而不是基于文件的资源则一般不实现getFile方法。

在具体资源实现之前,Spring还提供了一个抽象的公共实现AbstractResource,在AbstractResource中实现一些跟资源类型无关的通用逻辑,和底层资源有关的则留给具体的资源类型去实现。

public abstract class AbstractResource implements Resource {

    public boolean exists() {
        // Try file existence: can we find the file in the file system?
        try {
            return getFile().exists();
        } catch (IOException ex) {
            // Fall back to stream existence: can we open the stream?
            try {
                InputStream is = getInputStream();
                is.close();
                return true;
            } catch (Throwable isEx) {
                return false;
            }
        }
    }

    public boolean isReadable() {
        return true;
    }

    public boolean isOpen() {
        return false;
    }

    public URL getURL() throws IOException {
        // 默认认为资源无法表示为URL,子类可覆写此方法
        throw new FileNotFoundException(getDescription() + " cannot be resolved to URL");
    }

    public URI getURI() throws IOException {
        URL url = getURL();
        try {
            // 通过getURL方法的返回值来进行转换
            return ResourceUtils.toURI(url);
        } catch (URISyntaxException ex) {
            throw new NestedIOException("Invalid URI [" + url + "]", ex);
        }
    }

    public File getFile() throws IOException {
        // 默认认为资源无法表示为File对象,子类可覆写
        throw new FileNotFoundException(getDescription() + " cannot be resolved to absolute file path");
    }

    public long contentLength() throws IOException {
        InputStream is = this.getInputStream();
        Assert.state(is != null, "resource input stream must not be null");
        try {
            // 默认实现为读取inputStream中的所有数据来获取长度
            long size = 0;
            byte[] buf = new byte[255];
            int read;
            while((read = is.read(buf)) != -1) {
                size += read;
            }
            return size;
        } finally {
            try {
                is.close();
            } catch (IOException ex) {
            }
        }
    }

    public long lastModified() throws IOException {
        long lastModified = getFileForLastModifiedCheck().lastModified();
        if (lastModified == 0L) {
            throw new FileNotFoundException(getDescription() +
                    " cannot be resolved in the file system for resolving its last-modified timestamp");
        }
        return lastModified;
    }

    protected File getFileForLastModifiedCheck() throws IOException {
        return getFile();
    }

    public Resource createRelative(String relativePath) throws IOException {
        // 默认不支持创建相对路径资源
        throw new FileNotFoundException("Cannot create a relative resource for " + getDescription());
    }

    public String getFilename() {
        return null; // 默认返回null,即认为资源五文件名
    }

    @Override
    public String toString() {
        return getDescription();
    }

    @Override
    public boolean equals(Object obj) {
        return (obj == this ||
            (obj instanceof Resource && ((Resource) obj).getDescription().equals(getDescription())));
    }

    @Override
    public int hashCode() {
        return getDescription().hashCode();
    }

}

AbstractResource的实现中,默认认为资源不能够表示为URLFile的形式,这样的资源如ByteArrayResourceInputStreamResource等都可以适应,因为这些资源类型底层并不是基于文件而是包装了字节数组或输入流而成,因此正对这种类型的操作,一般只支持读取操作。具体资源类型可以通过覆写isOpen方法,通过返回truefalse来标识当前资源是否支持多次读取操作。

从上面的继承关系图中可以看到,FileSystemResource不但继承了AbstractResource还实现了WritableResource接口,也就是基于文件系统的资源类型,一般可以支持读写操作,当然一般也会支持相对路径资源。如果有兴趣可以阅读FileSystemResource类的源代码,可以发现大部分操作都是基于内部的File对象来完成,如isReadable方法和getInputStream方法:

public boolean isReadable() {
  return (this.file.canRead() && !this.file.isDirectory());
}
public InputStream getInputStream() throws IOException {
  return new FileInputStream(this.file);
}

另外像是isWritablegetOutputStreamgetURL等方法均是通过内部的File对象来完成。

从前面的继承关系图还可以看到,除了上面介绍的各种类型的资源实现,还有一个AbstractFileResolvingResouce类型的资源,并且他还有两个子类:UrlResourceClassPathResouce,其中ClassPathResource类型是我们在Spring中非常常用的资源类型。AbstractFileResolvingResource表示需要通过解析URL来获取的资源,例如其exists方法的实现:

public boolean exists() {
    try {
        URL url = getURL();
        if (ResourceUtils.isFileURL(url)) {
            // Proceed with file system resolution...
            return getFile().exists(); // 如果是文件,则直接检测文件是否存在
        } else {
            // Try a URL connection content-length header...
            URLConnection con = url.openConnection();
            ResourceUtils.useCachesIfNecessary(con);
            HttpURLConnection httpCon =  (con instanceof HttpURLConnection ? (HttpURLConnection) con : null);
            if (httpCon != null) {
                // 如果是http url则检测url对应的资源是否存在
                httpCon.setRequestMethod("HEAD");
                int code = httpCon.getResponseCode();
                if (code == HttpURLConnection.HTTP_OK) {
                    return true;
                } else if (code == HttpURLConnection.HTTP_NOT_FOUND) {
                    return false;
                }
            }
            if (con.getContentLength() >= 0) {
                return true;
            }
            if (httpCon != null) {
                // no HTTP OK status, and no content-length header: give up
                httpCon.disconnect();
                return false;
            } else {
                // Fall back to stream existence: can we open the stream?
                InputStream is = getInputStream();
                is.close();
                return true;
            }
        }
    } catch (IOException ex) {
        return false;
    }
}

从代码中可以看到,判断资源是否存在,均是通过判断URL的类型来执行不同的检测方式,如果通过URL无法判断资源是否存在,最终通过getInputStream方法来判断,如果能够返回输入流,那么资源必然存在。

对于UrlResource来说,基本上就是通过解析URL来完成相关的操作,只要符合URL规范的格式都可以表示为UrlResource对象。这里我们主要来看下ClassPathResource的实现吧。根据我们日常的使用经验和其名字透露的信息,我们知道ClassPathResource表示的是类路径下面的资源。既然是类路径下面的资源,那么根据我们对java技术的了解,一般要获取类路径下的资源无非是通过ClassClassLoader这两个类操作。带着这些疑问,我们看一下ClassPathResource的源码吧:

public class ClassPathResource extends AbstractFileResolvingResource {

    private final String path;

    private ClassLoader classLoader;

    private Class<?> clazz;

    public ClassPathResource(String path) {
        this(path, (ClassLoader) null);
    }

    public ClassPathResource(String path, ClassLoader classLoader) {
        Assert.notNull(path, "Path must not be null");
        String pathToUse = StringUtils.cleanPath(path);
        if (pathToUse.startsWith("/")) { // ClassLoader方式不需要'/'
            pathToUse = pathToUse.substring(1);
        }
        this.path = pathToUse;
        // 如果ClassLoader为null则使用默认的ClassLoader
        this.classLoader = (classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader());
    }

    public ClassPathResource(String path, Class<?> clazz) {
        Assert.notNull(path, "Path must not be null");
        this.path = StringUtils.cleanPath(path);
        this.clazz = clazz; // 使用Class来加载资源,也可以使用ClassLoader加载
    }

    protected ClassPathResource(String path, ClassLoader classLoader, Class<?> clazz) {
        this.path = StringUtils.cleanPath(path);
        // 同时使用Clss和ClassLoader
        this.classLoader = classLoader;
        this.clazz = clazz;
    }

    public final String getPath() {
        return this.path;
    }

    public final ClassLoader getClassLoader() {
        return (this.classLoader != null ? this.classLoader : this.clazz.getClassLoader());
    }

    @Override
    public boolean exists() {
        URL url;
        // 资源是否存在通过Class和ClassLoader来判断
        if (this.clazz != null) {
            url = this.clazz.getResource(this.path);
        } else {
            url = this.classLoader.getResource(this.path);
        }
        return (url != null);
    }

    public InputStream getInputStream() throws IOException {
        InputStream is;
        // InputStream的获取也是通过Class和ClassLoader来判断
        if (this.clazz != null) {
            is = this.clazz.getResourceAsStream(this.path);
        } else {
            is = this.classLoader.getResourceAsStream(this.path);
        }
        if (is == null) {
            throw new FileNotFoundException(getDescription() + " cannot be opened because it does not exist");
        }
        return is;
    }

    @Override
    public URL getURL() throws IOException {
        URL url;
        if (this.clazz != null) {
            url = this.clazz.getResource(this.path);
        } else {
            url = this.classLoader.getResource(this.path);
        }
        if (url == null) {
            throw new FileNotFoundException(getDescription() + " cannot be resolved to URL because it does not exist");
        }
        return url;
    }

    @Override
    public Resource createRelative(String relativePath) {
        String pathToUse = StringUtils.applyRelativePath(this.path, relativePath);
        return new ClassPathResource(pathToUse, this.classLoader, this.clazz);
    }

    @Override
    public String getFilename() {
        return StringUtils.getFilename(this.path);
    }


    public String getDescription() {
        StringBuilder builder = new StringBuilder("class path resource [");
        String pathToUse = path;
        if (this.clazz != null && !pathToUse.startsWith("/")) {
                 builder.append(ClassUtils.classPackageAsResourcePath(this.clazz));
            builder.append('/');
        }
        if (pathToUse.startsWith("/")) {
            pathToUse = pathToUse.substring(1);
        }
        builder.append(pathToUse);
        builder.append(']');
        return builder.toString();
    }

}

从代码中可以看到,和我们前面分析的一样,ClassPathResource确实是通过java提供的ClassClassLoader机制来完成对底层资源的处理的。

总结:Spring中对资源进行抽象,从而统一对资源操作的API,屏蔽不同资源之间的差异。使得其他组件可以不关心具体资源类型的实现,使用统一的API进行操作,并且通过不同的接口来分别定义资源的不同行为,然后通过抽象类的形式给出一个通用的实现,底层具体的实现只需要继承这个抽象类,并覆写跟当前资源类型需要特殊处理的方法即可。另外,在定义接口时,通过给出一对方法(如:isReadablegetInputStream)来分离条件检测和执行逻辑,这种设计方式在我们日常的开发过程中也是可以应用的。例如发短信操作,就可以将检测是否满足发短信条件和执行短信发送这两个逻辑分开定义(permit和semd),这样可以使得逻辑更加清晰,同时可以提高代码复用率(比如部分业务只需要检测是否满足发短信条件而不一定需要发短信)。

ResourceLoader

前面学习了Spring中的Resource体系,只是对资源类型进行了抽象,统一了接口,但是如果需要使用不同类型的Resource的时候,还是得通过new具体资源类型的方式来获取。Spring中为了简化对Resource的查找和加载,提供了ResourceLoader来专门负责加载Resource,使用这不需要关心如何加载具体的资源,只需要给出资源的定义(schame),剩下的就交由ResourceLoader来处理了。

先来看下ResourceLoader的继承结构:

ResourceLoader继承体系

Spring通过ResourceLoader接口来定义其基本的行为:

public interface ResourceLoader {

    // ClassPathResource对应的Url的协议头(前缀)
    String CLASSPATH_URL_PREFIX = ResourceUtils.CLASSPATH_URL_PREFIX;

    // 根据给出的资源Url获取对应的Resource对象
    Resource getResource(String location);

    // 获取当前ResourceLoader所使用的ClassLoader
    ClassLoader getClassLoader();

}

从接口的定义来看,ResourceLoader最主要的行为就是getResource操作。getResource方法接收一个字符串类型参数,通过对字符串参数的解析和处理返回对应的Resource对象,这里的location参数可以包含一定的协议头或前缀来表明参数的来源信息,这些可以使用的协议头和前缀定义在ResourceUtils类中:

    /** Pseudo URL prefix for loading from the class path: "classpath:" */
    public static final String CLASSPATH_URL_PREFIX = "classpath:";

    /** URL prefix for loading from the file system: "file:" */
    public static final String FILE_URL_PREFIX = "file:";

    /** URL protocol for a file in the file system: "file" */
    public static final String URL_PROTOCOL_FILE = "file";

    /** URL protocol for an entry from a jar file: "jar" */
    public static final String URL_PROTOCOL_JAR = "jar";

    /** URL protocol for an entry from a zip file: "zip" */
    public static final String URL_PROTOCOL_ZIP = "zip";

    /** URL protocol for an entry from a JBoss jar file: "vfszip" */
    public static final String URL_PROTOCOL_VFSZIP = "vfszip";

    /** URL protocol for a JBoss VFS resource: "vfs" */
    public static final String URL_PROTOCOL_VFS = "vfs";

    /** URL protocol for an entry from a WebSphere jar file: "wsjar" */
    public static final String URL_PROTOCOL_WSJAR = "wsjar";

    /** URL protocol for an entry from an OC4J jar file: "code-source" */
    public static final String URL_PROTOCOL_CODE_SOURCE = "code-source";

    /** Separator between JAR URL and file path within the JAR */
    public static final String JAR_URL_SEPARATOR = "!/";

例如对一个类路径下的资源spring.xml的表示为:classpath:spring.xml。可以使用前缀,也可以使用标准URL中的协议头(如:http://)的形式。

Spring提供了一个默认的实现DefaultResourceLoader,它可以使用ClassLoader作为参数进行创建。其getResource方法实现如下:

public Resource getResource(String location) {
  Assert.notNull(location, "Location must not be null");
  if (location.startsWith(CLASSPATH_URL_PREFIX)) { // classpath:前缀
    // ClassPathResource需要使用Class或ClassLoader,这里传入ClassLoader
    return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader());
  } else {
    try {
      // Try to parse the location as a URL...
      URL url = new URL(location);
      return new UrlResource(url);
    } catch (MalformedURLException ex) {
      // No URL -> resolve as resource path.
      return getResourceByPath(location); // 如果不符合URL规范,则当做普通路径(如:test/resource.xml)处理
    }
  }
}

从代码中可以看到,对于传入的location参数,显示判断了是否包含classpath:前缀,如果包含则返回ClassPathResource对象,如果不是则通过解析URL的方式解析location参数,如果参数符合URL规范,则创建一个UrlResource,如果资源既不包含classpath:特殊前缀,也不是URL形式,那么就将其当做普通的资源路径传递给getResourceByPath进行处理:

protected Resource getResourceByPath(String path) {
    return new ClassPathContextResource(path, getClassLoader());
}

private static class ClassPathContextResource extends ClassPathResource implements ContextResource {

  public ClassPathContextResource(String path, ClassLoader classLoader) {
      super(path, classLoader);
  }

  public String getPathWithinContext() {
      return getPath();
  }

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

getResourceByPath返回ClassPathContextResource类型,实际上也可以也就是ClassPathResource。可以看出,ResourceLoader中并不判断资源是否真实存在和是否可读写,而仅仅通过解析出传入的location参数返回不同的Resource实现而已,资源是否存在,是否可读,需要调用方在拿到Resource对象后通过Resource提供的方法自行判断。

DefaultResourceLoader的子类大多都只是覆写了getResourceByPath方法,用于在给定的参数既不是classpath:资源也不是UrlResource时需要返回的默认Resource类型。如FileSystemResourceLoader中的getResourceByPath返回FileSystemContextResource对象(继承自FileSystemResource);ClassPathRelativeResourceLoadergetResourceByPath方法返回ClassPathRelativeContextResource(继承自ClassPathResource)。

注意:虽然ClassPathContextResourceClassPathRelaticeContextResource都是继承自ClassPathResource,但是前者使用的ClassLoader来加载资源,后者使用的是Class来加载资源,二者还是有区别的:

  • ClassLoader来加载资源,路径以类路径的根目录为基准(如WEB-INF/classes为基准)。
  • Class来架子资源,资源以Class缩在的包路径为基准(如Web-INF/classes/com/test/resource/)

切记不要搞混淆了。

ResourcePatternResolver

Spring除了提供ResourceLoader之外,还提供了ResourcePatternResolver来扩充ResourceLoader。引进了一个新的前缀:classpath*:。和classpath:的差别就是,classpath*:可以搜索class path下所有满足条件的资源(包括同名的资源),而classpath:则只能返回一个资源(即使存在多个)。

同时,Spring也提供了默认的实现:PathMatchingResourcePatternResource。在它的内部包含一个DefaultResourceLoader的实例,所有ResourceLoader中定义的方法它都委托给内部的DefaultResourceLoader实例进行处理,而自己只负责处理getResources方法的实现。

getResources(locationPattern)
    -> findPathMatchingResources(locationPattern)
        -> doFindPathMatchingFileResources(rootDirResource, subPattern)
            -> doFindMatchingFileSystemResources(rootDir, subPattern)
                -> retrieveMatchingFiles(rootDir, subPattern)
                    -> doRetrieveMatchingFiles(fullPattern, rootDir, result)
    // classpath*:非模式匹配路径
    -> findAllClassPathResources(location)
    // 非classpath*:非模式匹配路劲
    -> getResourceLoader().getResource(locationPattern)

上面列出了getResources方法的大致的执行流程,其中只列举了从文件系统匹配资源的过程。

大概的执行流程如下:

  1. 判断locationPattern是否包含匹配模式:
    • 如果包含则调用findPathMatchingResources方法进行查找。
    • 否则判断locationPattern是否包含classpath*:前缀:
      • 如果包含classpath*:前缀,则调用findAllClassPathResources方法获取所有类路径下对应的资源
      • 否则,调用getResourceLoader().getResource()进行查找
  2. 进入findPathMatchingResources(locationPattern)方法后:
    1. 首先确定rootDirPath,即locationPattern中除开前缀的部分中,截至第一个模式匹配字符为止的目录路径。如:classpath*:spring/test/spring-*.xml,则对应的rootDirPath为:spring/test
    2. 递归调用getResources方法获取rootDirPath下的所有资源。此时会进入到findAllClassPathResources方法中,获取rootDirPath下的所有有效的资源
    3. 遍历上一步返回的资源列表,然后调用doFindPathMatchingFileResources(rootDirResource, subPattern)方法对当前资源和剩余的模式路劲进行匹配,并将返回结果加入到最终的结果集中
  3. doFindPathMatchingFileResources(rootDirResource, subPattern)方法中获取到rootDirResource对应的文件系统目录File对象,然后调用doFindMatchingFileSystemResources(rootDir, subPattern)方法进行匹配并返回匹配结果。
  4. doFindMatchingFileSystemResources(rootDir, subPattern)中调用retrieveMatchingFiles(rootDir, subPattern)方法获取到所有匹配成功的File集合,然后遍历生成FileSystemResource对象并加入到返回结果集中。
  5. retrieveMatchingFiles(rootDir, subPattern)方法中,检测rootDir表示的目录是否有效,无效则忽略。有效则拿到rootDir对应的absolutePath进行处理之后加上subPattern组合成完整的模式匹配路径(如:/home/luojing/webapp/spring/test/spring-*.xml),即最终的fullPattern是包含模式匹配符号的绝对路径。最后调用doRetrieveMatchingFiles(fullPattern, rootDir, result)进行处理。
  6. doRetrieveMatchingFiles(fullPattern, rootDir, result)中获取到rootDir目录下的所有内容(资源),并进行遍历,如果是目录则递归调用doRetrieveMatchingFiles(fullPattern, content, result)方法,如果是普通文件,则调用getPathMatcher().match(fullPattern, currPath)对当前文件的绝对路径(会进行一些格式化处理)和前面生成的fullPattern进行匹配,如果匹配成功则当前文件添加到匹配成功的列表中。
protected void doRetrieveMatchingFiles(String fullPattern, File dir, Set<File> result) throws IOException {
    File[] dirContents = dir.listFiles();
    if (dirContents == null) {
        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);
            }
        }
        if (getPathMatcher().match(fullPattern, currPath)) { // 真正进行匹配
            result.add(content);
        }
    }
}

PathMatcher

这里又引入了一个新的组件:PathMatcherPathMatcher主要用于对基于字符串的路径和指定的模式符号进行匹配。Spring中提供了一个默认的实现:AntPathMatcher`,主要使用 **Ant-style path patterns进行路径匹配:

The mapping matches URLs using the following rules:

  • ? matches one character
  • * matches zero or more characters
  • ** matches zero or more directories in a path

Examples

  • com/t?st.jsp — matches com/test.jsp but also com/tast.jsp or com/txst.jsp
  • com/*.jsp — matches all .jsp files in the com directory
  • com/**/test.jsp — matches all test.jsp files underneath the com path
  • org/springframework/**/*.jsp — matches all .jsp files underneath the org/springframework path
  • org/**/servlet/bla.jsp — matches org/springframework/servlet/bla.jsp but also org/springframework/testing/servlet/bla.jsp and org/servlet/bla.jsp

关于AntPathMatcher的实现原理和细节,等以后有时间了在详细分析。这里先推理猜想一下,根据前面对PathMatchingResourcePatternResolver的分析,后面可以看到在进行对比的时候,实际上是将前面不包含模式匹配符的部分替换成了对应的绝对路径,然后跟遍历获取到的资源文件进行匹配,如:

fullPath:/home/luojing/webapp/spring/test/spring-*.xml

curPath:/home/luojing/webapp/spring/test/spring-redis.xml

可以根据/符号将路径和模式匹配符分割为数组,然后一段一段的进行匹配,只要有一段匹配不成功即认为不匹配。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值