Загрузка файлов
К статье блога хочется приложить картинку. Файл не помещается в 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: uploadsmax-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/imagesHTTP/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) без тела |
Несуществующее имя в GET | 404, "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", иначе файл не отправится.