很多时候,我们需要将自定义行为注入到请求处理管道中。在.NET框架项目中,有许多方法可以做到这一点,这取决于你的行为需要发生在管道的哪个位置,以及该行为如何影响管道的其他部分。最通用的注入行为的方法之一是建立一个自定义的HttpModule。虽然功能强大,但HttpModules很难测试,而且不能很好地与你项目的其他代码整合。
在.NET核心中,微软引入了一种新的方式来构建管道行为--中间件。中间件解决了HttpModules的许多挑战,并使构建一个自定义请求管道变得容易。将我们的自定义行为转换为中间件是相当容易的,但也有一些惊喜在等着我们。在这篇文章中,我们将把一个做自定义请求记录的HttpModule转换成自定义中间件,并讨论这个强大的新工具带来的好处和潜在的陷阱。
我们的出发点
首先,让我们先看一下这个HttpModule的样本。这个模块监听所有的请求和响应,并记录调用IP、请求路径、响应状态代码以及响应的字节长度。
public class WebRequestLoggerModule : IHttpModule
{
private readonly ILog logger = LogManager.GetLogger("WebRequest");
public void Init(HttpApplication context)
{
context.BeginRequest += AddContentLengthFilter;
context.EndRequest += LogResponse;
}
private void AddContentLengthFilter(object sender, EventArgs e)
{
var application = (HttpApplication)sender;
application.Response.Filter = new ContentLengthCountingStream(application.Response.Filter);
}
private void LogResponse(object sender, EventArgs eventArgs)
{
var application = (HttpApplication)sender;
var request = application.Request;
var response = application.Response;
var responseStream = response.Filter as ContentLengthCountingStream;
logger.Info($"Calling IP: {request.UserHostAddress} Path: {request.Url.PathAndQuery} Status Code: {response.StatusCode} Length: { responseStream.Length }");
}
public void Dispose() { }
}
这段代码中省略了ContentLengthCountingStream 。事实证明,获得响应长度的唯一方法(在WebAPI和.NET Core中)是覆盖Stream ,并计算写入缓冲区的字节数。还值得注意的是,UserHostAddress 可能不是你实际用户的IP地址,特别是如果你在Kestrel前面运行一个反向代理。这是来自互联网的示例代码--请不要在生产中使用它!
当我们看HttpModules与请求管道的交互方式时,有几个明显的不足之处需要注意。首先,最明显的是缺乏像样的类型。BeginRequest 和EndRequest 都是EventHandlers,这意味着我们得到了彻头彻尾的可怕的签名(object, EventArgs) 。这个来自C# 1.0的小礼物不能很快被废弃。除了使用EventHandler ,BeginRequest 和EndRequest 都是事件,这意味着我们没有办法定义处理事件的顺序。在实践中,这通常是好的,但它确实意味着,如果你需要在处理的最开始或最后发生一些事情,你大多不走运。
中间件基础知识
中间件是.NET核心请求管道的基本构建块。在最基本的情况下,中间件只是一系列嵌套的委托,最后调用你的控制器。与老式的HttpModule 管道可扩展性相比,这提供了一个明显的好处,即更大程度的控制。你可以控制中间件执行的确切顺序,而不是仅仅挂上事件处理程序,并且可以根据请求的状态轻松地缩短执行。这种方法有一些限制,我们将在后面讨论,但总的来说,我对中间件提供的权力和控制非常满意。
在微软的文档中显示的第一个中间件的例子(在其他方面非常好)看起来是这样的。
public class Startup
{
public void Configure(IApplicationBuilder app)
{
app.Use(async (context, next) =>
{
// Do work that doesn't write to the Response.
await next.Invoke();
// Do logging or other work that doesn't write to the Response.
});
app.Run(async context =>
{
await context.Response.WriteAsync("Hello from 2nd delegate.");
});
}
}
app.Use 中定义的 lambda 是最简单的中间件,因为它实际上什么都不做。context 是一个HttpContext ,包含关于请求和响应的信息,与我们 HttpModule 中的HttpApplication 不一样。next 包含一个委托,它要么指向下一个中间件(如果它存在),要么指向控制器。
他们没有告诉你的是(但他们应该告诉你),虽然有可能这样写中间件,但这是一个糟糕的想法。使用内联lambdas来编写你的中间件有三个主要问题。首先,这使得中间件基本上无法测试,因为执行它的唯一方法是通过整个管道实际运行一个请求。其次,它给你的Startup增加了逻辑,而Startup已经太长太复杂了,所以增加超过你严格需要的配置是一个坏主意。最后,它使你无法利用你的IoC容器来注入依赖性。
将我们的HttpModule转换为中间件
幸运的是,有一种不同的方法来构建中间件,它可以解决所有这些问题!让我们用它来转换我们的HttpModule。让我们用它来把我们的HttpModule转换成中间件,执行同样的功能。首先,我们要做一个中间件类。
public class WebLoggingMiddleware
{
private readonly RequestDelegate next;
private readonly ILog log;
public WebLoggingMiddleware(RequestDelegate next, ILog log)
{
this.next = next;
this.log = log;
}
public async Task InvokeAsync(HttpContext context)
{
var wrappedContentStream = new ContentLengthCountingStream(context.Response.Body);
context.Response.Body = wrappedContentStream;
await next(context);
log.Info($"Calling IP: {context.Connection.RemoteIpAddress} Path: {context.Request.Path} Status Code: {context.Response.StatusCode} Length: { wrappedContentStream.Length }");
}
}
让我们来挖掘一下这里的几件事。首先,你会注意到,我们在构造函数中同时注入了一个RequestDelegate 和一个ILog 。这使我们有能力孤立地测试我们的中间件,使用测试替身来模拟我们依赖的行为。然而,需要注意的是,中间件在应用程序启动时被构造一次,所以你不能在构造函数中放入任何与请求相关的依赖。幸运的是,InvokeAsync 方法也是使用IoC容器调用的,所以如果你有任何范围内的依赖,你可以把它们作为参数添加到方法签名中,事情就会顺利进行。
接下来,看一下InvokeAsync ,你会注意到我们在调用await next(context);之前改变了响应。如果我们想在响应中添加自定义头信息,这是一个很好的地方。不过你确实需要在调用next 之前进行这些修改,因为当这个调用返回时,请求已经被序列化了,所以在这一点上响应是只读的。
最后,一个对请求管道完全控制的快乐结果。我们仍然需要用一个ContentLengthCountingStream (实现与.NET框架相同)来包装body,以获得.NET核心中响应的长度。另一方面,由于我们在读取响应值的同一方法中把它添加到响应中,我们可以避免把响应体作为ContentLengthCountingStream 。 虽然承认这不是完全控制的最重要结果,但它仍然是一个不错的好处。
现在我们已经建立并测试了我们的中间件,现在是时候配置它了。通过在Startup 中为我们的configure方法添加一个app.UseMiddleware<T>() ,我们可以将类型化的中间件添加到我们的管道中。
我非常欣赏使用中间件的一点是,中间件操作的顺序是明确的,而且容易配置。对app.UseMiddleware 的每个调用都是按顺序执行的,所以如果你的某些中间件需要绕过执行管道,很容易理解什么被跳过。因为我们要做日志,并且要确保即使是产生异常的请求也被记录下来,所以我们要确保WebLoggingMiddleware 是我们添加到应用程序的第一个中间件。除了日志,你可能希望异常处理是你的第一个中间件,因为你想确保中间件抛出的任何异常都被正确处理。
中间件 构建请求管道的更好方法
中间件可能是我在迁移到.NET核心时最喜欢的变化。突然间,在ASP.NET中构建请求管道的最深奥和不可测试的部分被转化为简单的普通代码。能够将原本与框架紧密相连的代码,转而让它站在自己的立场上,这是一种特别大的解放。这让我想起了从高度侵入性的ORM和数据的基类到微ORM和POCO(Plain Old Class Objects)的过渡。至少在这个例子中,未来是伟大的!"。