【.NET原始碼解讀】深入剖析中介軟體的設計與實現

2023-06-29 18:00:46

.NET本身就是一個基於中介軟體(middleware)的框架,它通過一系列的中介軟體元件來處理HTTP請求和響應。在之前的文章《.NET原始碼解讀kestrel伺服器及建立HttpContext物件流程》中,已經通過原始碼介紹瞭如何將HTTP封包轉換為.NET的HttpContext物件。接下來,讓我們深入瞭解一下.NET是如何設計中介軟體來處理HttpContext物件。

通過本文,您可以瞭解以下內容:

  • 認識中介軟體的本質
  • 實現自定義中介軟體
  • 原始碼解讀中介軟體原理

一、重新認識中介軟體

1. 中介軟體的實現方式

在介紹中介軟體之前,讓我們先了解一下管道設計模式:

管道設計模式是一種常見的軟體設計模式,用於將一個複雜的任務或操作分解為一系列獨立的處理步驟。每個步驟按特定順序處理資料並傳遞給下一個步驟,形成線性的處理流程。每個步驟都是獨立且可重用的元件。

在.NET中,針對每個HTTP請求的處理和響應任務被分解為可重用的類或匿名方法,這些元件被稱為中介軟體。中介軟體的連線順序是特定的,它們在一個管道中按順序連線起來,形成一個處理流程。這種設計方式可以根據需求自由地新增、刪除或重新排序中介軟體。

中介軟體的實現非常簡單,它基於一個委託,接受一個HttpContext物件和一個回撥函數(表示下一個中介軟體)作為引數。當請求到達時,委託執行自己的邏輯,並將請求傳遞給下一個中介軟體元件。這個過程會持續進行,直到最後一箇中介軟體完成響應並將結果返回給使用者端。

/*
 * 入參1 string:代表HttpContext
 * 入參2 Func<Task>:下一個中介軟體的方法
 * 結果返回 Task:避免執行緒阻塞
 * **/
Func<string, Func<Task>, Task> middleware = async (context, next) =>
{
    Console.WriteLine($"Before middleware: {context}");

    await next(); // 呼叫下一個中介軟體

    Console.WriteLine($"After middleware: {context}");
};
Func<Task> finalMiddleware = () =>
{
    // 最後一箇中介軟體的邏輯
    Console.WriteLine("Final middleware");
    return Task.CompletedTask;
};

為了給所有的中介軟體和終端處理器提供統一的委託型別,使得它們在請求處理管道中可以無縫地連線起來。所以引入了RequestDelegate委託。上文中Func方法,最終都會轉換成RequestDelegate委託,這一點放在下文原始碼解析中。

public delegate Task RequestDelegate(HttpContext context);

2. 中介軟體管道構建器原理

下面是從原始碼中提取出的一個簡單的中介軟體管道構建器實現範例。它包含一個 _middlewares 列表,用於儲存中介軟體委託,並提供了 Use 方法用於新增中介軟體,以及 Build 方法用於構建最終的請求處理委託。

這個實現範例雖然程式碼不多,但卻能充分展示中介軟體的構建原理。你可以仔細閱讀這段程式碼,深入理解中介軟體是如何構建和連線的。

public class MiddlewarePipeline
{
    private readonly List<Func<RequestDelegate, RequestDelegate>> _middlewares = 
        new List<Func<RequestDelegate, RequestDelegate>>();

    public void Use(Func<RequestDelegate, RequestDelegate> middleware)
    {
        _middlewares.Add(middleware);
    }

    public RequestDelegate Build()
    {
        RequestDelegate next = context => Task.CompletedTask;
        
        for (int i = _middlewares.Count - 1; i >= 0; i--)
        {
            next = _middlewares[i](next);
        }

        return next;
    }
}

二、實現自定義中介軟體

如果您想了解中介軟體中Run、Use、Map、MapWhen等方法,可以直接看官方檔案

1. 使用內聯中介軟體

該中介軟體通過查詢字串設定當前請求的區域性:

using System.Globalization;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseHttpsRedirection();

app.Use(async (context, next) =>
{
    var cultureQuery = context.Request.Query["culture"];
    if (!string.IsNullOrWhiteSpace(cultureQuery))
    {
        var culture = new CultureInfo(cultureQuery);

        CultureInfo.CurrentCulture = culture;
        CultureInfo.CurrentUICulture = culture;
    }

    // Call the next delegate/middleware in the pipeline.
    await next(context);
});

app.Run(async (context) =>
{
    await context.Response.WriteAsync(
        $"CurrentCulture.DisplayName: {CultureInfo.CurrentCulture.DisplayName}");
});

app.Run();

2.中介軟體類

以下程式碼將中介軟體委託移動到類:
該類必須具備:

  • 具有型別為 RequestDelegate 的引數的公共建構函式。
  • 名為 Invoke 或 InvokeAsync 的公共方法。 此方法必須:
    • 返回 Task。
    • 接受型別 HttpContext 的第一個引數。
      建構函式和 Invoke/InvokeAsync 的其他引數由依賴關係注入 (DI) 填充。
using System.Globalization;

namespace Middleware.Example;

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;
        }

        // Call the next delegate/middleware in the pipeline.
        await _next(context);
    }
}

// 封裝擴充套件方法
public static class RequestCultureMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestCulture(
        this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestCultureMiddleware>();
    }
}

3. 基於工廠的中介軟體

該方法具體描述請看官方檔案

上文描述的自定義類,其實是按照約定來定義實現的。也可以根據IMiddlewareFactory/IMiddleware 中介軟體的擴充套件點來使用:

// 自定義中介軟體類實現 IMiddleware 介面
public class CustomMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        // 中介軟體邏輯
        await next(context);
    }
}

// 自定義中介軟體工廠類實現 IMiddlewareFactory 介面
public class CustomMiddlewareFactory : IMiddlewareFactory
{
    public IMiddleware Create(IServiceProvider serviceProvider)
    {
        // 在這裡可以進行一些初始化操作,如依賴注入等
        return new CustomMiddleware();
    }
}

// 在 Startup.cs 中使用中介軟體工廠模式新增中介軟體
public void Configure(IApplicationBuilder app)
{
    app.UseMiddleware<CustomMiddlewareFactory>();
}

詳細具體的自定義中介軟體方式請參閱官方檔案

三、原始碼解讀中介軟體

以下是原始碼的部分刪減和修改,以便於更好地理解

1. 建立主機構建器

為了更好地理解中介軟體的建立和執行在整個框架中的位置,我們仍然從 Program 開始。在 Program 中使用 CreateBuilder 方法建立一個預設的主機構建器,設定應用程式的預設設定,並注入基礎服務。

// 在Program.cs檔案中呼叫
var builder = WebApplication.CreateBuilder(args);

CreateBuilder方法返回了WebApplicationBuilder範例

public static WebApplicationBuilder CreateBuilder(string[] args) =>
    new WebApplicationBuilder(new WebApplicationOptions(){ Args = args });

在 WebApplicationBuilder 的建構函式中,將設定並註冊中介軟體

internal WebApplicationBuilder(WebApplicationOptions options, Action<IHostBuilder>? configureDefaults = null)
{
    // 建立BootstrapHostBuilder範例
    var bootstrapHostBuilder = new BootstrapHostBuilder(_hostApplicationBuilder);

    // bootstrapHostBuilder 上呼叫 ConfigureWebHostDefaults 方法,以進行特定於 Web 主機的設定
    bootstrapHostBuilder.ConfigureWebHostDefaults(webHostBuilder =>
    {
        // 設定應用程式包含了中介軟體的註冊過程和一系列的設定
        webHostBuilder.Configure(ConfigureApplication);
    });

    var webHostContext = (WebHostBuilderContext)bootstrapHostBuilder.Properties[typeof(WebHostBuilderContext)];
    Environment = webHostContext.HostingEnvironment;

    Host = new ConfigureHostBuilder(bootstrapHostBuilder.Context, Configuration, Services);
    WebHost = new ConfigureWebHostBuilder(webHostContext, Configuration, Services);
}

ConfigureApplication 方法是用於設定應用程式的核心方法。其中包含了中介軟體的註冊過程。本篇文章只關注中介軟體,路由相關的內容會在下一篇文章進行詳細解釋。

private void ConfigureApplication(WebHostBuilderContext context, IApplicationBuilder app)
{
    Debug.Assert(_builtApplication is not null);

    // 在 WebApplication 之前呼叫 UseRouting,例如在 StartupFilter 中,
    // 我們需要移除該屬性並在最後重新設定,以免影響過濾器中的路由
    if (app.Properties.TryGetValue(EndpointRouteBuilderKey, out var priorRouteBuilder))
    {
        app.Properties.Remove(EndpointRouteBuilderKey);
    }

    // ...

    // 將源管道連線到目標管道
    var wireSourcePipeline = new WireSourcePipeline(_builtApplication);
    app.Use(wireSourcePipeline.CreateMiddleware);

    // ..

    // 將屬性複製到目標應用程式構建器
    foreach (var item in _builtApplication.Properties)
    {
        app.Properties[item.Key] = item.Value;
    }

    // 移除路由構建器以清理屬性,我們已經完成了將路由新增到管道的操作
    app.Properties.Remove(WebApplication.GlobalEndpointRouteBuilderKey);

    // 如果之前存在路由構建器,則重置它,這對於 StartupFilters 是必要的
    if (priorRouteBuilder is not null)
    {
        app.Properties[EndpointRouteBuilderKey] = priorRouteBuilder;
    }
}

通過新構建的RequestDelegate委託處理請求,在目標中介軟體管道中連線源中介軟體管道

private sealed class WireSourcePipeline(IApplicationBuilder builtApplication)
{
    private readonly IApplicationBuilder _builtApplication = builtApplication;

    public RequestDelegate CreateMiddleware(RequestDelegate next)
    {
        _builtApplication.Run(next);
        return _builtApplication.Build();
    }
}

2. 啟動主機,並偵聽HTTP請求

從Program中app.Run()開始,啟動主機,最終會呼叫IHost的StartAsync方法。

// Program呼叫Run
app.Run();

// 實現Run();
public void Run([StringSyntax(StringSyntaxAttribute.Uri)] string? url = null)
{
    Listen(url);
    HostingAbstractionsHostExtensions.Run(this);
}

// 實現HostingAbstractionsHostExtensions.Run(this);
public static async Task RunAsync(this IHost host, CancellationToken token = default)
{
    try
    {
        await host.StartAsync(token).ConfigureAwait(false);

        await host.WaitForShutdownAsync(token).ConfigureAwait(false);
    }
    finally
    {
        if (host is IAsyncDisposable asyncDisposable)
        {
            await asyncDisposable.DisposeAsync().ConfigureAwait(false);
        }
        else
        {
            host.Dispose();
        }
    }
}

將中介軟體和StartupFilters擴充套件傳入HostingApplication主機,並進行啟動

public async Task StartAsync(CancellationToken cancellationToken)
{
    // ...省略了從設定中獲取伺服器監聽地址和埠...

    // 通過設定構建中介軟體管道
    RequestDelegate? application = null;
    try
    {
        IApplicationBuilder builder = ApplicationBuilderFactory.CreateBuilder(Server.Features);

        foreach (var filter in StartupFilters.Reverse())
        {
            configure = filter.Configure(configure);
        }
        configure(builder);
        // Build the request pipeline
        application = builder.Build();
    }
    catch (Exception ex)
    {
        Logger.ApplicationError(ex);
    }

    /*
     * application:中介軟體
     * DiagnosticListener:事件監聽器
     * HttpContextFactory:HttpContext物件的工廠
     */
    HostingApplication httpApplication = new HostingApplication(application, Logger, DiagnosticListener, ActivitySource, Propagator, HttpContextFactory, HostingEventSource.Log, HostingMetrics);

    await Server.StartAsync(httpApplication, cancellationToken);

}

IApplicationBuilder 提供設定應用程式請求管道的機制,Build方法生成此應用程式用於處理HTTP請求的委託。

public RequestDelegate Build()
{
    // 構建一個 RequestDelegate 委託,代表請求的處理邏輯
    RequestDelegate app = context =>
    {
        var endpoint = context.GetEndpoint();
        var endpointRequestDelegate = endpoint?.RequestDelegate;
        if (endpointRequestDelegate != null)
        {
            throw new InvalidOperationException(message);
        }

        return Task.CompletedTask;
    };

    // 逐步構建了包含所有中介軟體的管道
    for (var c = _components.Count - 1; c >= 0; c--)
    {
        app = _components[c](app);
    }

    return app;
}

3. IApplicationBuilder作用及實現

這裡對IApplicationBuilder做個整體瞭解,然後再回歸上文流程。

IApplicationBuilder的作用是提供了設定應用程式請求管道的機制。它定義了一組方法和屬性,用於構建和設定應用程式的中介軟體管道,處理傳入的 HTTP 請求。

  • 存取應用程式的服務容器(ApplicationServices 屬性)。
  • 獲取應用程式的伺服器提供的 HTTP 特性(ServerFeatures 屬性)。
  • 共用資料在中介軟體之間傳遞的鍵值對集合(Properties 屬性)。
  • 嚮應用程式的請求管道中新增中介軟體委託(Use 方法)。
  • 建立一個新的 IApplicationBuilder 範例,共用屬性(New 方法)。
  • 構建處理 HTTP 請求的委託(Build 方法)。
 public partial class ApplicationBuilder : IApplicationBuilder
  {
      private readonly List<Func<RequestDelegate, RequestDelegate>> _components = new();
      private readonly List<string>? _descriptions;

      /// <summary>
      /// Adds the middleware to the application request pipeline.
      /// </summary>
      /// <param name="middleware">The middleware.</param>
      /// <returns>An instance of <see cref="IApplicationBuilder"/> after the operation has completed.</returns>
      public IApplicationBuilder Use(Func<RequestDelegate, RequestDelegate> middleware)
      {
          _components.Add(middleware);
          _descriptions?.Add(CreateMiddlewareDescription(middleware));

          return this;
      }

      private static string CreateMiddlewareDescription(Func<RequestDelegate, RequestDelegate> middleware)
      {
          if (middleware.Target != null)
          {
              // To IApplicationBuilder, middleware is just a func. Getting a good description is hard.
              // Inspect the incoming func and attempt to resolve it back to a middleware type if possible.
              // UseMiddlewareExtensions adds middleware via a method with the name CreateMiddleware.
              // If this pattern is matched, then ToString on the target returns the middleware type name.
              if (middleware.Method.Name == "CreateMiddleware")
              {
                  return middleware.Target.ToString()!;
              }

              return middleware.Target.GetType().FullName + "." + middleware.Method.Name;
          }

          return middleware.Method.Name.ToString();
      }

      /// <summary>
      /// Produces a <see cref="RequestDelegate"/> that executes added middlewares.
      /// </summary>
      /// <returns>The <see cref="RequestDelegate"/>.</returns>
      public RequestDelegate Build()
      {
          RequestDelegate app = context =>
          {
              // If we reach the end of the pipeline, but we have an endpoint, then something unexpected has happened.
              // This could happen if user code sets an endpoint, but they forgot to add the UseEndpoint middleware.
              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);
              }

              // Flushing the response and calling through to the next middleware in the pipeline is
              // a user error, but don't attempt to set the status code if this happens. It leads to a confusing
              // behavior where the client response looks fine, but the server side logic results in an exception.
              if (!context.Response.HasStarted)
              {
                  context.Response.StatusCode = StatusCodes.Status404NotFound;
              }

              // Communicates to higher layers that the request wasn't handled by the app pipeline.
              context.Items[RequestUnhandledKey] = true;

              return Task.CompletedTask;
          };

          for (var c = _components.Count - 1; c >= 0; c--)
          {
              app = _components[c](app);
          }

          return app;
      }

  }

迴歸上文流程,將生成的管道傳入HostingApplication中,並在處理Http請求時,進行執行。

// Execute the request
public Task ProcessRequestAsync(Context context)
{
    return _application(context.HttpContext!);
}

還是不清楚執行位置的同學,可以翻閱《.NET原始碼解讀kestrel伺服器及建立HttpContext物件流程》文章中的這塊程式碼來進行了解。

四、小結

.NET 中介軟體就是基於管道模式和委託來進行實現。每個中介軟體都是一個委託方法,接受一個 HttpContext 物件和一個 RequestDelegate 委託作為引數,可以對請求進行修改、新增額外的處理邏輯,然後呼叫 RequestDelegate 來將請求傳遞給下一個中介軟體或終止請求處理。

如果您覺得這篇文章有所收穫,還請點個贊並關注。如果您有寶貴建議,歡迎在評論區留言,非常感謝您的支援!

(也可以關注我的公眾號噢:Broder,萬分感謝_)