ASP.NET Core 6框架揭祕例項演示[24]:中介軟體的多種定義方式

語言: CN / TW / HK

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!");

執行該程式後,我們利用瀏覽器對應用監聽地址(“http://localhost:5000”)傳送請求,兩個中介軟體寫入的字串會以圖1所示的形式呈現出來。

圖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所示的方式輸出請求的瀏覽器上。

圖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物件都會在應用處理完當前請求之後被釋放。

圖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”環境下才會被開啟。

圖4針對Scoped服務的驗證