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 静态资源缓存配置

根据这篇文章,终于第一次实现了该功能。

posted @ 2022-04-16 19:54  极客子羽  阅读(1897)  评论(0编辑  收藏  举报