HTTP缓存之使用Etag控制静态资源缓存

一、什么是HTTP缓存?

1.1 什么是HTTP缓存

  • 当我们打开浏览器访问页面时,客户端并不总是请求服务器。当HTTP 请求状态码返回304时,就有可能使用到了HTTP缓存。通俗来说,当我们访问资源时,并不总是会请求服务器。部分可重用的资源,如果没有发生过修改,那么我们就可以直接使用而不必再次请求。
  • HTTP 缓存会存储与请求关联的响应,并将存储的响应复用于后续请求。

1.2 HTTP缓存的优点

  • 提高了响应速度。使用了HTTP缓存,那么缓存的地点离客户越近,那么相应速度就越快。因为它不需要将请求直接传递到源服务器,因此能够最大限度的减少请求时间,提高客户访问体验。
  • 降低了带宽消耗。由于不需要直接访问源服务器就能下载到完整的文件资源,如果资源文件较大或者请求较多,能够节约极大的带宽,变相的降低了生产成本。
  • 降低服务器负载。由于缓存减少了网络请求,那么服务器能有更多的资源去处理其他请求,因此提高服务器处理能力,降低服务器负载。

由于缓存能够极大的提高性能,将降低负载,并且减少宽带成本支出,因此它的应用面非常广泛。但是缓存的处理也是一个难题,有一句说是这样说的,编程最难的是两件事,一件事是给代码命名,另外一件事就是处理缓存。

1.3 执行缓存的流程

  1. 接收:缓存从网络中读取抵达的请求报文。
  2. 解析:缓存对报文进行解析,提取出 URL 和各种首部。
  3. 查询:缓存查看是否有本地副本可用,如果没有,就获取一份副本(并将其保存在本地)。
  4. 新鲜度检测:缓存查看已缓存副本是否足够新鲜,如果不是,就询问服务器是否有任何更新。
  5. 创建响应:缓存会用新的首部和已缓存的主体来构建一条响应报文。
  6. 发送:缓存通过网络将响应发回给客户端。
  7. 日志:缓存可选地创建一个日志文件条目来描述这个事务。

二、HTTP缓存的分类

  1. 由于我们日常生活中已经在大量的使用互联网,HTTP 缓存也因此发展成为了一个很大的分支。
  2. 从缓存的产品来看有非常多的场景:
  • 数据库缓存。如Redis、ElasticSearch等
  • CDN缓存。CDN,即内容分发网络,是一种基于HTTP协议的分布式网络架构,通过部署在不同地理位置的服务器来缓存网站的静态和动态资源,从而提高网站的访问速度和可用性。
  • 代理服务器缓存。代理服务器是浏览器和源服务器之间的中间服务器,浏览器先向这个中间服务器发起Web请求,经过处理后(比如权限验证,缓存匹配等),再将请求转发到源服务器。
  • 浏览器缓存。每个浏览器都会基于HTTP规则实现自己的一套缓存。当我们执行某些操作返回不同的结果时,浏览器会根据一些返回结果来进行判断是否需要用到浏览器缓存。
  • 应用层的缓存。作为程序员来说,我们经常使用这类缓存,通过在代码层面添加缓存,以此来减少重复计算和查询操作,提高接口响应速度。
  1. 从缓存的范围来看,可分为私有缓存共享缓存, 从共享缓存来说,还可细分为代理缓存托管缓存
  2. 从缓存的力量来说,还可分为强制缓存协商缓存;强制缓存则需要浏览器来按照指令强制执行指令。

2.1 私有缓存

  • 顾名思义,私有缓存是客户私有的,不与其他客户共享的缓存。缓存被存储在设备本地或者独立的账户体系下,仅供当前用户使用,他可以用来降低服务器压力,提高用户体验,甚至实现离线浏览。它是绑定到特定客户端的缓存,通常表现形式是浏览器缓存。当然,浏览器缓存不仅有私有缓存也有共享缓存,因此私有缓存只是浏览器缓存的一部分。
    在这里插入图片描述

  • 需要注意的是,因为私有缓存往往会存储用户的个性化内容,因此必须要保证缓存的私密性,否则将可能导致信息泄露。

  • 使用私有缓存我们需要使用此命令。

Cache-Control: private

2.2 共享缓存

  • 共享缓存是在代理服务器或者其他中间服务器中进行二次缓存的数据,一般这里我们常见的是CDN,这种缓存可以被多个用户访问,用来减少流量和延迟。共享缓存可以进一步细分为代理缓存托管缓存
    在这里插入图片描述
    对于代理缓存(Shared Cache - Proxy)而言,除了访问控制的功能外,一些代理还实现了缓存以减少网络流量。
    对于托管缓存(Shared Cache - Managed)而言,它的表现常常是反向代理、CDN 和 service worker 与缓存 API 的组合。

三、缓存策略

3.1 基于Cache-Control 的缓存策略-概要

  • 首先,我们需要有一个大概的概念,就是Header中的cache-control 的取值大概有哪些取值。
  • 响应头大致如下:
Cache-control: public
Cache-control: private
Cache-control: no-cache
Cache-control: no-store
Cache-control: no-transform
Cache-control: must-revalidate
Cache-control: proxy-revalidate
Cache-Control: max-age=
Cache-control: s-maxage=
  • 请求头如下:
Cache-Control: max-age=
Cache-Control: max-stale[=]
Cache-Control: min-fresh=
Cache-control: no-cache
Cache-control: no-store
Cache-control: no-transform
Cache-control: only-if-cached
  • 对于上面这些取值,我们主要分为了四个种类,接下来我们将一一了解。

此文章为暗余原创,微信公众号:程序媛那么可爱,谢谢您的阅读。

3.2 基于Cache-Control 的缓存策略-分类

对于前面的总览,接下来我们将它们分为几个种类以便于理解。

  • 可缓存性控制
    1. public: 表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容。(例如:1.该响应没有max-age指令或Expires消息头;2. 该响应对应的请求方法是 POST 。)
    2. private:表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容,比如:对应用户的本地浏览器,如果使用private,代表着这个资源,可以被私有用户缓存,缓存不会被共享,实际测试,当标注为private时,浏览器可以进行缓存,但是代理服务器不会缓存这个资源。
    3. no-cache: 在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证(协商缓存验证)。这是一个服务端经常使用的指令,也是一个比较容易与no-store混淆的指令,许多前端和客户端的同学都认为当服务端的响应中标注了no-cache,那么客户端就不会进行缓存,每次都会请求服务器获取新的内容。其实只说对了一半。在这种场景下,浏览器确实会每次都请求服务器,但是并不意味着浏览器不缓存资源,mozilla的官方解释是“把请求提交给原始服务器进行验证”如果缓存没有问题,那么服务器就会返回304,让浏览器继续使用自己本地的缓存”。
    4. no-store:缓存不应存储有关客户端请求或服务器响应的任何内容,即不使用任何缓存。这个指令就是完全不使用本地缓存,在这种模式下,客户端不会记录任何缓存,包括Etag等,每次都会重新发起请求,并且得到200响应和对应的数据。如果前端希望自己的网页完全不被缓存,那么可以试下这个指令。
  • 缓存有效性控制
  1. 以上指令解决了客户端以及代理服务器能不能缓存的问题,有的同学就会有疑问了,如果让客户端进行本地缓存,那么正常情况下如果不去手动刷新,客户端是不会请求服务器的,前端发新版后,客户端如何选择合适的时机请求服务器呢?
  2. 这个时候就要用到缓存有效性控制。浏览器和服务器之间的缓存校验是相互的 ,也就是说服务器可以告知浏览器 这个缓存你能用多久,能保留多久。我们接着来看看缓存有效性控制有哪些吧:
    1. max-age:设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间。
    2. s-maxage: 覆盖max-age或者Expires头,但是仅适用于共享缓存(比如各个代理),私有缓存会忽略它。
    3. max-stale: 表明客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。
    4. min-fresh:表示客户端希望获取一个能在指定的秒数内保持其最新状态的响应。
    5. stale-while-revalidate:表明客户端愿意接受陈旧的响应,同时在后台异步检查新的响应。秒值指示客户愿意接受陈旧响应的时间长度。
    6. stale-if-error:表示如果新的检查失败,则客户愿意接受陈旧的响应。秒数值表示客户在初始到期后愿意接受陈旧响应的时间。

缓存有效性控制指令一般会与可缓存性指令共同下发给客户端。

  • 重新验证和重新加载
  1. must-revalidate:一旦资源过期(比如已经超过max-age),在成功向原始服务器验证之前,缓存不能用该资源响应后续请求。
  2. proxy-revalidate:与must-revalidate作用相同,但它仅适用于共享缓存(例如代理),并被私有缓存忽略。
  • 其他控制
  1. no-transform:不得对资源进行转换或转变。Content-Encoding、Content-Range、Content-Type等HTTP头不能由代理修改。例如,非透明代理或者如Google’s Light Mode可能对图像格式进行转换,以便节省缓存空间或者减少缓慢链路上的流量。no-transform指令不允许这样做
  2. only-if-cached:表明客户端只接受已缓存的响应,并且不要向原始服务器检查是否有更新的拷贝。从这些描述以及分类中可以看出来,可缓存性控制+缓存有效性控制+其他控制 ,这几个控制维度是不冲突的,可以共同实现缓存的实现方式限定。

事实上cache-control确实是可以同时接受多个取值的,多个不同的指令可以搭配使用来对缓存进行控制。如果使用了相矛盾的多个指令取值,那么指令就会按照优先级进行缓存控制。比如no-store和max-age这两种在行为上矛盾的指令取值放在一起下发,那么终端就只会按照no-store来进行缓存。

3.3 使用启发式缓存来处理静态资源

  • HTTP 旨在尽可能多的使用缓存,因此即使没有给出Cache-Control,只要满足缓存条件,那么也会被存储和重用。这种缓存有一个名词,叫做启发式缓存
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2021 22:22:22 GMT

<!doctype html>
  • 在上面这个请求中,服务器返回了状态码200,我们假定它是第一请求。观察请求头中存在这样一个Header: Last-Modified: Tue, 22 Feb 2021 22:22:22 GMT,在这个Header中我们知道了它最新的更新时间是在什么时候。同时有这样一个Header: Date: Tue, 22 Feb 2022 22:22:22 GMT,我们由此可知服务器告诉我们这个数据在Last-Modified这个时间之后一年都可能不会再次更新,因此我们能够使用缓存来处理此请求。
  • 浏览器会根据服务器返回的信息来进行缓存,并决定缓存多久,这个跟浏览器有关,并不是所有浏览器的策略都是一样的。虽然一年到期,但是浏览器可能只缓存一个月(规范建议只缓存有效期的10%天数)
  • 启发式缓存是在Cache-Control被广泛采用之前出现的一种解决办法,基本上所有相应都明确指定Cache-Control 标头。

当我们访问一些静态资源时,如果浏览器没有禁用缓存,并且header头没有说不使用缓存等情况时,基于Last-Modify 的启发式缓存能够提供一个很基础的缓存功能。

3.4 基于age的缓存策略

  • 在 HTTP 中,age 是自响应生成以来经过的时间。如Cache-Control: max-age=604800 表示缓存。
  • 在 HTTP/1.0 中,有效期是通过 Expires 标头来指定的。Expires 标头使用明确的时间而不是通过指定经过的时间来指定缓存的生命周期。
Expires: Tue, 28 Feb 2022 22:22:22 GMT
  • 但是时间格式难以解析,也发现了很多实现的错误,有可能通过故意偏移系统时钟来诱发问题;因此,在 HTTP/1.1 中,Cache-Control 采用了 max-age——用于指定经过的时间。
  • 如果 Expires 和 Cache-Control: max-age 都可用,则将 max-age 定义为首选。因此,由于 HTTP/1.1 已被广泛使用,无需特地提供Expires。

四、使用Etag缓存来处理静态资源

  • 协商缓存依靠EtagLast-Modified来进行控制。
  • 他们两个共同点都是在第一次请求时拿到服务器给的一个值(Last-Modify获取的是最后一次的修改时间,而Etag则是访问的资源生成的哈希值)。
  • 稍微有点不同的是,Last-Modify是通过最后一次修改时间来判断资源是否发生改变。Etag是用hash值内容对比来判断资源是否发生改变。如果资源未改变,那么我们可以直接使用缓存,HTTP 状态码返回304。反之如果资源发生了改变,那么服务器会给我们发送最新的资源文件,并且告诉我们最新的Etag哈希值或最近一次更新时间,以便于我们下一次再进行比对。

4.1 Etag是什么?

  • Etag是什么?ETag HTTP 响应头是资源的特定版本的标识符。这可以让缓存更高效,并节省带宽,因为如果内容没有改变,Web 服务器不需要发送完整的响应。而如果内容发生了变化,使用 ETag 有助于防止资源的同时更新相互覆盖(“空中碰撞”)。
    • 如果给定 URL 中的资源更改,则一定要生成新的 ETag 值。比较这些 ETag 能快速确定此资源是否变化。

4.2 为什么使用Etag?

  • Last-Modified以秒为单位,如果不超过1s内不会检测到资源发送改变。
  • 资源走完一个生命周期回到原来的状态,其实没发生改变,但会会判断发生改变。
  • 因为Etg hash值内容是唯一的,通过对比就很快知道资源是否发送改变。

4.3 Etag指令

  • 语法:
ETag: W/"<etag_value>"         
ETag: "<etag_value>"
  • W/'W/'(大小写敏感) 表示使用弱验证器。弱验证器很容易生成,但不利于比较。强验证器是比较的理想选择,但很难有效地生成。相同资源的两个弱Etag值可能语义等同,但不是每个字节都相同。

  • <etag_value>: 实体标签唯一地表示所请求的资源。它们是位于双引号之间的 ASCII 字符串(如“675af34563dc-tr34”)。没有明确指定生成 ETag 值的方法。通常,使用内容的散列,最后修改时间戳的哈希值,或简单地使用版本号。例如,MDN 使用 wiki 内容的十六进制数字的哈希值。

  • Etag 示例:

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
ETag: W/"0815"

Etag 强验证需要逐字节对比,验证每个字节都必须一致。而使用弱验证,我们可以利用资源文件的更新时间来生成这个hash值,使用弱验证条件范围更宽一点。

  • Etag 强弱校验对比:
ETag1ETag2强校验弱校验
W/“1”W/“1”匹配失败匹配成功
W/“1”W/“2”匹配失败匹配失败
W/“1”“1”匹配失败匹配成功
“1”“1”匹配成功匹配成功

4.4 Etag 的作用

  • 避免空中碰撞:
  1. 在ETag和 If-Match 头部的帮助下,你可以检测到"空中碰撞"的编辑冲突。例如,当编辑 MDN 时,当前的 wiki 内容被散列,并在响应中放入Etag:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4
  1. 将更改保存到 Wiki 页面(发布数据)时,POST请求将包含有 ETag 值的If-Match头来检查是否为最新版本。
If-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
  1. 如果哈希值不匹配,则意味着文档已经被编辑,抛出412前提条件失败错误。
  • 缓存未更改的资源
  1. ETag头的另一个典型用例是缓存未更改的资源。如果用户再次访问给定的 URL(设有ETag字段),显示资源过期了且不可用,客户端就发送值为ETag的If-None-Match header 字段:
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
  1. 服务器将客户端的 ETag(与 If-None-Match 一起发送)与其当前版本的资源的 ETag 进行比较,如果两个值匹配(即资源未更改),服务器将返回不带任何内容的 304 Not Modified 状态,告诉客户端缓存版本可用(fresh)。

4.5 Etag交互过程

  • 当我们第一次访问接口时,Etag会在服务器中根据我们自定义(或框架协助)的生成规则生成Etag哈希值,并且返回给客户端。如图所示Response Headers中就携带了Etag内容
    在这里插入图片描述
  • 当我们再次访问接口时,Request Header中会携带一个If-None-Match,其值就是前面返回的Etag值,浏览器将会把上次服务器返回的Etag值重新发给服务器进行比较。同时服务器会再次根据访问的资源文件生成一个新的Etag哈希值,如果服务器资源没有发生变化,那么生成的Etag哈希值则应该是一样的,因此会匹配成功返回304状态码。如果最新Etag生成的哈希值与浏览器传递过来的不一致,那就说明资源已经发生改变,则会使用服务器资源而不会使用缓存,状态码也变成了200。
    在这里插入图片描述

4.6 使用Etag的一些注意事项

  • 对于Etag来说,它其实也是一种缓存策略。因此客户端必须启用缓存才能使用Etag,并且规定请求必须是GET类型。
  • 作为Web应用而言,我们每次发布新版本时总会携带一个新的版本号,我们通常会结合这个版本号来生成ETag。因为版本号没有变更那么足以说明代码和文件未发生变化,但是一旦更新了代码或者文件,那么web应用的版本理应产生变化,那么Etag值也会跟着变化。因此结合version来生成Etag值也是一个妙用。
  • Etag和Last-modify 稍有不同,但是它们并不是互斥的关系,我们也可以两者都同时使用来达到缓存目的。Last-Modify是当重新构建项目时,也就是重新部署时资源文件的Last-Modify会发生变化。而Etag是根据版本号来的,如果版本号没有更新即便多次部署那么Etag哈希值也不会变化,仍视为资源文件未修改。
  • 由于Last-Modify是当重新构建就会产生变化,集群应用不能保证所有节点同时部署,因此可能会导致缓存失效。使用Etag能够较好的处理这个问题。

五、使用Java程序完成Etag功能

  • 我们主流框架为SpringBoot,那么接下来我将介绍使用SpringBoot项目来完成Etag功能的开发;

5.1 配置application.yml文件

// version 版本号这个是我们自己设置的,任意命名或者放在任意位置都行,此处示例放在yml中进行引用;
version:xxx 
spring:
  web:
    resources:
      cache:
        cachecontrol:
          no-cache: true
          must-revalidate: true
  mvc:
    pathmatch:
      matching-strategy: ant-path-matcher

5.2 注册Bean EtagFilter

@Bean(name = "EtagFilter")
    public FilterRegistrationBean<EtagFilter> etagFilter(@Value("${version}") String version) {
        EtagFilter etagFilter = new EtagFilter(version);
        FilterRegistrationBean<EtagFilter> registration = new FilterRegistrationBean<>(etagFilter);
        // 这里填入相对路径
        registration.addUrlPatterns("/*");
        // order 这里定义过滤器顺序
        registration.setOrder(0);
        return registration;
    }

5.3 创建EtagFilter

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.Nullable;
import org.springframework.util.DigestUtils;
import org.springframework.web.filter.ShallowEtagHeaderFilter;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Slf4j
public class EtagFilter extends ShallowEtagHeaderFilter {
	private final String version;
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<String>();
    public EtagFilter(String version) {
        super();
        this.setWriteWeakETag(true);
        this.version = version;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        try {
            cacheRequestURI(request);
            super.doFilterInternal(request, response, filterChain);
        } finally {
            clearCache();
        }
    }

    @Override
    protected String generateETagHeaderValue(InputStream inputStream, boolean isWeak) {
    	// 这里的key是根据Url资源路径 + version版本号构成,我们可以自定义key的结构。
        String key = getRequestURI() + version;
        StringBuilder builder = new StringBuilder().append("W/\"0");
        DigestUtils.appendMd5DigestAsHex(key.getBytes(StandardCharsets.UTF_8), builder);
        builder.append('"');
        return builder.toString();
    }

    protected void cacheRequestURI(HttpServletRequest request) {
Optional.ofNullable(request).map(HttpServletRequest::getRequestURI).ifPresent(threadLocal::set);}

    protected String getRequestURI() {
        return threadLocal.get();
    }

    protected void clearCache() {
        threadLocal.remove();
    }
}

5.4 完成后进行测试

  • 添加Etag可以不需要前端参与,我们直接在后端定义一个拦截器拦截特定路径的资源(需要添加Etag的路径),然后再对比Etag是否一致(这部分对比由框架完成)。同时定义了创建Etag的EtagFilter 方法,我们通过传入Version和URL来生成Etag哈希值,可以唯一确定当前资源文件+版本。
  • 编码完成后,我们可以通过更新版本号来判断Etag是否生效。

写文不易,如果觉得文章不错,麻烦点赞加收藏噢~ 更多精彩请关注微信公众号:程序媛那么可爱, 感谢您的阅读 ~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

暗余

码字来之不易,您的鼓励我的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值