API
Моделирование REST-ресурсов: существительные, нужные клиенту, а не твои глаголы и таблицы
Миграция переименовывает users.full_name в users.display_name. Рутина. Деплой уходит в пятницу днём, и три версии мобильного приложения «в полях» начинают рисовать пустые поля имени, потому что API сериализовал строки БД прямо в JSON, а клиенты завязались на full_name. Чинить нечего — колонка и есть контракт. Откатывай миграцию, выкатывай хотфикс, который никто не сможет установить две недели, и добавляй «переименовать колонку» в список того, что теперь требует скоординированного релиза.
Ресурсы — существительные; действия — не endpoint’ы
Главный ход REST — смоделировать домен как ресурсы, адресуемые существительные: заказ, пользователь, инвойс, — и дать небольшому фиксированному набору HTTP-глаголов действовать на них. GET /orders/42 читает, PATCH /orders/42 обновляет, DELETE /orders/42 удаляет. Глагол живёт в методе, а не в URL. В тот миг, когда ты пишешь POST /createOrder или GET /getUserById, ты перестал делать REST и начал делать RPC с лишними шагами: URL теперь несёт глагол, который HTTP-метод уже выражает, и ты потерял кэшируемость, единообразный интерфейс и каждый инструмент, рассуждающий о ресурсах.
Две конвенции, которые сеньор считает не обсуждаемыми ради консистентности, потому что клиенты их распознают по паттерну:
- Множественные существительные коллекций:
/orders,/orders/42. Коллекция — это/orders; элемент —/orders/{id}. Не мешай/orderи/orders. - Стабильные непрозрачные ID, а не позиционные или угадываемые. ID в
/orders/42— это идентичность ресурса; клиенты сохраняют его и перезапрашивают, поэтому он не должен меняться при пересортировке или re-shard.
Фильтрация, сортировка и пагинация — не новые endpoint’ы, а query-параметры на коллекции: GET /orders?status=open&sort=-created_at&page=2. Изобретать /openOrders и /recentOrders как отдельные пути — та же RPC-ошибка на уровне коллекции.
Проблема не-CRUD действий
В реальных доменах есть действия, которые не create/read/update/delete: отменить заказ, опубликовать пост, вернуть платёж, перезапустить джобу. Наивный фикс — POST /orders/42/cancel читается как endpoint-глагол, и пуристы кривятся. Но это то самое место, где побеждает прагматизм, и даже гайдлайны Microsoft и Thoughtworks это принимают: переход состояния, который не является естественным обновлением поля, лучше всего моделировать как action-подресурс или transition-ресурс.
Два чистых паттерна:
- Action-подресурс:
POST /orders/42/cancel. Да, это глагол в URL, но он привязан к ресурсу и использует POST (неидемпотентный, с побочным эффектом) — честно. Большинство крупных публичных API (Stripe, GitHub) делают ровно так. - Transition-ресурс (трюк «онаунивания»): смоделируй само действие как ресурс. Возврат — не глагол на платеже, а
Refund, который ты создаёшь:POST /payments/42/refunds. Теперь у возврата есть свой ID, статус и аудит-трейл, и ты можешь сделатьGET /payments/42/refunds, чтобы получить их список. Это строго лучше, когда у действия есть собственный жизненный цикл или нужна запись о нём.
Анти-паттерн, которого надо избегать: подделывать переход через PATCH /orders/42 {"status": "cancelled"}. Выглядит RESTful, но это ловушка — ты выставил внутреннее поле состояния как доступное клиенту на запись, поэтому клиент может выставить status во что угодно, обойти бизнес-правила, гейтящие настоящую отмену, и загнать заказ в состояние, которого твой код никогда не ожидал.
| Ты хочешь… | Не надо | Надо |
|---|---|---|
| Создать заказ | POST /createOrder | POST /orders |
| Получить открытые заказы, новые первыми | GET /openOrders | GET /orders?status=open&sort=-created_at |
| Отменить заказ | PATCH /orders/42 {"status":"cancelled"} | POST /orders/42/cancel |
| Вернуть платёж (с аудитом) | POST /refundPayment?id=42 | POST /payments/42/refunds |
Гранулярность: чрезмерно вложенный, болтливый API
Вложенность выражает владение: /orders/42/items — это элементы, принадлежащие заказу 42. Читается хорошо — пока не продолжишь дальше. /customers/7/projects/3/orders/42/items/9/discounts — это реальная форма, которую команды отгружают, и это катастрофа в замедленной съёмке. URL хрупкие (перемести элемент между заказами — и каждый закодированный путь ломается), и хуже того, глубокая вложенность вынуждает болтливого клиента: чтобы отрисовать один экран, приложение должно пройти иерархию — получить клиента, затем его проекты, затем заказы каждого проекта, затем элементы каждого заказа — это N+1 проблема, поднятая до сетевых round-trip’ов. Дашборд, которому нужно 8 сущностей, превращается в 8+ последовательных HTTPS-вызовов, каждый платит свой налог на latency; при 80 мс на round-trip этот экран занимает 640 мс до любого рендеринга.
Правило большого пальца сеньора: вкладывай максимум на один уровень, два — как абсолютный потолок. Дальше адресуй ресурсы по их собственному ID в корне и используй query-параметры для связи. Вместо /customers/7/projects/3/orders выстави /orders?customer=7&project=3. У заказа есть собственная стабильная идентичность; добираться до него не должно требовать всей его родословной. Для проблемы болтливого экрана конкретно ответ — более грубое представление: позволь клиенту запросить связанные данные через ?expand=items,customer или ?include=..., чтобы один вызов вернул то, что нужно экрану, вместо принудительных N проходов.
Почему это работает
Почему POST /orders/42/cancel терпят, когда «глаголы в URL» — кардинальный грех? Потому что альтернатива — поле status, доступное на запись, — хуже. Endpoint отмены — это защищённый переход: сервер прогоняет правила (заказ уже отгружен? уже возвращён?) и либо выполняет весь переход, либо отклоняет его. Поле на запись отдаёт клиенту ключи от машины состояний. Чистота «без глаголов в URL» — меньшая цена, чем незащищённое поле состояния.
Representation vs ресурс и ловушка дырявой абстракции
Ресурс — это концептуальная вещь (заказ). Representation (представление) — одна сериализация ресурса (JSON, который ты возвращаешь; XML-вариант; краткий вид vs полный). У одного ресурса может быть много представлений; ресурс стабилен, представление — это выбор. Именно это различие даёт твоему API пережить изменения — а игнорирование его и вызвало пятничную катастрофу из Hook.
Когда API сериализует строки БД напрямую, representation и есть таблица. Теперь схема таблицы — публичный контракт: переименуй колонку — сломаешь каждого клиента; добавь колонку NOT NULL — старые записи падают; выстави внутренний флаг is_deleted — и клиент начнёт от него зависеть. Фикс — явный mapping-слой (DTO, сериализатор, view model), который переводит между формой хранения и формой на проводе. Он стоит пары минут на endpoint и покупает свободу рефакторить базу без скоординированного релиза клиентов. Это самое высокорычажное решение в дизайне API: representation — это контракт, которым владеешь ты, а не случайность твоего ORM.
HATEOAS — теоретический эндшпиль здесь: ответы несут гипермедиа-ссылки (_links: { cancel: "/orders/42/cancel" }), чтобы клиенты открывали доступные действия вместо хардкода URL, а сервер мог свободно эволюционировать URL. В теории это серебряная пуля развязки. На практике это самое недоиспользуемое ограничение REST — большинство «REST» API находятся на уровне 2 по Ричардсону (ресурсы + глаголы, без гипермедиа), потому что клиентские фреймворки редко потребляют ссылки, а дисциплина редко окупается вне крупных гипермедиа-нативных экосистем. Знай, что это, знай, почему это редкость, и не давай пуристу блокировать твой релиз из-за её отсутствия.
Экран показывает клиента с его проектами и заказами каждого проекта. Текущий API — /customers/{id}/projects/{id}/orders, и рендеринг занимает 9 последовательных вызовов. Выбери редизайн.
Нужно дать клиентам возвращать платёж, а финансы хотят, чтобы каждый возврат был аудируемым со своим статусом. Какая модель самая чистая?
Почему сериализация строк БД прямо в JSON в ответах кусает тебя потом?
Расставь шаги, чтобы смоделировать возможность «опубликовать черновик поста» по-RESTful:
- 1 Определи ресурс: пост — это существительное → /posts/{id}
- 2 Распознай, что 'publish' — переход состояния, а не CRUD-поле, которое клиент должен писать напрямую
- 3 Смоделируй это как защищённый action-подресурс: POST /posts/{id}/publish
- 4 Сервер прогоняет правила (уже опубликован? автор имеет право?) и выполняет весь переход
- 5 Верни новое представление поста (status: published) — через сериализатор, а не сырые строки БД
- 01Коллега хочет добавить «отменить заказ» как PATCH /orders/{id} с {"status": "cancelled"}. Объясни, почему это ловушка и что делать вместо этого.
- 02Почему «не сериализуй строки БД напрямую» считается самым высокорычажным решением в дизайне API и что реально покупает mapping-слой?
REST API — это модель ресурсов, существительных, важных клиенту, адресуемых стабильными ID, где глагол живёт в HTTP-методе, а не в URL. Используй множественные существительные коллекций и клади фильтрацию, сортировку и пагинацию в query-параметры, а не плоди новые endpoint’ы. Не-CRUD действия — настоящий тест: моделируй переход состояния как защищённый action-подресурс (POST /orders/{id}/cancel) или, когда у него свой жизненный цикл, как transition-ресурс (POST /payments/{id}/refunds) — но никогда как поле status на запись клиенту, которое разблокирует машину состояний. Держи вложенность мелкой (один уровень, два максимум) и тянись к query-параметрам плюс расширению полей, чтобы один экран не разворачивался в N последовательных round-trip’ов. И главное — сериализуй через mapping-слой, чтобы representation было контрактом, которым владеешь ты, а не зеркалом таблиц БД: это одно решение и держит базу рефакторимой без поломки каждого клиента. HATEOAS — изящная теория самоописывающих ссылок; знай её и знай, почему уровень 2 REST — это то, что почти все реально отгружают.