spring boot整合Thymeleaf实现静态资源文件自动添加版本号(文件内容md5)实战与源码解析
简介
如果能够根据文件内容计算出md5值,并且用这个md5值来作为文件后缀,那么只要文件内容发生变化,文件名就会发生变化,那么服务器发布时,用户就能访问到最新版本的js/css等文件了。
例如,我们在html代码中写的是
<link rel="shortcut icon" th:href="@{/public/favicon.ico}" type="image/x-icon"/>
<link rel="stylesheet" th:href="@{/static/layui/css/layui.css}" type="text/css">
<script type="text/javascript" th:src="@{/static/lib/jquery-3.6.0.min.js}" charset="utf-8"></script>
<script type="text/javascript" th:src="@{/static/layui/layui.js}" charset="utf-8"></script>
实际在浏览器中运行时,加载的html页面代码:
<link rel="shortcut icon" href="/public/favicon-70a8fdd950eeb21990c45c0566ba7a99.ico" type="image/x-icon"/>
<link rel="stylesheet" href="/static/layui/css/layui-ad0585393c509f1b14bd641057085743.css" type="text/css">
<script type="text/javascript" src="/static/lib/jquery-3.6.0.min-0732e3eabbf8aa7ce7f69eedbd07dfdd.js" charset="utf-8"></script>
<script type="text/javascript" src="/static/layui/layui-70ed0e8151d23de969de514bfd802a56.js" charset="utf-8"></script>
首先第一个问题:这个 -{文件内容md5}
值是执行什么代码加上去的呢?
VersionResourceResolver源码解析
org.springframework.web.servlet.resource.VersionResourceResolver 是 spring-webmvc
在 4.1 版本之后添加的类。
它是接口 org.springframework.web.servlet.resource.ResourceResolver 的一个实现类,
而接口 ResourceResolver 表示 将请求解析为服务器端资源的策略。该接口提供了的机制如下:
- 将传入请求解析为实际 org.springframework.core.io.Resource 的机制,
- 获取客户端在请求资源时应使用的公共URL路径的机制。
点开查看 ResourceResolver 源码
针对本文的最终目标,需要关注的是 resolveUrlPath 方法,将系统内资源路径转化为公开的URL路径:
@Override
protected String resolveUrlPathInternal(String resourceUrlPath,
List<? extends Resource> locations, ResourceResolverChain chain) {
// 先执行chain下游的ResourceResolver
String baseUrl = chain.resolveUrlPath(resourceUrlPath, locations);
if (StringUtils.hasText(baseUrl)) {
// 获取当前资源对应的版本号 策略
VersionStrategy versionStrategy = getStrategyForPath(resourceUrlPath);
if (versionStrategy == null) {
return baseUrl;
}
// 解析实际的资源,等会才能获取到文件内容
Resource resource = chain.resolveResource(null, baseUrl, locations);
Assert.state(resource != null, "Unresolvable resource");
// 这里根据不同的策略获取不同的 版本号(可选策略见下表,不做过多解读)
String version = versionStrategy.getResourceVersion(resource);
// 把 版本号 拼接到公开的 URL 路径
return versionStrategy.addVersion(baseUrl, version);
}
return baseUrl;
}
addVersion解析
其中,versionStrategy.addVersion 调用的是基类 org.springframework.web.servlet.resource.AbstractVersionStrategy 的方法:
可选的版本策略
版本策略 | 版本号 | 对应pathStrategy(VersionPathStrategy) | 转换前baseUrl示例 | addVersion结果示例 |
---|---|---|---|---|
FixedVersionStrategy | 固定字符串版本号 | PrefixVersionPathStrategy | path/foo.js | {version}/path/foo.js |
ContentVersionStrategy | 根据文件内容生成版本号 | FileNameVersionPathStrategy | path/foo.css | path/foo-{version}.css |
getStrategyForPath解析
这段代码本身简单,问题是 pattern 怎么写?
VersionResourceResolver resolver = new VersionResourceResolver();
// 我们可以配置特定后缀的文件
resolver.addContentVersionStrategy("/**/*.js", "/**/*.css");
另一个,则是指定前缀的例子:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* JSPs, Thymeleaf, FreeMarker and Velocity模板引擎,可直接使用此方法增加静态文件md5
*/
@Bean
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
return new ResourceUrlEncodingFilter();
}
/**
* 静态资源处理
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
VersionResourceResolver resolver = new VersionResourceResolver();
// 指定 pathPattern 时,要考虑 addResourceHandler 设置的 pathPattern
resolver.addContentVersionStrategy("lib/**");
// registry 实际上是类似“建造者模式”
// 之后,调用其 getHandlerMapping() 方法可以创建一个 SimpleUrlHandlerMapping
// SimpleUrlHandlerMapping 中包含 pattern 和 ResourceHttpRequestHandler 的映射
// ResourceHttpRequestHandler 又包含 ResourceResolver 的列表
// ResourceHttpRequestHandler 的 ResourceResolver 的列表中一定包含 PathResourceResolver 或者它的子类
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/")
// 生产环境推荐设置为true,开发环境推荐设置为false
// 设置true会为ResourceHttpRequestHandler添加默认的 CachingResourceResolver
.resourceChain(true)
// 然后
.addResolver(resolver);
}
}
ResourceHandlerRegistry 与 SimpleUrlHandlerMapping -> ResourceHttpRequestHandler -> PathResourceResolver
// ResourceHandlerRegistry.java
// ResourceHandlerRegistration.java
// ResourceChainRegistration.java
resourceChain(true) 与 CachingResourceResolver
举个例子,如下图所示,我们有一个文件在 /static/lib/
文件夹下:
但是,在 VersionResourceResolver 调用 addContentVersionStrategy 方法设置 pattern 和 VersionStrategy 的映射关系时,pattern 却使用的是 /lib/**
。而没有带上 static 或者 public。
为什么会这样呢?接着往下看。
ResourceUrlProvider源码解析
就像这篇 spring boot web 静态资源缓存配置 给出的两个思路。
无论使用哪种,原理都是调用 org.springframework.web.servlet.resource.ResourceUrlProvider 的 getForLookupPath 方法。
// 比如,lookupPath 的值可以 /static/lib/jquery-3.6.0.min.js
public final String getForLookupPath(String lookupPath) {
// 清除url中"//",重复的斜线会影响后面的匹配逻辑
String previous;
do {
previous = lookupPath;
lookupPath = StringUtils.replace(lookupPath, "//", "/");
} while (!lookupPath.equals(previous));
List<String> matchingPatterns = new ArrayList<>();
// 这里handlerMap中是下一小节说明,可以先跳到下一节了解一下,再回看
for (String pattern : this.handlerMap.keySet()) {
if (getPathMatcher().match(pattern, lookupPath)) {
matchingPatterns.add(pattern);
}
}
if (!matchingPatterns.isEmpty()) {
Comparator<String> patternComparator = getPathMatcher().getPatternComparator(lookupPath);
// 排序之后,/static/** 就会先于 /**
matchingPatterns.sort(patternComparator);
for (String pattern : matchingPatterns) {
// 从 lookupPath 提取出和 ** 相匹配的部分
// 例如,当 pattern 为 /static/** , lookupPath 为 /static/lib/jquery-3.6.0.min.js
// pathWithinMapping 值为 lib/jquery-3.6.0.min.js
String pathWithinMapping = getPathMatcher().extractPathWithinPattern(pattern, lookupPath);
// 从 pattern 和 lookupPath 提取公共的段
// 承上例,pathMapping 值为 /static/
String pathMapping = lookupPath.substring(0, lookupPath.indexOf(pathWithinMapping));
ResourceHttpRequestHandler handler = this.handlerMap.get(pattern);
// 在本例中,/static/** 对应的 ResourceHttpRequestHandler
// 将组成一个 CachingResourceResolver -> VersionResourceResolver -> PathResourceResolver 的责任链
ResourceResolverChain chain = new DefaultResourceResolverChain(handler.getResourceResolvers());
String resolved = chain.resolveUrlPath(pathWithinMapping, handler.getLocations());
if (resolved == null) {
continue;
}
return pathMapping + resolved;
}
}
if (logger.isTraceEnabled()) {
logger.trace("No match for \"" + lookupPath + "\"");
}
return null;
}
上面这段代码注释中,以请求本文中的 /static/lib/jquery-3.6.0.min.js
资源为例,但是需要注意的是,因为 CachingResourceResolver 的存在,会导致多次请求时,在 VersionResourceResolver 中的 resolveUrlPathInternal 中打断点无效。
解决方案,一个是将
resourceChain(true)
改为resourceChain(false)
,要么就重启服务达到清理内存的效果。
handlerMap初始化源码解析
当完成 ApplicationContext 的初始化或者刷新时,就会发送一个 ContextRefreshedEvent 事件
通常,AbstractApplicationContext 的 refresh 方法中调用 finishRefresh 方法时发送该事件。
此时会触发 ResourceUrlProvider 的探测 ResourceHandler 资源处理器的逻辑
protected void detectResourceHandlers(ApplicationContext appContext) {
// SimpleUrlHandlerMapping 功能就是处理静态资源请求,这里把所有该类型的Spring Bean都取出来
Map<String, SimpleUrlHandlerMapping> beans = appContext.getBeansOfType(SimpleUrlHandlerMapping.class);
List<SimpleUrlHandlerMapping> mappings = new ArrayList<>(beans.values());
// 根据 @Order 注解排序
AnnotationAwareOrderComparator.sort(mappings);
for (SimpleUrlHandlerMapping mapping : mappings) {
// 遍历静态资源处理器映射的handlerMap
for (String pattern : mapping.getHandlerMap().keySet()) {
Object handler = mapping.getHandlerMap().get(pattern);
// 把所有静态资源http请求处理器的 pattern 和 handler 注册到 ResourceUrlProvider 中!
if (handler instanceof ResourceHttpRequestHandler) {
ResourceHttpRequestHandler resourceHandler = (ResourceHttpRequestHandler) handler;
this.handlerMap.put(pattern, resourceHandler);
}
}
}
if (this.handlerMap.isEmpty()) {
logger.trace("No resource handling mappings found");
}
}
关于请求静态资源时, SimpleUrlHandlerMapping 以及 ResourceHttpRequestHandler 的作用如下图所示,可以参考一下。
图片来自于源码阅读网
在 SpringBoot中,默认的 handlerMap 的 pattern 有
/webjars/**
,/**
,它们对应的 ResourceHttpRequestHandler 中的 resourceResolvers 只有 PathResourceHandler 这一个;
另外,/static/**
和/public/**
是本例中由我自定义添加的,对应的 ResourceHttpRequestHandler 包含三个 resourceResolvers———— CachingResourceResolver & VersionResourceResolver & PathResourceResolver;
关于 PathResourceResolver 的源码分析可以点击这个链接查看,本文就不做太多分析了。
Thymeleaf简单分析
静态的html页面模板会被解析为 TemplateModel,它的成员变量 queues 包含各种标签。
比如以下标签
<script type="text/javascript" th:src="@{/static/lib/jquery-3.6.0.min.js}" charset="utf-8"></script>
<link rel="stylesheet" th:href="@{/static/layui/css/layui.css}" type="text/css">
<link rel="shortcut icon" th:href="@{/public/favicon.ico}" type="image/x-icon"/>
都会被解析成 StandaloneElementTag。
属性名称 | 该属性相关的处理器 |
---|---|
th:href |
SpringHrefTagProcessor |
th:src |
SpringSrcTagProcessor |
属性值 @{...}
经过 EngineEventUtils.computeAttributeExpression (在 AbstractStandardExpressionAttributeTagProcessor#doProcess
中调用,是 SpringHrefTagProcessor 和 SpringSrcTagProcessor 共同的父类)
得到的对象是 LinkExpression 对象。
在执行 LinkExpression#executeLinkExpression
时,会用到 StandardLinkBuilder 的以下代码:
protected String processLink(final IExpressionContext context, final String link) {
if (!(context instanceof IWebContext)) {
return link;
}
final HttpServletResponse response = ((IWebContext)context).getResponse();
// 这个encodeURL方法就是关键
return (response != null? response.encodeURL(link) : link);
}
马上就能看到注入 ResourceUrlEncodingFilter 这个过滤器的必要性!
ResourceUrlEncodingFilter源码解析
经过该过滤器时,请求和响应都增加了一层包装类。对应上一节 processLink 的源码,就串起来了。
ResourceUrlEncodingResponseWrapper 的 encodeURL 方法会调用 ResourceUrlEncodingRequestWrapper 的 resolveUrlPath 方法:
这样,注入这个 ResourceUrlEncodingFilter后,我们在 Thymeleaf 模板文件时,只要写 @{...}
的格式,就能自动触发 resourceUrlProvider.getForLookupPath
方法,而不需要我们自己来写成 ${urls.getForLookupPath('...')}
这样的格式了,这就更简单了。
因此,注入ResourceUrlEncodingFilter时,可以为他设置前缀,只让静态资源经过该过滤器,也算是一种优化吧!
最终的WebMvcConfig
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
import org.springframework.web.servlet.resource.VersionResourceResolver;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* JSPs, Thymeleaf, FreeMarker and Velocity模板引擎,可直接使用此方法增加静态文件md5
*/
@Bean
public FilterRegistrationBean getFilterRegistrationBean(){
FilterRegistrationBean<ResourceUrlEncodingFilter> bean = new FilterRegistrationBean<>(new ResourceUrlEncodingFilter());
bean.addUrlPatterns("*.html");
return bean;
}
/**
* 静态资源处理
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
VersionResourceResolver resolver = new VersionResourceResolver();
resolver.addContentVersionStrategy("/**/*.js", "/**/*.css");
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/")
.resourceChain(true) // 生产环境设置为true,开发环境设置为false
.addResolver(resolver);
registry.addResourceHandler("/public/**").addResourceLocations("classpath:/public/")
.resourceChain(true) // 生产环境设置为true,开发环境设置为false
.addResolver(resolver);
}
}
注意 Filter 支持的 urlPattern 不是 AntPathMatcher
,可选的模式有:
模式名称 | urlPattern | 可以匹配的请求 | 备注 |
---|---|---|---|
精确匹配 | / /table /list.html /path/to/list.html |
http://localhost:8080/myapp http://localhost:8080/myapp/table http://localhost:8080/myapp/list.html http://localhost:8080/myapp/path/to/list.html |
myapp是requestContext,有时候可以不存在这一层 |
扩展名匹配 | *.jsp *.html *.js *.css |
http://localhost:8080/myapp/login.jsp http://localhost:8080/myapp/login.html http://localhost:8080/myapp/login.js http://localhost:8080/myapp/login.css |
|
路径匹配 | /p/* |
http://localhost:8080/myapp/p/add http://localhost:8080/myapp/p/remove.do http://localhost:8080/myapp/p/path/to/go/list.html |
路径匹配和拓展名匹配无法同时设置:/path/to/go/*.html /*.js l*.html 以上三个urlPattern都是非法的 |
任意匹配 | /* |
(省略) | 所有的url都可以被匹配上 |
参考文档
spring boot实现静态资源文件自动添加版本号-MD5方式
这篇提供了思路,就是给资源文件加上版本号(并且用MD5来代表版本号),但是实战起来不可行,缺胳膊少腿
根据这篇文章,终于第一次实现了该功能。