Скрытый текст
Однажды, обсуждая с коллегой код review, отметил для себя некоторые тонкости REST API, которые влияют не только на удобство использования и поддержки API, но и оказывают прямое влияние на стабильность и масштабируемость сервиса.
Введение
Доброго времени суток!
Хочу предложить вниманию читателей немного поговорить о такой немодной теме, как REST API. Если не углубляться в различия между REST API и REST-подобным API, то с такой вещью имел дело каждый: от junior до senior. Более того, проходя собеседование, можно услышать вполне естественный вопрос: что такое REST API? Если сильно не задумываться, а лишь вспомнить, с чем имел дело, проектируя API очередного сервиса, то можно ответить: ну, там используется http 1.1. Также есть глаголы GET, PUT, POST, DELETE, PATCH и ещё некоторые. Каждый глагол отвечает за что-то одно: создание, изменение, удаление, чтение и т.д. В ответе используются коды ошибок: 200, 301, 401, 404, 500 и т.д. Каждый код обозначает, что всё прошло успешно, или возникли некоторые проблемы: нет прав, страница не найдена, внутренняя ошибка сервера и т.д... И в принципе, такой ответ нельзя назвать некорректным. Всё так...но он поверхностный - не отражает сути REST API. Без понимания сути сложно не допустить ошибку при проектировании. А для понимания необходимо осознать REST API, то есть дать корректное определение.
Определение
И так, что такое REST API? Representational State Transfer APplication Interface - интерфейс приложения для передачи репрезентативного состояния. Многие слышали эту формулировку. Она достаточно сложная для понимания. Мне хочется предложить более простое определение: REST API - это про сущности и их состояния. Хотя, возможно, понятнее не стало :) Попробую донести свою мысль на конкретных примерах. Предположим, нужно создать сущность user с именем "Остап Бендер":
POST /users
{
"name": "Остап Бендер",
"age": 42,
"email": "bender@mail.ru"
}
Запрос POST /users создаст сущность с требуемыми значениями полей, то есть с требуемым состоянием. Если нужно получить сущность user, то это делается запросом:
GET /users/1
Возможный вариант ответа: 200-ый код ошибки и тело ответа
{
"name": "Остап Бендер",
"age": 42,
"email": "bender@mail.ru"
}
А если интересует сущность, которая относится к сущности user, например email, то запрос может выглядеть так:
GET /users/contacts?type=email
В обоих примерах речь идёт про сущность user. В первом демонстрируется создание сущности с определённым состоянием, а в последнем - чтение состояния сущности. Соглашусь, что рассмотренные примеры едва ли могут убедить, что REST API - это про сущности и их состояния, т.к. пользователя можно создать запросом:
POST /user/create
{
"name": "Остап Бендер",
"age": 42,
"email": "bender@mail.ru"
}
Что запрещает? Всё будет работать. И я так когда-то делал. В url /user/create видим глагол create - API получается не только про сущности, но и про действия над сущностями. Но такая ручка будет казаться корректной, если не знать о рекомендациях для REST API.
Рекомендации REST API
Помимо правильного выбора глаголов (POST, GET и т.д.), возвращаемых кодов ошибок, также следует придерживаться некоторых рекомендаций относительно url ручек: 1) использовать существительные во множественном числе, 2) указывать идентификатор сущности, если речь идёт о конкретной сущности, 3) никаких глаголов быть не должно... Да, знаю, обычно рекомендации начинают интересовать, когда "набьёшь шишки", если, конечно, не сделаешь вывод: "это особенность технологии, большего от неё не стоит ожидать". Тем не менее, хочется показать, какие ошибки рекомендации позволяют избежать. Поэтому вернёмся к уже рассмотренному примеру создания пользователя:
POST /user/create
{
"name": "Остап Бендер",
"age": 42,
"email": "bender@mail.ru"
}
Глагол POST выбран правильно. Именно этим глаголом создаются сущности. Но в url "user" в единственном числе, а в конце используется глагол "create", что недопустимо, т. к. REST вырождается в RPC. Более того, глагол "create" избыточен, т.к. о намерении создать сущность говорит глагол POST. Более корректным будет решение:
POST /users
{
"name": "Остап Бендер",
"age": 42,
"email": "bender@mail.ru"
}
Ну, хорошо, с глаголами в url понятно - избегаем избыточности. Но почему рекомендуется использовать существительные во множественном числе? Для ответа на этот вопрос рассмотрим пример получения сущности:
GET /users/1
В рассматриваемом примере GET говорит о желании просто прочитать сущность, "users" уточняет о какой именно сущности идёт речь, а "1" в конце url указывает на то, что интересует пользователь с идентификатором 1. С другой стороны, эту ручку можно было спроектировать следующим образом:
GET /user/1
Здесь избыточности никакой нет. Вроде, всё то же самое, что и в первом варианте. Проблема не заметна, пока речь идёт о чтении конкретной сущности. Но что, если нам нужно получить список сущностей? Думаю, вы согласитесь, что ручка
GET /user
несколько невыразительна. Возникает ощущение, что она вернёт первого попавшегося пользователя. На практике неоднократно видел, как обыгрывали такую проблему:
GET /user/list
Но это сомнительное решение, т.к. "list" воспринимается, как отдельная сущность со своим состоянием, которая зависит или как-то относится к сущности "user". Вот, чтобы не городить такие "костыли", рекомендуется использовать в url сущности во множественном числе. Тогда получим:
GET /users/1 - получение пользователя с идентификатором 1
GET /users - получение списка пользователей
Такая, казалось бы, мелочь делает ваше API более интуитивно понятным, а распространение этого правила на все виды запросов стандартизирует, а значит и упрощает восприятие API.
Суровая реальность
И всё, вроде, хорошо, но вот, когда обсуждал эти вещи с коллегами, то мне задали хороший вопрос: а как обыграть ситуацию, когда нужна ручка просто для отправки уведомления на почту? Сразу отмечу, что в системе была сущность user и другие сущности, которые никакого отношения к способам уведомлений не имели. Для такой ситуации напрашивается что-то в роде:
POST /users/1/emails/send
{
"to": "balaganov@ya.ru",
"subject": "Некоторая тема",
"body": "Что-то весьма интрересное"
}
Глагол POST используется, как наиболее подходящий - выбирать приходится из того, что имеем. Из url понятно, что речь идёт про почтовое уведомление пользователя с идентификатором 1. В конце url видим глагол "send", для понимания какое именно действие требуется. Но это быстрое, поверхностное решение. На самом деле, здесь нужно не только ещё одну ручку API "прикрутить", но и понять, что в данный момент системе не хватает сущности. А вот какой? Попробуем понять. Предположим не хватает сущности email. Но является ли email сущностью? Что такое сущность? - Эрик Эванс в своей книге "Предметно-ориентированное проектирование. Структуризация сложных программных систем." даёт вполне конкретное определение: логически целостный объект, определяемый совокупностью индивидуальных черт, называется сущностью. Допустим, email является value object, а в рассматриваемой системе есть 2 пользователя: Остап Бендер и Шура Балаганов. В этом случае email не имеет каких-либо уникальных свойств, то есть может быть присвоен любой родительской сущности. А это значит, что email Остапа Бендера можно присвоить Шуре Балаганову. Но тогда что получается? Шура Балаганов будет получать письма, адресованные Остапу Бендеру. А чужие письма читать не хорошо, поэтому email является сущностью, т.к. адрес почтового ящика уникален. Но добавив в систему сущность email, едва ли можно утверждать, что проблема решена:
POST /users/1/emails
{
"to": "balaganov@ya.ru",
"subject": "Некоторая тема",
"body": "Что-то весьма интрересное"
}
Дело в том, что REST API предлагает набор инструментов для crud-операций над сущностями. То есть запрос POST /users/1/emails воспринимается, как просто создание нового почтового ящика для конкретного пользователя, но никак не отправка письма. И вообще говоря, эта ручка должна иметь вид:
POST /users/1/emails
{
"name": "bender@ya.ru"
}
К сожалению, это не то, что нам нужно. А нужно нам просто отправить письмо. Как решить эту задачу?...Стойте, а что, если добавим сущность "задача" - task. Задачу можно создать запросом:
POST /users/1/tasks
{
"type": "email",
"data": {
"to": "balaganov@ya.ru",
"subject": "Некоторая тема",
"body": "Что-то весьма интрересное"
}
}
Хотя мы так и не достигли цели, но это уже намного лучше - соблюдаются рекомендации при проектировании REST API, и мы не оказались в тупике - получили решение, которое нужно дальше развивать. То есть сейчас есть ручки для двух сущностей: user, task; при помощи которых можно работать с таблицами базы данных:
user
user_id |
uuid |
name |
varchar(255) |
age |
smallint |
varchar(255) |
task
task_id |
uuid |
type |
varchar(255) |
user_id |
uuid |
data |
jsonb |
Вообще говоря, на этом этапе следует успокоиться относительно REST API, т.к. её миссия полностью выполнена - создана задача на отправку письма. Непосредственная же отправка письма не должна входить в зону ответственности API. Для этого действия следует использовать фоновый процесс:
С таким подходом к проектированию сервис будет состоять из 2-х независимых модулей: API и job. API будет позволять осуществлять crud операции над сущностями, а job запускать соответствующую бизнес-логику.
Стабильность и масштабируемость
А нужно ли так усложнять логику? Возможно, для конкретного примера нет. Где-то на фронте есть кнопка "уведомить" или "отправить письмо". Пользователь на неё нажимает, письмо отправляется; либо не отправляется, и появляется ошибка "Что-то пошло не так. Повторите попытку позже". Например, возникла проблема на стороне почтового сервера. Что в таком случае нужно будет сделать пользователю? - как минимум повторить свои действия либо обратиться в службу поддержки и устроить скандал. А далее кто-то из поддержки или успокоившийся пользователь снова повторит попытку отправки. В общем, однозначного ответа относительно архитектуры нет. Здесь, наверное, нужно смотреть на нервозность пользователей, и то, как сильно такие ситуации бьют по карману и репутации собственника бизнеса. Тем не менее, хочется отметить, что при архитектуре с API и фоновым процессом в случае возникновения проблем с отправкой письма, job может повторить это действие немного позднее. Более того, при повторных ошибках проблему можно отловить мониторингом сервиса, затем что-то в коде поправить и перезапустить job, который любезно отправит письмо. В чём прелесть такой схемы? - пользователя не нужно заставлять повторять свои действия. Ему можно просто где-то в интерфейсе приложения подсветить статус отправки письма и успокоить: "Вася, не волнуйся - разбираемся. Всё будет хорошо!". Думаю, согласитесь, что когда хотите что-то сделать в приложении, а у вас это не получается, то это немного огорчает. Но если вас ещё и заставляют повторить какие-то действия, то вы сильнее, так сказать...расстраиваетесь. Более того, даже если сотрудник тех. поддержки сделает какое-то действие за вас, то это тоже ситуацию может не сильно улучшить - в вашем кабинете может быть какая-нибудь информация, предназначенная только для ваших глаз, или он может что-то случайно сломать в вашем профиле. В любом случае, останется привкус, что кто-то залез в ваш шкафчик и шарился в личных вещах - это не приятно. А фоновый процесс полностью исключает необходимость повторять действия на интерфейсе, т.к. всё, что необходимо сделать, записано в таблицу базы данных. Да и схема взаимодействия получается быстрее и проще - API не нужно ждать ответ от почтового сервера, она просто записывает данные в базу данных - логика минимизирована, и ломаться здесь практически нечему, если, конечно, у вас API или БД не "ляжет". То есть что получается? Следуя нудным рекомендациям, а именно, используя REST API только для crud-операций над сущностями, вынуждены были немного изменить архитектуру сервиса - добавить модуль job; в конечном счёте такой шаг повлиял на стабильность и модульность сервиса, т.к. получили 2 небольших и независимых модуля с минимальной логикой; модульность в свою очередь повлияло на масштабируемость сервиса - для n экземпляров API можно поднять m экземпляров job. И, что самое приятное, в случае возникновения проблем с отправкой письма, львиная доля из них решается просто перезапуском модуля job.
Удобство использования и поддержки
Проектируя сервисы различной сложности, неоднократно слышал вопрос: что может быть тупее прикручивания ручки для REST API? Казалось бы, проектирование REST API не такое уж сложное дело, и ответ здесь очевиден. Но не будем спешить, а попытаемся на него ответить, рассмотрев следующий пример. Допустим, у нас есть сущность user с полями name, contact, is_active. Причём пользователь может иметь несколько контактов: почта, telegram, номер телефона. Поле is_active говорит об активности пользователя. Активному пользователю доступен больший функционал системы. Предположим, есть ручка создания, удаления и чтения пользователя. И тут бизнес просит добавить ручку активации пользователя. Одно из решений, которое сразу приходит в голову:
PATCH /users/1/activate
{
"is_ative": true
}
Соответственно, если нужно деактивировать, то:
PATCH /users/1/activate
{
"is_ative": false
}
И в статьях встречал, и коллеги говорили насколько это оригинальное решение. На выходе получается человекопонятное API. Хорошо, прикрутили ручку активации пользователя. А через какое-то время попросят ручку, которая будет менять имя пользователя. Хорошим тоном является придерживаться уже существующего стиля:
PATCH /users/1/rename
{
"name": "Александр Балаганов"
}
А ещё через некоторое время потребуется добавлять новые почтовые ящики в автоматическом режиме. И это решение будет следующим:
PATCH /users/1/new_email
{
"name": "balaganov_forever@ya.ru"
}
Соответсвенно, для добавления telegram и номера телефона будут свои ручки. И того для изменения сущности user у нас будет 5 ручек...Наблюдая за похожими эволюциями систем, я заметил, что достаточно было прикрутить всего одну ручку:
PATCH /users/1
{
"name": "balaganov_forever@ya.ru",
"is_active": true,
"contact": [
{
"type": "email",
"name": "balaganov@ya.ru"
},{
"type": "email",
"name": "balaganov_contact@ya.ru"
},{
"type": "telegram",
"name": "@balagan"
},{
"type": "phone",
"name": "8(xxx)-xxx-xx-xx"
}
]
}
1 ручку проще поддерживать, чем 5. Более того, работа будет выполнена всего один раз. Таким образом, проектируя REST API, как API для выполнения crud-операций над сущностями, а не выдумывая "улучшения" в виде пояснений в url: ativate, rename, new_email и т.д., получим универсальную и минимальную API. Ну, а теперь самое время вернуться к ранее заданному вопросу: "что может быть тупее прикручивания ручки для REST API". Я вот пришёл к такому ответу: "тупее прикручивания ручки для REST API может быть прикручивание аналогичной ручки уже существующим".
Вывод
Понимание REST API, как API для работы с сущностями, а также следование немногочисленным и несложным рекомендациям, позволяет спроектировать универсальную и минимальную API, что значительно облегчает доработку и поддержку. А также в принципе может привести к пересмотру архитектуры, что в свою очередь обеспечит высокую модульность и, как следствие, положительно скажется на стабильности и масштабируемости сервиса в целом.
Комментарии (29)
k4ir05
11.10.2024 07:53APplication Interface
Это кто такую расшифровку API придумал? Нелепо же выглядит.
Blooderst
11.10.2024 07:53правильно - Application Programming Interface, подсказывает гугол в первой же выдаче )
k4ir05
11.10.2024 07:53Я то правильную знаю. Тут вопрос откуда автор черпал информацию. А то возникают сомнения в его компетентности и смысле читать статью. Там и так с самого начала полно воды.
AlexandrovAndrey Автор
11.10.2024 07:53Компетентному специалисту не составит труда не только подметить некоторые неточности с статье, но и в принципе оспорить главную мысль, причём аргументированно оспорить. Всегда интересно узнать опыт другого специалиста. Поэтому, если тебе хватает компетентности в этой теме, то можешь оспорить :)
k4ir05
11.10.2024 07:53Не вижу предмета для спора. При беглом взгляде не заметил ничего, кроме вариантов составления путей. Но это вкусовщина и зависит от конкретного проекта. По-моему, вы пытаетесь заново придумать то, что уже написано в книжках и реализовано в популярных фреймворках.
AlexandrovAndrey Автор
11.10.2024 07:53А здесь нет спора. Суть статьи в том, чтобы показать, что эта "вкусовщина" влияет на удобство поддержки и доработки API, а также оказывает влияние на архитектуру сервиса, что в свою очередь оказывает положительное влияние на масштабируемость, стабильность и поддержку сервиса в целом. Для облегчения понимания приведено множество примеров. Насчёт мелочей. Можно рассмотреть обычный понятный случай из жизни: жонглирование 4-мя мячами (взял побольше, чтобы наверняка). Кто умеет жонглировать таким количеством мячей? Наверное, далеко не каждый. А ведь можно разобрать какой мячик в какой момент времени и где должен находится. И тем не менее, если, после подробного инструктажа, попытается жонглировать новичок, то у него, скорее всего, ничего не получится. А если взять профессионального жонглёра, то у него всё получится. А почему? Да потому что мастер жонглирования "мелочится" - он делает те вещи, которые новичок в принципе не замечает или считает вкусовщиной. Так и в любом деле. Профессионал всегда мелочится, но умело... Ещё раз повторю, эта статья не для новичков. Здесь очень много мелочей :)
AlexandrovAndrey Автор
11.10.2024 07:53А вообще, одно дело знать правильную формулировку. Другое дело её понимать ) Если у тебя есть пример из твоей практики, который просто перечеркнёт смысл этой статьи, то буду признателен, если поделишься знанием + аргументированно докажешь, что твои подходы в проектировании лучше - объективно покажешь, какие проблемы твой подход решает, а какие добавляет, т.к. идеального решения не бывает - всегда какие-то вопросы решаются, а какие-то добавляются.
AlexandrovAndrey Автор
11.10.2024 07:53Моё изобретение ) Но сути не меняет - интерфейс приложения/сервиса
k4ir05
11.10.2024 07:53А зачем искажать уже общепринятые понятия? И как теперь быть уверенным, что вы ещё чего-то не нафантазировали в статье?
AlexandrovAndrey Автор
11.10.2024 07:53Если речь про API = APplication Interface, то признаю, моя ошибка. Она была связана со стереотипом, который давно сформировался. В этом моменте был уверен, поэтому не перепроверил + статья не о том, как точно расшифровать термин API. Всем понятно, что это интерфейс приложения. Конечно же, API = Application Programming Interface. Спасибо за замечание, в следующий раз постараюсь быть повнимательнее. Более того, я промахнулся, указав лёгкий уровень статьи, т.к. материал не для новичков. Его можно понять, имея определённый опыт в проектировании REST API. Насчёт фантазий...вся статья состоит из фантазий, это совершенно верное утверждение. Я бы даже уточнил: не фантазии, а личное восприятие проектирования автором статьи. Но нужно иметь в виду, что это личное восприятие опирается на личной опыт проектирования. В статье я делюсь личным опытом разработки REST API. Цель статьи - не показать читателям какой я классный, это я знаю и без них :) А обсудить с опытными разработчиками подходы к проектированию REST API, чтобы увидеть недочёты в своём личном восприятии и повысить качество разрабатываемых сервисов.
AlexandrovAndrey Автор
11.10.2024 07:53Я придумал. Свято верил, что это просто application interface. Но суть всё равно одна - интерфейс приложения
savostin
11.10.2024 07:53REST он как e-mail - простой, надежный, но устаревший и не отвечает современным требованиям. Но все им пользуются. Но нет четкого стандарта. И каждый добавляет в него что-то новое, согласно своей задаче, и он уже стал чем-то расплывчатым и непонятным.
AlexandrovAndrey Автор
11.10.2024 07:53В статье хотел показать, что расплывчивость и непонятность REST связана с непониманием сути REST. Это как ООП и процедурный стиль - наличие классов не гарантирует, что код написан в стиле ООП. Так и здесь, использование GET, POST, PUT и т.д. не гарантирует наличие REST API, можно получить RPC или что-то более оригинальное - в таких случаях говорят про REST-подобное API. Посыл статьи такой: прежде, чем делать какое-нибудь отступление от REST API, задумайся - может быть допустил ошибку в архитектуре сервиса? Может быть каких-то сущностей не хватает?
michael_v89
11.10.2024 07:53Наблюдая за похожими эволюциями систем, я заметил, что достаточно было прикрутить всего одну ручку
Ага, а как будет для нее выглядеть код на сервере? Будете проверять для каждого поля изменилось ли значение и делать роутинг вручную по этой информации? А другой программист потом должен в этом разбираться. Где же тут "проще поддерживать"?
Если вы делаете сущность с 4 CRUD-действиями, значит логика взаимодействия с ней переносится на клиента. Дальше оказывается, что пользователь может сам перевести заказ в оплаченные, или что если передать id в массиве contacts, то пользователь может прикрепить себе чужой email, и появляются всякие проверки для защиты. Дальше оказывается, что в бизнес-логике действий с сущностью больше чем 4, и начинаются попытки замапить 4 HTTP-действия на все бизнес-действия, в результате на сервере получается такой же RPC, только более запутанный. Если у вас 8 разных бизнес-действий с сущностью, то вам в любом случае надо все их реализовать, иначе программа будет работать не так, как нужно бизнесу. Вопрос только в том, как вы сделаете программный интерфейс для них.
Поэтому правильный подход это делать RPC с действиями, соответствующими бизнес-логике. Для каждого будут свои входные данные со своими правилами валидации. Для
PATCH /users/1/activate
вообще не будет входного DTO, дляPATCH /users/1/rename
будет{"name": "NewName"}
, дляPATCH /users/1/new_email
будет{"email": "new_email@example.com"}
с валидацией что поле required и в формате email, и процедурой подтверждения нового email. Но только если бизнес действительно хочет сделать отдельные действия с одним полем ввода.Тогда и не возникает вопроса "Как сделать эндпойнт для отправки email", просто берем и делаем.
AlexandrovAndrey Автор
11.10.2024 07:53Пока читал, аж прослезился...невольно вспомнилась старая песенка: "Какая боль! Какая боль! Аргентина-Ямайка 5:0" ))... Много слов. Честно говоря, я не понял суть проблемы )) Попробуем разобраться. В статье я обратил внимание, что вместо 10 ручек для crud-операций над сущностью, достаточно 4-х ручек. Обычно разработчики стараются следовать принципам чистой архитектуры, т.е. создавать слоённую архитектуру: middleware, контроллеры, бизнес-логика (кто-то называет сервисами, кто-то называет usecase - не суть), слой репозиториев. Допустим, у нас 10 оригинальных ручек для одной сущности. В силу непреодолимых обстоятельств (пожеланий бизнеса) требуется сущности добавить поле. Какие действия на бэке? - прикрутить новую оригинальную ручка, т.к. нужно чтить стиль "отцов-основателей". А это значит, для новой оригинальной ручки нужно написать логику в контроллере, в бизнес-логике, в репозитории, добавить её в роутинг, задуматься над правами. То есть при таком подходе при малейшем изменении сущности нужно проделывать хороший объём работы, вместо того, чтобы просто добавить новое свойство в сущность. Если нужна валидация, то её придётся писать, как при 10 ручках, так и при 4-х. Не думаю, что какой-нибудь разработчик возрадуется 10-ти ручкам вместо 4-х, так как при 10-ти ручках объём поддерживаемого кода больше, и, что самое печальное, при добавлении новых свойств сущности, он будет только увеличиваться...Если речь идёт "PUT или PATCH", так здесь нет однозначного ответа - зависит от контекста + выходит за рамки вопросов, обсуждаемых в статье.
michael_v89
11.10.2024 07:53В статье я обратил внимание, что вместо 10 ручек для crud-операций над сущностью, достаточно 4-х ручек.
А я обратил внимание, что это не так. Если вам надо 10 ручек, значит вам надо 10 ручек.
Если они находятся не в API, значит находятся где-то еще. Например на клиенте.
Если они не описаны в API явно, значит описаны неявно.В силу непреодолимых обстоятельств (пожеланий бизнеса) требуется сущности добавить поле.
Какие действия на бэке? - прикрутить новую оригинальную ручкаНет. Ни один принцип программирования или проектирования не предлагает делать отдельный эндпойнт для каждого поля. Это ерунда какая-то.
Действия на бэке - добавить поле в сущность и в DTO тех эндпойнтов, которые выполняют бизнес-действия, связанные с этим полем.
На всякий случай - запрос с одним полем
PATCH /user/1 {"new_field": "value"}
не противоречит принципам REST.То есть при таком подходе при малейшем изменении сущности нужно проделывать хороший объём работы
Поэтому нет, описанный вами объем работы проделывать не нужно. Никто так не делает.
Если нужна валидация, то её придётся писать, как при 10 ручках, так и при 4-х.
Ну так дело в том, что для разных действий ее проще написать.
Для одного действия поле является required, а для другого нет. Какое правило валидации делать для этого поля?Вот у нас есть сущность Article. У нее 3 состояния - "В черновике", "На модерации", и "Опубликована".
Пока статья в черновике, текст и заголовок могут быть пустыми.
Для отправки на модерацию они должны быть заполнены.
Пока статья на модерации, автору менять текст и заголовок нельзя.
При этом модератор текст и заголовок менять может.
Опубликованную статью на модерацию отправлять нельзя.
При редактировании она должна автоматически скрываться в черновики, после этого отправка на модерацию становится доступной.Покажите, как вы это сделаете с одним эндпойнтом PATCH?
С RPC это делается тривиально, и никаких сложностей не возникает. При этом для модератора вообще может быть своё API, недоступное снаружи.AlexandrovAndrey Автор
11.10.2024 07:53А я обратил внимание, что это не так. Если вам надо 10 ручек, значит вам надо 10 ручек.Если они находятся не в API, значит находятся где-то еще. Например на клиенте.Если они не описаны в API явно, значит описаны неявно.
Если API требуется 10 ручке, то, конечно, нужно прикрутить 10 ручек. Но если речь про конкретную сущность, то это избыточность - для конкретной сущности необходимо и достаточно 4 ручек.
Нет. Ни один принцип программирования или проектирования не предлагает делать отдельный эндпойнт для каждого поля. Это ерунда какая-то.
Действия на бэке - добавить поле в сущность и в DTO тех эндпойнтов, которые выполняют бизнес-действия, связанные с этим полем.
На всякий случай - запрос с одним полем
PATCH /user/1 {"new_field": "value"}
не противоречит принципам REST.Так я про это и пишу, что это ерунда какая-то. Для изменения состояния конкретной сущности требуется всего одна ручка - PUT или PATCH (что именно, зависит от контекста).
Поэтому нет, описанный вами объем работы проделывать не нужно. Никто так не делает.
Если учесть, что много где всю логику пишут прямо в контроллере, то, возможно, не придётся. Обычно такой код называют legacy. Его дорабатывают до определённого момента, потом либо разработчик с фразой "мне пора покорять новые вершины" увольняется, либо сервис переписывается с нуля. Паттерн, когда всё пишется в контроллере имеет право на существование, есть реально ситуации, когда он уместен. Но беда в том, что он не панацея, а им злоупотребляют.
Вот у нас есть сущность Article. У нее 3 состояния - "В черновике", "На модерации", и "Опубликована".Пока статья в черновике, текст и заголовок могут быть пустыми.Для отправки на модерацию они должны быть заполнены.Пока статья на модерации, автору менять текст и заголовок нельзя.При этом модератор текст и заголовок менять может.Опубликованную статью на модерацию отправлять нельзя.При редактировании она должна автоматически скрываться в черновики, после этого отправка на модерацию становится доступной.
Здесь можно обойтись одной ручкой PATCH или PUT (я бы выбрал PUT). Как я понял, разные поля могут принимать определённые значения (или в принципе иметь значение) в зависимости от значения поля status сущности. Можно написать валидатор, который будет следить за соблюдением этих правил. Более того, если используется postgres, то валидацию можно переложить на сторону БД - написать restriction. Но если валидация достаточно сложная, то, конечно, её лучше делать на стороне сервиса. С gRPC будет что-то похожее, а вот 100 500 ручек в стиле RPC я бы не прикручивал.
Беда темы проектирования REST API заключается в том, что просто почитать статью про REST недостаточно. Здесь требуются знания архитектуры сервисов, базы данных, с которой работаешь и т.д. (это то, что на поверхности). Задача для senior.
michael_v89
11.10.2024 07:53для конкретной сущности необходимо и достаточно 4 ручек.
Именно об этом я и говорю. Нет, недостаточно. Это можно сделать, но тогда код на клиенте и на сервере будет слишком запутанный, его будет сложнее поддерживать.
Так я про это и пишу, что это ерунда какая-то.
Вы это пишете, как будто конкретно я или подход RPC в целом рекомендует так делать. И пытаетесь этим доказать, что подход RPC неправильный. А на самом деле это вовсе не подход RPC.
а вот 100 500 ручек в стиле RPC я бы не прикручивал
"100 500 ручек" это не стиль RPC. В RPC число ручек зависит от количества бизнес-действий, а не от количества полей. И у вас они тоже будут, только вы будете их вызывать кодом вида
if ($prevStatus === DRAFT && $newStatus === ON_MODERATION) $this->sendOnModeration($article)
;Если учесть, что много где всю логику пишут прямо в контроллере, то, возможно, не придётся.
Паттерн, когда всё пишется в контроллере имеет право на существованиеПри чем тут логика в контроллере? Я про нее не говорил и не подразумевал.
Можно написать валидатор, который будет следить за соблюдением этих правил.
Можно. Это и есть пример усложнения кода, о котором я говорю.
Другому программисту придется распутывать, какие правила к какому бизнес-действию относятся, потому что они все у вас будут в одном эндпойнте обновления.если используется postgres, то валидацию можно переложить на сторону БД
Можно. Это тоже пример усложнения. С RPC это просто не нужно.
- Покажите, как вы это сделаете с одним эндпойнтом PATCH?
- Здесь можно обойтись одной ручкойНу так покажите, как она будет выглядеть, я же именно об этом и попросил. Почему вы вместо кода для демонстрации подхода предпочли написать большой коммент с рассказами, что это несложно?
С RPC у меня это заняло 130 строк и 20 минут, без всяких триггеров в БД.Код
class Article { int $id; int $userId; string $title; string $text; ArticleStatus $status; } enum ArticleStatus { case DRAFT = 1; case ON_MODERATION = 2; case PUBLISHED = 3; } class SaveArticleDTO { string $title; string $text; } class ArticleUserController { function save(string $id, SaveArticleDTO $dto) { $article = $this->findEntity($id); $validationErrors = $this->articleService->validateForSave($article); if (!empty($validationErrors)) { return $this->errorResponse($validationErrors); } $this->articleService->save($article, $dto); return $this->successResponse($article); } function sendOnModeration(string $id) { $article = $this->findEntity($id); $validationErrors = $this->articleService->validateForSendOnModeration($article); if (!empty($validationErrors)) { return $this->errorResponse($validationErrors); } $this->articleService->sendOnModeration($article); return $this->successResponse($article); } private function findEntity(string $id): Article { $article = $this->articleRepository->findOne($id); // автор может редактировать только свои статьи if ($article === null || $article->userId !== $this->getCurrentUser()->id) { throw new HttpNotFoundException('Article not found'); } return $article; } } class ArticleSerivce { function validateForSave(Article $article) { $errors = []; if ($article->status === ArticleStatus::ON_MODERATION) { $errors['status'] = 'Article is on moderation'; } return $errors; } function save(Article $article, SaveArticleDTO $dto) { $article->status = ArticleStatus::DRAFT; $article->title = $dto->title; $article->text = $dto->text; $this->entityManager->save($article); $this->entityManager->flush(); } function validateForModeration(Article $article) { $errors = []; if ($article->status !== ArticleStatus::IN_DRAFT) { $errors['status'] = 'Article must be in draft'; } if (empty($article->title)) { $errors['title'] = 'Title must not be empty'; } if (empty($article->text)) { $errors['text'] = 'Text must not be empty'; } return $errors; } function sendOnModeration(Article $article) { $article->status = ArticleStatus::ON_MODERATION; $this->entityManager->save($article); $this->entityManager->flush(); } function publish(Article $article) { $article->status = ArticleStatus::PUBLISHED; $this->entityManager->save($article); $this->entityManager->flush(); } } class ArticleModeratorController { function save(string $id, SaveArticleDTO $dto) { $article = $this->findEntity($id); $this->articleService->save($article, $dto); return $this->successResponse($article); } function publish(string $id) { $article = $this->findEntity($id); $this->articleService->publish($article); return $this->successResponse($article); } private function findEntity(string $id): Article { $article = $this->articleRepository->findOne($id); // модератор может редактировать любые статьи if ($article === null) { throw new HttpNotFoundException('Article not found'); } return $article; } }
AlexandrovAndrey Автор
11.10.2024 07:53Именно об этом я и говорю. Нет, недостаточно. Это можно сделать, но тогда код на клиенте и на сервере будет слишком запутанный, его будет сложнее поддерживать.
Совершенно верно! Логика станет слишком запутанной. Но эта ситуация будет, если систему проектировать монолитом. Т.е. есть некоторый бэк, у которого есть ручки. Каждая ручка изменяет состояние сущности и запускает какую-то логику. Но в своей статье я подсветил этот момент, что если создавать REST API, то придётся и архитектуру переосмыслить. В своей статье я пытался донести, что REST API - это просто получение и изменение состояний конкретных сущностей: получение списка пользователей, получение конкретного пользователя, изменение конкретного пользователя, удаление конкретного пользователя. Более того, как раз-таки специально рассмотрел, казалось бы, тупиковый случай - отправка письма. Ведь это же чистое действие. О какой сущности здесь может быть речь? Тем не менее, и такие случае можно обыграть через REST API - создаётся сущность task (задача отправить письмо). Также я подсвечивал, что непосредственной отправкой письма API не занимается. Письмо отправляет фоновый процесс. Сервис получается состоящим из двух независимых модулей: API, джоб для отправки письма. Более того, каждый модуль должен запускаться в отдельном контейнере. Далее в статье привёл какие плюсы даёт эта "сложная архитектура".
Я наблюдаю либо неготовность понять материал, либо невнимательное чтение. Если невнимательно читаете, то зачем задавать вопросы? - возможно, ответ уже есть в материале статьи. Если испытываете трудности с пониманием такой архитектуры, то я рекомендую сначала познакомиться с DDD, SOLID и т.д.
Ну так покажите, как она будет выглядеть, я же именно об этом и попросил. Почему вы вместо кода для демонстрации подхода предпочли написать большой коммент с рассказами, что это несложно?
А что здесь описывать? Есть ручка PUT, при помощи которой можно менять состояние article, т.е. менять поля status, title. В запросе отправляется состояние article с изменённым значением title. Либо на стороне сервиса, либо на стороне БД (postgres) запускается логика проверки возможности изменить title в зависимости от status. Если что-то можно изменить в статье, то статус автоматом возвращается в "draft". По-моему всё просто :)
Вы это пишете, как будто конкретно я или подход RPC в целом рекомендует так делать. И пытаетесь этим доказать, что подход RPC неправильный. А на самом деле это вовсе не подход RPC.
Эта статья не про RPC. Она о том, что, если не получается прикрутить ручку в стиле REST, то это не проблема REST, это проблема архитектуры конкретного сервиса: монолит, не хватает какой-то сущности и т.д. Ни в коем случае я не отрицаю RPC, я лишь обращаю внимание, что REST реален. Он есть!...в умелых руках ))
michael_v89
11.10.2024 07:53Тем не менее, и такие случае можно обыграть через REST API - создаётся сущность task
Никто не говорит, что нельзя. Разговор о том, что это усложняет усложняет поддержку, а не упрощает, как вы говорите.
В бизнес-требованиях нет такой сущности, а в коде есть. Поэтому другим программистам приходится разбираться, зачем она нужна.Я наблюдаю либо неготовность понять материал, либо невнимательное чтение.
Ну раз вы делаете вид что не поняли, напишу прямо. Я говорю о том, что REST это неправильный подход, и использовать его вообще не надо.
И что ваше утверждение о том, что он упрощает поддержку, полностью неверно.А что здесь описывать?
Код. Не описывать, а написать. Вы же сказали, что его будет проще поддерживать, а подтвердить свои слова примером отказываетесь.
В запросе отправляется состояние article с изменённым значением title.
запускается логика проверки возможности изменить title в зависимости от status.
Если что-то можно изменить в статье, то статус автоматом возвращается в "draft".
По-моему всё простоЭто всё верно и для RPC. Получается, и с RPC тоже всё просто?
А если так, зачем тогда нужен REST со всеми этими дополнительными сущностями и триггерами в базе?Эта статья не про RPC.
В статье вы обсуждаете вопрос "как обыграть ситуацию, когда нужна ручка просто для отправки уведомления на почту?". Это в чистом виде RPC, "отправить уведомление" это название процедуры.
Также вы обсуждаете ситуацию "бизнес просит добавить ручку активации пользователя". URL вида/users/1/activate
это и есть RPC.Вы сказали, что надо все такие действия с пользователем совместить в одно, и что это будет проще в поддержке. Поэтому ваша статья именно о том, что REST лучше RPC.
А я объяснил, что это не так. Что вы игнорируете, что для всех этих функций программа должна делать разные действия. Для изменения имени надо только поменять данные в базе, для активации надо также отправить письмо "Ваш аккаунт активирован", для подключения нового email отправить письмо "Подтвердите email" с токеном восстановления, и сохранить этот токен в базу. А вы не рассматриваете сложность кода после такого совмещения.
AlexandrovAndrey Автор
11.10.2024 07:53Никто не говорит, что нельзя. Разговор о том, что это усложняет усложняет поддержку, а не упрощает, как вы говорите.В бизнес-требованиях нет такой сущности, а в коде есть. Поэтому другим программистам приходится разбираться, зачем она нужна.
Похоже мы общаемся на разных языках )) Кажется, проблема в умении писать хороший код. В незнании, что классу не обязательно самому выполнять всю логику, часть логики он может делегировать другому классу - грамотная композиция позволяет избежать написания "каши".
Ну раз вы делаете вид что не поняли, напишу прямо. Я говорю о том, что REST это неправильный подход, и использовать его вообще не надо.И что ваше утверждение о том, что он упрощает поддержку, полностью неверно.
Когда человек разбирается с какой-то темой, он ничего не знает. В такой ситуации он принимает на веру первое попавшееся ему решение вопроса. Обычно самое простое. Другие решения они, на этом этапе своего развития, начинают категорически отрицать. Например, когда человек делает первые шаги в программировании, он пишет код в процедурном стиле. ООП ему кажется чем-то сложным и бессмысленным. Когда человек знакомится с межсервисным взаимодействием, то оно сводится к RPC, как к достаточно простому, т.к. не требует глубокого понимания архитектурных подходов при проектировании сервисов. Потом, когда человек получит достаточно знаний, он начинает понимать, что нет "хороших" и "плохих" решений. Просто каждое решение актуально для конкретного случая. В своей статье я отметил, что REST API требует серьёзной проработки архитектуры сервиса...Так что REST API - хорошая вещь, просто нужно "уметь готовить".
Код. Не описывать, а написать. Вы же сказали, что его будет проще поддерживать, а подтвердить свои слова примером отказываетесь.
Я описал максимально подробно. Не должно быть трудностей с реализацией.
Это всё верно и для RPC. Получается, и с RPC тоже всё просто?А если так, зачем тогда нужен REST со всеми этими дополнительными сущностями и триггерами в базе?
В RPC тоже можно сделать одну ручку: users/update. Но зачем RPC, когда есть gRPC? ) Кстати, про триггеры я не писал, restrictions в pg - это не триггеры. Зачем REST? - один из архитектурных подходов )
В статье вы обсуждаете вопрос "как обыграть ситуацию, когда нужна ручка просто для отправки уведомления на почту?". Это в чистом виде RPC, "отправить уведомление" это название процедуры.
Также вы обсуждаете ситуацию "бизнес просит добавить ручку активации пользователя". URL вида/users/1/activate
это и есть RPC.Вы сказали, что надо все такие действия с пользователем совместить в одно, и что это будет проще в поддержке. Поэтому ваша статья именно о том, что REST лучше RPC.
А я объяснил, что это не так. Что вы игнорируете, что для всех этих функций программа должна делать разные действия. Для изменения имени надо только поменять данные в базе, для активации надо также отправить письмо "Ваш аккаунт активирован", для подключения нового email отправить письмо "Подтвердите email" с токеном восстановления, и сохранить этот токен в базу. А вы не рассматриваете сложность кода после такого совмещения.
В статье я показал, как можно кейс, который обычно обыгрывается в стиле RPC, обыграть в стиле REST
В своей статье я не говорю, что REST круче RPC, а лишь показал, что этот архитектурный подход вполне применим на практике, какие плюсы он даёт. И даже минус подсветил - требуется определённая квалификация разработчика.
А вот ярые сторонники RPC, могут тоже написать статью про RPC. Где объективно подсветят плюсы и минусы на конкретных примерах.
michael_v89
11.10.2024 07:53В незнании, что классу не обязательно самому выполнять всю логику, часть логики он может делегировать другому классу
Похоже, вы не понимаете, о чем идет речь, и не хотите понять. Либо у вас вообще нет опыта в программировании.
При чем тут другие классы? Я говорю именно про тот код, который будет выполнять делегирование. Напишите конкретный код для примера, который я привел, я вам покажу пальцем, где у вас будет "каша".В своей статье я отметил, что REST API требует серьёзной проработки архитектуры сервиса
Это еще один пример, почему поддержка будет сложнее, а не проще.
Но зачем RPC, когда есть gRPC? )
В этом вопросе отсутствует смысл. Выглядит так, что у вас нет опыта в программировании, и вы не понимаете, о чем говорите.
а лишь показал, что этот архитектурный подход вполне применим на практике, какие плюсы он даёт
Ок, объясню еще раз. Ваше заявление о плюсах - это ложь. Потому что вы игнорируете некоторые важные последствия такого решения, которые не являются плюсами.
Я описал максимально подробно. Не должно быть трудностей с реализацией.
Почему вы уходите от ответа и подменяете понятия? Я разве где-то сказал, что будут трудности с реализацией? Я сказал, что код у такой реализации будет сложнее, поэтому его будет сложнее поддерживать.
Ладно, напишу за вас, раз вы сами неспособны.
class ArticleDto { public string $text; public string $title; public ArticleStatus $status; } enum ArticleStatus { case DRAFT = 1; case ON_MODERATION = 2; case PUBLISHED = 3; } class ArticleController { function update(string $id, ArticleDto $newArticleState) { $article = $this->findEntity($id); // усложнение 1 try { $this->articleService->update($article, $newArticleState, $this->getCurrentUser()); return $this->successResponse($article); } catch (ValidationException $exception) { return $this->errorResponse($exception->getErrors()); } } private function findEntity(string $id): Article { $article = $this->articleRepository->findOne($id); $currentUser = $this->getCurrentUser(); // усложнение 2 $canEdit = $currentUser->id === $article->user_id || $currentUser->getRole() === Role::MODERATOR; if ($article === null || !$canEdit) { throw new HttpNotFoundException('Article not found'); } return $article; } } class ArticleService { function update(Article $article, ArticleDto $newArticleState, User $currentUser) { // усложнение 3 if ($article->status === ArticleStatus::DRAFT) { if ($newArticleState->status === ArticleStatus::DRAFT) { $this->save($article, $newArticleState); } else if ($newArticleState->status === ArticleStatus::ON_MODERATION) { $this->save($article, $newArticleState); $errors = $this->validateForSendOnModeration($article); if (!empty($errors)) throw new ValidationException($errors); $this->sendOnModeration($article); } else if ($newArticleState->status === ArticleStatus::PUBLISHED) { throw new ValidationException(['status' => 'This change is not allowed']); } } else if ($article->status === ArticleStatus::ON_MODERATION) { if ($currentUser->getRole() !== Role::MODERATOR) { throw new ValidationException(['status' => 'This change is not allowed']); } $this->save($article, $newArticleState); if ($newArticleState->status === ArticleStatus::PUBLISHED) { $this->publish($article); } } else if ($article->status === ArticleStatus::PUBLISHED) { if ($newArticleState->status !== ArticleStatus::DRAFT) { throw new ValidationException(['status' => 'This change is not allowed']); } $this->save($article, $newArticleState); } } // делегирование function save(Article $article, ArticleDto $newArticleState) { ... } function validateForSendOnModeration(Article $article) { ... } function sendOnModeration(Article $article) { ... } function publish(Article $article) { ... } }
Вот "усложнение 3" это и есть ваша "каша". И вы никак ее не уберете, разве что можете немного трансформировать. Вопрос на засыпку - сколько времени у вас займет, чтобы определить, работает ли этот код в соответствии с указанными выше требованиями, или в нем есть ошибка?)
gochaorg
11.10.2024 07:53Все ничего только ссылок не хватает, а в частности
1) https://habr.com/ru/articles/739808/ [HTTP API & REST] Терминология. Мифология REST. Составляющие HTTP-запроса
2) Стандартизации REST как таковой нет, ну вот с разбегу RFCnnnn которое прямо описывало понятие таковое, не двусмысленно и четко - я не нашел
А соответственно, REST каждый волен понимать по своему, хотя есть разные трактовки на вкус и цвет
AlexandrovAndrey Автор
11.10.2024 07:53Это моя первая статья - "первая кровь". В следующих постараюсь это учесть.
icya
А где, собственно, про REST? Описывать рекомендации к оформлению эндпоинтов и работе с HTTP это хорошо, но REST не про протоколы.
1nd1go
а про что?
AlexandrovAndrey Автор
Большая часть статьи описывает проектирование ручек REST API. Можно ли назвать ручку API протоколом? ))) Более детально про REST (всевозможные глаголы, коды ошибок и т.д.) почитаешь в документации. Цель статьи - показать, что вырождение REST в RPC связано с непониманием сути REST, а не с какими-то особенностями или ограничениями архитектурного подхода.