ASP.NET Core 網站若同時使用 Attribute Routing 與 Conventional Routing,一條看似方便的 {culture}/{brand} 雙區段樣板可能把 /en/About 認成品牌頁。最棘手之處不是發生例外或回傳 404,而是請求成功回傳 200,卻執行了錯誤的 Controller 與 View。這篇文章把問題縮小為可重現的端點選擇案例,說明如何用路由約束、端點日誌、內容標記與路徑矩陣建立可驗收的修正。

直接答案:為什麼無約束 Attribute Route 會吃掉其他頁面

當 {culture}/{brand} 沒有限定可接受值時,任何兩段 URL 都可能成為候選;若它在端點選擇中勝出,/en/About 就會執行品牌 Action。正確修法是限制語系與品牌值,並用多路徑測試確認一般 Controller 路由仍可到達。

這不是單純的「Attribute Route 永遠依註冊順序優先」問題。ASP.NET Core 端點路由會先比對樣板,再套用約束與政策,最後依路由優先順序與其他中繼資料選出端點。實務上應避免把修正建立在偶然的註冊順序上;應讓每條路由只描述自己真正擁有的 URL 空間。字面常數比參數更具體,受約束參數比無約束參數更具體,而萬用參數最寬鬆。當應用程式混用 Attribute Routing 與 Conventional Routing 時,無約束雙參數樣板的可匹配範圍特別危險。

假設 HomeController 宣告 [HttpGet("{culture}/{brand}")],而一般頁面由 {culture}/{controller}/{action=Index} 處理。/en/About 對前者可被解讀成 culture=en、brand=About,對後者則是 culture=en、controller=About。若品牌端點被選中,瀏覽器看到的仍是正常成功回應,因此監控只看狀態碼不會示警。這類問題的本質是 URL 所有權重疊,而不是 Razor 編譯失敗。

從錯誤畫面反查實際端點

第一個動作不是刪除 bin、obj 或瀏覽器快取,而是確認請求到底執行哪個端點。可以在預期 View 放入唯一但不含環境資訊的 HTML 註解,例如 ``。重新建置後取得頁面 HTML;標記不存在,代表預期 View 根本沒有執行。這個判斷能立即把調查從「內容為何沒更新」轉向「哪個端點處理了請求」。

接著開啟 Microsoft.AspNetCore.Routing 與 Microsoft.AspNetCore.Mvc.Infrastructure 的開發環境資訊日誌。日誌中的 Executing endpoint、Controller 名稱與 Action 名稱,比猜測更可靠。測試資料只使用 en、zh-TW、alpha、beta 與 About 等固定占位值,不連接真實帳號、資料庫或外部服務。若畫面可見的標題或內容與來源檔不一致,也可以取一小段非敏感獨特字串,在本機原始碼中搜尋真正的 View 或 Layout。

調查時要區分三個層次。第一層是「樣板能否匹配」;第二層是「約束是否接受路由值」;第三層才是「哪個候選端點被選中」。只看 MapControllerRoute 的排列順序,無法完整解釋 Attribute Routing 所建立的端點集合。只看 Controller 名稱也不夠,因為參數型樣板不需要真的存在同名 Controller,就可以把該字串當成一般參數值。

用約束縮小 URL 所有權

路由約束應表達可接受的 URL 形狀,而不是拿來做輸入驗證。語系若只允許 en 與 zh-TW,品牌若只允許 alpha 與 beta,就讓端點在候選階段排除其他值。這使 /en/alpha 保留品牌用途,而 /en/About 回到一般 MVC Controller。若品牌清單會頻繁變動,不要把大量商業資料硬塞進正規表示式;應改用具名的自訂 IRouteConstraint、明確前綴如 /brand/{brand},或重整 URL 設計以消除重疊。

[ApiController]
public class HomeController : ControllerBase
{
    [HttpGet("{culture:regex(^(en|zh-TW)$)}/{brand:regex(^(alpha|beta)$)}")]
    public IActionResult Brand(string culture, string brand)
        => Content($"BRAND:{culture}:{brand}");
}

public class AboutController : Controller
{
    public IActionResult Index(string culture)
        => Content($"ABOUT:{culture}");
}

正規表示式必須錨定開始與結束,否則部分字串也可能符合。更重要的是,約束失敗通常意味著「這個端點不是候選者」,而不等於應用程式一定回傳 400。其他端點仍可匹配;若完全沒有端點,常見結果才是 404。輸入格式或商業規則錯誤應在 Model Binding、驗證器或 Action 內回報合適錯誤,不應濫用路由約束把驗證結果偽裝成「找不到路由」。

另一個穩健方案是加入固定前綴,例如 /{culture}/brand/{brand}。固定字面區段讓搜尋意圖、維運判讀與端點優先順序都更清楚,也避免品牌名稱未來剛好等於 Controller 名稱。既有網站若不能改 URL,才採用有限值約束並建立衝突清單。設計評審時應逐條問:這條路由擁有哪一段 URL 空間、哪些值必須排除、未來新增 Controller 是否可能與參數值碰撞。

本機驗證與重現:固定 .NET 9 路由行為

以下驗證已在排程環境以 .NET SDK 10.0.400 建置 net9.0 目標框架,並使用 .NET 9.0.9 執行階段實際執行。無約束版本對 /en/About 回傳 BRAND:en:About,加入約束後回傳 ABOUT:en,而 /en/alpha 在兩個版本都回傳 BRAND:en:alpha。這是功能性端點驗證,不是效能基準。

  1. 建立空白 ASP.NET Core Web 專案,將目標框架固定為 net9.0,加入 Controllers with Views,並同時註冊 MapControllers() 與 {culture}/{controller}/{action=Index} Conventional Route。
  2. 先以 [HttpGet("{culture}/{brand}")] 建置並啟動本機測試站,依序請求 /en/About 與 /en/alpha;通過條件是兩者都顯示 BRAND,證明寬鬆樣板攔截一般頁面。
  3. 把 Attribute Route 改為 [HttpGet("{culture:regex(^(en|zh-TW)$)}/{brand:regex(^(alpha|beta)$)}")],重新建置並啟動獨立測試站。
  4. 再請求 /en/About 與 /en/alpha;通過條件是前者顯示 ABOUT:en、後者顯示 BRAND:en:alpha。任何非預期端點、建置錯誤或非成功狀態都判定失敗。
dotnet --version
dotnet build --configuration Release
dotnet run --no-build --configuration Release --urls http://localhost:5181
curl --fail --silent http://localhost:5181/en/About
curl --fail --silent http://localhost:5181/en/alpha

測試必須在本機測試專案或隔離容器執行,連接埠只綁定 loopback,不載入正式設定,不使用真實網域,也不呼叫外部 API。若自動化測試框架可用,應以整合測試直接啟動 WebApplicationFactory,對回應內容與狀態碼做斷言;命令列重現的價值在於最小化相依與快速確認端點選擇,不應取代持續整合中的回歸測試。

建立路徑矩陣,而不是只測一頁

修正 /en/About 後,仍需證明其他路徑沒有退化。最小矩陣應包含網站根路徑、每種支援語系、一般 Controller、帶 Action 的三級路徑、合法品牌、未知品牌與不存在頁面。每筆案例記錄預期端點、預期狀態碼與最小內容標記。不要只比較 HTML 長度,因為兩個錯誤頁面可能長度相近;標題與穩定標記更能表達端點身分。

建議在測試中列舉資料,而不是複製多個近似測試方法。每個案例至少斷言三件事:HTTP 狀態符合契約、回應中含有預期標記、回應中不含另一端點的標記。若網站有多語系 fallback,也要明確定義不支援語系是 404、重新導向,或套用預設語系;不能讓結果取決於某個過寬路由剛好接住請求。

端點清單也值得納入診斷。開發或測試環境可以讀取 EndpointDataSource,輸出 Route Pattern、Display Name 與 Order,再與核准快照比較。這不代表每次框架更新都要盲目接受快照,而是讓新增的 {x}/{y} 或 {*path} 寬鬆樣板在程式碼審查前就被看見。輸出不得包含真實主機、使用者資料或設定值,只需保留端點中繼資料。

安全、相容性與效能界線

路由約束不是授權。即使 URL 只接受有限品牌名稱,Action 仍必須依應用程式政策做身分驗證與授權;不要因「其他值路由不到」就假設資源受到保護。本文範例刻意不處理登入資料、外部授權流程、付款、交易或任何真實帳號。錯誤頁面與日誌也不應回顯完整請求內容,以免把敏感輸入寫入可長期保存的紀錄。

相容性方面,本文以 ASP.NET Core 9 的官方文件與 net9.0 測試專案為查核基準。端點路由概念適用於近期 ASP.NET Core,但升級框架時仍應重新建置並跑完整矩陣,不要把某次觀察當成所有版本永遠不變的保證。尤其自訂 Matcher Policy、第三方動態路由或 Razor Pages 與 Controllers 混用時,候選端點集合可能更複雜。

效能方面,不應宣稱加入正規表示式就一定變快或變慢。本文沒有執行 benchmark,也不提供虛構延遲數字。約束的主要價值是正確性與可維護性。若路由表非常大且請求量足以影響決策,應以固定硬體、固定資料集、暖機策略與可重複工具另外量測;在沒有數據前,不把微觀路由成本包裝成效能成果。

發布前檢查清單

  • 寬鬆的雙參數與 catch-all 樣板都有清楚的 URL 所有權、固定前綴或有限值約束。
  • 路由約束只負責候選篩選;輸入驗證、授權與商業規則由正確層次處理。
  • 每個重要頁面都有狀態碼、預期標記與排除標記,而不只是「畫面看起來正常」。
  • 路徑矩陣同時涵蓋合法品牌、一般 Controller、未知值、每種語系與不存在頁面。
  • 測試固定目標框架與前置條件;升級 ASP.NET Core 後重新執行,不沿用舊結論。
  • 日誌只保留端點身分與必要診斷,不記錄敏感內容,也不連接任何真實環境。

結論很直接:當兩條路由能解釋同一個 URL,成功狀態碼不代表頁面正確。先用標記與端點日誌確認實際執行者,再以約束或固定前綴消除 URL 所有權重疊,最後用多路徑矩陣驗收。這套方法比反覆清快取更快,也能把一次偶發的錯頁轉成持續可執行的路由契約。