Когда браузер или другой сервис обращается к вашему приложению по HTTP, кто-то должен принять этот запрос, понять, какой код за него отвечает, и вернуть ответ. Этим и занимается Spring MVC. Разберём с нуля: как запрос проходит через приложение, как написать контроллер для REST API, как проверить присланные данные и как аккуратно отдавать ошибки.
Зачем вообще нужен Spring MVC
Представьте, что HTTP-запросы вы обрабатываете вручную. Тогда в каждом методе пришлось бы самому разбирать URL, доставать параметры из строки запроса, читать тело и превращать JSON в объект, а потом обратно объект в JSON. Кода много, и он одинаковый в каждом месте.
Spring MVC берёт эту рутину на себя. Вы пишете обычный метод и помечаете его аннотацией — «этот метод отвечает на GET /orders/5». Всё остальное (разбор URL, чтение тела, преобразование JSON) фреймворк делает сам. MVC расшифровывается как Model-View-Controller, но для REST API нас интересует в основном Controller — класс с методами-обработчиками.
Это синхронный веб-стек: каждый запрос обрабатывается своим потоком от начала до конца. Есть и реактивная альтернатива — WebFlux — но она нужна реже, чем кажется, и для типичного backend-сервиса хватает обычного MVC.
Как запрос доходит до вашего метода
Раньше, без единой точки входа, каждый кусок приложения сам решал, какие запросы он ловит — получалась путаница. В Spring MVC есть один «диспетчер», через который проходят все запросы.
DispatcherServlet — это единственный вход в приложение (его называют front controller, «главный контроллер»). Любой HTTP-запрос сначала попадает в него, а он уже решает, куда направить дальше. Аналогия: ресепшен в большом офисе — посетители идут не сразу к нужному человеку, а сначала к стойке, и она подсказывает, в какой кабинет.
Запрос приходит во встроенный Tomcat, оттуда — в DispatcherServlet, тот подбирает метод контроллера, вызывает его и превращает результат в JSON:
Через DispatcherServlet проходят все запросы: он подбирает обработчик по пути и HTTP-методу. Дальше поля проверяет @Valid — и корректный запрос доходит до метода контроллера, а кривой не доходит вовсе: ответ за него собирает @RestControllerAdvice.
Хорошая новость: регистрировать DispatcherServlet руками не нужно — Spring Boot делает это при старте.
REST-контроллер
REST API — это набор адресов, на которые клиент шлёт запросы и получает данные (обычно в JSON). Чтобы метод стал таким обработчиком, его кладут в класс с аннотацией @RestController.
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) {
return orderService.findById(id);
}
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
}
Что здесь происходит:
@RestControllerговорит: это контроллер, и то, что возвращают его методы, нужно отдавать как тело ответа (JSON), а не искать HTML-страницу.@RequestMapping("/orders")на классе — общий префикс адреса. Все методы внутри будут начинаться с/orders.@GetMapping,@PostMapping— на какой HTTP-метод и путь отвечает метод. Есть ещё@PutMapping,@DeleteMapping,@PatchMapping— по одному на каждый вид запроса.
Метод get отвечает на GET /orders/5, метод create — на POST /orders. Возвращённый объект Spring сам превратит в JSON.
Как достать данные из запроса
Данные приходят в запросе тремя разными способами, и для каждого своя аннотация.
// /orders/5 — часть самого адреса
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) { ... }
// /orders?status=NEW&limit=20 — параметры после знака ?
@GetMapping
public List<OrderResponse> list(
@RequestParam String status,
@RequestParam(defaultValue = "10") int limit) { ... }
// тело POST-запроса (JSON) превращается в объект
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest request) { ... }
@PathVariable— значение прямо из адреса (5в/orders/5).@RequestParam— параметр из строки запроса (status=NEWв/orders?status=NEW). Можно задать значение по умолчанию.@RequestBody— тело запроса. Spring сам прочитает JSON и соберёт из него объект.
Здесь легко перепутать одно с другим: @PathVariable достаёт кусок из самого пути, а @RequestParam — то, что идёт после ?.
Ту же механику видно и без Spring: путь отрезается от строки запроса, по нему ищется обработчик, значения достаются из пути и параметров.
живой пример
import java.util.HashMap;
import java.util.Map;
public class MiniDispatcher {
static String dispatch(String method, String uri) {
String path = uri.split("\\?", 2)[0];
Map<String, String> params = new HashMap<>();
if (uri.contains("?")) {
for (String pair : uri.split("\\?", 2)[1].split("&")) {
String[] kv = pair.split("=", 2);
params.put(kv[0], kv[1]);
}
}
String[] parts = path.split("/");
if (method.equals("GET") && parts.length == 3 && parts[1].equals("orders")) {
return "get(id=" + parts[2] + ")";
}
if (method.equals("GET") && path.equals("/orders")) {
return "list(status=" + params.getOrDefault("status", "ANY")
+ ", limit=" + params.getOrDefault("limit", "10") + ")";
}
if (parts.length == 3 && parts[1].equals("orders")) {
return "405 Method Not Allowed";
}
return "404 Not Found";
}
public static void main(String[] args) {
System.out.println(dispatch("GET", "/orders/5"));
System.out.println(dispatch("GET", "/orders?status=NEW&limit=20"));
System.out.println(dispatch("GET", "/orders"));
System.out.println(dispatch("DELETE", "/orders/5"));
System.out.println(dispatch("GET", "/invoices/5"));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →
get(id=5)
list(status=NEW, limit=20)
list(status=ANY, limit=10)
405 Method Not Allowed
404 Not Found
Третья строка — значение по умолчанию из @RequestParam(defaultValue = "10"). Четвёртая — тот же путь другим методом: Spring отвечает 405, а не 404. Пятая — адреса нет вовсе, вот там 404.
Что вернуть из метода
Чаще всего достаточно вернуть обычный объект — Spring превратит его в JSON и отдаст со статусом 200 OK. Но иногда нужно управлять статусом ответа или заголовками: например, после создания записи принято возвращать 201 Created. Для этого есть ResponseEntity.
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
OrderResponse created = orderService.create(request);
return ResponseEntity
.status(HttpStatus.CREATED) // 201 вместо 200
.body(created);
}
Правило простое: если хватает статуса 200 — возвращайте объект напрямую, так короче. Нужен другой статус или заголовки — оборачивайте в ResponseEntity.
Проверка входных данных
Клиент может прислать что угодно: пустое имя, отрицательное количество, дату из прошлого. Если не проверять, кривые данные уйдут вглубь приложения и сломают что-нибудь там, где разобраться уже трудно. Проверять лучше сразу на входе.
Для этого есть Bean Validation — стандартный механизм, где правила задаются прямо на полях аннотациями. В Spring Boot он подключается зависимостью spring-boot-starter-validation.
public record CreateOrderRequest(
@NotBlank String customerName, // не пустая строка
@NotEmpty List<OrderLineRequest> lines, // список не пустой
@NotNull @Future LocalDateTime deliveryDate // дата в будущем
) {}
Самые ходовые аннотации: @NotNull (не null), @NotBlank (строка не пустая), @NotEmpty (коллекция не пустая), @Size (длина в диапазоне), @Min/@Max и @Positive (числовые ограничения), @Email, @Future/@Past (даты).
Чтобы Spring реально применил эти правила, на параметре ставят @Valid:
@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) { ... }
Без @Valid аннотации на полях ничего не делают — это частая причина «почему валидация не сработала». Если данные не прошли проверку, Spring сам отклонит запрос ещё до входа в тело метода.
Обработка ошибок в одном месте
Что-то всегда идёт не так: заказ не найден, данные не прошли проверку, упала база. Если в каждом методе писать try/catch и руками собирать ответ об ошибке, код раздувается и ошибки выглядят по-разному в разных местах. Лучше собрать всю обработку в одном классе.
Для этого есть @RestControllerAdvice — класс, который ловит исключения из всех контроллеров сразу. Внутри — методы с @ExceptionHandler, по одному на тип ошибки.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<String> handleNotFound(OrderNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleUnknown(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Что-то пошло не так");
}
}
Теперь, где бы в контроллере ни выбросили OrderNotFoundException, клиент получит аккуратный ответ 404. А последний метод — «ловушка на всё остальное»: любая неожиданная ошибка превратится в 500, а не в кашу из стектрейса.
У этой ловушки есть обратная сторона. Обработчик на Exception перехватывает и MethodArgumentNotValidException — ту самую ошибку, которую бросает @Valid. В итоге запрос с пустым именем вернёт 500 вместо 400, и клиент решит, что сломался сервер. Поэтому рядом всегда держат отдельный обработчик на MethodArgumentNotValidException — он забирает ошибки валидации раньше, чем до них доберётся ловушка.
Единый формат ошибки
Чтобы ошибки от всего сервиса выглядели одинаково, в Spring есть готовый формат ответа — ProblemDetail. Это просто объект с понятными полями, который тоже отдаётся как JSON.
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
Ответ получится таким:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Заказ 5 не найден"
}
Свой формат ошибок изобретать не стоит — когда у всех ответов одинаковая структура, клиентам (веб, мобильное приложение) проще их разбирать.
Документация API через OpenAPI
Когда контроллеров становится много, клиентам нужна понятная справка: какие есть адреса, что они принимают и возвращают. Писать её руками и поддерживать в актуальном виде тяжело — она быстро расходится с кодом.
Поэтому документацию генерируют автоматически. Библиотека springdoc-openapi подключается одной зависимостью и сама собирает описание API по вашим контроллерам.
dependencies {
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.x.x")
}
После этого по адресу /swagger-ui.html открывается готовая страница, где видны все адреса и можно подёргать их прямо из браузера. Поскольку справка строится по коду, она всегда соответствует тому, что есть на самом деле.
Коротко
DispatcherServlet— единый вход в приложение: все запросы идут через него, а он находит метод по пути и HTTP-методу. Регистрировать его руками не нужно.@RestController+@GetMapping/@PostMapping/... делают метод обработчиком конкретного адреса; возвращённый объект уходит как JSON, а нужен другой статус или заголовки —ResponseEntity.- Данные из запроса достают тремя аннотациями:
@PathVariable(часть пути),@RequestParam(после?),@RequestBody(тело запроса). - Аннотации Bean Validation на полях срабатывают только вместе с
@Validна параметре — без него правила молчат. - Обработку ошибок собирают в одном
@RestControllerAdvice, единый формат ответа —ProblemDetail; ловушке наExceptionнельзя давать съесть400от валидации. - Справку по API не пишут руками — её генерирует springdoc-openapi, страница
/swagger-ui.html.
Что почитать дальше
- Spring WebFlux — реактивная альтернатива и когда она действительно нужна.
@Transactionalглубоко — как сделать операции в контроллере транзакционными правильно.- Spring Security — аутентификация и авторизация внутри MVC.
- Spring Testing —
@WebMvcTest,MockMvcи проверка контроллеров.