Перейти к содержанию

Файлы, async, локализация и сессии

20. Работа с файлами

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

Spring MVC обрабатывает multipart/form-data через MultipartResolver.

Boot автоконфигурирует StandardServletMultipartResolver.

Настройки (application.properties):

spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB

20.2. MultipartFile

@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
    if (file.isEmpty()) {
        return ResponseEntity.badRequest().body("Empty file");
    }
    String filename = StringUtils.cleanPath(file.getOriginalFilename());
    Path targetPath = uploadDir.resolve(filename);
    Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
    return ResponseEntity.ok("Uploaded: " + filename);
}

В production нельзя доверять getOriginalFilename() напрямую: проверяйте path traversal (..), whitelist расширений/media types, лимиты размера и права доступа. Часто безопаснее генерировать свое имя файла и хранить оригинальное имя отдельно как metadata.

Несколько файлов:

@PostMapping("/upload-multiple")
public List<String> uploadMultiple(@RequestParam("files") List<MultipartFile> files) {
    return files.stream()
        .map(f -> storageService.store(f))
        .toList();
}

20.3. multipart/form-data

HTML-форма для загрузки файла:

<form th:action="@{/upload}" method="post" enctype="multipart/form-data">
    <input type="file" name="file"/>
    <button type="submit">Upload</button>
</form>

enctype="multipart/form-data" — обязательно!

20.4. Отдача файлов клиенту

@GetMapping("/files/{filename:.+}")
public ResponseEntity<Resource> download(@PathVariable String filename) throws IOException {
    Path filePath = uploadDir.resolve(filename).normalize();
    Resource resource = new UrlResource(filePath.toUri());

    if (!resource.exists()) {
        return ResponseEntity.notFound().build();
    }

    String contentType = Files.probeContentType(filePath);
    if (contentType == null) {
        contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
    }

    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(contentType))
        .header(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"" + resource.getFilename() + "\"")
        .body(resource);
}

Для скачивания пользовательских файлов обязательно проверяйте, что normalize() не вывел путь за пределы upload-директории:

Path root = uploadDir.toAbsolutePath().normalize();
Path filePath = root.resolve(filename).normalize();
if (!filePath.startsWith(root)) {
    throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid filename");
}

20.5. Resource

Resource — Spring-абстракция над файлами и потоками данных.

Реализации: - FileSystemResource — файл на диске, - ClassPathResource — файл из classpath, - UrlResource — файл по URL, - ByteArrayResource — данные в памяти.

20.6. Content-Disposition

Заголовок Content-Disposition контролирует поведение браузера:

// Скачать файл (браузер предложит сохранить):
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.pdf\"")

// Открыть в браузере (inline):
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"image.png\"")

21. Redirect и Forward

21.1. Что такое redirect

Redirect — сервер отвечает 3xx с заголовком Location. Браузер делает новый HTTP-запрос по новому URL.

  • URL в браузере меняется.
  • Создается новый request/response.
  • Данные из первого request не доступны (только flash attributes).
  • Два HTTP-запроса.

21.2. Что такое forward

Forward — сервер внутри себя передает обработку другому handler'у.

  • URL в браузере не меняется.
  • Тот же request/response.
  • Данные request доступны в target.
  • Один HTTP-запрос.

21.3. Разница между redirect и forward

Redirect Forward
HTTP-запросов 2 1
URL меняется да нет
Request-объект новый тот же
Данные из оригинального request нет да
Используется для POST -> redirect -> GET внутренняя передача управления

21.4. PRG pattern (Post-Redirect-Get)

Паттерн для предотвращения двойной отправки формы:

1. POST /register — обработка формы
2. redirect:/login  — 302 Found, Location: /login
3. GET /login  — браузер делает новый запрос

Без redirect: при обновлении страницы браузер предлагает повторить POST. С redirect: обновление страницы повторяет GET (безопасно).

@PostMapping("/register")
public String register(@Valid @ModelAttribute RegisterForm form, BindingResult result) {
    if (result.hasErrors()) return "register";
    userService.register(form);
    return "redirect:/login?registered"; // PRG
}

21.5. Когда использовать каждый вариант

  • Redirect: после успешного POST (PRG), перенаправление на другую страницу, смена URL.
  • Forward: когда нужно передать управление другому контроллеру с тем же request (редко).

22. Асинхронная обработка в Spring MVC

22.1. Асинхронность в servlet stack

Spring MVC работает на Servlet API, где один запрос = один поток. Servlet 3.0+ поддерживает async processing: поток можно освободить, пока идет долгая операция, а затем вернуть результат.

Важно: Spring MVC async освобождает servlet-поток, но не делает блокирующий код неблокирующим. Если внутри Callable выполняется JDBC-запрос или blocking HTTP call, он всё равно занимает поток executor'а. Для настоящего non-blocking I/O нужен WebFlux/reactive stack или неблокирующие клиенты.

Для production обычно настраивают: - AsyncTaskExecutor, - timeout, - propagation SecurityContext/MDC, - обработку timeout/error callbacks.

22.2. Callable

Контроллер возвращает Callable<T>. Spring выполняет его в отдельном потоке (из TaskExecutor), освобождая servlet-поток.

@GetMapping("/heavy")
public Callable<String> heavyOperation() {
    return () -> {
        Thread.sleep(5000); // симуляция долгой работы
        return "result";    // выполняется в другом потоке
    };
}

22.3. DeferredResult

Результат устанавливается извне (из другого потока, по событию, из MQ):

@GetMapping("/subscribe")
public DeferredResult<String> subscribe() {
    DeferredResult<String> result = new DeferredResult<>(10_000L); // timeout 10s

    // Кто-то установит результат позже:
    eventBus.subscribe(event -> result.setResult(event.getData()));

    result.onTimeout(() -> result.setResult("timeout"));
    return result;
}

22.4. WebAsyncTask

Как Callable, но с возможностью задать timeout и executor:

@GetMapping("/task")
public WebAsyncTask<String> asyncTask() {
    return new WebAsyncTask<>(5000L, () -> heavyService.process());
}

22.5. StreamingResponseBody

Потоковая отдача больших данных без буферизации:

@GetMapping("/export")
public StreamingResponseBody export() {
    return outputStream -> {
        try (PrintWriter writer = new PrintWriter(outputStream)) {
            for (int i = 0; i < 1_000_000; i++) {
                writer.println("line " + i);
                writer.flush();
            }
        }
    };
}

22.6. SseEmitter

Server-Sent Events — сервер push'ит события клиенту через постоянное HTTP-соединение:

@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents() {
    SseEmitter emitter = new SseEmitter(30_000L);

    executor.submit(() -> {
        try {
            for (int i = 0; i < 10; i++) {
                emitter.send(SseEmitter.event()
                    .name("update")
                    .data("event " + i));
                Thread.sleep(1000);
            }
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });

    return emitter;
}

23. Статические ресурсы

23.1. Что считается static content

CSS, JavaScript, изображения, шрифты, favicon.ico — файлы, которые отдаются как есть, без обработки.

23.2. CSS / JS / images

Spring Boot по умолчанию раздает статику из: - classpath:/static/ - classpath:/public/ - classpath:/resources/ - classpath:/META-INF/resources/ - root of ServletContext при war deployment

src/main/resources/static/
    css/
        main.css     -> /css/main.css
    js/
        app.js       -> /js/app.js
    images/
        logo.png     -> /images/logo.png

23.3. Настройка resource handling

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/files/**")
                .addResourceLocations("file:/opt/uploads/")
                .setCacheControl(CacheControl.maxAge(7, TimeUnit.DAYS));
    }
}

23.4. Раздача статических файлов

# Изменить базовый путь (по умолчанию /**)
spring.mvc.static-path-pattern=/static/**

# Изменить источник:
spring.web.resources.static-locations=classpath:/static/,classpath:/public/

В Thymeleaf:

<link rel="stylesheet" th:href="@{/css/main.css}"/>
<script th:src="@{/js/app.js}"></script>
<img th:src="@{/images/logo.png}" alt="Logo"/>

24. Локализация и интернационализация

24.1. Locale

Locale — объект, представляющий язык и регион пользователя (например, ru_RU, en_US).

Spring MVC определяет Locale через LocaleResolver.

24.2. LocaleResolver

LocaleResolver — интерфейс, определяющий текущий Locale запроса.

Реализации: - AcceptHeaderLocaleResolver — из Accept-Language header (дефолт). - CookieLocaleResolver — из cookie. - SessionLocaleResolver — из HTTP-сессии. - FixedLocaleResolver — фиксированный Locale.

@Bean
public LocaleResolver localeResolver() {
    SessionLocaleResolver resolver = new SessionLocaleResolver();
    resolver.setDefaultLocale(Locale.ENGLISH);
    return resolver;
}

// Смена языка:
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
    LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
    interceptor.setParamName("lang"); // GET /page?lang=ru
    return interceptor;
}

24.3. MessageSource

MessageSource — интерфейс для получения локализованных сообщений.

# messages.properties (дефолт):
user.greeting=Hello, {0}!

# messages_ru.properties:
user.greeting=Привет, {0}!
@Autowired
private MessageSource messageSource;

public String greet(String name, Locale locale) {
    return messageSource.getMessage("user.greeting", new Object[]{name}, locale);
}

Настройка:

spring.messages.basename=messages
spring.messages.encoding=UTF-8

24.4. i18n в Spring MVC

@GetMapping("/greeting")
public String greeting(Locale locale, Model model) {
    String msg = messageSource.getMessage("greeting", null, locale);
    model.addAttribute("message", msg);
    return "greeting";
}

24.5. Локализованные сообщения в представлениях

В Thymeleaf:

<!-- Простое сообщение: -->
<p th:text="#{user.greeting}"></p>

<!-- С параметром: -->
<p th:text="#{user.greeting(${user.name})}"></p>

<!-- Сообщения ошибок валидации тоже из MessageSource: -->
<span th:errors="*{email}"></span>

25. Session и Flash Attributes

25.1. Работа с HttpSession

@GetMapping("/cart/add/{id}")
public String addToCart(@PathVariable Long id, HttpSession session) {
    Cart cart = (Cart) session.getAttribute("cart");
    if (cart == null) cart = new Cart();
    cart.add(productService.findById(id));
    session.setAttribute("cart", cart);
    return "redirect:/cart";
}

25.2. Хранение состояния между запросами

HTTP — stateless протокол. Для хранения состояния между запросами: - Session — хранится на сервере, ID в cookie (JSESSIONID). - Cookie — хранится в браузере. - JWT/Token — в header или cookie, stateless. - Flash attributes — одноразовые данные для redirect.

25.3. @SessionAttributes

Хранит атрибуты модели в сессии для использования между запросами в рамках одного контроллера:

@Controller
@SessionAttributes("wizard")   // сохраняем объект "wizard" в сессии
public class WizardController {

    @GetMapping("/step1")
    public String step1(Model model) {
        model.addAttribute("wizard", new WizardData()); // создаем и кладем в сессию
        return "wizard/step1";
    }

    @PostMapping("/step2")
    public String step2(@ModelAttribute WizardData wizard) { // берем из сессии
        wizard.setStep2Data(...);
        return "wizard/step2";
    }

    @PostMapping("/finish")
    public String finish(@ModelAttribute WizardData wizard, SessionStatus status) {
        wizardService.complete(wizard);
        status.setComplete(); // убираем из сессии
        return "redirect:/done";
    }
}

25.4. Flash attributes

Flash attributes — данные, которые живут ровно один redirect (один запрос после redirect).

Используются для передачи сообщений об успехе/ошибке после POST-redirect-GET:

@PostMapping("/order/create")
public String createOrder(RedirectAttributes redirectAttributes) {
    orderService.create(...);
    redirectAttributes.addFlashAttribute("successMessage", "Order created!");
    return "redirect:/orders";
}

@GetMapping("/orders")
public String orders(Model model) {
    // "successMessage" автоматически появится в модели, если был установлен через flash
    return "orders";
}

В Thymeleaf:

<div th:if="${successMessage}" class="alert alert-success">
    <span th:text="${successMessage}"></span>
</div>

25.5. RedirectAttributes

Интерфейс для передачи данных через redirect:

// Flash attribute (в сессии, исчезает после одного запроса):
redirectAttributes.addFlashAttribute("message", "Saved!");

// Query param (добавляется к URL):
redirectAttributes.addAttribute("id", createdId); // redirect:/items?id=42

Аннотации и типы раздела:

Аннотация / тип Где используется Что делает
@SessionAttributes класс @Controller Сохраняет выбранные model attributes в HTTP session в рамках controller workflow.
@ModelAttribute метод/параметр Создает/читает model attribute; вместе с @SessionAttributes может доставать объект из session.
SessionStatus параметр метода Позволяет вызвать setComplete() и очистить session attributes текущего controller workflow.
RedirectAttributes параметр метода Добавляет query params (addAttribute) или flash attributes (addFlashAttribute) для redirect.
HttpSession параметр метода Низкоуровневый прямой доступ к server-side session.

@SessionAttributes не стоит использовать как общий storage пользователя. Это инструмент для небольших MVC-flow, например wizard form. Для security/session management используйте Spring Security и явные сервисы.