深入 Spring 系列之静态资源处理

1,615 阅读10分钟
原文链接: blog.coding.net

前一段时间,WebIDE 开源的过程中,无意间接触到 webjars,觉得比较有趣,于是研究并整理了一下。

webjars 是将前端的库(比如 jQuery)打包成 Jar 文件,然后使用基于 JVM 的包管理器(比如 Maven、Gradle 等)管理前端依赖的方案。

webjars 的效果非常神奇。可以举个例子,我们可以在 maven 项目中添加下面的依赖:


    org.webjars
    jquery
    3.1.0

然后通过请求 http://localhost:8080/webjars/jquery/3.1.0/jquery.js 即可正确访问到 jquery 文件。

这勾起了我的好奇心,研究了下才发现,这并非新的技术,而是利用现有的框架对静态资源的处理方案实现的。

1. 预备知识

1.1 Servlet 3

我们可以先来看一下 jquery webjar 的包结构:

jquery-3.1.0.jar
    └─ META-INF
        └─ resources
            └─ webjars
                └─ jquery
                    └─ 3.1.0
                        └─ jquery.js

拿 Servlet 3 举例,应用打成 war 后,Jar(包括 WebJars)会被放在 WEB-INF/lib 目录下,而 Servlet 3 允许直接访问 WEB-INF/lib 下 jar 中的 /META-INF/resources 目录下的资源。简单来说就是 WEB-INF/lib/{\*.jar}/META-INF/resources 下的资源可以被直接访问。

图片

所以对于 Servlet 3,直接使用 http://localhost:8080/webjars/jquery/3.1.0/jquery.js 即可访问到 webjar 中的 jquery.js,而不用做其它的配置。

那么如何在 Spring MVC 中访问 webjars 呢?或者说,Spring MVC 如何处理静态资源?

1.2 Spring MVC

Spring MVC 的入口是 DispatcherServlet,所有的请求都会汇集于该类,而后分发给不同的处理类。如果不做额外的配置,是无法访问静态资源的。

图片

如果想让 Dispatcher Servlet 直接可以访问到静态资源,最简单的方法当然是交给默认的 Servlet。

图片

@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }
}

这种情况下 Spring MVC 对资源的处理与 Servlet 方式相同。

2. 基础

我们可以通过很简单的配置使得 Spring MVC 有能力处理对静态资源进行处理。

在 Spring MVC 中,资源的查找、处理使用的是责任链设计模式(Filter Chain):

图片

其思路为如果当前 resolver 找不到资源,则转交给下一个 resolver 处理。 当前 resolver 找到资源则立即返回给上级 resovler(如果存在),此时上级 resolver 又可以选择对资源进一步处理或再次返回给它的上级(如果存在)。

配置方法为重写 WebMvcConfigurerAdapter 类的 addResourceHandlers。

@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {
    @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/webjars/**")
                .addResourceLocations(
                        "classpath:/META-INF/resources/webjars/");
}

通过这样的配置,就成功添加了一个 PathResourceResolver

图片

该 resolver 的作用是将 url 为 /webjars/** 的请求映射到 classpath:/META-INF/resources/webjars/

比如请求 http://localhost:8080/webjars/jquery/3.1.0/jquery.js 时, Spring MVC 会查找路径为 classpath:/META-INF/resources/webjars/jquery/3.1.0/jquery.js 的资源文件。

3. 进阶

3.1 为静态资源添加版本号

为了简单起见,我们假设静态资源存放在 classpath:/static,且映射的 url 为 /static

@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {

    // 映射 /static 的请求到 classpath 下的 static 目录

    registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static");
    }
}

比如,请求 /static/style.css, 则会直接查找 classpath:/static/style.css

我们刚才说到,这段代码实际上是添加了一个 PathResourceResolver,来完成对资源的查找,那么我们是不是可以继续向 Resolver Chain 添加更多的 Resource Resolver,从而实现对静态资源更多样化的处理呢?

答案是肯定的,接下来,我们添加 VersionResourceResolver。

图片

VersionResourceResolver 可以为资源添加版本号。其所作的工作如下:首先使用下一个 resolver 获取资源,如果找到资源则返回,不做其它处理;如果 下一个 resolver 找不到资源,则尝试去掉 url 中的 version 信息,重新调用下一个 resolver 处理,然后无论下一个 resolver 能否处理,都返回其结果。

版本号的策略有两种,下面分别阐述。

3.1.1 指定版本号

指定固定值作为版本号,比如:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
   registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static")
           // resourceChain(false) 的作用后面会讲解
           .resourceChain(false)
           // 添加 VersionResourceResolver,且指定版本号
           .addResolver(new VersionResourceResolver()
               .addFixedVersionStrategy("1.0.0", "/**"));
}

这样,在请求资源时,加上 /1.0.0 前缀,即 http://localhost:8080/static/1.0.0/style.css 也可正确访问。

VersionResourceResolver 在处理该请求时,首先使用 PathResourceResolver 按照配置的映射关系 "/static/**" => "classpath:/static" 处理,即查找文件 classpath:/static/1.0.0/style.css。由于该文件不存在,VersionResourceResolver 尝试去掉版本号 1.0.0,然后再次查找 classpath:/static/style.css,找到文件,直接返回。

3.1.2 使用 MD5 作为版本号

除了指定版本号,也可以使用资源的 MD5 作为其版本号,配置方法为:

@Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/")
                .resourceChain(false)
                .addResolver(new VersionResourceResolver()
                    .addContentVersionStrategy("/**"));
    }

这样,请求资源时,加上资源的 md5,即 http://localhost:8080/static/style-dfbe630979d120fe54a50593f2621225.css 也可正确访问。

由于使用资源的 MD5 作为版本号,是 VersionResourceResolver 的其中一种策略,因此与指定版本号的处理方式相同,不再阐述。

3.2 gzip 压缩

很多时候,为了降低传输的数据量,可以对资源进行压缩。比如可以将 style.css 压缩成 style.css.gz,但是如何让 Spring MVC 在处理对 style.css 的请求时能正确返回 style.css.gz 呢?

为了解决这个问题,我们可以继续添加一个 Resource Resolver —— GzipResourceResolver。

图片

GzipResourceResolver 用来查找资源的压缩版本,它首先使用下一个 Resource Resolver 查找资源,如果可以找到,则再尝试查找该资源的 gzip 版本。如果存在 gzip 版本则返回 gzip 版本的资源,否则返回非 gzip 版本的资源。

比如对于如下的资源:

static
    └─ style.css
    └─ style.css.gz (使用 gzip 压缩)

在请求 /static/style.css 时,会先使用 PathResourceResolver 查找 style.css,找到后则再次查找 style.css.gz。这里该文件是存在的,因此会返回 style.css.gz 的内容。

PS: 请求头中的 Content-Encoding 要包含 gzip

配置 GzipResourceResolver 很简单:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
   registry.addResourceHandler("/static/**")
           .addResourceLocations("classpath:/static/")
           .resourceChain(false)
           .addResolver(new GzipResourceResolver())
           .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
           
}

3.3 chain cache

从上面的情况可以看出,Spring MVC 会对资源进行较多的处理。如果每一次请求都做这些处理,无疑会降低服务器的性能。为了避免这种情况,这时可以添加 CachingResourceResolver 来解决这种问题。

图片

CachingResourceResolver 用于缓存其它 Resource Resolver 查找到的资源。因此 CachingResourceResolver 会被放在最外层。请求先到达 CachingResourceResolver,尝试在缓存中查找,如果找到,则直接返回,如果找不到,则依次调用后面的 resolver,直到有一个 resolver 能够找到资源,CachingResourceResolver 将找到的资源缓存起来,下次请求同样的资源时,就可以从缓存中取了。

可能有人会担心缓存资源会占用太多的内存。但实际上并没有资源内容,仅仅是对资源的路径(或者说资源的抽象)进行了缓存。

开启缓存的方法很简单:

.requestChain(true)

前面的例子中都选择关闭 chain cache,原因是缓存的存在会增加调试的难度。因此开发时可以考虑关闭该功能。

3.4 省略 webjar 版本

AbstractResourceResolver 的子类一共有 5 个,我们已经提到了 4 个。最后一个是 WebJarsResourceResolver。

图片

WebJarsResourceResolver 并不需要手动添加。WebJarsResourceResolver 依赖了 webjars-locator 包,因此当添加了 webjars-locator 依赖时,Spring MVC 会自动添加 WebJarsResourceResolver。


    org.webjars
    webjars-locator
    0.32

WebJarsResourceResolver 的作用是可以省略 webjar 的版本。比如对于请求 http://localhost:8080/webjars/jquery/3.1.0/jquery.js 省略版本号 3.1.10 直接使用 http://localhost:8080/webjars/jquery/jquery.js 也可访问。

至此所有 Spring MVC 提供的 ResourceResolver 都讲完了。Spring MVC 提供的这 4 个 ResourceResolver 基本够用,如果不能满足业务需求,也可以自定义 ResourceResolver 来满足需求。

3.5 Transformer

实际上,除了 ResourceResolver,Spring MVC 还支持修改资源内容,即 Resource Transformer。

图片

可用的 Resource Transformer 有以下几个:

图片

他们的功能依次为:

  • AppCacheManifestTransformer: 帮助处理 HTML5 离线应用的 AppCache 清单内的文件
  • CachingResourceTransformer: 缓存其它 transfomer 的结果,作用同 CachingResourceResolver
  • CssLinkResourceTransformer: 处理 css 文件中的链接,为其加上版本号
  • ResourceTransformerSupport: 抽象类,自定义 transfomer 时继承

我们拿 CssLinkResourceTransformer 举例。 它会将 css 文件中的 @import 或 url() 函数中的资源路径自动转换为包含版本号的路径。

配置方法为:

registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/")
                .resourceChain(false)
                .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"))
                .addTransformer(new CssLinkResourceTransformer());

当我们在 style.css 中通过 @import "style-other.css"; 导入了另一个 css 文件,则 transformer 会自动将该 style.css 内部的 css 文件路径地址转换为: @import "style-other-d41d8cd98f00b204e9800998ecf8427e.css"

3.6 Http 缓存

为了避免客户端重复获取资源,HTTP/1.1 规范中定义了 Cache-Control 头。几乎所有浏览器都实现了支持 Cache-Control

配置方法如下:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
   registry.addResourceHandler("/static/**")
           .addResourceLocations("classpath:/static/")
           .setCacheControl(CacheControl
                   .maxAge(10, TimeUnit.MINUTES)
                   .cachePrivate());
}

当请求 /static/style.css 时,返回的头信息中会多两条信息:

Cache-Control:max-age=600, private
Last-Modified:Sun, 04 Oct 2016 15:08:22 GMT

浏览器会将该信息连同资源储存起来,当再次请求该资源时,会取出 Last-Modified 并添加到在请求头 If-Modified-Since 中:

If-Modified-Since:Sun, 04 Oct 2016 15:08:22 GMT

Spring MVC 在收到请求,发现存在 If-Modified-Since,会提取出来该值,并与资源的修改时间比较,如果发现没有改变,则仅仅返回状态码 304,无需传递资源内容。浏览器收到状态码 304,明白资源从上次请求到现在未被改变,http 缓存依旧可用。

http 缓存的更多用法参见 这里

4. 使用 Spring Boot 配置

众所周知,使用 Spring MVC 搭建 Web 服务,不仅要编写不少的代码或 XML 配置,如果开发人员使用不同的 IDE,还要配置这些 IDE 使其得以被正确运行。

为了解决这些问题,spring.io 平台提供了 Spring Boot。Spring Boot 采用 约定优于配置 的理念,在整合已有的 Spring 组件的同时,提供了大量的默认配置。得益于这些默认配置,使用 Spring Boot,只需要编写一个 pom.xml,再加上一个 java 类,就可以跑起来一个 web 服务,如果使用 groovy,一个类文件就能跑起来 web 服务。正是由于 spring boot 带来的这种便捷的特性,被广泛应用在微服务的场景中。

现在,Spring Boot 已经非常成熟了,最好的教程当然是官方文档

项目的创建可以为普通 maven 项目,当然还可以使用 spring.io 提供的 在线创建 Spring Boot 项目 的服务创建简项目或者。当然,也可以查看本文的示例代码。

强烈推荐 看下 WebMvcAutoConfiguration 这个类,它为 Spring Boot 提供了大量的 Web 服务的默认配置。这些配置包括但不局限于:设置了主页、webjars配置、静态资源位置等。这些配置对于我们使用配置 Web 服务很有借鉴意义。

ps: 想要使用默认配置,无需使用 @EnaleWebMvc 注解。使用了 @EnableWebMvc 注解后 WebMvcAutoConfiguration 提供的默认配置会失效,必须提供全部配置。

最后,我们使用 spring boot 提供的编写配置文件的方式,实现上面使用代码才能完成的功能。

# application.properties

# 设置静态资源的存放地址
spring.resources.static-locations=classpath:/resources 

# 开启 chain cache
spring.resources.chain.cache=true

# 开启 gzip
spring.resources.chain.gzipped=true

# 指定版本号
spring.resources.chain.strategy.fixed.enabled=true
spring.resources.chain.strategy.fixed.paths=/static  
spring.resources.chain.strategy.fixed.version=1.0.0

# 使用 MD5 作为版本号
spring.resources.chain.strategy.content.enable=true
spring.resources.chain.strategy.content.paths=/**

# http 缓存过期时间
spring.resources.cachePeriod=60 

最后介绍一下如何查看这些配置的技巧:

通过查看 ResourceProperties 这个类可以看到,该类顶部有一个注解 @ConfigurationProperties(prefix = "spring.resources", ignoreUnknownFields = false)

ConfigurationProperties 是用来注入值的,prefix = "spring.resources" 表示前缀。比如我们配置文件中的 spring.resources.static-locations=classpath:/resources 这个配置,去掉 spring.resources 这个前缀,剩下的为 static-locations ,则它的值 classpath:/resources 会被注入到 ConfigurationProperties 类的 staticLocations 成员变量中。通过这种方法,我们就能通过编写配置文件改变类的状态而无需编写代码。当然,如何使用这些配置的关键还是要知道这些成员变量的作用。

5. 总结

本文从一个新的技术点 webjars 出发,探讨了 Spring MVC 对静态资源的处理,紧接着又了解了 Spring Boot 的配置技巧。

示例代码:下载

6. 参考

developers.google.com/web/fundame…
docs.spring.io/spring/docs…
qiita.com/kazuki43zoo…
docs.spring.io/spring-boot…