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

Контроллеры

Контроллер - бин с @Controller или @RestController, методы которого обрабатывают HTTP-запросы. Принимает данные запроса через параметры метода, вызывает сервисный слой и возвращает результат: имя view, DTO, ResponseEntity. См. Путь запроса.


@Controller и @RestController

@Controller - стереотип MVC-контроллера для HTML-приложений.

  • класс находится component scan'ом, как любой @Component
  • по умолчанию результат метода - имя view, его ищет ViewResolver
  • HttpMessageConverter работает только для методов с @ResponseBody
@Controller
@RequestMapping("/users")
public class UserController {
    @GetMapping("/{id}")
    public String getUser(@PathVariable Long id, Model model) {
        model.addAttribute("user", userService.findById(id));
        return "users/detail";
    }
}

@RestController - @Controller + @ResponseBody на уровне класса, для REST API.

  • все методы возвращают тело ответа напрямую
  • ViewResolver не используется, результат сериализует HttpMessageConverter
@RestController
@RequestMapping("/api/users")
public class UserRestController {
    @GetMapping("/{id}")
    public UserDto getUser(@PathVariable Long id) {
        return userService.findById(id);   // сериализуется в JSON
    }
}

@ResponseBody - отключает трактовку результата как имени view и передаёт значение в HttpMessageConverter.

Частая ошибка - перепутать эти два вида контроллеров (см. Частые ошибки).


Маппинг запросов

@RequestMapping - базовая аннотация маппинга. На классе задаёт базовый URL, на методе уточняет маппинг. Без method маппинг работает для всех HTTP-методов.

@RequestMapping(
    value = "/users",               // шаблон URL
    method = RequestMethod.GET,     // HTTP-метод
    params = "active=true",         // query-параметр
    headers = "X-API=v2",           // заголовок
    consumes = "application/json",  // Content-Type входящего запроса
    produces = "application/json"   // что отдаём, сверяется с Accept клиента
)

Сокращения - @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping. Принимают те же атрибуты: params, headers, consumes, produces.

Шаблоны URL:

@GetMapping("/users/{id}")          // path variable
@GetMapping("/files/**")            // любой суффикс
@GetMapping("/users/{id}/orders")   // вложенный ресурс
@GetMapping("/v{version}/users")    // переменная внутри сегмента

Шаблоны по умолчанию разбирает PathPatternParser. Старый AntPathMatcher встречается в legacy-проектах, новые приложения проектируют под PathPatternParser.

Маппинг по params, headers, consumes, produces:

// по params
@GetMapping(value = "/users", params = "active=true")
@GetMapping(value = "/users", params = "!active")              // параметра нет
@GetMapping(value = "/users", params = {"active", "role=admin"})

// по headers
@GetMapping(value = "/users", headers = "X-Version=2")
@GetMapping(value = "/users", headers = "!X-Version")

// consumes - что принимаем (Content-Type запроса)
@PostMapping(value = "/users", consumes = MediaType.APPLICATION_JSON_VALUE)

// produces - что отдаём (сверяется с Accept), несколько вариантов - content negotiation
@GetMapping(value = "/users", produces = {
    MediaType.APPLICATION_JSON_VALUE,
    MediaType.APPLICATION_XML_VALUE
})

Версионирование API (Spring Framework 7)

В Spring Framework 7 в MVC появилась встроенная поддержка версий API. Сначала указывают, откуда брать версию:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        configurer.useRequestHeader("API-Version");
    }
}

Потом маппинг учитывает версию:

@GetMapping(path = "/accounts/{id}", version = "1.0")
public AccountDto getV1(@PathVariable Long id)

@GetMapping(path = "/accounts/{id}", version = "2.0")
public AccountDto getV2(@PathVariable Long id)

@GetMapping(path = "/accounts/{id}", version = "2.0+")   // 2.0 и новее
public AccountDto getV2AndNewer(@PathVariable Long id)
  • версию можно брать из заголовка, query-параметра, сегмента пути или параметра media type
  • неподдерживаемая версия - InvalidApiVersionException, обычно это 400 Bad Request

Параметры методов

Шпаргалка - откуда что берётся:

Аннотация или тип Откуда
@RequestParam Query-параметры, form-data, x-www-form-urlencoded
@PathVariable Переменные в URL
@ModelAttribute Данные формы или query-параметры в POJO
@RequestBody JSON или XML тело запроса
@RequestHeader HTTP-заголовки
@CookieValue Cookie
@SessionAttribute Атрибут из HTTP-сессии
@RequestAttribute Атрибут запроса, который положил фильтр или interceptor
@RequestPart Часть multipart/form-data: файл или JSON
@MatrixVariable Параметры внутри сегмента URI
HttpServletRequest, HttpServletResponse Сырой servlet API
HttpSession Сессия целиком
Principal, Authentication Текущий пользователь
Locale Локаль запроса
Model, ModelMap, Map Данные для HTML view

Аргументы собирает HandlerMethodArgumentResolver (см. HandlerAdapter).


@PathVariable - значение из шаблона пути.

@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) { ... }   // имя совпадает с переменной - имя можно не писать

@GetMapping("/users/{userId}/orders/{orderId}")
public OrderDto getOrder(@PathVariable Long userId, @PathVariable Long orderId) { ... }

@GetMapping("/users/{user-id}")
public UserDto get(@PathVariable("user-id") Long userId) { ... }   // имя отличается

@GetMapping({"/users/{id}", "/users"})
public UserDto get(@PathVariable(required = false) Long id) { ... }   // условно-опциональный вариант

@RequestParam - query-параметр или поле формы. Для простых типов: String, int, boolean и т.п.

// GET /users?role=admin&page=2
@GetMapping("/users")
public List<UserDto> getUsers(
    @RequestParam String role,
    @RequestParam(defaultValue = "0") int page,              // значение по умолчанию, если параметра нет
    @RequestParam(required = false) String name
) { ... }

// несколько значений одного параметра: GET /filter?tag=java&tag=spring
@GetMapping("/filter")
public List<Item> filter(@RequestParam List<String> tag) { ... }

@RequestBody - тело запроса, десериализованное через HttpMessageConverter.

@PostMapping("/users")
public UserDto create(@RequestBody @Valid CreateUserRequest request) { ... }

@ModelAttribute - связывает поля формы или query-параметры с объектом (POJO).

// GET /search?name=Alice&age=25
@GetMapping("/search")
public String search(@ModelAttribute SearchForm form, Model model) { ... }

// POST /register с form data
@PostMapping("/register")
public String register(@ModelAttribute @Valid RegisterForm form, BindingResult result) { ... }

На методе @ModelAttribute заранее кладёт данные в модель для всех методов контроллера, а в @ControllerAdvice - для всех контроллеров:

@ModelAttribute("categories")
public List<Category> categories() {
    return categoryService.findAll();
}

См. HTML-формы.


@RequestHeader - значение HTTP-заголовка.

@GetMapping("/profile")
public UserDto getProfile(
    @RequestHeader("Authorization") String authHeader,
    @RequestHeader(value = "X-Request-Id", required = false) String requestId
) { ... }

@CookieValue - значение cookie.

@GetMapping("/dashboard")
public String dashboard(@CookieValue(name = "sessionId", defaultValue = "unknown") String sessionId) { ... }

@RequestPart - часть multipart/form-data, когда одна часть - файл, а другая - JSON.

@PostMapping("/upload")
public void upload(
    @RequestPart("file") MultipartFile file,
    @RequestPart("meta") @Valid FileMeta meta
) { ... }

См. Файлы.


HttpServletRequest, HttpServletResponse - низкоуровневый servlet API.

@GetMapping("/info")
public String info(HttpServletRequest request, HttpServletResponse response) {
    String ip = request.getRemoteAddr();
    response.setHeader("X-Custom", "value");
    return "info";
}

HttpSession - прямой доступ к серверной сессии.

@GetMapping("/cart")
public CartDto getCart(HttpSession session) {
    return (CartDto) session.getAttribute("cart");
}

@SessionAttribute - читает уже существующий атрибут сессии. Не путать с @SessionAttributes, который сохраняет атрибуты модели в сессию между запросами (см. Сессии).

@GetMapping("/checkout")
public String checkout(@SessionAttribute("cart") Cart cart, Model model) {
    model.addAttribute("cart", cart);
    return "checkout";
}

@RequestAttribute - атрибут, который раньше положил фильтр, interceptor или servlet-контейнер.

@GetMapping("/trace")
public String trace(@RequestAttribute("traceId") String traceId) { ... }

Principal, Authentication - текущий пользователь, обычно вместе со Spring Security (см. Spring Security).

@GetMapping("/me")
public UserDto getCurrentUser(Principal principal) {
    return userService.findByUsername(principal.getName());
}

Locale - локаль запроса (см. Локализацию).

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

Model, ModelMap, Map - данные для HTML view (см. Model).

@GetMapping("/users/{id}")
public String profile(@PathVariable Long id, Model model) {
    model.addAttribute("user", userService.findById(id));
    model.addAttribute("roles", roleService.findAll());
    return "users/profile";
}

@MatrixVariable - параметры внутри сегмента URI. Встречается редко. Сегмент с параметрами должен совпадать с URI-переменной в шаблоне.

GET /cars/tesla;color=red;year=2024
@GetMapping("/cars/{model}")
public List<CarDto> cars(@PathVariable String model,
                         @MatrixVariable String color,
                         @MatrixVariable int year) { ... }

С PathPatternParser матричные переменные работают сразу. Со старым UrlPathHelper нужно было отключить удаление содержимого после ;.


Data Binding

Data Binding - автоматическое превращение данных из URL, query и полей формы в Java-объекты. Выполняет WebDataBinder. JSON и XML тело для @RequestBody читает не он, а HttpMessageConverter. После binding'а или десериализации может запуститься валидация.

// GET /search?name=Alice&minAge=18&maxAge=30
public class SearchCriteria {
    private String name;
    private int minAge;
    private int maxAge;
    // getters / setters
}

@GetMapping("/search")
public List<UserDto> search(@ModelAttribute SearchCriteria criteria)   // query-параметры лягут в поля

WebDataBinder - объект, который делает binding: конвертация типов (String -> int, String -> дата), заполнение полей, валидация.

@InitBinder - метод, который настраивает WebDataBinder перед binding'ом аргументов. Ставится в контроллере или в @ControllerAdvice. Частые задачи:

  • запретить опасные поля (id, role, createdAt) - защита от mass assignment
  • зарегистрировать legacy PropertyEditor
  • подключить свой validator для конкретной формы
@InitBinder
public void initBinder(WebDataBinder binder) {
    binder.setDisallowedFields("id", "createdAt");                       // эти поля не биндить
    SimpleDateFormat dateFormat = new SimpleDateFormat("dd.MM.yyyy");
    binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));   // legacy java.util.Date
}

Для java.time вместо PropertyEditor используют @DateTimeFormat на поле или параметре.


Встроенные преобразования:

  • String -> int, long, double, boolean
  • String -> LocalDate, LocalDateTime, OffsetDateTime в ISO-формате или через @DateTimeFormat
  • String -> enum
  • String -> UUID

Converter - одностороннее преобразование одного типа в другой.

@Component
public class StringToStatusConverter implements Converter<String, Status> {
    @Override
    public Status convert(String source) {
        return Status.valueOf(source.toUpperCase());
    }
}

Formatter - преобразование String <-> объект с учётом Locale.

@Component
public class MoneyFormatter implements Formatter<Money> {
    @Override
    public Money parse(String text, Locale locale) { ... }

    @Override
    public String print(Money money, Locale locale) { ... }
}

Глобальная регистрация:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToStatusConverter());
    }
}
Аннотация или API Где Что делает
@InitBinder Метод контроллера или advice Настраивает WebDataBinder
@DateTimeFormat Поле или параметр Формат дат и времени
@NumberFormat Поле или параметр Формат чисел
Converter<S, T> Бин или конфиг Одностороннее преобразование типа
Formatter<T> Бин или конфиг String <-> объект с учётом Locale
WebMvcConfigurer#addFormatters Конфиг Глобальная регистрация converters и formatters

Ошибки binding'а. Если binding не удался, например буква вместо числа для int:

  • создаётся BindingResult с FieldError
  • BindingResult объявлен сразу после bindable-объекта - контроллер сам решает, что делать
  • BindingResult нет - Spring бросает исключение, тип зависит от аргумента: MethodArgumentNotValidException, BindException, MethodArgumentTypeMismatchException, HandlerMethodValidationException

Валидация

@Valid (jakarta.validation.Valid) - запускает Bean Validation объекта.

@PostMapping("/users")
public UserDto create(@RequestBody @Valid CreateUserRequest request)

BindingResult - ошибки binding'а и валидации. Объявляется сразу после валидируемого объекта.

@PostMapping("/register")
public String register(@Valid @ModelAttribute RegisterForm form, BindingResult result) {
    if (result.hasErrors()) {
        return "register";   // вернуть форму с ошибками
    }
    userService.register(form);
    return "redirect:/login";
}

Аннотации Bean Validation (jakarta.validation):

Аннотация Назначение
@NotNull Не null
@NotBlank Не null и не пустая строка после trim
@NotEmpty Не null и не пустая коллекция или строка
@Size(min, max) Длина строки или размер коллекции
@Min(value), @Max(value) Границы числа
@Email Формат email
@Pattern(regexp) Регулярное выражение
@Positive, @PositiveOrZero > 0 и >= 0
@Past, @Future Дата в прошлом или будущем
@Valid Вложенная валидация

Ошибки валидации без BindingResult:

  • @RequestBody @Valid - MethodArgumentNotValidException
  • ограничения прямо на параметрах (@PathVariable @Min(1), @RequestParam @NotBlank) - в Spring 6.1+ HandlerMethodValidationException
  • обрабатываются через @ExceptionHandler в @RestControllerAdvice (см. Обработку исключений)
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .toList();
        return new ErrorResponse("Validation failed", errors);
    }

    @ExceptionHandler(HandlerMethodValidationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleMethodValidation(HandlerMethodValidationException ex) {
        return new ErrorResponse("Validation failed", List.of(ex.getMessage()));
    }
}

Итого: валидируй на границе приложения - DTO в контроллере, а не сущности в репозитории (см. Лучшие практики).


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

String - имя view:

@GetMapping("/home")
public String home() {
    return "home";   // ViewResolver ищет templates/home.html
}

Model + имя view:

@GetMapping("/users/{id}")
public String user(@PathVariable Long id, Model model) {
    model.addAttribute("user", userService.findById(id));
    return "users/detail";
}

ModelAndView:

@GetMapping("/users/{id}")
public ModelAndView user(@PathVariable Long id) {
    ModelAndView mav = new ModelAndView("users/detail");
    mav.addObject("user", userService.findById(id));
    return mav;
}

void - ответ пишется вручную через HttpServletResponse:

@GetMapping("/download")
public void download(HttpServletResponse response) throws IOException {
    response.setContentType("application/pdf");
    response.getOutputStream().write(fileBytes);
}

DTO или любой объект - только при @ResponseBody или @RestController, сериализуется в JSON:

@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
    return userService.findById(id);
}

@ResponseBody на отдельном методе обычного контроллера:

@Controller
public class UserController {
    @GetMapping("/api/users/{id}")
    @ResponseBody
    public UserDto getUser(@PathVariable Long id)
}

ResponseEntity - полный контроль: статус, заголовки, тело.

@GetMapping("/users/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
    return userService.findById(id)
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());
}

@PostMapping("/users")
public ResponseEntity<UserDto> create(@RequestBody @Valid CreateUserRequest req) {
    UserDto created = userService.create(req);
    URI location = URI.create("/api/users/" + created.getId());
    return ResponseEntity.created(location).body(created);   // 201 + Location
}

// свои заголовки
return ResponseEntity.ok()
    .header("X-Custom-Header", "value")
    .body(dto);

HttpEntity - как ResponseEntity, но без статуса: только заголовки и тело.

@GetMapping("/info")
public HttpEntity<InfoDto> info() {
    HttpHeaders headers = new HttpHeaders();
    headers.add("X-Info", "value");
    return new HttpEntity<>(new InfoDto(), headers);
}

Redirect:

@PostMapping("/login")
public String login() {
    return "redirect:/dashboard";
}

@PostMapping("/submit")
public View submit() {
    return new RedirectView("/success");
}

@GetMapping("/old-path")
public ResponseEntity<Void> redirect() {
    return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
        .location(URI.create("/new-path"))
        .build();
}

Forward - внутренний переход, URL в браузере не меняется:

@GetMapping("/old")
public String forward() {
    return "forward:/new";
}

См. Redirect и Forward.

Файл через Resource:

@GetMapping("/files/{filename}")
public ResponseEntity<Resource> downloadFile(@PathVariable String filename) {
    Resource resource = new FileSystemResource(Paths.get(uploadDir, filename));
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

См. Файлы.