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

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

Возможности MVC за пределами «принял JSON - отдал JSON»: загрузка и отдача файлов, redirect и forward, асинхронные ответы, статика, локализация, состояние между запросами.


Файлы

Загрузка

MultipartResolver - компонент, который разбирает multipart/form-data. Spring Boot сам настраивает StandardServletMultipartResolver.

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

MultipartFile - загруженный файл в параметре контроллера:

@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
    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);
}

// несколько файлов
@PostMapping("/upload-multiple")
public List<String> uploadMultiple(@RequestParam("files") List<MultipartFile> files) {
    return files.stream()
        .map(f -> storageService.store(f))
        .toList();
}

Warning

На проде нельзя доверять getOriginalFilename(): проверяй path traversal (..), белый список расширений и media types, лимиты размера и права доступа. Надёжнее генерировать своё имя файла, а оригинальное хранить отдельно как метаданные.

HTML-форма - enctype="multipart/form-data" обязателен:

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

Файл вместе с JSON-частью - через @RequestPart (см. Параметры).


Отдача клиенту

@GetMapping("/files/{filename:.+}")
public ResponseEntity<Resource> download(@PathVariable String filename) throws IOException {
    Path root = uploadDir.toAbsolutePath().normalize();
    Path filePath = root.resolve(filename).normalize();
    if (!filePath.startsWith(root)) {                       // защита от выхода за пределы папки
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid filename");
    }

    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);
}

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

  • FileSystemResource - файл на диске
  • ClassPathResource - файл из classpath
  • UrlResource - файл по URL
  • ByteArrayResource - данные в памяти

Content-Disposition - заголовок, который говорит браузеру, что делать с файлом:

.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.pdf\"")   // скачать
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"image.png\"")        // открыть в браузере

Redirect и Forward

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

  • URL в браузере меняется
  • два HTTP-запроса, новый request и response
  • данные первого запроса недоступны, кроме flash attributes

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

  • URL в браузере не меняется
  • один HTTP-запрос, тот же request и response
  • данные запроса доступны в цели
Redirect Forward
HTTP-запросов 2 1
URL меняется Да Нет
Request-объект Новый Тот же
Данные исходного запроса Нет Да
Для чего POST -> redirect -> GET Внутренняя передача управления, нужна редко

См. Возвращаемые значения.


PRG (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
}

Асинхронная обработка

В servlet stack один запрос - один поток (см. Servlet stack). Servlet 3.0+ умеет async: поток контейнера освобождается на время долгой операции, а результат отдаётся позже.

Warning

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

На проде настраивают:

  • AsyncTaskExecutor
  • таймаут
  • передачу SecurityContext и MDC в другой поток
  • обработку timeout и error callbacks

Callable - Spring выполняет его в отдельном потоке из TaskExecutor, servlet-поток свободен.

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

DeferredResult - результат выставляют извне: из другого потока, по событию, из очереди.

@GetMapping("/subscribe")
public DeferredResult<String> subscribe() {
    DeferredResult<String> result = new DeferredResult<>(10_000L);   // таймаут 10 с
    eventBus.subscribe(event -> result.setResult(event.getData()));  // кто-то выставит результат позже
    result.onTimeout(() -> result.setResult("timeout"));
    return result;
}

WebAsyncTask - как Callable, но со своим таймаутом и executor'ом.

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

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();
            }
        }
    };
}

SseEmitter - Server-Sent Events: сервер шлёт события клиенту по долгому 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;
}

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

Статика - файлы, которые отдаются как есть: CSS, JavaScript, картинки, шрифты, favicon.ico.

Spring Boot по умолчанию раздаёт её из:

  • classpath:/static/
  • classpath:/public/
  • classpath:/resources/
  • classpath:/META-INF/resources/
  • корня ServletContext при деплое в WAR
src/main/resources/static/
    css/main.css      -> /css/main.css
    js/app.js         -> /js/app.js
    images/logo.png   -> /images/logo.png
# базовый путь, по умолчанию /**
spring.mvc.static-path-pattern=/static/**

# откуда брать
spring.web.resources.static-locations=classpath:/static/,classpath:/public/

Своя раздача - через addResourceHandlers:

@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));
    }
}

В Thymeleaf:

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

Локализация

Locale - язык и регион пользователя: ru_RU, en_US. В контроллер приходит параметром Locale.

LocaleResolver - определение Locale текущего запроса.

  • AcceptHeaderLocaleResolver - из заголовка Accept-Language, по умолчанию
  • CookieLocaleResolver - из cookie
  • SessionLocaleResolver - из HTTP-сессии
  • FixedLocaleResolver - фиксированная локаль
@Bean
public LocaleResolver localeResolver() {
    SessionLocaleResolver resolver = new SessionLocaleResolver();
    resolver.setDefaultLocale(Locale.ENGLISH);
    return resolver;
}

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

LocaleChangeInterceptor начинает работать, только когда его зарегистрировали в addInterceptors (см. Interceptors). С AcceptHeaderLocaleResolver смена языка параметром не работает: язык задаёт браузер.


MessageSource - получение локализованных сообщений по ключу (см. ApplicationContext).

# messages.properties - по умолчанию
user.greeting=Hello, {0}!

# messages_ru.properties
user.greeting=Привет, {0}!
spring.messages.basename=messages
spring.messages.encoding=UTF-8
@GetMapping("/greeting")
public String greeting(Locale locale, Model model) {
    String msg = messageSource.getMessage("user.greeting", new Object[]{"Ramil"}, locale);
    model.addAttribute("message", msg);
    return "greeting";
}

В Thymeleaf:

<p th:text="#{user.greeting}"></p>                    <!-- простое сообщение -->
<p th:text="#{user.greeting(${user.name})}"></p>      <!-- с параметром -->
<span th:errors="*{email}"></span>                    <!-- ошибки валидации тоже из MessageSource -->

Сессии и flash attributes

HTTP не хранит состояние. Способы хранить его между запросами:

  • Session - на сервере, идентификатор в cookie JSESSIONID
  • Cookie - в браузере
  • JWT или токен - в заголовке или cookie, сервер состояние не хранит (см. Stateful и Stateless)
  • Flash attributes - одноразовые данные для redirect

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";
}

@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";
    }
}

Warning

@SessionAttributes - не общее хранилище пользователя. Для безопасности и управления сессией - Spring Security и явные сервисы.


Flash attributes - данные, которые живут ровно до следующего запроса после redirect. Нужны для сообщений об успехе или ошибке в паттерне PRG.

RedirectAttributes - параметр метода для передачи данных через redirect:

redirectAttributes.addFlashAttribute("message", "Saved!");   // flash: в сессии, исчезнет после одного запроса
redirectAttributes.addAttribute("id", createdId);            // query-параметр: redirect:/items?id=42
@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) {
    return "orders";   // successMessage сам окажется в модели
}
<div th:if="${successMessage}" class="alert alert-success">
    <span th:text="${successMessage}"></span>
</div>
Аннотация или тип Где Что делает
@SessionAttributes Класс @Controller Хранит выбранные атрибуты модели в сессии на время сценария контроллера
@ModelAttribute Метод или параметр Создаёт или читает атрибут модели, вместе с @SessionAttributes берёт объект из сессии
SessionStatus Параметр метода setComplete() чистит атрибуты сессии текущего сценария
RedirectAttributes Параметр метода Query-параметры (addAttribute) или flash (addFlashAttribute) для redirect
HttpSession Параметр метода Прямой доступ к серверной сессии

События сессий в Spring Session и Redis

Spring Session - модуль, который выносит HTTP-сессию из памяти Tomcat во внешнее хранилище, например Redis. Тогда сессия общая для всех экземпляров приложения.

События сессий - Spring Session публикует в контексте SessionCreatedEvent, SessionDeletedEvent и SessionExpiredEvent. На них подписываются обычным @EventListener.

@Component
public class SessionAudit {
    @EventListener
    public void onExpired(SessionExpiredEvent event) {
        log.info("Session {} expired", event.getSessionId());   // аудит, счётчик онлайна, очистка ресурсов
    }
}

Для чего:

  • аудит входов и выходов
  • счётчик пользователей онлайн
  • очистка ресурсов, привязанных к сессии
  • контроль одновременных сессий в Spring Security (SpringSessionBackedSessionRegistry)

Как включить с Redis:

  • нужен индексируемый репозиторий RedisIndexedSessionRepository (@EnableRedisIndexedHttpSession). Обычный RedisSessionRepository событий не публикует
  • о создании и удалении Spring Session знает сам, а об истечении TTL узнаёт из keyspace notifications Redis. Их включают настройкой notify-keyspace-events