簡介
如果能夠根據文件內容計算出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來代表版本號),但是實戰起來不可行,缺胳膊少腿
根據這篇文章,終於第一次實現了該功能。