Скрытый текст
Однажды, обсуждая с коллегой код 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, что значительно облегчает доработку и поддержку. А также в принципе может привести к пересмотру архитектуры, что в свою очередь обеспечит высокую модульность и, как следствие, положительно скажется на стабильности и масштабируемости сервиса в целом.
Автор: AlexandrovAndrey