Model, View и REST¶
После метода контроллера результат идёт одним из двух путей: в HTML через Model + View, или в тело ответа через
HttpMessageConverter (REST). Здесь оба пути и HTML-формы.
Model¶
Model - контейнер атрибутов, которые контроллер передаёт во view. По сути Map<String, Object>: ключ - имя
атрибута в шаблоне, значение - Java-объект.
@GetMapping("/dashboard")
public String dashboard(Model model) {
model.addAttribute("user", currentUser());
model.addAttribute("stats", statsService.getStats());
return "dashboard";
}
Model, Map, ModelMap - три взаимозаменяемых параметра, работают одинаково:
// 1. Model - интерфейс Spring
public String page(Model model) {
model.addAttribute("key", value);
return "view";
}
// 2. Map<String, Object>
public String page(Map<String, Object> model) {
model.put("key", value);
return "view";
}
// 3. ModelMap - extends LinkedHashMap
public String page(ModelMap modelMap) {
modelMap.addAttribute("key", value);
return "view";
}
View и ViewResolver¶
View - объект, который рендерит ответ. Обычно шаблон, но может быть redirect, internal resource или свой рендерер. Получает атрибуты модели и пишет HTML или другой формат в ответ.
ViewResolver - превращение строкового имени view в объект View. Spring перебирает резолверы по приоритету
до первого успешного.
return "users/profile";
// ViewResolver: "users/profile" -> templates/users/profile.html
// Thymeleaf: prefix = "classpath:/templates/", suffix = ".html"
return "redirect:/login"; // специальный префикс, превращается в RedirectView
return "forward:/internal"; // внутренний forward
Рендеринг HTML:
- Контроллер возвращает имя view.
ViewResolverнаходит шаблон.View.render()берёт атрибуты модели и рендерит HTML.- HTML записывается в
HttpServletResponse.
См. Конфигурирование Spring MVC.
REST¶
Spring MVC создавался для HTML, но REST-поддержка добавлялась постепенно, и сейчас это основной сценарий. Контроллер
помечают @RestController (см. Контроллеры), и все методы возвращают тело ответа.
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@GetMapping
public List<OrderDto> getAll() {
return orderService.findAll(); // -> JSON-массив
}
}
JSON по умолчанию - Jackson сериализует объект в JSON, Content-Type: application/json ставится сам.
DTO (Data Transfer Object) - отдельные классы для входа и выхода API.
// входящие данные
public record CreateOrderRequest(
@NotNull Long userId,
@NotEmpty List<@Valid OrderItemRequest> items
) {}
// исходящие данные
public record OrderDto(
Long id,
String status,
BigDecimal total,
List<OrderItemDto> items
) {}
Warning
Никогда не возвращай Entity напрямую из контроллера: наружу утекают внутренние поля, ленивые связи ломают сериализацию, а любое изменение схемы ломает API (см. Лучшие практики).
Статусы и заголовки. Когда статус зависит от результата - ResponseEntity (см.
Возвращаемые значения). Когда статус постоянный - @ResponseStatus.
@ResponseStatus(HttpStatus.CREATED)
@PostMapping
public OrderDto create(@RequestBody @Valid CreateOrderRequest req)
@GetMapping("/{id}")
public ResponseEntity<OrderDto> get(@PathVariable Long id) {
return ResponseEntity.ok()
.header("X-Request-Id", UUID.randomUUID().toString())
.body(orderService.findById(id));
}
| Ситуация | Статус |
|---|---|
| Успешный GET | 200 OK |
| Успешный POST, ресурс создан | 201 Created |
| Успешный DELETE или PUT без тела | 204 No Content |
| Ошибка валидации | 400 Bad Request |
| Не аутентифицирован | 401 Unauthorized |
| Нет прав | 403 Forbidden |
| Ресурс не найден | 404 Not Found |
| Конфликт | 409 Conflict |
| Внутренняя ошибка | 500 Internal Server Error |
Content negotiation - Spring выбирает формат ответа по заголовку Accept:
Accept: application/json-> JSONAccept: application/xml-> XML, если подключёнjackson-dataformat-xmlAccept: */*-> первый подходящий converter
@GetMapping(value = "/users", produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
})
public List<UserDto> getUsers()
Аннотации REST:
| Аннотация или тип | Где | Что делает |
|---|---|---|
@RestController |
Класс | Все методы пишут результат через HttpMessageConverter |
@RequestBody |
Параметр | Десериализует тело запроса в DTO |
@ResponseBody |
Метод или класс | Сериализует результат в тело ответа |
@ResponseStatus |
Метод или исключение | Задаёт статус, когда он постоянный |
ResponseEntity<T> |
Результат | Статус + заголовки + тело, когда ответ зависит от результата |
ProblemDetail |
Результат | Стандартный формат REST-ошибки (см. ошибки в JSON) |
@JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat |
Поля DTO | Управление JSON-контрактом через Jackson |
HttpMessageConverter¶
HttpMessageConverter
public interface HttpMessageConverter<T> {
boolean canRead(Class<?> clazz, MediaType mediaType);
boolean canWrite(Class<?> clazz, MediaType mediaType);
T read(Class<? extends T> clazz, HttpInputMessage inputMessage) throws IOException;
void write(T t, MediaType contentType, HttpOutputMessage outputMessage) throws IOException;
}
JSON - Jackson-конвертер (MappingJackson2HttpMessageConverter в Spring 6 и Boot 3):
@RequestBody CreateUserRequest request- Jackson читает JSON в объект, нуженContent-Type: application/json- объект из
@ResponseBodyили@RestController- Jackson пишет JSON в тело, с учётомAccept - параметр типа
Stringобычно читаетStringHttpMessageConverter, а не Jackson
Как выбирается converter:
@PostMapping("/users")
public UserDto create(@RequestBody CreateUserRequest request) {
// 1. смотрит Content-Type: application/json
// 2. ищет converter, который canRead(CreateUserRequest.class, application/json)
// 3. Jackson-конвертер read() -> объект
}
@GetMapping("/users/{id}")
@ResponseBody
public UserDto getUser(@PathVariable Long id) {
// 1. смотрит Accept: application/json
// 2. ищет converter, который canWrite(UserDto.class, application/json)
// 3. Jackson-конвертер write() -> JSON в тело
}
Jackson - библиотека JSON по умолчанию в Spring Boot. Настраивают через кастомайзер ObjectMapper:
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // даты строкой ISO
.featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // падать на лишних полях
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
}
Аннотации на DTO:
@JsonProperty("user_name") // имя поля в JSON
@JsonIgnore // исключить поле
@JsonInclude(NON_NULL) // не писать null-поля
@JsonFormat(pattern = "dd.MM.yyyy") // формат даты
Note
В Spring Framework 7 и Spring Boot 4 по умолчанию Jackson 3 (пакет tools.jackson), конвертер называется
JacksonJsonHttpMessageConverter, а кастомайзер Boot - JsonMapperBuilderCustomizer. Код выше - для Spring 6 и
Boot 3 с Jackson 2.
Встроенные конвертеры:
| Converter | MediaType |
|---|---|
StringHttpMessageConverter |
text/plain, text/* |
MappingJackson2HttpMessageConverter |
application/json |
MappingJackson2XmlHttpMessageConverter |
application/xml, если есть jackson-dataformat-xml |
ByteArrayHttpMessageConverter |
application/octet-stream |
ResourceHttpMessageConverter |
*/* для Resource |
FormHttpMessageConverter |
application/x-www-form-urlencoded |
AllEncompassingFormHttpMessageConverter |
Form data + части multipart |
Свои конвертеры подключают через WebMvcConfigurer (см. Настройку converters).
HTML-формы¶
GET и POST формы:
<!-- GET: данные в query string -->
<form th:action="@{/search}" method="get">
<input type="text" name="query"/>
<button type="submit">Search</button>
</form>
<!-- POST: данные в теле как application/x-www-form-urlencoded -->
<form th:action="@{/register}" th:object="${form}" method="post">
<input type="text" th:field="*{name}"/>
<input type="email" th:field="*{email}"/>
<button type="submit">Register</button>
</form>
th:field в Thymeleaf сам генерирует id и name, подставляет текущее значение из объекта модели и работает
вместе с th:object:
<form th:object="${form}">
<input th:field="*{email}"/> <!-- name="email", id="email", value="${form.email}" -->
</form>
Цикл формы целиком:
- GET кладёт в модель пустой объект формы.
- POST получает заполненную форму через
@ModelAttributeи валидирует её (см. Валидацию). - Есть ошибки - возвращаем ту же view. Spring оставляет заполненный объект в модели, шаблон показывает введённые
данные, Thymeleaf выводит ошибки через
th:errors. - Ошибок нет - сохраняем и делаем redirect (паттерн PRG, см. Redirect и Forward).
@Controller
@RequestMapping("/register")
public class RegisterController {
@GetMapping
public String showForm(Model model) {
model.addAttribute("form", new RegisterForm()); // пустая форма
return "register";
}
@PostMapping
public String submit(@Valid @ModelAttribute("form") RegisterForm form,
BindingResult result) { // BindingResult - сразу после формы
if (result.hasErrors()) return "register"; // показать форму с ошибками
userService.register(form);
return "redirect:/login?registered";
}
}
<!-- register.html -->
<form th:action="@{/register}" th:object="${form}" method="post">
<input type="text" th:field="*{name}" placeholder="Name"/>
<span th:errors="*{name}" class="error"></span>
<input type="email" th:field="*{email}" placeholder="Email"/>
<span th:errors="*{email}" class="error"></span>
<button type="submit">Register</button>
</form>
Блок ошибки только при наличии ошибки: