Файлы, 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- файл из classpathUrlResource- файл по URLByteArrayResource- данные в памяти
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
CallableTaskExecutor, 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;
}
WebAsyncTaskCallable, но со своим таймаутом и 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- из cookieSessionLocaleResolver- из 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}!
@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