MVC

使用 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 和产品 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 上关注他。

衷心感谢以下技术专家对本文的审阅: Daniel Roth