1. REST‑словарь и общий язык
Если вы хоть раз спорили в команде, “как правильно сделать REST”, то знаете эту боль: один человек говорит “ресурс” и имеет в виду JSON‑объект, другой — “табличку в базе”, третий — “endpoint”, а четвёртый — “любую сущность, у которой есть id”. Разговор получается как диалог двух микросервисов без общего контракта: все что‑то шлют, но никто никого не понимает.
В этой лекции мы фиксируем минимальный набор терминов, который позволит нам дальше проектировать Task Tracker API осознанно. Нам важно различать что существует в предметной области (ресурс), как это существует в API как набор (коллекция), как это показывается и принимается в обмене (представление) и что живёт только в контексте родителя (подресурс). Никакой философии, только инженерная ясность.
Чтобы было проще, держите в голове одну мысль: REST‑словарь нужен не ради “правильности”, а чтобы потом быстро отвечать на вопросы вроде “это отдельная сущность или часть другой?” и “этот JSON — это ресурс или просто представление?”.
Ресурс: смысл и идентичность
Когда слово “ресурс” слышишь впервые, очень хочется мысленно подставить “объект в коде” или “запись в базе”. Это естественно: мы привыкли мыслить тем, что можно потрогать. Но в REST ресурс — это прежде всего значимая часть предметной области, с которой работает клиент, и у неё есть устойчивая идентичность (обычно — идентификатор).
Ключевое здесь вот что: ресурс — это “вещь” на уровне смысла. В нашем проекте “задача” — ресурс не потому, что у нас будет класс Task (он будет), и не потому, что будет таблица tasks (её пока не будет), а потому, что клиент мыслит системой так: “вот есть задачи, я хочу посмотреть одну, изменить одну, удалить одну”. То есть для клиента “задача” — отдельная адресуемая сущность.
Очень полезный здравый критерий: ресурс — это то, на что вы можете сослаться фразой “вот эта штука” и не потерять смысл. “Эта задача”, “этот комментарий”, “это вложение”. А вот “сортировка по приоритету” — не ресурс, это параметр работы с коллекцией. “Статус” — не отдельный ресурс в нашем проекте, а часть состояния задачи (по крайней мере на текущем масштабе).
Мини‑пример на Java (не про Spring, просто про смысл “есть идентичность”):
import java.util.UUID;
public class IdExample {
public static void main(String[] args) {
// UUID здесь играет роль устойчивой идентичности ресурса (taskId),
// а не "просто строки" (важно именно то, что это адресуемый идентификатор).
String taskId = UUID.fromString("2f1d1b7a-3c8a-4f2b-9f2a-0e6f3c8b2d11").toString();
// Клиенту важно, что по этому id можно сослаться на "ту самую" задачу.
System.out.println(taskId); // 2f1d1b7a-3c8a-4f2b-9f2a-0e6f3c8b2d11
}
}
Здесь не важно, где хранится задача. Важно, что ресурс “Task” в нашем проекте будет адресуемым: у него есть id (UUID string по ТЗ проекта), и это позволяет клиенту сказать: “дай мне задачу с таким id”.
2. Коллекция и отдельный ресурс
Когда мы говорим “задачи” (tasks), речь обычно идёт о двух разных типах запросов клиента, и по смыслу они сильно отличаются. Первый — “покажи мне список задач” (коллекция), второй — “покажи мне конкретную задачу” (один ресурс). Если не различать эти случаи, API начинает путаться: мы случайно пытаемся применить к одному объекту правила коллекции, а к списку — правила единичного ресурса.
Коллекция — это набор однотипных ресурсов. Важный момент: коллекция — это не просто List<Task> в Java. Коллекция в REST‑мышлении — это часть внешнего контракта: клиент может “просматривать” набор, добавлять в него элементы, искать в нём нужный. Даже если внутри мы храним данные как угодно (массив, карта, файлик, “а у нас пока просто хардкод, потому что учебный проект”), для клиента это всё равно коллекция задач.
Отдельный ресурс — это конкретный элемент коллекции. И тут уже включаются другие ожидания: у элемента есть конкретный id, у него есть состояние, и операции над ним обычно происходят “по адресу”.
Пока не обсуждаем правила “как именно должны выглядеть URI” (это будет завтра), просто зафиксируем: это два разных объекта разговора.
import java.util.List;
public class CollectionVsItemExample {
public static void main(String[] args) {
// Путь к коллекции: здесь мы "говорим о множестве" (список/поиск/добавление).
String tasksCollection = "/tasks";
// Путь к элементу: здесь мы "говорим о конкретной штуке" по её идентификатору.
String oneTask = "/tasks/{taskId}";
List<String> forms = List.of(tasksCollection, oneTask);
// Наглядно видно, что это два разных объекта разговора: коллекция vs элемент.
System.out.println(forms); // [/tasks, /tasks/{taskId}]
}
}
Даже этот простой пример полезен: /tasks — это не “путь к задаче”, а путь к коллекции задач. А /tasks/{taskId} — это уже путь к конкретной задаче. Домен один, но вопросы клиента разные.
3. Представление: как ресурс выглядит наружу
Пожалуй, самое недооценённое слово в REST‑словаре — “представление”. Новичок часто думает так: “Ну ресурс — это JSON, который я отдаю”. И пока проект маленький, кажется, что это работает. Но как только у ресурса появляется хоть какая‑то жизнь (разные поля, разные сценарии, разные формы ответа), выясняется: ресурс — это одно, а его JSON‑форма — другое.
Представление ресурса (representation) — это форма данных, в которой ресурс показывается клиенту или принимается от клиента. Это может быть JSON (в нашем курсе это основной формат), но в общем случае представление может быть и другим: например, у вложения может быть JSON‑метадата и бинарное содержимое файла. Мы детально займёмся файлами позже, но мысль полезно зафиксировать уже сейчас: representation — это то, “как выглядит ресурс в обмене”.
Что важно для проекта Task Tracker API: одна и та же задача может иметь разные представления. Для списка задач обычно удобнее показывать “короткую карточку” (id, title, status), а для подробного просмотра — “детальную карточку” (плюс описание, теги, сроки и так далее). Это не “хитрый трюк”, а обычная потребность клиента.
Мини‑пример (пока не называем это DTO и не уходим глубоко в контрактный дизайн — просто фиксируем идею разных представлений):
import java.time.LocalDate;
// "Ресурсный смысл": задача как сущность домена (внутренняя/богатая форма).
record Task(String id, String title, String description, LocalDate dueDate) {}
// "Представление": упрощённая форма для конкретного сценария (например, список задач).
record TaskSummaryView(String id, String title) {}
public class RepresentationExample {
static TaskSummaryView toSummary(Task task) {
// Маппинг в представление: сознательно показываем наружу только нужные поля.
return new TaskSummaryView(task.id(), task.title());
}
}
Task здесь — условный “ресурсный смысл” (задача), а TaskSummaryView — одно из представлений. На уровне REST‑мышления мы учимся различать “что существует” и “как мы это показываем”.
4. Ресурс и представление: пример Task Tracker
Путаница “ресурс = JSON” обычно всплывает в самый неудобный момент: когда вы захотели добавить внутреннее поле, поменять формат ответа или скрыть что‑то от клиента. Внутри приложения вы можете хранить больше данных, чем показываете наружу, и это нормально. Наружу должен выходить контролируемый контракт, а ресурс как смысловая сущность при этом остаётся тем же.
Давайте закрепим это различие на одном домене. Возьмём задачу как ресурс и подумаем, как она может выглядеть снаружи.
| Понятие | Что это по смыслу | Пример в Task Tracker | Что важно помнить |
|---|---|---|---|
| Ресурс | Значимая сущность предметной области | “Задача” | Это не обязательно Java‑класс и не обязательно таблица |
| Представление | Форма данных “в обмене” | “Короткая карточка задачи” для списка, “детальная карточка” для просмотра | Представлений может быть несколько |
| Идентичность | Что делает ресурс “тем самым” | taskId (UUID string) | Идентификатор стабилизирует разговор клиента с API |
Ещё один небольшой пример кода, чтобы почувствовать, как внутренняя модель может быть богаче внешнего вида. Мы специально добавим “служебное” поле и не включим его в представление:
import java.time.Instant;
// Внутренняя модель: содержит служебные данные, которые не обязаны быть публичными.
record TaskInternal(String id, String title, String internalNote, Instant createdAt) {}
// Публичное представление: контролируемый контракт для клиента.
record TaskPublicView(String id, String title, Instant createdAt) {}
public class InternalVsPublicExample {
static TaskPublicView toPublic(TaskInternal task) {
// Важно: internalNote намеренно не выносится наружу — это часть "контрактной гигиены".
return new TaskPublicView(task.id(), task.title(), task.createdAt());
}
}
В реальном проекте internalNote может быть чем угодно: технической меткой, внутренним флагом миграции, деталями хранения в файловом хранилище и так далее. Главное: наличие поля внутри не означает, что оно обязано попасть во внешний контракт. И именно это различие между ресурсом и представлением потом спасает API от хаоса.
5. Подресурс: объект в контексте родителя
Теперь мы подходим к слову, которое чаще всего ломает новичкам мозг: “подресурс”. Подресурс — это тоже ресурс, но такой, который естественно существует в контексте другого ресурса, и чаще всего его жизненный цикл связан с родителем. Это не “кусок JSON внутри JSON”, а отдельная сущность, просто не самостоятельная на верхнем уровне нашей модели.
В Task Tracker API комментарий почти невозможно обсуждать в вакууме. Фраза “удали комментарий c-123” без уточнения задачи звучит подозрительно: а что это за комментарий, к чему он относится? Аналогично и с вложением: вложение “само по себе” пользователю почти не нужно, оно нужно “как вложение к задаче”.
Это и есть основной смысл подресурса: родительский контекст важен для смысла. Поэтому мы читаем comments и attachments как подресурсы задачи.
Пример на уровне модели (опять же — это не про Spring и не про конечный дизайн, а про сам смысл: “есть связь с родительским id”):
import java.time.Instant;
// Подресурс (comment) имеет свою идентичность (id),
// но смысл/жизненный цикл привязан к родительскому ресурсу (taskId).
record Comment(String id, String taskId, String authorName, String text, Instant createdAt) {}
public class SubresourceExample {
static boolean belongsToTask(Comment comment, String taskId) {
// Проверка привязки к родителю — в API это будет естественно выражено через URI,
// а в модели часто выражается через поле taskId.
return comment.taskId().equals(taskId);
}
}
Наличие taskId в комментарии подчёркивает идею: комментарий имеет свою идентичность (id), но живёт “под” задачей (taskId). Это модель, которая потом очень естественно ложится на ресурсную карту API.
6. Подресурсы и lookup: comments, attachments, tags
Когда вы начинаете проектировать API, очень легко сделать всё “одинаково важным” и превратить домен в бесконечный CRUD-зоопарк. У новичка это обычно выглядит так: “Раз есть комментарии — значит, нужен полный верхнеуровневый CRUD для comments. Раз есть вложения — значит, ещё один. Раз есть теги — значит, и их туда же”. И вот через два дня у вас уже не Task Tracker, а коллекция разрозненных контроллеров.
В Task Tracker API полезнее сразу разложить роли. Task — основной ресурс. Comment и Attachment — вспомогательные подресурсы задачи: у них есть свои id, но смысл держится на родительской задаче. Клиенту обычно нужен не “комментарий вообще”, а комментарий конкретной задачи; не “файл мира”, а вложение конкретной задачи.
С тегами картина другая. В рамках курса теги — это значения, которые живут внутри задач и помогают искать и классифицировать их. Поэтому tags удобнее мыслить как lookup-коллекцию значений: клиенту нужен список доступных вариантов, а не отдельный модуль управления тегами.
Небольшой пример, чтобы “теги как значения” почувствовались руками:
import java.util.Set;
import java.util.TreeSet;
public class TagsExample {
public static void main(String[] args) {
// В качестве "значений" теги удобно нормализовать/уникализировать.
// Здесь для демонстрации используем набор с регистронезависимым сравнением.
Set<String> tags = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
tags.add("backend");
tags.add("Backend"); // Дубликат с другой капитализацией.
// Идея: если теги — значения, то их уникальность/нормализация важнее CRUD-жизненного цикла.
System.out.println(tags); // [backend]
}
}
Этого различия уже достаточно, чтобы не путать три роли: основной ресурс, подресурс и lookup-значения.
7. Схема‑опора ресурсной модели
Когда эти слова разложены по местам, проектная поверхность читается без гадания:
- task — основной ресурс, с которым клиент работает как с отдельной адресуемой сущностью;
- comments и attachments — подресурсы task, потому что их смысл держится на родителе;
- tags — lookup-коллекция значений, а не параллельная CRUD-вселенная;
- представления живут рядом с ресурсами и не обязаны совпадать с внутренней моделью.
Этой схемы уже достаточно, чтобы перейти к следующему инженерному вопросу: как на коллекцию и один ресурс ложатся GET, POST, PUT, PATCH, DELETE? Иначе говоря, словарь у нас уже есть — теперь нужна грамматика операций.
8. Типичные ошибки в терминах
Ошибка №1: называть ресурсом любой JSON‑фрагмент.
Часто новичок видит поле status или priority в задаче и говорит: “о, это ресурс”. В итоге появляются странные идеи вроде “давайте сделаем /statuses и /priorities и вообще всё вынесем в отдельные сущности”. Обычно это не улучшает API, а только раздувает его. Ресурс — это не “любая часть данных”, а значимая единица домена, с которой клиент реально взаимодействует как с “вещью”.
Ошибка №2: путать коллекцию и отдельный ресурс, а потом удивляться странным операциям.
Если вы мыслите “список задач” как “одну задачу, только много”, в API появляются гибриды: где-то вы пытаетесь “удалить список”, где-то — “обновить все задачи одним запросом”, где-то — “получить задачу, но без id”. Правильное мышление проще: коллекция отвечает на вопрос “какие есть?”, а отдельный ресурс — “что за конкретная штука?”.
Ошибка №3: считать, что ресурс и представление обязаны совпадать один к одному.
Это приводит к монстру TaskDtoForEverything, который одновременно и для списка, и для деталей, и для создания, и для обновления, и ещё с внезапными полями, которые клиент “не должен трогать, но мы ему отдали, надеюсь, он не тронет”. В нормальном API ресурс стабилен по смыслу, а представления подбираются под сценарии клиента. Сегодня мы это фиксируем терминологически, а позже превратим в практику DTO и контрактов.
Ошибка №4: делать подресурсом всё подряд.
Иногда кажется, что раз у Task есть assigneeName, то “пользователь” — подресурс, значит, /tasks/{id}/assignee. Потом появляются /tasks/{id}/priority, /tasks/{id}/status, /tasks/{id}/title, и API превращается в набор “ручек” к отдельным полям. Подресурс — это отдельная сущность со смыслом и своим жизненным циклом, а не “любое поле в JSON”.
Ошибка №5: раздувать второстепенные элементы до масштаба главного ресурса.
В нашем проекте теги полезны, но они не являются “главной сущностью уровня Task”. Если превратить Tag в полный CRUD‑модуль, мы получим большой объём кода и обсуждений, который не даёт новых знаний про REST‑контракт, а просто увеличивает площадь проекта. В учебном домене нужно уметь вовремя остановиться — это навык не хуже, чем написать ещё один контроллер.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ