ASP.NET Core的請求處理管道由一個服務器和一組中間件組成,位于 “龍頭” 的服務器負責請求的監聽、接收、分發和最終的響應,針對請求的處理由后續的中間件來完成。中間件最終體現為一個Func<RequestDelegate, RequestDelegate>委托,但是我們具有不同的定義和注冊方式。[本文節選《ASP.NET Core 6框架揭秘》第15章]
[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服務的驗證