本文章是由機器翻譯。

ASP.NET

使用 ASP.NET Web API 構建超媒體 Web API

Pablo Cibraro

下載代碼示例

超媒體(通常稱為應用程式狀態的引擎 (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 中所示。

The HAL Media Type
圖 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 類型,這些類型必須從 Linked­Resource 或 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 控制器中提供更好的連結支援

以前的 ProductCatalog­Controller 示例肯定有不妥之處。 其中的所有連結都硬編碼了,如果路由經常變化,會令人頭疼不已。 幸好框架提供了名為 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 和產品識別碼 從預設路由生成了產品的連結「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 上關注他。

衷心感謝以下技術專家對本文的審閱:丹尼爾 · 羅斯