Статьи

Написание сервера загрузки — часть VI: опишите, что вы отправляете

Что касается 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 определенно принесет больше улучшений и методов, таких как расстановка приоритетов.

Написание сервера загрузки

Пример приложения  разработаны на протяжении этих статей доступен на GitHub.