在我们的系统中无时无刻都在使用缓存。而这个缓存可以用在很多地方,比如,数据库查询缓存,hibernate等对数据的缓存,memcached对数据的缓存,系统缓存,浏览器缓存等等。这里不打算深入各个种类的缓存,而是着重强调浏览器端的缓存机制,这个缓存机制主要依靠HTTP协议达成浏览器与服务器缓存之间的协商。而要完成这个机制,与服务器的配合是密不可少的,所以,介绍他们的同时,也会从tomcat的源码来加深认识。
首先来看最初也是最简单的方式,我们这里简称last-modified方式。这个方式工作原理如下,第一步,客户端需要请求一个资源,假设是index.html;第二步,服务器返回该资源的同时,会在HTTP的响应头附带一个header Last-modified表明该文件的上次修改时间,客户端接到该响应,先将资源缓存,并且记下这个上次修改时间;第三步,每次客户端想要再次请求该资源时,会在HTTP的请求头附带一个header If-Modified-Since,并且附上那个文件的上次修改时间,其目的在于问服务器,上次修改后至今是否又再修改过;第四步,服务器根据该文件的时间戳以及请求头中该文件的上次修改时间来确定到底是否该返回真实文件,如果时间戳大于上次修改时间,说明改过,则读取该文件并返回之,如果时间戳小于上次修改时间,说明没有改过,服务器返回一个HTTP状态码304表示Not modified,客户端看到该状态码,则自动使用浏览器的本地缓存。可以看到,每次浏览器发起请求时,只要缓存有效,则节省了传输文件的带宽跟时间。如下图:
接下来,我们看看tomcat是如何实现这个机制的。在tomcat中,对动态请求设置Last-Modified是在servlet中service方法中实现的,而且,默认的实现只支持GET方法。getLastModified方法是一个protected方法,默认返回了-1,这意味着如果你想要支持last-modified方式就必须在serlvet类中自己实现getLastModified方法,默认是不支持的(即getLastModified是否返回-1就是是否支持该方式的开关),接下来,如果请求头中有If-Modified-Since,并且这个时间又小于文件的真实修改时间,说明我们需要返回给客户端更新的资源,即再次设置lastModified标记,以及返回该资源(即doGet()),如果If-Modified-Since大于文件的真实修改时间,说明文件自上次修改以来未被修改过,则直接返回304(即SC_NOT_MODIFIED)。
这段代码说明几个问题:
1. 如果你想使用到last-modified方式,就不要复写service方法(除非你想自己实现)
2. 如果要开启last-modified方式,请复写getLastModified方法
3. 如果你没看懂这句lastModified / 1000 * 1000,可以程序实际测试下,其实他只是将1000以下的数置为0
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String method = req.getMethod();
if (method.equals(METHOD_GET)) {
long lastModified = getLastModified(req);
if (lastModified == -1) {
doGet(req, resp);
} else {
long ifModifiedSince = req.getDateHeader(HEADER_IFMODSINCE);
if (ifModifiedSince < (lastModified / 1000 * 1000)) {
maybeSetLastModified(resp, lastModified);
doGet(req, resp);
} else {
resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
}
}
} else if (method.equals(METHOD_HEAD)) {
long lastModified = getLastModified(req);
maybeSetLastModified(resp, lastModified);
doHead(req, resp);
} else if (method.equals(METHOD_POST)) {
doPost(req, resp);
}...
else {
String errMsg = lStrings.getString("http.method_not_implemented");
Object[] errArgs = new Object[1];
errArgs[0] = method;
errMsg = MessageFormat.format(errMsg, errArgs);
resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED, errMsg);
}
}
第一种方式成功的完成了浏览器和客户端缓存的协商,能够减少很多无谓的流量。但是,这种方式有缺点吗?可以考虑以下的情况:1. 如果一个网页频繁的被更新,可是实际内容并没有被改变;2.如果采用了负载均衡,该文件实际上被存在了多台服务器上,如何保证被请求的每台服务器都有相同的时间呢?
所以,接下来就是第二种方式 ETag,ETag的方式大致与last-modified一致,不同的是,服务器端回送ETag标志而不是Last-Modified,而这个ETag后面不再是跟文件的修改时间,而是自定义的字符串。如下图:
仔细想一下,etag方式恰好解决了last-modified方式带来的问题。
在tomcat中,etag的控制是放在DefaultServlet中处理默认静态资源时发生的(DefaultServlet也用了last-modified方式,请自行查阅相关代码)。关于这部分代码逻辑,解释如下(部分不重要代码已去掉)。
在resources.lookupCache中,会查找缓存中该资源的相关元信息,如果找不到,也会到绝对路径查找是否有该资源,如果有则新建出来,没有则cacheEntry.exists为false。在checkIfHeaders中,会依次检查If-Modified-Since和If-None-Match,如果请求中包含这两个header,并且上次修改日期比实际修改日期靠后或者etag一致,说明该静态文件没有改过,在checkIfHeaders调用结束后就直接返回了;反之,则说明该静态文件已经改过,在继续下面的代码时,会添上Etag和last-modified返回头以便通知浏览器该资源的缓存情况。
protected void serveResource(HttpServletRequest request,
HttpServletResponse response,
boolean content)
throws IOException, ServletException {
String path = getRelativePath(request);
CacheEntry cacheEntry = resources.lookupCache(path);
if (!cacheEntry.exists) {
String requestUri = (String) request.getAttribute(
Globals.INCLUDE_REQUEST_URI_ATTR);
if (requestUri == null) {
requestUri = request.getRequestURI();
} else {
response.getWriter().write(RequestUtil.filter(
sm.getString("defaultServlet.missingResource",
requestUri)));
}
response.sendError(HttpServletResponse.SC_NOT_FOUND,
requestUri);
return;
}
if (cacheEntry.context == null) {
// Checking If headers
boolean included =
(request.getAttribute(Globals.INCLUDE_CONTEXT_PATH_ATTR) != null);
if (!included && !isError &&
!checkIfHeaders(request, response, cacheEntry.attributes)) {
return;
}
}
if (cacheEntry.context != null) {
if (!listings) {
response.sendError(HttpServletResponse.SC_NOT_FOUND,
request.getRequestURI());
return;
}
contentType = "text/html;charset=UTF-8";
} else {
if (!isError) {
if (useAcceptRanges) {
response.setHeader("Accept-Ranges", "bytes");
}
ranges = parseRange(request, response, cacheEntry.attributes);
response.setHeader("ETag", cacheEntry.attributes.getETag());
response.setHeader("Last-Modified",
cacheEntry.attributes.getLastModifiedHttp());
}
contentLength = cacheEntry.attributes.getContentLength();
if (contentLength == 0L) {
content = false;
}
}
}
但是,etag方式有缺点吗?如果是一个3秒钟定时刷新的ajax请求,那就意味着每三秒客户端都会去请求一次,即使服务器端告诉他,该资源并没有过期,这无疑会占用大量带宽。
所以,HTTP1.1又有了新的改进方式,Expires方式只会发送一个Expires头表示该资源将会在某时刻(绝对时间)过期,这样一来,就改进了last-modified和etag方式的缺点,因为浏览器再也不用一直请求了,所有请求都可以通过本地判断是否已经过期来避免额外的请求。
当然,expires仍然有缺点,怎么能保证服务器和客户端的时间一致呢?
所有,最后有了Cache-Control,Cache-Control与Expires方式相同,不一样的地方是Cache-Control会发送相对时间(比如相对客户端延后3600秒)
在tomcat中Expires和Cache-Control也是在一起的。主要是在以下这些地方用到:
AuthenticatorBase: invoke方法中会添加Cache-Control和Expires,添加这些头主要是让缓存失效。因为认证资源可是不能缓存的
HTMLManagerServlet: displaySessionsListPage中添加Cache-Control和Expires,让缓存失效。可以想象,显示session的资源也是不能缓存的