Что касается HTTP, то клиент загружает всего несколько байтов. Однако клиент действительно хотел бы знать, как интерпретировать эти байты. Это изображение? Или, может быть, ZIP-файл? В последней части этой серии описывается, как дать подсказку клиенту о том, что она загружает.
Установить заголовок ответа типа содержимого
Тип содержимого описывает MIME-тип возвращаемого ресурса. Этот заголовок указывает веб-браузеру, как обрабатывать поток байтов, поступающий с сервера загрузки. Без этого заголовка браузер не знает, что он на самом деле получил, и просто отображает контент, как если бы он был текстовым файлом. Само собой разумеется, двоичный 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, и мы избегаем потенциальных проблем. Зная тип носителя, мы должны вернуть его в ответ. Обратите внимание, что этот небольшой фрагмент кода использует как Optional из JDK 8 и Guava, так и класс MediaType из среды Spring и Guava. Какой тип системы беспорядок!
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);
}
Сохранить оригинальное имя файла и расширение
Хотя Content-type отлично работает, когда вы открываете документ прямо в веб-браузере, представьте, что ваш пользователь хранит этот документ на диске. Решение о том, решит ли браузер отобразить или сохранить загруженный файл, выходит за рамки этой статьи, но мы должны быть готовы к тому и другому. Если браузер просто сохраняет файл на диске, он должен сохранить его под некоторым именем. Firefox по умолчанию будет использовать последнюю часть URL, которая в нашем случае является UUID ресурса. Не очень удобно для пользователя. Chrome немного лучше — зная тип MIME из заголовка типа Content, он эвристически добавляет соответствующее расширение, например .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 определенно принесет больше улучшений и методов, таких как расстановка приоритетов.
Написание сервера загрузки
- Часть I: Всегда передавайте, никогда не храните полностью в памяти
- Часть II: заголовки: последние изменения, ETag и If-None-Match
- Часть III: заголовки: длина содержимого и диапазон
- Часть IV: Реализация
HEAD
операции (эффективно) - Часть V: Скорость загрузки газа
- Часть VI. Опишите, что вы отправляете (Content-type, et.al.)
Пример приложения разработаны на протяжении этих статей доступен на GitHub.