# HTTP304缓存

本文Etag的简介和作用大量摘抄自知乎:ETag简介与作用 (opens new window)

ETag是URL的tag,用来标示URL对象是否改变。这样可以应用于客户端的缓存:服务器产生ETag,并在HTTP响应头中将其传送到客户端,服务器用它来判断页面是否被修改过,如果未修改返回304,无需传输整个对象。

页面渲染速度大头取决于:静态文件下载速度和动态请求返回速度。通过Fiddler或浏览器F12开发者工具能够看到每个页面的加载情况。

对于css、静态图片、js文件这类请求文件,第一次访问页面之后都会被浏览器缓存以提升二次访问效率。

对于后台请求,默认不会缓存,而有的后端数据经常几小时、几天都无变化,如果被高频访问,就白白浪费服务器资源。Etag就是用来解决这类长期无数据更新的后台请求的,使用Etag机制之后即使访问后台也不返回数据,数据都从浏览器缓存处获取。

1714302608090.png

# ETag的作用

HTTP1.1用ETag来判断请求的文件是否被修改,主要为了解决Last-Modified无法解决的一些问题

1、一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候并不希望客户端认为这个文件被修改了重新GET;

2、某些文件修改非常频繁,1秒内修改了N次,If-Modified-Since能检查到的粒度是秒级的,这种修改无法判断

3、某些服务器不能精确的得到文件的最后修改时间;

为此,HTTP1.1引入了ETag。但标准并没有规定ETag的内容是什么或者说要怎么实现,唯一规定的是ETag需要放在双引号内。ETag由服务器端生成,客户端通过If-Match或者说If-None-Match这个条件判断请求来验证资源是否修改。我们常见的是使用If-None-Match。

# Etag缓存示例

请求一个后台数据的流程如下:

第一次请求:

1)客户端发起HTTP get请求一个文件

2)服务器处理请求,返回文件内容和一堆Header,当然包括ETag(例如"1ec5-502264e2ae4c0")(假设服务器支持ETag生成和已经开启了ETag).状态码200,如下图所示

1714302623110.png

第二次请求:

1)客户端发起HTTP GET请求一个文件,这个时候客户端同时发送一个If-None-Match头,这个头的内容就是我们第一次请求时服务器返回的ETag:1ec5-502264e2ae4c0

2)服务器判断发送过来的ETag和计算出来的ETag是匹配的,不返回200,返回304,让客户端继续使用本地缓存。

返回200是:服务器返回结果数据+返回HTTP200状态码返回304是:服务器返回空的结果数据+返回HTTP304状态码,浏览器识别到304之后从浏览器本地缓存提取上次的数据使用

1714302636132.png

# 入门实现案例

Etag缓存所有代码只需要在后端Controller层完成,平台提供了WebUtil.checkEtag和WebUtil.writeETag的封装来简化开发难度。

下面是最简单的Etag实现方式:

 /***
   * 模拟场景:此方法用于返回当前登录用户指定月份的薪资,我们知道薪资发放入库之后一般都不会变化了。
   * 所以如果当前用户调用过指定月份的薪资数据之后,我们就可以打一个Etag标记,后续就不会再发送请求。
   */
  int year = ReqUtil.getInt(request, "year");
  int month = ReqUtil.getInt(request, "month");
  User user = AppContext.getCurrentUser();
  // 假设每个月薪资
  String eTag = String.format("B-%d-%d-%d", user.getId(),year,month);
  
  try {
   // 校验request的If-None-Match是否与服务器生成的一致,如果一致则说明此前已经请求过,直接返回null。
   // PS:WebUtil.checkEtag方法内会自动返回HTTP304
   if(WebUtil.checkEtag(request, response, eTag)){
    return null;
   }
  } catch (IOException e) {
   logger.error("",e);
  }
  
  List result = dataManager.find(user,year,month);
  
  if(CollectionUtils.isNotEmpty(result)){
   // 如果有指定月份的薪资数据,则向浏览器写入当前的ETag,下次浏览器再将ETag带回来做对比
   // 最后一个参数是Etag有效期,默认定缓存30天
   WebUtil.writeETag(request, response, eTag, 1000L * 60 * 60 * 12 * 30);   
  }
  
  return result;

# 增强实现案例

上面是最简单的Etag实现方式,上面的Etag前提是:某人指定年月的薪资100%确认并且永远不会更改了,我们就可以基于“人员ID-年-月”来做Etag标记。而现实场景,我们很多数据可能出现二次更新的情况,比如第一次录入薪资错误了,次月又重新更新了数据。如果我们用上面那种Etag就会导致,数据一直是录入错误的薪资,即使更新了数据库,前端也看不到正确数据。

基于以上问题,平台封装了ETagCacheManager组件来统一管理ETag时间戳,ETagCacheManager的方法只有三个,非常简单实用。

public interface ETagCacheManager {

    /**
     * 根据分类和key获取最后修改时间戳
     * 
     * @param category 分类标识<br>
     *          如:SECTION,记录栏目属性发生变化<br>
     *          如:BUL_TYPE,记录公告板块下数据发生变化<br>
     *          如:BUL_USER,记录用户阅读情况发生变化<br>
     * @param key
     *      如:entityId,栏目ID<br>
     *      如:typeId,公告板块ID<br>
     *      如:userId,人员ID<br>
     * @return
     */
    public Long getETagDate(String category, String key);

    /**
     * 根据分类和key更新时间戳
     * 
     * @param category 分类标识<br>
     *          如:SECTION,记录栏目属性发生变化<br>
     *          如:BUL_TYPE,记录公告板块下数据发生变化<br>
     *          如:BUL_USER,记录用户阅读情况发生变化<br>
     * @param key
     *      如:entityId,栏目ID<br>
     *      如:typeId,公告板块ID<br>
     *      如:userId,人员ID<br>
     */
    public void updateETagDate(String category, String key);

    /**
     * 根据分类清除所有时间戳(如大变动,需要清除整个分类下时间戳)
     * 
     * @param category 分类标识
     */
    public void clearCategoryETagDate(String category);

}

基于上面场景,我们的实现可以修改为:

  int year = ReqUtil.getInt(request, "year");
  int month = ReqUtil.getInt(request, "month");
  User user = AppContext.getCurrentUser();
  // 假设每个月薪资
  String eTagKey = String.format("B-%d-%d-%d", user.getId(),year,month);
  
  ETagCacheManager eTagCacheManager = (ETagCacheManager) AppContext.getBean("eTagCacheManager");
  Long eTag = eTagCacheManager.getETagDate(ApplicationCategoryEnum.hr.name(), eTagKey);
  
  try {
   // 校验request的If-None-Match是否与服务器生成的一致,如果一致则说明此前已经请求过,直接返回null。
   // PS:WebUtil.checkEtag方法内会自动返回HTTP304
   if(eTag != null && WebUtil.checkEtag(request, response, String.valueOf(eTag))){
    return null;
   }
  } catch (IOException e) {
   logger.error("",e);
  }
  
  List result = dataManager.find(user,year,month);
  
  if(CollectionUtils.isNotEmpty(result)){
   eTagCacheManager.updateETagDate(ApplicationCategoryEnum.hr.name(), eTagKey);
   eTag = eTagCacheManager.getETagDate(ApplicationCategoryEnum.hr.name(), eTagKey);
   // 如果有指定月份的薪资数据,则向浏览器写入当前的ETag,下次浏览器再将ETag带回来做对比
   // 最后一个参数是Etag有效期,默认定缓存30天
   WebUtil.writeETag(request, response, String.valueOf(eTag), 1000L * 60 * 60 * 12 * 30);   
  }
  
  return result;

另外,要注意,在薪资进行修改的业务类中,也需要更新ETag时间戳,这样才能保证我们的数据是最新的:

 public void updateSalary() {
  dataManager.updateSalary(user.getId(),year,month,data);
  String eTagKey = String.format("B-%d-%d-%d", user.getId(),year,month);
  ETagCacheManager eTagCacheManager = (ETagCacheManager) AppContext.getBean("eTagCacheManager");
  Long eTag = eTagCacheManager.getETagDate(ApplicationCategoryEnum.hr.name(), eTagKey);
  if(eTag != null) {
   //
   eTagCacheManager.updateETagDate(ApplicationCategoryEnum.hr.name(), eTagKey);
  }
 }

# 常用案例-栏目

一个空间下有多个栏目,每个栏目都要请求不同的业务数据,如果配置的栏目过多,页面渲染速度就会降低。基于此问题,门户在Section中封装了getLastModify方法来做Etag被标记。

下面是一个典型是栏目示例:

各业务Setion需要重写getLastModify方法,组装自己的ETag格式,空间栏目组件会优先调用getLastModify看下是否与浏览器ETag一致,如果一致则不调用业务的查询数据方法,而是直接返回304。

如果业务数据发生变化,则栏目数据要更新,各业务可以通过eTagCacheManager.updateETagDate来更新ETag时间戳。下面这个业务代码利用了Spring切面能力来更新ETag,也是一个无侵入的好方法。

public class RelevanceProjectSetion extends BaseSectionImpl {
 private static final Log logger = CtpLogFactory.getLog(RelevanceProjectSetion.class);
    
 private ETagCacheManager eTagCacheManager;

 public void seteTagCacheManager(ETagCacheManager eTagCacheManager) {
  this.eTagCacheManager = eTagCacheManager;
 }
 
    @Override
 public Long getLastModify(Map<String, String> preference) {
  return eTagCacheManager.getETagDate(ProjectContants.PROJECT_SECTION_ETAG,ProjectContants.PROJECT_SECTION_ETAG);
 }
    
    /**
     * 利用平台的AOP切面监听如下方法,只要以下任何方法被执行,则触发projectChangeEvent方法
     */
    @After({
     "/project/project.do.orderProject",
     "/project/project.do.saveProject",
     "/project/project.do.setPhase",
     "/project/project.do.updateProject",
     "/project/project.do.doImport",
     "/project/project.do.saveProjectType",
     "/project/project.do.updateProjectType",
     "projectConfigManager.updateProjectOrder",
     "projectConfigManager.deleteProject",
     "projectAjaxManager.saveProjectMark",
     "projectAjaxManager.deleteMarkProjectById",
     "projectAjaxManager.updataProjectByField",
     "projectAjaxManager.saveCustomViewType"
    })
    public void projectChangeEvent(){
     // 当项目出现增、删、改,则说明数据发生变化,需要进行Etag时间戳更新
     eTagCacheManager.updateETagDate(ProjectContants.PROJECT_SECTION_ETAG,ProjectContants.PROJECT_SECTION_ETAG);
    }
}
创建人:het
修改人:het