超媒體(通常稱為應用程序狀態的引擎 (HATEOAS))是具象狀態傳輸 (REST) 的主要限制之一。有一種觀念認為超媒體項目(如鏈接或表單)可用於說明客戶端如何與一組 HTTP 服務交互。這迅速成為一個有趣的概念,在開發可演變的 API 設計時會用到它。這與我們通常與 Web 交互的方式沒有任何不同。我們通常記住網站主頁的一個入口點或 URL,然后使用鏈接瀏覽網站的各個不同區域。我們還使用表單,它附帶預定義的操作或 URL 以提交網站執行某些操作所需的數據。
開發人員傾向在服務中提供所有支持的方法的靜態描述,從正式約定(如 SOAP 服務中的 Web 服務描述語言 (WSDL))到非超媒體 Web API 中的簡單文檔都是如此。這樣做的主要問題是靜態 API 描述將客戶端與服務器緊密關聯。簡而言之,它阻止了可演變性,因為 API 描述中的任何更改都可能中斷所有現有客戶端。
這在可以預先控制和了解客戶端應用程序數目的企業中暫時不會引起問題。但是,當潛在客戶端數呈指數級增長時(就像當前,數以千計的第三方應用程序在多個設備上運行),這樣做就不合適了。簡單地從 SOAP 遷移到 HTTP 服務並不能保證解決此問題。例如,如果在要計算 URL 的客戶端上提供一些知識,問題仍會存在,甚至沒有 WSDL 之類的任何顯式約定。超媒體可以幫助客戶端屏蔽任何服務器更改。
應用程序狀態工作流也應位於服務器端,它確定客戶端接下來可以做什么。假定資源中的一個操作僅對指定狀態可用,該邏輯是否應駐留在任意可能的 API 客戶端?肯定不行。服務器應始終控制可以對資源執行什么操作。例如,如果取消采購訂單 (PO),就不應允許客戶端應用程序提交該 PO,這意味着在發送到客戶端的響應中應無法使用提交該 PO 的鏈接或表單。
超媒體應運而生
鏈接始終是 REST 體系結構的重要組件。當然,鏈接在諸如瀏覽器的用戶界面上下文中很常見;例如,考慮采用“參見詳細信息”鏈接來獲取目錄中指定產品的詳細信息。但是沒有用戶界面或用戶交互的計算機到計算機情形怎么辦呢?我們認為,您也可以在這些情形中使用超媒體項目。
使用這個新方法后,服務器不僅僅返回數據。它返回數據和超媒體項目。超媒體項目為客戶端提供了一種方法,使它可以根據服務器應用程序工作流的狀態來確定可以在指定時間點執行的操作集合。
這是通常區分常規 Web API 和支持 REST 的 API 的一處,但是還存在適用的其他限制,因此在大多數情況下討論 API 是否支持 REST 可能沒有意義。我們要關注的是 API 能否正確將 HTTP 作為應用程序協議並盡可能利用超媒體。通過啟用超媒體,您可以創建可自我發現的 API。這沒有為不提供文檔找借口,但是 API 在可更新性方面更靈活了。
可以使用哪些超媒體項目主要由所選的媒體類型決定。我們當前用於構建 Web API 的很多媒體類型(如 JSON 或 XML)和 HTML 一樣,不提供表示鏈接或表單的內置概念。您可以通過定義表示超媒體的方式來利用這些媒體類型,但是這要求客戶端了解超媒體語義在其上是如何定義的。相比之下,諸如 XHTML (application/xhtml+xml) 或 ATOM (application/atom+xml) 的媒體類型已支持其中的一些超媒體項目(如鏈接或表單)。
在 HTML 中,一個鏈接由三個部分組成: 一個指向 URL 的“href”屬性,一個說明鏈接與當前資源關系的“rel”屬性和一個可選的“type”屬性(用於指定要求的媒體類型)。例如,如果要使用 XHTML 公開目錄中的產品列表,資源負載可能類似於圖 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 庫分析會容易很多。而且作為負載的一部分,包含了一個錨點 (a) 元素,表示用於將該項添加到當前用戶購物車的鏈接。通過查看該鏈接,客戶端可以從 rel 屬性推斷其用法(添加新項),並將 href 用於對該資源 (/cart) 執行一個操作。請注意,鏈接由服務器根據其業務工作流來生成,因此客戶端不需要對任何 URL 進行硬編碼或推斷任何規則。這也提供了在運行時修改工作流的新機會而不影響現有客戶端。如果目錄中的任意產品缺貨,服務器只需要忽略用於將該產品添加到購物車的鏈接即可。從客戶端角度看,該鏈接不可用,因此無法訂購該產品。服務器端可能應用了與該工作流有關的更復雜的規則,但是客戶端根本意識不到這點,因為它唯一關注的事情是該鏈接不存在。由於超媒體和鏈接,客戶端與服務器端的業務工作流已取消關聯。
而且,可以使用超媒體和鏈接改進 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>
盡管現有客戶端可能忽略該鏈接並不受這個新功能的影響,但是較新的客戶端可以立即開始使用該功能。這樣,考慮為您的 Web API 提供單個入口點或根 URL 也就不足為奇了,該入口點或根 URL 包含發現其余功能的鏈接。例如,您可以具有一個 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 屬性,這暗含一些有關如何處理用 href 屬性表示的該 URL 的帶外知識。客戶端應使用 HTTP POST 還是 HTTP GET?如果它使用 POST,應在請求主體中包含什么數據?盡管所有知識可能記錄在某處,但是如果客戶端可以實際發現該功能不更好嗎?對於所有這些問題,使用 HTML 表單可以解決,它有很多意義。
操作中的表單
使用瀏覽器與 Web 交互時,通常使用表單表示操作。在產品目錄示例中,按“添加到購物車”鏈接暗示將 HTTP GET 發送到服務器,它將返回一個可用於將產品添加到購物車的 HTML 表單。該表單可以包含一個帶 URL 的“action”屬性、一個表示 HTTP 方法的“method”屬性和一些可能要求用戶輸入的輸入字段,還包含可讀的繼續操作的說明。
您可以在計算機到計算機情形中做同樣的事情。如果不想通過人工與表單交互,您可能需要運行 JavaScript 或 C# 的應用程序。在產品目錄中,用於訪問第一個產品的“add-cart”鏈接的 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 將此表單提交到 action 屬性中指定的 URL。服務器還可以在表單中包含其他信息,例如,包含一個偽造標記以避免跨站點請求偽造 (CSRF) 攻擊或對預先為服務器填充的數據進行簽名。
此模型允許任意 Web API 通過基於不同因素(如用戶權限或客戶端要使用的版本)提供新表單來自由演變。
用於 XML 和 JSON 的超媒體?
如我在前文中所述,XML (application/xml) 和 JSON (application/json) 的通用媒體類型沒有對超媒體鏈接或表單的內置支持。盡管可以使用域特定的概念(如“application/vnd-shoppingcart+xml”)擴展這些媒體類型,但是這要求新客戶端了解在新類型中定義的所有語義(並還可能衍生媒體類型),因此一般不這樣做。
正因為如此,有人提出了使用鏈接語義擴展 XML 和 JSON 的新媒體類型建議,它名為超文本應用程序語言 (HAL)。該草案在 stateless.co/hal_specification.html 上公布,它簡單定義一個使用 XML 和 JSON 表示超鏈接和嵌入資源(數據)的標准方式。HAL 媒體類型定義包含一組屬性、一組鏈接和一組嵌入資源的資源,如圖 2 中所示。
圖 2 HAL 媒體類型
圖 3 顯示一個示例,它說明產品目錄在同時使用 XML 和 JSON 表示形式的 HAL 中是什么樣子。圖 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 支持格式化程序的概念。格式化程序實現形式知道如何處理特定媒體類型,以及如何將它序列化或反序列化為具體的 .NET 類型。過去在 ASP.NET MVC 中對新媒體類型的支持十分有限。只有 HTML 和 JSON 被視為有效成員並在整個堆棧中獲得完全支持。此外,沒有用於支持內容協商的一致模型。您可以通過提供自定義 ActionResult 實現來支持響應消息的不同媒體類型格式,但是它不清楚如何引入新媒體類型來反序列化請求消息。利用具有新的模型綁定程序或值提供程序的模型綁定基礎結構通常可以解決此問題。幸運的是,這種不一致性在 ASP.NET Web API 中已通過引入格式化程序得到解決。
每個格式化程序從基類 System.Net.Http.Formatting.MediaTypeFormatter 派生並重寫方法 CanReadType/ReadFromStreamAsync 以支持反序列化,重寫方法 CanWriteType/WriteToStreamAsync 以支持將 .NET 類型序列化為指定的媒體類型格式。
圖 5 顯示 MediaTypeFormatter 類的定義。
圖 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 方法依賴任務並行庫 (TPL) 來執行異步操作,因此它們返回 Task 實例。如果您要顯式使格式化程序實現同步工作,基類 BufferedMediaTypeFormatter 將在內部為您執行此操作。此基類提供您可以在實現中重寫的兩個方法 SaveToStream 和 ReadFromStream,它們是 SaveToStreamAsync 和 ReadFromStreamAsync 的同步版本。
開發用於 HAL 的 MediaTypeFormatter
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
- {
- // Rest of the collection implementation
- }
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<>);
- }
- ...
- }
該代碼首先在構造函數中定義支持的此實現的媒體類型(“application/hal+xml”),然后重寫 CanReadType 和 CanWriteType 方法以指定支持的 .NET 類型,這些類型必須從 LinkedResource 或 LinkedResourceCollection 派生。因為已在構造函數中定義,此實現只支持 HAL 的 XML 變體。還可以實現另一個格式化程序來支持 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)
- {
- // Serializes the resource state and links recursively
- SerializeInnerResource(writer, innerResource);
- }
- writer.WriteEndElement();
- }
- else
- {
- // Serializes a single linked resource
- 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")
- {
- // Deserializes a linked resource recursively
- var innerResource = DeserializeInnerResource(reader, innerType);
- collection.Add(innerResource);
- }
- }
- else
- {
- // Deserializes a linked resource recursively
- value = DeserializeInnerResource(reader, type);
- }
- reader.Close();
- return value;
- }
最后一步是將格式化程序實現作為 Web API 宿主的一部分配置。此步驟幾乎可以用與在 ASP.NET 或 ASP.NET Web API 自托管中相同的方式來實現,只是所需的 HttpConfiguration 實現不同。盡管自托管使用 HttpSelfHostConfiguration 實例,ASP.NET 通常使用在 System.Web.Http.GlobalConfiguration.Configuration 中全局可用的 HttpConfiguration 實例。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 管道中配置格式化程序后,任何控制器使用 HAL 都可以簡單地返回一個模型類,該模型類從格式化程序要序列化的 LinkedResource 派生。對於產品目錄實例,產品和表示目錄的產品集合可以分別從 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 現在可以為 Get 方法返回 Product 和 Products 的實例(如圖 8 中所示)。
圖 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 的模板。您在 RestBugs 中可以找到用於 Razor 的 MediaTypeFormatter 的具體實現,該示例應用程序由 Howard Dierking 創建,演示如何使用 ASP.NET Web API 來創建超媒體 Web API,網址為 github.com/howarddierking/RestBugs。
格式化程序使您可以輕松使用新媒體類型擴展 Web API。
在 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(可以在模型中使用該 URL 來避免硬編碼)。Link 方法接收兩個變量: 路由名稱和要構成 URL 的值。
圖 9 顯示對於以前的產品目錄示例,如何在 Get 方法中使用 UrlHelper 類。
圖 9 如何在 Get 方法中使用 UrlHelper 類
- 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;
- }
已使用控制器名稱 ProductCatalog 和產品 ID 從默認路由生成了產品的鏈接“self”。還從默認路由中生成了用於將產品添加到購物車的鏈接,只是使用的控制器名稱為 Cart。如圖 9 中所示,用於將產品添加到購物車的鏈接根據產品可用性 (product.IsAvailable) 與響應關聯。向客戶端提供鏈接的邏輯主要依賴於通常在控制器中實施的業務規則。
總結
超媒體的功能很強大,允許客戶端和服務器獨立演變。通過在不同階段使用服務器提供的鏈接或其他超媒體項目(如表單),客戶端可以成功與驅動交互的服務器業務工作流取消關聯。
Pablo Cibraro 是國際上公認的專家,在使用 Microsoft 技術設計和實現大型分布式系統方面擁有超過 12 年的豐富經驗。 他是互聯系統 MVP。 最近 9 年中,Cibraro 幫助眾多 Microsoft 團隊開發了一些工具和框架,以便於使用 Web 服務、Windows Communication Foundation、ASP.NET 和 Windows Azure 構建面向服務的應用程序。 他的博客地址是 weblogs.asp.net/cibrax,您可以在 Twitter twitter.com/cibrax 上關注他。