Spring Resource源码分析

作者: VincentWang9 | 来源:发表于2020-04-30 19:53 被阅读0次

    转载请注明出处即可。
    上篇文章描述了阅读Spring源码的相关思考阅读Spring Frameworks源码的思考,在这里就按照文章中所描述的思维方式来进行分析和拆解,并进行一些核心类的解析。

    一、构建Demo项目

    我们先来构建一个简单的项目,
    pom.xml引入了Spring boot,里面的Spring是5.1.9的版本

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>org.example</groupId>
        <artifactId>study</artifactId>
        <version>1.0-SNAPSHOT</version>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.1.8.RELEASE</version>
            <relativePath/>
        </parent>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
        </dependencies>
    
    </project>
    

    然后在项目的resources目录下编写一个bean.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"
           xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    
        <bean id="myTestBean" class="org.study.MyTestBean"/>
    </beans>
    

    在编写一个Bean

    public class MyTestBean {
        private String testStr = "testStr";
    
        public String getTestStr() {
            return testStr;
        }
    
        public void setTestStr(String testStr) {
            this.testStr = testStr;
        }
    }
    

    最后补充一个main

    public class Run {
        public static void main(String[] args) throws Exception {
            ApplicationContext context = new ClassPathXmlApplicationContext("bean.xml");
            MyTestBean bean = (MyTestBean) context.getBean("myTestBean");
            System.out.println(bean.getTestStr());
        }
    }
    

    这应该是使用Spring的最简单的方式之一。如果到这里项目可以执行了,那么就已经可以进入源码阅读的历程了,并不需要去clone框架的源码。

    二、模块拆解

    先看下Run类中的3行代码,目标是搞清楚,这三行的底层是怎么实现的。那么在理清之前,优先应该关注的就是"接口"。因为接口作为框架"对外"提供的"功能描述",很容易通过接口所提供的方法来知晓可以利用的框架功能。并且通过接口还可以在接口的继承链中,或者该接口实现类的属性中(对象组合)找到"模块"的划分,进而根据不同的模块阅读来分别理解Spring的某一项功能的实现原理,整理出模块内部类与类之间的关系,最后在整合出模块与模块之间的关系,并了解他们是如何配合ApplicationContext来提供相关功能的。

    ApplicationContext是这3行代码中唯一的接口。我们看下这个继承链。

    ApplicationContext

    在Java中一般只有两种方式来扩展自身接口或者类的功能,一种是继承另一种是组合,ApplicationContext的继承关系只表明了对外提供了哪些功能,并不代表着这些功能一定是ApplicationContext的实现类来实现的,可能会委托给其他的类。所以在贴一张ClassPathXmlApplicationContext的继承链,当然还缺失了引用的关系,这里只有继承。

    ClassPathXmlApplicationContext
    看到相对较繁琐的继承关系可能已经抓不住头脑了。不过没关系,我们对源码的拆解是一步一步的不会步子跨的太大,可以先对这张图有个基本的概念,后面我们会发现,对于找到对应的实现,这张图还是很有作用的。前面刚说可以在接口的继承链或者,实现类的属性中(对象)找到模块的划分。
    ApplicationContext
    如图所示,把整个ApplicationContext的接口划分为5个模块,并且认为ApplicationContext至少是由这5个模块构成(这里其实没考虑对象组合的那些类)。
    我们在这篇先看下ResourceLoader接口,其他的接口在后续拆解IOC和事件发布的时,在详细看。先找一个切入点进行入手。

    三、ResourceLoader接口详解

    ResourceLoader
    通过接口的名称资源加载器,我们就能知晓接口的功能,无非就是加载资源而已。那么问题来了,一个是什么是资源? 另一个是如何加载资源。要解答这两个问题,接口上的注释和接口的方法其实就是答案了。

    注释上首先提供了3个最重要的信息
    (1) 加载的资源可能在classpath下,也能可能只是文件系统中的一个资源。那么这里其实就可以知晓,所谓的资源就是某一个文件,只是随着加载策略的不同,读取的文件的路径也不同。
    (2) 这个接口的功能是ApplicationContext必须提供的,至于为什么,后面的注释有解释。
    (3) 通过继承于ResourceLoader的接口ResourcePatternResolver提供了更多的功能。
    在这里也可以看到单一职责原则的好处了吧。注释上涵盖的几条信息就已经描述清楚了接口的职责。

    注释还没解释完,但是这里还是要在对上面3个信息多说一些。
    所谓资源就是某一个文件,对于这个描述,如果看过源码的人可能就会喷我了。但是如果没有完整的把所有细节都看完,只是看注释来得出这个结论也无伤大雅。随着阅读量的增加必然的会修正早期阅读理解的错误。

    其他两段注释描述了两个信息
    (1) DefaultResourceLoader可以独立于ApplicationContext来使用,这个实现类也在被ResourceEditor类使用,我们点进去看下这个类,发现构造器上确实new 了一个。

    ResourceEditor
    (2) 在ApplicationContext运行时,如果某个Bean的属性存在Resource或者Resource[],可以通过字符串来使用加载策略。至于只用的什么加载策略,我们后面再看。

    在看下ResourceLoader的接口中的方法和属性

    public interface ResourceLoader {
    
        String CLASSPATH_URL_PREFIX = ResourceUtils.CLASSPATH_URL_PREFIX;
    
        Resource getResource(String location);
    
        @Nullable
        ClassLoader getClassLoader();
    
    }
    

    先看下CLASSPATH_URL_PREFIX属性。顺便看下ResourceUtils

    ResourceUtils
    其实到这里,就可以大概猜出来ResourceLoader的策略模式使如何实现的了。

    在调用getResource(String location)方法时,通过location的不同的前缀来执行不同的Resource获取策略。在查看具体实现之前,先了解下Resource接口,毕竟已经出现在ResourceLoader接口里面了。

    四、Resource接口详解

    对于Resource接口,其实不用多说,毕竟注释已经写得非常清楚了。

    /**
     * Interface for a resource descriptor that abstracts from the actual
     * type of underlying resource, such as a file or class path resource.
     *
     * <p>An InputStream can be opened for every resource if it exists in
     * physical form, but a URL or File handle can just be returned for
     * certain resources. The actual behavior is implementation-specific.
     *
     */
    public interface Resource extends InputStreamSource {
        boolean exists();
    
        default boolean isReadable() {
            return exists();
        }
    
        default boolean isOpen() {
            return false;
        }
    
        default boolean isFile() {
            return false;
        }
    
        URL getURL() throws IOException;
    
        URI getURI() throws IOException;
    
        File getFile() throws IOException;
    
        default ReadableByteChannel readableChannel() throws IOException {
            return Channels.newChannel(getInputStream());
        }
    
        long contentLength() throws IOException;
    
        long lastModified() throws IOException;
    
        Resource createRelative(String relativePath) throws IOException;
    
        @Nullable
        String getFilename();
    
        String getDescription();
    
    }
    

    通过注释总结下来的有几点。
    (1) Resource在Spring中是作为所有资源的抽象。如果要获取某个资源,需要通过ResourceLoaderResourcePatternResolver来进行获取(后面再说两个接口的区别是什么)。
    (2) 资源不一定是一个文件,可以是个网页,也可以只是远程对象存储的一个对象(Spring的实现里面没有,但不妨碍我们自己封装一个Resource的子类)。
    (3) 大部分资源都可以获取资源的InputStream, 当然Resource也继承了InputStreamSource,除非有其他的一些特定的实现以外。

    public interface InputStreamSource {
    
        InputStream getInputStream() throws IOException;
    
    }
    

    我们已经解释了2个接口,并且提到了一个ResourceLoader的实现类。为了加深印象,我们来使用下相关的实现类和接口。

    五、Resource和ResourceLoader的使用

    先看下Resource的实现类。通过类名我们其实可以很清楚的了解大部分实现类的功能。部分理解不了的,可以先放放,后面的源码阅读遇到时,现读也来得及。

    Resource

    我们随便找几个实现类试下,具体实现类不用解释,类型表述的很清楚了。

    Resource fileSystemResource = new FileSystemResource("/Users/wangzedong/Documents/java-project/study/src/main/resources/bean.xml");
    assert fileSystemResource.exists();
    Resource classPathResource = new ClassPathResource("bean.xml");
    assert classPathResource.exists();
    Resource urlResource = new UrlResource("https://www.baidu.com");
    assert urlResource.exists();
    

    其实在使用Spring框架的过程中,如果也有读取资源的情况,不妨用Resource来获取。但是如果资源的种类比较多的话可能就需要加各种判断来区分使用哪个子类。说到这,你是不是又想起来ResourceLoader和它的实现类DefaultResourceLoader了? 没错,这货就是在干这个工作。

    我们来写段代码试下

    DefaultResourceLoader resourceLoader = new DefaultResourceLoader();
    Resource resource = resourceLoader.getResource("/Users/wangzedong/Documents/java-project/study/src/main/resources/bean.xml");
    assert resource instanceof FileSystemResource;
    assert resource.exists();
    resource = resourceLoader.getResource("bean.xml");
    assert resource instanceof ClassPathResource;
    assert resource.exists();
    resource = resourceLoader.getResource("https://www.baidu.com");
    assert resource instanceof UrlResource;
    assert resource.exists();
    

    前两个断言是执行不过去的。后四个是没有问题的,这和DefaultResourceLoader的实现逻辑有关。到这里我们在详细看下DefaultResourceLoader的实现

    六、DefaultResourceLoader的实现

    下面是getResource方法的源码,我们也分为几个块来阅读

    @Override
    public Resource getResource(String location) {
        Assert.notNull(location, "Location must not be null");
          
         // 第一步
        for (ProtocolResolver protocolResolver : this.protocolResolvers) {
            Resource resource = protocolResolver.resolve(location, this);
            if (resource != null) {
                return resource;
            }
        }
    
         // 第二步
        if (location.startsWith("/")) {
            return getResourceByPath(location);
        }
        else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
            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 (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url));
            }
            catch (MalformedURLException ex) {
                // No URL -> resolve as resource path.
                return getResourceByPath(location);
            }
        }
    }
    

    (1) 第一步中,先循环协议解析器,默认情况下协议解析器的集合虽然由容量但是里面并没有相关对象

    private final Set<ProtocolResolver> protocolResolvers = new LinkedHashSet<>(4);
    

    所以可以通过DefaultResourceLoaderaddProtocolResolver方法来添加

    public void addProtocolResolver(ProtocolResolver resolver) {
        Assert.notNull(resolver, "ProtocolResolver must not be null");
        this.protocolResolvers.add(resolver);
    }
    

    其实DefaultResourceLoader仅仅实现了几个策略(协议),甚至连ResourceUtils常量里面的策略(协议)都没有完全实现。为了提高代码的灵活性,这里,使用了集合来处理用户自定义的协议解析规则。我们只需要实现ProtocolResolver接口,并通过addProtocolResolver添加到protocolResolvers集合中就好。因为比较简单就不写具体的示例了,感兴趣的可以自己实现下试试。

    @FunctionalInterface
    public interface ProtocolResolver {
    
        @Nullable
        Resource resolve(String location, ResourceLoader resourceLoader);
    
    }
    

    (2) 第二步判断如果是"/"开头,则使用Resource的实现类ClassPathContextResource。这也解释了前面两个断言为什么执行无法通过。并不是通过FileSystemResource来实现的。

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

    如果是classpath:开头的字符串会通过ClassPathResource来实现,创建之前还去掉了classpath:的前缀。
    在这里在看下ClassPathContextResourceClassPathResource的区别。ClassPathContextResourceClassPathResource的子类。在ClassPatchContextResource的构造器中,必须要传一个ClassLoader,但如果DefaultResourceLoader里面不指定的话,用的都是ClassUtils.getDefaultClassLoader()

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

    (3) 第三步比较简单了,判断下是不是url,然后在判断url指向的是文件,还是远程的地址。并创建相应的对象。url解析失败了还会尝试用ClassPathContextResource来试试。
    到此为止,我们已经把ResourceResourceLoader基本说清楚了,但是别忘了,还有一个ResourcePatternResolver接口需要研究。

    七、ResourcePatternResolver详解

    其实不看注释,只看方法,也能知道是做了读取多个Resource的扩展。并且locationPattern是可以传通配符的。

    public interface ResourcePatternResolver extends ResourceLoader {
    
        String CLASSPATH_ALL_URL_PREFIX = "classpath*:";
    
        Resource[] getResources(String locationPattern) throws IOException;
    
    }
    

    但为了理解源码作者的意图,略过注释可不是一个好习惯。

    ResourcePatternResolver
    注释中没有隐含的信息,所以不做解释。PathMatchingResourcePatternResolverResourcePatternResolver的实现类。

    八、ApplicationContext与ResourceLoader

    到了这一步,先不去看 PathMatchingResourcePatternResolver的实现是怎么样的。通过类名和实现的接口也能猜个七七八八。
    我们把思路回到最初决策模块拆分的那个类图。

    ApplicationContext
    到目前为止,已经理解了ResourceResourceLoaderResourcePatternResolver以及对应的实现类的原理(还剩一个实现类没看)。那么从具体的树木观察走出来,我们看看这片小森林。需要关注下这里个接口之间以及实现类之间有什么关系呢?
    我们先写一个demo
    ApplicationContext context = new ClassPathXmlApplicationContext("bean.xml");
    Resource resource = context.getResource("bean.xml");
    Resource[] resources = context.getResources("bean.xml");
    

    因为ApplicationContext的继承,所以它毫无意外的拥有这两个方法。我们再看下getResources方法实现在了哪里。
    通过idea可以看到,具体逻辑在AbstractApplicationContextgetResources方法。

    @Override
    public Resource[] getResources(String locationPattern) throws IOException {
        return this.resourcePatternResolver.getResources(locationPattern);
    }
    

    在继续在AbstractApplicationContext跟下this.resourcePatternResolver的创建

    /**
    * Create a new AbstractApplicationContext with no parent.
     */
    public AbstractApplicationContext() {
        this.resourcePatternResolver = getResourcePatternResolver();
    }
    
    protected ResourcePatternResolver getResourcePatternResolver() {
        return new PathMatchingResourcePatternResolver(this);
    }
    

    根据构造器和方法,发现AbstractApplicationContext并没有亲力亲为,而是将相关逻辑委托给了PathMatchingResourcePatternResolver
    再看下PathMatchingResourcePatternResolver的构造器, 上面AbstractApplicationContext把this传入了构造器。

    public PathMatchingResourcePatternResolver(ResourceLoader resourceLoader) {
        Assert.notNull(resourceLoader, "ResourceLoader must not be null");
        this.resourceLoader = resourceLoader;
    }
    

    看到这里说明AbstractApplicationContext需要继承ResourceLoader的子类,或者自己实现getResource方法,但是毕竟已经存在了DefaultResourceLoader,无需在复写一遍代码。所以我们可以看到AbstractApplicationContext为了实现ResourceLoader,继承了DefaultResourceLoader

    AbstractApplicationContext

    到这里,我们画下相关的类图,理解下到目前为止的源码。

    uml
    @startuml
    interface ResourceLoader
    interface ResourcePatternResolver
    interface ApplicationContext
    interface ConfigurableApplicationContext
    class DefaultResourceLoader
    abstract class AbstractApplicationContext {
    private ResourcePatternResolver resourcePatternResolver;
    }
    class PathMatchingResourcePatternResolver
    
    
    ResourceLoader <|.. DefaultResourceLoader: 实现
    DefaultResourceLoader <|-- AbstractApplicationContext: 继承
    ResourceLoader <|-- ResourcePatternResolver: 继承
    ResourcePatternResolver <|-- ApplicationContext: 继承
    ApplicationContext <|-- ConfigurableApplicationContext: 继承
    ConfigurableApplicationContext <|.. AbstractApplicationContext: 实现
    ResourcePatternResolver <|.. PathMatchingResourcePatternResolver: 实现
    PathMatchingResourcePatternResolver .. AbstractApplicationContext: 引用
    @enduml
    

    九、PathMatchingResourcePatternResolver详解

    具体的关系理清后,我们最后再看下具体的实现类。
    直接看getResources方法,因为关键实现都在这里。

    PathMatchingResourcePatternResolver
    刚进入代码阅读就会发现一个新家伙。PathMatcher接口, 并且很容易找到实现类。
    PathMatchingResourcePatternResolver
    简单来看其实就是一个路径匹配器,因为方法含义很清楚,并且注释也写的很详细。在这就简单描述下每个方法的作用。
    public interface PathMatcher {
        // 验证路径是否是一个需要匹配的字符串,比如 /**/*.xml。如果不是,只是一个静态路径只需要直接读取即可,无需在判断match
        boolean isPattern(String path);
        // 验证path 和模式(patten)字符串是否匹配
        boolean match(String pattern, String path);
        // 这里代表着前缀匹配,和match方法区别是,如果只是字符串后面匹配了,但是前缀不匹配依然会返回false
        boolean matchStart(String pattern, String path);
        // 这个方法是提取出匹配的部分字符串
        String extractPathWithinPattern(String pattern, String path);
        // 这个方法是用于提取uri变量的, 直接用注释里的例子 : pattern  为 "/hotels/{hotel}" ,
        // 路径为 "/hotels/1", 则该方法会返回一个 map为 : "hotel"->"1".
        Map<String, String> extractUriTemplateVariables(String pattern, String path);
        // 通过path返回一个Comparator, 可以用于排序
        Comparator<String> getPatternComparator(String path);
        // 合并两个模式
        String combine(String pattern1, String pattern2);
    }
    

    然后我们看下子类AntPathMatcher,发现Spring使用的是Apache Ant的样式路径(https://ant.apache.org),具体的实现我觉得无需多言,无非就是字符串的操作,感兴趣的可以看看具体的实现。

    通配符 描述
    ? 匹配任何单字符
    * 匹配0或者任意数量的字符
    ** 匹配0或者更多的目录
    路径 描述
    /app/*.x 匹配(Matches)所有在app路径下的.x文件
    /app/p?ttern 匹配(Matches) /app/pattern 和 /app/pXttern,但是不包括/app/pttern
    /**/example 匹配(Matches) /app/example, /app/foo/example, 和 /example
    /app/**/dir/file 匹配(Matches) /app/dir/file.jsp, /app/foo/dir/file.html,/app/foo/bar/dir/file.pdf, 和 /app/dir/file.java
    /**/*.jsp 匹配(Matches)任何的.jsp 文件

    了解到这里,我们在回头看下getResources方法。

    @Override
    public Resource[] getResources(String locationPattern) throws IOException {
        Assert.notNull(locationPattern, "Location pattern must not be null");
        // 判断是否是classpath*:开头
        if (locationPattern.startsWith(CLASSPATH_ALL_URL_PREFIX)) {
            // a class path resource (multiple resources for same name possible)
            // 判断是否有?和*,以及{},根据前面的描述,如果存在模式,则需要对根目录下的所有资源的path进行match
            if (getPathMatcher().isPattern(locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()))) {
                // a class path resource pattern
                return findPathMatchingResources(locationPattern);
            }
            else {
                // all class path resources with the given name
                /**
                 * 执行到这说明可能是静态字符串,直接找具体的资源就好,或者可能是classpath*:,要找jar的目录
                 * 当然方法名称中还隐含了其他信息,因为如果仅仅是在当前项目下的查找方法名称直接叫做findClassPathResources就好了
                 * 我们可以看下doFindAllClassPathResources的实现,其实也包含jar包。
                 */
                return findAllClassPathResources(locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()));
            }
        }
        else {
            // 以下逻辑和上面类似,不在多说
            // Generally only look for a pattern after a prefix here,
            // and on Tomcat only after the "*/" separator for its "war:" protocol.
            int prefixEnd = (locationPattern.startsWith("war:") ? locationPattern.indexOf("*/") + 1 :
                    locationPattern.indexOf(':') + 1);
            if (getPathMatcher().isPattern(locationPattern.substring(prefixEnd))) {
                // a file pattern
                return findPathMatchingResources(locationPattern);
            }
            else {
                // a single resource with the given name
                // 这里的ResourceLoader,其实就是AbstractApplicationContext
                return new Resource[] {getResourceLoader().getResource(locationPattern)};
            }
        }
    }
    

    然后我们在看下findPathMatchingResources

    protected Resource[] findPathMatchingResources(String locationPattern) throws IOException {
        // 获取根路径, 也就是classpath*:
        String rootDirPath = determineRootDir(locationPattern);
        // 获取子路径, 也就是*.xml
        String subPattern = locationPattern.substring(rootDirPath.length());
        // 通过根路径在此调用getResources, 其实也就是逻辑了jar里面的根路径, 因为isPattern执行是false了
        Resource[] rootDirResources = getResources(rootDirPath);
        // 结果集合
        Set<Resource> result = new LinkedHashSet<>(16);
    
        // 遍历所有的路径
        for (Resource rootDirResource : rootDirResources) {
            // 这步什么都没错,只是一个return, 但是方法是protected的,也就是说这里是个模板方法的模式
            rootDirResource = resolveRootDirResource(rootDirResource);
            URL rootDirUrl = rootDirResource.getURL();
            if (equinoxResolveMethod != null && rootDirUrl.getProtocol().startsWith("bundle")) {
                URL resolvedUrl = (URL) ReflectionUtils.invokeMethod(equinoxResolveMethod, null, rootDirUrl);
                if (resolvedUrl != null) {
                    rootDirUrl = resolvedUrl;
                }
                rootDirResource = new UrlResource(rootDirUrl);
            }
            // 判读是否是vfs前缀
            if (rootDirUrl.getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) {
                result.addAll(VfsResourceMatchingDelegate.findMatchingResources(rootDirUrl, subPattern, getPathMatcher()));
            }
            // 判读是否是jar
            else if (ResourceUtils.isJarURL(rootDirUrl) || isJarResource(rootDirResource)) {
                result.addAll(doFindPathMatchingJarResources(rootDirResource, rootDirUrl, subPattern));
            }
            else {
                // 如果都不是可能只是一个普通文件
                result.addAll(doFindPathMatchingFileResources(rootDirResource, subPattern));
            }
        }
        if (logger.isTraceEnabled()) {
            logger.trace("Resolved location pattern [" + locationPattern + "] to resources " + result);
        }
        return result.toArray(new Resource[0]);
    }
    

    看到这里我们基本已经把核心逻辑捋清了,当然还有部分实现逻辑没有往下继续写,但肯定少不了getPathMatcher().match的调用。比较简单的逻辑就不在这里完全把细节写完了。

    十、总结

    到这里基本已经就把Resource相关的内容看完了,当然可能还有疑问,为什么Spring在ApplicationContext中实现相关功能。其实无论是properties、yaml和xml都是可以作为Resource来进行描述的。在Spring中需要大量的获取资源的的操作。如果每个使用资源的地方都去写一遍类似的逻辑显然是不符合面向对象的设计原则的。所以Spring中通过Resource来抽象了对资源的获取。并通过ResourceLoaderResourcePatternResolver两个接口抽象了资源的加载策略,进而为其他的类提供服务。我们也可以看到Resource相关的接口和实现类都是在Spring的core包中的,具体为org.springframework.core.io,在这点上也可以看到相关的接口和类在Spring功能实现中的重要性。

    参考

    # Spring MVC的路径匹配规则 Ant-style

    相关文章

      网友评论

        本文标题:Spring Resource源码分析

        本文链接:https://www.haomeiwen.com/subject/gcanwhtx.html