ASP.NET Core中介軟體初始化探究

yi念之間發表於2021-03-15

前言

    在日常使用ASP.NET Core開發的過程中我們多多少少會設計到使用中介軟體的場景,ASP.NET Core預設也為我們內建了許多的中介軟體,甚至有時候我們需要自定義中介軟體來幫我們處理一些請求管道過程中的處理。接下來,我們將圍繞著以下幾個問題來簡單探究一下,關於ASP.NET Core中介軟體是如何初始化的

  • 首先,使用UseMiddleware註冊自定義中介軟體和直接Use的方式有何不同
  • 其次,使用基於約定的方式定義中介軟體和使用實現IMiddleware介面的方式定義中介軟體有何不同
  • 再次,使用基於約定的方式自定義中介軟體的究竟是如何約束我們編寫的類和方法格式的
  • 最後,使用約定的方式定義中介軟體,通過構造注入和通過Invoke方法注入的方式有何不同

接下來我們將圍繞這幾個核心點來逐步探究關於ASP.NET Core關於中介軟體初始化的神祕面紗,來指導我們以後使用它的時候需要有注意點,來減少踩坑的次數。

自定義的方式

使用自定義中介軟體的方式有好幾種,我們們簡單來演示一下三種比較常用方式。

Use方式

首先,也是最直接最簡單的使用Use的方式,比如

app.Use(async (context, next) =>
{
    var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
    if (endpoint != null)
    {
        ResponseCacheAttribute responseCache = endpoint.Metadata.GetMetadata<ResponseCacheAttribute>();
        if (responseCache != null)
        {
            //做一些事情
        }
    }
    await next();
});
基於約定的方式

然後使用UseMiddleware也是我們比較常用的一種方式,這種方式使用起來相對於第一種來說,雖然使用起來可能會稍微繁瑣一點,畢竟需要定義一個類,但是更好的符合符合物件導向的封裝思想,它的使用方式大致如下,首先定義一個Middleware的類

public class RequestCultureMiddleware
{
    private readonly RequestDelegate _next;
    public RequestCultureMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var cultureQuery = context.Request.Query["culture"];
        if (!string.IsNullOrWhiteSpace(cultureQuery))
        {
            var culture = new CultureInfo(cultureQuery);
            CultureInfo.CurrentCulture = culture;
            CultureInfo.CurrentUICulture = culture;
        }
        await _next(context);
    }
}

編寫完成之後,需要手動的將類註冊到管道中才能生效,註冊方式如下所示

app.UseMiddleware<RequestCultureMiddleware>();
實現IMiddleware的方式

還有一種方式是實現IMiddleware介面的方式,這種方式比如前兩種方式常用,但是也確確實實的存在於ASP.NET Core中,既然存在也就有它存在的理由,我們也可以探究一下,它的使用方式也是需要自定義一個類去實現IMiddleware介面,如下所示

public class RequestCultureOtherMiddleware:IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var cultureQuery = context.Request.Query["culture"];
        if (!string.IsNullOrWhiteSpace(cultureQuery))
        {
            var culture = new CultureInfo(cultureQuery);
            CultureInfo.CurrentCulture = culture;
            CultureInfo.CurrentUICulture = culture;
        }
        await next(context);
    }
}

這種方式和第二種方式略有不同,需要手動將中介軟體註冊到容器中,至於宣告週期也沒做特殊要求,可以直接註冊為單例模式

services.AddSingleton<IMiddleware,RequestCultureOtherMiddleware>();

完成上步操作之後,同樣也需要將其註冊到管道中去

app.UseMiddleware<RequestCultureOtherMiddleware>();

這種方式相對於第二種方式的主要區別在於靈活性方面的差異,它實現了IMiddleware介面,那就要受到IMiddleware介面的約束,也就是我們常說的里氏代換原則,首先我們可以先來看下IMiddleware介面的定義[點選檢視原始碼?]

public interface IMiddleware
{
	/// <summary>
	/// 請求處理方法
	/// </summary>
	/// <param name="context">當前請求上下文</param>
	/// <param name="next">請求管道中下一個中介軟體的委託</param>
	Task InvokeAsync (HttpContext context, RequestDelegate next);
}

通過這個介面也就看出來InvokeAsync只能接受HttpContext和RequestDelegate引數,無法定義其他形式的引數,也沒辦法通過注入的方式編寫InvokeAsync方法引數,說白了就是沒有第二種方式靈活,受限較大。
關於常用的自定義中介軟體的方式,我們就先說到這裡,我們也知道了如何定義使用中介軟體。接下來我們就來探討一下,這麼多種方式之間到底存在怎樣的聯絡。

原始碼探究

上面我們已經演示了關於使用中介軟體的幾種方式,那麼這麼幾種使用方式之間有啥聯絡或區別,我們只看到了表面的,接下來我們來看一下關於中介軟體初始化的原始碼來一探究竟。
首先,無論那種形式都是基於IApplicationBuilder這個介面擴充套件而來的,所以我們先從這裡下手,找到原始碼IApplicationBuilder位置[點選檢視原始碼?]可以看到以下程式碼

/// <summary> 
/// 將中介軟體委託新增到應用程式的請求管道。
/// </summary> 
/// <param name="middleware">中介軟體委託</param> 
/// <returns>The <see cref="IApplicationBuilder"/>.</returns> 
IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware); 

IApplicationBuilder介面裡只有Use的方式可以新增中介軟體,由此我們可以大致猜到兩點資訊

  • 其它新增中介軟體的方式,都是在擴充套件自IApplicationBuilder,並不是IApplicationBuilder本身的方法。
  • 其它新增中介軟體的形式,最終都會轉換為Use的方式。
Use擴充套件方法

上面我們看到了IApplicationBuilder只包含了一個Use方法,但是我們日常程式設計中最常使用到的卻並不是這一個,而是來自UseExtensions擴充套件類的Use擴充套件方法,實現如下所示[點選檢視原始碼?]

public static IApplicationBuilder Use(this IApplicationBuilder app, Func<HttpContext, Func<Task>, Task> middleware)
{
   //將middleware轉換為Use(Func<RequestDelegate, RequestDelegate> middleware)的形式
    return app.Use(next =>
    {
        return context =>
        {
            Func<Task> simpleNext = () => next(context);
            return middleware(context, simpleNext);
        };
    });
}

如預料的那樣,Use的擴充套件方法最終都會轉換為Use(Func<RequestDelegate, RequestDelegate> middleware)的形式去執行。Use擴充套件方法的形式還是比較清晰的,畢竟也是基於委託的形式,而且引數是固定的。

UseMiddleware

上面我們看到了Use的擴充套件方法,它最終還是轉換為Use(Func<RequestDelegate, RequestDelegate> middleware)的形式去執行。接下來我們來看下通過編寫類的形式定義中介軟體會是怎樣的轉換操作。找到UseMiddleware擴充套件方法所在的地方,也就是UseMiddlewareExtensions擴充套件類裡[點選檢視原始碼?],我們最常用的是UseMiddleware這個方法,而且這個方法是UseMiddlewareExtensions擴充套件類的入口方法[點選檢視原始碼?],說白了就是它是完全呼叫別的方法沒有自己的實現邏輯

/// <summary> 
/// 將中介軟體型別新增到應用程式的請求管道.
/// </summary> 
/// <typeparam name="TMiddleware">中介軟體型別</typeparam> 
/// <param name="args">傳遞給中介軟體型別例項的建構函式的引數.</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); 
} 

繼續向下看找到它呼叫的擴充套件方法,在展示該方法之前我們先羅列一下該類的常量屬性,因為類中的方法有用到,如下所示

internal const string InvokeMethodName = "Invoke"; 
internal const string InvokeAsyncMethodName = "InvokeAsync"; 

從這裡我們可以得到一個資訊,基於約定的形式自定義的中介軟體觸發方法名可以是Invoke或InvokeAsync

繼續看執行方法的實現程式碼

public static IApplicationBuilder UseMiddleware(this IApplicationBuilder app, [DynamicallyAccessedMembers(MiddlewareAccessibility)] Type middleware, params object[] args)
{
    //判斷自定義的中介軟體是否是實現了IMiddleware介面
    if (typeof(IMiddleware).GetTypeInfo().IsAssignableFrom(middleware.GetTypeInfo()))
    {
        //Middleware不支援直接傳遞引數
        //因為它是註冊到容器中的,所以不能通過建構函式傳遞自定義的引數,否則丟擲異常
        if (args.Length > 0)
        {
            throw new NotSupportedException(Resources.FormatException_UseMiddlewareExplicitArgumentsNotSupported(typeof(IMiddleware)));
        }
        //實現IMiddleware介面的中介軟體走的是這個邏輯,我們們待會看
        return UseMiddlewareInterface(app, middleware);
    }

    var applicationServices = app.ApplicationServices;
    return app.Use(next =>
    {
        //獲取自定義中介軟體類的非靜態public方法
        var methods = middleware.GetMethods(BindingFlags.Instance | BindingFlags.Public);
        //查詢方法名為Invoke或InvokeAsync的方法
        var invokeMethods = methods.Where(m =>
            string.Equals(m.Name, InvokeMethodName, StringComparison.Ordinal)
            || string.Equals(m.Name, InvokeAsyncMethodName, StringComparison.Ordinal)
            ).ToArray();
        //方法名為Invoke或InvokeAsync的方法只能有有一個,存在多個話會丟擲異常
        if (invokeMethods.Length > 1)
        {
            throw new InvalidOperationException(Resources.FormatException_UseMiddleMutlipleInvokes(InvokeMethodName, InvokeAsyncMethodName));
        }
        //自定義的中介軟體類中必須包含名為Invoke或InvokeAsync的方法,否則也會丟擲異常
        if (invokeMethods.Length == 0)
        {
            throw new InvalidOperationException(Resources.FormatException_UseMiddlewareNoInvokeMethod(InvokeMethodName, InvokeAsyncMethodName, middleware));
        }
        //名為Invoke或InvokeAsync的方法的返回值型別必須是Task型別,否則會丟擲異常
        var methodInfo = invokeMethods[0];
        if (!typeof(Task).IsAssignableFrom(methodInfo.ReturnType))
        {
            throw new InvalidOperationException(Resources.FormatException_UseMiddlewareNonTaskReturnType(InvokeMethodName, InvokeAsyncMethodName, nameof(Task)));
        }
        //獲取Invoke或InvokeAsync方法的引數
        var parameters = methodInfo.GetParameters();
        //如果該方法不存在引數或方法的第一個引數不是HttpContext型別的例項,會丟擲異常
        if (parameters.Length == 0 || parameters[0].ParameterType != typeof(HttpContext))
        {
            throw new InvalidOperationException(Resources.FormatException_UseMiddlewareNoParameters(InvokeMethodName, InvokeAsyncMethodName, nameof(HttpContext)));
        }
        //定義新的陣列比傳遞的引數長度多一個,為啥呢?往下看。
        var ctorArgs = new object[args.Length + 1];
        //因為方法陣列的首元素是RequestDelegate型別的next
        //也就是基於約定定義的中介軟體建構函式的第一個引數是RequestDelegate型別的例項
        ctorArgs[0] = next;
        Array.Copy(args, 0, ctorArgs, 1, args.Length);
        //建立基於約定的中介軟體例項
        //又看到ActivatorUtilities這個類了,關於這個類有興趣的可以研究一下,可以根據容器建立型別例項,非常好用
        var instance = ActivatorUtilities.CreateInstance(app.ApplicationServices, middleware, ctorArgs);
        //如果Invoke或InvokeAsync方法只有一個引數,則直接建立RequestDelegate委託返回
        if (parameters.Length == 1)
        {
            //RequestDelegate其實就是public delegate Task RequestDelegate(HttpContext context);
            return (RequestDelegate)methodInfo.CreateDelegate(typeof(RequestDelegate), instance);
        }
        //編譯Invoke或InvokeAsync方法,關於Compile的實現等會我們們再看
        var factory = Compile<object>(methodInfo, parameters);
        //返回這個委託
        //看著這個委託的格式有點眼熟,其實就是RequestDelegate即public delegate Task RequestDelegate(HttpContext context);
        return context =>
        {
            var serviceProvider = context.RequestServices ?? applicationServices;
            //serviceProvider不能為空,否則沒法玩了
            if (serviceProvider == null)
            {
                throw new InvalidOperationException(Resources.FormatException_UseMiddlewareIServiceProviderNotAvailable(nameof(IServiceProvider)));
            }
            //返回委託執行結果
            return factory(instance, context, serviceProvider);
        };
    });
}

這個方法其實是工作的核心方法,通過這裡可以看出來,自定義中介軟體的大致執行過程。程式碼中的註釋我寫的比較詳細,有興趣的可以仔細瞭解一下,如果懶得看我們就大致總結一下大致的核心點

  • 首先UseMiddleware的本質確實還是執行的Use方法
  • 實現IMiddleware介面的中介軟體走的是獨立的處理邏輯,而且建構函式傳遞自定義的引數,因為它的資料來自於容器的注入。
  • 基於約定定義中介軟體的情況,即不實現IMiddleware的情況下。
    ①基於約定定義的中介軟體,建構函式的第一個引數需要是RequestDelegate型別
    ②查詢方法名可以為Invoke或InvokeAsync,且存在而且只能存在一個
    ③Invoke或InvokeAsync方法返回值需為Task,且方法的第一個引數必須為HttpContext型別
    ④Invoke或InvokeAsync方法如果只包含HttpContext型別引數,則該方法直接轉換為RequestDelegate
    ⑤我們之所以可以通過構造注入在中介軟體中獲取服務是因為基於約定的方式是通過ActivatorUtilities類建立的例項

通過上面的原始碼我們瞭解到了實現IMiddleware介面的方式自定義中介軟體的方式是單獨處理的即在UseMiddlewareInterface方法中[點選檢視原始碼?],接下來我們檢視一下該方法的程式碼

private static IApplicationBuilder UseMiddlewareInterface(IApplicationBuilder app, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type middlewareType)
{
    return app.Use(next =>
    {
        return async context =>
        {
            var middlewareFactory = (IMiddlewareFactory?)context.RequestServices.GetService(typeof(IMiddlewareFactory));
            if (middlewareFactory == null)
            {
                // 沒有middlewarefactory直接丟擲異常
                throw new InvalidOperationException(Resources.FormatException_UseMiddlewareNoMiddlewareFactory(typeof(IMiddlewareFactory)));
            }
            //建立middleware例項
            var middleware = middlewareFactory.Create(middlewareType);
            if (middleware == null)
            {
                throw new InvalidOperationException(Resources.FormatException_UseMiddlewareUnableToCreateMiddleware(middlewareFactory.GetType(), middlewareType));
            }

            try
            {
                //執行middleware的InvokeAsync方法
                await middleware.InvokeAsync(context, next);
            }
            finally
            {
                //釋放middleware
                middlewareFactory.Release(middleware);
            }
        };
    });
}

通過上面的程式碼我們可以看到,IMiddleware例項是通過IMiddlewareFactory例項建立而來,ASP.NET Core中IMiddlewareFactory預設註冊的實現類是MiddlewareFactory,接下來我們看下這個類的實現[點選檢視原始碼?]

public class MiddlewareFactory : IMiddlewareFactory
{
    private readonly IServiceProvider _serviceProvider;

    public MiddlewareFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IMiddleware? Create(Type middlewareType)
    {
        //根據型別從容器中獲取IMiddleware例項
        return _serviceProvider.GetRequiredService(middlewareType) as IMiddleware;
    }

    public void Release(IMiddleware middleware)
    {
        //因為容器控制了物件的生命週期,所以這裡啥也沒有
    }
}

好吧,其實就是在容器中獲取的IMiddleware例項,通過這個我們就可以總結出來實現IMiddleware介面的形式建立中介軟體的操作

  • 需要實現IMiddleware介面,來約束中介軟體的行為,方法名只能為InvokeAsync
  • 需要手動註冊IMiddleware和實現類到容器中,生命週期可自行約束,如果生命週期為Scope或瞬時,那麼每次請求都會建立新的中介軟體例項
  • 沒辦法通過InvokeAsync方法注入服務,因為受到了IMiddleware介面的約束

上面我們看到了實現IMiddleware介面的方式中介軟體是如何被初始化的,接下來我們繼續來看,基於約定的方式定義的中介軟體是如何被初始化的。通過上面我們展示的原始碼可知,實現邏輯在Compile方法中,該方法整體實現方式就是基於Expression,主要原因個人猜測有兩點,一個是形式比較靈活能應對的場景較多,二是效能稍微比反射好一點。在此之前,我們先展示一下Compile方法依賴的操作,首先反射是獲取UseMiddlewareExtensions類的GetService方法操作

private static readonly MethodInfo GetServiceInfo = typeof(UseMiddlewareExtensions).GetMethod(nameof(GetService), BindingFlags.NonPublic | BindingFlags.Static)!;

其中GetService方法的實現如下所示,其實就是在容器ServiceProvider中獲取指定型別例項

private static object GetService(IServiceProvider sp, Type type, Type middleware)
{
    var service = sp.GetService(type);
    if (service == null)
    {
        throw new InvalidOperationException(Resources.FormatException_InvokeMiddlewareNoService(type, middleware));
    }
    return service;
}

好了上面已將Compile外部依賴已經展示出來了,接下來我們就可以繼續探究Compile方法了[點選檢視原始碼?]

private static Func<T, HttpContext, IServiceProvider, Task> Compile<T>(MethodInfo methodInfo, ParameterInfo[] parameters)
{
    var middleware = typeof(T);
    //構建三個Parameter名為httpContext、serviceProvider、middleware
    var httpContextArg = Expression.Parameter(typeof(HttpContext), "httpContext");
    var providerArg = Expression.Parameter(typeof(IServiceProvider), "serviceProvider");
    var instanceArg = Expression.Parameter(middleware, "middleware");

    //穿件Expression陣列,且陣列第一個引數為httpContextArg
    var methodArguments = new Expression[parameters.Length];
    methodArguments[0] = httpContextArg;
    //因為Invoke或InvokeAsync方法第一個引數為HttpContext,且methodArguments第一個引數佔位,所以跳過第一個引數
    for (int i = 1; i < parameters.Length; i++)
    {
        //獲取方法引數
        var parameterType = parameters[i].ParameterType;
        //不支援ref型別操作
        if (parameterType.IsByRef)
        {
            throw new NotSupportedException(Resources.FormatException_InvokeDoesNotSupportRefOrOutParams(InvokeMethodName));
        }
       
       //構建引數型別表示式,即使用者構建方法引數的操作
        var parameterTypeExpression = new Expression[]
        {
            providerArg,
            Expression.Constant(parameterType, typeof(Type)),
            Expression.Constant(methodInfo.DeclaringType, typeof(Type))
        };
        //宣告呼叫GetServiceInfo的表示式
        var getServiceCall = Expression.Call(GetServiceInfo, parameterTypeExpression);
        //將getServiceCall操作轉換為parameterType
        methodArguments[i] = Expression.Convert(getServiceCall, parameterType);
    }
    //獲取中介軟體型別表示式
    Expression middlewareInstanceArg = instanceArg;
    if (methodInfo.DeclaringType != null && methodInfo.DeclaringType != typeof(T))
    {
        //轉換中介軟體型別表示式型別與宣告型別一致
        middlewareInstanceArg = Expression.Convert(middlewareInstanceArg, methodInfo.DeclaringType);
    }
    //呼叫middlewareInstanceArg(即當前中介軟體)的methodInfo(即獲取Invoke或InvokeAsync)方法引數(methodArguments)
    var body = Expression.Call(middlewareInstanceArg, methodInfo, methodArguments);
    //轉換為lambda
    var lambda = Expression.Lambda<Func<T, HttpContext, IServiceProvider, Task>>(body, instanceArg, httpContextArg, providerArg);
    return lambda.Compile();
}

上面的程式碼比較抽象,其實主要是因為它是基於表示式樹進行各種操作的,如果對錶達式樹比較熟悉的話,可能對上面的程式碼理解起來還好一點,如果不熟悉表示式樹的話,可能理解起來比較困難,不過還是建議簡單學習一下Expression相關的操作,慢慢的發現還是挺有意思的,它的效能整體來說比傳統的反射效能也會更好一點。其實Compile主要實現的操作轉化為我們比較容易理解的程式碼的話就是下面所示的操作,如果我們編寫了一個如下的中介軟體程式碼

public class Middleware
{
    public Task Invoke(HttpContext context, ILoggerFactory loggerFactory)
    {
    }
}

那麼通過Compile方法將轉換為類似以下形式的操作,這樣說的話可能會好理解一點

Task Invoke(Middleware instance, HttpContext httpContext, IServiceProvider provider)
{
    return instance.Invoke(httpContext, (ILoggerFactory)UseMiddlewareExtensions.GetService(provider, typeof(ILoggerFactory));
}

通過上面的原始碼分析我們瞭解到,基於約定的方式定義的中介軟體例項是通過ActivatorUtilities類建立的,而且建立例項是在返回RequestDelegate委託之前,IApplicationBuilder的Use方法只會在首次執行的時候執行,後續管道串聯執行的其實正是它返回的結果RequestDelegate這個委託。但是執行轉換Invoke或InvokeAsync方法為執行委託的操作卻是在返回的RequestDelegate委託當中,也就是我們每次請求管道會處理的邏輯中。這個邏輯可以在IApplicationBuilder預設的實現類ApplicationBuilder類的Build方法中可以得知[點選檢視原始碼?],它的實現邏輯如下所示

public RequestDelegate Build()
{
    //最後的管道處理,即請求未能匹配到任何終結點的情況
    RequestDelegate app = context =>
    {
        var endpoint = context.GetEndpoint();
        var endpointRequestDelegate = endpoint?.RequestDelegate;
        if (endpointRequestDelegate != null)
        {
            var message =
                $"The request reached the end of the pipeline without executing the endpoint: '{endpoint!.DisplayName}'. " +
                $"Please register the EndpointMiddleware using '{nameof(IApplicationBuilder)}.UseEndpoints(...)' if using " +
                $"routing.";
            throw new InvalidOperationException(message);
        }
        //執行管道的重點是404,只有未命中任何終結點的情況下才會走到這裡
        context.Response.StatusCode = StatusCodes.Status404NotFound;
        return Task.CompletedTask;
    };
    //_components即我們通過Use新增的中介軟體
    foreach (var component in _components.Reverse())
    {
       //得到執行結果即RequestDelegate
        app = component(app);
    }
    //返回第一個管道中介軟體
    return app;
}

通過上面的程式碼我們可以清楚的看到,管道最終執行的就是執行Func<RequestDelegate, RequestDelegate>這個委託的返回結果RequestDelegate。

由此得到結論,基於約定的中介軟體形式,通建構函式注入的服務例項,是和應用程式的生命週期一致的。通過Invoke或InvokeAsync方法注入的服務例項每次請求都會被執行到,即生命週期是Scope的。

總結

    通過本次對原始碼的研究,我們認識到了自定義的ASP.NET Core中介軟體是如何被初始化的。雖然自定義的中介軟體的形式有許多種方式,但是最終還都是轉換為IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)這種方式。將中介軟體抽離為獨立的類有兩種方式,即基於約定的方式和實現IMiddleware介面的形式,通過分析原始碼我們也更深刻的瞭解兩種方式的不同之處。基於約定的方式更靈活,它的宣告週期是單例的,但是通過它的Invoke或InvokeAsync方法注入的服務例項生命週期是Scope的。實現IMiddleware介面的方式生命週期取決於自己註冊服務例項時候宣告的週期,而且這種方式沒辦法通過方法注入服務,因為有IMiddleware介面InvokeAsync方法的約束。
    當然不僅僅是我們在總結中說的的這些,還存在更多的細節,這些我們在分析原始碼的時候都有涉及,相信閱讀文章比較仔細的同學肯定會注意到這些。閱讀原始碼收穫正是這些,解決心中的疑問,瞭解更多的細節,有助於在實際使用中避免一些不必要的麻煩。本次講解就到這裡,願各位能有所收穫。

?歡迎掃碼關注我的公眾號? ASP.NET Core中介軟體初始化探究

相關文章