就HTTP而言,客户端下载的只是一堆字节。 但是,客户真的很想知道如何解释这些字节。 它是图像吗? 或者也许是ZIP文件? 本系列的最后一部分描述了如何向客户端提示她下载的内容。
设置
内容类型描述了要返回的资源的MIME类型 。 此标头指示Web浏览器如何处理从下载服务器流出的字节流。 如果没有此标头,浏览器将无法得知其实际接收到的内容,只会像显示文本文件一样显示内容。 不用说二进制PDF(请参见上面的屏幕截图),像文本文件一样显示的图像或视频看起来并不好。 最难的部分是以某种方式实际获得媒体类型。 幸运的是,Java本身有一个用于根据资源的扩展名和/或内容来猜测媒体类型的工具:
import com.google.common.net.MediaType;
import java.io.*;
import java.time.Instant;public class FileSystemPointer implements FilePointer {private final MediaType mediaTypeOrNull;public FileSystemPointer(File target) {final String contentType = java.nio.file.Files.probeContentType(target.toPath());this.mediaTypeOrNull = contentType != null ?MediaType.parse(contentType) :null;}
请注意,使用Optional<T>
作为类字段不是惯用的,因为它不是可Serializable
并且我们避免了潜在的问题。 知道媒体类型后,我们必须在响应中返回它。 注意,这一小段代码使用了JDK 8和Guava中的Optional
,以及Spring框架和Guava中的MediaType
类。 多么糟糕的类型系统!
private ResponseEntity<Resource> response(FilePointer filePointer, HttpStatus status, Resource body) {final ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.status(status).eTag(filePointer.getEtag()).contentLength(filePointer.getSize()).lastModified(filePointer.getLastModified().toEpochMilli());filePointer.getMediaType().map(this::toMediaType).ifPresent(responseBuilder::contentType);return responseBuilder.body(body);
}private MediaType toMediaType(com.google.common.net.MediaType input) {return input.charset().transform(c -> new MediaType(input.type(), input.subtype(), c)).or(new MediaType(input.type(), input.subtype()));
}@Override
public Optional<MediaType> getMediaType() {return Optional.ofNullable(mediaTypeOrNull);
}
保留原始文件名和扩展名
当您直接在Web浏览器中打开文档时,虽然Content-type
效果很好,但是可以想象您的用户将该文档存储在磁盘上。 浏览器是决定显示还是存储下载的文件不在本文的讨论范围之内,但是我们应该为两者做好准备。 如果浏览器只是将文件存储在磁盘上,则必须使用某种名称进行保存。 默认情况下,Firefox将使用URL的最后一部分,在本例中,该部分恰好是资源的UUID。 不太用户友好。 铬是好一点-知道根据MIME类型Content-type
报头,将试探性地加入适当的扩展名,例如.zip
中的情况下, application/zip
。 但是文件名仍然是随机的UUID,而用户上传的文件可能是cats.zip
。 因此,如果您的目标是浏览器而不是自动化客户端,则最好使用真实名称作为URL的最后一部分。 我们仍然希望使用UUID在内部区分资源,避免冲突并且不公开我们的内部存储结构。 但是在外部,我们可以重定向到用户友好的URL,但为了安全起见保留UUID。 首先,我们需要一个额外的端点:
@RequestMapping(method = {GET, HEAD}, value = "/{uuid}")
public ResponseEntity<Resource> redirect(HttpMethod method,@PathVariable UUID uuid,@RequestHeader(IF_NONE_MATCH) Optional<String> requestEtagOpt,@RequestHeader(IF_MODIFIED_SINCE) Optional<Date> ifModifiedSinceOpt) {return findExistingFile(method, uuid).map(file -> file.redirect(requestEtagOpt, ifModifiedSinceOpt)).orElseGet(() -> new ResponseEntity<>(NOT_FOUND));
}@RequestMapping(method = {GET, HEAD}, value = "/{uuid}/{filename}")
public ResponseEntity<Resource> download(HttpMethod method,@PathVariable UUID uuid,@RequestHeader(IF_NONE_MATCH) Optional<String> requestEtagOpt,@RequestHeader(IF_MODIFIED_SINCE) Optional<Date> ifModifiedSinceOpt) {return findExistingFile(method, uuid).map(file -> file.handle(requestEtagOpt, ifModifiedSinceOpt)).orElseGet(() -> new ResponseEntity<>(NOT_FOUND));
}private Optional<ExistingFile> findExistingFile(HttpMethod method, @PathVariable UUID uuid) {return storage.findFile(uuid).map(pointer -> new ExistingFile(method, pointer, uuid));
}
如果仔细观察,甚至没有使用{filename}
,它只是浏览器的提示。 如果需要额外的安全性,可以将提供的文件名与映射到给定UUID
文件名进行比较。 这里真正重要的是,仅要求提供UUID
重定向我们:
$ curl -v localhost:8080/download/4a8883b6-ead6-4b9e-8979-85f9846cab4b
> GET /download/4a8883b6-ead6-4b9e-8979-85f9846cab4b HTTP/1.1
...
< HTTP/1.1 301 Moved Permanently
< Location: /download/4a8883b6-ead6-4b9e-8979-85f9846cab4b/cats.zip
而且您需要进行一次额外的网络行程来获取实际文件:
> GET /download/4a8883b6-ead6-4b9e-8979-85f9846cab4b/cats.zip HTTP/1.1
...
>
HTTP/1.1 200 OK
< ETag: "be20c3b1...fb1a4"
< Last-Modified: Thu, 21 Aug 2014 22:44:37 GMT
< Content-Type: application/zip;charset=UTF-8
< Content-Length: 489455
该实现很简单,但是为了避免重复,对其进行了一些重构:
public ResponseEntity<Resource> redirect(Optional<String> requestEtagOpt, Optional<Date> ifModifiedSinceOpt) {if (cached(requestEtagOpt, ifModifiedSinceOpt))return notModified(filePointer);return redirectDownload(filePointer);
}public ResponseEntity<Resource> handle(Optional<String> requestEtagOpt, Optional<Date> ifModifiedSinceOpt) {if (cached(requestEtagOpt, ifModifiedSinceOpt))return notModified(filePointer);return serveDownload(filePointer);
}private boolean cached(Optional<String> requestEtagOpt, Optional<Date> ifModifiedSinceOpt) {final boolean matchingEtag = requestEtagOpt.map(filePointer::matchesEtag).orElse(false);final boolean notModifiedSince = ifModifiedSinceOpt.map(Date::toInstant).map(filePointer::modifiedAfter).orElse(false);return matchingEtag || notModifiedSince;
}private ResponseEntity<Resource> redirectDownload(FilePointer filePointer) {try {log.trace("Redirecting {} '{}'", method, filePointer);return ResponseEntity.status(MOVED_PERMANENTLY).location(new URI("/download/" + uuid + "/" + filePointer.getOriginalName())).body(null);} catch (URISyntaxException e) {throw new IllegalArgumentException(e);}
}private ResponseEntity<Resource> serveDownload(FilePointer filePointer) {log.debug("Serving {} '{}'", method, filePointer);final InputStreamResource resource = resourceToReturn(filePointer);return response(filePointer, OK, resource);
}
您甚至可以进一步使用高阶函数来避免重复:
public ResponseEntity<Resource> redirect(Optional<String> requestEtagOpt, Optional<Date> ifModifiedSinceOpt) {return serveWithCaching(requestEtagOpt, ifModifiedSinceOpt, this::redirectDownload);
}public ResponseEntity<Resource> handle(Optional<String> requestEtagOpt, Optional<Date> ifModifiedSinceOpt) {return serveWithCaching(requestEtagOpt, ifModifiedSinceOpt, this::serveDownload);
}private ResponseEntity<Resource> serveWithCaching(Optional<String> requestEtagOpt, Optional<Date> ifModifiedSinceOpt, Function<FilePointer, ResponseEntity<Resource>> notCachedResponse) {if (cached(requestEtagOpt, ifModifiedSinceOpt))return notModified(filePointer);return notCachedResponse.apply(filePointer);
}
显然,额外的重定向是每次下载都必须支付的额外费用,因此这是一个折衷方案。 您可以考虑基于User-agent
启发式(如果是浏览器,则为重定向;如果是自动客户端,则为服务器),以避免非人工客户端的重定向。 这样就结束了我们有关文件下载的系列文章。 HTTP / 2的出现必将带来更多的改进和技术,例如确定优先级。
编写下载服务器
- 第一部分:始终流式传输,永远不要完全保留在内存中
- 第二部分:标头:Last-Modified,ETag和If-None-Match
- 第三部分:标头:内容长度和范围
- 第四部分:有效地实现
HEAD
操作 - 第五部分:油门下载速度
- 第六部分:描述您发送的内容(内容类型等)
- 这些文章中开发的示例应用程序可在GitHub上找到。
翻译自: https://www.javacodegeeks.com/2015/07/writing-a-download-server-part-vi-describe-what-you-send-content-type-et-al.html