Мифология REST

?v=1

Мифология REST


Матчасть

Мало какая технология в истории IT вызывала столько ожесточённых споров, как REST. Самое удивительное при этом состоит в том, что спорящие стороны, как правило, совершенно не представляют себе предмет спора.

Начнём с самого начала. В 2000 году один из авторов спецификаций HTTP и URI Рой Филдинг защитил докторскую диссертацию на тему «Архитектурные стили и дизайн архитектуры сетевого программного обеспечения», пятая глава которой была озаглавлена как «Representational State Transfer (REST)». Диссертация доступна по ссылке.

Как нетрудно убедиться, прочитав эту главу, она представляет собой довольно абстрактный обзор распределённой сетевой архитектуры, вообще не привязанной ни к HTTP, ни к URL. Более того, она вовсе не посвящена правилам дизайна API; в этой главе Филдинг методично перечисляет ограничения, с которыми приходится сталкиваться разработчику распределённого сетевого программного обеспечения. Вот они:


  • клиент и сервер не знают внутреннего устройства друг друга (клиент-серверная архитектура);
  • сессия хранится на клиенте (stateless-дизайн);
  • данные должны размечаться как кэшируемые или некэшируемые;
  • интерфейсы взаимодействия должны быть стандартизированы;
  • сетевые системы являются многослойными, т.е. сервер может быть только прокси к другим серверам;
  • функциональность клиента может быть расширена через поставку кода с сервера.

Всё, на этом определение REST заканчивается. Дальше Филдинг конкретизирует некоторые аспекты имплементации систем в указанных ограничениях, но все они точно так же являются совершенно абстрактными. Буквально: «ключевая информационная абстракция в REST — ресурс; любая информация, которой можно дать наименование, может быть ресурсом».

Ключевой вывод, который следует из определения REST по Филдингу, вообще-то, таков: любое сетевое ПО в мире соответствует принципам REST, за очень-очень редкими исключениями.

В самом деле:


  • очень сложно представить себе систему, в которой не было бы хоть какого-нибудь стандартизованного интерфейса взаимодействия, иначе её просто невозможно будет разрабатывать;
  • раз есть интерфейс взаимодействия, значит, под него всегда можно мимикрировать, а значит, требование независимости имплементации клиента и сервера всегда выполнимо;
  • раз можно сделать альтернативную имплементацию сервера — значит, можно сделать и многослойную архитектуру, поставив дополнительный прокси между клиентом и сервером;
  • поскольку клиент представляет собой вычислительную машину, он всегда хранит хоть какое-то состояние и кэширует хоть какие-то данные;
  • наконец, code-on-demand вообще лукавое требование, поскольку всегда можно объявить данные, полученные по сети, «инструкциями» на некотором формальном языке, а код клиента — их интерпретатором.

Да, конечно, вышеприведённое рассуждение является софизмом, доведением до абсурда. Самое забавное в этом упражнении состоит в том, что мы можем довести его до абсурда и в другую сторону, объявив ограничения REST неисполнимыми. Например, очевидно, что требование code-on-demand противоречит требованию независимости клиента и сервера — клиент должен уметь интерпретировать код с сервера, написанный на вполне конкретном языке. Что касается правила на букву S («stateless»), то систем, в которых сервер вообще не хранит никакого контекста клиента в мире вообще практически нет, поскольку ничего полезного для клиента в такой системе сделать нельзя. (Что, кстати, постулируется в соответствующем разделе прямым текстом: «коммуникация … не может получать никаких преимуществ от того, что на сервере хранится какой-то контекст».)

Наконец, сам Филдинг внёс дополнительную энтропию в вопрос, выпустив в 2008 году разъяснение, что же он имел в виду. В частности, в этой статье утверждается, что:


  • REST API не должно зависеть от протокола;
  • разработка REST API должна фокусироваться на описании медиатипов, представляющих ресурсы; при этом клиент вообще ничего про эти медиатипы знать не должен;
  • в REST API не должно быть фиксированных имён ресурсов и операций над ними, клиент должен извлекать эту информацию из ответов сервера.

Короче говоря, REST по Филдингу подразумевает, что клиент, получив каким-то образом ссылку на точку входа REST API, далее должен быть в состоянии полностью выстроить взаимодействие с API, не обладая вообще никаким априорным знанием о нём, и уж тем более не должен содержать никакого специально написанного кода для работы с этим API.

Оставляя за скобками тот факт, что Филдинг весьма вольно истолковал свою же диссертацию, просто отметим, что ни одна существующая система в мире не удовлетворяет описанию REST по Филдингу-2008.


Здравое зерно REST

Нам неизвестно, почему из всех обзоров абстрактной сетевой архитектуры именно диссертация Филдинга обрела столь широкую популярность; очевидно другое: теория Филдинга, преломившись в умах миллионов программистов (включая самого Филдинга), превратилась в целую инженерную субкультуру. Путём редукции абстракций REST применительно конкретно к протоколу HTTP и стандарту URL родилась химера «RESTful API», конкретного смысла которой никто не знает.

Хотим ли мы тем самым сказать, что REST является бессмысленной концепцией? Отнюдь нет. Мы только хотели показать, что она допускает чересчур широкую интерпретацию, в чём одновременно кроется и её сила, и её слабость.

С одной стороны, благодаря многообразию интерпретаций, разработчики API выстроили какое-то размытое, но всё-таки полезное представление о «правильной» архитектуре API. С другой стороны, если бы Филдинг чётко расписал в 2000 году, что же он конкретно имел в виду, вряд ли бы об этой диссертации знало больше пары десятков человек.

Что же «правильного» в REST-подходе к дизайну API (таком, как он сформировался в коллективном сознании широких масс программистов)? То, что такой дизайн позволяет добиться более эффективного использования времени — времени программистов и компьютеров.

Если коротко обобщить все сломанные в попытках определить REST копья, то мы получим следующий принцип: лучше бы ты разрабатывал распределённую систему так, чтобы промежуточные агенты умели читать метаданные запросов и ответов, проходящих через эту систему (готовая заповедь для RESTful-пастафарианства практически!).

У протокола HTTP есть очень важное достоинство: он предоставляет стороннему наблюдателю довольно подробную информацию о том, что произошло с запросом и ответом, даже если этот наблюдатель ничего не знает о семантике операции:


  • URL идентифицирует некоего конечного получателя запроса, какую-то единицу адресации;
  • по статусу ответа можно понять, выполнена ли операция успешно или произошла ошибка; если имеет место быть ошибка — то можно понять, кто виноват (клиент или сервер), и даже в каких-то случаях понять, что же конкретно произошло;
  • по методу запроса можно понять, является ли операция модифицирующей; если операция модифицирующая, то можно выяснить, является ли она идемпотентной;
  • по методу запроса и статусу и заголовкам ответа можно понять, кэшируем ли результат операции, и, если да, то какова политика кэширования.

Немаловажно здесь то, что все эти сведения можно получить, не вычитывая тело ответа целиком — достаточно прочитать служебную информацию из заголовков.

Почему это полезно? Потому что современный стек взаимодействия между клиентом и сервером является (как предсказывал Филдинг) многослойным. Разработчик пишет код поверх какого-то фреймворка, который отправляет запросы; фреймворк базируется на API языка программирования, которое, в свою очередь, обращается к API операционной системы. Далее запрос (возможно, через промежуточные HTTP-прокси) доходит до сервера, который, в свою очередь, тоже представляет собой несколько слоёв абстракции в виде фреймворка, языка программирования и ОС; к тому же, перед конечным сервером, как правило, находится веб-сервер, проксирующий запрос, а зачастую и не один. В современных облачных архитектурах HTTP-запрос, прежде чем дойти до конечного обработчика, пройдёт через несколько абстракций в виде прокси и гейтвеев. Если бы все эти агенты трактовали мета-информацию о запросе одинаково, это позволило бы обрабатывать многие ситуации оптимальнее — тратить меньше ресурсов и писать меньше кода.

(На самом деле, в отношении многих технических аспектов промежуточные агенты и так позволяют себе разные вольности, не спрашивая разработчиков. Например, свободно менять Accept-Encoding и Content-Length при проксировании запросов.)

Каждый из аспектов, перечисленных Филдингом в REST-принципах, позволяет лучше организовать работу промежуточного ПО. Ключевым здесь является stateless-принцип: промежуточные прокси могут быть уверены, что метаинформация запроса его однозначно описывает.

Приведём простой пример. Пусть в нашей системе есть операции получения профиля пользователя и его удаления. Мы можем организовать их разными способами. Например, вот так:

// Получение профиля
GET /me
Cookie: session_id=<идентификатор сессии>
// Удаление профиля
GET /delete-me
Cookie: session_id=<идентификатор сессии>

Почему такая система неудачна с точки зрения промежуточного агента?


  1. Сервер не может кэшировать ответы; все /me для него одинаковые, поскольку он не умеет получать уникальный идентификатор пользователя из куки; в том числе промежуточные прокси не могут и заранее наполнить кэш, так как не знают идентификаторов сессий.
  2. На сервере сложно организовать шардирование, т.е. хранение информации о разных пользователях в разных сегментах сети; для этого опять же потребуется уметь обменивать сессию на идентификатор пользователя.

Первую проблему можно решить, сделав операции более машиночитаемыми, например, перенеся идентификатор сессии в URL:

// Получение профиля
GET /me?session_id=<идентификатор сессии>
// Удаление профиля
GET /delete-me?session_id=<идентификатор сессии>

Шардирование всё ещё нельзя организовать, но теперь сервер может иметь кэш (в нём будут появляться дубликаты для разных сессий одного и того же пользователя, но хотя бы ответить из кэша неправильно будет невозможно), но возникнут другие проблемы:


  1. URL обращения теперь нельзя сохранять в логах, так как он содержит секретную информацию; более того, появится риск утечки данных со страниц пользователей, т.к. одного URL теперь достаточно для получения данных.
  2. Ссылку на удаление пользователя клиент обязан держать в секрете. Если её, например, отправить в мессенджер, то робот-префетчер мессенджера удалит профиль пользователя.

Как же сделать эти операции правильно с точки зрения REST? Вот так:

// Получение профиля
GET /user/{user_id}
Authorization: Bearer 
// Удаление профиля
DELETE /user/{user_id}
Authorization: Bearer 

Теперь URL запроса в точности идентифицирует ресурс, к которому обращаются, поэтому можно организовать кэш и даже заранее наполнить его; можно организовать маршрутизацию запроса в зависимости от идентификатора пользователя, т.е. появляется возможность шардирования. Префетчер мессенджера не пройдёт по DELETE-ссылке;, а если он это и сделает, то без заголовка Authorization операция выполнена не будет.

Наконец, неочевидная польза такого решения заключается в следующем: промежуточный сервер-гейтвей, обрабатывающий запрос, может проверить заголовок Authorization и переслать запрос далее без него (желательно, конечно, по безопасному соединению или хотя бы подписав запрос). И, в отличие от схемы с идентификатором сессии, мы всё ёщё можем свободно организовывать кэширование данных в любых промежуточных узлах. Более того, агент может легко модифицировать операцию: например, для авторизованных пользователей пересылать запрос дальше как есть, а неавторизованным показывать публичный профиль, пересылая запрос на специальный URL, ну, скажем, GET /user/{user_id}/public-profile — для этого достаточно всего лишь дописать /public-profile к URL, не изменяя все остальные части запроса. Для современных микросервисных архитектур возможность корректно и дёшево модифицировать запрос при маршрутизации является самым ценным преимуществом в концепции REST.

Шагнём ещё чуть вперёд. Предположим, что гейтвей спроксировал запрос DELETE /user/{user_id} в нужный микросервис и не дождался ответа. Какие дальше возможны варианты?

Вариант 1. Можно сгенерировать HTML-страницу с ошибкой, вернуть её веб-серверу, чтобы тот вернул её клиенту, чтобы клиент показал её пользователю, и дождаться реакции пользователя. Мы прогнали через систему сколько-то байтов и переложили решение проблемы на конечного потребителя. Попутно заметим, что при этом на уровне логов веб-сервера ошибка неотличима от успеха — и там, и там какой-то немашиночитаемый ответ со статусом 200 — и, если действительно пропала сетевая связность между гейтвеем и микросервисом, об этом никто не узнает.

Вариант 2. Можно вернуть веб-серверу подходящую HTTP-ошибку, например, 504, чтобы тот вернул её клиенту, чтобы клиент обработал ошибку и, сообразно своей логике, что-то предпринял по этому поводу, например, отправил запрос повторно или показал ошибку пользователю. Мы прокачали чуть меньше байтов, попутно залогировав исключительную ситуацию, но теперь переложили решение на разработчика клиента — это ему надлежит не забыть написать код, который умеет работать с ошибкой 504.

Вариант 3. Гейтвей, зная, что метод DELETE идемпотентен, может сам повторить запрос; если исполнить запрос не получилось — проследовать по варианту 1 или 2. В этой ситуации мы переложили ответственность за решение на архитектора системы, который должен спроектировать политику перезапросов внутри неё (и гарантировать, что все операции за DELETE действительно идемпотенты), но мы получили важное свойство: система стала самовосстанавливающейся. Теперь она может «сама» побороть какие-то ситуации, которые раньше вызывали исключения.

Внимательный читатель может заметить, что вариант (3) при этом является наиболее технически сложным из всех, поскольку включает в себя варианты (1) и (2): для правильной работы всей схемы разработчику клиента всё равно нужно написать код работы с ошибкой. Это, однако, не так; есть очень большая разница в написании кода для системы (3) по сравнению с (1) и (2): разработчику клиента не надо знать как устроена политика перезапросов сервера. Он может быть уверен, что сервер уже сам выполнил необходимые действия, и нет никакого смысла немедленно повторять запрос. Все серверные методы с этой точки зрения для клиента начинают выглядеть одинаково, а значит, эту функциональность (ожидание перезапроса, таймауты) можно передать на уровень фреймворка.

Важно, что для разработчика клиента при правильно работающих фреймворках (клиентском и серверном) пропадают ситуации неопределённости: ему не надо предусматривать какую-то логику «восстановления», когда запрос вроде бы не прошёл, но его ещё можно попытаться исправить. Клиент-серверное взаимодействие становится бинарным — или успех, или ошибка, а все пограничные градации обрабатываются другим кодом.

В итоге, более сложная архитектура оказалась разделена по уровням ответственности, и каждый разработчик занимается своим делом. Разработчик гейтвея гарантирует наиболее оптимальный роутинг внутри дата-центра, разработчик фреймворка предоставляет функциональность по реализации политики таймаутов и перезапросов, а разработчик клиента пишет бизнес-логику обработки ошибок, а не код восстановления из низкоуровневых состояний неопределённости.

Разумеется все подобные оптимизации можно выполнить и без опоры на стандартную номенклатуру методов / статусов / заголовков HTTP, или даже вовсе поверх другого протокола. Достаточно разработать одинаковый формат данных, содержащий нужную мета-информацию, и научить промежуточные агенты и фреймворки его читать. В общем-то, именно это Филдинг и утверждает в своей диссертации. Но, конечно, очень желательно, чтобы этот код уже был кем-то написан за нас.

Заметим, что многочисленные советы «как правильно разрабатывать REST API», которые можно найти в интернете, никак не связаны с изложенными выше принципами, а зачастую и противоречат им:


  1. «Не используйте в URL глаголы, только существительные» — этот совет является всего лишь костылём для того, чтобы добиться правильной организации мета-информации об операции. В контексте работы с URL важно добиться двух моментов:


    • чтобы URL был ключом кэширования для кэшируемых операций и ключом идемпотентности для идемпотентных;
    • чтобы при маршрутизации запроса в многослойной системе можно было легко понять, куда будет маршрутизироваться эта операция по её URL — как машине, так и человеку.

  2. «Используйте HTTP-глаголы для описания действий того, что происходит с ресурсом» — это правило попросту ставит телегу впереди лошади. Глагол указывает всем промежуточным агентам, является ли операция (не)модифицирующей, (не)кэшируемой, (не)идемпотентной и есть ли у запроса тело; вместо того, чтобы выбирать строго по этим четырём критериям, предлагается воспользоваться какой-то мнемоникой — если глагол подходит к смыслу операции, то и ок. Это в некоторых случаях просто опасно: вам может показаться, что DELETE /list?element_index=3 прекрасно описывает ваше намерение удалить третий элемент списка, но т.к. эта операция неидемпотентна, использовать метод DELETE здесь нельзя;


  3. «Используйте POST для создания сущностей, GET для доступа к ним, PUT для полной перезаписи, PATCH для частичной и DELETE для удаления» — вновь мнемоника, позволяющая «на пальцах» прикинуть, какие побочные эффекты возможны у какого из методов. Если попытаться разобраться в вопросе глубже, то получится, что вообще-то этот совет находится где-то между «бесполезен» и «вреден»:


    • использовать метод GET в API имеет смысл тогда и только тогда, когда вы можете указать заголовки кэширования; если выставить Cache-Control в no-cache — то получится просто неявный POST; если их не указать совсем, то какой-то промежуточный агент может взять и додумать их за вас;
    • создание сущностей желательно делать идемпотентным, в идеале — за PUT (например, через схему с драфтами);
    • частичная перезапись через PATCH опасная и двусмысленная операция, лучше её декомпозировать через более простые PUT;
    • наконец, в современных системах сущности очень редко удаляются — скорее архивируются или помечаются скрытыми, так что и здесь PUT /archive?entity_id будет уместнее.

  4. «Не используйте вложенные ресурсы» — это правило просто отражает тот факт, что отношения между сущностями имеют тенденцию меняться, и строгие иерархии перестают быть строгими.


  5. «Используйте множественное число для сущностей», «приписывайте слэш в конце URL» и тому подобные советы по стилистике кода, не имеющие никакого отношения к REST.


Осмелимся в конце этого раздела сформулировать четыре правила, которые действительно позволят вам написать хорошее REST API:


  1. Соблюдайте стандарт HTTP, особенно в части семантики методов, статусов и заголовков.
  2. Используйте URL как ключ кэша и ключ идемпотентности.
  3. Проектируйте архитектуру так, чтобы для организации маршрутизации запросов внутри многослойной системы было достаточно манипулировать частями URL (хост, путь, query-параметры), статусами и заголовками.
  4. Рассматривайте сигнатуры вызовов HTTP-методов вашего API как код, и применяйте к нему те же стилистические правила, что и к коду: сигнатуры должны быть семантичными, консистентными и читабельными.

Преимущества и недостатки REST

Главное преимущество, которое вам предоставляет REST — возможность положиться на то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — без необходимости писать какой-то дополнительный код. Немаловажно уточнить, что, если вы этими преимуществами не пользуетесь, никакой REST вам не нужен.

Главным недостатком REST является то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — даже если вы их об этом не просили. Более того, так как стандарты HTTP являются сложными, концепция REST — непонятной, а разработчики программного обеспечения — неидеальными, то промежуточные агенты могут трактовать метаданные запроса неправильно. Особенно это касается каких-то экзотических и сложных в имплементации стандартов.

Разработка распределённых систем в парадигме REST — это всегда некоторый торг: какую функциональность вы готовы отдать на откуп чужому коду, а какую — нет. Увы, нащупывать баланс приходится методом проб и ошибок.


О метапрограммировании и REST по Филдингу

Отдельно всё-таки выскажемся о трактовке REST по Филдингу-2008, которая, на самом деле, уходит корнями в распространённую концепцию HATEOAS. С одной стороны, она является довольно логичным продолжением принципов, изложенных выше: если машиночитаемыми будут не только метаданные текущей исполняемой операции, но и всех возможных операций над ресурсом, это, конечно, позволит построить гораздо более функциональные сетевые агенты. Вообще сама идея метапрограммирования, когда клиент является настолько сложной вычислительной машиной, что способен расширять сам себя без необходимости привлечь разработчика, который прочитает документацию API и напишет код работы с ним, конечно, выглядит весьма привлекательной для любого технократа.

Недостатком этой идеи является тот факт, что клиент будет расширять сам себя без привлечения разработчика, который прочитает документацию API и напишет код работы с ним. Возможно, в идеальном мире так работает; в реальном — нет. Любое большое API неидеально, в нём всегда есть концепции, для понимания которых (пока что) требуется живой человек. А поскольку, повторимся, API работает мультипликатором и ваших возможностей, и ваших ошибок, автоматизированное метапрограммирование поверх API чревато очень-очень дорогими ошибками.

Пока сильный ИИ не разработан, мы всё-таки настаиваем на том, что код работы с API должен писать живой человек, который опирается на подробную документацию, а не догадки о смысле гиперссылок в ответе сервера.

--

Это черновик будущей главы книги о разработке API. Работа ведётся на Github. Англоязычный вариант этой же главы опубликован на medium. Я буду признателен, если вы пошарите его на реддит — я сам не могу согласно политике платформы.

© Habrahabr.ru