2019 年 6 月

第 34 卷,第 6 期

[领先技术]

ASP.NET Core 管道再探

作者 Dino Esposito | 2019 年 6 月

Dino Esposito几乎任何服务器端处理环境都有自己的直通组件管道,用于检查、重路由或修改传入请求和传出响应。经典 ASP.NET 围绕 HTTP 模块理念进行排列,而 ASP.NET Core 采用基于中间件组件的更现代的体系结构。最终目的是相同的 - 允许可配置的外部模块以请求(以及稍后的响应)在服务器环境中传递的方式进行干预。中间件组件的主要目的是以某种方式改变和筛选数据流(在某些特定情况下,只是使请求短路,停止任何进一步的处理)。

ASP.NET Core 管道自框架 1.0 版发布以来几乎没有变化,但即将发布的 ASP.NET Core 3.0 邀请了一些关于当前体系结构的评论,这些评论在很大程度上被忽略了。因此,在本文中,我将重新讨论 ASP.NET Core 运行时管道的整体功能,并着重介绍 HTTP 终结点的角色和可能的实现。

适用于 Web 后端的 ASP.NET Core

特别是在过去几年,构建前端和后端完全解耦的 Web 应用程序已经变得相当普遍。因此,大多数 ASP.NET Core 项目现在都是简单的没有 UI 的 Web API 项目,仅为单页面和/或移动应用程序提供 HTTP 外观,大多数情况下,这些应用通过 Angular、React、Vue 及其移动对等项构建而成。

当你意识到这一点,将出现一个问题:在不使用任何 Razor 工具的应用程序中,绑定到 MVC 应用程序模型是否仍有意义?MVC 模型并不是免费的,事实上,在某种程度上,一旦停止使用控制器来提供操作结果,它甚至可能不是最轻量级的选项。进一步追问:如果大部分 ASP.NET Core 代码编写只是为了返回 JSON 有效负载,那么操作结果概念本身是否绝对必要?

带着这些想法,让我们回顾一下 ASP.NET Core 管道和中间件组件的内部结构,以及可以在启动期间绑定到的内置运行时服务列表。

启动类

在任何 ASP.NET Core 应用程序中,一个类被指定为应用程序引导程序。大多数情况下,此类采用名称“Startup”。该类在 Web 主机配置中声明为启动类,Web 主机通过反射实例化并调用该类。该类可以有两种方法 - ConfigureServices(可选)和 Configure。在第一个方法中,你将接收当前(默认)运行时服务列表,并需要添加更多服务来为实际应用程序逻辑做准备。在 Configure 方法中,为默认服务和显式请求支持应用程序的服务进行配置。

Configure 方法接收至少一个应用程序生成器类实例。可以将此实例视为传递给代码的 ASP.NET 运行时管道的工作实例,以便根据需要进行配置。一旦 Configure 方法返回,管道工作流将配置完毕,并用于执行从连接的客户端发送的任何进一步请求。图 1 提供了 Startup 类的 Configure 方法的实现示例。

图 1 Startup 类中 Configure 方法的基本示例

public void Configure(IApplicationBuilder app)
{
  app.Use(async (context, nextMiddleware) =>
  {
    await context.Response.WriteAsync("BEFORE");
    await nextMiddleware();  
    await context.Response.WriteAsync("AFTER");
  });
  app.Run(async (context) =>
  {
    var obj = new SomeWork();
    await context
      .Response
      .WriteAsync("<h1 style='color:red;'>" +
                   obj.SomeMethod() +
                  "</h1>");
  });
}

Use 扩展方法是用于将中间件代码添加到其他空管道工作流的主要方法。注意,添加的中间件越多,服务器需要执行更多的工作来满足任何传入请求。最小的是管道,最快的是客户端至第一字节的时间 (TTFB)。

可以使用 lambdas 或临时中间件类向管道添加中间件代码。这完全由你来决定:lambda 更直接,但类(最好是一些扩展方法)将使整个过程更易于阅读和维护。中间件代码获取请求的 HTTP 上下文和对管道中下一个中间件的引用(如果有的话)。图 2 显示了各种中间件组件链接在一起的总体视图。

ASP.NET Core 运行时管道
图 2 ASP.NET Core 运行时管道

每个中间件组件都有两次机会介入正在进行的请求的生命周期。它可以预先处理从先前注册运行的组件链接收到的请求,然后它会被链中的下一个组件取代。当链中的最后一个组件有机会预先处理请求时,请求会被传递给终止中间件,以便进行实际处理,目的是生成具体输出。之后,组件链按相反顺序返回,如图 2 所示,每个中间件都有第二次处理的机会 - 不过,这一次将是后处理操作。在图 1 代码中,预处理代码与后处理代码的分割线为:

await nextMiddleware();

终止中间件

在图 2 所示的体系结构中,关键是终止中间件的角色,它是 Configure 方法底部的代码,用于终止链并处理请求。所有演示 ASP.NET Core 应用程序都有一个终止的 lambda,如下所示:

app.Run(async (context) => { ... };

lambda 接收 HttpContext 对象,并在应用程序上下文中执行它应执行的任何操作。

实际上,有意不生成到下一个组件的中间件组件将终止链,进而导致响应被发送到请求的客户端。UseStaticFiles 中间件就是一个很好的例子,它在指定的 Web 根文件夹下提供静态资源,并终止请求。另一个示例是 UseRewriter,它可以命令客户端重定向到新 URL。在没有终止中间件的情况下,请求很难在客户端上产生一些可见输出,尽管响应仍然通过运行中间件(例如,通过添加 HTTP 标头或响应 cookie)以修改后的形式发送。

还有两个专用的中间件工具也可用于短路请求:app.Map 和 app.MapWhen。前者检查请求路径是否匹配参数并运行自己的终止中间件,如下所示:

app.Map("/now", now =>
{
  now.Run(async context =>
  {
    var time = DateTime.UtcNow.ToString("HH:mm:ss");
    await context
      .Response
      .WriteAsync(time);
  });
});

而后者仅在验证了指定布尔条件后才运行自己的终止中间件。布尔条件来自接受 HttpContext 的函数计算。图 3**** 中的代码提供了一个非常精简的小型 Web API,它仅为单个终结点提供服务,并且在没有任何类似于控制器类项的情况下执行该操作。

图 3 极小型 ASP.NET Core Web API

public void Configure(IApplicationBuilder app,
                      ICountryRepository country)
{
  app.Map("/country", countryApp =>
  {
    countryApp.Run(async (context) =>
    {
      var query = context.Request.Query["q"];
      var list = country.AllBy(query).ToList();
      var json = JsonConvert.SerializeObject(list);
      await context.Response.WriteAsync(json);
    });
  });
  // Work as a catch-all
  app.Run(async (context) =>
  {
    await context.Response.WriteAsync("Invalid call");
  }
});

如果 URL 匹配 /country,终止中间件将从查询字符串中读取一个参数,并安排对存储库的调用,以获得匹配国家/地区列表。然后,对列表对象以 JSON 格式直接手动序列化到输出流。通过添加一些其他映射路由,你甚至可以扩展 Web API。再简单不过了。

MVC 怎么样?

在 ASP.NET Core 中,整个 MVC 机制作为黑盒运行时服务提供。只需绑定到 ConfigureServices 方法中的服务,并在 Configure 方法中配置其路由,如下面的代码所示:

public void ConfigureServices(IServiceCollection services)
{
  // Bind to the MVC machinery
  services.AddMvc();
}
public void Configure(IApplicationBuilder app)
{
  // Use the MVC machinery with default route
  app.UseMvcWithDefaultRoute();
  // (As an alternative) Use the MVC machinery with attribute routing
  app.UseMvc();
}

此时,如果你打算提供 HTML,欢迎填充常用的 Controllers 文件夹,甚至 Views 文件夹。注意在 ASP.NET Core 还可以使用 POCO 控制器,它是简单的 C# 类,经修饰后可以被识别为控制器,并与 HTTP 上下文断开连接。

MVC 机制是终止中间件的另一个很好的示例。一旦请求被 MVC 中间件捕获,一切都在它的控制之下,管道会突然终止。

有趣的是,MVC 机制在内部运行自己的自定义管道。它不以中间件为中心,但它仍然是一个完整的运行时管道,控制请求如何路由到控制器操作方法,并最终将生成的操作结果呈现到输出流。MVC 管道由在各个控制器方法前后运行的各种类型的操作筛选器(操作名称选择器、授权筛选器、异常处理程序、自定义操作结果管理器)组成。在 ASP.NET Core 中,内容协商也隐藏在运行时管道中。

从更深刻的角度来看,整个 ASP.NET MVC 机制看起来就像是被固定在最新的、经重新设计的、以中间件为中心的 ASP.NET Core 管道的上方。就像 ASP.NET Core 管道和 MVC 机制是不同类型的实体,只是以某种方式连接在一起。总体情况与 MVC 被固定在现已被摒弃的 Web 窗体运行时之上的方式没有太大不同。实际上,在该上下文中,如果处理请求无法与物理文件匹配(很可能是 ASPX 文件),MVC 就会通过专用的 HTTP 处理程序启动。

这有问题吗?很可能没有。也许问题还没出现!

将 SignalR 置于循环中

将 SignalR 添加到 ASP.NET Core 应用程序时,需要做的就是创建一个中心类来公开终结点。有趣的是中心类可能与控制器完全不相关。不需要 MVC 来运行 SignalR,但中心类的行为类似于外部请求的前端控制器。从中心类公开的方法可以执行任何工作 - 甚至与框架的跨应用通知性质无关的工作,如图 4 所示。

图 4 从中心类公开方法

public class SomeHub : Hub
{
  public void Method1()
  {
    // Some logic
    ...
    Clients.All.SendAsync("...");
  }
  public void Method2()
  {
    // Some other logic
    ...
    Clients.All.SendAsync("...");
  }
}

能看到图片吗?

SignalR 中心类可以被视为一个控制器类,不需要整个 MVC 机制,非常适合无 UI(或者更确切地说,是 Razor)响应。

将 gRPC 置于循环中

在 3.0 版本中,ASP.NET Core 还提供了对 gRPC 框架的本机支持。该框架设计为与 RPC 准则一起使用,是围绕接口定义语言的一层代码,接口定义语言完全定义终结点,并且能够使用 HTTP/2 上的 Protobuf 二进制序列化触发连接方之间的通信。从 ASP.NET Core 3.0 角度来看,gRPC 还是另一个可调用外观,可以进行服务器端计算并返回值。下面介绍了如何启用 ASP.NET Core 服务器应用程序以支持 gRPC:

public void ConfigureServices(IServiceCollection services)
{
  services.AddGrpc();
}
public void Configure(IApplicationBuilder app)
{
  app.UseRouting(routes =>
    {
      routes.MapGrpcService<GreeterService>();
    });
}

另请注意全局路由的使用,以使应用程序支持没有 MVC 机制的路由。可以将 UseRouting 视为定义 app.Map 中间件块的一种更结构化的方法。

前面代码的净效果是支持从客户端应用程序到映射服务的 RPC 样式调用,即 Greeter­Service 类。有趣的是,GreeterService 类在概念上等同于 POCO 控制器,只不过它不需要被识别为控制器类,如下所示:

public class GreeterService : Greeter.GreeterBase
{
  public GreeterService(ILogger<GreeterService> logger)
  {
  }
}

基类(GreeterBase 是一个封装在静态类中的抽象类)包含执行请求/响应通信所需的管道。gRPC 服务类与 ASP.Net Core 基础结构完全集成,可以注入外部引用。

底线

特别是随着 ASP.NET Core 3.0 的发布,将会出现另外两种情况,在这些情况下,使用无 MVC 的控制器样式外观将会很有帮助。SignalR 有一个中心类,gRPC 有一个服务类,但关键是它们在概念上是相同的,必须在不同的场景以不同方式实现这些类。MVC 机制已或多或少被移植到 ASP.NET Core,因为它最初是为经典 ASP.NET 设计的,并围绕控制器和操作结果维护自己的内部管道。与此同时,随着 ASP.NET Core 越来越广泛地被用作普通后端服务提供程序(不支持视图),对 HTTP 终结点可能统一的 RPC 样式外观的需求也在增长。


Dino Esposito 在他 25 年的职业生涯中撰写了超过 20 本的书籍和 1000 篇的文章。Esposito 不仅是舞台剧《事业中断》的作者,还是 BaxEnergy 的数字策略分析师,正忙于编写有助于建设环保世界的软件。可以在 Twitter 上关注他 (@despos)。

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


在 MSDN 杂志论坛讨论这篇文章