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

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

  • 相关阅读:
    makefile基本操作
    Visual Studio Code 的 launch.json 解析
    Manjaro 安装与配置
    Manjaro 系统添加国内源和安装搜狗输入法
    ununtu 18.04 163 mirror
    How to Use GNOME Shell Extensions
    Ubuntu: repository/PPA 源
    什么是线程阻塞?为什么会出现线程阻塞?
    Java锁的种类
    java8流式编程(一)
  • 原文地址:https://www.cnblogs.com/kendoziyu/p/16129765.html
Copyright © 2020-2023  润新知