截止目前,我们已经把Blazor作为一个前端框架,特别是WASM模式,本身的核心知识讲的七七八八了,虽然没有事无巨细面面俱到,但基本上把开发中最常用的核心知识都讲到了。
后续的文章的大方向,将是站在全栈开发的角度上,来补充其它方面的知识:比如认证授权,数据库交互,使用开源的组件库,部署上云等。
前面的文章我都只是在假定读者仅有面向对象编程基础,甚至于你其实并不需要掌握asp .net core的一些基础知识,只需要掌握C#的语法,或者仅掌握类Java的语法就可以了。
我们下一个要涉及的知识点,认证授权,则与asp .net core框架是深度集成的,虽然概念性的知识是通用的,框架无关,但具体实现严重依赖于asp .net core的设计与实现。
所以这里专开一页,快速的向大家补充一些asp .net core框架的基础知识。如果你对asp .net core比较熟悉,这篇番外篇文章是可以略过不看的
这是一个很蠢的问题,但值得思考一下,简单来说,asp .net core是一个开发网站的框架。之所以它的名字这么奇怪,是有历史原因的:
最早在.net core之前,.net技术栈是绑定在windows平台上的,叫.net framework,那个时候在.net framework上微软推出了一个网站开发框架,其地位类似于Severlet + Java Server Page,即JSP,那时这个开发框架叫ASP .NET。
ASP .NET和Severlet+JSP非常相似:
这两个框架一个用Java开发,一个用C#开发
两个框架写出来的程序都不是独立可执行的二进制,而是需要寄宿在一个web server程序里的类库
Severlet+JSP那边使用到的web server以tomcat最为流行,而ASP .NET这边使用到的web server就是IIS
web server的功能是处理网络连接,接受HTTP请求,然后将HTTP请求包装成对应平台的对象,再传输给Severlet或ASP .NET程序
之后Severlet或ASP .NET程序将处理完的,对象形式的HTTP回应,再传递给web server,web server负责将它们转化成真正的HTTP响应,以TCP回传给请求方
二者在开发过程中都使用到了“程序设计语言+HTML”混杂的一种标记语言,在Java这边这种语言就叫JSP,在ASP .NET这边这种语言叫Razor
时代在发展,社会在进步,服务端渲染落伍了,时代的大潮是前后端分离以及SPA单页应用。
Java那边Severlet+JSP没落了,然后Spring开始流行,后端程序员开始使用Spring框架去写Web API,.NET这边就稍微有点混乱。一直到.net core发布,ASP .NET被迁移到了.net core平台上,不再局限于windows平台了,迁移后的这个框架,就叫asp .net core
。
Java那边的SpringBoot将web server集成了起来,相当于框架内置了一个tomcat。在ASP这边,asp .net core依然支持IIS web server,但与此同时,还内置了web server的功能,就是所谓的Kestrel。
而进化后的ASP,不再以服务端渲染为卖点了,因为除了服务端渲染,ASP现在还支持Web API和实时应用,也就是说,现在用asp .net core
可以写出三类web应用:
这些东西,广义上都可以叫,是在使用asp .net core框架,但回头过来看那个基本问题:asp .net core到底是什么?味道就变了:
如果我们把Razor引擎剥掉,把为了支持web api框架实现的那些MVC+Controller的设计剥掉,把SingalR剥掉,框架还剩下什么?
其实asp .net core框架就剩下了DI(依赖注入)和middleware component pipeline了,而这两个东西,又是什么呢?
DI是Dependency Injection的缩写,直译过来是“依赖注入”,这个概念在七八年前还属于Java面试的月经题目,但实际讲起来特别简单:
一般情况下,像C#和Java这样的程序,是由对象构成的,程序运行的过程就是在不停的创建对象,使用对象,析构对象。
在一个复杂的程序中,对象与对象之间往往是有互相依赖关系的,这些依赖关系一般写在类定义里,比如要我们要写一个类对存储在数据库中的用户信息进行增删改查的话,这个类可能长下面这样(伪代码):
public class UserService
{
private DBService db;
public UserService(DBService db)
{
this.db = db;
}
public void AddUser(User u) => this.db.AddUser(u);
public User GetUser(int id) => this.db.GetUser(id);
...
...
}
这个类就依赖于存储层操作类DBService
,这意味着如果我以最朴素的方式去写代码的话,我每次要创建UserService
,都要先拿到一个DBService
的实例去初始化它。
而依赖注入的意思则是:我框架为你提供了一个大池子,里面可以存放各种对象:
而至于各种对象和类之间的依赖关系,你不要管,我自己来分析就行了,还以UserService
和DBService
为例,用依赖注入的话,就可以在程序初始化的时候先写出类似下面的代码:
{
// ...
DI.AddService<DBService>(() => new DBService("connection string"));
DI.AddService<UserService>();
}
然后在后续需要用到UserService
的时候,直接从池子里去取就行了,如下:
{
// ...
UserService us = DI.GetService<UserService>();
// ...
}
你可能会问:诶,创建UserService
的构造函数不是需要传入DBService db
作为参数吗?
是的,没错,但DI框架会通过反射查明UserService
的构造函数需要DBService db
作为参数,然后在池子里已经有DBService
的情况下,自动使用那个实例去调用UserService
的构造函数。
所以要点:
向DI池中注册对象时,有两个东西比较关键:
对象的声称的类型,比如对象的实际类型是SQLDBService
,但你可以声称它的类型为父类DBService
,或接口IStorageService
如何创建这个对象,而不是对象本身。
从DI池中取对象的时候,只能按当初注册对象时声称的类型去取,而不能以实际类型去取
比如我们以DI.AddService<IStorageService, SQLDBService>()
的方式向池中注册了一个对象
那么我们以DI.GetService<IStorageService>()
去取的话,就会取到这个对象,而以DI.GetService<SQLDBService>()
的试去取的话,是取不到这个对象的。
当然,实际编程的时候,具体向DI注册对象,以及取出对象的方法到底是什么,需要实际去查看文档,但整个DI机制的原理就是上面这样,它的核心优势在于:
对于Web应用来说,DI还要再新增一个概念:对象的生命周期。
在Web框架中,DI中的对象有三类:
Transient: 每次你向DI要对象的时候,DI都给你创建一个全新的
Scoped: 在单次HTTP请求处理过程中,你向DI要一个指定类型的对象,无论要一次还是多次,DI给你返回的都是同一个对象
但这个对象在这次HTTP请求处理结束后就会被析构,它的生命和HTTP请求是等长的
如果你的程序在某个时刻同时在处理30个HTTP请求,那么对于每个请求的处理栈来说,它们都有各自独立的对象
Singleton: 全局变量,只会初始化一次,不管你什么时候要,返回的对象都是那一个
以下是一些在asp .net core中向DI池中注册对象的例子:
调用代码 | 这个对象会在某个时刻自动被GC析构吗? | 声明类型和实际类型一致吗? | DI创建这个对象是怎么创建的? |
---|---|---|---|
services.AddSingleton<IStorageService, SQLService>() |
不会,因为这是Singleton,与程序同寿,一旦创建除非程序终止,不然始终存在 | 不同,声明类型是IStorageService ,实际类型是SQLService |
通过构造函数创建的 |
services.AddScoped<IStorageService, SQLService>() |
会,因为它的寿命是与HTTP请求同寿的,HTTP请求一旦结束,就会被析构 | 不同,同上 | 通过构造函数创建的 |
services.AddSingleton<IStorageService>(sp => new SQLService()) |
不会,因为这是Singleton对象 | 不同,同上 | 通过注册时调用的lambda表达式创建的 |
services.AddSingleton<SQLService>(sp => new SQLService()) |
不会,因为这是Singleton对象 | 相同,都是SQLService 类型 |
通过注册时调用的lambda表达式创建的 |
services.AddTransient<UserService>(sp => new UserService()) |
会,这是一个Transient对象,在引用计数为0时就会被释放 | 相同,都是UserService 类型 |
通过注册时调用的lambda表达式创建的 |
框架在程序员没有写一行代码的情况下,会自动向DI池中注册很多对象,比如如下最简单的asp .net core程序,它甚至不能处理任何有意义的HTTP请求,当程序运行起来的时候,DI池中已经被注册了一百多个对象
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Run();
}
我们可以在app.Run()
处打断点,然后查看app.ServiceDescriptors
来查看框架向DI池中注入的对象,如下图所示:
注意上面的ServiceDescriptors
字段仅有在调试器窗口中才能看出来,这是因为app.Services
的实际类型是Microsoft.Extensions.DependencyInjection.ServiceProvider
类,而这个类的源代码在脑门上定义了[DebuggerTypeProxy(typeof(ServiceProviderDebugView))]
。
作为框架的使用者,我们没必要一个一个的去学习这自带的一百多个对象的类型,况且这只是框架没有添加任何其它内容,甚至没有提供任何处理HTTP请求能力的情况下,就有一百多个对象被注册了。而常规来说,一个有着实际意义的asp .net core项目,即便程序员自己不向DI池中添加任何自定义对象,框架自带的那些对象的数目也会暴涨到300多个甚至更多。
好,有关DI的内容就简单讲到这里,接下来我们来看asp .net core框架是怎么处理HTTP请求的
下面是一个最简单的asp .net core程序的目录结构:
HelloAspNetCore
|
\--> HelloAspNetCore.csproj
\--> Program.cs
其中HelloAspNetCore.csproj
的内容如下:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
Program.cs
的内容如下:
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace HelloAspNetCore;
public static class Program
{
public static void Main(string[] args)
{
WebApplication app = WebApplication.CreateBuilder(args).Build();
app.Run(SayHello);
app.Run();
}
public static async Task SayHello(HttpContext ctx)
{
await ctx.Response.WriteAsync("<h1>Hello, ASP.NET Core!</h1>");
}
}
运行效果如下:
核心代码就三行:
WebApplication app = WebApplication.CreateBuilder(args).Build()
就如何字面意思那样,创建了一个Web程序
app.Run(SayHello)
这里的Run
方法是添加了一个middleware component,这个Run
定义在Microsoft.AspNetCore.Builder.RunExtensions
扩展类中,官方文档对其的说明是:
Adds a terminal middleware delegate to the application's request pipeline.
app.Run()
这里的Run
方法语义则与上一个完全不同:这里的意思是将第一行创建的Web应用运行起来,这个Run
就定义在Microsoft.AspNetCore.Builder.WebApplication
类中,官方文档对其的说明是:
Runs an application and block the calling thread until host shutdown.
程序的功能也非常简单:无论接收到的HTTP请求是什么东西,无论请求路径是什么,无论请求方法是什么,我都给它返回一个HTTP回应,回应体的内容就是<h1>Hello, ASP .NET Core!</h1>
对于Get请求,这个回应会被浏览器当成HTML文档去渲染,这也是我们在浏览器上能看到大字的原因。
如果我们在Powershell中使用Invoke-WebRequest
去测试这个程序,假设程序启动后监听的https端口为51058
,则会有如下结果:
使用以下命令测试
> Invoke-WebRequest -URI https://localhost:51058 -Method Get
> Invoke-WebRequest -URI https://localhost:51058/a/b/c/d -Method Get
> Invoke-WebRequest -URI https://localhost:51058/a/b/c/d -Method Post
> Invoke-WebRequest -URI https://localhost:51058/a/b/ -Method Delete
得到的结果均是:
StatusCode : 200
StatusDescription : OK
Content : {60, 104, 49, 62...}
RawContent : HTTP/1.1 200 OK
Transfer-Encoding: chunked
Date: Fri, 02 Aug 2024 03:07:53 GMT
Server: Kestrel
<h1>Hello, ASP.NET Core!</h1>
Headers : {[Transfer-Encoding, chunked], [Date, Fri, 02 Aug 2024 03:07:53 GMT], [Server, Kestrel]}
RawContentLength : 29
发送Head
请求的话,则asp .net core
框架会按照HTTP标准,只向我们回复HTTP请求头
> Invoke-WebRequest -URI https://localhost:51058 -Method Head
结果如下:
StatusCode : 200
StatusDescription : OK
Content : {}
RawContent : HTTP/1.1 200 OK
Date: Fri, 02 Aug 2024 03:12:12 GMT
Server: Kestrel
Headers : {[Date, Fri, 02 Aug 2024 03:12:12 GMT], [Server, Kestrel]}
RawContentLength : 0
asp .net core框架的设计使用了一种处理HTTP请求常用的设计模式,这个设计模式叫什么名字我已经忘了,但它的设计如下图所示
上面图中的每个Middleware你可以暂时简单的理解为如下的函数:
async Task Middlewarexxx(HttpContext ctx, RequestDelegate next)
{
// 操作ctx对象,对这个Http请求进行操作
if(xxx)
{
await next.Invoke(ctx);
}
else
{
return;
}
}
Middleware的关键点在于:
它本质上是个函数,内部可以通过ctx
对象来获取到有关这个HTTP请求的所有信息,也可以通过ctx
对象来决定如何响应这个HTTP请求
ctx.Response
中写入内容,来编辑Responsectx.Response.StatusCode
来向客户端发送一个非200的Responsectx.Response.Redirect
向客户端发送一个重定向Responsectx.Response.Cookies
来设定cookie,或者ctx.Response.Headers
或ctx.Response.BodyWriter
直接编辑Response的头部字段和body在操作ctx
之后,这个函数内部可以选择
next.Invoke(ctx)
,其中next
就是“下一个Middleware函数”next.Invoke(ctx)
,函数就此返回如果函数调用了next.Invoke(ctx)
,在await next.Invoke(ctx)
之后,这个middleware还可以继续对ctx
对象进行操作
在await next.Invoke(ctx)
之后再对ctx
进行操作的话,就意味着当前的HTTP请求对象ctx
已经经过了后续Middleware
的处理
这也是上图中,返回路径的含义
框架用这种设计,暗示所有使用者对功能进行分层,比如对于一个论坛来说,非登录用户可以查看帖子,而只能登录用户才能发帖和回复,那么整个网站的middleware设计就可以分为以下几层:
第一层:认证授权层,伪代码如下:
async Task AuthMiddleware(HttpContext ctx, ReuestDelegate next)
{
if(用户访问的是需要登录才能查看的API:即发贴或回复)
{
if(ctx中没有包含认证信息或权限不正确)
{
return 401 权限不足
}
}
await next.Invoke(ctx);
}
第二层:渲染页面的middleware,伪代码如下:
async Task RenderContentMiddleware(HttpContext ctx, RequestDelegate next)
{
if(登录用户)
{
ctx.Response.WriteAsync(欢迎登录用户:xxx);
}
else
{
ctx.Response.WriteAsync(登录后才能发表内容);
}
// 渲染页面内容或提交发布的内容
}
那么现在的问题是:如何把这两个Middleware
插入到框架里去呢?框架提供了两个基本方法:app.Use()
和app.Run()
注意这里说的app.Run()
并不是在Program.cs
最后一行的那个Run
方法。
这两个方法的区别在于:
Use
方法是正常的添加Middleware
的方法
Run
方法仅用于添加“最后一个Middleware”,即如果一个Middleware
是整个处理流程的最后一步,在它之后没有其它middleware
了,那么就可以用Run
去添加它
并且这个middleware
不需要在实现时,声明RequestDelegate next
参数,只需要HttpContext ctx
就行了
这种特殊的Middleware
,叫Terminal Middleware
现在我们做个小试验,来体验一下middleware
public static class Program
{
public static void Main(string[] args)
{
WebApplication app = WebApplication.CreateBuilder(args).Build();
app.Use(Middleware_First);
app.Use(Middleware_Second);
app.Run(Middleware_Thrid);
app.Run();
}
public static async Task Middleware_First(HttpContext ctx, RequestDelegate next)
{
await ctx.Response.WriteAsync("<h1>HttpIncoming: First middleware</h1>");
await next.Invoke(ctx);
await ctx.Response.WriteAsync("<h1>HttpOutgoing: First middleware</h1>");
}
public static async Task Middleware_Second(HttpContext ctx, RequestDelegate next)
{
await ctx.Response.WriteAsync("<h1>HttpIncoming: Second middleware</h1>");
await next.Invoke(ctx);
await ctx.Response.WriteAsync("<h1>HttpOutgoing: Second middleware</h1>");
}
public static async Task Middleware_Thrid(HttpContext ctx)
{
await ctx.Response.WriteAsync("<h1>Third middleware is a terminal middleware</h1>");
}
}
实验结果如下:
而像这种,由多个Middleware按顺序排列形成的,处理HTTP请求的流水线,这就是request pipeline
,有时也叫middleware pipeline
。
而Middleware本身,在文档中也有多种称呼:有时叫middleware
,有时叫middleware component
,有时直接干脆叫component
你可能会说,为什么我也对asp .net core略知一二,但我从来没写过上面的那种函数?也没调过app.Use
或app.Run
来注册这些函数:我平常都是在Controller
里写代码啊!或者我都是在Pages
目录里写razor页面啊,那我写的Controller
或者razor页面,算是middleware吗?
这里有几个灰色地带需要理解一下
Middleware
并不一定是一个函数的模样我们上面把Middleware
描述成了一个函数的样子,其实是不太对的。只是它可以写成函数的样子,并不意味着所有的Middleware都是一个简单的函数。
从概念上来讲,Middleware正确的定义,是:一段处理HTTP请求的代码,即:
app.Use
和app.Run
两种途径,框架的内部实现可能以非常扭曲的方式去挂载一个非常复杂的middlewareapp.Use
和app.Run
去注册,比较简单而已比如我们通过dotnet new webapp --use-program-main -o HelloWebApp
创建一个传统的asp .net core razor服务端渲染项目的话,默认的官方模板写的Program.cs
长下面这样:
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
}
在var app = builder.Build();
之后,到最后一行app.Run()
之前,这中间十几行代码,其实都是在向request pipeline注册middleware
这些方法都叫Usexxx
,如果你点开它们的实现,你会发现,任何一个UseXXX
背后的代码逻辑都非常复杂。而下面,是一张官方文档中的图:
这张图描绘的,就是一个典型的asp .net core应用中,常见的,官方自带的默认request pipeline长得样子。
这张图和我们上面贴的模板代码基本是能对上的,除了
图上在Routing
之后,有CORS
和Authentication
两个middleware,代码中是没有的
图上在Authorization
之后,画了两个Custom middlewares
,而代码中是没有的
图上这两个Custom middlewares
其实指代的就是程序员自定义的middleware
图上最后一个middleware叫Endpoint
,而代码中最后一个Use方法调用的是app.MapRazorPages
为了与图保持一致,我们把代码改成下面这样:
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseExceptionHandler("/Error");
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
}
然后在最后一行代码处打个断点,再去观察app.Middlewares
字段,如下:
现在基本和官方文档的图对上了,即每个app.Usexxx
的方法调用,都是在向request pipeline中添加一个middleware,对照表如下:
middleware在示意图中的名字 | Usexxx 方法 |
app.Middleware 字段中存储的名字 |
---|---|---|
ExceptionHandler | app.UseExceptionHandler("/Error") |
M.A.Builder.ExceptionHandlerExtensions+<>c__DisplayClass5_0.<SetExceptionHandlerMiddleware>b_0 |
HSTS | app.UseHsts() |
M.A.HttpsPolicy.HstsMiddleware |
HttpsRedirection | app.UseHttpsRedirection() |
M.A.HttpsPolicy.HttpsRedirectionMiddleware |
Static Files | app.UseStaticFiles() |
M.A.StaticFiles.StaticFileMiddleware |
Routing | app.UseRouting() |
M.A.Routing.EndpointRoutingMiddleware |
CORS | app.UseCors() |
M.A.Cors.Infrastructure.CorsMiddleware |
Authentication | app.UseAuthentication() |
M.A.Authentication.AuthenticationMiddleware |
Authorization | app.UseAuthorization() |
M.A.Authorization.AuthorizationMiddlewareInternal |
需要注意的是,WebApplication.Middleware
和ServiceProvider.ServiceDescriptors
一样,都是仅有在调试器下才会看到的内容,你去翻开WebApplication
的源代码去看,能看到它的脑门上有一个[DebuggerTypeProxy(typeof(WebApplicationDebugView))]
的修饰。
虽然如此,但至少我们能明显看出来,这些框架自带的middleware,大多数都被写成了类的模样,而即使是ExceptionHandler
这个在app.Middleware
中长得不像类名的家伙,真的翻开ExceptionHandlerExtensions
类的源代码去看的话,也会发现,那个字符串其实说的是SetExceptionHandlerMiddleware
扩展方法,而在扩展方法内部,有两个分支:
private static IApplicationBuilder SetExceptionHandlerMiddleware(IApplicationBuilder app, IOptions<ExceptionHandlerOptions>? options)
{
// ...
if (/* ... */)
{
return app.Use(next =>
{
// ...
return new ExceptionHandlerMiddlewareImpl(next, loggerFactory, options, diagnosticListener, exceptionHandlers, meterFactory, problemDetailsService).Invoke;
});
}
if (options is null)
{
return app.UseMiddleware<ExceptionHandlerMiddlewareImpl>();
}
return app.UseMiddleware<ExceptionHandlerMiddlewareImpl>(options);
}
我们会发现,它最终还是将我们指向了一个类名:ExceptionHandlerMiddlewareImpl
那么至此,我们可以得出两个初步结论:
app.Use
和app.Run
方法,是最简单的注册自定义middleware的方法,使用这两个方法,需要把middleware的逻辑封装成函数的模样app.UseMiddleware
方法注册进request pipeline中而我们在Program.cs
中调用的各种Usexxx
方法,其实就是将app.UseMiddleware
包装了一层之后的扩展方法而已。
这里我们可以翻开M.A.Builder.UseMiddlewareExtensions
的源代码看一眼,就非常清晰了:
/// <summary>
/// Adds a middleware type to the application's request pipeline.
/// </summary>
/// <typeparam name="TMiddleware">The middleware type.</typeparam>
/// <param name="app">The <see cref="IApplicationBuilder"/> instance.</param>
/// <param name="args">The arguments to pass to the middleware type instance's constructor.</param>
/// <returns>The <see cref="IApplicationBuilder"/> instance.</returns>
public static IApplicationBuilder UseMiddleware<[DynamicallyAccessedMembers(MiddlewareAccessibility)] TMiddleware>(this IApplicationBuilder app, params object?[] args)
{
return app.UseMiddleware(typeof(TMiddleware), args);
}
/// <summary>
/// Adds a middleware type to the application's request pipeline.
/// </summary>
/// <param name="app">The <see cref="IApplicationBuilder"/> instance.</param>
/// <param name="middleware">The middleware type.</param>
/// <param name="args">The arguments to pass to the middleware type instance's constructor.</param>
/// <returns>The <see cref="IApplicationBuilder"/> instance.</returns>
public static IApplicationBuilder UseMiddleware(
this IApplicationBuilder app,
[DynamicallyAccessedMembers(MiddlewareAccessibility)] Type middleware,
params object?[] args)
{
if (typeof(IMiddleware).IsAssignableFrom(middleware))
{
// IMiddleware doesn't support passing args directly since it's
// activated from the container
if (args.Length > 0)
{
throw new NotSupportedException(Resources.FormatException_UseMiddlewareExplicitArgumentsNotSupported(typeof(IMiddleware)));
}
var interfaceBinder = new InterfaceMiddlewareBinder(middleware);
return app.Use(interfaceBinder.CreateMiddleware);
}
var methods = middleware.GetMethods(BindingFlags.Instance | BindingFlags.Public);
MethodInfo? invokeMethod = null;
foreach (var method in methods)
{
if (string.Equals(method.Name, InvokeMethodName, StringComparison.Ordinal) || string.Equals(method.Name, InvokeAsyncMethodName, StringComparison.Ordinal))
{
if (invokeMethod is not null)
{
throw new InvalidOperationException(Resources.FormatException_UseMiddleMutlipleInvokes(InvokeMethodName, InvokeAsyncMethodName));
}
invokeMethod = method;
}
}
if (invokeMethod is null)
{
throw new InvalidOperationException(Resources.FormatException_UseMiddlewareNoInvokeMethod(InvokeMethodName, InvokeAsyncMethodName, middleware));
}
if (!typeof(Task).IsAssignableFrom(invokeMethod.ReturnType))
{
throw new InvalidOperationException(Resources.FormatException_UseMiddlewareNonTaskReturnType(InvokeMethodName, InvokeAsyncMethodName, nameof(Task)));
}
var parameters = invokeMethod.GetParameters();
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(HttpContext))
{
throw new InvalidOperationException(Resources.FormatException_UseMiddlewareNoParameters(InvokeMethodName, InvokeAsyncMethodName, nameof(HttpContext)));
}
var reflectionBinder = new ReflectionMiddlewareBinder(app, middleware, args, invokeMethod, parameters);
return app.Use(reflectionBinder.CreateMiddleware);
}
上面的代码太难看懂了?或者看着脑袋疼?不要紧,我给你粗略的翻译成伪代码:
public static IApplicationBuilder UseMiddleWare(this IApplicationBuilder app, Middleware类, 额外参数)
{
if(Middleware类实现了IMiddleware接口)
{
如果有额外参数的话,就抛异常,因为IMiddleware接口不支持额外参数;
否则就把IMiddleware.InvokeAsync里的逻辑注册到request pipeline上;
完活;
}
else
{
找找这个类里有没有名为Invoke或InvokeAsync的方法,
并且这些方法要至少接受一个HttpContext对象作为参数;
如果没找到这样的方法,就抛异常;
如果找到了,就把这个方法中的逻辑注册到request pipeline上;
}
}
这就很奇怪了,为什么要有两套设计呢?答案在于:生命周期。
如果你仔细看上面的源代码,就会发现
IMiddleware
接口的类,会调用interfaceBinder.CreateMiddleware
去创建一个RequestDelegate
,然后把这个创建好的RequestDelegate
注册到request pipeline中去IMiddleware
接口的类,会调用reflectionBinder.CreateMiddleware
去创建ReqiestDelegate
这两个东西的区别在于,interfaceBinder
创建出的delegate,每次在request pipeline中被执行时,都会新创建一个Middleware类的实例,然后去调用实例下的InvokeAsync
方法
而reflectionBinder
则是在初始化的时候,就拿着一个已经创建好的Middleware类的实例,随后调用reflectionBinder.CreateMiddleware
的时候,也只是把这个实例内部的方法逻辑包装成了一个delegate
下面来说说二者的区别
IMiddleware
接口,直接写个类如果你不实现IMiddleware
接口,而是直接写一个类来当Middleware的话,有以下要求:
有一个名为Invoke
或InvokeAsync
的实例方法,第一个参数必须是HttpContext ctx
,返回值是Task
app.UseMiddleware<MiddlewareType>(额外参数)
的方式提供,或通过DI容器自动注入通过构造函数接收next
,即后续的middleware
在执行时,这个类会被框架实例化成一个对象,而这个对象的生命周期是与程序等长的:即无论来多少个请求,调用的都是同一个实例的Invoke
或InvokeAsync
方法
所以这就导致它有一个非常大的缺陷:在从DI池中拿东西的时候要非常小心
Invoke
方法注入Invoke
方法注入,因为构造函数只会执行一次下面是一个例子,包含以下要素:
next
InvokeAsync
参数列表注入DI池中的Scoped对象Usexxx
方法namespace Middleware.Example;
public class MyCustomMiddleware
{
private readonly RequestDelegate _next;
public MyCustomMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext httpContext, IMessageWriter svc)
{
svc.Write(DateTime.Now.Ticks.ToString());
await _next(httpContext);
}
}
public static class MyCustomMiddlewareExtensions
{
public static IApplicationBuilder UseMyCustomMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<MyCustomMiddleware>();
}
}
它使用的时候要如下使用,要点有:
InvokeAsync
所需要的Scoped对象Mapxxx
方法using Middleware.Example;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IMessageWriter, LoggingMessageWriter>();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseMyCustomMiddleware();
app.MapGet("/", () => "Hello World!");
app.Run();
IMiddleware
接口如果实现IMiddleware
接口的话,事情就变得稍微简单了一点点,因为每次有HTTP请求来,middleware逻辑要被执行时,框架都会new出一个新的实例来调用这个实例的InvokeAsync
方法,所以传递什么的就简单多了:
它唯一的一个缺点,就是不能通过app.UseMiddleware<MyCustomMiddleware>(额外参数)
的方式传递参数了,不过这并不重要。
如果我们将上面的MyCustomMiddleware
翻译成IMiddleware
实例的话,就可以如下改写:
namespace Middleware.Example;
-public class MyCustomMiddleware
+public class MyCustomMiddleware : IMiddleware
{
private readonly RequestDelegate _next;
+ private readonly IMessageWriter _svc;
- public MyCustomMiddleware(RequestDelegate next)
+ public MyCustomMiddleware(RequestDelegate next, IMessageWriter svc)
{
_next = next;
+ _svc = svc;
}
- public async Task InvokeAsync(HttpContext httpContext, IMessageWriter svc)
+ public async Task InvokeAsync(HttpContext httpContext)
{
- svc.Write(DateTime.Now.Ticks.ToString());
+ _svc.Write(DateTime.Now.Ticks.ToString());
await _next(httpContext);
}
}
public static class MyCustomMiddlewareExtensions
{
public static IApplicationBuilder UseMyCustomMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<MyCustomMiddleware>();
}
}
Controller
或者razor页面算不算是middleware我们讲明白了框架自带的Middleware,也讲明白了怎么通过类的方式去实现、注册middleware。
还有个问题没说,回到我们之前的话题上:
app.MapRazorPages()
,app.MapControllers()
等一大堆Mapxx
方法是在干嘛?为什么我没有在app.Middleware
字段中见到对应的middleware这也是asp .net core整体框架设计中最让人感到困惑的地方。而这些问题的答案,就是asp .net core中一个非常重要的知识点:路由
接下来,我们新开一个章节来单独讲路由,当你明白路由是怎么回事后,这些问题也就自然有了答案。
注意,下面所讲的所有路由相关的知识,都是服务端的路由,和我们之前讲Blazor框架时的路由不是一回事:
我们再来看这张图:
通过上面的介绍,你已经明白了从Exception Handler
到Authorization
,乃至于图中的两个自定义middleware是怎么回事。
而图中最后一个Endpoint
,其实也是个Middleware
,只不过它比较特殊,它需要配合着Routing
一起使用
我们先看下面的代码:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(ConfigureEndpoints);
app.Run();
}
public static async Task HandleHomePageRequest(HttpContext ctx)
{
await ctx.Response.WriteAsync("<h1>HomePage</h1>");
await ctx.Response.WriteAsync("<a href='/Routing'>To /Routing</a>");
await ctx.Response.WriteAsync("<br/>");
await ctx.Response.WriteAsync("<a href='/Endpoints'>To /Endpoints</a>");
}
public static async Task HandleRoutingRequest(HttpContext ctx)
{
await ctx.Response.WriteAsync("<h1>Hello Routing</h1>");
await ctx.Response.WriteAsync("<a href='/'>Home Page</a>");
await ctx.Response.WriteAsync("<br/>");
await ctx.Response.WriteAsync("<a href='/Endpoints'>To /Endpoints</a>");
}
public static async Task HandleEndpointsRequest(HttpContext ctx)
{
await ctx.Response.WriteAsync("<h1>Hello Endpoints</h1>");
await ctx.Response.WriteAsync("<a href='/'>Home Page</a>");
await ctx.Response.WriteAsync("<br/>");
await ctx.Response.WriteAsync("<a href='/Routing'>To /Routing</a>");
}
public static void ConfigureEndpoints(IEndpointRouteBuilder eb)
{
eb.Map("/", HandleHomePageRequest);
eb.Map("/Routing", HandleRoutingRequest);
eb.Map("/Endpoints", HandleEndpointsRequest);
}
}
上面这个程序运行起来的样子是这样的:
在app.Run();
处打断点,可以观察到,上面的代码向request pipeline中添加了两个middleware,分别是EndpointRoutingMiddleware
和EndpointMiddleware
:
代码非常简单易懂,现在我们来说一下语义:
“endpoint”有两层意思
作为一个“middleware”来讲,它描述的是在request pipeline中最后一个执行的middleware
这个middleware也比较特殊,特殊的点在于
ConfigureEndpoints
函数中的三行代码就是在描述路由表),而这个middleware在执行时,会根据路由表的描述,将请求分发至不同的函数中去作为路由表中的项而言
它包含两个部分:
其实就是上面ConfigureEndpoints
函数中,在调用eb.Map
时提供的两个参数:两个参数合并在一起,就是一个概念上的endpoint
从这个角度来看,我们在asp .net core项目中书写的Razor页面,或者controller之类的代码,其实都是在描述路由表项,也就是在描述一个个的endpoint
为了避免术语混乱,我们后续将使用EndpointMiddleware
来指代middleware概念,用endpoint
来指代路由表项的概念
那么从理论上来说,对于上面这个代码例子,其实我们只需要整个request pipeline中有EndpointMiddleware
存在就可以了,那那个UseRouting
带来的EndpointRoutingMiddleware
有什么存在的意义呢?
而且当我们把app.UseRouting();
注释掉的话,框架会在初始化时期抛出一个异常,如下:
确实,从理论上来说,对于我们上面的代码例子,我们只需要EndpointMiddleware
就可以了。但对于实际开发来说,这样的设计是不够的,最典型的一个问题就是:如果我们需要有一个middleware来判断用户权限,怎么办?
比如,未登录的匿名用户可以访问首页/Home
和/
,而登录用户可以访问个人资料/Profile
页面,管理员用户可以访问/Admin
页面。
首先在不考虑权限的情况下,仅说路由的话,我们大概会写出下面这样的代码(伪代码)
public static void ConfigureEndpoints(IEndpointRouteBuilder eb)
{
eb.Map("/", 首页);
eb.Map("/Home", 首页);
eb.Map("/Profile", 个人资料页面);
eb.Map("/Admin", 管理员页面);
}
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseEndpoints(ConfigureEndpoints);
app.Run();
}
现在我们要添加权限逻辑,问题就有点麻烦了:假如我们的权限逻辑如下所示(伪代码):
{
if(path = "/" || path == "/Home"){
允许访问
} else if(path.StartsWith("/Profile")) {
仅登录用户可访问
} else if(path.StartsWith("/Admin")) {
仅管理员可访问
} // ...
}
这段逻辑应该怎么结合进我们的EndpointMiddleware呢?一种天然的办法,就是让用户在定义endpoint的时候,主动去做权限检查,比如在个人资料页面
的执行逻辑中,如以下伪代码一样添加权限检查:
public async Task 个人资料页面(HttpContext ctx)
{
if(ctx中包含了用户信息) {
返回200,允许访问
} else {
返回401
}
}
这样的缺陷非常明显,权限逻辑分散在多个endpoint的执行逻辑体内,项目规模扩大后,或者权限逻辑有重大调整的时候,改代码非常痛苦。那么很自然的一个方法,就是把权限检查逻辑统一封装在一个middleware中,我们的代码就会写成下面这样:
public static void ConfigureEndpoints(IEndpointRouteBuilder eb)
{
eb.Map("/", 首页);
eb.Map("/Home", 首页);
eb.Map("/Profile", 个人资料页面);
eb.Map("/Admin", 管理员页面);
}
public async Task AuthorizationMiddleware(HttpContext ctx, RequestDelegate next)
{
if(权限检查通过) {
await next.Invoke(next);
} else {
ctx.Response.StatusCode = 401;
}
}
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(AuthorizationMiddleware);
app.UseEndpoints(ConfigureEndpoints);
app.Run();
}
这样做也没有问题,但有一点点不方便:用户在新写一个页面的时候,需要做两件事:
Pages/
目录下新建一个页面AuthorizationMiddleware
中去针对这个页面添加权限逻辑更自然的方式,是在endpoint的执行逻辑处,用Attribute说明访问这个页面所需要的权限,然后AuthorizationMiddleware
通过反射的方式去收集这个信息,比如我们的个人资料页面
,假设它是一个Razor页面,那么就是在Pages/Profile.razor
文件里,我们可以如下在它脑门上添加一个描述性的Attribute
@*
...
*@
@attribute [Authorize("登录用户")]
@*
...
*@
如果它是一个C#类的话,就可以如下写:
[Authorize("登录用户")]
public class Profile {
// ...
}
然后在AuthorizationMiddleware
中,通过反射去获取这些权限描述信息,再去做判断
public async Task AuthorizationMiddleware(HttpContext ctx, RequestDelegate next)
{
var pageRenderFunc = 判断当前请求会被匹配到哪个endpiont上(ctx);
var authorizeInfo = 通过反射拿到权限要求(pageRenderFunc);
if(当前请求满足authorizeInfo中的要求) {
await next.Invoke(next);
} else {
ctx.Response.StatusCode = 401;
}
}
但这样的实现,有一点点不自然:很明显,判断当前请求会被匹配到哪个endpoint
上这部分代码,不应该写在权限逻辑里面,这就不是权限逻辑应该干的活,权限逻辑应该干的活是:
那么又是非常自然的:我们应当把判断当前请求会被匹配到哪个endpoint
上这部分代码,再单独写成一个middleware,这个middleware,就是EndpointRoutingMiddleware
,就是app.UseRouting()
做的事情。
现在,你可以简单的把app.UseRouting()
的功能理解为以下伪代码:
public static UseRoutingExtension
{
public static IApplicationBuilder UseRouting(this IApplicationBuilder builder)
{
builder.Use((ctx, next) =>
{
判断当前请求会被匹配到哪个endpiont上,并把endpoint写进ctx.Endpoint属性中
await next.Invoke(ctx);
});
}
}
那么权限逻辑就可以改写为以下:
public static UseAuthorization
{
public static IAppliationBuilder UseAuthorization(this IApplicationBuilder builder)
{
builder.Use((ctx, next) =>
{
var authorizeInfo = 通过反射拿到权限要求(ctx.Endpoint);
if(当前请求满足authorizeInfo中的要求) {
await next.Invoke(next);
} else {
ctx.Response.StatusCode = 401;
}
});
}
}
程序就可以如下写:
public static void ConfigureEndpoints(IEndpointRouteBuilder eb)
{
eb.Map("/", 首页);
eb.Map("/Home", 首页);
eb.Map("/Profile", 个人资料页面);
eb.Map("/Admin", 管理员页面);
}
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseRouting(); // 拿到路由匹配结果
app.UseAuthorization(); // 执行权限逻辑,依赖于上一步写入的ctx.Endpoint
app.UseEndpoints(ConfigureEndpoints); // 执行路由匹配结果
app.Run();
}
到这里,我们基本就说明白了UseRouting
的作用:就是提前做路由匹配,并把路由匹配结果写进ctx.Endpoint
。
那么,这里再总结一下:
app.UseRouting()
EndpointRoutingMiddleware
EndpointRoutingMiddleware
在处理HTTP请求时,它的功能是为当前请求匹配出一个endpoint,并把这个endpoint的信息写进ctx.Endpoint
属性上app.UseEndpoints(ConfigureEndpoints)
这行代码在被执行时,有两个功能
向request pipeline中添加了EndpointMiddleware
向框架注册了路由表,即通过ConfigureEndpoints
参数,说明了所有的endpoint
EndpointRoutingMiddleware
看的,但却是在UseEndpoints
,即向request pipeline中添加EndpointMiddleware
的时候传入框架的而EndpointMiddleware
在处理HTTP请求时,并不会理会ConfigureEndpoints
参数,而是直接去执行ctx.Endpoint
中记录的逻辑
这里最别扭的事情,就是“路由表”的注册时机:
EndpointMiddleware
的时候向框架提供的路由表EndpointRoutingMiddleware
看的EndpointMiddleware
其实完全不看路由表这里再贴一遍代码,不过我们简化一下路由表,再在app.UseRouting()
之前再添加一个简单的自定义middleware
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
+
+ app.Use(async (ctx, next) =>
+ {
+ await ctx.Response.WriteAsync("<h1>Custom middleware incoming</h1>"); // 断点1
+ await next.Invoke(ctx);
+ await ctx.Response.WriteAsync("<h1>Custom middleware outgoing</h1>"); // 断点2
+ });
app.UseRouting();
app.UseEndpoints(ConfigureEndpoints);
app.Run();
}
public static async Task HandleHomePageRequest(HttpContext ctx)
{
await ctx.Response.WriteAsync("<h1>HomePage</h1>"); // 断点3
- await ctx.Response.WriteAsync("<a href='/Routing'>To /Routing</a>");
- await ctx.Response.WriteAsync("<br/>");
- await ctx.Response.WriteAsync("<a href='/Endpoints'>To /Endpoints</a>");
}
-
- public static async Task HandleRoutingRequest(HttpContext ctx)
- {
- await ctx.Response.WriteAsync("<h1>Hello Routing</h1>");
- await ctx.Response.WriteAsync("<a href='/'>Home Page</a>");
- await ctx.Response.WriteAsync("<br/>");
- await ctx.Response.WriteAsync("<a href='/Endpoints'>To /Endpoints</a>");
- }
-
- public static async Task HandleEndpointsRequest(HttpContext ctx)
- {
- await ctx.Response.WriteAsync("<h1>Hello Endpoints</h1>");
- await ctx.Response.WriteAsync("<a href='/'>Home Page</a>");
- await ctx.Response.WriteAsync("<br/>");
- await ctx.Response.WriteAsync("<a href='/Routing'>To /Routing</a>");
- }
-
public static void ConfigureEndpoints(IEndpointRouteBuilder eb)
{
eb.Map("/", HandleHomePageRequest);
- eb.Map("/Routing", HandleRoutingRequest);
- eb.Map("/Endpoints", HandleEndpointsRequest);
}
}
我们打三个断点,来观察请求的处理,在请求来临时,最先击中的是断点1,可以观察到,此时由于EndpointRoutingMiddleware
还未执行,所以ctx.Endpoint
的值是null
断点1执行后,EndpointRoutingMiddleware
开始执行,开始查路由表,并把匹配结果写在ctx.Endpoint
上,再之后就是EndpointMiddleware
的执行,会调用到ctx.Endpoint
属性指的代码块上,也就是HandleHomePageRequest
函数,断点3处。
此时我们观察调试器窗口,会明显看到ctx.Endpoint
属性中记录了请求路径,路由匹配结果:
再之后request pipeline正向已经执行结束,开始回退,当回退到自定义middleware的时候,能观察到,ctx.Endpoint
依然存在:
HttpContext
并不存在一个属性叫Endpoint
之所以我们能在调试器窗口看到ctx.Endpoint
属性,是因为HttpContext
有如下修饰:
[DebuggerTypeProxy(typeof(HttpContextDebugView))]
public abstract class HttpContext
{
// ...
}
我们在调试器窗口看到的ctx.Endpoint
属性,其实是对ctx.GetEndpoint()
方法的调用结果。而这个方法在HttpContext
中是没有定义GetEndpoint()
方法的,这个方法的实现其实是在M.A.Http.EndpointHttpContextExtensions
中,如下实现:
public static class EndpointHttpContextExtensions
{
/// <summary>
/// Extension method for getting the <see cref="Endpoint"/> for the current request.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> context.</param>
/// <returns>The <see cref="Endpoint"/>.</returns>
public static Endpoint? GetEndpoint(this HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.Features.Get<IEndpointFeature>()?.Endpoint;
}
// ...
}
Map
系列方法我们上面的例子中,Map
方法用在调用app.UseEndpoints
的时候,在内部“注册路由表”:每次调用都是在向路由表中添加一个endpoint。
Map
方法注册的路由项,只匹配请求路径,但无视请求方法,比如我们用eb.Map("/", HandleHomePageRequest)
注册的endpoint,不光会匹配Get请求,还会匹配Post请求和Delete请求。
要在匹配请求路径的同时,将请求方法也纳入匹配逻辑中,可以调用它的兄弟方法:MapGet
,MapPost
,MapDelete
诸如此类的方法。
这些方法重载繁多,我这里就不过度展开了,使用的时候现查文档,或者借助IDE的提示写代码吧
UseRouting
和UseEndpoint
程序也能跑?通过上面的讲解,你大致已经明白了在asp .net core框架中,EndpointRoutingMiddleware
和EndpointMiddleware
的功能与职责了:一个匹配路由,一个执行endpoint
但是它解释不了为什么下面的代码可以成功运行:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", async (ctx) =>
{
await ctx.Response.WriteAsync("<h1>Hello World</h1>");
});
app.Run();
}
}
或者更准确一点说,以上的代码,仅在.net sdk的版本 >= 6.0的时候能正常运行,而由于.net 8.0为很多基础类添加了DebuggerTypeProxy
,调试器的细节更丰富,我们就在.net 8.0中接着讨论吧。
我们在app.Run();
处打个断点,会发现app.Middleware
里是空的:
而如果我们在endpoint逻辑体里打个断点,会发现,在处理HTTP请求时,看起来不光EndpointMiddleware
生效了(不然不会进入到执行体内),好像EndpointRoutingMiddleware
也生效了:因为ctx.Endpoint
不为null
这就很奇怪了,到底EndpointRoutingMiddleware
和EndpointMiddleware
有没有被注册到request pipeline里呢?
答案是有的。
那到底注册到哪了呢?答案是:
如果你没有主动调用UseRouting()
,那么框架会自动把EndpointRoutingMiddleware
添加到request pipeline中去。而至于添加到什么位置,你别管,总之:EndpointRoutingMiddleware
它一定在任何自定义middleware之前被执行
如果你没有主动调用UseEndpoint(...)
,那么框架会自动把EndpointMiddleware
添加到request pipeline中去,而位置,位于request pipeline的末尾
至于路由表,框架可以通过app.Map
系列方式向路由表中添加endpoint,添加endpoint的位置无所谓,只需要在app.Run()
之前就行
如果你没有主动调用UseRouting()
和UseEndpoint(...)
,那么EndpointRoutingMiddleware
或EndpointMiddleware
不会出现在调试器的app.Middleware
字段中去,这是这个调试器专用字段实现上的缺陷
那么主动调用UseRouting
和UseEndpoint
的意义在哪里呢?
主动调用UseRouting
的意义在于,可以调整EndpointRoutingMiddleware
在request pipeline中的位置
主动调用UseEndpoint
已经基本没有意义了,理论上来讲,也可以通过主动调用UseEndpoint
来调整EndpointMiddleware
在request pipeline的位置,但这在实践中几乎没有任何意义
以上的解释与变动,仅适用于.net sdk版本号 >= 6.0的情况,或者更准确一点来说,是在框架有了WebApplication
相关的类定义之后。
在老版本的.net sdk中,比如5.0,我们可以通过dotnet new web -o HelloNet5Web -f net5.0
命令来创建一个web项目,来观察在上一个时代,asp .net core是如何设计的:
程序的主体被拆分成了两个代码文件,一个是Program.cs
,如下:
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace HelloNet5Web
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
另一个是Startup.cs
,如下:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace HelloNet5Web
{
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});
});
}
}
}
从代码书写的角度来讲,区别主要有:
老版本使用的是Host.CreateDefaultBuilder(args)
来创建一个builder
对象,而新版本使用的是WebApplication.CreateBuilder(args)
来创建builder
对象
IHostBuilder
,新版本是WebApplicationBuilder
老版本将“向DI容器注册对象”和“配置request pipeline”的功能独立在了Startup.cs
中
Startup.ConfigureServices
里应当书写“向DI容器注册对象”的逻辑Startup.Configure
里应当书写“配置request pipeline”的逻辑而在新版本中,这部分内容统统都默认在Main
函数中
builder
对象调用builder.Build()
之前,代码可以通过builder.Service.AddXXX
来向DI池中注册对象builder
对象调用builder.Build()
之后,在app.Run()
之前,配置request pipeline而从路由方面的知识来讲,区别主要在于:在老版本中,你是无法通过IApplicationBuilder.Map
来向路由表中添加endpoint的,而在新版本中,可以直接通过WebApplication.Map
系列方法直接向路由表中添加endpoint
老版本确实有一个IApplicationBuilder.Map
方法可用,但这个Map
方法的语义和新版本的WebApplication.Map
系列方法完全不一样:老版本中的Map
方法的功能是对request pipeline整体制造一个分支,如下:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
+ app.Map("/NewBranch", app =>
+ {
+ app.Run(async ctx => await ctx.Response.WriteAsync("New branch terminal middleware"));
+ });
+
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Hello World!");
});
+
+ endpoints.MapGet("/NewBranch", async context =>
+ {
+ await context.Response.WriteAsync("New branch endpoint");
+ });
});
}
上面的代码,app.Map
的功能是在EndpointRoutingMiddleware
之前,挂载了一个分支路径:
/NewBranch
,那么请求将被新的request pipeline分支所接管,在这个新分支中,有唯一一个terminal middleware,功效是向HTTP回应中写字符串"New branch terminal middleware"
/NewBranch
,那么就会顺着主要的request pipeline主线继续移动:先是EndpointRoutingMiddleware
,再是EndpointMiddleware
所以你会看到,当以上的代码运行时,我们在浏览器请求/NewBranch
路径时,页面上显示的是"New branch terminal middleware"
,而不是我们定义在主request pipeline路由表里的"New branch endpoint"
新版本里,其实还保留了这个设计,即我们可以通过app.Map
的重载,来为request pipeline开一个分支,比如你可以写出如下等效的代码(但为了后面介绍调试方便,我们把middleware和endpoint执行体写成了独立函数的样子)
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
+
+ // #1
+ app.Map("/NewBranch", app =>
+ {
+ app.Run(NewBranchTerminalMiddleware);
+ });
+ // #2
app.MapGet("/", async (ctx) =>
{
await ctx.Response.WriteAsync("<h1>Hello World</h1>");
});
+
+ // #3
+ app.Map("/NewBranch", NewBranchEndpointFunc);
app.Run();
}
+
+ public static async Task NewBranchTerminalMiddleware(HttpContext ctx)
+ {
+ await ctx.Response.WriteAsync("New branch terminal middleware");
+ }
+
+ public static async Task NewBranchEndpointFunc(HttpContext ctx)
+ {
+ await ctx.Response.WriteAsync("New branch endpoint");
+ }
}
在上面的代码中,有三种Map
调用
app.Map
,调用的是M.A.MapExtensions.Map(this IApplicationBuilder app, string path Match, Action<IApplicationBuilder> configuration)
的重载,语义是开一个新的request pipeline分支app.MapGet
和app.Map
,调用的是M.A.Builder.EndpointRouteBuilderExtensions
类中的MapGet
和Map
重载,语义是向路由表中添加一个新的endpoint我们可以预见的是,最终响应/NewBranch
请求的肯定是#1处注册的分支pipeline里的NewBranchTerminalMiddleware
,但有意思的是,如下图所示,在NewBranchTerminalMiddleware
执行时,我们会发现,ctx.Endpoint
不为null
,反倒还指向了NewBranchEndpointFunc
,如下图所示:
这是为什么呢?答案其实也很简单:因为我们没有显式的调用app.UseRouting()
,所以EndpointRoutingMiddleware
第一时间执行了。。而倒霉的是,虽然对于这个单个请求,EndpointRoutingMiddleware
虽然给出了路由结果,但请求在request pipeline中走了一个分支,而那个分支里,并没有EndpointMiddleware
要解决掉这个反直觉的问题,只需要将app.UseRouting()
放在#1号分支之后调用即可。
话题有点扯远了,我们回过头来看路由功能的设计
老版的设计奠定了asp .net core框架中路由的设计:
EndpointRoutingMiddleware
和EndpointMiddleware
两个middleware将路由功能拆分成了“匹配”与“执行”两部分,这个设计在新版中也没有被更改,新版试图通过修修补补的方式让老版本中的问题不再蛋疼
新版本试图告诉开发者,“路由表”是写给框架看的,所以endpoint可以直接通过app.Mapxxx
系列方法直接注册,而不需要与任何“middleware的注册”捆绑起来
新版本试图淡化EndpointRoutingMiddleware
和EndpointMiddleware
两个middleware,而是假装在表面上,让asp .net core的路由机制更简洁一点:用户只需要注册endpoint就可以了,不需要管其它乱七八糟的
而实际开发需求当中,开发者也确实不需要太过关心路由机制的细节,开发者关心的只是:让我的函数/方法能接收到我希望接收到的请求即可
我猜这也是为什么在不显式调用UseRouting
和UseEndpoints
的情况下,调试器中看不到EndpointRoutingMiddleware
和EndpointMiddleware
的原因
但是代价就是:这些暗示、引导以及语焉不详的文档,为开发者正确了解路由机制增加了障碍
现在回到我们没有正式回答的两个问题身上:
app.MapRazorPages()
,app.MapControllers()
等一大堆Mapxx
方法是在干嘛?为什么我没有在app.Middleware
字段中见到对应的middleware现在答案就非常明显了:
app.Mapxxx()
这些方法,并不是在定义middleware,而是在注册endpoint。我们通过dotnet new webapp --use-program-main -o HelloRazorPages
来创建一个Razor页面项目,现在对于默认模板中写的Program.cs
,相信你有了更深的理解。
我们在Pages/Index.cshtml.cs
的OnGet
方法上打一个断点,然后观察请求,有以下现象:
就很显然了,Razor页面在路由机制的视角下,就是一个endpoint,它有endpoint的两大要素:
Pages
目录下的相对位置,以及页面上的@page
指令,为每个页面生成一个或多个请求路径我们在Program.cs
末尾再添加对app.MapControllers()
的调用,如下:
app.MapRazorPages();
+ app.MapControllers();
app.Run();
}
然后新建文件Controllers/RandomNumberController.cs
,代码如下:
using Microsoft.AspNetCore.Mvc;
namespace HelloRazorPages.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class RandomNumberController : ControllerBase
{
private Random r;
public RandomNumberController()
{
this.r = new Random();
}
[HttpGet("{count}")]
public IEnumerable<int> Get(int count)
{
List<int> res = new List<int>();
for(int i = 0; i < count; ++i)
{
res.Add(this.r.Next());
}
return res;
}
}
}
然后我们在Get
方法上打断点,然后在浏览器中访问api/RandomNumber/3
,会观察到如下:
示例至此,就无需我再多言了。
现在.net 8.0时代,我们可以简单的认为,asp .net core的核心重点有两个:
这两个是整个框架的基础功能中的基础功能,框架的大部分其它功能,都是构建在这两个设计之上的:
这篇文章没有给你讲怎么写Razor页面,怎么写API Controller,怎么从query string或者请求体中解析参数,只是给你讲了asp .net core最基本的设计。
因为我们系列教程的目的是使用Blazor来写出一个完整的、可部署的、有实际价值的项目。之所以要讲asp .net core的基础知识,是因为Blazor是运行在asp .net core框架里的一个渲染框架,也因为Blazor本身只负责页面渲染,而像认证、授权、数据库交互等方面功能的实现,其实都是asp .net core框架的相关知识。
Blazor只是一个渲染UI的框架,对于一个完整的项目来说,渲染UI只是其中的一部分工作而已。