ХабрПостыНовостиМегапосты?

Hypermedia — то без чего ваше API не совсем REST

Всем привет! Меня зовут Дмитрий Павлов, в компании Align Technology мы с коллегами занимаемся разработкой Web API для взаимодействия внутренних систем и интеграции нашей компании со сторонними вендорами. Об идеях создания API для веба, а точнее RESTful API я хотел бы рассказать в этой статье.


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


REST или не REST?


Большинство разработчиков, особенно в России, понимает под REST программный интерфейс, работающий с использованием протокола HTTP[S] при условии соблюдения следующих свойств:



Алгоритм по которому такой API используется обычно стандартен. Для начала нужно пойти на сайт с документацией, найти страничку со списком URL-шаблонов для доступа к ресурсам. Обычно она выглядит так:


Список ресурсов для API "Рецепты печенек"

--- /recipes/cookies - список рецептов печенек, 
GET на данный URL возвращает список доступных рецептов
  [
      {
         "name" : "Овсяное печенье с шоколадом",
         "rating" : 5,
         "shortDescription" : "...."
      }
  ]

POST на данный URL возволит вам создать новый рецепт. В качестве тела запроса ожидается json вида как
  {
     "name" : "Малиновое печенье",
     "shortDescription" : "...."
     ......
  }

--- /recipes/cookies/:name - рецепт печеньки с именем ${name}
{
  "name" : "Овсяное печенье с шоколадом",
  "rating" : 5,
  "shortDescription" : "....",
  "description" : "...."
  "ingredients" : [
      {
        "name" : "Овсянка",
        .....
      },
      {
        "name" : "Масло",
        .....
      },
      {
        "name" : "Шоколад",
        .....
      }
  ],
  "cookingSteps" : [
   ....
  ]
}
// остальные ресурсы гипотетического API с перечсилением HTTP методов и URL шаблонов

и изучив её выполнять запросы к ресурсам (что обычно выражается в написании клиента, который по заданным форматам URL'ов подставляет параметры и обрабатывает ответы).


Примеров таких API на просторах сети предостаточно, до недавнего времени у Яндекса многие API (раз, два) заявленные как REST работали по этой схеме.


Если мы обратимся к первоисточникам, т.е. к диссертации Роя Филдинга (на которую очень часто ссылаются, но гораздо реже читают), мы увидим, что API, созданные таким способом, не могут называться REST, поскольку они нарушают некоторые из принципов, описанных в диссертации, самый главный из которых — использование hypermedia как средства управления состоянием (Hypermedia As The Engine Of Application State ,HATEOAS), косвенно затрагивая вопросы само описываемых сообщений (self-descriptive messages).


Hypermedia в сообщениях


Суть HATEOAS состоит в подходе к описанию ресурсов нашего API. Вместо простого перечисления набора ресурсов, со списком всех возможных операций, которые клиент может вызвать, руководствуясь некоторой внутренней логикой, мы проводим инверсию контроля — теперь за состояние ресурса отвечает сервер и он диктует клиенту, какие операции над ресурсом можно совершить в текущий момент. Эта информация должна присутствовать в самом представлении ресурса, который получает клиент. Таким образом, представление ресурса само себя описывает в достаточной степени, чтобы клиент понял, что с ним можно делать.


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


Для достижения этой задачи как раз и используются гиперссылки (hypermedia):



Отсутствие ссылки как на связанные ресурсы, так и на доступные действия означает, что данная операция недоступна в текущем состоянии ресурса.


Пример переработки API "Рецепты печенек" в Hypermedia представление


Возвращаясь к примеру с нашим API о каталоге рецептов печенек, преобразуем его в Hypermedia-вид.


Как вы помните, у нас был список рецептов и ресурс, подробно описывающий конкретный рецепт с перечислением ингредиентов и шагов по приготовлению. Вот как они будут выглядеть с использованием hypermedia подхода:


//список рецептов
{
 "links": {
  "self"  "/recipes/cookies"
 }
 "items": [
      {
         "name": "Овсяное печенье с шоколадом",
         "rating": 5,
         "shortDescription": "...."
         "links": {
           "self": "/recipes/cookies/Овсяное печенье с шоколадом"
         }
      }
 ]
}

//конкретный рецепт
{
  "links": {
    "self": "/recipes/cookies/Овсяное печенье с шоколадом"
  }
  "name": "Овсяное печенье с шоколадом",
  "rating": 5,
  "shortDescription": "....",
  "description": "...."
  "ingredients": [
      {        
        "name": "Овсянка",
        .....
      },
      {
        "name": "Масло",
        .....
      },
      {
        "name": "Шоколад",
        .....
      }
  ],
  "cookingSteps": [
   ....
  ]
}

Значительное отличие от оригинальной версии заключается в появлении объекта links внутри каждого ресурса. Ключи этого объекта представляют собой relation'ы (те самые идентификаторы), а значения — ссылки. В результате наши ресурсы не требуют дополнительной информации (вне самого ресурса) о том как же перейти из каталога рецептов к детальному описанию, ссылка встроена в представление ресурса.


Данные подход позволяет легко расширять функциональность нашего API. Предположим, что мы для каждого рецепта хотим предоставить клиенту набор рекомендаций, представимый в виде списка рецептов. Сделать это очень легко, достаточно добавить в наш объект links новый ключ:


"links" : {
  "self" : "/recipes/cookies/Овсяное печенье с шоколадом",
  "http://acme.com/recipes/rels/you-can-also-like" : "/recipes/cookies?related_to=Овсяное+печенье+с+шоколадом"
}

Аналогично, совершенно не составит труда добавить идентификацию ингредиентов как отдельных ресурсов, если в этом возникнет необходимость.


Содержание URI не играет никакой роли, ведь теперь элементом API является relation, и мы без каких либо изменений на клиенте можем поменять ссылку на /recipes/related-to/Овсяное печенье с шоколадом или на /recipes/234892skfj45sdlkfjdsa12


Hypermedia на службе перемен


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


Для наглядности рассмотрим пример с нашим API, добавив hypermedia-контрол для создания нового рецепта.


//список рецептов
{
 "links" : {
  "self" : "/recipes/cookies",
  "http://acme.com/recipes/rels/add-recipe" : "/recipes/cookies"
 }
 "items" : [
      .....
 ]
}

Мы лишь добавили ссылку со специальным relation'ом. Основное правило заключается в том, что клиент игнорирует неизвестные ему отношения: "старые" клиенты, не знающие как добавлять новый рецепт, будут работать как раньше, а для тех кто поддерживает создание, это будет сигналом, что есть возможность добавления нового рецепта путем отправки запроса на URI, который указан в отношении http://acme.com/recipes/rels/add-recipe.


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


Разумеется, предоставление ссылок не снимает с сервера ответсвенности за корректное оперирование HTTP методами и соблюдения их семантики :).


А как же relation?


У вас к данному моменту наверняка возник вопрос: какой смысл затевать все это, если для эффективной работы клиент все равно должен понимать смысл relation'ов? По ним-то документация должна иметься.


В самом деле, для эффективной работы клиент действительно должен понимать, что значит каждое отношение. Основная идея, стоящая за заменой интерпретации URI на работу с relation'ами, состоит в большей долговечности последних. URI является деталью реализации и может меняться со временем или от сервера к серверу. Relation же представляет собой семантическое описание связи и не завязан на детали хранения.


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


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


Hypermedia types или почему application/json нам не подходит


Решив воспользоваться преимуществами Hypermedia подхода, мы модифицировали наш API указанным выше способом, и теперь у нас ресурсы связаны друг с другом по ссылке. С первого взгляда может показаться, что с нашим API все в порядке, но перед тем как заявить, что у нас Hypermedia API, посмотрим на заголовок Content-Type, возвращаемый нами в ответах. Если там стоит application/json или даже text/plain, то нам еще предстоит потрудиться.


Ресурсы глазами машины


Глядя на получившиеся у нас ресурсы, человек сразу выделяют ссылки, что создает впечатление о корректном формате нашего сообщения. Мы делаем вывод об этом, анализируя содержимое сообщения, тогда так стандарт предписывает смотреть на Content-Type заголовок ответа.


Рассмотрим следующий ответ сервера:


200 OK
Content-Type: text/plain

<?xml version="1.0"?>
<hello>world</hello>

Нам очевидно, что в ответе содержится xml-документ, но Content-Type предписывает воспринимать содержимое как простой текст, поэтому то, что он похож на xml-документ может быть просто совпадением или частным случаем. Именно поэтому верный Content-Type так важен.


Давайте разбираться, чем же для нашей задачи не подойдет application/json? Дело в том, что стандарт, описывающий этот тип, не предусматривает никакого места или механизма для определения ссылок в нем. И даже если сформированное нами сообщения содержит ссылки, то машина не может отличить их от строки, в которой содержится текст по формату напоминающий ссылку. Нам же нужно однозначно определить, где в сообщении ссылка, а где нет, поэтому нам нужен другой тип.


Vendor specific типы


Одним из способов решить проблему корректности Content-Type'а — использовать свой собственный. В документации мы явно укажем, где у нас в сообщении расположены ссылки. Если клиент получил от сервера ответ с нашем личным Content-Type'ом, ему не нужно будет динамически угадывать, что ссылка а что нет, если конечно он понимает наш Content-Type. Стоит отметить, что зачастую документация с описанием типа содержит не только подробности самого формата (т.е. где расположены ссылки, а где свойства), но и другую информацию:



Такие типы называются vendor specific, поскольку часто создаются под конкретную задачу и конкретной организацией. Их нет необходимости регистрировать в IANA. Рекомендуется давать им название вида application/vnd.${vendor}+${base_format}, где ${vendor} — это перевернутый домен компании, ${base_format} — тип который мы взяли за основу. Если компания имеет домен acme.com и для представления наших ресурсов мы используем json, то для нашего API рецептов название типа будет выглядеть как application/vnd.com.acme.recipes+json.


Hypermedia типы общего назначения


На первый взгляд, vendor specific типы решают возникшую проблему со ссылками, но у них есть и свои проблемы:



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



Именно эта задача и решается: тип общего назначения не пытается подстроиться под конкретную доменную область, ими можно описать большинство ресурсов, с которыми мы имеем дело.


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


На данный момент существует уже достаточно большое количество подобных форматов, поэтому перечислим лишь некоторые из них:



В описании всех таких форматов вы найдете, как и куда помещать свойства ресурса, в каком виде оформлять ссылки на другие ресурсы.


Различаются они форматом и возможностями, которые содержатся в самом типе.


Разница в форматах или принципиальность создателей


Большинство типов общего назначения отличается незначительными деталями, например, как форматировать ссылки. Так, в HAL ссылки выглядят следующим образом:


"_links" : {
  "self" : ....
  "relToResource": .....
}

Тогда как Siren представляет их так:


"links" : [
  {"rel" : ["self"], "href" : "...."},
  {"rel" : ["relToResource"], "href" : "...."}
]

Основное отличие здесь в представлении relation значений. Создатель HAL стремился сделать формат более лаконичным, в то время как создатель Siren — более полным: relation у ссылки действительно может быть сложным (поэтому в Siren это массив значений), но это не всегда используется (поэтому в HAL это скаляр, да еще и ключ в объекте).


Такие вот разные взгляды и привели к созданию разных форматов, об одном формате договориться не смогли.


Различие в возможностях


Не будем здесь перечислять все различия в форматах, обозначим только основные, на примере уже упомянутых типов:



Generic vs vendor specific


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


Одно из главных преимуществ hypermedia-типа общего назначения — экономия времени вам и клиентам вашего API. Вот за счет чего она достигается:



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


В итоге для большинства задач можно рекомендовать использовать один из имеющихся Hypermedia-форматов общего назначения по умолчанию и делать выбор в пользу vendor-форматов в сложных или специфических случаях (если вы, конечно, не ставите целью vendor lock-in).


Насколько мне нужно все это?


Описанный подход не является очередной серебряной пулей, призванной решить все проблемы при разработке API.


Можно отметить, что концепция точек входа способна привести к росту числа запросов, чтобы "добраться" до нужного ресурса и что включая ссылки мы делаем сообщение более объемным по сравнению с голыми данными.


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


Основной плюс REST-подхода (здесь я имею ввиду полноценный REST) в гибкости и расширяемости, который он предоставляет, позволяя нам добавлять новые возможности или просто менять организацию ресурсов у себя на сервере без нарушения работы существующих клиентов.


Даже если вы решите не использовать hypermedia в вашем API, теперь вы знаете, что без нее REST — это не REST, а просто Web API. Это не делает API плохим или хорошим, я просто констатирую факт. Главное не забывать, что API мы делаем не ради самого API, а для решения задач, стоящих перед нами :).


Примеры на просторах сети


@
08.04.2016 12:52 UTC
Первоисточник