客户端缓存是万维网的基础之一。 服务器应告知客户端资源的有效性,客户端应尽可能快地对其进行缓存。 如我们所见,如果不缓存Web,将会非常慢。 只需在任何网站上Ctrl
+ F5
并将其与普通F5
进行比较-后者就会更快,因为它使用了已缓存的资源。 缓存对于下载也很重要。 如果我们已经获取了几兆字节的数据,并且它们没有改变,则通过网络推送它们是非常浪费的。
使用
HTTP ETag
标头可用于避免重复下载客户端已有的资源。 服务器与第一响应服务器一起返回ETag
标头,该标头通常是文件内容的哈希值。 客户端可以保留ETag
并在以后请求相同资源时将其发送(在If-None-Match
请求标头中)。 如果在此期间未更改,则服务器可以简单地返回304 Not Modified
响应。 让我们从对ETag
支持的集成测试开始:
def 'should send file if ETag not present'() {
expect:
mockMvc
.perform(
get('/download/' + FileExamples.TXT_FILE_UUID))
.andExpect(
status().isOk())
}
def 'should send file if ETag present but not matching'() {
expect:
mockMvc
.perform(
get('/download/' + FileExamples.TXT_FILE_UUID)
.header(IF_NONE_MATCH, '"WHATEVER"'))
.andExpect(
status().isOk())
}
def 'should not send file if ETag matches content'() {
given:
String etag = FileExamples.TXT_FILE.getEtag()
expect:
mockMvc
.perform(
get('/download/' + FileExamples.TXT_FILE_UUID)
.header(IF_NONE_MATCH, etag))
.andExpect(
status().isNotModified())
.andExpect(
header().string(ETAG, etag))
}
有趣的是,Spring框架中内置了ShallowEtagHeaderFilter
。 安装它会使所有测试通过,包括最后一个测试:
@WebAppConfiguration
@ContextConfiguration(classes = [MainApplication])
@ActiveProfiles("test")
class DownloadControllerSpec extends Specification {
private MockMvc mockMvc
@Autowired
public void setWebApplicationContext(WebApplicationContext wac) {
mockMvc = MockMvcBuilders.webAppContextSetup(wac)
.addFilter(new Sha512ShallowEtagHeaderFilter(), "/download/*")
.build()
}
//tests...
}
我实际上插入了使用SHA-512而不是默认MD5的自己的Sha512ShallowEtagHeaderFilter
。 同样由于某种原因,默认实现在哈希值前面加上0
:
public class ShallowEtagHeaderFilter {
protected String generateETagHeaderValue(byte[] bytes) {
StringBuilder builder = new StringBuilder("\"0");
DigestUtils.appendMd5DigestAsHex(bytes, builder);
builder.append('"');
return builder.toString();
}
//...
}
与:
public class Sha512ShallowEtagHeaderFilter extends ShallowEtagHeaderFilter {
@Override
protected String generateETagHeaderValue(byte[] bytes) {
final HashCode hash = Hashing.sha512().hashBytes(bytes);
return "\"" + hash + "\"";
}
}
不幸的是,我们无法使用内置过滤器,因为它们必须首先完全读取响应主体才能计算ETag
。 这基本上关闭了上一篇文章中介绍的主体流传输–整个响应都存储在内存中。 我们必须自己实现ETag
功能。 从技术上讲, If-None-Match
可以包含多个ETag
值。 但是,谷歌浏览器和ShallowEtagHeaderFilter
支持它,因此我们也将跳过它。 为了控制响应头,我们现在返回ResponseEntity<Resource>
:
@RequestMapping(method = GET, value = "/{uuid}")
public ResponseEntity<Resource> download(
@PathVariable UUID uuid,
@RequestHeader(IF_NONE_MATCH) Optional<String> requestEtagOpt) {
return storage
.findFile(uuid)
.map(pointer -> prepareResponse(pointer, requestEtagOpt))
.orElseGet(() -> new ResponseEntity<>(NOT_FOUND));
}
private ResponseEntity<Resource> prepareResponse(FilePointer filePointer, Optional<String> requestEtagOpt) {
return requestEtagOpt
.filter(filePointer::matchesEtag)
.map(this::notModified)
.orElseGet(() -> serveDownload(filePointer));
}
private ResponseEntity<Resource> notModified(String etag) {
log.trace("Cached on client side {}, returning 304", etag);
return ResponseEntity
.status(NOT_MODIFIED)
.eTag(etag)
.body(null);
}
private ResponseEntity<Resource> serveDownload(FilePointer filePointer) {
log.debug("Serving '{}'", filePointer);
final InputStream inputStream = filePointer.open();
final InputStreamResource resource = new InputStreamResource(inputStream);
return ResponseEntity
.status(OK)
.eTag(filePointer.getEtag())
.body(resource);
}
该过程由可选的requestEtagOpt
控制。 如果存在并且与客户端发送的内容匹配,则返回304。否则照常发送200 OK。 本示例中介绍的FilePointer
新方法如下所示:
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.common.io.Files;
public class FileSystemPointer implements FilePointer {
private final File target;
private final HashCode tag;
public FileSystemPointer(File target) {
try {
this.target = target;
this.tag = Files.hash(target, Hashing.sha512());
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
@Override
public InputStream open() {
try {
return new BufferedInputStream(new FileInputStream(target));
} catch (FileNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
@Override
public String getEtag() {
return "\"" + tag + "\"";
}
@Override
public boolean matchesEtag(String requestEtag) {
return getEtag().equals(requestEtag);
}
}
在这里,您将看到FileSystemPointer
实现,该实现直接从文件系统读取文件。 关键部分是缓存标记,而不是在每次请求时都重新计算标记。 上面的实现的行为符合预期,例如,Web浏览器不会再次下载资源。
3.使用
与ETag
和If-None-Match
标头类似,还有Last-Modified
和If-Modified-Since
。 我猜它们很容易解释:第一个服务器返回Last-Modified
响应标头,指示给定资源的最后修改时间( duh! )。 客户端缓存此时间戳,并将其与后续请求一起传递给If-Modified-Since
请求标头中的相同资源。 如果同时未更改资源,则服务器将响应304,从而节省带宽。 这是一个后备机制,同时实现ETag
和Last-Modified
是一个很好的实践。 让我们从集成测试开始:
def 'should not return file if wasn\'t modified recently'() {
given:
Instant lastModified = FileExamples.TXT_FILE.getLastModified()
String dateHeader = toDateHeader(lastModified)
expect:
mockMvc
.perform(
get('/download/' + FileExamples.TXT_FILE_UUID)
.header(IF_MODIFIED_SINCE, dateHeader))
.andExpect(
status().isNotModified())
}
def 'should not return file if server has older version than the client'() {
given:
Instant lastModifiedLaterThanServer = FileExamples.TXT_FILE.getLastModified().plusSeconds(60)
String dateHeader = toDateHeader(lastModifiedLaterThanServer)
expect:
mockMvc
.perform(
get('/download/' + FileExamples.TXT_FILE_UUID)
.header(IF_MODIFIED_SINCE, dateHeader))
.andExpect(
status().isNotModified())
}
def 'should return file if was modified after last retrieval'() {
given:
Instant lastModifiedRecently = FileExamples.TXT_FILE.getLastModified().minusSeconds(60)
String dateHeader = toDateHeader(lastModifiedRecently)
expect:
mockMvc
.perform(
get('/download/' + FileExamples.TXT_FILE_UUID)
.header(IF_MODIFIED_SINCE, dateHeader))
.andExpect(
status().isOk())
}
private static String toDateHeader(Instant lastModified) {
ZonedDateTime dateTime = ZonedDateTime.ofInstant(lastModified, ZoneOffset.UTC)
DateTimeFormatter.RFC_1123_DATE_TIME.format(dateTime)
}
并执行:
@RequestMapping(method = GET, value = "/{uuid}")
public ResponseEntity<Resource> download(
@PathVariable UUID uuid,
@RequestHeader(IF_NONE_MATCH) Optional<String> requestEtagOpt,
@RequestHeader(IF_MODIFIED_SINCE) Optional<Date> ifModifiedSinceOpt
) {
return storage
.findFile(uuid)
.map(pointer -> prepareResponse(
pointer,
requestEtagOpt,
ifModifiedSinceOpt.map(Date::toInstant)))
.orElseGet(() -> new ResponseEntity<>(NOT_FOUND));
}
private ResponseEntity<Resource> prepareResponse(FilePointer filePointer, Optional<String> requestEtagOpt, Optional<Instant> ifModifiedSinceOpt) {
if (requestEtagOpt.isPresent()) {
final String requestEtag = requestEtagOpt.get();
if (filePointer.matchesEtag(requestEtag)) {
return notModified(filePointer);
}
}
if (ifModifiedSinceOpt.isPresent()) {
final Instant isModifiedSince = ifModifiedSinceOpt.get();
if (filePointer.modifiedAfter(isModifiedSince)) {
return notModified(filePointer);
}
}
return serveDownload(filePointer);
}
private ResponseEntity<Resource> serveDownload(FilePointer filePointer) {
log.debug("Serving '{}'", filePointer);
final InputStream inputStream = filePointer.open();
final InputStreamResource resource = new InputStreamResource(inputStream);
return response(filePointer, OK, resource);
}
private ResponseEntity<Resource> notModified(FilePointer filePointer) {
log.trace("Cached on client side {}, returning 304", filePointer);
return response(filePointer, NOT_MODIFIED, null);
}
private ResponseEntity<Resource> response(FilePointer filePointer, HttpStatus status, Resource body) {
return ResponseEntity
.status(status)
.eTag(filePointer.getEtag())
.lastModified(filePointer.getLastModified().toEpochMilli()).body(body);
}
可悲的是,习惯上使用Optional
不再看起来不错,所以我坚持使用isPresent()
。 我们同时检查If-Modified-Since
和If-None-Match
。 如果两者都不匹配,我们将照常提供文件。 只是为了让您了解这些标头的工作方式,让我们执行一些端到端测试。 第一个要求:
> GET /download/4a8883b6-ead6-4b9e-8979-85f9846cab4b HTTP/1.1
> ...
>
< HTTP/1.1 200 OK
< ETag: "8b97c678a7f1d2e0af...921228d8e"
< Last-Modified: Sun, 17 May 2015 15:45:26 GMT
< ...
带有ETag
后续请求(已缩短):
> GET /download/4a8883b6-ead6-4b9e-8979-85f9846cab4b HTTP/1.1
> If-None-Match: "8b97c678a7f1d2e0af...921228d8e"
> ...
>
< HTTP/1.1 304 Not Modified
< ETag: "8b97c678a7f1d2e0af...921228d8e"
< Last-Modified: Sun, 17 May 2015 15:45:26 GMT
< ...
如果我们的客户仅支持Last-Modified
:
> GET /download/4a8883b6-ead6-4b9e-8979-85f9846cab4b HTTP/1.1
> If-Modified-Since: Tue, 19 May 2015 06:59:55 GMT
> ...
>
< HTTP/1.1 304 Not Modified
< ETag: "8b97c678a7f1d2e0af9cda473b36c21f1b68e35b93fec2eb5c38d182c7e8f43a069885ec56e127c2588f9495011fd8ce032825b6d3136df7adbaa1f921228d8e"
< Last-Modified: Sun, 17 May 2015 15:45:26 GMT
有许多内置工具,例如过滤器,可以为您处理缓存。 但是,如果您需要确保在服务器端流传输文件而不是对其进行预先缓冲,则需要格外小心。
编写下载服务器
- 第一部分:始终流式传输,永远不要完全保留在内存中
- 第二部分:标头:Last-Modified,ETag和If-None-Match
- 第三部分:标头:内容长度和范围
- 第四部分:有效地实现
HEAD
操作 - 第五部分:油门下载速度
- 第六部分:描述您发送的内容(内容类型等)
- 这些文章中开发的示例应用程序可在GitHub上找到。