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.VersionResourceResolverspring-webmvc4.1 版本之后添加的類。
它是接口 org.springframework.web.servlet.resource.ResourceResolver 的一個實現類,
而接口 ResourceResolver 表示 將請求解析為服務器端資源的策略。該接口提供了的機制如下:

  1. 將傳入請求解析為實際 org.springframework.core.io.Resource 的機制,
  2. 獲取客戶端在請求資源時應使用的公共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 方法設置 patternVersionStrategy 的映射關系時,pattern 卻使用的是 /lib/**。而沒有帶上 static 或者 public

為什么會這樣呢?接着往下看。

ResourceUrlProvider源碼解析

就像這篇 spring boot web 靜態資源緩存配置 給出的兩個思路。

無論使用哪種,原理都是調用 org.springframework.web.servlet.resource.ResourceUrlProvidergetForLookupPath 方法。

// 比如,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 事件

通常,AbstractApplicationContextrefresh 方法中調用 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 的源碼,就串起來了。

ResourceUrlEncodingResponseWrapperencodeURL 方法會調用 ResourceUrlEncodingRequestWrapperresolveUrlPath 方法:

這樣,注入這個 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都可以被匹配上

參考文檔 servlet的url-pattern匹配規則詳細描述(小結)

參考文檔

spring boot實現靜態資源文件自動添加版本號-MD5方式

這篇提供了思路,就是給資源文件加上版本號(並且用MD5來代表版本號),但是實戰起來不可行,缺胳膊少腿

spring boot web 靜態資源緩存配置

根據這篇文章,終於第一次實現了該功能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM