1. Endpoint в Spring MVC как часть контракта
Когда новичок впервые видит контроллер Spring, легко почувствовать, будто аннотации — это просто способ «как-то привязать URL к методу». Но на деле вы делаете вещь важнее: описываете публичный интерфейс вашего сервиса. Endpoint — это не просто Java-метод, а связка «HTTP-метод + путь», и именно она будет жить долго и стабильно, даже если вы перепишете половину внутреннего кода.
Чтобы почувствовать разницу, представьте, что Java-метод — это «внутренняя кнопка» в вашем приборе, а endpoint — это кнопка на панели снаружи, по которой клиенты действительно нажимают. Клиенту не важно, как называется ваш метод listTasks() — он всё равно никогда его не увидит. Клиенту важно, что он может сделать GET /api/v1/tasks и получить понятный ответ.
В этом курсе мы сознательно относимся к endpoint’ам как к контракту, поэтому нас интересуют две вещи: как объявить endpoint в коде и как сделать это читаемо. Читаемо — это значит, что вы открыли класс, пробежались глазами по аннотациям и уже понимаете, какие маршруты поддерживаются. Почти как карта метро: линии и станции должны быть видны, иначе поездки превращаются в квест.
Небольшая «табличка контракта», чтобы держать опору в голове:
| Часть контракта | Пример | Как выражаем в Spring MVC |
|---|---|---|
| HTTP‑метод | GET | @GetMapping (или ) |
| Путь | /api/v1/tasks | @RequestMapping("/api/v1/tasks"), и т.д. |
| Где живёт endpoint | контроллер + метод | @RestController + метод с mapping-аннотацией |
О теле запроса, сериализации JSON и статусах ответа мы сейчас сознательно не говорим. В этой лекции нам важнее научиться объявлять маршруты так, чтобы по коду контроллера было видно устройство API.
2. @RestController: режим API
Когда вы пишете backend API, вы обычно хотите, чтобы метод контроллера возвращал данные, которые станут телом HTTP-ответа. И вот здесь начинается путаница: в Spring есть «контроллеры для страниц» (когда вы возвращаете имя HTML-шаблона), и есть «контроллеры для API» (когда вы возвращаете данные). @RestController как раз и говорит Spring: «это API-контроллер, не пытайся сделать из него генератор HTML».
Внутри Spring это решено довольно элегантно: @RestController — это по сути «сборная аннотация», которая означает «контроллер + возвращаемое значение = тело ответа». Вам не нужно помнить детали реализации, достаточно запомнить мысль: если вы строите API, которое в первую очередь отдаёт JSON, то базовая аннотация контроллера — @RestController.
Самый маленький «пинг», который показывает, что endpoint объявлен и работает, выглядит так:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Говорим Spring: это REST API-контроллер, возвращаемое значение — тело ответа
class PingController {
@GetMapping("/api/v1/ping") // Контракт: GET /api/v1/ping
public String ping() {
return "pong"; // Это станет телом HTTP-ответа
}
}
Если к вашему приложению приходит запрос GET /api/v1/ping, Spring сопоставляет его с этим методом и вызывает ping(). А строка "pong" попадает в тело HTTP-ответа (да, просто строкой — это нормально для демонстрации, даже если в реальном API чаще будет JSON).
Практический нюанс, который часто «стреляет» новичкам: если вы случайно поставите @Controller вместо @RestController, то Spring может начать трактовать строку "pong" как имя представления (view name), то есть как «название HTML-шаблона». И вы будете сидеть и думать: «Почему я возвращаю pong, а сервер пытается найти какой-то pong.html?». Это классическая ситуация, когда кажется, что «Spring сломался», хотя на самом деле вы просто включили другой режим работы.
Ещё один важный момент: Spring должен найти ваш контроллер. Обычно это происходит благодаря сканированию компонентов: если ваш @SpringBootApplication находится в корневом пакете com.example.tasktracker, то Spring увидит классы ниже по дереву пакетов, например com.example.tasktracker.api.controller. Поэтому с самого начала стоит держать контроллеры в одном понятном месте, а не раскидывать их по всему проекту «как файлы на рабочем столе».
3. @RequestMapping на классе: общий префикс
Когда вы проектируете ресурс tasks, логично, что все операции по нему начинаются с общего префикса /api/v1/tasks. Если вы будете писать этот префикс в каждом методе контроллера, получится много повторений, а повторения — это почти всегда будущие ошибки. Один раз опечатались, один раз забыли /api/v1, и внезапно половина API «уехала» на другой путь.
Для этого и существует @RequestMapping на уровне класса: он задаёт общий префикс пути для всех методов внутри контроллера. Очень удобная ментальная модель: класс контроллера — это как «папка», а методы — как «файлы» внутри неё. Папка задаёт базовый путь, а каждый метод добавляет «хвостик».
Для механики общего префикса возьмём демонстрационный DemoTaskController: здесь нам важно увидеть именно путь на уровне класса, без всей остальной проектной обвязки.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST-режим: возвращаемое значение методов — тело ответа
@RequestMapping("/api/v1/tasks") // Базовый префикс для всех endpoint’ов этого контроллера
class DemoTaskController {
@GetMapping // GET на базовый путь контроллера: /api/v1/tasks
public String listTasks() {
return "tasks"; // Для демонстрации возвращаем строку
}
}
Здесь важно увидеть две вещи.
Первая: @RequestMapping("/api/v1/tasks") на классе говорит, что контроллер в целом отвечает за ресурс tasks в версии /api/v1.
Вторая: @GetMapping без аргументов означает «GET на базовый путь контроллера». То есть итоговый endpoint — GET /api/v1/tasks. Мы не пишем "/api/v1/tasks" второй раз, и это не лень, а дисциплина: чем меньше повторяющегося текста в контракте, тем меньше шанс сделать /tasks/tasks.
Кстати, у @RequestMapping есть несколько вариантов записи, которые иногда сбивают с толку, но по смыслу они близки:
// Все три записи ниже эквивалентны: это один и тот же путь, просто разные имена параметра
@RequestMapping("/api/v1/tasks")
@RequestMapping(path = "/api/v1/tasks")
@RequestMapping(value = "/api/v1/tasks")
В учебном проекте лучше выбрать один стиль и придерживаться его. Обычно самый простой — без лишних слов: @RequestMapping("/api/v1/tasks").
4. @GetMapping: читаемое объявление GET
@RequestMapping — это «универсальный швейцарский нож»: им можно объявить endpoint почти для чего угодно. Но проблема швейцарских ножей в том, что если вы постоянно пользуетесь только ножом, ложкой и вилкой, доставать каждый раз «универсальную штуку на 27 функций» — немного шумно.
В Spring MVC есть целое семейство специализированных mapping-аннотаций, которые делают код короче и яснее. Для GET-операций используется @GetMapping. Это не «другой механизм», а просто более читаемый способ сказать: «этот метод обрабатывает GET».
Сравните два варианта. Оба работают одинаково:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST API-контроллер
class PingController {
@GetMapping("/api/v1/ping") // Короткая и читаемая запись контракта: GET /api/v1/ping
public String ping() {
return "pong"; // Пойдёт в body ответа
}
}
и более «универсальный», но более шумный:
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST API-контроллер
class PingController {
@RequestMapping(path = "/api/v1/ping", method = RequestMethod.GET) // То же самое, но через универсальный @RequestMapping
public String ping() {
return "pong"; // Тело ответа
}
}
Во втором варианте мы тратим больше символов на то, чтобы сказать то же самое. А в контрактном коде, который читается чаще, чем пишется, лишние символы — это не украшение, а мусор (иногда очень дорогой мусор).
Чтобы картинка стала совсем цельной, вот небольшая таблица «семейства»:
| HTTP‑метод | Mapping-аннотация | Интуитивный смысл (без углубления) |
|---|---|---|
| GET | @GetMapping | чтение ресурса/коллекции |
| POST | @PostMapping | создание |
| PUT | @PutMapping | полная замена |
| PATCH | @PatchMapping | частичное изменение |
| DELETE | @DeleteMapping | удаление |
В рамках сегодняшнего дня нас интересует именно @GetMapping, потому что мы стартуем с простых чтений. Но важно уже сейчас привыкнуть к идее: в Spring MVC принято выражать web‑контракт декларативно, а значит — максимально читаемо.
5. Сборка полного пути endpoint’а
Когда вы используете @RequestMapping на классе и mapping-аннотацию на методе, итоговый путь получается склейкой этих двух частей. Это очень удобно, но у такой удобности есть побочный эффект: если вы перепутаете, что задаётся на классе, а что на методе, то легко соберёте путь, который формально валиден, но логически нелеп.
Давайте зафиксируем эту механику в одной таблице. Пусть у нас есть:
@RequestMapping("/api/v1/tasks")
class DemoTaskController { ... }
Тогда:
| Аннотация на методе | Итоговый путь | Как это читать |
|---|---|---|
| @GetMapping | /api/v1/tasks | «список задач» (коллекция) |
| @GetMapping("/summary") | /api/v1/tasks/summary | «список задач, но в специальном представлении» |
| @GetMapping("/{taskId}") | /api/v1/tasks/{taskId} | «одна задача по id» (placeholder в пути) |
Обратите внимание: в строках с аннотациями на методе мы добавляем только «хвост». Поэтому "/summary", а не "/api/v1/tasks/summary".
Теперь покажем антипаттерн, который встречается буквально у каждого второго новичка (ничего страшного, это почти обязательная стадия взросления):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST API-контроллер
@RequestMapping("/api/v1/tasks") // Базовый путь ресурса tasks
class BadDemoTaskController {
@GetMapping("/tasks") // Ошибка: повторяем сегмент и получаем /api/v1/tasks/tasks
public String listTasks() {
return "wrong"; // Демонстрация: будет отвечать, но контракт станет менее чистым
}
}
В результате получится путь /api/v1/tasks/tasks. Технически сервер не «сломался» — он честно поднялся и будет обрабатывать этот URL. Но контракт API стал хуже: он менее предсказуем, менее читабелен и точно вызовет у клиента вопрос: «А почему tasks два раза?».
Иногда кажется, что «ну и ладно, клиент же может вызвать». Может. Но именно такие мелочи в сумме превращают API в набор случайностей. А мы как раз учимся делать API таким, чтобы его можно было понять без телепатии.
Если хочется визуализации, можно держать в голове такую схему:
flowchart TB
A["@RequestMapping('/api/v1/tasks') DemoTaskController"] --> B["@GetMapping listTasks()"]
A --> C["@GetMapping('/summary') summary()"]
A --> D["@GetMapping('/{taskId}') details()"]
Здесь важно не то, что вы выучили Mermaid. Важно, что вы видите: класс задаёт «корень дерева», а методы — ветви.
6. Два стиля объявления путей
В Spring MVC можно встретить оба стиля, и оба технически допустимы. Но с точки зрения читабельности контракта (а это наша главная валюта в этом курсе) они очень разные.
Первый стиль — «полный путь в каждом методе». Он выглядит так:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST API-контроллер
class DemoTaskController {
@GetMapping("/api/v1/tasks") // Полный путь объявлен прямо здесь: GET /api/v1/tasks
public String listTasks() {
return "tasks"; // Упрощённый ответ для демонстрации
}
}
Здесь плюс в том, что путь виден целиком прямо в методе. Минус в том, что если у вас станет 5–10 методов, вы получите 5–10 повторений /api/v1/tasks, и все эти строки нужно будет поддерживать в одинаковом виде.
Второй стиль — «общий префикс на классе». Он выглядит так:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST API-контроллер
@RequestMapping("/api/v1/tasks") // Общий префикс для всех методов
class DemoTaskController {
@GetMapping // Здесь видна операция, а путь собирается автоматически: GET /api/v1/tasks
public String listTasks() {
return "tasks"; // Упрощённый ответ для демонстрации
}
}
Этот вариант обычно легче поддерживать и легче читать как карту API. Вы один раз увидели, что класс про tasks, и дальше вам остаётся различать операции внутри ресурса.
В учебном проекте Task Tracker API мы будем придерживаться второго стиля, потому что он лучше поддерживает ресурсное мышление: один контроллер — один ресурс, и путь ресурса задан на классе.
Ещё один практический момент: @RequestMapping можно ставить и на класс, и на методы. Но «в среднем по больнице» очень хорошая привычка такая: на классе — @RequestMapping как базовый путь ресурса, а на методах — более конкретные аннотации (@GetMapping и другие). Так меньше шанс случайно сделать endpoint, который реагирует на «все методы сразу» без вашего осознания.
7. Читаемый контракт: имена и структура URI
Когда вы работаете в команде, контроллеры читают чаще, чем пишут. Их читают коллеги, которые добавляют фичи. Их читают тестировщики (иногда буквально глазами). Их читает будущий вы, когда через два месяца забудете, что именно имели в виду. Поэтому контроллер должен выглядеть как аккуратная вывеска, а не как полотно «и так сойдёт».
Начинается всё с простого: контроллер называется по ресурсу в единственном числе, например TaskController, а путь ресурса — во множественном, например /tasks. Это кажется мелочью, но именно из таких мелочей и строится API, которое можно угадать без документации.
Дальше — группировка. Не стоит складывать в один контроллер «всё, что связано с задачами, комментариями, тегами и погодой в Токио». Даже если вы умеете, даже если вам скучно. Один контроллер — одна зона ответственности. Для сегодняшнего дня нам достаточно ресурса tasks.
Пример контроллера, который читается как маленькая карта API:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST API-контроллер
@RequestMapping("/api/v1/tasks") // Ресурс: tasks (коллекция)
class DemoTaskController {
@GetMapping // GET /api/v1/tasks — получить список
public String listTasks() {
return "list"; // Демонстрационный ответ
}
@GetMapping("/summary") // GET /api/v1/tasks/summary — альтернативное представление
public String summary() {
return "summary"; // Демонстрационный ответ
}
}
Здесь пока возвращаются строки, потому что наша цель — увидеть структуру endpoint’ов. Но уже сейчас видно, какие маршруты доступны. И это главный критерий успеха на текущем шаге: вы открыли контроллер и понимаете, что он делает, без чтения «внутренностей» методов.
И ещё одна микро-привычка, которая сильно помогает: не делать имена методов слишком «техническими». Название listTasks() лучше, чем getMappingForTasks() (да, такое тоже бывает). Контроллер — это слой про контракт, а контракт должен говорить словами «что мы делаем», а не «как именно оно технически устроено».
8. Типичные ошибки при объявлении endpoint’ов
Ошибка №1: использовать @Controller вместо @RestController и удивляться «куда делось тело ответа».
Если вы пишете API, которое в первую очередь отдаёт JSON, и возвращаете строку или объект, то ожидаете, что это станет телом ответа. Но @Controller исторически больше про MVC-страницы, и возвращаемая строка может трактоваться как имя view. В итоге вы не получаете то, что ожидали, и кажется, будто «Spring чудит». Лечится это просто: для REST API используйте @RestController.
Ошибка №2: дублировать сегменты пути и случайно получить /api/v1/tasks/tasks.
Это происходит, когда на классе уже стоит @RequestMapping("/api/v1/tasks"), а на методе вы добавляете @GetMapping("/tasks"). Формально всё работает, но контракт становится менее чистым и менее предсказуемым. Хорошее правило — на классе держать базовый путь ресурса, а на методе добавлять только хвост ("/summary"), либо вообще ничего не добавлять для корня ресурса.
Ошибка №3: смешивать в одном контроллере разные ресурсы «потому что так быстрее».
Сегодня вы добавили в TaskController ещё и GET /api/v1/tags, «потому что это же тоже рядом». Завтра добавили туда же комментарии. Послезавтра контроллер превращается в свалку из несвязанных между собой endpoint’ов. Плата за «быстрее сейчас» — «больнее всегда». Контроллер должен быть сгруппирован вокруг одного ресурса, иначе читать контракт становится тяжело.
Ошибка №4: делать глагольные пути вроде /api/v1/getTasks или /api/v1/tasks/listAll.
Мы уже договорились мыслить ресурсами: tasks — это коллекция. Для чтения коллекции у нас есть GET. Если вы добавляете глагол в путь, вы ухудшаете читаемость и начинаете скатываться в RPC-стиль. Путь — про ресурс, метод — про операцию.
Ошибка №5: объявить mapping слишком общим и неосознанно «поймать всё подряд».
Новичок иногда ставит @RequestMapping("/api/v1/tasks") на метод и забывает ограничить HTTP-метод, а потом удивляется, что endpoint реагирует не так, как ожидалось. Универсальный @RequestMapping требует аккуратности: если вы хотите GET — используйте @GetMapping. Это и яснее, и безопаснее для контракта.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ