ASP.NET Core 6框架揭秘实例演示[24]:中间件的多种定义方式

ASP.NET Core的请求处理管道由一个服务器和一组中间件组成,位于 “龙头” 的服务器负责请求的监听、接收、分发和最终的响应,针对请求的处理由后续的中间件来完成。中间件最终体现为一个Func<RequestDelegate, RequestDelegate>委托,但是我们具有不同的定义和注册方式。(本篇提供的实例已经汇总到《ASP.NET Core 6框架揭秘-实例演示版》)

[S1505]以Func<RequestDelegate, RequestDelegate>形式定义中间件(源代码
[S1506]定义强类型中间件类型(源代码
[S1507]定义基于约定的中间件类型(源代码
[S1508]查看默认注册的服务(源代码
[S1509]中间件类型的构造函数注入(源代码
[S1510]中间件类型的方法注入(源代码
[S1511]服务实例的周期(源代码
[S1512]针对服务范围的验证(源代码

[S1505]以Func<RequestDelegate, RequestDelegate>形式定义中间件

如下所示的演示程序创建了两个Func<RequestDelegate, RequestDelegate>委托,它们会在响应中写入两个字符串(“Hello”和“World!”)。在创建出代表承载应用的WebApplication对象之后,我们将其转成IApplicationBuilder接口后(IApplicationBuilder接口的Use方法在WebApplication类型中是显式实现的,所以不得不作这样的类型转换),我们调用其Use方法将这两个委托对象注册为中间件。

var app = WebApplication.Create(args);
IApplicationBuilder applicationBuilder = app;
applicationBuilder
    .Use(Middleware1)
    .Use(Middleware2);
app.Run();

static RequestDelegate Middleware1(RequestDelegate next) => async context =>
    {
        await context.Response.WriteAsync("Hello");
        await next(context);
    };
static RequestDelegate Middleware2(RequestDelegate next) => context => context.Response.WriteAsync(" World!");

运行该程序后,我们利用浏览器对应用监听地址(“//localhost:5000”)发送请求,两个中间件写入的字符串会以图1所示的形式呈现出来。

image
图1 利用注册的中间件处理请求

[S1506]定义强类型中间件类型

如果采用强类型中间件类型定义方式,只需要实现如下这个IMiddleware接口。该接口定义了唯一的InvokeAsync方法来处理请求。这个InvokeAsync方法定义了两个参数,前者表示当前HttpContext上下文,后者是一个RequestDelegate委托,代表后续中间件组成的管道。如果当前中间件需要将请求分发给后续中间件进行处理,只需要调用这个委托对象即可,否则针对请求的处理就到此为止。

public interface IMiddleware
{
    Task InvokeAsync(HttpContext context, RequestDelegate next);
}

如下所示的演示程序定义了一个实现了IMiddleware接口的StringContentMiddleware中间件类型,实现的InvokeAsync方法将构造函数中指定的字符串作为响应的内容。由于中间件最终是采用依赖注入的方式来提供的,所以需要预先对它注册为服务。用于存放服务注册的 IServiceCollection对象可以通过WebApplicationBuilder的Services属性获得,演示程序利用它完成了针对StringContentMiddleware的服务注册。由于代表承载应用的WebApplication类型实现了IApplicationBuilder接口,所以我们直接调用它的UseMiddleware<TMiddleware>扩展方法来注册中间件类型。启动该程序后利用浏览器访问监听地址,依然可以得到图1所示的输出结果

var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<StringContentMiddleware>(new StringContentMiddleware("Hello World!"));
var app = builder.Build();
app.UseMiddleware<StringContentMiddleware>();
app.Run();

public sealed class StringContentMiddleware : IMiddleware
{
    private readonly string _contents;
    public StringContentMiddleware(string contents)=> _contents = contents;
    public Task InvokeAsync(HttpContext context, RequestDelegate next)=> context.Response.WriteAsync(_contents);
}

[S1507]定义基于约定的中间件类型

可能我们已经习惯了通过实现某个接口或者继承某个抽象类的扩展方式,其实这种方式有时显得约束过重,不够灵活,基于约定来定义中间件类型更常用。这种定义方式比较自由,因为它并不需要实现某个预定义的接口或者继承某个基类,而只需要遵循如下这些约定即可

  • 中间件类型需要有一个有效的公共实例构造函数,该构造函数必须包含一个RequestDelegate类型的参数,当中间件实例被创建的时候,代表后续中间件管道的RequestDelegate对象将与这个参数进行绑定。构造函数可以包含任意其他参数,RequestDelegate参数出现的位置也没有限制。
  • 针对请求的处理实现在返回类型为Task的InvokeAsync或者Invoke方法中,它们的第一个参数为HttpContext上下文。约定并未对后续参数作限制,但是由于这些参数最终由依赖注入框架提供,所以相应的服务注册必须存在。

这种方式定义的中间件依然通过前面介绍的UseMiddleware方法和UseMiddleware<TMiddleware>方法进行注册。由于这两个方法会利用依赖注入框架来提供指定类型的中间件对象,所以它会利用注册的服务来提供传入构造函数的参数。如果构造函数的参数没有对应的服务注册,就必须在调用这个方法的时候显式指定。

演示实例定义了如下这个StringContentMiddleware类型,它的InvokeAsync方法会将预先指定的字符串作为响应内容。StringContentMiddleware的构造函数定义了contents和forewardToNext参数,前者表示响应内容,后者表示是否需要将请求分发给后续中间件进行处理。在调用UseMiddleware<TMiddleware>扩展方法对这个中间件进行注册时,我们显式指定了响应的内容,至于参数forewardToNext,我们之所以没有每次都显式指定,是因为默认值的存在。

var app = WebApplication.CreateBuilder().Build();
app
    .UseMiddleware<StringContentMiddleware>("Hello")
    .UseMiddleware<StringContentMiddleware>(" World!", false);
app.Run();

public sealed class StringContentMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string _contents;
    private readonly bool _forewardToNext;

    public StringContentMiddleware(RequestDelegate next, string contents, bool forewardToNext = true)
    {
        _next 		        = next;
        _forewardToNext 	= forewardToNext;
        _contents 		= contents;
    }

    public async Task Invoke(HttpContext context)
    {
        await context.Response.WriteAsync(_contents);
        if (_forewardToNext)
        {
            await _next(context);
        }
    }
}

启动该程序后,利用浏览器访问监听地址依然可以得到图1所示的输出结果。对于前面介绍的形式定义的中间件,它们的不同之处除了体现在定义和注册方式上,还体现在自身生命周期上。强类型方式定义的中间件采用的生命周期取决于对应的服务注册,但是按照约定定义的中间件则总是一个单例对象。

[S1508]查看默认注册的服务

ASP.NET Core框架本身在构建请求处理管道之前会注册一些必要的服务,这些公共服务除了供框架自身消费外,也可以供应用程序使用。那么应用启动后究竟预先注册了哪些服务?我们编写了如下这个简单的程序来回答这个问题。

using System.Text;

var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.Run(InvokeAsync);
app.Run();

Task InvokeAsync(HttpContext httpContext)
{
    var sb = new StringBuilder();
    foreach (var service in builder.Services)
    {
        var serviceTypeName = GetName(service.ServiceType);
        var implementationType = service.ImplementationType?? service.ImplementationInstance?.GetType()
            ?? service.ImplementationFactory?.Invoke(httpContext.RequestServices)?.GetType();
        if (implementationType != null)
        {
           sb.AppendLine(@$"{service.Lifetime,-15}{GetName(service.ServiceType),-60}{ GetName(implementationType)}");
        }
    }
    return httpContext.Response.WriteAsync(sb.ToString());
}

static string GetName(Type type)
{
    if (!type.IsGenericType)
    {
        return type.Name;
    }
    var name = type.Name.Split('`')[0];
    var args = type.GetGenericArguments().Select(it => it.Name);
    return @$"{name}<{string.Join(",", args)}>";
}

演示程序调用WebApplication对象的Run扩展方法注册了一个中间件,它会将每个服务对应的声明类型、实现类型和生命周期作为响应内容进行输出。启动这段程序执行之后,系统注册的所有公共服务会以图2所示的方式输出请求的浏览器上。

image
图2 ASP.NET Core框架注册的公共服务

[S1509]中间件类型的构造函数注入

在构造函数或者约定的方法中注入依赖服务对象是主要的服务消费方式。对于以处理管道为核心的ASP.NET Core框架来说,依赖注入主要体现在中间件的定义上。由于ASP.NET Core框架在创建中间件对象并利用它们构建整个管道时,所有的服务都已经注册完毕,所以注册的任何一个服务都可以采用如下的方式注入到构造函数中。

using System.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
builder.Services
    .AddSingleton<FoobarMiddleware>()
    .AddSingleton<Foo>()
    .AddSingleton<Bar>();
var app = builder.Build();
app.UseMiddleware<FoobarMiddleware>();
app.Run();

public class FoobarMiddleware : IMiddleware
{
    public FoobarMiddleware(Foo foo, Bar bar)
    {
        Debug.Assert(foo != null);
        Debug.Assert(bar != null);
    }

    public Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        Debug.Assert(next != null);
        return Task.CompletedTask;
    }
}

public class Foo {}
public class Bar {}

[S1510]中间件类型的方法注入

上面演示的是强类型中间件的定义方式,如果采用约定方式来定义中间件类型,依赖服务还可以采用如下的方式注入用于处理请求的InvokeAsync或者Invoke方法中。

using System.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
builder.Services
    .AddSingleton<Foo>()
    .AddSingleton<Bar>();
var app = builder.Build();
app.UseMiddleware<FoobarMiddleware>();
app.Run();

public class FoobarMiddleware
{
    private readonly RequestDelegate _next;
    public FoobarMiddleware(RequestDelegate next) => _next = next;
    public Task InvokeAsync(HttpContext context, Foo foo, Bar bar)
    {
        Debug.Assert(context != null);
        Debug.Assert(foo != null);
        Debug.Assert(bar != null);
        return _next(context);
    }
}

public class Foo {}
public class Bar {}

[S1511]服务实例的周期

我们演示了如下的实例使读者对注入服务的生命周期具有更加深刻的认识,。如代码片段所示,我们定义了Foo、Bar和Baz三个服务类,它们的基类Base实现了IDisposable接口。我们分别在Base的构造函数和实现的Dispose方法中输出相应的文字,以确定服务实例被创建和释放的时机。

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Services
    .AddSingleton<Foo>()
    .AddScoped<Bar>()
    .AddTransient<Baz>();

var app = builder.Build();
app.Run(InvokeAsync);
app.Run();

static Task InvokeAsync(HttpContext httpContext)
{
    var path = httpContext.Request.Path;
    var requestServices = httpContext.RequestServices;
    Console.WriteLine($"Receive request to {path}");

    requestServices.GetRequiredService<Foo>();
    requestServices.GetRequiredService<Bar>();
    requestServices.GetRequiredService<Baz>();

    requestServices.GetRequiredService<Foo>();
    requestServices.GetRequiredService<Bar>();
    requestServices.GetRequiredService<Baz>();

    if (path == "/stop")
    {
        requestServices.GetRequiredService<IHostApplicationLifetime>().StopApplication();
    }
    return httpContext.Response.WriteAsync("OK");
}

public class Base : IDisposable
{
    public Base() => Console.WriteLine($"{GetType().Name} is created.");
    public void Dispose() => Console.WriteLine($"{GetType().Name} is disposed.");
}
public class Foo : Base {}
public class Bar : Base {}
public class Baz : Base {}

我们采用不同的生命周期对这三个服务进行了注册,并将针对请求的处理实现在InvokeAsync这个本地方法中。该方法会从HttpContext上下文中提取出RequestServices,并利用它“两次”提取出三个服务对应的实例。若请求路径为“/stop”,它会采用相同的方式提取出IHostApplicationLifetime对象,并通过调用其StopApplication方法将应用关闭。

我们采用命令行的形式来启动该应用程序,然后利用浏览器依次向该应用发送两个请求,采用的路径分别为 “/index”和“ /stop”,控制台上会出现如图3所示的输出。由于Foo服务采用的生命周期模式为Singleton,所以在整个应用的生命周期内只会创建一次。对于每个接收的请求,虽然Bar和Baz都被使用了两次,但是采用Scoped模式的Bar对象只会被创建一次,而采用Transient模式的Baz对象则被创建了两次。再来看释放服务相关的输出,采用Singleton模式的Foo对象会在应用被关闭的时候被释放,而生命周期模式分别为Scoped和Transient的Bar与Baz对象都会在应用处理完当前请求之后被释放。

image
图3 服务的生命周期

[S1512]针对服务范围的验证

Scoped服务既不应该由ApplicationServices来提供,也不能注入一个Singleton服务中,否则它将无法在请求结束之后被及时释放。如果忽视了这个问题,就容易造成内存泄漏,下面是一个典型的例子。下面的演示程序使用的FoobarMiddleware的中间件需要从数据库中加载由Foobar类型表示的数据。这里采用Entity Framework Core从SQL Server中提取数据,所以我们为实体类型Foobar定义的DbContext(FoobarDbContext),我们调用IServiceCollection接口的AddDbContext<TDbContext>扩展方法对它以Scoped生命周期进行了注册。

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseDefaultServiceProvider(options => options.ValidateScopes = false);
builder.Services.AddDbContext<FoobarDbContext>(options => options.UseSqlServer("{your connection string}"));
var app = builder.Build();
app.UseMiddleware<FoobarMiddleware>();
app.Run();

public class FoobarMiddleware
{
    private readonly RequestDelegate 	_next;
    private readonly Foobar? 		_foobar;
    public FoobarMiddleware(RequestDelegate next, FoobarDbContext dbContext)
    {
        _next = next;
        _foobar = dbContext.Foobar.SingleOrDefault();
    }

    public Task InvokeAsync(HttpContext context)
    {
        return _next(context);
    }
}

public class Foobar
{
    [Key]
    public string Foo { get; set; }
    public string Bar { get; set; }
}

public class FoobarDbContext : DbContext
{
    public DbSet<Foobar> Foobar { get; set; }
    public FoobarDbContext(DbContextOptions options) : base(options) { }
}

采用约定方式定义的中间件实际上是一个单例对象,而且它是在应用启动时中由ApplicationServices创建的。由于FoobarMiddleware的构造函数中注入了FoobarDbContext对象,所以该对象自然也成了一个单例对象,这就意味着FoobarDbContext对象的生命周期会延续到当前应用程序被关闭的那一刻,造成的后果就是数据库连接不能及时地被释放。

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseDefaultServiceProvider(options => options.ValidateScopes = true);
builder.Services.AddDbContext<FoobarDbContext>(options => options.UseSqlServer("{your connection string}"));
var app = builder.Build();
app.UseMiddleware<FoobarMiddleware>();
app.Run();
...

在一个ASP.NET Core应用中,如果将服务的生命周期注册为Scoped模式,我们希望服务实例真正采用基于请求的生命周期模式。我们可以通过启用针对服务范围的验证来避免采用作为根容器的IServiceProvider对象来提供Scoped服务实例。针对服务范围的检验开关可以调用IHostBuilder接口的UseDefaultServiceProvider扩展方法进行设置。如果我们采用上面的方式开启针对服务范围验证,启动该程序之后会出现图4所示的异常。由于此验证会影响性能,所以默认情况下此开关只有在“Development”环境下才会被开启。

image
图4 针对Scoped服务的验证