SpringMVC 中的静态资源处理

SpringMVC 中的静态资源处理

官方文档

官方文档

参考博客:

Serve Static Resources with Spring | Baeldung

Cachable Static Assets with Spring MVC | Baeldung

关于 ResourceResourceLoader 得源码分析请看《Spring 中的资源获取.md

注册/配置静态资源处理器

《《SpringMVC》- 第十篇:基于注解配置 SpringMVC》中的 addResourceHandlers 小节

静态资源处理器 - ResourceHttpRequestHandler

HttpRequestHandler 接口的子类,处理对静态资源的请求。

ResourceHttpRequestHandler#locations 属性为一个 Resource 类型的列表,该 handler 允许从这些位置提供静态资源。资源可以从类路径位置提供,例如 classpath:/public-web-resources/",这样就允许提供打包在 jar 文件中的诸如 .js.css 等资源。

此 handler 还可以配置 ResourcesResolver 资源解析链和 ResourceTransformer 资源转换链,以支持所服务静态资源的任意解析和转换。默认情况下,PathResourceResolver 只是根据配置的位置信息(Resource 对象)查找静态资源。应用可以配置附加的 ResourcesResolver 解析器和 ResourceTransformer 转换器,例如 VersionResourceResolver,它可以为静态资源解析和准备包含版本信息的 URL。

这些我们后面的实践都会涉及到

此 handler 还正确地评估请求中的 Last-Modified 消息头 (如果存在),以便适当地返回 304 状态码,表示服务器资源未更新,不需要返回新的资源,避免对客户端已经缓存的资源造成不必要的服务器开销。


接下来简单分析一下 ResourceHttpRequestHandler 中的代码,精力有限,我们这里只分析 ResourceHandlerRegistrationResourceHandlerRegistry 中注册的 ResourceHttpRequestHandler 相关的源码分析

首先 ResourceHandlerRegistration#getRequestHandler 的源码

protected ResourceHttpRequestHandler getRequestHandler() {
    ResourceHttpRequestHandler handler = new ResourceHttpRequestHandler();
    if (this.resourceChainRegistration != null) {
        handler.setResourceResolvers(this.resourceChainRegistration.getResourceResolvers());
        handler.setResourceTransformers(this.resourceChainRegistration.getResourceTransformers());
    }
    handler.setLocationValues(this.locationValues);
    if (this.cacheControl != null) {
        handler.setCacheControl(this.cacheControl);
    }
    else if (this.cachePeriod != null) {
        handler.setCacheSeconds(this.cachePeriod);
    }
    handler.setUseLastModified(this.useLastModified);
    return handler;
}

ResourceHandlerRegistration#getRequestHandler 中涉及到的对 ResourceHttpRequestHandler 的自定义。即

再看 ResourceHandlerRegistry#getHandlerMapping 的源码

@Nullable
@SuppressWarnings("deprecation")
protected AbstractHandlerMapping getHandlerMapping() {
    if (this.registrations.isEmpty()) {
        return null;
    }

    Map<String, HttpRequestHandler> urlMap = new LinkedHashMap<>();
    for (ResourceHandlerRegistration registration : this.registrations) {
        for (String pathPattern : registration.getPathPatterns()) {
            ResourceHttpRequestHandler handler = registration.getRequestHandler();
            if (this.pathHelper != null) {
                handler.setUrlPathHelper(this.pathHelper);
            }
            if (this.contentNegotiationManager != null) {
                handler.setContentNegotiationManager(this.contentNegotiationManager);
            }
            handler.setServletContext(this.servletContext);
            handler.setApplicationContext(this.applicationContext);
            try {
                handler.afterPropertiesSet();
            }
            catch (Throwable ex) {
                throw new BeanInitializationException("Failed to init ResourceHttpRequestHandler", ex);
            }
            urlMap.put(pathPattern, handler);
        }
    }

    return new SimpleUrlHandlerMapping(urlMap, this.order);
}

ResourceHandlerRegistry#getHandlerMapping 中对 ResourceHttpRequestHandler 的自定义

ResourceHandlerRegistry#getHandlerMapping 中,调用 ResourceHttpRequestHandler#afterPropertiesSet 之前,以及设置了这么多属性

接下来,我们看 ResourceHttpRequestHandler#afterPropertiesSet 接口做了什么

除了 useLastModified 是 ResourceHttpRequestHandler 中的字段,CacheControlcacheSeconds 都是 ResourceHttpRequestHandler 继承的 WebContentGenerator 中的字段,用于设置响应的跟缓存相关的消息头,直到是什么就可以了,这里不深入了解

实现了 InitializingBean 接口

主要做三件事:

源码分析

先是将资源路径字符串列表(locationValues)转换 Resource 列表(locations),然后开始初始化资源解析器列表(主要是准备 PathResourceResolver 类型的解析器)和资源转换器列表,最后将这两个列表分别转化为资源解析链对象和资源转换链对象。

public void afterPropertiesSet() throws Exception {
    // locationValues -> locations
    // 将资源路径字符串列表转换Resource列表
    resolveResourceLocations();

    if (logger.isWarnEnabled() && CollectionUtils.isEmpty(this.locations)) {
        logger.warn("Locations list is empty. No resources will be served unless a " +
                "custom ResourceResolver is configured as an alternative to PathResourceResolver.");
    }

    // 如果资源解析器列表为空,添加默认的资源解析器  PathResourceResolver,
    // 不得不说,PathResourceResolver 的出镜率是真的高
    if (this.resourceResolvers.isEmpty()) {
        this.resourceResolvers.add(new PathResourceResolver());
    }

    // 只用于处理 PathResourceResolver类型的解析器
    // 将转化出来的Resource列表初始化到资源解析器列表中所有的 allowedLocations字段为空的 PathResourceResolver类型的解析器  的allowedLocations字段中
    // 资源解析器列表中一般只有一个 PathResourceResolver类型的解析器,因为也只需要一个
    initAllowedLocations();

    // Initialize immutable resolver and transformer chains
    // 将资源解析器列表转化为资源解析链对象
    this.resolverChain = new DefaultResourceResolverChain(this.resourceResolvers);
    // 将资源转换器列表转化为资源转换链对象,同时还传入了资源解析链对象作为参数
    this.transformerChain = new DefaultResourceTransformerChain(this.resolverChain, this.resourceTransformers);

    if (this.resourceHttpMessageConverter == null) {
        this.resourceHttpMessageConverter = new ResourceHttpMessageConverter();
    }
    if (this.resourceRegionHttpMessageConverter == null) {
        this.resourceRegionHttpMessageConverter = new ResourceRegionHttpMessageConverter();
    }

    ContentNegotiationManager manager = getContentNegotiationManager();
    if (manager != null) {
        setMediaTypes(manager.getMediaTypeMappings());
    }

    @SuppressWarnings("deprecation")
    org.springframework.web.accept.PathExtensionContentNegotiationStrategy strategy =
            initContentNegotiationStrategy();
    if (strategy != null) {
        setMediaTypes(strategy.getMediaTypes());
    }
}   

其中,我们先看看 resolveResourceLocations,具体是怎么转换的,其实主要还是调用 ApplicationContextgetResource 方法获取 Resource 对象, 而ApplicationContext 的 getResource 方法本身就支持三种格式的路径:web 应用程序根目录、编译输出路径、系统文件路径

Web 应用中 ApplicationContext 的具体实现类为 AnnotationConfigWebApplicationContextgetResource 方法继承自 DefaultResourceLoader

转换成功后,可以通过 getLocations 方法获取查找目的地资源列表,getLocations 方法会在后面的 ResourceHttpRequestHandler#getResource 中用到。

private void resolveResourceLocations() {
    // 判断 locationValues 路径字符串是否为空
    if (CollectionUtils.isEmpty(this.locationValues)) {
        return;
    }
    else if (!CollectionUtils.isEmpty(this.locations)) {
        throw new IllegalArgumentException("Please set either Resource-based \"locations\" or " +
                "String-based \"locationValues\", but not both.");
    }

    ApplicationContext applicationContext = obtainApplicationContext();
    // 依次遍历路径字符串,转换成Resource资源对象
    for (String location : this.locationValues) {
        // 一般未配置 embeddedValueResolver
        if (this.embeddedValueResolver != null) {
            String resolvedLocation = this.embeddedValueResolver.resolveStringValue(location);
            if (resolvedLocation == null) {
                throw new IllegalArgumentException("Location resolved to null: " + location);
            }
            location = resolvedLocation;
        }
        Charset charset = null;
        location = location.trim();
        // 一般路径也不会以URL_RESOURCE_CHARSET_PREFIX开头 PS:URL_RESOURCE_CHARSET_PREFIX = "[charset=" 
        if (location.startsWith(URL_RESOURCE_CHARSET_PREFIX)) {
            int endIndex = location.indexOf(']', URL_RESOURCE_CHARSET_PREFIX.length());
            if (endIndex == -1) {
                throw new IllegalArgumentException("Invalid charset syntax in location: " + location);
            }
            String value = location.substring(URL_RESOURCE_CHARSET_PREFIX.length(), endIndex);
            charset = Charset.forName(value);
            location = location.substring(endIndex + 1);
        }
        // 所以最终,还是通过 ApplicationContext的getResource 方法获取资源对象
        // 而 ApplicationContext 的 getResource 方法本身就支持三种格式的路径:web应用程序根目录、编译输出路径、系统文件路径
        // Web应用中 ApplicationContext 的具体实现类为 AnnotationConfigWebApplicationContext, getResource 方法继承自 DefaultResourceLoader
        Resource resource = applicationContext.getResource(location);
        this.locations.add(resource);
        if (charset != null) {
            if (!(resource instanceof UrlResource)) {
                throw new IllegalArgumentException("Unexpected charset for non-UrlResource: " + resource);
            }
            this.locationCharsets.put(resource, charset);
        }
    }
}

ResourceResolverChain 和 ResourceTransformerChain

现在,我们专门来看看 ResourceResolverChainResourceTransformerChain 到底是什么东西。

ResourceResolverChain 接口:

public interface ResourceResolverChain {

    //  根据请求对象和URL中包含的资源路径和服务器上的可供查找资源路径列表(即指定查找范围)来查找资源
    @Nullable
    Resource resolveResource(@Nullable HttpServletRequest request, String requestPath, List<? extends Resource> locations);

    // 将给定的内部资源路径解析成面向外部的公共URL路径,以便客户机用于访问位于给定内部资源路径上的资源。
    // 这在渲染到客户端的URL链接时非常有用。
    // 在 ResourceUrlProvider 中有所使用
    @Nullable
    String resolveUrlPath(String resourcePath, List<? extends Resource> locations);

}

ResourceResolverChain 只有一个实现 DefaultResourceResolverChainDefaultResourceResolverChain 实际上是一个单向链表的节点类,字段 nextChain 保存着下一个节点(DefaultResourceResolverChain)的指针。

class DefaultResourceResolverChain implements ResourceResolverChain {

    // 当前链表节点的资源解析器 
    @Nullable
    private final ResourceResolver resolver;

    // 当前链表节点的下一个链表节点
    @Nullable
    private final ResourceResolverChain nextChain;

    // 用于通过  List<? extends ResourceResolver> resolvers 初始化  DefaultResourceResolverChain节点组成的链表 的三个方法
    // 其中,initChain 方法主要通过倒叙迭代构建 DefaultResourceResolverChain节点组成的链表的方法很棒
    ......

    @Override
    @Nullable
    public Resource resolveResource(
            @Nullable HttpServletRequest request, String requestPath, List<? extends Resource> locations) {
        // 直接将解析工作委托给当前链表节点中的资源解析器,同时增加一个参数:下一个资源处理链节点,方便资源解析器在解析失败的时候调用下一个资源处理链节点中的资源解析器进行处理
        return (this.resolver != null && this.nextChain != null ?
                this.resolver.resolveResource(request, requestPath, locations, this.nextChain) : null);
    }

    @Override
    @Nullable
    public String resolveUrlPath(String resourcePath, List<? extends Resource> locations) {
        // 直接将解析工作委托给当前链表节点中的资源解析器,同时增加一个参数:下一个资源处理链节点,方便资源解析器在解析失败的时候调用下一个资源处理链节点中的资源解析器进行处理
        return (this.resolver != null && this.nextChain != null ?
                this.resolver.resolveUrlPath(resourcePath, locations, this.nextChain) : null);
    }

}

ResourceTransformerChain 接口:

public interface ResourceTransformerChain {

    //  返回相关的资源解析链对象,用于解析被转换的资源,
    // 当需要被转换的资源中有对其他资源的链接的时候,这个其他资源就需要用资源解析链对象解析一下
    ResourceResolverChain getResolverChain();

    // 传入请求对象和带转换的资源对象返回转换过后的资源对象
    Resource transform(HttpServletRequest request, Resource resource) throws IOException;

}

ResourceTransformerChain 只有一个实现 DefaultResourceTransformerChainDefaultResourceTransformerChain 实际上是一个单向链表的节点,字段 nextChain 保存着下一个节点(DefaultResourceTransformerChain)的指针。

class DefaultResourceTransformerChain implements ResourceTransformerChain {

    // 资源解析链对象 
    // 注意,资源转换链中所有节点使用的都是同一个资源解析对象,即调用构造函数的的时候传入的资源解析链对象
    private final ResourceResolverChain resolverChain;

    // 当前资源转换链节点的资源转换器 
    @Nullable
    private final ResourceTransformer transformer;

    // 当前资源转换链节点的下一个资源转换链节点
    @Nullable
    private final ResourceTransformerChain nextChain;

    // 用于通过 ResourceResolverChain resolverChain 和 List<? extends ResourceTransformer> resolvers 初始化  DefaultResourceTransformerChain节点组成的链表 的三个方法
    // initTransformerChain 方法主要通过倒叙迭代构建 DefaultResourceTransformerChain节点组成的链表的方法很棒
    ......

    @Override
    public ResourceResolverChain getResolverChain() {
        return this.resolverChain;
    }

    @Override
    public Resource transform(HttpServletRequest request, Resource resource) throws IOException {
        // 直接将转换工作委托给当前链表节点中的资源转换器,同时增加一个参数:下一个资源钻换链节点,方便资源转换器在转换完成之后调用下一个资源转换链节点中的资源转换器进行处理
        return (this.transformer != null && this.nextChain != null ?
                this.transformer.transform(request, resource, this.nextChain) : resource);
    }

}

ResourceResolver

ResourceResolverChain 接口方法基本上一模一样,只是两个方法都多了一个 ResourceResolverChain 参数,用于在当前节点的资源解析器解析失败的时候调用下一个 ResourceResolverChain 节点的资源解析器继续解析。

public interface ResourceResolver {

    // 根据请求对象和URL中包含的资源路径和服务器上的可供查找资源路径列表(即指定查找范围)来查找资源
    @Nullable
    Resource resolveResource(@Nullable HttpServletRequest request, String requestPath, List<? extends Resource> locations, ResourceResolverChain chain);

    // 将给定的内部资源路径解析成面向外部的公共URL路径,以便客户机用于访问位于给定内部资源路径上的资源。
    // 这在渲染到客户端的URL链接时非常有用。
    // 在 ResourceUrlProvider 中有所使用
    @Nullable
    String resolveUrlPath(String resourcePath, List<? extends Resource> locations, ResourceResolverChain chain);

}

ResourceResolver 的实现类,也不多,就这几个

ResourceTransformer

ResourceTransformer 是函数式接口,跟 ResourceTransformerChain 接口相比,少了一个 getResolverChain() 方法,此外 transform 方法多了一个 ResourceTransformerChain 参数,用于在当前节点的资源转换器转换完成之后调用下一个 ResourceTransformerChain 节点的资源转换器继续进行处理。

@FunctionalInterface
public interface ResourceTransformer {

    // 传入请求对象和带转换的资源对象返回转换过后的资源对象
    Resource transform(HttpServletRequest request, Resource resource, ResourceTransformerChain transformerChain) throws IOException;

}

ResourceTransformer 的实现类也不多,就这几个,ResourceTransformerSupport 很重要,帮助快速搭建 ResourceTransformer 实现类

实现 HttpRequestHandler 接口

HttpRequestHandler 接口

只有一个方法,处理请求,返回响应,简单粗暴

@FunctionalInterface
public interface HttpRequestHandler {

    // 处理请求,返回响应
    void handleRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException;

}
调用链

HttpRequestHandler 类型的 handler(处理器)的专用适配器是 HttpRequestHandlerAdapterHttpRequestHandlerAdapter 直接将对 HandlerAdapter 接口的实现委托给 HttpRequestHandler

public class HttpRequestHandlerAdapter implements HandlerAdapter {

    @Override
    public boolean supports(Object handler) {
        // 仅支持 HttpRequestHandler 类型的 handler
        return (handler instanceof HttpRequestHandler);
    }

    @Override
    @Nullable
    public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 将 handler 强制转化为 HttpRequestHandler ,然后转手就是委托给 HttpRequestHandler#handleRequest
        ((HttpRequestHandler) handler).handleRequest(request, response);
        return null;
    }

    @Override
    public long getLastModified(HttpServletRequest request, Object handler) {
        if (handler instanceof LastModified) {
            return ((LastModified) handler).getLastModified(request);
        }
        return -1L;
    }

}

DispatcherServlet#doDispatch 中,HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); 通过此适配器 HttpRequestHandlerAdapter 调用 HttpRequestHandler 接口的实现类也就是 ResourceHttpRequestHandlerhandleRequest 方法。

ResourceHttpRequestHandler 实现

实际执行流程也很简单:ResourceHttpRequestHandler#handleRequest 一开始就调用 ResourceHttpRequestHandler#getResourceResourceHttpRequestHandler#getResource 中调用资源解析链 resolverChain 和资源转换链 transformerChain 来查找资源,解析资源。然后开始进行 HTTP 缓存验证,主要是 Last-Modified,最后如果需要返回新的资源,设置响应的消息头。

ResourceHttpRequestHandler#handleRequest

@Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

    // For very general mappings (e.g. "/") we need to check 404 first
    // 如果使用了 VersionResourceResolver,在这里生效
    // 因此如果版本信息更新了,但是客户端访问老版本的资源,那么会找不到,即这里就会404
    Resource resource = getResource(request);
    if (resource == null) {
        logger.debug("Resource not found");
        // HttpServletResponse.SC_NOT_FOUND 就是我们常见的404
        response.sendError(HttpServletResponse.SC_NOT_FOUND);
        return;
    }

    // 处理 HTTP请求方法为 HttpMethod.OPTIONS 的请求 
    if (HttpMethod.OPTIONS.matches(request.getMethod())) {
        response.setHeader("Allow", getAllowHeader());
        return;
    }

    // Supported methods and required session
    // 检查HTTP请求方法是否是指定的方法,已经当前请求是否处于服务器已经存在的session中
    // 不满足条件的话就报错
    // 跟WebContentGenerator有关,不深入了解
    checkRequest(request);

    // Header phase
    // 如果使用 Last-Modified 消息头,则判断资源时间
    if (isUseLastModified() && new ServletWebRequest(request, response).checkNotModified(resource.lastModified())) {
        logger.trace("Resource not modified");
        return;
    }

    // Apply cache settings, if any
    // 做完了所有的检查,准备返回新静态资源 
    // 先进行跟缓存相关的消息头的设置,跟WebContentGenerator有关,不深入了解
    prepareResponse(response);

    // Check the media type for the resource
    MediaType mediaType = getMediaType(request, resource);
    setHeaders(response, resource, mediaType);

    // Content phase
    ServletServerHttpResponse outputMessage = new ServletServerHttpResponse(response);
    if (request.getHeader(HttpHeaders.RANGE) == null) {
        Assert.state(this.resourceHttpMessageConverter != null, "Not initialized");
        this.resourceHttpMessageConverter.write(resource, mediaType, outputMessage);
    }
    else {
        Assert.state(this.resourceRegionHttpMessageConverter != null, "Not initialized");
        ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(request);
        try {
            List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
            response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
            this.resourceRegionHttpMessageConverter.write(
                    HttpRange.toResourceRegions(httpRanges, resource), mediaType, outputMessage);
        }
        catch (IllegalArgumentException ex) {
            response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
            response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
        }
    }
}

再仔细看看 ResourceHttpRequestHandler#getResource

@Nullable
protected Resource getResource(HttpServletRequest request) throws IOException {
    // 从请求域的属性找到路径
    // AbstractHandlerMapping的initLookupPath方法中初始化的,这个我们在研究 
    // 1. UrlPathHelper+PathMatcher简单解析
    // 2. PathContainer+PathPatternParser+PatternParser系列接口简单解析
    // 的时候都研究过 
    // 也可能是由 PathExposingHandlerInterceptor 暴露的
    String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
    if (path == null) {
        throw new IllegalStateException("Required request attribute '" +
                HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE + "' is not set");
    }
    // 清除路径中的不规则元素,剔除非法的URL
    path = processPath(path);
    if (!StringUtils.hasText(path) || isInvalidPath(path)) {
        return null;
    }
    if (isInvalidEncodedPath(path)) {
        return null;
    }

    Assert.notNull(this.resolverChain, "ResourceResolverChain not initialized.");
    Assert.notNull(this.transformerChain, "ResourceTransformerChain not initialized.");

    // 先按照资源解析链解析,解析完了,获取了最终的资源对象
    // 再开始资源转换链的转化
    // 会用到前面 afterPropertiesSet 方法中 resolveResourceLocations 从路径列表 locationValues 中转化而来的 Resource 列表
    Resource resource = this.resolverChain.resolveResource(request, path, getLocations());
    if (resource != null) {
        // 开始资源转换链的转化
        resource = this.transformerChain.transform(request, resource);
    }
    return resource;
}

实践

跟 Web 配置相关的源码细节,请看

《《SpringMVC》- 第十篇:基于注解配置 SpringMVC》中的 addResourceHandlers 小节

常规使用

新建 Web 资源

src/main/webapp 下新建 resourcesSimple 文件夹,在 src/main/resources 下新建 staticSimple 文件夹,下面放一下静态资源

Web 配置

addResourceHandler 方法也可以添加多个路径,只不过我们一般只添加一个。此返回 ResourceHandlerRegistration

addResourceLocations 添加资源位置,可以添加 war 包的根目录中的位置(默认)、编译输出路径(classpath: 开头)、系统文件路径(file: 开头)如果有多个路径匹配,排在前面的先匹配,这是 PathResourceResolvergetResource 方法决定的。 注意,路径要以 / 结尾,不然报错。此返回 ResourceHandlerRegistration

setCacheControl 设置响应的的消息头的 Cache-Control,用于设置第一次获取此资源的时候的响应的消息头的 Cache-Control,下文中的配置对应的消息头为 Cache-Control:max-age=20 单位是秒,表示此静态资源在浏览器缓存中的保留时间是 20s,20s 内浏览器访问此资源(新开一个 tab 页,将 URL 复制过来然后回车,不按 F5 或者 Ctrl+F5),不会请求服务器,20s 后,浏览器缓存过期,才会请求服务器获取新的资源。此返回 ResourceHandlerRegistration

一般我们设置这三个方法,就够用了。

当然你还可以调用 resourceChain,启用或者不启用资源解析缓存或者资源转换缓存。一般生产环境建议开启,调试环境不建议开启

@Configuration
//扫描组件
@ComponentScan("xyz.xiashuo.springmvcannotationconfig")
//开启MVC注解驱动
@EnableWebMvc
@PropertySource("classpath:application.properties")
@Order(1)
public class WebConfig implements WebMvcConfigurer {


    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 常规的静态资源处理器映射
        // 效果等同于 xml中的配置
        //<mvc:resources mapping="/resources/**" location="/resources, classpath:/static/" cache-period="20" />
        // 添加静态资源映射路径
                // addResourceHandler 方法可以添加多个路径,只不过我们一般只添加一个。
        registry.addResourceHandler("/resourcesSimple/**")
                // addResourceLocations 添加资源位置,可以添加war包的根目录中的位置(默认)、编译输出路径(classpath:开头)、系统文件路径(file:开头)
                // 如果有多个路径匹配,排在前面的先匹配,这是 PathResourceResolver 的 getResource 方法决定的
                // 注意,路径要以 / 结尾,不然报错
                .addResourceLocations("/resourcesSimple/", "classpath:/staticSimple/", "file:/C:\\test/")
                // setCacheControl 设置响应的的消息头的Cache-Control
                .setCacheControl(CacheControl.maxAge(20, TimeUnit.SECONDS));
        WebMvcConfigurer.super.addResourceHandlers(registry);
    }

}

访问链接 http://localhost:8080/SpringMVC_AnnotationConfig/resourcesSimple/images/aaa.jpg

这个资源在 src/main/webapp/resourcesSimplesrc/main/resources/staticSimple 下都有,但是 src/main/webapp/resourcesSimple 排在前面,所以返回 src/main/webapp/resourcesSimple 中的资源,

第一次在 15:58:30 请求之后,因为我们配置了缓存时间为 20s,所以虽然在 15:58:3015:58:50 之间我请求了多次(新开一个 tab 页,将 URL 复制过来然后回车,不按 F5 或者 Ctrl+F5),但是使用的都是浏览器缓存,服务器并没有被请求,浏览器缓存失效后,访问服务器,同时由于资源没有改变,因此最终服务器返回的也是 304,表示资源并没有改变,继续用之前的即可,然后浏览器再次缓存 20s,接下来的 20s 如果再请求,仍然会使用浏览器资源,直到缓存超时,再次访问服务器,如此循环。

15:58:30.114 [http-nio-8080-exec-4] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesSimple/images/aaa.jpg", parameters={}
15:58:30.115 [http-nio-8080-exec-4] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["/resourcesSimple/", "classpath:/staticSimple/", "file:/C:\test/"]
15:58:30.117 [http-nio-8080-exec-4] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 200 OK
15:58:50.378 [http-nio-8080-exec-8] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesSimple/images/aaa.jpg", parameters={}
15:58:50.379 [http-nio-8080-exec-8] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["/resourcesSimple/", "classpath:/staticSimple/", "file:/C:\test/"]
15:58:50.381 [http-nio-8080-exec-8] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 304 NOT_MODIFIED

如果文件有所更改,就会返回新文件

19:19:53.684 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesSimple/images/aaa.jpg", parameters={}
19:19:53.684 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["/resourcesSimple/", "classpath:/staticSimple/", "file:/C:\test/"]
19:19:53.684 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 304 NOT_MODIFIED
19:20:56.361 [http-nio-8080-exec-6] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesSimple/images/aaa.jpg", parameters={}
19:20:56.362 [http-nio-8080-exec-6] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["/resourcesSimple/", "classpath:/staticSimple/", "file:/C:\test/"]
19:20:56.363 [http-nio-8080-exec-6] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 200 OK

新开一个 tab 页,将 URL 复制过来然后回车和直接按 F5 或者 Ctrl+F5 有什么区别?

背景知识:HTTP 缓存

Cache-Control: 在 HTTP 消息头中,用于指示代理和 UA 使用何种缓存策略, 表示了资源是否可以被缓存,以及缓存的有效期。

当 Cache-Control 为可缓存时,同时可指定缓存时间(比如 public, max-age:86400)。 这意味着在 1 天(60x60x24=86400)时间内,浏览器都可以直接使用该缓存(此时服务器收不到任何请求)。

Last-Modified :表示资源的最后修改时间,如果请求资源的请求的响应的消息头中带有 Last-Modified ,下一次请求此资源的时候就会带上 If-modified-since 的消息头 。与服务器上的资源的最新的修改时间进行比较,如果请求消息头中的时间在资源最新的需改时间之前,则表示浏览器缓存过期,服务器返回最新的资源,如果请求消息头中的时间和资源最新的需改时间相同或者在其之后,则表示浏览器缓存还是最新的,不需要更新,服务器会直接返回 304,意思是没事儿,让浏览器继续用之前的缓存。

这部分代码,在 ResourceHttpRequestHandler#handleRequest 方法中写的清清楚楚

Cache-Control 和 Last-Modified 一起生效,当对静态资源发起请求的时候,浏览器首先会检查 Cache-Control,看浏览器本地是否有缓存,同时缓存有没有过期,如果缓存没有过期,那么直接使用缓存,这个时候不会发起对服务器的请求,如果 Cache-Control 已经过期,则直接发起对服务器的请求,同时带上 If-modified-since 的消息头 。与服务器上的资源的最新的修改时间进行比较,如果请求消息头中的时间在资源最新的需改时间之前,则表示浏览器缓存过期,服务器返回最新的资源,如果请求消息头中的时间和资源最新的需改时间相同或者在其之后,则表示浏览器缓存还是最新的,不需要更新,服务器会直接返回 304,意思是没事儿,让浏览器继续用之前的缓存。

这里需要注意

当使用 F5 刷新请求资源的 URL 的时候,请求头会带上

Cache-Control: max-age=0

表示浏览器缓存强制过期(强制 Cache-Control 失效),直接发起对服务器的请求,此时 Last-Modified 还是有效的,如果请求消息头中的 If-modified-since 时间和资源最新的需改时间相同或者在其之后,依然会返回 304

当使用 Ctrl+F5 刷新请求资源的 URL 的时候,请求头会带上

Cache-Control: no-cache

表示不使用缓存,此时 Cache-Control 和 Last-Modified 都失效,服务器直接返回服务器中的资源内容。

勾选 Network 下的 Disable cache 也是一样的效果:


其他跟浏览器缓存相关的 HTTP 消息头

Etag : 消息头中代表资源的唯一标识标签,在服务器端生成。如果响应的消息头中带有 Etag ,下一次请求同一个资源的时候在消息头带 Etag ,如果 Etag 没有变化,将收到 304 的响应,从缓存中读取。

Etag 在使用时要注意相同资源多台 Web 服务器的 Etag 的一致性。

Expire 是消息头中代表资源的过期时间,由服务器段设置。如果响应的消息头带有 Expire ,则在 Expire 过期前不会发生 Http 请求,直接从缓存中读取。用户强制 F5 或者 Ctrl+F5 例外。

Last-Modified,Etag,Expires 三个同时使用时。先判断 Expire ,然后发送 Http 请求,服务器先判断 last-modified ,再判断 Etag ,必须都没有过期,才能返回 304 响应。

注意,此时的 ResourceHttpRequestHandler#resolverChain,只有一个 PathResourceResolver

ResourceHttpRequestHandler#transformerChain,为空

带版本信息的资源请求

src/main/resources 下新建 staticVersion 文件夹,下面放一下静态资源

在代替 Web.xml 的 Java 配置中添加过滤器 ResourceUrlEncodingFilter,用于自动更新页面上对静态资源的访问。

@Override
protected Filter[] getServletFilters() {
    CharacterEncodingFilter encodingFilter = new CharacterEncodingFilter();
    encodingFilter.setEncoding("UTF-8");
    encodingFilter.setForceRequestEncoding(true);
    encodingFilter.setForceResponseEncoding(true);
    HiddenHttpMethodFilter hiddenHttpMethodFilter = new HiddenHttpMethodFilter();

    // 用于自动更新资源URL后面的版本信息
    ResourceUrlEncodingFilter resourceUrlEncodingFilter = new ResourceUrlEncodingFilter();
    return new Filter[]{encodingFilter, hiddenHttpMethodFilter,resourceUrlEncodingFilter};
}

Web 配置

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    // 常规的静态资源处理器映射
    // 效果等同于 xml中的配置
    //<mvc:resources mapping="/resources/**" location="/resources, classpath:/static/" cache-period="20" />
    // 添加静态资源映射路径
            // addResourceHandler 方法可以添加多个路径,只不过我们一般只添加一个。
    registry.addResourceHandler("/resourcesSimple/**")
            // addResourceLocations 添加资源位置,可以添加war包的根目录中的位置(默认)、编译输出路径(classpath:开头)、系统文件路径(file:开头)
            // 如果有多个路径匹配,排在前面的先匹配,这是 PathResourceResolver 的 getResource 方法决定的
            //注意,路径要以 / 结尾,不然报错
            .addResourceLocations("/resourcesSimple/", "classpath:/staticSimple/", "file:/C:\\test/")
            // setCacheControl 设置响应的的消息头的Cache-Control
            .setCacheControl(CacheControl.maxAge(20, TimeUnit.SECONDS));
    // 为资源添加版本信息
    registry.addResourceHandler("/resourcesVersion/**")
            .addResourceLocations("classpath:/staticVersion/")
            .setCacheControl(CacheControl.maxAge(20, TimeUnit.SECONDS))
            // resourceChain 唯一的参数 表示是否添加缓存,将静态资源解析的结果缓存起来,生产环境建议设置为true,开发环境设置为false
            .resourceChain(false)
            // 添加版本信息解析器
            // 为所有路径指定基于内容的版本字符串生成器
            .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"))
            // 添加 Css 转换器,专门用于处理,在开启静态资源的URL版本的时候,css文件中通过 @import "another.css" 的情况,可以将 @import 的css的路径也假设版本信息
            // 也可以不添加,系统会默认添加。
            .addTransformer(new CssLinkResourceTransformer());
    WebMvcConfigurer.super.addResourceHandlers(registry);
}

启动项目之后,刷新页面,你会发现,页面上对静态资源的访问链接,都加上了版本信息后缀,例如 /SpringMVC_AnnotationConfig/resourcesVersion/js/echarts.js 变成了 /SpringMVC_AnnotationConfig/resourcesVersion/js/echarts-205ce38d25df47b43cc2e1f4fa4ef354.js

第一次获取静态资源之后,20s 内新开一个 tab 页,将 URL 复制过来然后回车,仍然也是不会访问服务器的,缓存失效之后,再次访问,仍然会返回 304,然后浏览器再次缓存 20s,接下来的 20s 如果再请求,仍然会使用浏览器资源,直到缓存超时,再次访问服务器,如此循环。

19:21:42.789 [http-nio-8080-exec-4] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesVersion/js/echarts-205ce38d25df47b43cc2e1f4fa4ef354.js", parameters={}
19:21:42.789 [http-nio-8080-exec-4] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["classpath:/staticVersion/"]
19:21:42.798 [http-nio-8080-exec-4] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 304 NOT_MODIFIED
19:22:02.793 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesVersion/js/echarts-205ce38d25df47b43cc2e1f4fa4ef354.js", parameters={}
19:22:02.794 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["classpath:/staticVersion/"]
19:22:02.806 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 304 NOT_MODIFIED

但是跟常规使用不一样的是,如果在访问服务器的时候,服务器的静态资源已经更改,服务器不会返回新的资源,而是直接报错,需要刷新请求资源的 URL 所在的页面获取新的 URL,即获取新的版本号,才能正常访问。

修改资源,不刷新 URL 所在的页面,访问服务器

19:22:39.147 [http-nio-8080-exec-6] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesVersion/js/echarts-205ce38d25df47b43cc2e1f4fa4ef354.js", parameters={}
19:22:39.147 [http-nio-8080-exec-6] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["classpath:/staticVersion/"]
19:22:39.161 [http-nio-8080-exec-6] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 304 NOT_MODIFIED
19:23:05.204 [http-nio-8080-exec-8] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesVersion/js/echarts-205ce38d25df47b43cc2e1f4fa4ef354.js", parameters={}
19:23:05.205 [http-nio-8080-exec-8] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["classpath:/staticVersion/"]
19:23:05.224 [http-nio-8080-exec-8] DEBUG org.springframework.web.servlet.resource.ResourceHttpRequestHandler - Resource not found
19:23:05.225 [http-nio-8080-exec-8] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 404 NOT_FOUND

刷新 URL 所在的页面,并访问服务器

19:24:34.812 [http-nio-8080-exec-10] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/", parameters={}
19:24:34.812 [http-nio-8080-exec-10] DEBUG org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping - Mapped to xyz.xiashuo.springmvcannotationconfig.controller.HelloController#index()
19:24:34.850 [http-nio-8080-exec-10] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 200 OK
19:24:36.103 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.DispatcherServlet - GET "/SpringMVC_AnnotationConfig/resourcesVersion/js/echarts-b1706f4f6701fb8f28dd37a8f8330095.js", parameters={}
19:24:36.104 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.handler.SimpleUrlHandlerMapping - Mapped to ResourceHttpRequestHandler ["classpath:/staticVersion/"]
19:24:36.140 [http-nio-8080-exec-1] DEBUG org.springframework.web.servlet.DispatcherServlet - Completed 200 OK

对比一下可以发现,刷新访问静态资源的 URL 所在的页面之后,URL 中包含的静态资源的版本号也更新了。这个时候才能正常访问资源。

这个是由于 ResourceHttpRequestHandler#handleRequest 决定的,这个方法一上来就解析请求的 URL,版本号变了,带有原来版本号的 URL 是查不到资源的,所以直接报错.

直接在新 tab 栏输入不带版本信息的链接,始终返回最新的版本的内容,这在 VersionResourceResolverresolveResourceInternal 的源码分析的一开始就说明了这一点

所以判断流程是,浏览器本地缓存是否过期 -> 文件版本是否更新 -> 资源更新时间(LastModified)

此时的 ResourceHttpRequestHandler#resolverChain,有两个资源解析器,VersionResourceResolverPathResourceResolver

ResourceHttpRequestHandler#transformerChain,只有一个资源转换器 CssLinkResourceTransformer

简单应用 - 静态资源的大版本区分

在配置文件 application.properties 中引入版本变量

resource.version = 4

Web 配置

@Configuration
//扫描组件
@ComponentScan("xyz.xiashuo.springmvcannotationconfig")
//开启MVC注解驱动
@EnableWebMvc
@PropertySource("classpath:application.properties")
@Order(1)
public class WebConfig implements WebMvcConfigurer {

    @Value("${resource.version}")
    private int resourceVersion;

   @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 常规的静态资源处理器映射
        // 效果等同于 xml中的配置
        //<mvc:resources mapping="/resources/**" location="/resources, classpath:/static/" cache-period="20" />
        // 添加静态资源映射路径
                // addResourceHandler 方法可以添加多个路径,只不过我们一般只添加一个。
        registry.addResourceHandler("/resourcesSimple/**")
                // addResourceLocations 添加资源位置,可以添加war包的根目录中的位置(默认)、编译输出路径(classpath:开头)、系统文件路径(file:开头)
                // 如果有多个路径匹配,排在前面的先匹配,这是 PathResourceResolver 的 getResource 方法决定的
                //注意,路径要以 / 结尾,不然报错
                .addResourceLocations("/resourcesSimple/", "classpath:/staticSimple/", "file:/C:\\test/")
                // setCacheControl 设置响应的的消息头的Cache-Control
                .setCacheControl(CacheControl.maxAge(20, TimeUnit.SECONDS));
        // 为资源添加版本信息
        registry.addResourceHandler("/resourcesVersion/**")
                .addResourceLocations("classpath:/staticVersion/")
                .setCacheControl(CacheControl.maxAge(20, TimeUnit.SECONDS))
                // resourceChain 唯一的参数 表示是否添加缓存,将静态资源解析的结果缓存起来,生产环境建议设置为true,开发环境设置为false
                .resourceChain(false)
                // 添加版本信息解析器
                // 为所有路径指定基于内容的版本字符串生成器
                .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"))
                // 添加 Css 转换器,专门用于处理,在开启静态资源的URL版本的时候,css文件中通过 @import "another.css" 的情况,可以将 @import 的css的路径也假设版本信息
                // 也可以不添加,系统会默认添加。
                .addTransformer(new CssLinkResourceTransformer());
        // 添加大版本切换
        registry.addResourceHandler("/resources/**")
                // resourceVersion 为静态资源大版本
                .addResourceLocations("classpath:/resourcesAllVersion/V" + resourceVersion + "/")
                .setCacheControl(CacheControl.maxAge(20, TimeUnit.SECONDS));
        WebMvcConfigurer.super.addResourceHandlers(registry);
    }

}

src/main/resources 下新建 staticVersion 文件夹,下面放一下静态资源

添加 html 页面

<h1>大版本静态资源切换</h1>
<a target="_blank" th:href="@{/resources/images/aaa.jpg}">查看图片</a>

启动项目,可以看到,访问的是 V4 文件夹下的图片,修改 application.properties 中的版本为 5,就可以访问 V4 文件夹下的图片


总结

静态资源处理链是一个典型的链式处理设计模式。

静态资源处理是一个很大的话题,但是有了链式处理结构(ResourceHttpRequestHandler+ 解析链 + 转换链)之后,灵活度就很高了。

之前学习的内容协商等策略的处理,也都可以看做是一个简单的链式处理设计模式,

用一个 lis 存储所有的处理节点,处理的时候,用一个 for 循环依次遍历所有节点进行处理,其中,如果一个节点返回无效值,比如 null,或者空集合,则表示此节点处理失败,继续遍历,如果节点返回的是非无效值,则直接返回,链式处理的结果跟处理节点的顺序高度相关。