Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Загрузка файлов

К статье блога хочется приложить картинку. Файл не помещается в JSON, поэтому его отправляют особым видом запроса — multipart/form-data. Тело такого запроса разделено на части: в одной лежит файл, в других — обычные поля. Так браузер отправляет форму с полем <input type="file">, и так же умеет curl. Spring собирает часть с файлом в объект MultipartFile.

Где хранить файлы

Сохраним картинки в папку uploads рядом с проектом, а её адрес вынесем в настройки:

# src/main/resources/application.yaml
spring:
  servlet:
    multipart:
      max-file-size: 2MB
      max-request-size: 2MB
 
blog:
  upload-dir: uploads

max-file-size ограничивает один файл, max-request-size — весь запрос целиком. Без этих строк действует ограничение Spring Boot по умолчанию: 1 МБ на файл и 10 МБ на запрос. Папку uploads/ добавьте в .gitignore.

Сервис для файлов

Работу с диском держим в сервисе, как и раньше:

// src/main/java/com/example/blog/ImageStorage.java
package com.example.blog;
 
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Set;
import java.util.UUID;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
 
@Service
public class ImageStorage {
 
    private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg");
 
    private final Path dir;
 
    public ImageStorage(@Value("${blog.upload-dir}") Path dir) throws IOException {
        this.dir = dir;
        Files.createDirectories(dir);
    }
 
    public String save(MultipartFile file) throws IOException {
        if (file.isEmpty() || !ALLOWED.contains(file.getContentType())) {
            throw new IllegalArgumentException("Нужна картинка PNG или JPEG");
        }
        String extension = file.getContentType().equals("image/png") ? ".png" : ".jpg";
        String name = UUID.randomUUID() + extension;
        try (InputStream in = file.getInputStream()) {
            Files.copy(in, dir.resolve(name), StandardCopyOption.REPLACE_EXISTING);
        }
        return name;
    }
 
    public Path find(String name) {
        Path path = dir.resolve(name).normalize();
        if (!path.startsWith(dir) || !Files.isRegularFile(path)) {
            throw new ImageNotFoundException(name);
        }
        return path;
    }
}

Имя файла от клиента мы не используем: в нём может оказаться ../ или чужое имя. Вместо него сервис придумывает случайное через UUID. Проверка startsWith(dir) в find не даёт выйти из папки uploads. Класс ImageNotFoundException устроен так же, как PostNotFoundException, а в обработчик ошибок добавим два метода:

// src/main/java/com/example/blog/ApiExceptionHandler.java
@ExceptionHandler(ImageNotFoundException.class)
public ProblemDetail handleImageNotFound(ImageNotFoundException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
 
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}

Теперь отсутствующий файл даёт ответ 404, а неподходящий — 400.

Контроллер

// src/main/java/com/example/blog/ImageController.java
package com.example.blog;
 
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.Map;
 
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
 
@RestController
@RequestMapping("/api/images")
public class ImageController {
 
    private final ImageStorage storage;
 
    public ImageController(ImageStorage storage) {
        this.storage = storage;
    }
 
    @PostMapping
    public ResponseEntity<Map<String, String>> upload(@RequestParam("file") MultipartFile file) throws IOException {
        String name = storage.save(file);
        String url = "/api/images/" + name;
        return ResponseEntity.created(URI.create(url)).body(Map.of("url", url));
    }
 
    @GetMapping("/{name}")
    public ResponseEntity<Resource> download(@PathVariable String name) {
        Path path = storage.find(name);
        MediaType type = MediaTypeFactory.getMediaType(name).orElse(MediaType.APPLICATION_OCTET_STREAM);
        return ResponseEntity.ok().contentType(type).body(new FileSystemResource(path));
    }
}

@RequestParam("file") берёт из запроса часть с именем file. Метод download возвращает Resource — Spring сам прочитает файл и отправит его байты. MediaTypeFactory подбирает тип по расширению, чтобы браузер показал картинку, а не предложил её скачать.

Проверка

Ключ -F заставляет curl отправить запрос multipart/form-data, а @ перед именем — приложить файл:

curl -i -F "file=@cat.png" http://localhost:8080/api/images
HTTP/1.1 201
Location: /api/images/9cd77aa2-9863-4416-a5bf-62ed5ca41327.png
Content-Type: application/json
 
{"url":"/api/images/9cd77aa2-9863-4416-a5bf-62ed5ca41327.png"}

Файл появился в папке uploads. Откройте адрес из ответа в браузере — покажется картинка. Заголовки ответа на GET: Content-Type: image/png и Content-Length с размером файла.

Что получит клиент при ошибке

ЗапросОтвет
Текстовый файл вместо картинки400, "detail":"Нужна картинка PNG или JPEG"
Файл больше 2 МБ413 (Payload Too Large) без тела
Несуществующее имя в GET404, "detail":"Файл nope.png не найден"

При слишком большом файле в журнале появится строка MaxUploadSizeExceededException: Maximum upload size exceeded.

Частая ошибка

Имя части в запросе должно совпадать с именем в @RequestParam. Если отправить -F "image=@cat.png", а метод ждёт file, придёт ответ 400, а в журнале будет:

Resolved [org.springframework.web.multipart.support.MissingServletRequestPartException: Required part 'file' is not present.]

То же касается HTML-формы: у поля должно быть name="file", а у самой формы — enctype="multipart/form-data", иначе файл не отправится.

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.