Зачем нужен этот ваш REST, а также о некоторых тонкостях реализации RESTful приложений

25 ноября 2013

На днях кое-кто спросил меня, дескать, зачем вообще нужен этот REST. Зачем, например, заморачиваться с методом DELETE или там заголовком Accept? Не проще ли использовать метод GET и передавать все в параметрах, например, delete=true или format=json? Вбил в браузере, и работает! А вот этот ваш DELETE так просто через браузер не пошлешь. На что был дан примерно следующий ответ.

Вот, допустим, у вас есть некоторые ресурсы. Для определенности, пусть это будут книги и пользователи. Что, собственно, означает иметь REST API для работы с этими ресурсами? В первом приближении, следующее. Если мы хотим получить какую-то книгу, то говорим GET /books/123. Аналогично информация о пользователе получается запросом GET /users/456. Вообще-то, в начале URL неплохо бы иметь что-то вроде /api/v1.0/, но для краткости мы это опустим. По умолчанию данные отдаются, например, в JSON’е, но при желании мы можем передать Accept-заголовок с другим форматом. Для создания или обновления существующей книги следует использовать метод PUT, передав данные в теле запроса и указав формат этих данных в заголовке Content-type. Для удаления данных используется метод DELETE.

Внимательный читатель спросит, а для чего тогда нужен POST? Вообще, если делать все по науке, он должен использоваться для добавления элементов в сущность, словно она является неким контейнером, например, словарем. Однако на практике так обычно не делают, ведь при использовании API несколькими клиентами один клиент может изменить название книги, а второй — ее цену, в результате чего получится ерунда. Поэтому POST либо вообще не используют, либо используют в качестве замены методов PUT и DELETE. То есть, POST с каким-то телом запроса работает, как PUT, а без тела запроса — как DELETE. Это позволяет работать с клиентами, которые почему-то не умеют посылать PUT и DELETE.

Можно работать и сразу с целыми коллекциями. Для получения списка всех пользователей говорим GET /users, а для создания нового пользователя с автоматически сгенерированным id — POST /users. Как и ранее, в последнем случае данные передаются в теле запроса. Также можно перезаписать всю коллекцию, сказав PUT /users, и удалить сразу всех пользователей, сказав DELETE /users. Еще иногда требуется фильтрация по полям или пагинация, в этих случаях делают так:

GET /api/v1.0/users?fields=id,email,url&offset=100&limit=10&order_by=id

… или как-то так:

GET /api/v1.0/logs?from=2013-01-01+00:00:00&to=2013-12-31+23:59:59

Как бы, это все. Довольно однообразно и даже логично, не так ли? Так чем такой подход лучше описанного в начале поста?

В свое время я имел удовольствие работать над проектом, где API был устроен «простым и понятным» образом, на методах GET и POST, со всякими delete=1 и так далее. Смею вас заверить, что на самом деле вы этого не хотите. Потому что на практике работа с этим API превращается в какой-то кошмар.

Допустим, один программист занимается книгами, а второй пользователями. Первый решает, что для получения списка всех сущностей будет использоваться запрос GET /all_books, а второй решает перечислять только id и использовать URL GET /select_user_ids. Для удаления сущности первый программист решает использовать параметр del=true, а второй — delete=1. Для экспорта данных в CSV первый программист делает поддержку export=text/csv, а второй — format=CSV. Потом выясняется, что некоторые библиотеки не умеют посылать GET-запросы со слишком длинными query string и ходить за данными на чтение начинают методом POST. А затем кто-то случайно удаляет через браузер всех пользователей в боевом окружении… И так далее, и тому подобное, полный бардак в общем.

Вы спросите, что же мешает привести все это безобразие в одному стандарту, например, использовать только del=1 и export=csv? Так вот, REST — это и есть то самое приведение к одному стандарту, с учетом всяческих граблей типа случайного удаления данных через браузер и так далее. Притом у разных компаний этот стандарт одинаковый. Когда в команду разработчиков приходит новичок, вы просто говорите ему, что у вас всюду REST, а основные ресурсы — это пользователи и книги. Все, после этого одного предложения ваш новый коллега знает 90% API, безо всякого там чтения Wiki. Если вы хотите говорить с иностранцами, вы же просто используете общепринятый английский язык, а не изобретаете новый? Вот так же и здесь. Нельзя также не напомнить о пользе повторного использования протоколов и кода. А ведь для работы с REST, и HTTP вообще, написана куча библиотек и фреймворков.

Вы скажите «я, конечно, согласен, что REST такой весь из себя интуитивно понятный и общепринятый, но что, если я просто хочу загрузить через браузер список книг в формате CSV»? Тут важно понимать, что REST — это не о том, как сделать все через браузер. Предполагается, что должен быть клиент, который умеет работать с вашим API, вот через него и экспортируете. Но если по каким-то причинам это затруднительно, вы можете, например, использовать curl. Если у вас нелады с консолью, вы без труда найдете множество GUI-клиентов или, скажем, какой-нибудь плагин для Chrome, с аналогичным функционалом. Однако я все же советую попробовать curl. Пользоваться им совсем не так сложно, как вам может казаться. Всего-то нужно запомнить пару флагов.

Так задаются дополнительные HTTP-заголовки:

-H 'Accept: text/csv' -H 'Content-type: application/json'

Выбираем используемый метод:

-X{GET|PUT|POST|DELETE}

Указываем тело запроса:

-d '{"name":"Alex","url":"http://eax.me/"}'

Если тело запроса большое, можно сохранить его в файл и сказать:

-d @filename.json
# чтобы при этом не удалялись символы новой строки:
--data-binary @filename.json

Выводим заголовки из ответа сервера в stdout:

-D -

Говорим передавать данные в gzip’е:

--compressed

Сохраняем тело ответа в указанный файл вместо stdout:

-o output.json

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

Теперь рассмотрим пару примеров.

Экспорт книг в формате CSV:

curl -H 'Accept: text/csv' http://localhost/api/v1.0/books -o books.csv

Создание пользователя c выводом заголовков из ответа сервера в stdout:

curl -XPOST -H 'Content-type: application/json' -d '{"name":"Alex"}' \
  http://localhost/api/v1.0/users -D -

Удаление пользователя с заданным id:

curl -XDELETE http://localhost/api/v1.0/users/123

Несложно, правда ведь?

Несколько финальных замечаний, относящихся не совсем к REST. Во-первых, иногда от приложения требуется не только предоставлять доступ к некоторым ресурсам, но и выполнять какие-то команды. Таким командам имеет смысл выделять URL-адреса, начинающиеся с /commands/. Например, запуск почтовой рассылки по всем пользователям будет выглядеть как-то так:

curl -XPOST -H 'Content-type: application/json' \
  -d '{"subject":"Good news, everyone!","body":"..."}' \
  http://localhost/api/v1.0/commands/notify_all_users_via_email

Дополнение: Некоторые команды должны быть доступны только в тестовом окружении, для них можно выделить URL-адреса, начинающиеся с /debug/.

Во-вторых, иногда требуется реализовать бесконечные потоки событий, или отправку текущего состояния, а затем обновлений к нему. Таким концам разумно выделить URL, начинающиеся, например, со /streams/. Вот как примерно это должно работать:

curl -H 'Accept: application/x-json-stream' \
  http://localhost/api/v1.0/streams/users -N

{"type":"user","data":{"id":123,"name":"Alex","url":"http://eax.me/"}}
{"type":"user","data":{"id":456,"name":"Bob","url":"http://ya.ru/"}}
...
{"type":"sync"}
{"type":"heartbeat"}
{"type":"heartbeat"}
{"type":"user_deleted","data":{"id":123}}
...

Нужно обратить внимание на несколько моментов. Здесь используется формат x-json-stream, то есть, поток JSON-объектов, разделенных символом \n. Если этот символ встречается в самом JSON-объекте, его, соответственно, следует кодировать. Некоторым клиентам может быть удобнее работать с честным JSON’ом, то есть, списком JSON-объектов. Предусмотреть поддержку сразу нескольких форматов довольно просто. Во втором случае список объектов должен начинаться с открывающейся квадратной скобки, а объекты должны разделяться запятыми. Для удобства работы со стримом нужно либо ставить после запятых символ \n, либо делать это на стороне клиента с помощью sed:

curl ... | sed 's/},/}\n/g'

Каждый объект имеет поле type и опциональное поле data. Объекты с типом heartbeat посылаются несмотря ни на что один раз в пять секунд. Если клиент не видит такого объекта в течение десяти секунд, он считает, что либо что-то сломалось на стороне сервера, либо что-то не так с сетью, и закрывает соединение. Объект с типом sync используется в стримах, посылающих некое состояние, а затем обновления к нему, для разделения первого от второго. Наконец, все остальные типы представляют собой полезную нагрузку. Поле data нужно по той причине, что вложенные данные также могут иметь поле type, что приводило бы к неразберихе.

В-третьих, когда вы пишите RESTful приложение, старайтесь с самого начала придерживаться некоторых соглашений. Например, с самого начала договоритесь, что имена полей в JSON-объектах должны всегда писаться в camelCase. Раз и навсегда запретите использовать в идентификаторах такие спецсимволы, как знак плюс и пробелы. Договоритесь, что в случае получения кода 301 клиент должен посылать точно такой же запрос на URL, указанный в заголовке Location. Примите соглашение о том, как будет передаваться автоматически сгенерированные id. Например, в Riak для этого используется заголовок Location. Подумайте о том, как вы будете сообщать о различных типах ошибок, в том числе временной недоступности БД, ошибках валидации полей и так далее. Пользователи почти наверняка предпочтут увидеть:

{"message":"validation_error","description":"..."}

… вместо кода 500 без каких-либо дополнительных пояснений. Если для вашего приложения важна точность представления чисел, договоритесь передавать все числа в виде строк, чтобы json-декодер не терял точность из-за преобразования строк во float’ы.

Но помните, хотя все написанное выше — это идеал, к которому стоит стремиться, на практике всем наплевать на стандарты. А значит, вас ждет много подпорок, слепленных на скорую руку, нежелание коллег переходить на более правильные версии API (зачем, если все работает?), и многие другие увлекательные вещи.

Дополнение: Пишем REST-сервис на Python с использованием Flask

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.