# HTTP304缓存
本文Etag的简介和作用大量摘抄自知乎:ETag简介与作用 (opens new window)
ETag是URL的tag,用来标示URL对象是否改变。这样可以应用于客户端的缓存:服务器产生ETag,并在HTTP响应头中将其传送到客户端,服务器用它来判断页面是否被修改过,如果未修改返回304,无需传输整个对象。
页面渲染速度大头取决于:静态文件下载速度和动态请求返回速度。通过Fiddler或浏览器F12开发者工具能够看到每个页面的加载情况。
对于css、静态图片、js文件这类请求文件,第一次访问页面之后都会被浏览器缓存以提升二次访问效率。
对于后台请求,默认不会缓存,而有的后端数据经常几小时、几天都无变化,如果被高频访问,就白白浪费服务器资源。Etag就是用来解决这类长期无数据更新的后台请求的,使用Etag机制之后即使访问后台也不返回数据,数据都从浏览器缓存处获取。
# 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,如下图所示
第二次请求:
1)客户端发起HTTP GET请求一个文件,这个时候客户端同时发送一个If-None-Match头,这个头的内容就是我们第一次请求时服务器返回的ETag:1ec5-502264e2ae4c0
2)服务器判断发送过来的ETag和计算出来的ETag是匹配的,不返回200,返回304,让客户端继续使用本地缓存。
返回200是:服务器返回结果数据+返回HTTP200状态码返回304是:服务器返回空的结果数据+返回HTTP304状态码,浏览器识别到304之后从浏览器本地缓存提取上次的数据使用
# 入门实现案例
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);
}
}