领先技术

可查询服务

Dino Esposito

Dino Esposito很多公司将其后端业务服务公开为普通 HTTP 终结点,这种现象越来越普遍。这种类型的体系结构不需要数据库和物理数据库模型相关的信息。客户端应用程序甚至不需要对实体框架等特定于数据库的库进行引用。服务的物理位置无关紧要,您可以将后端服务保留在本地,或将其以透明方式迁移到云中。

作为解决方案架构师或主要开发人员,采用这一策略时,您应该准备好应对两种情况。第一种情况是不具备对服务的内部工作原理的任何访问权限。在这种情况下,您甚至不处于这样一种状态:请求更多或更少数据来优化客户端应用程序的性能。

第二种情况是,您同时还负责维护这些后端服务,并可以在某种程度上影响公共 API。在本文中,我将重点探讨后一种情况。我将讨论以灵活的方式实现可查询服务的特定技术角色。我要使用的技术是基于 ASP.NET Web API 的 OData 服务。本文中讨论的几乎所有内容都适用于现有 ASP.NET 平台和 ASP.NET 5 vNext。

密封的后端服务

在开始设计可查询服务之前,我将简要介绍您无法控制可用服务的第一种情况。您获得了调用这些服务所需的所有详细信息,但无法修改响应的数量和形状。

此类密封服务的密封是有原因的。他们都属于您公司的正式 IT 后端。这些服务是整体体系结构的一部分,不会随意更改。随着更多的客户端应用程序依赖于这些服务,很有可能您的公司正在考虑进行版本控制。不过,一般而言,在实施这些服务的新版本之前,必须有一个令人信服的理由。

如果密封的 API 是关于您正在开发的客户端应用程序的问题,那么您能执行的唯一操作是在其他代理层中封装原始服务。然后,您可以使用高速缓存、新的数据聚合和插入其他数据等任何技巧来达到目的。从体系结构的角度而言,服务结果集之后会从基础结构层上升到域服务层。甚至可能上升到更高的应用程序层(参见图 1)。

从密封的服务到更灵活的应用程序服务
图 1 从密封的服务到更灵活的应用程序服务

API 读取端

现代 Web 应用程序围绕内部 API 而构建。在某些情况下,此 API 将成为公共 API。值得注意的是,要考虑 ASP.NET 5 vNext 推送的体系结构,其中,ASP.NET MVC 和 Razor 引擎提供了生成 HTML 视图的必要基础结构。

ASP.NET Web API 表示用于处理来自客户端(而非浏览器和 HTML 页)的客户端请求的理想基础结构。换句话说,新的 ASP.NET 站点会围绕一组可能密封的后端服务理想地设计为 HTML 的一个薄层。不过,负责 Web 应用程序的团队现在也是后端 API 的所有者,而不是使用者。如果任何人对此有疑问,您会听到他的意见或建议。

大部分使用者 API 问题都涉及返回数据的数量和质量。API 的查询端创建通常最棘手,因为从长远来看,您永远不会知道请求数据和使用数据的方式。API 的命令端通常更加稳定,因为它取决于业务领域和服务。域服务有时会更改,但更改至少会保持在一个不同的、通常较慢的速度。

通常情况下,您有一个基于 API 的模型。API 的查询端往往反映一个模型:API 是具有 REST 还是 RPC 风格。最后,读取 API 的弱点是它返回的数据格式和支持的数据聚合格式。此问题有一个友好名称,即数据传输对象 (DTO)。

创建 API 服务时,通过相同的本机或自定义模型从现有数据模型进行构建,并将其公开到外界。多年以来,软件架构师都以自下而上的方式设计应用程序。他们通常从典型关系数据模型底部开始着手。此模型一直上升到表示层。

根据各种客户端应用程序的需要,此过程中会创建一些 DTO 类,以确保展示可以按正确的格式处理正确的数据。如今,客户端应用程序变得越来越重要,受其影响,软件体系结构和开发方面也发生着变化。虽然构建后端 API 的命令端仍然相对简单,但设计出适合所有可能客户端的单个和通常足够的数据模型则复杂得多。读取 API 的灵活性现在是一个取胜因素,因为您永远不知道您的 API 将面对的是哪些客户端应用程序。

可查询服务

在《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2014 年)一书的最新版中,Andrea Saltarello 和我规范化了一个概念,我们称其为“分层表达式树 (LET)”。LET 背后的理念是应用程序层和域服务尽可能交换 IQueryable<T> 对象。当各层位于同一进程空间中且不需要序列化时,通常会发生这种情况。通过交换 IQueryable<T>,可以将来自筛选器组合和数据投影的任何所需的查询结果推迟到最后一分钟。您可以按每个应用程序(而不是按域级 API 的某些硬编码的形式)进行改写。

LET 的理念与新兴的 CQRS 模式密切相关。这将促使将读取堆栈从命令堆栈中分离。图 2 列出了在您的体系结构中拥有 LET 模式和大量可查询服务的要点。

在最后一分钟查询可查询对象
图 2 在最后一分钟查询可查询对象

LET 的主要优势是您不需要任何 DTO 来跨层传输数据。在某些时候,您仍需要拥有视图模型类,但这又是另外一回事。只要您拥有使用数据填充的 UI,就必须处理视图模型类。视图模型类表示您的用户期待的所需数据布局。这是您将拥有的唯一一组 DTO 类。以物理方式查询数据的级别及以上级别中的所有其他内容都通过 IQueryable 引用提供。

LET 和可查询服务的另一个优势是,生成的查询是应用程序级的查询。它们的逻辑紧密遵循域专家语言。这使得将要求映射到代码并与客户讨论所谓的 Bug 或误解变得更轻松。大多数情况下,快速了解代码可帮助您解释逻辑。例如,LET 查询可能如下所示:

var model = from i in db.Invoices
                        .ForBusinessUnit(buId)
                        .UnpaidInLast(30.Days)
  orderby i.PaymentDueDate
  select new UnpaidViewModel
    {
      ...
    };

根据实体框架根对象的数据库上下文,您将查询所有入站发票,并选择与给定业务部门相关的内容。其中,您将找到到期若干天后仍未付款的发票。

比较好的一点是 IQueryable 引用并不是实际的数据。仅当您为某些 IList 交换 IQueryable 时,才会对数据源执行查询。在此过程中添加的筛选器只是添加到在某一时刻运行的实际查询的 WHERE 子句。如果您处于同一进程空间中,则传输并保存在内存中的数据量是最低限度。

这会如何影响可扩展性?vNext 平台优化的新趋势是,尽量保持精简的 Web 后端。理想情况下为单个层。通过各类 Microsoft Azure Web 角色复制唯一层,从而实现可扩展性。具有 Web 后端的单个层可以让您在任何地方使用 IQueryable,而不需要大量的 DTO 类。

实现可查询服务

在前面的代码段中,我假定您的服务作为围绕一些实体框架数据库上下文的一个层来实现。但这仅仅是一个例子。您也可以将实际数据提供程序完全封装在 ASP.NET Web API 外层下。这样一来,您就具有了表示域服务功能的 API 优势,并且仍可以通过 HTTP 到达,从而将客户端从特定平台和技术中分离出来。

然后,您可以创建一个 Web API 类库,并在一些 ASP.NET MVC 站点、Windows 服务、甚至是一些自定义托管应用程序中进行托管。在 Web API 项目中,您将创建派生自 ApiController 的控制器类并公开返回 IQueryable<T> 的方法。最后,通过 EnableQuery 属性修饰每个 IQueryable 方法。现已过时的 Queryable 属性同样也起作用。这里的关键因素是,EnableQuery 属性允许您将 OData 查询追加到所请求的 URL,如下所示:

[EnableQuery]
public IQueryable<Customer> Get()
{
  return (from c in db.Customers select c);
}

这段基本代码表示您的 API 的核心内容。它本身不返回任何数据。它允许客户端定制所需的所有返回数据。请查看图 3 中的代码,并考虑将其作为客户端应用程序中的代码。

图 3 客户端应用程序可以定制返回的数据

var api = "api/customers?$select=LastName";
var request = new HttpRequestMessage()
{
  RequestUri = new Uri(api)),
    Method = HttpMethod.Get,
};
var client = new HttpClient();
var response = client.SendAsync(request).Result;
if (response.IsSuccessStatusCode)
{
  var list = await
    response.Content.ReadAsAsync<IEnumerable<Customer>>();
  // Build view model object here
}

URL 中的 $select 约定将确定客户端接收的数据投影。您可以使用 OData 查询语法的强大功能定制查询。有关详细信息,请参阅 bit.ly/15PVBXv

例如,一个客户端可能只请求一小部分列。其他客户端或同一客户端中的其他屏幕可能会查询较大的数据块。在此过程中,如果不接触 API 和创建大量 DTO,就会发生这种情况。您只需使用 OData 可查询 Web API 服务和最终视图模型类。传输的数据保持在仅返回筛选的字段的最低要求。

这包含几个重要方面。首先,OData 是一个详细的协议,否则无法赋予其角色功能。这意味着,当您应用 $select 投影时,JSON 负载仍将列出原始 IQueryable<T> 中的所有字段(Get 方法中原始 Customer 类的所有字段)。但是,只有指定的字段才会保留一个值。

要考虑的另一点是区分大小写。这会影响您用于将 WHERE 子句添加到查询的 $filter 查询元素。您可能想要调用 Tolower 或 Toupper OData 函数(如果您使用的 OData 客户端库支持)来规范化字符串之间的对比。

总结

坦白地讲,我从不认为 OData 值得审慎考虑,直到我发现作为后端 API 的所有者,我自己已处于从同一数据模型返回不同 DTO 的请求风暴中。每个请求似乎都是合理的,并且都是因为非常伟大的“性能改进”原因而发出请求。

在某些时候,这些客户端执行的所有操作在很大程度上似乎是在按查询常规数据库表的同一方式“查询”后端。然后,我将后端服务更新为公开 OData 终结点,让每个客户端仅下载感兴趣的字段,这就为它们提供了灵活性。

每个 IQueryable 方法的类型 T 非常重要。它可能是您在物理模型中的同一类型 T,也可能不是。它可匹配普通的数据库表,也可以由在服务器端上完成的数据聚合生成,并对客户端透明。不过,当您应用 OData 时,就允许客户端查询已知的单个实体 T 的数据集,那么,为什么不试一试?


Dino Esposito 是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2014 年)和《Programming ASP.NET MVC 5》(Microsoft Press,2014 年)的合著者。作为 JetBrains 的 Microsoft .NET Framework 和 Android 平台的技术推广人员,Esposito 经常在全球行业活动中发表演讲,并在 software2cents.wordpress.com 上以及 twitter.com/despos 上的推文中分享他对于软件的愿景。

衷心感谢以下技术专家对本文的审阅:Jon Arne Saeteras