中间件(middleware)是ASP.NET Core中的核心组件,ASP.NET CoreMVC框架、响应缓存、用户身份验证、CORS、Swagger等重要的框架功能都是由ASP.NET内置的中间件提供的。
我们也可以开发自定义的中间件来提供额外的功能。
虽然对于大部分开发人员来讲,不需要开发自定义的中间件,但是了解中间件的原理能够帮助我们更好地使用ASP.NET Core。
1. 什么是中间件
我们知道,在浏览网站或者使用手机APP加载内容的时候,浏览器或者手机APP其实在向服务器发送HTTP请求。
服务器在收到HTTP请求后会对用户的请求进行一系列的处理,比如检查请求的身份验证信息、处理请求报文头、检查是否存在对应的服务器端响应缓存、找到和请求对应的控制器类中的操作方法等。
当控制器类的操作方法执行完成后,服务器也会对响应进行一系列的处理,比如保存响应缓存、设置缓存报文头、设置CORS报文头、压缩响应内容等。
这一些列操作如果全部都硬编码在ASP.NET Core中,会使得代码的耦合度太高,无法做到按需组装处理逻辑。
因此ASP.NET Core基础框架只完成HTTP请求的调度、报文的解析等必要的工作,其他可选的工作都由不同的中间件来提供,如图所示:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-1024x353.png)
广义上来讲,中间件指的是系统软件和应用软件之间连接的软件,以便于软件之间的沟通,比如Web服务器、Redis服务器等都可以称作中间件。
狭义上来将,ASP.NET Core中的中间件则指的是一个组件。
每个中间件由前逻辑、next、后逻辑3个部分组成,前逻辑为第一段要执行的逻辑代码,next为指向下一个中间件的调用,后逻辑为从下一个中间件返回所执行的逻辑代码。
每个HTTP请求都要经历一系列中间件的处理,每个中间件对请求进行特定的处理后,再将其转到下一个中间件,最终的业务逻辑代码执行完成后,响应的内容也会按照请求处理的相反顺序进行处理,然后形成HTTP响应报文返回给客户端。
这些中间件组成一个管道(pipeline),整个ASP.NET Core的执行过程就是HTTP请求和响应按照中间件组装的顺序在中间件之间流转的过程。
开发人员可以对组成管道的中间件按照需要进行自由组合,比如调整中间件的顺序、添加或者删除中间件、自定义中间件等。
2. 中间件的三个概念
要进行中间件的开发,我们需要先了解3个重要的概念:Map、Use和Run。
Map用来定义一个管道可以处理哪些请求,Use和Run用来定义管道,一个管道由若干个Use和一个Run组成,每个Use引入一个中间件,而Run用来执行最终的核心应用逻辑。
Map、Use和Run的关系如下图所示:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-1-1024x782.png)
在上面的例子中,当用户请求/test1这个路径的时候,请求就被放到”管道1″进行处理,经过两个Use引入的中间件,最后在Run中执行完请求。
一个管道中可以包含多个Use,一般只包含一个Run,而且Run被放到最后,因为一个Use引入的中间件可以把请求转给下一个中间件,但是一旦执行Run,处理就终止了。
也就是说,Map是用来引入请求的,请求来到管道之后,由组成管道的多个Use负责对请求进行预处理及请求处理完成后的扫尾工作,Run负责主要的业务规则。
3. 简单演示中间件
本小节,我们将通过简单的案例了解Map、Use和Run在ASP.NET Core中的不同作用。
如果我们创建一个ASP.NET Core MVC 或者 ASP.NET Core Web API 项目,向导会自动帮我们创建模板代码,这些模板代码中会自动帮我们引入一些中间件。
为了能够更清晰地了解中间件,我们创建一个空的ASP.NET Core项目,然后手动添加中间件。
首先,在Visual Studio新建一个项目,在项目创建向导中选择ASP.NET Core 空。
接下来,修改Program.cs文件的内容,如以下代码所示:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Map("/test", async appbuilder =>
{
appbuilder.Use(async (context, next) =>
{
context.Response.ContentType = "text/html";
await context.Response.WriteAsync("1 Start<br/>");
await next.Invoke();
await context.Response.WriteAsync("1 End<br/>");
});
appbuilder.Use(async (context, next) =>
{
await context.Response.WriteAsync("2 Start<br/>");
await next.Invoke();
await context.Response.WriteAsync("2 End<br/>");
});
appbuilder.Run(async context =>
{
await context.Response.WriteAsync("Hello Middleware <br/>");
});
});
app.Run();
在第5行代码中,使用Map定义了所有对/test路径的请求都由第8~44行代码定义的管道来处理;
在管道内部,请求会由第8行、第24行代码引入的两个中间件来处理;
管道中Use的声明顺序就是中间件的执行顺序,一个请求到来的时候,会依次第8行、第24行代码中的两个中间件,最后执行第39行代码中的Run;
在中间件中,我们可以使用await next.Invoke()来执行下一个中间件;
由于Run不会再把请求向后传递,因此Run中不需要也不能够再执行next.Invoke之类的逻辑;
Run的代码执行结束后,响应会按照请求的相反顺序执行每个Use中await next.Invoke()之后的代码,因此Run执行结束后,会依次执行第34行、第13行代码;
最后程序把响应输出给客户端。
需要注意的是,按照微软的建议,如果我们在一个中间件中使用context.Response.WriteAsync等方式向客户端发送响应,我们就不能再执行next.Invoke把请求转到其他中间件了。
因为其他中间件中有可能对Response进行了更改,比如修改响应状态码、修改报文头或者向响应报文中写入其他数据,这样就会造成响应报文体被损坏的问题。
因此,在上面代码中的中间件中,我们在向报文体中写入内容后,又执行next.Invoke是不推荐的行为,我们这样做只是为了演示而已。
最后,启动项目,并且在浏览器中访问/test路径,程序运行结果如下图:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-2.png)
可以看出,程序运行结构和我们分析的一致。
如果Use中定义的中间件代码比较简单的话,可以用上面的代码来编写中间件的代码;
如果定义中间件的代码比较复杂,或者需要重复使用一个中间件的话,最好把中间件的代码放到一个单独的类中,这样的类我们称之为”中间件类”。
中间件类是一个普通的.NET类,它不需要继承任何父类或实现任何接口,但是这个类需要有一个构造方法,构造方法至少要有一个RequestDelegate类型的参数,这个参数用来指向下一个中间件。
这个类还需要定义一个名字为Invoke或InvokeAsync的方法,方法中至少有一个HttpContext类型的参数,方法的返回值必须是Task类型。
中间件类的构造方法和Invoke方法还可以定义其他参数,其他参数会通过依赖注入自动赋值。
下面开发一个简单的中间件类,这个中间件类会检查请求中是否有password为123的查询字符串,而且会把请求报文体按照JSON格式尝试解析为dynamic类型的对象,并且把dynamic对象放入context.Items中供后续的中间件或者Run使用,如下面代码所示:
public class CheckAndParsingMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
//获取查询字符串中password的值
string? pwd = context.Request.Query["password"];
//判断
if (pwd == "123")
{
//如果password检查正确
if (context.Request.HasJsonContentType())//检查这个请求是否为JSON请求
{
//把请求报文体解析为dynamic类型,并且放入context.Items。
var reqStream = context.Request.BodyReader.AsStream();
//目前System.Text.Json不支持把Json字符串反序列化为dynamic类型
//使用第三方NuGet包Dynamic.Json的DJson类来完成这个工作。
dynamic? jsonObj = DJson.Parse(reqStream);
context.Items["BodyJson"] = jsonObj;
}
//把请求转给下一个中间件
await next(context);
}
else //如果password的值不是123
{
//向客户端发送状态码为401(代表"未授权")
context.Response.StatusCode = 401;
//由于没有执行await next(context);,请求不会被传递到其他中间件
//因此也不会执行Run中的代码,请求终止
}
}
}
接下来,使用中间件类CheckAndParsingMiddleware,修改后的Program如下所示:
using NullApp;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Map("/test", async appbuilder =>
{
//通过UseMiddleware 来引入中间件类到管道中。
appbuilder.UseMiddleware<CheckAndParsingMiddleware>();
appbuilder.Run(async context =>
{
context.Response.ContentType = "text/html";
context.Response.StatusCode = 200;
//从HttpContext的Items中CheckAndParsingMiddleware类中设置的dynamic对象取出;
//因为HttpContext.Items在同一个请求中是共享的,所以可以用它来实现在多个中间件之间传递数据。
dynamic jsonObj = context.Items["BodyJson"];
int i = jsonObj.i;
int j = jsonObj.j;
await context.Response.WriteAsync($"{i}+{j} = {i+j}");
});
});
app.Run();
执行程序,然后向/test?password=123发送内容为{“i”:3,”j”:5}的请求,程序运行结果如图所示:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-3-1024x678.png)
如果我们发送的请求中没有设置password请求字符串或者请求字符串的值错误,则程序运行如下,服务器端响应的状态码为我们设置的401
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-4-1024x498.png)
除了常用的根据路径进行请求匹配的Map方法,ASP.NET Core中还提供了其他的Map方法,比如匹配GET请求的MapGet方法、匹配POST请求的MapPost方法。
通过MapWhen方法中的类型Func<HttpContext,bool>的参数还可以使用自定义的过滤条件来进行请求的匹配,如下面代码所示:
//定义了一个所有"报文头中AAA的值为123"的请求对应的管道
app.MapWhen(context => context.Request.Headers["AAA"] == "123", async appbuilder => { });
//定义了一个所有请求路径以"/api"开头的请求对应的管道。
app.MapWhen(context => context.Request.Path.StartsWithSegments("/api"), async appbuilder => { });
4. 筛选器和中间件的区别
大家可能感觉中间件筛选器非常类似,它们都是通过next串起来的一系列的组件,并且都可以在请求处理前后执行代码,都可以通过不执行next来进行请求的终止。
那么筛选器和中间件有什么区别和联系呢?
我们知道,中间件是ASP.NET Core中提供的功能,而筛选器是ASP.NET Core MVC中提供的功能。
ASP.NET Core MVC是由MVC中间件提供的框架,而筛选器属于MVC中间件提供的功能。
因此中间件和筛选器的关系如图所示:
![](https://ichiblog.cn/wp-content/uploads/2024/09/图片-15-1024x574.png)
因此,中间件和筛选器所处的层级是不同的,中间件是一个基础的概念,而筛选器是MVC中间件中的机制。
所以,中间件可以处理所有的请求,无论是针对控制器的请求还是针对静态文件等的请求,而筛选器只能处理对控制器的请求;
由于中间件运行在一个更底层、更抽象的级别,因此在中间件中无法处理IActionResult、ActionDescriptor等MVC中间件特有的概念。
中间件和筛选器可以完成很多相似的功能,比如我们既可以编写”未处理异常中间件”,也可以编写”未处理异常筛选器”。
但是未处理异常中间件可以处理所有管道中的异常,而未处理异常筛选器只能处理ASP.NET MVC的异常;
同样地,我们既可以编写”请求限流中间件”,也可以编写”请求限流筛选器”,但是请求限流中间件可以对所有请求进行限流,而请求限流筛选器只能对控制器的访问进行限流。
由于中间件工作在比筛选器更低的层级中,因此在实现同样的功能的时候,中间件的运行效率更高。
比如,我们要实现”请求限流”,如果通过中间件实现,对于超限的请求,我们可以在中间件中对其进行截断;
如果通过筛选器的话,超限的请求也会被管道中的多个中间件处理,其中包括MVC中间件的处理,从而浪费更多的服务器处理资源。
如果要实现相同的功能,中间件是比筛选器应用范围更广、效率更高的选择。
但是,如果开发一些和MVC请求相关的功能,这些是不能通过中间件完成的,因为这些功能只有在MVC中间件中才能实现。
总之,在开发一个对请求进行前后逻辑编程的组件的时候,优先选择使用中间件;
但是如果这个组件只针对MVC或者需要调用一些与MVC相关的类的时候,就只能选择筛选器。