Несколько лет назад я разрабатывал для одного большого телекома новую информационную систему. Нам приходилось взаимодействовать со всё нарастающим количеством веб-сервисов, открываемых более старыми системами или бизнес-партнёрами. Как вы понимаете, мы получили добрую порцию SOAP-ада. Заумные WSDL, несовместимые библиотеки, странные баги… Где только возможно мы старались продвинуть — и использовать — простые RPC-протоколы: XMLRPC или JSONRPC.
Наши первые серверы и клиенты, работавшие по этим протоколам, были очень простыми, со скромными возможностями и ненадёжными. Но мы постепенно их улучшали и через несколько сотен строк кода достигли желаемого:
- поддержки разных диалектов (вроде созданных для Apache XMLRPC-расширений),
- встроенной конверсии между исключениями Python и иерархическими кодами ошибок,
- раздельной обработки функциональных и технических ошибок,
- автоматических повторных попыток в случае технических ошибок,
- релевантного журналирования и сбора статистики до и после запросов,
- доскональной проверки входных данных…
Теперь можно было надёжно подключаться к любому подобному API с помощью лишь нескольких строк кода. И также мы теперь могли с помощью нескольких декораторов и обновлений документов открывать любой набор функций для широкой аудитории, для серверов и браузеров.
А когда дошло до взаимодействия между разными приложениями (построенными на основе микросервисов), этим уже занимался наш сисадмин. С программной частью уже практически не было никаких неясностей.
Разработчик отдыхает после трудной получасовой интеграции RPC API
А затем появился REST.
REpresentational State Transfer — передача состояния представления.
Эта новая волна сотрясла основы межсервисного взаимодействия.
RPC умер, будущее было за RESTful: ресурсы живут на своих собственных URL, а манипулировать ими можно только по протоколу HTTP.
С тех пор каждый API, который нам приходилось выставлять или к которому мы обращались, превращался в новую трудность, если не сказать — в безумие.
А что за проблема с REST?
Чтобы не описывать на пальцах, проиллюстрирую на примере. Вот маленький API, типы данных убраны для удобства чтения.
createAccount(username, contact_email, password) -> account_id
addSubscription(account_id, subscription_type) -> subscription_id
sendActivationReminderEmail(account_id) -> null
cancelSubscription(subscription_id, reason, immediate=True) -> null
getAccountDetails(account_id) -> {full data tree}
Просто добавьте правильно задокументированную иерархию исключений (InvalidParameterError
, MissingParameterError
, WorkflowError
…) с подклассами для определения важных ситуаций (к примеру, AlreadyExistingUsernameError
), и порядок.
В этом API легко разобраться, его легко использовать, он надёжен. Он поддерживается точной машиной состояний (state machine), но ограниченный набор доступных операций удерживает пользователей от необдуманных действий (вроде изменения даты создания аккаунта).
Ожидаемая длительность выставления этого API в качестве простого RPC-сервиса: несколько часов.
Так, а теперь в дело вступает RESTful.
Никаких стандартов, никаких точных спецификаций. Лишь невнятная «философия REST», бесконечные дебаты и множество дурацких костылей.
Как вы сопоставите точные функции из приведённого примера с CRUD-операциями? Является ли отправка напоминающего письма обновлением атрибута must_send_activation_reminder_email? Или созданием activation_reminder_email resource? Разумно ли использовать DELETE для cancelSubscription()
, если подписка остаётся живой в течение grace-периода и в любой момент может быть активирована? Как вы распределяете по конечным точкам дерево данных getAccountDetails()
, чтобы соответствовать модели данных REST?
Какую URL-endpoint вы присваиваете каждому «ресурсу»? Да, это просто, но приходится делать в любом случае.
Как выражаете разнообразие ошибочных состояний с помощью очень ограниченного набора HTTP-кодов?
Какие форматы сериализации, какие специфические диалекты вы используете для ввода и вывода полезных данных?
Как именно вы распределяете эти простые сигнатуры по HTTP-методам, URL, строкам запросов, полезным данным, заголовкам и кодам статуса?
И вы часами переизобретаете колесо. Причём не заточенное под вас, умное колесо, а сломанное и хрупкое. Оно нарушает спецификации, даже не замечая этого, а чтобы разобраться в колесе, нужно прочитать тома документации.
Как так вышло, что REST требует столько РАБОТЫ?
Это и парадокс, и бесстыжий каламбур (игра слов: rest может означать «отдых»).
Давайте рассмотрим искусственные проблемы, возникшие из этой философии проектирования.
Удовольствие от глагольных REST-команд
REST вам не CRUD, его сторонники будут уверять, что вы не спутаете эти два понятия. А через несколько минут они начнут радоваться, что у глагольных HTTP-команд прекрасно определённая семантика для создания (POST), получения (GET), обновления (PUT/PATCH) и удаления (DELETE) ресурсов.
Сторонники открыто восхищаются, что нескольких HTTP-команд достаточно для выражения любой операции. Конечно, достаточно. Точно так же, как горстки этих команд достаточно для выражения на английском языке любой концепции: «Сегодня обновил своим телом моё АвтомобильноеВодительскоеСиденье и создал ЗажиганиеДвигателя, но ТопливныйБак себя удалил». От того, что вы можете так сделать, результат не становится менее уродливым. Если только вы не обожаете язык токипона.
Если важен минимализм, то хотя бы делайте всё правильно. Вы знаете, почему PUT, PATCH и DELETE никогда не были реализованы в браузерных формах? Потому что они бесполезны и вредны. Нельзя просто использовать GET для чтения и POST для записи. Или выполнять POST эксклюзивно, когда нежелательно кеширование на HTTP-уровне. Остальные глагольные команды в лучшем случае будут вам мешать, а в худшем — просто испортят вам целый день.
Хотите с помощью PUT обновлять свои ресурсы? Ладно, но Пресвятые Спецификации утверждают, что ввод данных должен представлять собой «полный ресурс» (complete resource), т. е. нужно следовать той же схеме, что и у вывода данных с помощью GET. И что вы будете делать с многочисленными параметрами только для чтения, возвращаемыми командой GET (время создания, время последнего обновления, сгенерированный сервером токен…)? Вы пренебрегаете ими и нарушаете принципы PUT? Вы в любом случае их вставляете и ожидаете «конфликта HTTP 409», если они не совпадают с серверными значениями (потом заставляя вас отправлять GET…)? Вы даёте им случайные значения и ожидаете, что сервер будет их игнорировать (удовольствие от тихих ошибок)? Выберите, что вам ближе, REST не даёт ясного понимания, что такое атрибут только для чтения, в ближайшем будущем эта ситуация не изменится. При этом опасность GET в том, что он должен возвращать пароль (или номер банковской карты), который затем отправляется в предыдущий POST/PUT — удачи вам и с этими параметрами только для записи.
Хотите для обновления своего ресурса использовать PATCH? Прекрасно, но, как и 99 % людей, использующих глагольные команды, будете просто отправлять в полезных данных своего запроса подмножество полей ресурса в надежде, что сервер правильно поймёт, что вы хотели сделать (со всеми возможными побочными эффектами). Многие параметры ресурсов глубоко взаимосвязаны или обоюдно эксклюзивны (например, в данных о пользовательском счёте указывается номер банковской карты ИЛИ токен PayPal), но RESTful-архитектура скрывает и эту важную информацию. Но вы в любом случае опять нарушите спецификации: PATCH не должен отправлять кучу полей для переопределения. Он должен отправлять «набор инструкций», которые будут применяться к ресурсам. Так что берите блокнот и кружку с кофе, придётся определиться с выражением этих инструкций и их семантикой. Не-изобретённый-здесь синдром — это стандарт де-факто в мире REST.
Хотите удалять ресурсы с помощью DELETE? Ладно, но надеюсь, вам не понадобится предоставлять важную контекстную информацию типа PDF-скана пользовательского запроса о расторжении. DELETE не должен содержать полезную информацию. Этим ограничением REST-архитекторы часто пренебрегают, потому что большинство веб-серверов не заставляют соблюдать это правило в запросах к себе. Но насколько совместимым будет DELETE-запрос с прикреплённой двухмегабайтной base64-строкой запроса?
Приверженцы REST с лёгкостью заявляют, что «люди всё делают неправильно» и что их API «на самом деле не RESTful». К примеру, многие разработчики используют PUT для создания ресурсов прямо в финальном URL (/myresourcebase/myresourceid), в то время как «правильный способ» — применить POST к родительскому URL (/myresourcebase) и позволить серверу с помощью HTTP-заголовка «расположения» показать URL нового ресурса (это не HTTP-переадресация). Хорошая новость: разницы никакой. Все эти сурьёзные принципы — как споры между сторонниками Big Endian и Little Endian, философы часами переубеждают друг друга, но всё это «правильное делание» имеет очень мало отношения к реальным проблемам.
Кстати… делать URL’ы вручную всегда очень весело. Вы знаете, сколько реализаций правильно применяют urlencode()
к идентификаторам при сборке REST URL’ов? Не слишком-то много. Готовьтесь к грубым вторжениям и SSRF/CSRF-атакам.
Когда вы забыли применить urlencode к именам пользователей в одном из 30 своих составленных вручную URL’ов
Удовольствие от REST-обработки ошибок
Почти каждый программист может выполнить «номинальную» работу. Обработка ошибок — одно из свойств, делающих ваш код надёжной программой или огромной кучей хлама.
HTTP из коробки предоставляет список кодов ошибок. Отлично, давайте посмотрим.
Использовать HTTP 404 Not Found для уведомления об отсутствующем ресурсе — звучит чертовски по-RESTful, верно? Паршивое решение: ваш nginx был ошибочно сконфигурирован на один час, так что пользователи вашего API получают только ошибки 404 и вычищают сотни аккаунтов, думая, что они удалены…
Наши пользователи после того, как мы по ошибке удалили гигабайты их картинок с котиками
Кажется, вполне можно выбрать HTTP 401 Unauthorized, если у пользователя нет данных для доступа к стороннему сервису? Однако если Ajax-вызов в браузер Safari получит такой код, то может напугать вашего конечного пользователя очень неожиданным запросом пароля (несколько лет назад так и происходило).
HTTP существовал задолго до появления REST, и в экосистеме веба можно найти множество толкований значений кодов ошибок. Использовать их для передачи ошибок приложения — всё равно что хранить токсичные отходы в молочных бутылках: однажды у вас неизбежно возникнут проблемы.
Некоторые стандартные коды ошибок HTTP характерны для Webdav, другие для Microsoft, а у оставшихся такие расплывчатые описания, что от них никакого толку. В результате, как и большинство пользователей REST, вы, вероятно, для выражения исключений в своём приложении используете случайные HTTP-коды вроде HTTP 418 I’m a teapot или вообще неприсвоенные номера. Или бесстыдно возвращаете HTTP 400 Bad Request на все функциональные ошибки, а затем изобретаете собственный корявый формат ошибок, с булевыми значениями, цифровыми кодами, описательными блоками и преобразованными сообщениями, засунутыми в произвольные полезные данные. Или вы вообще забиваете на корректную обработку ошибок: просто возвращаете сообщение на обычном языке в надежде, что вызывающей стороной будет человек, который сможет проанализировать проблему и принять меры. Удачи вам в общении с такими API из автономных программ.
Удовольствие от концепций REST
REST сделал карьеру на хвастовстве либо концепциями, которые и так уважает любой находящийся в здравом уме архитектор, либо принципами, которым сам REST даже не следует. Вот несколько выдержек с сайтов из первых позиций поисковой выдачи:
REST — это клиент-серверная архитектура. У клиента и сервера разные задачи.
Какая сенсация в мире разработки ПО.
REST предоставляет единый интерфейс для взаимодействия компонентов.
Ну, собственно, как и любой другой протокол, который считается лингва франка в целой экосистеме сервисов.
REST — это система, состоящая из уровней. Отдельные компоненты ограничены тем уровнем, с которым они в данный момент взаимодействуют.
Звучит как нормальное следствие любой грамотно спроектированной, слабо взаимосвязанной архитектуры. Невероятно.
REST замечательный, потому что он не отслеживает состояния (stateless).
Быть может, за каким-то веб-сервисом скрывается огромная база данных, но сервис не помнит состояния клиентов. Ну, он помнит сессии аутентификации, разрешения доступа… но всё же он не отслеживает состояния. Точнее, он их не отслеживает в той же мере, что и любой протокол на основе HTTP, вроде того же RPC.
С помощью REST вы можете управлять мощью HTTP-кеширования!
Тут можно сделать как минимум одно заключение: GET-запрос и его заголовки управления кешированием хорошо ладят с веб-кешами. Получается, локальных кешей (Memcached и т. д.) достаточно для 99 % веб-сервисов? Неуправляемые кеши — это опасные чудовища. Кто из вас хочет открывать свои API в чисто текстовом формате, чтобы какой-нибудь Varnish или прокси мог доставлять устаревший контент намного позже обновления или удаления ресурса? Возможно даже, чтобы мог доставлять «вечно» в случае ошибки в конфигурации? Система должна быть безопасной по умолчанию. Я искренне признаю, что некоторые высоконагруженные системы нуждаются в HTTP-кешировании, но гораздо дешевле открывать несколько конечных точек GET для тяжёлых операций только для чтения, а не переводить все операции на REST с его сомнительной обработкой ошибок.
Благодаря этому REST обладает высокой производительностью!
Мы в этом уверены? Любой проектировщик API знает: локально нам нужны мелкие API, чтобы можно было делать что захочется; а удалённо нам нужны крупные API, чтобы уменьшить влияние обменов данными по сети между клиентом и сервером. И здесь REST тоже с треском проваливается. Распределение данных по «ресурсам», каждый экземпляр на своей собственной конечной точке, приводит к проблеме N + 1 запросов. Чтобы получить все пользовательские данные (аккаунт, подписки, информация о счёте…) — вам придётся сделать N + 1 HTTP-запросов. И вы не сможете их распараллелить, потому что не знаете заранее уникальные ID связанных ресурсов. Это, а также невозможность извлечь только часть объектов ресурса, превращается в самое настоящее узкое место.
У REST лучше совместимость.
Это как? Почему тогда так много REST веб-сервисов содержат в своих базовых URL’ах “/v2/” или “/v3/”? C помощью высокоуровневых языков не так уж трудно реализовать API, совместимые вперёд и назад, пока вы следуете простым правилам при добавлении/избегании параметров. Насколько я знаю, REST не привносит сюда ничего нового.
REST прост, все знают HTTP!
Ну, все знают и про валуны, только люди предпочитают строить дома из более удобных материалов. Точно так же XML — метаязык, к HTTP — метапротокол. Чтобы получить настоящий протокол уровня приложения (чем являются «диалекты» по отношению к XML), вам придётся очень много всего определить. И в результате вы получите Ещё Один Протокол RPC, как будто мало уже имеющихся.
REST так прост, по нему можно из оболочки делать запросы с помощью CURL!
На самом деле с помощью CURL можно обращаться по любому протоколу на основе HTTP. Даже по SOAP. Нет ничего сложного в отправке GET-команд, но желаю вам удачи в написании вручную JSON- или XML-полезных данных в POST-командах. Обычно люди используют вспомогательные файлы или, что гораздо удобнее, эффективные API-клиенты, инстанцируемые прямо в командной строке интерфейсов разных языков программирования.
Чтобы клиент мог пользоваться сервисом, ему не нужно ничего знать заранее об этом сервисе.
Моя любимая цитата. Я встречал её множество раз, в разных формулировках, зачастую в сочетании с модным выражением HATEOAS; иногда после неё ставят осторожные (но бесполезные) фразы об «исключениях». Я не знаю, в каком мире фантазий живут эти люди, но в нашем мире клиентская программа — это не муравейник. Она не обращается случайным образом к удалённым API, решая потом, как лучше всего их обработать: с помощью шаблонов распознавания или чёрной магии. У клиента есть конкретные ожидания: с помощью PUT передать это поле с этим значением вон тому URL’у, а и серверу лучше уважать семантику, согласованную при интеграции, иначе может начаться ад.
Когда спрашиваешь, как же работает HATEOAS
Как делать REST правильно и быстро?
Забудьте о «правильно». REST — это как религия, ни один смертный не сможет постичь всех высот его гениальности и «сделать правильно».
Правильный вопрос: если вам приходится открывать веб-сервисы в RESTful-стиле или работать с ними, как можно поскорее закончить с этим и перейти к более конструктивным задачам?
Как индустриализировать выставление серверной части?
У каждого веб-фреймворка свой способ определения URL-конечных точек. Так что если нужно воткнуть в ваш любимый сервер имеющийся API в качестве REST-конечной точки, то готовьтесь к большим зависимостям или к доброй порции шаблонного кода, который придётся писать вручную.
Библиотеки вроде Django-Rest-Framework автоматизируют создание REST API, действуя по принципу ориентированных на обработку данных обёрток вокруг SQL/noSQL-схем. Если вам просто нужен «CRUD поверх HTTP», то проблем с ними не будет. Но если вам нужно выставлять настоящие API с рабочими процессами, ограничениями и всем прочим, то придётся долго обрабатывать любой REST-фреймворк напильником, чтобы подогнать под свои нужды.
Приготовьтесь один за другим соединять каждый HTTP-метод с каждой конечной точкой, с соответствующим вызовом метода. Придётся вручную обработать немало исключений, чтобы преобразовать транзитные исключения в соответствующие коды ошибок и полезную информацию.
Как индустриализировать клиентскую интеграцию?
По моему опыту, никак.
Придётся читать длинную документацию для каждой интеграции API и следовать подробным инструкциям выполнения каждой из N возможных операций.
Придётся вручную собирать URL’ы, писать сериализаторы и десериализаторы, а также изучать, как закостылить неясности API. Укрощать чудовище будете методом проб и ошибок.
Вы знаете, как поставщики веб-сервисов компенсируют это и облегчают внедрение?
Они просто пишут собственные официальные реализации клиентов.
ДЛЯ. КАЖДОГО. ОСНОВНОГО. ЯЗЫКА. И. ПЛАТФОРМЫ.
Я недавно работал с системой управления подписками. Авторы предоставляют клиентские части для PHP, Ruby, Python, .NET, iOS, Android, Java… и ещё несколько внешних разработок для Go и NodeJS.
Каждый клиент живёт в собственном Github-репозитории. У каждого собственные длинные списки коммитов, отчётов о багах и pull-реквестах. Каждый со своими примерами использования. У каждого своя вычурная архитектура, что-то среднее между ActiveRecord и RPC-прокси.
Это поразительно. Сколько времени потрачено на эти странные обёртки вместо улучшения нормальных, ценных, эффективных веб-сервисов.
Сизиф разрабатывает Ещё Один Клиент для своего API
Заключение
В течение десятилетий почти все языки программирования использовали один рабочий процесс: входные данные отправляли тому, кого можно вызвать, а в качестве выходных данных получали результат или ошибки. Всё работало. И неплохо.
А с REST всё превратилось в безумие: сопоставляем яблоки с апельсинами, восхваляем HTTP-спецификации, чтобы через минуту их лихо нарушить.
Почему такая простая задача — соединение библиотек по сети — остаётся настолько сложным и трудоёмким делом? И это происходит в эпоху, когда микросервисы становятся обычным делом.
Не сомневаюсь, что какие-нибудь умники приведут в пример ситуации, когда REST проявляет себя во всей красе. Эти люди покажут свои собственные протоколы на основе REST, которые благодаря гиперссылкам позволяют работать с деревьями произвольных объектов и применять к ним CRUD-операции. Эти люди расскажут о замечательных свойствах REST-архитектуры, отметят, что я не прочитал достаточно статей и диссертаций, посвящённых этим концепциям.
Мне плевать. Деревья оценивают по плодам. Решения, на которые с простым RPC у меня уходило несколько часов написания кода и которые работали очень надёжно, теперь требуют недель труда и бесконечного изобретения новых неудачных подходов или нарушения ожиданий. Разработку заменило латание дыр.
Почти прозрачные RPC удовлетворяли 99 % людей, а имеющиеся протоколы, пусть и несовершенные, прекрасно работали. А этот массовый монопсихоз, связанный с наименьшим общим знаменателем веба — HTTP, — в основном привёл к огромным потерям времени и серого вещества.
REST обещал простоту, а принёс сложность.
REST обещал надёжность, а принёс хрупкость.
REST обещал совместимость, а принёс неоднородность.
REST — это новый SOAP.
Эпилог
Будущее может быть светлым. Есть множество превосходных протоколов, бинарных и текстовых, со схемами и без, некоторые используют новые возможности HTTP2… так давайте двигаться дальше. Нельзя вечно оставаться в каменном веке веб-сервисов.
Многие спрашивают меня об этих протоколах, эта тема заслуживает отдельной истории. Но вы можете обратить внимание на XMLRPC и JSONRPC, или на характерные для конкретных языков слои вроде Pyro или RMI для внутреннего использования, или на новичков вроде GraphQL и gRPC для публичных API…
Автор: Макс