Гипермедиа (более известная как Hypermedia as the Engine of Application State, HATEOAS) — одно из основных ограничений Representational State Transfer (REST). Идея в том, что артефакты гипермедиа, такие как ссылки или формы, можно использовать для описания того, как клиенты могут взаимодействовать с набором HTTP-сервисов. Она быстро превратилась в интересную концепцию для разработки расширяемых API. Это ничем не отличается от того, как мы обычно взаимодействуем с Web. Как правило, мы запоминаем единственную точку входа или URL основной страницы какого-либо веб-сайта, а потом переходим по различным разделам сайта, используя ссылки. Мы также применяем формы, которые поступают с предопределенным действием или URL для передачи данных, которые могут потребоваться сайту для выполнения определенной операции.
Среди разработчиков наблюдается тенденция предоставлять статические описания всех поддерживаемых методов в сервисе — от формальных контрактов, таких как Web Services Description Language (WSDL) в SOAP-сервисах, до простой документации в Web API, не использующих гипермедиа. Основная проблема этого подхода в том, что статическое описание API жестко связывает клиент с сервером. Если в двух словах, то это препятствует дальнейшему расширению и совершенствованию, так как любое изменение в описании API может нарушить работу всех существующих клиентов.
Какое-то время это не было проблемой для предприятий, где число клиентских приложений можно было контролировать и было заранее известно. Однако, когда количество потенциальных клиентов растет экспоненциально (как это происходит сегодня с тысячами сторонних приложений, выполняемых на различных устройствах), это плохая идея. Тем не менее, простой переход от SOAP- к HTTP-сервисам не дает никаких гарантий, что проблема будет решена. Если на клиенте есть, например, некоторые данные для вычисления URL, проблема по-прежнему остается — даже без явного контракта вроде WSDL. Гипермедиа — вот что дает возможность защитить клиенты от любых изменений на серверной стороне.
Рабочий процесс состояния приложения (application state workflow), определяющий, что клиент может делать на следующем шаге, тоже должен располагаться на серверной стороне. Допустим, некое действие над ресурсом доступно только при определенном состоянии; должна ли эта логика находиться на клиенте любого возможного API? Явно нет. Сервер всегда должен диктовать, что можно делать с ресурсом. Например, если отменяется заказ на покупку (purchase order, PO), клиентскому приложению нельзя разрешать передавать этот PO, а значит, ссылка или форма для передачи PO не должна быть доступна в ответе, посылаемом клиенту.
Гипермедиа спешит на помощь
Связывание всегда было ключевым компонентом архитектуры REST. Конечно, ссылки привычны в таких UI-контекстах, как браузеры; возьмите, к примеру, ссылку «See details» для получения подробных сведений о данном товаре в каталоге. А как насчет сценариев «компьютер к компьютеру», где нет никаких UI или участия пользователя? Идея в том, что артефакты гипермедиа можно использовать и в этих сценариях.
Связывание всегда было ключевым компонентом архитектуры REST.
При этом новом подходе сервер возвращает не только данные, но и артефакты гипермедиа. Эти артефакты позволяют клиенту определять доступный набор действий, который может быть выполнен в данный момент на основе состояния рабочего процесса серверного приложения.
Это одна область, которая обычно отличает обычный Web API от RESTful API, но есть и другие ограничения, которые также применяются, поэтому обсуждать, относится API к RESTful или нет, в большинстве случаев, по-видимому, не имеет смысла. Важно то, что API корректно использует HTTP как прикладной протокол и по возможности применяет гипермедиа. За счет поддержки гипермедиа можно создавать самораспознаваемые (self-discoverable) API. Это не является оправданием для отсутствия документации, но такие API обеспечивают более гибкие возможности в изменении.
Какие артефакты гипермедиа доступны, в основном определяется выбранными media-типами. Многие из media-типов, применяемых сегодня для создания Web API, скажем, JSON или XML, не имеют встроенной концепции для представления ссылок или форм, как это делается в HTML. Вы можете задействовать эти media-типы, определив способ выражения гипермедиа, но это потребует от клиентов знания того, как семантика гипермедиа определена поверх этих типов. И напротив, media-типы вроде XHTML (application/xhtml+xml) или ATOM (application/atom+xml) уже поддерживают некоторые из артефактов гипермедиа, например ссылки или формы.
В случае HTML ссылка состоит из трех компонентов: атрибута href, указывающего на URL, атрибута rel, описывающего, как ссылка связана с текущим ресурсом, и необязательного атрибута type для указания ожидаемого media-типа. Например, если вы хотите предоставить список товаров в каталоге, используя XHTML, полезные данные (payload) ресурса могли бы выглядеть, как показано на рис. 1.
Рис. 1. Применение XHTML для предоставления списка товаров
<div id="products">
<ul class="all">
<li>
<span class="product-id">1</span>
<span class="product-name">Product 1</span>
<span class="product-price">5.34</span>
<a rel="add-cart" href="/cart" type="application/xml"/>
</li>
<li>
<span class="product-id">2</span>
<span class="product-name">Product 2</span>
<span class="product-price">10</span>
<a rel="add-cart" href="/cart" type="application/xml"/>
</li>
</ul>
</div>
В этом примере каталог товаров представлен стандартными HTML-элементами, но я использовал XHTML, так как его гораздо легче разбирать с помощью любой существующей библиотеки XML. Кроме того, в полезную информацию включен элемент anchor (a), представляющий ссылку для добавления единицы товара в корзину текущего покупателя. Проверяя эту ссылку, клиент может логически понять ее предназначение благодаря атрибуту rel (добавление нового элемента) и использовать href для выполнения операции над ресурсом (/cart). Важно отметить, что ссылки генерируются сервером на основе своего рабочего процесса, поэтому «зашивать» в код клиента никакие URL или логически выводить какие-либо правила не требуется. Это также открывает новые возможности в модификации рабочего процесса в период выполнения безо всякого влияния на существующие клиенты. Если какие-то товары, предлагаемые в каталоге, заканчиваются на складе, сервер может просто опустить ссылку на добавление этого товара в корзину покупателя. С точки зрения клиента, эта ссылка недоступна, поэтому данный товар заказать нельзя. На серверной стороне могут применяться и более сложные правила, относящиеся к этому рабочему процессу, но клиенту не нужно знать о них, так как для него важно лишь то, что ссылка отсутствует. Благодаря гипермедиа и ссылкам клиент отделяется от рабочего процесса на серверной стороне.
Многие из media-типов, применяемых сегодня для создания Web API, скажем, JSON или XML, не имеют встроенной концепции для представления ссылок или форм, как это делается в HTML.
Более того, гипермедиа и ссылки позволяют улучшить возможности расширения (дальнейшей эволюции) API. По мере эволюции рабочего процесса на сервере можно предлагать дополнительные ссылки для новой функциональности. В нашем примере с каталогом товаров сервер мог бы включить новую ссылку, с помощью которой какой-либо товар помечается как наиболее популярный, например:
<li>
<span class="product-id">1</span>
<span class="product-name">Product 1</span>
<span class="product-price">5.34</span>
<a rel="add-cart" href="/cart/1" type="application/xml"/>
<a rel="favorite" href="/product_favorite/1" type="application/xml"/>
</li>
Хотя существующие клиенты могут игнорировать эту ссылку и новая функциональность никак их не затронет, более новые клиенты могут сразу же начать использовать ее. Тогда будет вполне разумно подумать об одной точке входа или корневом URL для своего Web API, который содержит ссылки для обнаружения остальной функциональности. Например, у вас мог бы быть единственный URL «/shopping_cart», который возвращает следующее HTML-представление:
<div class="root">
<a rel="products" href="/products"/>
<a rel="cart" href="/cart"/>
<a rel="favorites" href="/product_favorite"/>
</div>
Аналогичная функциональность также имеется в OData-сервисах, которые предоставляют один документ сервиса в корневом URL со всеми поддерживаемыми наборами ресурсов и ссылок для сопоставления с ними данных.
Ссылки — отличный способ соединения серверов и клиентов, но с ними есть одна очевидная проблема. В предыдущем примере с каталогом товаров ссылка в HTML предоставляет лишь атрибуты rel, href и type, что подразумевает некое знание о том, что делать с URL, выраженным в атрибуте href. Что должен использовать клиент — HTTP POST или HTTP GET? Если — POST, то какие данные должен включать клиент в тело запроса? Хотя все это можно было бы где-то документировать, не лучше ли, чтобы клиенты реально могли обнаруживать функциональность? Ответом на все эти вопросы является использование HTML-форм.
Формы в действии
Когда вы взаимодействуете с Web через браузер, действия, как правило, представляются формами. В примере с каталогом товаров нажатие ссылки Add to Cart (добавить в корзину) подразумевает отправку HTTP GET на сервер для получения от него HTML-формы, которую можно использовать для добавления товара в корзину. Эта форма могла бы содержать атрибут action с URL, атрибут method, представляющий HTTP-метод, и какие-то поля ввода для получения нужных данных от пользователя, а также те или иные инструкции для пользователя.
Когда вы взаимодействуете с Web через браузер, действия, как правило, представляются формами.
То же самое можно сделать и в сценарии «компьютер к компьютеру». Место человека, взаимодействующего с формой, может занять приложение на JavaScript или C#. В каталоге товаров HTTP GET для ссылки «добавить в корзину» применительно к первому товару могла бы возвращать следующую форму, представленную в XHTML:
<form action="/cart" method="POST">
<input type="hidden" id="product-id">1</input>
<input type="hidden" id="product-price">5.34</input>
<input type="hidden" id="product-quantity" class="required">1</input>
<input type="hidden" id="___forgeryToken">XXXXXXXX</input>
</form>
Теперь клиентское приложение отделено от некоторых деталей, связанных с добавлением товара в корзину. Ему нужно лишь передать эту форму командой HTTP POST по URL, указанному в атрибуте action. Кроме того, сервер может включать дополнительную информацию в форму, например маркер фальсификации (forgery token), чтобы избежать атак с подделкой кросс-сайтовых запросов (cross-site request forgery, CSRF) или подписывать данные, которые заранее заполняются для сервера.
Эта модель обеспечивает свободное развитие любого Web API — вы просто предлагаете новые формы на основе различных факторов, таких как разрешения пользователя или версия, необходимая клиенту.
Гипермедиа для XML и JSON?
Как уже упоминалось, универсальные media-типы для XML (application/xml) и JSON (application/json) не имеют встроенной поддержки гипермедийных ссылок или форм. Хотя эти media-типы можно расширить концепциями, специфичными для предметной области, например «application/vnd-shoppingcart+xml», это потребует создания новых клиентов, понимающих всю семантику, определенную в этом новом типе (и вероятно, приведет к «размножению» media-типов), так что, в целом, этот подход не считается хорошей идеей.
По этой причине был предложен новый media-тип, расширяющий XML и JSON семантикой ссылок и названный Hypertext Application Language (HAL). Черновая версия, которая просто определяет стандартный способ выражения гиперссылок и встраиваемых ресурсов (данных) с применением XML и JSON, доступна по ссылке stateless.co/hal_specification.html. Media-тип HAL определяет ресурс, содержащий набор свойств, набор ссылок и набор встраиваемых ресурсов, как показано на рис. 2.
Рис. 2. Media-тип HAL
ResourceState (Properties) | ResourceState (свойства) |
Links | Ссылки |
rel | rel |
href | href |
Embedded Resources | Встраиваемые ресурсы |
Универсальные media-типы для XML (application/xml) и JSON (application/json) не имеют встроенной поддержки гипермедийных ссылок или форм.
На рис. 3 приведен пример того, как будет выглядеть каталог товаров в HAL с использованием как XML-, так и JSON-представлений. На рис. 4 показано JSON-представление для примера ресурса.
Рис. 3. Каталог товаров в HAL
<resource href="/products">
<link rel="next" href="/products?page=2" />
<link rel="find" href="/products{?id}" templated="true" />
<resource rel="product" href="/products/1">
<link rel="add-cart" href="/cart/" />
<name>Product 1</name>
<price>5.34</price>
</resource>
<resource rel="product" href="/products/2">
<link rel="add-cart" href="/cart/" />
<name>Product 2</name>
<price>10</price>
</resource>
</resource>
Рис. 4. JSON-представление для примера ресурса
{
"_links": {
"self": { "href": "/products" },
"next": { "href": "/products?page=2" },
"find": { "href": "/products{?id}", "templated": true }
},
"_embedded": {
"products": [{
"_links": {
"self": { "href": "/products/1" },
"add-cart": { "href": "/cart/" },
},
"name": "Product 1",
"price": 5.34,
},{
"_links": {
"self": { "href": "/products/2" },
"add-cart": { "href": "/cart/" }
},
"name": "Product 2",
"price": 10
}]
}
}
Поддержка гипермедиа в ASP.NET Web API
До сих пор я рассматривал некоторые теоретические вопросы, стоящие за применением концепции гипермедиа в проектировании Web API. Теперь обсудим, как эту теорию можно реализовать на практике, используя ASP.NET Web API со всеми точками и средствами расширения, предоставляемыми этой инфраструктурой.
На базовом уровне ASP.NET Web API поддерживает идею средств форматирования (formatters). Реализация средства форматирования знает, как работать с определенным media-типом и как сериализовать или десериализовать его в конкретные .NET-типы. В прошлом поддержка новых media-типов в ASP.NET MVC была очень ограниченной. Только HTML и JSON обрабатывались полноценно и полностью поддерживались всем стеком. Более того, не было единой модели для поддержки согласования формата контента (content negotiation). Вы могли поддерживать различные форматы media-типа для сообщений-ответов, предоставляя свои реализации ActionResult, но было неясно, как можно было бы ввести новый media-тип для десериализации сообщений-запросов. Обычно это решалось использованием инфраструктуры связывания моделей с новыми средствами привязки моделей (model binders) или провайдерами значений (value providers). К счастью, эта рассогласованность устранена в ASP.NET Web API введением средств форматирования.
Каждое средство форматирования наследует от базового класса System.Net.Http.Formatting.MediaTypeFormatter, а также переопределяет метод CanReadType/ReadFromStreamAsync для поддержки десериализации и метод CanWriteType/WriteToStreamAsync для поддержки сериализации .NET-типов в заданный формат media-типа.
Определение класса MediaTypeFormatter представлено на рис. 5.
Рис. 5. Класс MediaTypeFormatter
public abstract class MediaTypeFormatter
{
public Collection<Encoding> SupportedEncodings { get; }
public Collection<MediaTypeHeaderValue> SupportedMediaTypes { get; }
public abstract bool CanReadType(Type type);
public abstract bool CanWriteType(Type type);
public virtual Task<object> ReadFromStreamAsync(Type type, Stream readStream,
HttpContent content, IFormatterLogger formatterLogger);
public virtual Task WriteToStreamAsync(Type type, object value,
Stream writeStream, HttpContent content, TransportContext transportContext);
}
Средства форматирования играют очень важную роль в ASP.NET Web API для поддержки согласования формата контента, так как инфраструктура теперь может выбирать правильный объект форматирования на основе значений, полученных в заголовках Accept и Content-Type сообщения-запроса.
Методы ReadFromStreamAsync и WriteToStreamAsync полагаются на Task Parallel Library (TPL) в выполнении асинхронной работы, поэтому они возвращают экземпляр Task. Если вы хотите явным образом заставить свою реализацию средства форматирования работать синхронно, то для вас это сделает на внутреннем уровне базовый класс BufferedMediaTypeFormatter. Этот базовый класс предоставляет два метода, которые можно переопределять в реализации: SaveToStream и ReadFromStream; это синхронные версии SaveToStreamAsync и ReadFromStreamAsync.
Реализация средства форматирования знает, как работать с определенным media-типом и как сериализовать или десериализовать его в конкретные .NET-типы.
Разработка MediaTypeFormatter для HAL
HAL использует специфическую семантику для представления ресурсов и ссылок, поэтому в реализации какого-либо Web API нельзя взять просто любую модель. По этой причине применяют один базовый класс для представления ресурса, а другой — для представления набора ресурсов; это резко упрощает реализацию средства форматирования:
public abstract class LinkedResource
{
public List<Link> Links { get; set; }
public string HRef { get; set; }
}
public abstract class LinkedResourceCollection<T> : LinkedResource,
ICollection<T> where T : LinkedResource
{
// Остальная часть реализации набора
}
Реальные классы модели, которую будут использовать контроллеры Web API, могут наследовать от этих двух базовых классов. Например, товар или набор товаров можно реализовать так:
public class Product : LinkedResource
{
public int Id { get; set; }
public string Name { get; set; }
public decimal UnitPrice { get; set; }
}
...
public class Products : LinkedResourceCollection<Product>
{
}
Теперь, располагая стандартным способом определения HAL-моделей, пора приступить к реализации средства форматирования. Самое простое — наследование либо от базового класса MediaTypeFormatter, либо от базового класса BufferedMediaTypeFormatter. В примере на рис. 6 используется второй базовый класс.
Рис. 6. Базовый класс BufferedMediaTypeFormatter
public class HalXmlMediaTypeFormatter : BufferedMediaTypeFormatter
{
public HalXmlMediaTypeFormatter()
: base()
{
this.SupportedMediaTypes.Add(new MediaTypeHeaderValue(
"application/hal+xml"));
}
public override bool CanReadType(Type type)
{
return type.BaseType == typeof(LinkedResource) ||
type.BaseType.GetGenericTypeDefinition() ==
typeof(LinkedResourceCollection<>);
}
public override bool CanWriteType(Type type)
{
return type.BaseType == typeof(LinkedResource) ||
type.BaseType.GetGenericTypeDefinition() ==
typeof(LinkedResourceCollection<>);
}
...
}
Этот код сначала определяет в конструкторе поддерживаемые media-типы для этой реализации (application/hal+xml) и переопределяет методы CanReadType и CanWriteType, чтобы указать поддерживаемые .NET-типы, которые должны быть производными от LinkedResource или LinkedResourceCollection. Поскольку это определяется в конструкторе, данная реализация поддерживает только XML-вариацию HAL. Другое средство форматирования могло бы дополнительно реализовать поддержку JSON-вариации.
Сама работа выполняется в методах WriteToStream и ReadFromStream (рис. 7), которые будут использовать соответственно XmlWriter и XmlReader для записи объекта в поток и чтения из потока.
Рис. 7. Методы WriteToStream и ReadFromStream
public override void WriteToStream(Type type, object value,
System.IO.Stream writeStream, System.Net.Http.HttpContent content)
{
var encoding = base.SelectCharacterEncoding(content.Headers);
var settings = new XmlWriterSettings();
settings.Encoding = encoding;
var writer = XmlWriter.Create(writeStream, settings);
var resource = (LinkedResource)value;
if (resource is IEnumerable)
{
writer.WriteStartElement("resource");
writer.WriteAttributeString("href", resource.HRef);
foreach (LinkedResource innerResource in (IEnumerable)resource)
{
// Рекурсивно сериализуем состояние ресурса и ссылки
SerializeInnerResource(writer, innerResource);
}
writer.WriteEndElement();
}
else
{
// Сериализуем один связанный ресурс
SerializeInnerResource(writer, resource);
}
writer.Flush();
writer.Close();
}
public override object ReadFromStream(Type type,
System.IO.Stream readStream, System.Net.Http.HttpContent content,
IFormatterLogger formatterLogger)
{
if (type != typeof(LinkedResource))
throw new ArgumentException(
"Only the LinkedResource type is supported", "type");
var value = (LinkedResource)Activator.CreateInstance(type);
var reader = XmlReader.Create(readStream);
if (value is IEnumerable)
{
var collection = (ILinkedResourceCollection)value;
reader.ReadStartElement("resource");
value.HRef = reader.GetAttribute("href");
var innerType = type.BaseType.GetGenericArguments().First();
while (reader.Read() && reader.LocalName == "resource")
{
// Рекурсивно десериализуем связанный ресурс
var innerResource = DeserializeInnerResource(reader, innerType);
collection.Add(innerResource);
}
}
else
{
// Рекурсивно десериализуем связанный ресурс
value = DeserializeInnerResource(reader, type);
}
reader.Close();
return value;
}
Последний шаг — конфигурирование реализации средства форматирования как части хоста Web API. Этот шаг можно выполнить почти так же, как в ASP.NET или ASP.NET Web API Self-Host, с единственным различием в необходимой реализации HttpConfiguration. Если Self-Host использует экземпляр HttpSelfHostConfiguration, то в ASP.NET, как правило, применяется экземпляр HttpConfiguration, доступный глобально в System.Web.Http.GlobalConfiguration.Configuration. Класс HttpConfiguration предоставляет набор Formatters, в который вы можете ввести свою реализацию средства форматирования. Вот как это делается для ASP.NET:
protected void Application_Start()
{
Register(GlobalConfiguration.Configuration);
}
public static void Register(HttpConfiguration config)
{
config.Formatters.Add(new HalXmlMediaTypeFormatter());
}
Как только средство форматирования сконфигурировано в конвейере ASP.NET Web API, любой контроллер может просто вернуть класс модели, производный от LinkedResource, для сериализации средством форматирования с использованием HAL. В примере с каталогом товар и набор товаров, представляющие каталог, могут быть производными соответственно от LinkedResource и LinkedResourceCollection:
public class Product : LinkedResource
{
public int Id { get; set; }
public string Name { get; set; }
public decimal UnitPrice { get; set; }
}
public class Products : LinkedResourceCollection<Product>
{
}
Контроллер ProductCatalogController, который обрабатывает все запросы для ресурса каталога товаров, теперь может возвращать экземпляры Product и Products, как показано на рис. 8 для метода Get.
Рис. 8. Класс ProductCatalogController
public class ProductCatalogController : ApiController
{
public static Products Products = new Products
{
new Product
{
Id = 1,
Name = "Product 1",
UnitPrice = 5.34M,
Links = new List<Link>
{
new Link { Rel = "add-cart", HRef = "/api/cart" },
new Link { Rel = "self", HRef = "/api/products/1" }
}
},
new Product
{
Id = 2,
Name = "Product 2",
UnitPrice = 10,
Links = new List<Link>
{
new Link { Rel = "add-cart", HRef = "/cart" },
new Link { Rel = "self", HRef = "/api/products/2" }
}
}
};
public Products Get()
{
return Products;
}
}
В этом примере используется формат HAL, но вы можете аналогично подойти и к созданию средства форматирования, применяющего Razor и шаблоны для сериализации моделей в XHTML. Конкретную реализацию MediaTypeFormatter для Razor вы найдете в RestBugs (github.com/howarddierking/RestBugs) — приложении-примере, созданном Говардом Диркингом (Howard Dierking) для демонстрации того, как с помощью ASP.NET Web API можно создавать гипермедийные Web API.
Средства форматирования упрощают расширение вашего Web API новыми media-типами.
Более эффективная поддержка связывания в контроллерах Web API
Что-то явно не так с предыдущим примером ProductCatalogController. Все ссылки «зашиты» в код, что вызвало бы кучу проблем, если бы маршруты часто менялись. Хорошая новость в том, что инфраструктура предоставляет вспомогательный класс System.Web.Http.Routing.UrlHelper для автоматического логического распознавания ссылок по таблице маршрутизации. Экземпляр этого класса доступен через свойство Url базового класса ApiController, поэтому его можно легко использовать в любом методе контроллера. Вот как выглядит определение класса UrlHelper:
public class UrlHelper
{
public string Link(string routeName,
IDictionary<string, object> routeValues);
public string Link(string routeName, object routeValues);
public string Route(string routeName,
IDictionary<string, object> routeValues);
public string Route(string routeName, object routeValues);
}
Методы Route возвращают относительный URL для данного маршрута (например, /products/1), а методы Link — абсолютный URL, который можно использовать в моделях и тем самым избежать их записи непосредственно в код. Метод Link принимает два аргумента: имя маршрута и значения, образующие URL.
На рис. 9 показано, как можно было бы использовать класс UrlHelper в методе Get в предыдущем примере с каталогом товаров.
Рис. 9. Как можно было бы использовать класс UrlHelper в методе Get
public Products Get()
{
var products = GetProducts();
foreach (var product in products)
{
var selfLink = new Link
{
Rel = "self",
HRef = Url.Route("API Default",
new
{
controller = "ProductCatalog",
id = product.Id
})
};
product.Links.Add(selfLink);
if(product.IsAvailable)
{
var addCart = new Link
{
Rel = "add-cart",
HRef = Url.Route("API Default",
new
{
controller = "Cart"
})
};
product.Links.Add(addCart);
}
}
return Products;
}
Ссылка «self» для товара сгенерирована на основе маршрута по умолчанию, используя имя контроллера (ProductCatalog) и идентификатор товара. Ссылка для добавления товара в корзину также сформирована на основе маршрута по умолчанию, но в данном случае имя контроллера — Cart. Как видно на рис. 9, ссылка для добавления товара в корзину связана с ответом по наличию товара (product.IsAvailable). Логика предоставления ссылок клиенту будет весьма сильно зависеть от бизнес-правил, обычно вводимых в действие через контроллеры.
Заключение
Гипермедиа — мощное средство, обеспечивающее независимое развитие клиентов и серверов. Используя ссылки или другие артефакты гипермедиа, такие как формы, предлагаемые сервером на различных этапах, клиенты могут быть успешно отделены от рабочего процесса на серверной стороне, управляющего взаимодействием.