Blazor教程 第十二课前的番外篇

Blazor

截止目前,我们已经把Blazor作为一个前端框架,特别是WASM模式,本身的核心知识讲的七七八八了,虽然没有事无巨细面面俱到,但基本上把开发中最常用的核心知识都讲到了。

后续的文章的大方向,将是站在全栈开发的角度上,来补充其它方面的知识:比如认证授权,数据库交互,使用开源的组件库,部署上云等。

前面的文章我都只是在假定读者仅有面向对象编程基础,甚至于你其实并不需要掌握asp .net core的一些基础知识,只需要掌握C#的语法,或者仅掌握类Java的语法就可以了。

我们下一个要涉及的知识点,认证授权,则与asp .net core框架是深度集成的,虽然概念性的知识是通用的,框架无关,但具体实现严重依赖于asp .net core的设计与实现。

所以这里专开一页,快速的向大家补充一些asp .net core框架的基础知识。如果你对asp .net core比较熟悉,这篇番外篇文章是可以略过不看的

1. 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非常相似:

  1. 这两个框架一个用Java开发,一个用C#开发

  2. 两个框架写出来的程序都不是独立可执行的二进制,而是需要寄宿在一个web server程序里的类库

  3. 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回传给请求方

  4. 二者在开发过程中都使用到了“程序设计语言+HTML”混杂的一种标记语言,在Java这边这种语言就叫JSP,在ASP .NET这边这种语言叫Razor

    1. JSP和Razor,其实写的都是类:一个是Java的类,一个是.NET上的类
    2. 二者都是运行在服务端上的

时代在发展,社会在进步,服务端渲染落伍了,时代的大潮是前后端分离以及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应用:

  1. Razor依然支持,依然可以写服务端渲染
  2. 可以写纯web api项目
  3. 可以使用SingalR写出前后端实时交互的web项目

这些东西,广义上都可以叫,是在使用asp .net core框架,但回头过来看那个基本问题:asp .net core到底是什么?味道就变了:

如果我们把Razor引擎剥掉,把为了支持web api框架实现的那些MVC+Controller的设计剥掉,把SingalR剥掉,框架还剩下什么?

其实asp .net core框架就剩下了DI(依赖注入)和middleware component pipeline了,而这两个东西,又是什么呢?

2. DI是什么?

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的实例去初始化它。

而依赖注入的意思则是:我框架为你提供了一个大池子,里面可以存放各种对象:

  1. 你先把你要创建的所有对象,以及怎么创建它,告诉我
  2. 然后我在池子里把这些对象给你创建好,你用的时候直接从池子里拿就行了

而至于各种对象和类之间的依赖关系,你不要管,我自己来分析就行了,还以UserServiceDBService为例,用依赖注入的话,就可以在程序初始化的时候先写出类似下面的代码:

    {
        // ...
        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的构造函数。

所以要点:

  1. 向DI池中注册对象时,有两个东西比较关键:

    1. 对象的声称的类型,比如对象的实际类型是SQLDBService,但你可以声称它的类型为父类DBService,或接口IStorageService

    2. 如何创建这个对象,而不是对象本身。

      • 这可以通过传入一个工厂方法,或lambda表达式的方式去描述对象创建过程
      • 也可以通过传入实际类型的方式,告诉DI机制:你自己去用反射看构造函数吧
  2. 从DI池中取对象的时候,只能按当初注册对象时声称的类型去取,而不能以实际类型去取

    比如我们以DI.AddService<IStorageService, SQLDBService>()的方式向池中注册了一个对象

    • 其中第一个类型参数是“声称类型”,第二个类型参数是“实际类型”

    那么我们以DI.GetService<IStorageService>()去取的话,就会取到这个对象,而以DI.GetService<SQLDBService>()的试去取的话,是取不到这个对象的。

当然,实际编程的时候,具体向DI注册对象,以及取出对象的方法到底是什么,需要实际去查看文档,但整个DI机制的原理就是上面这样,它的核心优势在于:

  1. 把程序员从繁重的对象依赖关系中解放了出来,DI池可以从千万个注册信息中,分析出依赖关系,然后以正确的顺序把所有对象都初始化出来
  2. 程序员能更进一步的对程序去解耦,大量使用接口来描述依赖而非实际类型,反正有DI机制为我们降低了心智负担

asp .net core中的依赖注入

对于Web应用来说,DI还要再新增一个概念:对象的生命周期。

在Web框架中,DI中的对象有三类:

  1. Transient: 每次你向DI要对象的时候,DI都给你创建一个全新的

  2. Scoped: 在单次HTTP请求处理过程中,你向DI要一个指定类型的对象,无论要一次还是多次,DI给你返回的都是同一个对象

    但这个对象在这次HTTP请求处理结束后就会被析构,它的生命和HTTP请求是等长的

    如果你的程序在某个时刻同时在处理30个HTTP请求,那么对于每个请求的处理栈来说,它们都有各自独立的对象

  3. 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池中注入的对象,如下图所示:

default_di_objects

注意上面的ServiceDescriptors字段仅有在调试器窗口中才能看出来,这是因为app.Services的实际类型是Microsoft.Extensions.DependencyInjection.ServiceProvider类,而这个类的源代码在脑门上定义了[DebuggerTypeProxy(typeof(ServiceProviderDebugView))]

作为框架的使用者,我们没必要一个一个的去学习这自带的一百多个对象的类型,况且这只是框架没有添加任何其它内容,甚至没有提供任何处理HTTP请求能力的情况下,就有一百多个对象被注册了。而常规来说,一个有着实际意义的asp .net core项目,即便程序员自己不向DI池中添加任何自定义对象,框架自带的那些对象的数目也会暴涨到300多个甚至更多。

好,有关DI的内容就简单讲到这里,接下来我们来看asp .net core框架是怎么处理HTTP请求的

2. middleware component pipeline

2.1 先来写一个最简单的asp .net core程序

下面是一个最简单的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>");
    }
}

运行效果如下:

核心代码就三行:

  1. WebApplication app = WebApplication.CreateBuilder(args).Build()

    就如何字面意思那样,创建了一个Web程序

  2. app.Run(SayHello)

    这里的Run方法是添加了一个middleware component,这个Run定义在Microsoft.AspNetCore.Builder.RunExtensions扩展类中,官方文档对其的说明是:

    Adds a terminal middleware delegate to the application's request pipeline.
    
  3. 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,则会有如下结果:

  1. 使用以下命令测试

    > 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
  2. 发送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
    

2.2 再来说什么是middleware

asp .net core框架的设计使用了一种处理HTTP请求常用的设计模式,这个设计模式叫什么名字我已经忘了,但它的设计如下图所示

request-delegate-pipeline

上面图中的每个Middleware你可以暂时简单的理解为如下的函数:

async Task Middlewarexxx(HttpContext ctx, RequestDelegate next)
{
    // 操作ctx对象,对这个Http请求进行操作

    if(xxx)
    {
        await next.Invoke(ctx);
    }
    else
    {
        return;
    }
}

Middleware的关键点在于:

  1. 它本质上是个函数,内部可以通过ctx对象来获取到有关这个HTTP请求的所有信息,也可以通过ctx对象来决定如何响应这个HTTP请求

    • 比如可以向ctx.Response中写入内容,来编辑Response
    • 比如编辑ctx.Response.StatusCode来向客户端发送一个非200的Response
    • 比如调用ctx.Response.Redirect向客户端发送一个重定向Response
    • 比如通过ctx.Response.Cookies来设定cookie,或者ctx.Response.Headersctx.Response.BodyWriter直接编辑Response的头部字段和body
  2. 在操作ctx之后,这个函数内部可以选择

    • 调用next.Invoke(ctx),其中next就是“下一个Middleware函数”
    • 不调用next.Invoke(ctx),函数就此返回
  3. 如果函数调用了next.Invoke(ctx),在await next.Invoke(ctx)之后,这个middleware还可以继续对ctx对象进行操作

    await next.Invoke(ctx)之后再对ctx进行操作的话,就意味着当前的HTTP请求对象ctx已经经过了后续Middleware的处理

    这也是上图中,返回路径的含义

框架用这种设计,暗示所有使用者对功能进行分层,比如对于一个论坛来说,非登录用户可以查看帖子,而只能登录用户才能发帖和回复,那么整个网站的middleware设计就可以分为以下几层:

  1. 第一层:认证授权层,伪代码如下:

    async Task AuthMiddleware(HttpContext ctx, ReuestDelegate next)
    {
        if(用户访问的是需要登录才能查看的API:即发贴或回复)
        {
            if(ctx中没有包含认证信息或权限不正确)
            {
                return 401 权限不足
            }
        }
    
        await next.Invoke(ctx);
    }
  2. 第二层:渲染页面的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方法。

这两个方法的区别在于:

  1. Use方法是正常的添加Middleware的方法

  2. 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>");
    }
}

实验结果如下:

pipeline_test

而像这种,由多个Middleware按顺序排列形成的,处理HTTP请求的流水线,这就是request pipeline,有时也叫middleware pipeline

而Middleware本身,在文档中也有多种称呼:有时叫middleware,有时叫middleware component,有时直接干脆叫component

2.3 不对啊,我平常写的代码不长这样样子啊!

你可能会说,为什么我也对asp .net core略知一二,但我从来没写过上面的那种函数?也没调过app.Useapp.Run来注册这些函数:我平常都是在Controller里写代码啊!或者我都是在Pages目录里写razor页面啊,那我写的Controller或者razor页面,算是middleware吗?

这里有几个灰色地带需要理解一下

2.3.1 首先,Middleware并不一定是一个函数的模样

我们上面把Middleware描述成了一个函数的样子,其实是不太对的。只是它可以写成函数的样子,并不意味着所有的Middleware都是一个简单的函数。

从概念上来讲,Middleware正确的定义,是:一段处理HTTP请求的代码,即:

  • 它不一定是一个函数的样子,可能被写得非常复杂,包装成N个类的样子
  • 将它注册在request pipeline中也不只有app.Useapp.Run两种途径,框架的内部实现可能以非常扭曲的方式去挂载一个非常复杂的middleware
  • 只是对于框架的使用者而已,将middleware实现成函数的样子,再用app.Useapp.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背后的代码逻辑都非常复杂。而下面,是一张官方文档中的图:

middleware_pipeline

这张图描绘的,就是一个典型的asp .net core应用中,常见的,官方自带的默认request pipeline长得样子。

这张图和我们上面贴的模板代码基本是能对上的,除了

  1. 图上在Routing之后,有CORSAuthentication两个middleware,代码中是没有的

  2. 图上在Authorization之后,画了两个Custom middlewares,而代码中是没有的

    图上这两个Custom middlewares其实指代的就是程序员自定义的middleware

  3. 图上最后一个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字段,如下:

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.MiddlewareServiceProvider.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

那么至此,我们可以得出两个初步结论:

  1. 框架向使用者暴露的app.Useapp.Run方法,是最简单的注册自定义middleware的方法,使用这两个方法,需要把middleware的逻辑封装成函数的模样
  2. 框架自带的大部分middleware,则写成了类的模样,通过app.UseMiddleware方法注册进request pipeline中

而我们在Program.cs中调用的各种Usexxx方法,其实就是将app.UseMiddleware包装了一层之后的扩展方法而已。

2.3.2 既然middleware可以写成类的模样,具体应该怎么做呢?

这里我们可以翻开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的话,有以下要求:

  1. 有一个名为InvokeInvokeAsync的实例方法,第一个参数必须是HttpContext ctx,返回值是Task

    • 方法可以有额外的参数,这些参数需要在注册时,以app.UseMiddleware<MiddlewareType>(额外参数)的方式提供,或通过DI容器自动注入
  2. 通过构造函数接收next,即后续的middleware

在执行时,这个类会被框架实例化成一个对象,而这个对象的生命周期是与程序等长的:即无论来多少个请求,调用的都是同一个实例的InvokeInvokeAsync方法

所以这就导致它有一个非常大的缺陷:在从DI池中拿东西的时候要非常小心

  • 如果它需要从DI池中拿一个Singlton对象的话,既可以通过构造函数注入,也可以通过Invoke方法注入
  • 如果它需要从DI池中拿一个Scoped对象的话,就不能通过构造函数注入了,只能通过Invoke方法注入,因为构造函数只会执行一次
  • 如果它需要从DI池中拿一个Transient对象的话,则需要按实际情况来看,是否能使用构造函数注入

下面是一个例子,包含以下要素:

  • 通过构造函数注入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>();
    }
}

它使用的时候要如下使用,要点有:

  • 注意要向DI池先注册InvokeAsync所需要的Scoped对象
  • 自定义pipeline一般情况下都放在最后的位置,紧挨着下一步要调用的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>();
     }
 }

2.3.3 等会,我还是不知道Controller或者razor页面算不算是middleware

我们讲明白了框架自带的Middleware,也讲明白了怎么通过类的方式去实现、注册middleware。

还有个问题没说,回到我们之前的话题上:

  • app.MapRazorPages()app.MapControllers()等一大堆Mapxx方法是在干嘛?为什么我没有在app.Middleware字段中见到对应的middleware
  • 像Controller,Razor页面等,是middleware吗?

这也是asp .net core整体框架设计中最让人感到困惑的地方。而这些问题的答案,就是asp .net core中一个非常重要的知识点:路由

接下来,我们新开一个章节来单独讲路由,当你明白路由是怎么回事后,这些问题也就自然有了答案。

注意,下面所讲的所有路由相关的知识,都是服务端的路由,和我们之前讲Blazor框架时的路由不是一回事:

  • 服务端的路由讲的是当服务端接收一个请求后,服务端的代码根据请求路径,选择一坨服务端的代码去响应这个请求
  • Blazor框架中的路由,特别是WASM模式下的路由,是用户在浏览器变更URL后,浏览器内部的Blazor WASM代码在本地选择一坨代码,去渲染不同的页面组件

3. 路由

我们再来看这张图:

middleware_pipeline

通过上面的介绍,你已经明白了从Exception HandlerAuthorization,乃至于图中的两个自定义middleware是怎么回事。

而图中最后一个Endpoint,其实也是个Middleware,只不过它比较特殊,它需要配合着Routing一起使用

3.1 什么是endpoint

我们先看下面的代码:

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);
    }
}

上面这个程序运行起来的样子是这样的:

routing_endpoints_1

app.Run();处打断点,可以观察到,上面的代码向request pipeline中添加了两个middleware,分别是EndpointRoutingMiddlewareEndpointMiddleware

two_middlewares

代码非常简单易懂,现在我们来说一下语义:

“endpoint”有两层意思

  • 作为一个“middleware”来讲,它描述的是在request pipeline中最后一个执行的middleware

    • 这个middleware也比较特殊,特殊的点在于

      • 它的执行代码并不是一个简单的函数,或者一段被封装在某个类中的固定代码,而是需要由框架的使用者来自定义
      • 框架的使用者需要向它传递一张路由表(上面ConfigureEndpoints函数中的三行代码就是在描述路由表),而这个middleware在执行时,会根据路由表的描述,将请求分发至不同的函数中去
  • 作为路由表中的项而言

    • 它包含两个部分:

      1. “匹配条件”:我将匹配什么样的请求
      2. “执行代码”:匹配到请求后,我将做什么动作
    • 其实就是上面ConfigureEndpoints函数中,在调用eb.Map时提供的两个参数:两个参数合并在一起,就是一个概念上的endpoint

    • 从这个角度来看,我们在asp .net core项目中书写的Razor页面,或者controller之类的代码,其实都是在描述路由表项,也就是在描述一个个的endpoint

  • 为了避免术语混乱,我们后续将使用EndpointMiddleware来指代middleware概念,用endpoint来指代路由表项的概念

那么从理论上来说,对于上面这个代码例子,其实我们只需要整个request pipeline中有EndpointMiddleware存在就可以了,那那个UseRouting带来的EndpointRoutingMiddleware有什么存在的意义呢?

而且当我们把app.UseRouting();注释掉的话,框架会在初始化时期抛出一个异常,如下:

userouting_is_required

3.2 什么是routing

确实,从理论上来说,对于我们上面的代码例子,我们只需要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();
    }

这样做也没有问题,但有一点点不方便:用户在新写一个页面的时候,需要做两件事:

  1. 实现页面逻辑,比如是Razor页面的话,需要去Pages/目录下新建一个页面
  2. 要去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即将被执行的前提下,直接通过反射去拿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

那么,这里再总结一下:

  1. app.UseRouting()

    • 这行代码在被执行时,是在向request pipeline中添加EndpointRoutingMiddleware
    • EndpointRoutingMiddleware在处理HTTP请求时,它的功能是为当前请求匹配出一个endpoint,并把这个endpoint的信息写进ctx.Endpoint属性上
  2. app.UseEndpoints(ConfigureEndpoints)

    • 这行代码在被执行时,有两个功能

      1. 向request pipeline中添加了EndpointMiddleware

      2. 向框架注册了路由表,即通过ConfigureEndpoints参数,说明了所有的endpoint

        • 这个事别扭的点就在这里:路由表其实是写给EndpointRoutingMiddleware看的,但却是在UseEndpoints,即向request pipeline中添加EndpointMiddleware的时候传入框架的
    • EndpointMiddleware在处理HTTP请求时,并不会理会ConfigureEndpoints参数,而是直接去执行ctx.Endpoint中记录的逻辑

这里最别扭的事情,就是“路由表”的注册时机:

  • 从书写代码的角度来看,我们是在注册EndpointMiddleware的时候向框架提供的路由表
  • 但从框架的内部实现来看,这个路由表其实是写给EndpointRoutingMiddleware看的
  • 从接受处理HTTP请求的角度来看,EndpointMiddleware其实完全不看路由表

3.3 再来回头看3.1章节的例子

这里再贴一遍代码,不过我们简化一下路由表,再在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

bp1

断点1执行后,EndpointRoutingMiddleware开始执行,开始查路由表,并把匹配结果写在ctx.Endpoint上,再之后就是EndpointMiddleware的执行,会调用到ctx.Endpoint属性指的代码块上,也就是HandleHomePageRequest函数,断点3处。

此时我们观察调试器窗口,会明显看到ctx.Endpoint属性中记录了请求路径,路由匹配结果:

bp2

再之后request pipeline正向已经执行结束,开始回退,当回退到自定义middleware的时候,能观察到,ctx.Endpoint依然存在:

bp3

请注意,我们以上的描述都只是为了简化理解,实际上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的提示写代码吧

3.4 为什么不调用UseRoutingUseEndpoint程序也能跑?

通过上面的讲解,你大致已经明白了在asp .net core框架中,EndpointRoutingMiddlewareEndpointMiddleware的功能与职责了:一个匹配路由,一个执行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里是空的:

no_middleware

而如果我们在endpoint逻辑体里打个断点,会发现,在处理HTTP请求时,看起来不光EndpointMiddleware生效了(不然不会进入到执行体内),好像EndpointRoutingMiddleware也生效了:因为ctx.Endpoint不为null

endpoint_is_not_null

这就很奇怪了,到底EndpointRoutingMiddlewareEndpointMiddleware有没有被注册到request pipeline里呢?

答案是有的。

那到底注册到哪了呢?答案是:

  1. 如果你没有主动调用UseRouting(),那么框架会自动把EndpointRoutingMiddleware添加到request pipeline中去。而至于添加到什么位置,你别管,总之:EndpointRoutingMiddleware它一定在任何自定义middleware之前被执行

  2. 如果你没有主动调用UseEndpoint(...),那么框架会自动把EndpointMiddleware添加到request pipeline中去,而位置,位于request pipeline的末尾

  3. 至于路由表,框架可以通过app.Map系列方式向路由表中添加endpoint,添加endpoint的位置无所谓,只需要在app.Run()之前就行

  4. 如果你没有主动调用UseRouting()UseEndpoint(...),那么EndpointRoutingMiddlewareEndpointMiddleware不会出现在调试器的app.Middleware字段中去,这是这个调试器专用字段实现上的缺陷

  5. 那么主动调用UseRoutingUseEndpoint的意义在哪里呢?

    1. 主动调用UseRouting的意义在于,可以调整EndpointRoutingMiddleware在request pipeline中的位置

    2. 主动调用UseEndpoint已经基本没有意义了,理论上来讲,也可以通过主动调用UseEndpoint来调整EndpointMiddleware在request pipeline的位置,但这在实践中几乎没有任何意义

以上的解释与变动,仅适用于.net sdk版本号 >= 6.0的情况,或者更准确一点来说,是在框架有了WebApplication相关的类定义之后。

3.5 老版本的.net sdk中,asp .net core是如何设计的

在老版本的.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!");
                });
            });
        }
    }
}

从代码书写的角度来讲,区别主要有:

  1. 老版本使用的是Host.CreateDefaultBuilder(args)来创建一个builder对象,而新版本使用的是WebApplication.CreateBuilder(args)来创建builder对象

    • 当然两者的返回值类型是不一样的,老版本是IHostBuilder,新版本是WebApplicationBuilder
  2. 老版本将“向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"

new_branch_terminal_middleware

新版本里,其实还保留了这个设计,即我们可以通过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调用

  • #1处的app.Map,调用的是M.A.MapExtensions.Map(this IApplicationBuilder app, string path Match, Action<IApplicationBuilder> configuration)的重载,语义是开一个新的request pipeline分支
  • #2和#3处的app.MapGetapp.Map,调用的是M.A.Builder.EndpointRouteBuilderExtensions类中的MapGetMap重载,语义是向路由表中添加一个新的endpoint

我们可以预见的是,最终响应/NewBranch请求的肯定是#1处注册的分支pipeline里的NewBranchTerminalMiddleware,但有意思的是,如下图所示,在NewBranchTerminalMiddleware执行时,我们会发现,ctx.Endpoint不为null,反倒还指向了NewBranchEndpointFunc,如下图所示:

interesting_routing

这是为什么呢?答案其实也很简单:因为我们没有显式的调用app.UseRouting(),所以EndpointRoutingMiddleware第一时间执行了。。而倒霉的是,虽然对于这个单个请求,EndpointRoutingMiddleware虽然给出了路由结果,但请求在request pipeline中走了一个分支,而那个分支里,并没有EndpointMiddleware

要解决掉这个反直觉的问题,只需要将app.UseRouting()放在#1号分支之后调用即可。

no_misleading_endpoint

话题有点扯远了,我们回过头来看路由功能的设计

  • 老版的设计奠定了asp .net core框架中路由的设计:

    • EndpointRoutingMiddlewareEndpointMiddleware两个middleware将路由功能拆分成了“匹配”与“执行”两部分,这个设计在新版中也没有被更改,
    • 只不过在书写代码时,老版本中有个很蛋疼的问题:路由表虽然是写给“匹配”看的,但在代码上却要写在注册“执行”middleware的时候
  • 新版试图通过修修补补的方式让老版本中的问题不再蛋疼

    • 新版本试图告诉开发者,“路由表”是写给框架看的,所以endpoint可以直接通过app.Mapxxx系列方法直接注册,而不需要与任何“middleware的注册”捆绑起来

    • 新版本试图淡化EndpointRoutingMiddlewareEndpointMiddleware两个middleware,而是假装在表面上,让asp .net core的路由机制更简洁一点:用户只需要注册endpoint就可以了,不需要管其它乱七八糟的

      而实际开发需求当中,开发者也确实不需要太过关心路由机制的细节,开发者关心的只是:让我的函数/方法能接收到我希望接收到的请求即可

      我猜这也是为什么在不显式调用UseRoutingUseEndpoints的情况下,调试器中看不到EndpointRoutingMiddlewareEndpointMiddleware的原因

    • 但是代价就是:这些暗示、引导以及语焉不详的文档,为开发者正确了解路由机制增加了障碍

3.6 回到我们最初的问题上:

现在回到我们没有正式回答的两个问题身上:

  • app.MapRazorPages()app.MapControllers()等一大堆Mapxx方法是在干嘛?为什么我没有在app.Middleware字段中见到对应的middleware
  • 像Controller,Razor页面等,是middleware吗?

现在答案就非常明显了:

  • app.Mapxxx()这些方法,并不是在定义middleware,而是在注册endpoint。
  • 像Controller,Razor页面等东西,本质上就是一个endpoint

我们通过dotnet new webapp --use-program-main -o HelloRazorPages来创建一个Razor页面项目,现在对于默认模板中写的Program.cs,相信你有了更深的理解。

我们在Pages/Index.cshtml.csOnGet方法上打一个断点,然后观察请求,有以下现象:

razor_endpoint

就很显然了,Razor页面在路由机制的视角下,就是一个endpoint,它有endpoint的两大要素:

  1. 请求路径匹配逻辑:Razor引擎会按Pages目录下的相对位置,以及页面上的@page指令,为每个页面生成一个或多个请求路径
  2. 执行主体:那自然是Razor引擎转译后的服务端渲染逻辑了

我们在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,会观察到如下:

controller_endpoint

示例至此,就无需我再多言了。

4. 小总结

现在.net 8.0时代,我们可以简单的认为,asp .net core的核心重点有两个:

  1. DI
  2. middleware pipeline

这两个是整个框架的基础功能中的基础功能,框架的大部分其它功能,都是构建在这两个设计之上的:

  • 大部分与“处理HTTP请求”无关的功能,都以对象的形式被注册在DI池子里,最典型的就比如日志功能
  • 而大部分与“处理HTTP请求”有关的功能,都以middleware的形式被实现了,最典型的就是认证和授权

这篇文章没有给你讲怎么写Razor页面,怎么写API Controller,怎么从query string或者请求体中解析参数,只是给你讲了asp .net core最基本的设计。

因为我们系列教程的目的是使用Blazor来写出一个完整的、可部署的、有实际价值的项目。之所以要讲asp .net core的基础知识,是因为Blazor是运行在asp .net core框架里的一个渲染框架,也因为Blazor本身只负责页面渲染,而像认证、授权、数据库交互等方面功能的实现,其实都是asp .net core框架的相关知识。

Blazor只是一个渲染UI的框架,对于一个完整的项目来说,渲染UI只是其中的一部分工作而已。