1. 程式人生 > >ASP.NET Core 執行原理解剖[3]:Middleware-請求管道的構成

ASP.NET Core 執行原理解剖[3]:Middleware-請求管道的構成

原文: ASP.NET Core 執行原理解剖[3]:Middleware-請求管道的構成

在 ASP.NET 中,我們知道,它有一個面向切面的請求管道,有19個主要的事件構成,能夠讓我們進行靈活的擴充套件。通常是在 web.config 中通過註冊 HttpModule 來實現對請求管道事件監聽,並通過 HttpHandler 進入到我們的應用程式中。而在 ASP.NET Core 中,對請求管道進行了重新設計,通過使用一種稱為中介軟體的方式來進行管道的註冊,同時也變得更加簡潔和強大。

目錄

本系列文章從原始碼分析的角度來探索 ASP.NET Core 的執行原理,分為以下幾個章節:

ASP.NET Core 執行原理解剖[1]:Hosting

ASP.NET Core 執行原理解剖[2]:Hosting補充之配置介紹

ASP.NET Core 執行原理解剖[3]:Middleware-請求管道的構成(Current)

  1. IApplicationBuilder
  2. IMiddleware

ASP.NET Core 執行原理解剖[4]:進入HttpContext的世界

ASP.NET Core 執行原理解剖[5]:Authentication

IApplicationBuilder

第一章中,我們就介紹過 IApplicationBuilder,在我們熟悉的 Startup 類的Configure方法中,通常第一個引數便是IApplicationBuilder,對它應該是非常熟悉了,而在這裡,就再徹底的解剖一下 IApplicationBuilder 物件。

首先,IApplicationBuilder 是用來構建請求管道的,而所謂請求管道,本質上就是對 HttpContext 的一系列操作,即通過對 Request 的處理,來生成 Reponse。因此,在 ASP.NET Core 中定義了一個 RequestDelegate

委託,來表示請求管道中的一個步驟,它有如下定義:

public delegate Task RequestDelegate(HttpContext context);

而對請求管道的註冊是通過 Func<RequestDelegate, RequestDelegate> 型別的委託(也就是中介軟體)來實現的。

為什麼要設計一個這樣的委託呢?讓我們來分析一下,它接收一個 RequestDelegate 型別的引數,並返回一個 RequestDelegate 型別,也就是說前一箇中間件的輸出會成為下一個中介軟體的輸入,這樣把他們串聯起來,形成了一個完整的管道。那麼第一個中介軟體的輸入是什麼,最後一箇中間件的輸出又是如何處理的呢?帶著這個疑惑,我們慢慢往下看。

IApplicationBuilder 的預設實現是 ApplicationBuilder,它的定義在 HttpAbstractions 專案中 :

public interface IApplicationBuilder
{
    IServiceProvider ApplicationServices { get; set; }

    IFeatureCollection ServerFeatures { get; }

    IDictionary<string, object> Properties { get; }

    IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware);

    IApplicationBuilder New();

    RequestDelegate Build();
}

public class ApplicationBuilder : IApplicationBuilder
{
    private readonly IList<Func<RequestDelegate, RequestDelegate>> _components = new List<Func<RequestDelegate, RequestDelegate>>();

    ...
}

它有一個內部的 Func<RequestDelegate, RequestDelegate> 型別的集合(用來儲存我們註冊的中介軟體)和三個核心方法:

Use

Use是我們非常熟悉的註冊中介軟體的方法,其實現非常簡單,就是將註冊的中介軟體儲存到其內部屬性 _components 中。

public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)
{
    _components.Add(middleware);
    return this;
}

我們使用Use註冊兩個簡單的中介軟體:

public void Configure(IApplicationBuilder app)
{
    app.Use(next =>
    {
        Console.WriteLine("A");
        return async (context) =>
        {
            // 1. 對Request做一些處理
            // TODO

            // 2. 呼叫下一個中介軟體
            Console.WriteLine("A-BeginNext");
            await next(context);
            Console.WriteLine("A-EndNext");

            // 3. 生成 Response
            //TODO
        };
    });

    app.Use(next =>
    {
        Console.WriteLine("B");
        return async (context) =>
        {
            // 1. 對Request做一些處理
            // TODO

            // 2. 呼叫下一個中介軟體
            Console.WriteLine("B-BeginNext");
            await next(context);
            Console.WriteLine("B-EndNext");

            // 3. 生成 Response
            //TODO
        };
    });
}

如上,註冊了A和B兩個中介軟體,通常每一箇中間件有如上所示三個處理步驟,也就是圍繞著Next分別對RequestRespone做出相應的處理,而B的執行會巢狀在A的裡面,因此A是第一個處理Request,並且最後一個收到Respone,這樣就構成一個經典的的U型管道。

而上面所示程式碼的執行結算如下:

pipeline-demo

非常符合我們的預期,但是最終返回的結果是一個 404 HttpNotFound,這又是為什麼呢?讓我們再看一下它的 Build 方法。

Build

第一章中,我們介紹到,在 Hosting 的啟動中,便是通過該 Build 方法建立一個 RequestDelegate 型別的委託,Http Server 通過該委託來完成整個請求的響應,它有如下定義:

public RequestDelegate Build()
{
    RequestDelegate app = context =>
    {
        context.Response.StatusCode = 404;
        return Task.CompletedTask;
    };

    foreach (var component in _components.Reverse())
    {
        app = component(app);
    }

    return app;
}

可以看到首先定義了一個 404 的中介軟體,然後使用了Reverse函式將註冊的中介軟體列表進行反轉,因此首先執行我們所註冊的最後一箇中間件,輸入引數便是一個 404 ,依次執行到第一個中介軟體,將它的輸出傳遞給 HostingApplication 再由 IServer 來執行。整個構建過程是類似於俄羅斯套娃,按我們的註冊順序從裡到外,一層套一層。

最後,再解釋一下,上面的程式碼返回404的原因。RequestDelegate的執行是從俄羅斯套娃的最外層開始,也就是從我們註冊的第一個中介軟體A開始執行,A呼叫B,B則呼叫前面介紹的404 的中介軟體,最終也就返回了一個 404,那如何避免返回404呢,這時候就要用到 IApplicationBuilder 的擴充套件方法Run了。

Run

對於上面 404 的問題,我們只需要對中介軟體B做如下修改即可:

app.Use(next =>
{
    Console.WriteLine("B");
    return async (context) =>
    {
        // 1. 對Request做一些處理
        // TODO

        // 2. 呼叫下一個中介軟體
        Console.WriteLine("B-BeginNext");
        await context.Response.WriteAsync("Hello ASP.NET Core!");
        Console.WriteLine("B-EndNext");

        // 3. 生成 Response
        //TODO
    };
});

將之前的 await next(context); 替換成了 await context.Response.WriteAsync("Hello ASP.NET Core!");,自然也就將404替換成了返回一個 "Hello ASP.NET Core!" 字串。

在我們註冊的中介軟體中,是通過 Next 委託 來串連起來的,如果在某一箇中間件中沒有呼叫 Next 委託,則該中介軟體將做為管道的終點,因此,我們在最後一箇中間件不應該再呼叫 Next 委託,而 Run 擴充套件方法,通常用來註冊最後一箇中間件,有如下定義:

public static class RunExtensions
{
    public static void Run(this IApplicationBuilder app, RequestDelegate handler)
    {
        if (app == null)
        {
            throw new ArgumentNullException(nameof(app));
        }

        if (handler == null)
        {
            throw new ArgumentNullException(nameof(handler));
        }

        app.Use(_ => handler);
    }
}

可以看到,Run 方法接收的只有一個 RequestDelegate 委託,沒有了 Next 委託,進而保證了它不會再呼叫下一個中介軟體,即使我們在它之後註冊了其它中介軟體,也不會被執行。因此建議,我們最終處理 Response 的中介軟體使用 Run 來註冊,類似於 ASP.NET 4.x 中的 HttpHandler

New

IApplicationBuilder 還有一個常用的 New 方法,通常用來建立分支:

public class ApplicationBuilder : IApplicationBuilder
{
    private ApplicationBuilder(ApplicationBuilder builder)
    {
        Properties = new CopyOnWriteDictionary<string, object>(builder.Properties, StringComparer.Ordinal);
    }

    public IApplicationBuilder New()
    {
        return new ApplicationBuilder(this);
    }
}

New 方法根據自身來“克隆”了一個新的 ApplicationBuilder 物件,而新的 ApplicationBuilder 可以訪問到建立它的物件的 Properties 屬性,但是對自身 Properties 屬性的修改,卻不到影響到它的建立者,這是通過 CopyOnWriteDictionary 來實現的:

internal class CopyOnWriteDictionary<TKey, TValue> : IDictionary<TKey, TValue>
{
    private readonly IDictionary<TKey, TValue> _sourceDictionary;

    public CopyOnWriteDictionary(IDictionary<TKey, TValue> sourceDictionary, IEqualityComparer<TKey> comparer)
    {
        _sourceDictionary = sourceDictionary;
        _comparer = comparer;
    }

    private IDictionary<TKey, TValue> ReadDictionary => _innerDictionary ?? _sourceDictionary;

    private IDictionary<TKey, TValue> WriteDictionary => 
    {
        if (_innerDictionary == null)
        {
            _innerDictionary = new Dictionary<TKey, TValue>(_sourceDictionary, _comparer);
        }
        return _innerDictionary;
    };
}

最後再放一張網上經典的 ASP.NET Core 請求管道圖:

request-pipeline

IMiddleware

通過上面的介紹,我們知道,中介軟體本質上就是一個型別為 Func<RequestDelegate, RequestDelegate> 的委託物件,但是直接使用這個委託物件還是多有不便,因此 ASP.NET Core 提供了一個更加具體的中介軟體的概念,我們在大部分情況下都會將中介軟體定義成一個單獨的型別,使程式碼更加清晰。

首先看一下 IMiddleware 介面定義:

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

IMiddleware 中只有一個方法:InvokeAsync,它接收一個 HttpContext 引數,用來處理HTTP請求,和一個 RequestDelegate 引數,代表下一個中介軟體。當然, ASP.NET Core 並沒有要求我們必須實現 IMiddleware 介面,我們也可以像 Startup 類的實現方式一樣,通過遵循一些約定來更加靈活的定義我們的中介軟體。

UseMiddleware

對於 IMiddleware 型別的中介軟體的註冊,使用 UseMiddleware 擴充套件方法,定義如下:

public static class UseMiddlewareExtensions
{
    public static IApplicationBuilder UseMiddleware<TMiddleware>(this IApplicationBuilder app, params object[] args)
    {
        return app.UseMiddleware(typeof(TMiddleware), args);
    } 

    public static IApplicationBuilder UseMiddleware(this IApplicationBuilder app, Type middleware, params object[] args)
    {
        if (typeof(IMiddleware).GetTypeInfo().IsAssignableFrom(middleware.GetTypeInfo()))
        {
            return UseMiddlewareInterface(app, middleware);
        }
        
        ...
    }
}

泛型的註冊方法,在 ASP.NET Core 中比較常見,比如日誌,依賴注入中都有類似的方法,它只是一種簡寫形式,最終都是將泛型轉換為Type型別進行註冊。

如上程式碼,首先通過通過 IsAssignableFrom 方法來判斷是否實現 IMiddleware 介面,從而分為了兩種方式實現方式,我們先看一下實現了 IMiddleware 介面的中介軟體的執行過程:

private static IApplicationBuilder UseMiddlewareInterface(IApplicationBuilder app, Type middlewareType)
{
    return app.Use(next =>
    {
        return async context =>
        {
            var middlewareFactory = (IMiddlewareFactory)context.RequestServices.GetService(typeof(IMiddlewareFactory));

            var middleware = middlewareFactory.Create(middlewareType);

            try
            {
                await middleware.InvokeAsync(context, next);
            }
            finally
            {
                middlewareFactory.Release(middleware);
            }
        };
    });
}

如上,建立了一個 Func<RequestDelegate, RequestDelegate> 委託,在返回的 RequestDelegate 委託中呼叫我們的 IMiddleware 中介軟體的 InvokeAsync 方法。其實也只是簡單的對 Use 方法的一種封裝。而 IMiddleware 例項的建立則使用 IMiddlewareFactory 來實現的:

public class MiddlewareFactory : IMiddlewareFactory
{
    private readonly IServiceProvider _serviceProvider;

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

    public IMiddleware Create(Type middlewareType)
    {
        return _serviceProvider.GetRequiredService(middlewareType) as IMiddleware;
    }

    public void Release(IMiddleware middleware)
    {
    }
}

通過如上程式碼,可以發現一個坑,因為 IMiddleware 例項的建立是直接從 DI 容器中來獲取的,也就是說,如果我們沒有將我們實現了 IMiddleware 介面的中介軟體註冊到DI中,而直接使用 UseMiddleware 來註冊時,會報錯:“`InvalidOperationException: No service for type 'MiddlewareXX' has been registered.”。

不過通常我們並不會去實現 IMiddleware 介面,而是採用基於約定的,更加靈活的方式來定義中介軟體,而此時,UseMiddleware 方法會通過反射來建立中介軟體的例項:

public static IApplicationBuilder UseMiddleware(this IApplicationBuilder app, Type middleware, params object[] args)
{
    // 未例項 IMiddleware 時的註冊方式
    return app.Use(next =>
    {
        var methods = middleware.GetMethods(BindingFlags.Instance | BindingFlags.Public);
        var invokeMethods = methods.Where(m =>
            string.Equals(m.Name, InvokeMethodName, StringComparison.Ordinal)
            || string.Equals(m.Name, InvokeAsyncMethodName, StringComparison.Ordinal)
            ).ToArray();

        ...
        var methodinfo = invokeMethods[0];
        var parameters = methodinfo.GetParameters();
        var ctorArgs = new object[args.Length + 1];
        ctorArgs[0] = next;
        Array.Copy(args, 0, ctorArgs, 1, args.Length);
        var instance = ActivatorUtilities.CreateInstance(app.ApplicationServices, middleware, ctorArgs);
        if (parameters.Length == 1)
        {
            return (RequestDelegate)methodinfo.CreateDelegate(typeof(RequestDelegate), instance);
        }

        var factory = Compile<object>(methodinfo, parameters);

        return context =>
        {
            return factory(instance, context, serviceProvider);
        };
    });
}

首先是根據命名約定來判斷我們的註冊的 Middleware 類是否符合要求,然後使用ActivatorUtilities.CreateInstance呼叫建構函式,建立例項。而在呼叫建構函式時需要的碼數,會先在傳入到 UseMiddleware 方法中的引數 args 中來查詢 ,如果找不到則再去DI中查詢,再找不到,將會丟擲一個異常。例項建立成功後,呼叫Invoke/InvokeAsync方法,不過針對Invoke方法的呼叫並沒有直接使用反射來實現,而是採用表了達式,後者具有更好的效能,感興趣的可以去看完整程式碼 UseMiddlewareExtensions 中的 Compile 方法。

通過以上程式碼,我們也可以看出 IMiddleware 的命名約定:

  • 必須要有一個 InvokeInvokeAsync 方法,兩者也只能存在一個。

  • 返回型別必須是 Task 或者繼承自 Task

  • InvokeInvokeAsync 方法必須要有一個 HttpContext 型別的引數。

不過,需要注意的是,Next 委託必須放在建構函式中,而不能放在 InvokeAsync 方法引數中,這是因為 Next 並不在DI系統中,而 ActivatorUtilities.CreateInstance 建立例項時,也會檢查構造中是否具有 RequestDelegate 型別的 Next 引數,如果沒有,則會丟擲一個異常:“A suitable constructor for type '{instanceType}' could not be located. Ensure the type is concrete and services are registered for all parameters of a public constructor.”。

UseWhen

在有些場景下,我們可能需要針對某些請求,做一些特定的操作。當然,我們可以定義一箇中間件,在中介軟體中判斷該請求是否符合我們的預期,進而選擇是否執行該操作。但是有一種更好的方式 UseWhen 來實現這樣的需求。從名字我們可以猜出,它提供了一種基於條件來註冊中介軟體的方式,有如下定義:

using Predicate = Func<HttpContext, bool>;

public static IApplicationBuilder UseWhen(this IApplicationBuilder app, Predicate predicate, Action<IApplicationBuilder> configuration)
{
    var branchBuilder = app.New();
    configuration(branchBuilder);

    return app.Use(main =>
    {
        branchBuilder.Run(main);
        var branch = branchBuilder.Build();

        return context =>
        {
            if (predicate(context))
            {
                return branch(context);
            }
            else
            {
                return main(context);
            }
        };
    });
}

首先使用上面介紹過的 New 方法建立一個管道分支,將我們傳入的 configuration 委託註冊到該分支中,然後再將 Main 也就是後續的中介軟體也註冊到該分支中,最後通過我們指定的 Predicate 來判斷是執行新分支,還是繼續在之前的管道中執行。

它的使用方式如下:

public void Configure(IApplicationBuilder app)
{
    app.UseMiddlewareA();

    app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), appBuilder =>
    {
        appBuilder.UseMiddlewareB();
    });

    app.UseMiddlewareC);
}

我們註冊了三個中介軟體:A, B, C 。中介軟體 A 和 C 會一直執行(除了短路的情況), 而 B 只有在符合預期時,也就是當請求路徑以 /api 開頭時,才會執行。

UseWhen是非常強大和有用的,建議當我們想要針對某些請求做一些特定的處理時,我們應該只為這些請求註冊特定的中介軟體,而不是在中介軟體中去判斷請求是否符合預期來選擇執行某些操作,這樣能有更好的效能。

以下是 UseWhen 的一些使用場景:

  • 分別對MVC和WebAPI做出不同的錯誤響應。
  • 為特定的IP新增診斷響應頭。
  • 只對匿名使用者使用輸出快取。
  • 針對某些請求進行統計。

MapWhen

MapWhen 與 UseWhen 非常相似,但是他們有著本質的區別,先看一下 MapWhen 的定義:

using Predicate = Func<HttpContext, bool>;

public static IApplicationBuilder MapWhen(this IApplicationBuilder app, Predicate predicate, Action<IApplicationBuilder> configuration)
{
    var branchBuilder = app.New();
    configuration(branchBuilder);
    var branch = branchBuilder.Build();

    // put middleware in pipeline
    var options = new MapWhenOptions
    {
        Predicate = predicate,
        Branch = branch,
    };
    return app.Use(next => new MapWhenMiddleware(next, options).Invoke);
}

如上,可以看出他們的區別:MapWhen 並沒有將父分支中的後續中介軟體註冊進來,而是一個獨立的分支,而在 MapWhenMiddleware 中只是簡單的判斷是執行新分支還是舊分支:

public class MapWhenMiddleware
{
    ...
    
    public async Task Invoke(HttpContext context)
    {
        if (_options.Predicate(context))
        {
            await _options.Branch(context);
        }
        else
        {
            await _next(context);
        }
    }
}

再看一下 MapWhen 的執行效果:

public void Configure(IApplicationBuilder app)
{
    app.UseMiddlewareA();

    app.MapWhen(context => context.Request.Path.StartsWithSegments("/api"), appBuilder =>
    {
        appBuilder.UseMiddlewareB();
    });

    app.UseMiddlewareC();
}

如上,中介軟體A將一直執行,之後如果請求路徑以 /api 開頭,則會執行 B ,併到此結束,不會再執行 C ,反之,不執行 B ,而執行 C 以及後續的其它的中介軟體。

當我們希望某些請求使用完全獨立的處理方式時,MapWhen 就非常有用,如 UseStaticFiles

public void Configure(IApplicationBuilder app)
{
    app.MapWhen(context => context.Request.Path.Value.StartsWithSegments("/assets"), 
        appBuilder => appBuilder.UseStaticFiles());
}

如上,只有以 /assets 開頭的請求,才會執行 StaticFiles 中介軟體,而其它請求則不會執行 StaticFiles 中介軟體,這樣可以帶來稍微的效能提升。

UsePathBase

UsePathBase用於拆分請求路徑,類似於 MVC 中 Area 的效果,它不會建立請求管道分支,不影響管道的流程,僅僅是設定 RequestPathPathBase 屬性:

public static IApplicationBuilder UsePathBase(this IApplicationBuilder app, PathString pathBase)
{
    pathBase = pathBase.Value?.TrimEnd('/');
    if (!pathBase.HasValue)
    {
        return app;
    }
    return app.UseMiddleware<UsePathBaseMiddleware>(pathBase);
}

public class UsePathBaseMiddleware
{
    public async Task Invoke(HttpContext context)
    {
        if (context.Request.Path.StartsWithSegments(_pathBase, out matchedPath, out remainingPath))
        {
            var originalPath = context.Request.Path;
            var originalPathBase = context.Request.PathBase;
            context.Request.Path = remainingPath;
            context.Request.PathBase = originalPathBase.Add(matchedPath);
            try
            {
                await _next(context);
            }
            finally
            {
                context.Request.Path = originalPath;
                context.Request.PathBase = originalPathBase;
            }
        }
        else
        {
            await _next(context);
        }
    }
}

如上,當請求路徑以我們指定的 PathString 開頭時,則將請求的 PathBase 設定為 傳入的 pathBase,Path 則為剩下的部分。

PathString 用來表示請求路徑的一個片段,它可以從字串隱式轉換,但是要求必須以 / 開頭,並且不以 / 結尾。

Map

Map 包含 UsePathBase 的功能,並且建立一個獨立的分支來完成請求的處理,類似於 MapWhen

public static class MapExtensions
{
    public static IApplicationBuilder Map(this IApplicationBuilder app, PathString pathMatch, Action<IApplicationBuilder> configuration)
    {
        ...

        return app.Use(next => new MapMiddleware(next, options).Invoke);
    }
}

以上方法中與 MapWhen 一樣,不同的只是 Map 呼叫了 MapMiddleware 中介軟體:

public class MapMiddleware
{
    ...

    public async Task Invoke(HttpContext context)
    {
        PathString matchedPath;
        PathString remainingPath;
        if (context.Request.Path.StartsWithSegments(_options.PathMatch, out matchedPath, out remainingPath))
        {
            var path = context.Request.Path;
            var pathBase = context.Request.PathBase;
            context.Request.PathBase = pathBase.Add(matchedPath);
            context.Request.Path = remainingPath;
            try
            {
                await _options.Branch(context);
            }
            finally
            {
                context.Request.PathBase = pathBase;
                context.Request.Path = path;
            }
        }
        else
        {
            await _next(context);
        }
    }
}

如上,可以看出 Map 擴充套件方法比 MapWhen 多了對 Request.PathBaseRequest.Path 的處理,最後演示一下 Map 的用例:

public void Configure(IApplicationBuilder app)
{
    app.Map("/account", builder =>
    {
        builder.Run(async context =>
        {
            Console.WriteLine($"PathBase: {context.Request.PathBase}, Path: {context.Request.Path}");
            await context.Response.WriteAsync("This is from account");
        });
    });

    app.Run(async context =>
    {
        Console.WriteLine($"PathBase: {context.Request.PathBase}, Path: {context.Request.Path}");
        await context.Response.WriteAsync("This is default");
    });
}

如上,我們為 /account 定義了一個分支,當我們 /account/user 的時候,將返回 This is from account ,並且會將 Request.PathBase 設定為 /account ,將 Request.Path 設定為 /user

總結

本文詳細介紹了 ASP.NET Core 請求管道的構建過程,以及一些幫助我們更加方便的來配置請求管道的擴充套件方法。在 ASP.NET Core 中,至少要有一箇中間件來響應請求,而我們的應用程式實際上只是中介軟體的集合,MVC 也只是其中的一箇中間件而已。簡單來說,中介軟體就是一個處理http請求和響應的元件,多箇中間件構成了請求處理管道,每個中介軟體都可以選擇處理結束,還是繼續傳遞給管道中的下一個中介軟體,以此串聯形成請求管道。通常,我們註冊的每個中介軟體,每次請求和響應均會被呼叫,但也可以使用 Map , MapWhen ,UseWhen 等擴充套件方法對中介軟體進行過濾。

參考資料: