1. 程式人生 > 實用技巧 >翻譯 - 基本知識 - ASP.NET Core 路由

翻譯 - 基本知識 - ASP.NET Core 路由

翻譯自:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-5.0

路由負責匹配 Http 請求,然後分發這些請求到應用程式的最終執行點。Endpoints 是應用程式可執行請求處理的程式碼單元。Endpoints 在應用程式中定義並在應用程式啟動的時候配置。

Endpoint 匹配處理可以從請求的 URL 中提出值和為請求處理提供值。使用從應用程式獲取的 Endpoint 資訊,路由也可以生成匹配 Endpoint 的 URLS。

應用可以通過以下方式配置路由:

  • 控制器
  • Razor Pages
  • SignalR
  • gPRC Services
  • Endpoint - enable 中介軟體,例如: Health Checks.
  • 使用路由註冊的代理和 lambdas.

這篇文件涵蓋了ASP.NET Core 路由的底層詳情。

這篇文件中描述的 Endpoint 路由系統適用於 ASP.NET Core 3.0 或者更新的版本。

路由基礎

所有的 ASP.NET Core 模板程式碼中都包含路由。路由在 Startup.Configure 中註冊在中介軟體管道中。

下面程式碼展示了一個路由的示例:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    
if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapGet("/", async context => { await context.Response.WriteAsync("Hello World!"); }); }); }

路由使用了一對中介軟體,通過 UseRouting 和 UseEndPoints 註冊:

  • UseRouting 新增路由匹配到中介軟體管道中。這個路由匹配中介軟體查詢在應用程式中定義的 Endpoints 的集合,選擇最佳匹配請求的 Endpoint。
  • UseEndpoints 新增 Endpoint 的執行體到中介軟體管道。它通過關聯到選擇的 Endpoint 代理執行。

前面這個示例包含了一個單獨的路由到程式碼的 Endpoint 使用 MapGet 方法:

  • 當一個 HTTP GET 請求傳送到根 URL /:
    - 請求代理展示執行
    - Hello World! 被寫入 HTTP 請求迴應中。預設的,根 URL / 是 https://localhost:5001/。
  • 如果說請求方法不是 GET 或者 根 URL 不是 /,沒有路由匹配的情況下會返回 HTTP 404。

Endpoint

MapGet 方法用來定義一個 Endpoint。一個 endpoint 可以是以下情況:

  • 選擇:通過匹配 URL 和 HTTP 方法
  • 執行:通過執行代理

在 UseEndpoints 中配置的 Endpoints 可以被 APP 匹配和執行。例如,MapGet, MapPost, 和一些類似於連線請求代理到路由系統的方法。更多的方法可以被用於連線 ASP.NET Core 框架的特性到路由系統中:

  • MapRazorPages 用於 Razor Pages
  • MapController 用於 控制器
  • MapHub<THub> 使用者 SignalR
  • MapGrpcService<TService> 用於 gPRC

下面這個例子展示了一個路由一個比較複雜的路由模板:

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("/hello/{name:alpha}", async context =>
    {
        var name = context.Request.RouteValues["name"];
        await context.Response.WriteAsync($"Hello {name}!");
    });
});

字串 /hello/{name:alpha} 是一個路有模板。被用來配置 endpoint 如何被匹配到。在這個例子中,模板匹配以下情況:

  • 像 /hello/Ryan 這樣一個 URL
  • 任何的以 /hello/ 開頭的,緊跟一串字母的 URL。:alpha 應用了一個路由約束,它僅僅匹配字母。路由約束會在下面介紹到。

{name:alpha}: 上面 URL 路徑中的第二段:

  • 被繫結到 name 引數上
  • 被捕獲並存儲到 HttpRequest.RouteValues 中

當前文件中描述的 endpoint 路由系統是在 ASP.NET Core 3.0 中新新增的。然而,所有版本的 ASP.NET Core 都支援同樣的路由模板特性和路由約束的集合。

下面的示例展示了帶有 health checks 和 授權的路由:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // Matches request to an endpoint.
    app.UseRouting();

    // Endpoint aware middleware. 
    // Middleware can use metadata from the matched endpoint.
    app.UseAuthentication();
    app.UseAuthorization();

    // Execute the matched endpoint.
    app.UseEndpoints(endpoints =>
    {
        // Configure the Health Check endpoint and require an authorized user.
        endpoints.MapHealthChecks("/healthz").RequireAuthorization();

        // Configure another endpoint, no authorization requirements.
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

上面這個示例展示瞭如何:

  • 授權中介軟體可以被路由使用
  • Endpoints 可以被用於配置授權行為

MapHealthChecks 添加了一個 health check endpoint。接著又呼叫了 RequireAuthorization 附加了一個授權策略到這個 endpoint 上。

UseAuthentication 和 UseAuthorization 新增認證和授權中介軟體。這些中介軟體在 UseRouting 和 UseEndpoints 中間呼叫,因此可以:

  • 檢視哪個 endpoint 被選中通過 UseRouting
  • 在 UseEndpoints 分發請求到 endpoint 之前應用授權策略

Endpoint 元資訊

在前面這個例子中,有兩個 endpoints,但是隻有 health check 附加了一個授權策略。如果請求匹配了 health check, /healthz,授權檢查就會被執行。這說明 endpoints 可以有額外的資料附加到他們上面。這寫額外的資料叫做 endpoint metadata:

  • metadata 可以被路由中介軟體處理
  • metadata 可以是任何的 .NET 型別

路由的概念

路由系統通過新增強大的 endpoint 概念建立在中介軟體管道之上。Endpoints 代表了一組應用程式的功能,這些功能和路由,授權和 ASP.NET Core 核心系統功能是不同的。

ASP.ENT Core 中 endpoint 的定義

ASP.NET Core endpoint:

  • 可執行的:包含一個請求代理
  • 可擴充套件的:包含一個 Meatadata 集合
  • 可選擇的:可選的,包含路由資訊
  • 可列舉的:enpoint 的集合可以通過 EndpointDataSource 獲取被列出來

下面的程式碼展示瞭如何獲取和檢查匹配當前請求的 endpoint:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.Use(next => context =>
    {
        var endpoint = context.GetEndpoint();
        if (endpoint is null)
        {
            return Task.CompletedTask;
        }
        
        Console.WriteLine($"Endpoint: {endpoint.DisplayName}");

        if (endpoint is RouteEndpoint routeEndpoint)
        {
            Console.WriteLine("Endpoint has route pattern: " +
                routeEndpoint.RoutePattern.RawText);
        }

        foreach (var metadata in endpoint.Metadata)
        {
            Console.WriteLine($"Endpoint has metadata: {metadata}");
        }

        return Task.CompletedTask;
    });

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

如果 endpoint 被選中了,那麼可以從 HttpContext 中獲取到。它的屬性可以被檢測到。Endpoint 物件是不可變的,建立之後就不可以修改了。最常見的 endpoint 型別是 RouteEnpoint。RouteEndpoint 包含了它可以被路由系統選擇的資訊。

在前面的程式碼中,app.Use 配置了一個行內的 middleware。

下面的程式碼展示了由於 app.Use 呼叫位置不同,可能就沒有一個 enpoint。

// Location 1: before routing runs, endpoint is always null here
app.Use(next => context =>
{
    Console.WriteLine($"1. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
    return next(context);
});

app.UseRouting();

// Location 2: after routing runs, endpoint will be non-null if routing found a match
app.Use(next => context =>
{
    Console.WriteLine($"2. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
    return next(context);
});

app.UseEndpoints(endpoints =>
{
    // Location 3: runs when this endpoint matches
    endpoints.MapGet("/", context =>
    {
        Console.WriteLine(
            $"3. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
        return Task.CompletedTask;
    }).WithDisplayName("Hello");
});

// Location 4: runs after UseEndpoints - will only run if there was no match
app.Use(next => context =>
{
    Console.WriteLine($"4. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
    return next(context);
});

上面示例添加了 Console.WriteLine 語句,顯示了是否一個 endpoint 被選中。為了清晰,示例中為 / endpoint 增加了名稱顯示。

執行這段程式碼,訪問 /,將會顯示:

1. Endpoint: (null)
2. Endpoint: Hello
3. Endpoint: Hello

如果訪問其他 URL,則會顯示:

1. Endpoint: (null)
2. Endpoint: (null)
4. Endpoint: (null)

輸出結果說明了:

  • 在 UseRouting 呼叫之前,enpoint 總是 null
  • 在 UseRouting 和 UseEndpoints 之間,如果一個匹配被發現,endpoin 就不是 null
  • 當一個匹配被發現時,UseEndpoints 中介軟體會是一個終結。終結中介軟體在文件後面會講到
  • 在 UseEndpoints 之後的中介軟體只有在沒有匹配被發現的時候才會執行

UseRouting 中介軟體使用 SetEndpoint 方法把 endpoint 附加到當前請求上下文。可以使用自定義的邏輯替換掉 UseRouting 並且使用 endpoint 好處。Endpoints 是和中介軟體類似的低級別的原語,不和路由的實現耦合在一起。大多數的應用程式不需要自定義邏輯替換 UseRouting。

UseEndpoints 中介軟體被設計用來和 UseRouting 中介軟體配合使用。執行一個 endpoint 的核心邏輯並不複雜。 使用 GetEndpoint 獲取 endpoint,然後呼叫它的 RequestDelegate 屬性。

下面的程式碼展示了中介軟體如何對路由產生影響或者做出反應:

public class IntegratedMiddlewareStartup
{ 
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        // Location 1: Before routing runs. Can influence request before routing runs.
        app.UseHttpMethodOverride();

        app.UseRouting();

        // Location 2: After routing runs. Middleware can match based on metadata.
        app.Use(next => context =>
        {
            var endpoint = context.GetEndpoint();
            if (endpoint?.Metadata.GetMetadata<AuditPolicyAttribute>()?.NeedsAudit
                                                                            == true)
            {
                Console.WriteLine($"ACCESS TO SENSITIVE DATA AT: {DateTime.UtcNow}");
            }

            return next(context);
        });

        app.UseEndpoints(endpoints =>
        {         
            endpoints.MapGet("/", async context =>
            {
                await context.Response.WriteAsync("Hello world!");
            });

            // Using metadata to configure the audit policy.
            endpoints.MapGet("/sensitive", async context =>
            {
                await context.Response.WriteAsync("sensitive data");
            })
            .WithMetadata(new AuditPolicyAttribute(needsAudit: true));
        });

    } 
}

public class AuditPolicyAttribute : Attribute
{
    public AuditPolicyAttribute(bool needsAudit)
    {
        NeedsAudit = needsAudit;
    }

    public bool NeedsAudit { get; }
}

上面的示例展示了兩個重要的概念;

  • 中介軟體可以在 UseRouting 之前執行,然後修改路由的執行行為。通常的,這些出現在路由之前的中介軟體,會修改一些請求的屬性,比如 UseRewriter, UseHttpMethodOverride, 或者 UsePathBase。
  • 在中介軟體被執行之前,中介軟體可以執行在 UseRouting 和 UseEndpoints 之間處理一些路由的結果。執行在 UseRouting 和 UseEndpoints 之間的中介軟體:通常會檢查 metadata 去理解 endpoints;通常會做出安全方面的決定,比如使用 UseAuthorization 和 UseCors。
  • 中介軟體和 metadata 的結合允許為每一個 endpoint 配置策略。

上面的程式碼展示了一個自定義的支援為每一個 endpoint 新增策略的 endpoint。這個中介軟體輸出訪問敏感資料的 audit log 到控制檯。這個中介軟體可以使用 AuditPolicyAttribute metadata 配置為一個 audit enpoint。這個示例展示了一個選擇模式,只有 enpoints 被標記為敏感的才會被驗證。也可以反向定義邏輯,例如驗證沒有被標記為安全的一切。endpoint metadata 系統是靈活的。邏輯可以被設計為任何符合使用情況的方式。

上面的示例程式碼是為了展示 endpoints 的基本概念。示例不是為了用於生產環境。一個更完整的 audit log 中介軟體應該是這樣的:

  • 日誌儲存到一個檔案或者資料庫中
  • 包含詳細資訊,例如使用者,IP地址,敏感 endpoint 的名稱以及更多的資訊

audit 策略 metadata AuditPolicyAttribute 被定義為一個 Attribute 是為了在一個 class-based 的 framework 中更加容易使用,例如 controllers 和 SignalR。當使用路由編碼時:

  • Metadata 是附加到一個 builder API 上的
  • Class-based frameworks 包含建立 endpoints 時關於對應的方法和類的所有的屬性

對於 metadata 型別的最佳實踐是把它們定義為介面或者屬性。介面和屬性允許程式碼複用。metadata 系統是靈活的並且不強加任何限制。

比較終端中介軟體和路由

下面的程式碼展示了使用中介軟體和使用路由的差別:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // Approach 1: Writing a terminal middleware.
    app.Use(next => async context =>
    {
        if (context.Request.Path == "/")
        {
            await context.Response.WriteAsync("Hello terminal middleware!");
            return;
        }

        await next(context);
    });

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        // Approach 2: Using routing.
        endpoints.MapGet("/Movie", async context =>
        {
            await context.Response.WriteAsync("Hello routing!");
        });
    });
}

中介軟體的方式展示的方法1:一個終端中介軟體。被叫做終端中介軟體是因為它匹配了以下操作:

  • 前面例子中中介軟體匹配的操作是 Path = "/" ,路由匹配的操作是 Path = "/Movie"
  • 當一個匹配成功的時候,執行了一些功能然後返回了,而不是呼叫 next 執行下一個中介軟體

被叫做終端中介軟體是因為它終結了搜尋,執行了一些操作,然後就返回了

比較一個終端中介軟體和路由:

  • 兩種途徑都允許終結處理管道:中介軟體通過返回語句而不是呼叫 next 方法終結管道;路由 Endpoints 總是結束執行
  • 終結中介軟體允許在管道的任意地方呼叫;Endpoints 執行的位置在 UseEnpoints 中
  • 終結中介軟體允許任意的的程式碼去決定什麼時候去中介軟體匹配:自定義的路由匹配程式碼可能是很難寫正確的;路由為典型的應用程式提供了一個直接的解決方案。大部分的應用程式不需要自定義路由匹配的邏輯
  • Enpoints 介面帶有中介軟體,例如 UseAuthorizaton 和 UseCors: 通過呼叫 UseAuthorization 和 UseCors 使用一個終端中介軟體要求手動的實現授權系統

一個 endpoint 定義了:

  • 一個處理請求的代理
  • 一個任意 metadata 的集合。metadata 用來實現橫向的基於策略的考慮和配置到每一個 endpoint

終結中介軟體可以是一個有效的工具,但是要求:

  • 大量的程式碼和測試
  • 手動的整合整合其它系統以獲得更好的靈活性

在編寫一個終結中介軟體之前,應該優先考慮使用整合到路由

現有的集成了 Map 或者 MapWhen 的終結中介軟體通常可以在一個路由中實現 endpoint。MapHealthChecks 展示了 router-ware 的模型:

  • 在 IEndpointRouteBuilder 介面上寫了一個擴充套件方法
  • 使用 CreateApplicationBuilder 建立一個巢狀的中介軟體管道
  • 把中介軟體附加到一個新的管道,在這個例子中使用了 UseHealthChecks
  • 編譯中介軟體管道到一個 RequestDelegate
  • 呼叫 Map 然後提供一個新的中介軟體管道
  • 從擴充套件方法中返回 Map 提供的編譯後的物件

下面的程式碼展示了 MapHealthChecks 的使用:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // Matches request to an endpoint.
    app.UseRouting();

    // Endpoint aware middleware. 
    // Middleware can use metadata from the matched endpoint.
    app.UseAuthentication();
    app.UseAuthorization();

    // Execute the matched endpoint.
    app.UseEndpoints(endpoints =>
    {
        // Configure the Health Check endpoint and require an authorized user.
        endpoints.MapHealthChecks("/healthz").RequireAuthorization();

        // Configure another endpoint, no authorization requirements.
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

上面的程式碼展示了為什麼返回一個建立後的物件是重要的。返回一個建立的物件執行應用程式開發者去配置策略,例如 endpoint 授權策略。在這個例子中,health check 中介軟體沒有直接整合授權系統。

metadata 系統的建立是為了響應擴充套件性作者在使用終結中介軟體時所遇到的問題。對於每一箇中間件,它是不確定的,是為了實現中介軟體自己帶有授權系統的整合。

URL matching

  • 是路由匹配進站請求到一個 endpoint 的過程
  • 基於 URL path 和 headers 中的資料
  • 可以被擴充套件為考慮請求中的任意資料

當一個路由中介軟體執行的時候,它設定一個 Endpoint 並且設定路由的值到一個從當前請求中獲取的一個 HttpContext 的請求特性中:

  • 呼叫 HttpContext.GetEndpoint 獲取一個 endpoint
  • HttpRequest.RouteValues 獲取到路由值得集合

執行在路由中介軟體之後的中介軟體可以檢測 endpoint 後決定執行的操作。舉個例子,一個授權中介軟體可以查詢 endpoint 的 metadata 集合實現授權策略。在請求處理管道中的所有中介軟體執行完畢後,被選擇的 endpoint 的代理被呼叫。

基於 enpoint 路由的路由系統負責所有的請求分發的決定。因為中介軟體應用策略是基於被選擇的 endpoint,這是很重要的:

  • 任何可能影響分發或者應用程式安全策略的決定都可以在路由系統中確定

警告:為了向後相容,當一個控制器或者 Razor 頁面 endpoint 代理被執行的時候,目前基於執行的請求處理RouteContext.RouteData被設定為合適的值。

RouteContext 型別在以後的版本中將被標記為廢棄的在:

  • 遷移 RouteData.Value 到 HttpRequest.RouteValues 中
  • 遷移 RaouteData.DataTokens 從 endpoint metadata 獲取 IDataTokensMetadata

URL 匹配操作在一個可配置的分段集合中。在每一個分段中,輸出是匹配的集合。通過下一個分段,匹配的集合會逐步縮小。路由的實現不保證匹配 endpoints 的處理順序。所有可能的匹配一次就會處理。URL的匹配遵循以下順序。

ASP.NET Core:

  1. 處理 URL path 和 endpoints 的集合比較和它們的路由模板,蒐集所有匹配的
  2. 處理列表,移除不滿足路由約束的匹配
  3. 處理列表,移除不滿足 MatcherPolicy 示例集合的匹配
  4. 使用 EndpointSelector 從處理列表中做出最終決定

  enpoints 列表的優先順序遵循以下原則:

  • RouteEndpoint.Order
  • 路由模板優先

所有匹配的 endpoints 在每個階段直到 EndpointSelector 執行。 EnpointSelector 是最後一個階段。它從所有匹配的 endpoints 中選擇優先順序最高的 endpoint 作為最佳匹配。如果有同樣優先順序的匹配,一個模糊匹配的異常將會丟擲。

路由的優先順序是基於一個更加具體的路由模板被計算出來被賦予一個更高的優先順序。例如,比較一個兩個路由模板 /hello 和 /{message}

  • 兩個模板都匹配 URL path /hello
  • /hello 更加具體,因此有更高優先順序

通常的,在實際中,路由優先順序已經做了一個從各種各樣的 URL schemes 中選擇最佳匹配的很好的工作。僅僅在為了避免歧義的時候使用 Order。

由於路由提供了各種各樣型別的擴充套件性,路由系統不太可能花費大量的時間去計算有歧義的路由。考慮一下像 /{message:alpha} 和 /{message:int} 這兩個路由模板:

  • alpha 約束只匹配字母字元
  • int 約束只匹配數字
  • 這兩個模板擁有相同的路由優先順序,但是沒有一個單獨的 URL 是它們都匹配的
  • 如果路由系統在 startup 中報告了一個歧義錯誤,這將會阻止這種情況的使用

警告:

UseEnpoints 中的操作的順序不會影響路由的行為,但有一個例外。 MapControllerRoute 和 MapAreaRoute 自動的會基於它們被呼叫的順序賦值一個排序的值給它們的 enpoints。這模擬了控制器的長期行為,而這些控制器沒有路由器提供和舊的路由實現相同的保證。

在舊的路由實現中,是可以實現依賴路由處理順序的擴充套件。ASP.NET Core 以及更新的版本中的 endpoint 路由:

  • 沒有路由的概念
  • 不提供順序保證。所有的 endpoints 都一次處理。

路由模板的優先順序和 enpoint 選擇順序

路由模板優先是基於如何具體化一個路由模板,並給它賦予一個值得系統。路由模板優先順序:

  • 避免在大多數情況下調整 enpoints 順序的必要
  • 嘗試匹配路由行為的常識性期望

例如,模板 /Products/List 和 /Products/{id}。對於 URL path,系統將會認為 /Products/List 比/Produts/{id} 更加匹配。這是因為字面值段 /List 被認為比引數 /{id} 有更高的優先順序。

優先順序工作原理和路由模板如何定義相結合的詳情如下:

  • 擁有更多段的模板被認為更加具體
  • 字面文字的段被認為比一個引數的段更加具體
  • 帶有約束的引數的段被認為比沒有約束的引數的段更加具體
  • 一個複雜的段被認為和一個帶約束引數的段一樣具體
  • Catch-all 引數最不具體。檢視路由模板引用中的 catch-all 更多的關於 catch-all 路由的重要資訊

URL 生成概念

URL 生成:

  • 基於一組路由值建立一個 URL path 的過程
  • 允許在 enpoints 和 URLs 之間進行邏輯分離

Endpoint 裸遊包含 LinkGenerator API。LInkGenerator 作為一個單利服務從依賴注入中獲取。LinkGenerator API 可以在正在執行的請求的上下文之外執行。Mvc.IUrlHelper 和 scenarios 依賴於 IUrlHelper,例如 Tag Helpers,HTML Helpers,以及 Action Results,在內部使用 LinkGenerator API 提供生成連結的功能。

路由生成器由地址和地址架構的概念的支援。一個地址架構是一種決定哪些 endpoints 應該被用來生成連結的方式。例如,許多使用者熟悉的從控制器獲取路由名稱和路由值以及 Razor Pages 被用來實現作為一種地址架構。

連結生成器可以連結到控制器和 Razor Pages 通過以下擴充套件方法:

  • GetPathByAction
  • GetUriByAction
  • GetPathByPage
  • GetUriByPage

這些方法的過載的引數包含 HttpContext。這些方法在功能上等同於 Url.Action 和 Url.Page,但是提供更多的靈活性和選擇。

GetPath* 之類的方法和 Url.Action 以及 Url.Page 很相似,它們生成的 URI 包含一個絕對路徑。GetUrl* 方法總是生成一個包含一個架構和主機的絕對 URI。接受引數 HttpContext 引數的方法在正在執行的請求的 Context 中生成一個 URI。除非重寫,否則路由值將使用當前正在執行的請求中的 URI base path,架構以及主機。

LinkGenerator 被地址呼叫。生成一個 URI 在以下兩個步驟中出現:

  1. 地址被繫結到一組匹配當前地址的 enpoints
  2. 直到一個路由模型匹配了提供的值被發現了,endpoint 的路由模型才會被評估。輸出的結果組合了其它 URI 的部分並提供給連結生成器返回。

LinkGenerator 提供的方法支援生成任何型別的標準的連結的能力。使用 link generator 最方便的方式是通過那些為特定地址型別操作的擴充套件方法:

GetPathByAddress 基於提供的值生成一個絕對路徑的 URI

GetUriByAdderss 基於提供的值生成一個絕對的 URI

⚠️ 警告:

注意呼叫 LinkGenerator 會有以下影響:

  • 在配置不驗證 Host headers 的應用程式中,請謹慎使用 GetUri* 擴充套件方法。如果請求的 Host header 不驗證,不被信任的請求輸入就會在檢視或者page中的URIs被髮送到客戶端。我們建議素有的生產用的應用程式都配置伺服器驗證已知的值作為 Host header 有效的值。
  • 在結合了 Map 或者 MapWhen 的中介軟體中謹慎使用 LinkGenerator。Map* 改變了執行請求的基本路徑,這會影響 link generator 的輸出。所有的 LinkGenerator APIs 允許指定一個基本的路徑。指定一個空的基本路徑可以避免 Map* 對 link generation 產生的影響。

中介軟體示例

在下面的例子中,一箇中間件使用了 LinkGenerator API 為一個列出儲存產品的方法建立一個連結。通過注入 link generator 到一個類中,然後在任何一個應用程式中的類都可以呼叫 GenerateLink:

public class ProductsLinkMiddleware
{
    private readonly LinkGenerator _linkGenerator;

    public ProductsLinkMiddleware(RequestDelegate next, LinkGenerator linkGenerator)
    {
        _linkGenerator = linkGenerator;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        var url = _linkGenerator.GetPathByAction("ListProducts", "Store");

        httpContext.Response.ContentType = "text/plain";

        await httpContext.Response.WriteAsync($"Go to {url} to see our products.");
    }
}

路由模板參考

{} 中的符號定義了路由匹配時繫結的路由引數。可以在路由分段中定義多個路由引數,但是路由引數必須由字面值分割開來。例如, {controller=Home}{action=Index} 不是一個有效的路由,由於在 {controller} 和 {action} 之間沒有字面值。路由引數必須有一個名稱,也可能有更多指定的屬性。

字面值而不是路由引數(例如 {id}) 和路徑分隔符 / 必須匹配 URL 中的文字。文字匹配區分大小寫並且基於 URL's 路由的編碼。根據定界符 { 或者 } 匹配一個字面上的路由引數,通過重複字元轉義定界符。例如: {{ 或者 }}

星號 * 或者 兩個星號 **:

  • 可以作為路由引數的一個字首,繫結到 URI 剩餘的部分
  • 被稱為 catch-all 引數。例如,blog/{**slug}:匹配所有的以 /blog 開頭的以及跟有任何值得 URI;跟在 /blog 後面的值被賦值給 slug

Catch-all 引數也可以匹配空的字串。

當路由被用來生成一個 URL,catch-all 引數會轉義合適的字元,包括路徑分隔符 /。例如,帶有引數 { path = "my/path" } 的路由 foo/{*path} 生成 foo/my%2Fpath。注意轉義的斜槓。為了保留路徑分隔符,使用 ** 作為路徑引數的字首。路由 foo/{**path} 賦值 { path="my/path" } 生成 foo/my/path。

檢視捕捉一個帶有可選的副檔名的檔名稱的 URL 模式的時候,需要有更多的考慮。例如,模板 files/{filename}.{ext?}。當引數 filename 和 ext 都有值得時候,兩個值都會被填充。如果只有 filename 的值存在於 URL 中,路由將會匹配,因為尾部的 . 這是就是可選的。下面的 URLs 會匹配這兩種路由:

  • /files/myFile.txt
  • /files/myFile

路由引數可以提供預設值,通過在引數名稱後面新增等號(=)給路由引數指定。例如, {controller=Home},為 controller 指定了 Home 作為預設的值。預設的值在 URL 中沒有為引數提供值的時候使用。通過在路由引數名稱後面新增一個問號 (?) 來指定這個引數是可選的。例如,id?。可選引數和預設引數不同的是:

  • 提供預設值得路由引數總是會生成一個值
  • 可選引數只有在請求的 URL 中提供值的時候才會被賦予一個值

路由引數可能包含必須匹配繫結到 URL 中的路由值的約束。在路由引數名稱後面新增 : 和約束名稱在行內指定引數約束。如果約束要求帶有引數,它們被包括在圓括號(...)中跟在約束名稱後面。多個行內約束可以通過新增另外一個 : 和約束名稱來指定。

約束名稱和引數被傳遞給 IlnlineConstraintResolver 用來建立一個 IRouteConstratin 例項,這個例項在 URL 處理過程中使用。例如,路由模板 blog/(article:minlength(10) 指定了一個值為 10 的 minlength 約束。更多的關於路由約束和框架提供的一系列的約束,請檢視Route constraint reference部分。

路由引數可能會有引數轉換。路由引數在生成連結和匹配方法以及pages到URLs的時候會轉換引數的值。和約束一樣,引數轉換可以通過在路由引數名稱後面新增一個 : 和轉換名稱到路由引數。例如,路由模板 blog/{article:slugify} 指定一個名稱為 slugify 的轉換。關於更過關於引數轉換的資訊,請檢視Parameter transformer reference部分。

下面的表格展示了路由模板和他們的行為:

路由模板 匹配的 URI 示例 請求 URI...
hello /hello 只匹配一個路徑 /hello
{Page=Home} / 匹配並且設定 Page 為 Home
{Page=Home} /Contact 匹配並且設定 Page 為 Contact
{controller}/{action}/{id?} /Products/List 對映 Products 到 controller,List 到 action
{controller}/{action}/{id?} /Products/Details/123 對映 Products 到 controller,Details 到 action,id 的值被設定為 123
{controller=Home}/{action=Index}/{id?} / 對映到 Home controller 和 Index 方法. id 引數被忽略
{controller=Home}/{action=Index}/{id?} /Products 對映到 Products controller 和 Index 方法,id 引數被忽略

通常的,使用模板是最簡單的獲取路由的方式。約束和預設值也可以被指定在路由模板的外部。

複雜的分段

複雜的分段是通過以非貪婪的方式從右到左匹配文字分割的方式來處理的。例如,[Route("/a{b}c{d}")] 是一個複雜的分段路由。複雜的分段以一種特殊的方式工作,必須理解以能夠正確的使用它們。這部分的示例展示了為什麼複雜的分段只有在分界文字不在引數值中才能正常工作的原因。使用正則表示式,然後對於更加複雜的例子需要手動提取其中的值。

⚠️ 警告:

當使用 System.Text.RegularExpressions 處理不受信任的輸入的時候,傳入一個超時時間。一個惡意的使用者提供給 RegularExpressions 的輸入可能會引起Denial-of-Service attack. ASP.NET Core 框架的 APIs 使用 RegularExpressions 的時候傳入了超時時間。

下面總結了路由處理模板 /a{b}c{d} 匹配 URL path /abcd 的步驟。| 用來使演算法是怎麼工作的更加形象:

  • 從右到左計算,第一個文字值是 c。因此 /abcd 從右搜尋然後發現 /ab|c|d
  • 右邊 (d)的所有現在匹配到路由引數 {d}
  • 從右到左計算,下一個是 a。因此 /ab|c|d 從我們結束的地方開始搜尋,然後 a 被發現 /a|b|c|d
  • 右邊的 (b) 現在匹配路由引數 {b}
  • 沒有了剩餘的文字和路由模板,因此一個匹配就完成了

這裡舉例一個使用相同模板 /a{b}c{d},不同 URL 路徑匹配不成功的例子。| 用來更形象的展示演算法的工作。這個例子使用同樣的演算法解釋了沒有匹配成功:

  • 從右到左,第一個文字是 c。因此從右開始搜尋 /aabcd,發現了 /aab|c|d
  • 右邊的(d)的所有都匹配了路由引數 {d}
  • 從右到左,下一個文字是 a。因此從上次我們停止的地方 /aab|c|d 開始搜尋,a 在 /a|a|b|c|d 中被發現
  • 右邊的 (b) 現在匹配到路由引數 {b}
  • 此時,還有一個剩餘的字元 a,但是演算法已經按照路由模板執行完畢,因此匹配沒有成功

由於匹配演算法是非貪婪的:

  • 在每一個步驟中,它匹配最小數量的文字
  • 任何情況下,在引數值內的分隔符的值都會導致不匹配

正則表示式提供了更多的匹配行為。

貪婪匹配,也叫做懶匹配,匹配最大可能的字串。非貪婪模式匹配最小的字串。

路由約束參考

路由約束在匹配入站 URL 和 URL path 被路由值標記進入的時候會執行。路由約束通過路由模板檢測路由值,然後確認值是否可以被接受。一些路由約束使用路由值之外的資料去考慮是否一個請求可以被路由。例如,HttpMethodRouteConstraint 可以基於它的 HTTP 謂詞接受或者拒絕一個請求。約束被用於路由請求和連結生成中。

⚠️ 警告:

不要使用約束驗證輸入。如果約束被用於輸入驗證,無效的輸入將會導致 404 Not Found 被返回。無效的輸入應該產生一個帶有合適錯誤資訊的 400 Bad Request。路由約束用來消除相似路由的歧義,而不是用來驗證一個特定的路由。

下面的表格展示了示例路由約束和它們期望的行為:

約束 示例 匹配的示例 備註
int {id:int} 123456789,-123456789 匹配任何的整型資料
bool {active:bool} true,False 匹配 true,false。不區分大小寫
datetime {dob:datetime} 2020-01-02,2020-01-02 14:27pm 在固定區域中匹配一個有效的 DateTime 型別的值。檢視處理警告。
decimal {price:decimal} 49.99,-1,000.01 在固定區域中匹配一個有效的 decimal 型別的值。檢視處理警告。
double {weight:double} 1.234,-1,001.01e8 在固定區域中匹配一個有效的 double 型別的值。檢視處理警告。
float {weight:float} 1.234,-1,001.01e8 在固定區域中匹配一個有效的 float 型別的值。檢視處理警告。
guid {id:guid} CD2C1638-1638-72D5-1638-DEADBEEF1638 匹配一個有效的 guid 型別的值
long {ticks:long} 123456789,-123456789 匹配一個有效的 long 型別的值
minlength(value) {username:minlength(4)} Rick 長度至少為 4 的字串
maxlength(value) {filename:maxlength(8)} MyFile 長度最多為 8 的字串
length(length) {filename:length(12)} somefile.txt 長度為 12 的字串
length(min,max) {filename:length(8,16)} somefile.txt 長度處於 8 -16 的字串
min(value) {age:min(18)} 19 最小為 18 的整型資料
max(value) {age:max(120)} 91 最大為 120 的整型資料
range(min,max) {age:range(18,120)} 91 18 - 120 的整型資料
alpha {name:alpha} Rick 字串必須包含一個或者更多的字母字元,a-z,不區分大小寫
regex(expression) {ssn:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)} 123-45-6789 字串必須匹配提供的正則表示式。檢視定義正則表示式的提示。
required {name:required} Rick 在生成 URL 的過程中用來強制一個非空引數值

⚠️ 警告:

當使用 System.Text.RegularExpressions 處理不被信任的輸入時,傳入一個超時時間。一個惡意的使用者可能會提供一個引起Denial-of-Service attack的 RegularExpressions。ASP.NET Core 框架的 APIs 使用 RegularExpressions 時都會傳入一個超時時間。

多個冒號分隔符可以應用於單個的引數。例如,下面的約束限制一個最小為1的整型:

[Route("users/{id:int:min(1)}")]
public User GetUserById(int id) {}

⚠️ 警告:

路由約束驗證 URL 並且總是使用固定的區域轉換為一個 CLR 型別。例如,轉換為 CLR 型別中的 int 或者 DateTime。這些約束假設 URL 不是本地化的。框架提供的路由約束不修改儲存在路由值中的值。所有的路由值從 URL 中解析出來被儲存為字串。例如,float 約束試圖轉換一個路由值為 float 型別,但是轉換隻有在驗證可以被轉換的時候才會使用到。

約束中的正則表示式

⚠️ 警告:

當使用 System.Text.RegularExpressions 處理不被信任的輸入時,傳入一個超時時間。一個惡意的使用者可能會提供一個引起Denial-of-Service attack的 RegularExpressions。ASP.NET Core 框架的 APIs 使用 RegularExpressions 時都會傳入一個超時時間。

約束中的正則表示式可以使用 regex(...) 在行內指定。 MapControllerRoute 一類的方法也接受物件字面值。如果使用了這種格式,字串的值被解釋為正則表示式。

下面的程式碼使用了行內正則表示式約束:

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("{message:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}",
        context => 
        {
            return context.Response.WriteAsync("inline-constraint match");
        });
 });

下面的程式碼使用了字面物件指定一個正則表示式約束:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "people",
        pattern: "People/{ssn}",
        constraints: new { ssn = "^\\d{3}-\\d{2}-\\d{4}$", },
        defaults: new { controller = "People", action = "List", });
});

ASP.NET Core 框架在正則表示式的構造方法中添加了 RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant 引數。檢視 RegexOptions 瞭解這些成員的描述。

正則表示式使用分隔符和符號和路由以及 C# 語言相似。正則表示式的符號必須被轉義。在約束行內使用正則表示式^\d{3}-\d{2}-\d{4}$,可以使用以下任意一種方法:

為了轉義路由引數分隔符 {,},[,],在表示式中使用重複的字元,例如,{{,}},[[,]]。下面的表格展示了正則表示式和它的轉義版本:

正則表示式 轉義後的正則表示式
^\d{3}-\d{2}-\d{4}$ ^\\d{{3}}-\\d{{2}}-\\d{{4}}$
^[a-z]{2}$ ^[[a-z]]{{2}}$

路由中的正則表示式經常是 ^ 字元開頭去匹配字串的起始位置。正則表示式總是以 $ 結尾來匹配字串的結尾。 ^ 和 $ 字元保證了正則表示式能夠匹配全部的路由引數值。如果沒有 ^ 和 $,正則表示式匹配任意的子字串,這不是我們期望得到的。下面的表格列出了一些例子,並且解釋了為什麼能夠匹配或者匹配失敗:

表示式 字串 是否匹配 備註
[a-z]{2} hello YES 子字串匹配
[a-z]{2} 123abc456 YES 子字串匹配
[a-z]{2} mz YES 匹配正則表示式
[a-z]{2} MZ YES 不區分大小寫
^[a-z]{2}$ hello NO 檢視上面 ^ 和 $
^[a-z]{2}$ 123abc456 NO 檢視上面 ^ 和 $

更多關於正則表示式語法的資訊,檢視.NET Framework Regular Expressions.

使用正則表示式可以約束引數到一些已知的可能的值上面。例如,{action:regex(^(list|get|create)$)} 僅僅匹配 action 的路由值到 list,get 或者 create。如果傳遞到約束字典中,字串^(list|get|create)$) 是等同的。傳入約束字典的約束如果不匹配任意一個一直的約束,那麼任然被認為是一個正則表示式。使用模板傳入的約束如果不匹配任意一個已知的約束將不被認為是正則表示式。

自定義路由約束

通過實現 IRouteConstraint 介面可以建立自定義的路由約束。介面 IRouteConstraint 包含 Match,當滿足約束的時候它會返回 true,否則返回 false。自定義約束很少被用到。在實現一個自定義約束之前,考慮更直接的方法,比如模型繫結。

ASP.ENT Core 約束資料夾提供了一個很好的建立約束的例子。例如,GuidRouteConstraint。

為了使用一個自定義的 IRouteConstraint,路由約束的型別必須使用 ConstraintMap 在服務容器中註冊。一個 CostraintMap 是一個對映路由約束鍵值和 驗證這些約束 IRouteConstraint 實現的字典。應用程式的 ConstraintMap 可以在 Startup.ConfigureServices中或者作為 services.AddRouting 呼叫的一部分或者直接通過 services.Configure<RouteOptions>配置 RouteOptions 來更新。例如:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddRouting(options =>
    {
        options.ConstraintMap.Add("customName", typeof(MyCustomConstraint));
    });
}

上面新增的約束在下面的程式碼中使用:

[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
    // GET /api/test/3
    [HttpGet("{id:customName}")]
    public IActionResult Get(string id)
    {
        return ControllerContext.MyDisplayRouteInfo(id);
    }

    // GET /api/test/my/3
    [HttpGet("my/{id:customName}")]
    public IActionResult Get(int id)
    {
        return ControllerContext.MyDisplayRouteInfo(id);
    }
}

MyDisplayRouteInfo 通過 Rick.Docs.Samples.RouteInf NuGet 包提供,用來顯示路由資訊。

MyCustomConstraint 約束的實現阻止 0 被賦值給路由引數:

class MyCustomConstraint : IRouteConstraint
{
    private Regex _regex;

    public MyCustomConstraint()
    {
        _regex = new Regex(@"^[1-9]*$",
                            RegexOptions.CultureInvariant | RegexOptions.IgnoreCase,
                            TimeSpan.FromMilliseconds(100));
    }
    public bool Match(HttpContext httpContext, IRouter route, string routeKey,
                      RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (values.TryGetValue(routeKey, out object value))
        {
            var parameterValueString = Convert.ToString(value,
                                                        CultureInfo.InvariantCulture);
            if (parameterValueString == null)
            {
                return false;
            }

            return _regex.IsMatch(parameterValueString);
        }

        return false;
    }
}

⚠️ 警告:

當使用 System.Text.RegularExpressions 處理不被信任的輸入時,傳入一個超時時間。一個惡意的使用者可能會提供一個引起Denial-of-Service attack的 RegularExpressions。ASP.NET Core 框架的 APIs 使用 RegularExpressions 時都會傳入一個超時時間。

前面的程式碼:

  • 禁止0賦值給 {id}
  • 展示了實現一個自定義約束的基本示例。它不應該被用於一個正式環境的應用程式中。

下面的程式碼展示了一個更好的禁止0賦值給 id 的處理過程:

[HttpGet("{id}")]
public IActionResult Get(string id)
{
    if (id.Contains('0'))
    {
        return StatusCode(StatusCodes.Status406NotAcceptable);
    }

    return ControllerContext.MyDisplayRouteInfo(id);
}

上面的程式碼比自定義的約束 MyCustomConstraint 有以下優勢:

  • 不用實現一個自定義的約束
  • 當路由引數包含 0 的時候,會返回一個更加描述性的錯誤資訊

引數轉換參考

引數轉換:

例如,一個在模型 blog\{article:slugify} 中的自定義的 slugfy 引數轉換時使用 Url.Action(new { artical = "MyTestArtical" }) 生成 blog\my-test-artical。

考慮下面 IOutboundParameterTransformer 的實現:

public class SlugifyParameterTransformer : IOutboundParameterTransformer
{
    public string TransformOutbound(object value)
    {
        if (value == null) { return null; }

        return Regex.Replace(value.ToString(), 
                             "([a-z])([A-Z])",
                             "$1-$2",
                             RegexOptions.CultureInvariant,
                             TimeSpan.FromMilliseconds(100)).ToLowerInvariant();
    }
}

在引數模型中為了使用一個引數轉換,需要在 Startup.ConfigureServices 中使用 ConstraintMap 配置。如下:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddRouting(options =>
    {
        options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer);
    });
}

ASP.NET Core 框架在一個 enpoint 被解析到的時候會使用引數轉換去轉換 URI。例如,引數轉換會轉換被用來匹配一個 area, controller,action,和page 的路由值。

例如以下程式碼:

routes.MapControllerRoute(
    name: "default",
    template: "{controller:slugify=Home}/{action:slugify=Index}/{id?}");

對於上面這個路由模板,方法SubscriptionManagementController.GetAll 匹配了 URI /subscription-management/get-all。引數轉換並沒有更改用來生成一個連結的路由值。例如,Url.Action("GetAll", "SubscriptionManagement")輸出/subscription-management/get-all.

ASP.NET Core 提供了供生成的路由使用的引數轉換 API 的約定:

URL 生成參考

這部分包含了 URL 生成演算法的參考。在實際中,大多複雜的 URL 生成使用控制器或者 Razor Pages。檢視routing in controllers獲取更多資訊。

URL 生成過程一開始呼叫LinkGenerator.GetPathByAddress或者一個相似的方法。這個方法提供一個地址,一組路由值和關於從 HttpContext 獲取到的當前請求的可選的資訊。

第一步就是使用地址去解析一組候選的 endpoints,這些 endpoints 使用IEndpointAddressScheme<TAddress>去匹配地址的型別。

一旦根據地址架構獲取到了一組候選 endpoints,endpoints 將會被排序,然後迭代處理直到一個 URL 生成的操作成功。URL 生成不檢查歧義性,第一個返回的結果就是最終的結果。

使用日誌跟蹤 URL 生成

跟蹤 URL 生成的第一步就是設定日誌等級由 Microsoft.AspNetCore.Routing 到 TRACE。LinkGenerator 記錄了很多關於對解決問題有用的處理過程的詳細資訊。

檢視URL generation reference關於 URL 生成的詳細資訊。

地址

地址的概念是 URL 生成用來繫結一個連結生成器中的一個呼叫到一組候選的 enpoints。

地址是隨兩個預設實現擴展出來的概念:

  • 使用 enpoint 名稱 (string) 作為地址:
    為 MVC的路由名稱提供相似的功能
    使用IEndpointNameMetadata作為 metadata 的型別
    根據所有註冊的 enpoints 的 metadata 解析提供的字串
    如果多個 endpoints 使用相同的名稱,將會在 startup 中丟擲異常
    對於通用目的的使用,推薦在控制器和 Razor Pages 之外使用
  • 使用路由值 (RouteValuesAddress)作為地址
    提供類似於控制器和 Razor Pages 遺留的 URL 生成的功能
    擴充套件和除錯非常複雜
    提供 IUrlHelper,Tag Helpers,HTML Helpers,Action Result 等等使用的實現

地址架構的作用是通過任意條件在地址和 enpoints 匹配之間建立關聯。

環境值和顯式值

從當前請求中,路由從 HttpContext.Request.RouteValues 中獲取路由值。和當前請求關聯的值被稱為環境值。為了更清晰,文件中把路由值中傳遞給方法的值稱為顯式值。

下面的例子展示了環境值和顯式值。它提供了從當前請求中獲取的環境值和顯式值: { id = 17 }

public class WidgetController : Controller
{
    private readonly LinkGenerator _linkGenerator;

    public WidgetController(LinkGenerator linkGenerator)
    {
        _linkGenerator = linkGenerator;
    }

    public IActionResult Index()
    {
        var url = _linkGenerator.GetPathByAction(HttpContext,
                                                 null, null,
                                                 new { id = 17, });
        return Content(url);
    }

上面程式碼:

  • 返回了 /Widget/Index/17
  • 通過DI(依賴注入)獲取 (LinkGenerator

下面的例子展示了不提供環境值,提供顯式值: { controller = "Home", action = "Subscribe", Id = 17 }:

public IActionResult Index2()
{
    var url = _linkGenerator.GetPathByAction("Subscribe", "Home",
                                             new { id = 17, });
    return Content(url);
}

上面的方法返回 /Home/Subscribe/17

WidgetController 中下面的程式碼返回 /Widget/Subscribe/17:

var url = _linkGenerator.GetPathByAction("Subscribe", null,
                                         new { id = 17, });

下面的程式碼提供了從當前請求的環境值中獲取的控制器,顯式值: { action = "Edit", id = 17 }:

public class GadgetController : Controller
{
    public IActionResult Index()
    {
        var url = Url.Action("Edit", new { id = 17, });
        return Content(url);
    }

在上面的程式碼中:

  • /Gadget/Edit/17 被返回
  • Url獲取IUrlHelper.
  • Action為 action 方法生成了一個絕對路徑的 URL

下面的程式碼提供了從當前請求中獲取的環境值,以及顯式值: { page = ".Edit", id = 17 }:

public class IndexModel : PageModel
{
    public void OnGet()
    {
        var url = Url.Page("./Edit", new { id = 17, });
        ViewData["URL"] = url;
    }
}

上面的程式碼在當 Edit Razor Page 包含以下指令的時候會設定 url 為 /Edit/17:

@page "{id:int}"

如果 Edit page 不包含 "{id:int}" 路由模板,url 就是 /Edit?id=17.

MVC 的IUrlHelper又增加了一層複雜性,除了下面描述的規則外:

  • IUrlHelper 總是從當前請求中獲取路由值作為環境值
  • IUrlHelper.Action總是複製當前的 action 和 controller 的路由值作為顯式值,除非他們被重寫
  • IUrlHelper.Page總是複製當前 page 的路由值作為顯式值,除非 page 被重寫
  • IUrlHelper.Page 總是覆蓋當前 handler 的路由值為 null 來作為顯式值,除非被重寫

使用者總是對環境值的詳細行為感到驚訝,因為 MVC 似乎不跟隨它自己的規則。由於歷史和相容性的原因,確定的路由值,例如 action,controller,page 和 handler 都有它們自己特定的行為。

LinkGenerator.GetPathByAction 和LinkGenerator.GetPathByPage 提供的相同功能為了相容性複製了 IUrlHelper 的這些異常。

URL 生成處理

一旦一組候選的 enpoints 被發現了,接下來就是 URL 生成演算法:

  • 迭代的處理 enpoints
  • 返回第一個成功的結果

處理過程的第一不叫做路由值驗證。路由值驗證通過路由決定從環境值獲取到的路由值哪個應該被使用和哪個應該被忽略。每一個環境值都會被考慮是結合顯示值還是被忽略。

理解環境值得最好方式是在通常情況下認為它試圖節省應用程式開發者的輸入。傳統的,環境值使用的場景對相關的 MVC 是非常有用的:

  • 當連結到同一個控制器的其他方法時,控制器的名稱不必指定
  • 當連結到同一 area 中的其他控制器時,area 的名稱不必指定
  • 當連結到同一個方法時,路由值不必指定
  • 當連結到應用程式的另外一部分時,你不想攜帶一些對其他部分沒有意義的路由值

呼叫 LinkGenerator 或者 IUrlHelper 返回 null 的情況通常是由於沒有通過路由值驗證。除錯路由值驗證,可以通過顯式指定更多的路由值來檢視問題是否解決。

路由值無效的前提是假設應用程式 URL 架構是分層的,擁有一個從左到右的分層結構。考慮一個基本的路由模板 {controller}/{action}/{id?} 可以直觀的感受在實際中它是怎麼工作的。對一個值的更改會使得出現在右邊的所有路由值失效。這反映了關於層次結構的假設。如果應用程式中 id 有一個環境值,並且操作給控制器指定了一個不同的值:

  • id 不會被重複使用,因為 {controller} 在 {id} 的左邊

一些示例展示了這個原則:

  • 如果顯式值中包含了 id 的值,環境中的 id 的值就會被忽略。環境值中的 controller 和 action 可以被使用
  • 如果顯式值中包含了 action 的值,任何環境值中 action 的值都會被忽略。環境值中的 controller 會被使用。如果顯式值中的 action 的值和環境值中的 action 中的值不同,id 的值將不會被使用。如果 action 的值相同,則 id 的值會被使用。
  • 如果顯式值中包含了 controller 的值,任何環境中的 controller 的值都被忽略。如果顯式值中的 controller 和環境值得 controller 值不相同,action 和 id 的值不會被使用。如果 controller 中的值相同,action 和 id 的值會被使用。

對於現存的屬性路由和專用常規路由,這一處理過程更加複雜。控制器常規路由,例如 {controller}/{action}/{id?} 使用路由引數指定了一個分層結構。 控制器和 Razor Pages 中的常規路由和屬性路由:

  • 有一個分層的路由值
  • 它們不出現在路由模板中

對於這些情況, URL 生成定義了 required values 的概念。controllers 和 Razor Pages 建立的 endpoints 可以指定允許路由值驗證工作的 required values。

路由值驗證演算法的詳細資訊:

  • required value 名稱和路由引數結合,然後從左到右處理
  • 對於每一個引數,環境值和顯式值都會被比較:
    如果環境值和顯式值相同,處理過程繼續
    如果有環境值沒有顯式值,環境值被用來生成 URL
    如果有顯式值沒有環境值,則拒絕環境值和後續的所有環境值
    如果環境值和顯示值都存在,並且兩個值不相同,則拒絕環境值和後續的所有環境值

這是,URL 生成操作已經準備好開始評估路由約束。接受的值的集合與提供給約束的預設的引數值相結合。如果約束全部通過,操作將會繼續。

下一步,被接受的引數可以用來展開路由模板。路由模板的處理過程如下:

  • 從左到右
  • 每一個引數都會替代被接受的值
  • 有以下特殊情況
    如果沒有被接受的值,但是引數有一個預設的值,預設的值會被使用
    如果沒有被接受的值,並且引數是可選的,則過程繼續
    如果缺失的可選引數的右邊的路由引數有任何值,則操作失敗
    連續的預設引數值和可選引數可能會被摺疊

不匹配路由分段的顯式的值被新增到 query 字串中。下面的表格展示了使用路由模板 {controller}/{action}/{id?} 的情況:

環境值 顯式值 結果
controller = "Home" action = "About" /Home/About
controller = "Home" controller = "Order",action="About" /Order/About
controller="Home",color="Red" action="About" /Home/About
controller="Home" action="About",color="Red" /Home/About?color=Red

路由值驗證的問題

ASP.NET Core 3.0 中一些 URL 生成的架構在早期的 ASP.ENT Core 的版本中 URL 生成工作的並不好。ASP.NET Core 團隊計劃在未來的釋出版本中新增新的特性的需求。目前,最好的解決方法就是使用傳統路由。

下面的程式碼展示了 URL 生成架構不被路由支援的示例:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute("default", 
                                     "{culture}/{controller=Home}/{action=Index}/{id?}");
    endpoints.MapControllerRoute("blog", "{culture}/{**slug}", 
                                      new { controller = "Blog", action = "ReadPost", });
});

在上面的程式碼中,culture 路由引數被用來本地化。期望的是 culture 引數總是被接受為一個環境值。然而,culture 引數不被接受為一個環境值,因為 required values 的工作方式。

  • 在 "default" 路由模板中,culture 路由引數在 controller 的左邊,因此對於 controller 的更改不會驗證 culture
  • 在 "blog" 路由模板中,culture 路由引數被認為是在 controller 的右邊,出現在了 required values 裡面

配置 endpoint metadata

下面的連結提供了配置 enpoint metadata 的更多資訊:

路由中主機匹配與 RequireHost

RequireHost 應用一個約束到需要指定主機的路由。RequireHost 或者 [Host] 引數可以是:

  • Host: www.domain.com,匹配任意埠的 www.domain.com
  • 帶有萬用字元的 Host: *.domain.com,匹配任意埠自的 www.domain.com,subdomain.domain.com 或者 www.subdomain.domain.com
  • 埠:*:5000,匹配任意5000埠的 Host
  • Host 和 port: www.domain.com:5000 或者 *.domain.com:5000,匹配 host 和 port

使用 RequireHost 或者 [Host] 可以指定多個引數。匹配主機的約束驗證任意的引數。例如,[Host("domain.com","*domain.com")] 匹配 domain.com,www.domain.com 和 subdomain.domain.com。

下面的程式碼使用 RequireHost 要求在路由中指定主機:

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

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", context => context.Response.WriteAsync("Hi Contoso!"))
            .RequireHost("contoso.com");
        endpoints.MapGet("/", context => context.Response.WriteAsync("AdventureWorks!"))
            .RequireHost("adventure-works.com");
        endpoints.MapHealthChecks("/healthz").RequireHost("*:8080");
    });
}

下面的程式碼在 controller 上使用 [Host] 屬性要求任意指定的主機:

[Host("contoso.com", "adventure-works.com")]
public class ProductController : Controller
{
    public IActionResult Index()
    {
        return ControllerContext.MyDisplayRouteInfo();
    }

    [Host("example.com:8080")]
    public IActionResult Privacy()
    {
        return ControllerContext.MyDisplayRouteInfo();
    }
}

當 [Host] 屬性在 controller 和 action 方法上都應用了的情況:

  • action 上面的屬性被使用
  • controller 屬性被忽略

路由效能指南

大部分的路由在 ASP.NET Core 3.0 被更新提高了效能。

當一個應用程式出現效能問題的時候,路由總是被懷疑是問題所在。路由被懷疑的原因是像 controllers 和 Razor Pages 這樣的框架在它們的日誌資訊中報告了在框架內部花費了大量的時間。當 controllers 報告的時間和請求的總的時間有很大不同的時候:

  • 開發者消除了他們應用程式程式碼是問題的根源
  • 通常會懷疑是路由引起的

路由使用了成千上萬的 enpoints 來測試效能。一個典型的應用程式不太可能僅僅因為太大而遇到應能問題。路由效能緩慢最常見的根本原因是由於不好的自定義的中介軟體引起的。

下面的程式碼展示了縮小延遲來源的基本技術:

public void Configure(IApplicationBuilder app, ILogger<Startup> logger)
{
    app.Use(next => async context =>
    {
        var sw = Stopwatch.StartNew();
        await next(context);
        sw.Stop();

        logger.LogInformation("Time 1: {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
    });

    app.UseRouting();

    app.Use(next => async context =>
    {
        var sw = Stopwatch.StartNew();
        await next(context);
        sw.Stop();

        logger.LogInformation("Time 2: {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
    });

    app.UseAuthorization();

    app.Use(next => async context =>
    {
        var sw = Stopwatch.StartNew();
        await next(context);
        sw.Stop();

        logger.LogInformation("Time 3: {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
    });

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Timing test.");
        });
    });
}

對於時間路由:

  • 在上面的程式碼中,時間中介軟體交錯在每一箇中間件中間
  • 在程式碼中新增一個唯一的標識關聯時間資料

這是一種最基本的縮小延遲的方法,當延遲很嚴重的時候,例如超過 10ms。Time 2 減去 Time1 就是 UseRouting 中介軟體花費的時間。

相比前面的程式碼,下面的程式碼使用了一個更加緊湊的方法:

public sealed class MyStopwatch : IDisposable
{
    ILogger<Startup> _logger;
    string _message;
    Stopwatch _sw;

    public MyStopwatch(ILogger<Startup> logger, string message)
    {
        _logger = logger;
        _message = message;
        _sw = Stopwatch.StartNew();
    }

    private bool disposed = false;


    public void Dispose()
    {
        if (!disposed)
        {
            _logger.LogInformation("{Message }: {ElapsedMilliseconds}ms",
                                    _message, _sw.ElapsedMilliseconds);

            disposed = true;
        }
    }
}
public void Configure(IApplicationBuilder app, ILogger<Startup> logger)
{
    int count = 0;
    app.Use(next => async context =>
    {
        using (new MyStopwatch(logger, $"Time {++count}"))
        {
            await next(context);
        }

    });

    app.UseRouting();

    app.Use(next => async context =>
    {
        using (new MyStopwatch(logger, $"Time {++count}"))
        {
            await next(context);
        }
    });

    app.UseAuthorization();

    app.Use(next => async context =>
    {
        using (new MyStopwatch(logger, $"Time {++count}"))
        {
            await next(context);
        }
    });

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Timing test.");
        });
    });
}

潛在的昂貴的路由特性

下面列表展示了相比基本路由模板開銷更多的路由特性分析:

  • 正則表示式:可能會編寫複雜的正則表示式,或者較少的輸入就會導致長時間的執行
  • 複雜分段({x}-{y}-{z}):
    要比解析一個正則表示式 URL 路徑分段更加複雜
    導致更多的子字串的開銷
    ASP.NET Core 3.0 中複雜分段邏輯並沒有更新,路由的效能也沒有更新
  • 非同步資料獲取:許多複雜的應用程式在它們的路由中都會訪問資料庫。ASP.NET Core 2.2 以及之前的版本路由沒有提供路由訪問資料庫的功能。例如,IRouteConstraint,IActionConstraint 是同步的。擴充套件的 MatcherPolicy 和 EndpointSelectorContext 是非同步的。

庫作者指南

這部分包含了建立在路由之上的庫編寫者指南。這些細節目的是為了保證應用程式的開發者在使用庫和框架擴充套件路由的時候能有一個好的體驗。

定義 endpoints

建立一個使用路由實現 URL 匹配的框架,開始需要定義一個建立在 UesEnpoints 之上的使用者體驗。

保證 在IEndpointRouteBuilder之上開始建立。這執行使用者把你的框架和其它 ASP.NET Core 特性很好的構造在一起。每一個 ASP.NET Core 模板都包含路由。假設路由已經存在並且對使用者來說很熟悉。

app.UseEndpoints(endpoints =>
{
    // Your framework
    endpoints.MapMyFramework(...);

    endpoints.MapHealthChecks("/healthz");
});

保證 呼叫 MapMyFramework(...) 返回一個具體的實現了IEndpointConventionBuilder的型別。大多數的框架 Map... 方法遵循這個模型。IEndpointConventionBuilder 介面;

  • 允許組合 metadata
  • 以各種擴充套件方法為目標

宣告你自己的型別允許你新增你自己框架特有的功能到 builder 中。封裝一個框架宣告的 builder 然後去呼叫它是可行的。

app.UseEndpoints(endpoints =>
{
    // Your framework
    endpoints.MapMyFramework(...).RequireAuthorization()
                                 .WithMyFrameworkFeature(awesome: true);

    endpoints.MapHealthChecks("/healthz");
});

考慮編寫你自己的EndpointDataSource. EndpointDataSource 用來宣告和更新 endpoints 集合的低階原語。EndpointDataSource 是一個功能強大的 API,被 controllers 和 Razor Pages 使用。

路由測試包含一個不更新 data source 的基本示例。

不要試圖預設註冊一個 EndpointDataSource。要求使用者在 UseEndpoint 中註冊你的框架。路由的哲學就是預設什麼都不包含, UseEndpoints 就是註冊 endpoints 的地方。

建立一個路由整合的中介軟體

考慮定義 metadata 型別作為一個介面

保證 能夠在類和方法上面使用 metadata 型別。

public interface ICoolMetadata
{
    bool IsCool { get; }
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class CoolMetadataAttribute : Attribute, ICoolMetadata
{
    public bool IsCool => true;
}

像 controllers 和 Razor Pages 這樣的框架支援應用 metadata 屬性到型別和方法。如果你聲明瞭 metadata 型別:

  • 使得它們可以作為attributes被獲取
  • 大多數使用者熟悉應用屬性

宣告 metadata 型別為一個介面增加了另外一層靈活性:

  • 介面是可組合的
  • 開發者可以宣告結合多個策略的型別

保證 metadata 能夠被重寫,就像下面展示的例子一樣:

public interface ICoolMetadata
{
    bool IsCool { get; }
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class CoolMetadataAttribute : Attribute, ICoolMetadata
{
    public bool IsCool => true;
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class SuppressCoolMetadataAttribute : Attribute, ICoolMetadata
{
    public bool IsCool => false;
}

[CoolMetadata]
public class MyController : Controller
{
    public void MyCool() { }

    [SuppressCoolMetadata]
    public void Uncool() { }
}

遵守這些指南的最好的方式是避免定義 maker metadata:

  • 不要只尋找 metadata 型別的存在
  • 定義一個 metadata 的屬性並且要檢查這個屬性

metadata 集合是根據優先順序排序和支援重寫的。在控制器的情況下,action 上的 metadata 是最確定的。

保證 不論有沒有路由中介軟體都應該有用。

app.UseRouting();

app.UseAuthorization(new AuthorizationPolicy() { ... });

app.UseEndpoints(endpoints =>
{
    // Your framework
    endpoints.MapMyFramework(...).RequireAuthorization();
});

作為這個指南的一個例子,考慮使用 UseAuthorization 中介軟體。authorization 中介軟體允許你傳遞一個反饋策略。如果反饋策略被指定了,將會應用到:

  • 沒有指定策略的 endpoints
  • 不需要匹配 endpoint 的請求

這使得 authorization 中介軟體在路由上下文之外也有作用。authorization 中介軟體可以被用做傳統中介軟體程式設計。

除錯診斷

對於更詳細的路由除錯輸出,設定 Logging:LogLevel:Microsoft 為 Debug。在開發環境中,在 appsettings.Development.json 中設定日誌等級:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Debug",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  }
}