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

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";
}
<span th:text="${user.name}"></span>
<span th:text="${stats.total}"></span>

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:

  1. Контроллер возвращает имя view.
  2. ViewResolver находит шаблон.
  3. View.render() берёт атрибуты модели и рендерит HTML.
  4. 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 -> JSON
  • Accept: application/xml -> XML, если подключён jackson-dataformat-xml
  • Accept: */* -> первый подходящий 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 - преобразование тела HTTP-сообщения в Java-объект и обратно.

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>

Цикл формы целиком:

  1. GET кладёт в модель пустой объект формы.
  2. POST получает заполненную форму через @ModelAttribute и валидирует её (см. Валидацию).
  3. Есть ошибки - возвращаем ту же view. Spring оставляет заполненный объект в модели, шаблон показывает введённые данные, Thymeleaf выводит ошибки через th:errors.
  4. Ошибок нет - сохраняем и делаем 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>

Блок ошибки только при наличии ошибки:

<div th:if="${#fields.hasErrors('name')}" class="error">
    <span th:errors="*{name}">Name error</span>
</div>