Go 1.22 對路由的改善

原文
[name=Roy Thomas Fielding, on behalf of the Go team] 13 February 2024

Go 1.22 為 net/http package 的路由器帶來了兩個改善:方法匹配 (method matching) 以及萬用字元 (wildcards)。這些特性能夠讓你將常見的路由表達成模式 (patterns) 而不是用 Go 語言撰寫的程式碼。雖然它們易於使用及解釋,但當一個請求匹配多個模式時,找到正確的規則去選擇最終的 (winning) 模式是一個挑戰。

這些改變是我們持續努力讓 Go 成為建立產品等級系統 (production systems) 的一種良好語言的一部分。我們研究了很多第三方的網站框架,提取我們認為最常使用到的功能,並將它們整合進 net/http。然後我們在 GitHub discussionproposal issue 中與社群協作,驗證我們的選擇並改進我們的設計。將這些功能加入標準函式庫 (standard library) 代表有許多專案可以少一個依賴 (dependency)。但對於目前的使用者或有進階路由需求的程式來說,第三方網站框架仍是一個不錯的選擇。

改善

新的路由特性幾乎只影響到了要傳進 net/http.ServeMux 的兩個方法 HandleHandleFunc 的模式字串 (pattern string),以及對應的 頂層函式 http.Handlehttp.HandleFunc。唯一對 API 的變更是在 net/http.Request 上新增兩個處理萬用字元匹配的新方法。

我們將透過一個假想的部落格伺服器來說明這些變更,其中每篇文章都有一個整數識別碼。像 GET /posts/234 這樣的請求會找出 ID 為 234 的文章。在 Go 1.22 版本之前,處理這些請求的程式碼會以以下這樣的一行開始:

1
http.HandleFunc("/posts/", handlePost)

後方的 / 將所有以 /posts/ 開頭的請求都路由到 handlePost 函式,該函式必須檢查 HTTP 方法是否為 GET,提取出識別碼,然後找出文章。因方法檢查不是滿足請求的嚴格必要條件,遺漏它是一個容易犯的錯誤。這意味著像是 DELETE /posts/234 的請求也會去取得文章,這是令人意外的行為。

在 Go 1.22 中,現有的程式碼會繼續運作,或者你可以改為寫成這樣:

1
http.HandleFunc("GET /posts/{id}", handlePost2)

這個模式匹配到一個 GET 請求,其路徑從 /posts/ 開始且有兩段。(作為一個特殊案例,GET 也會匹配到 HEAD;其他所有的方法都需要完全匹配)。handlePost2 函數不再需要檢查方法,而且可以透過 net/http.Request 中的新 PathValue 方法提取識別碼字串:

1
idString := req.PathValue("id")

handlePost2 的其餘部分的行為會像 handlePost 一樣,將字串識別碼轉換為整數並取得文章。

如果沒有註冊其他匹配模式的話,像是 DELETE /posts/234 將會失敗。根據 HTTP semantics,一個 net/http 伺服器會對這樣的請求回應一個 405 Method Not Allowed 的錯誤,並在 Allow 這個標頭 (header) 中列出可使用的方法。

萬用字元可以匹配整個段落,像上方例子中的 {id},或者如果它以 ... 結尾,它可以匹配到路徑所有剩餘的段落,就像模式 /files/{pathname...}

最後一點語法。正如我們上面展示的,以 / 結尾的模式,如 /posts/,會匹配所有以此字串開頭的路徑。要匹配只有 / 結尾的路徑,你可以寫成 /posts/{$}。這將匹配 /posts/ 但不匹配 /posts/posts/234

還有最後一點 API: net/http.Request 有一個 SetPathValue 方法,這樣標準函式庫外的路由器可以透過 Request.PathValue 使他們自己的路徑解析結果是可使用的。

優先級

每個 HTTP 路由器都必須處理重疊 (overlap) 的模式,例如 /posts/{id}/posts/latest。這兩個模式都能匹配路徑 posts/latest,但只能有一個可以處理這個請求。哪一個模式有優先權?

一些路由器不允許重疊;其他的會使用最後註冊的模式。Go 一直都允許重疊,並都會選擇較長的模式無論註冊順序。保持順序獨立性對我們來說很重要 (且對於向後兼容是必須的),但我們需要一個比”最長就贏”更好的規則。此規則會選擇 /posts/latest 而不是 /posts/{id},但會優先選擇 /posts/{identifier} 而不選擇這兩者。這看起來不太對:萬用字元的名稱不應該有影響。感覺 /posts/latest 應該總是在這場競爭中勝出,因為它只匹配到一個路徑,而不是多個。

我們探索一個好的優先級規則的過程,讓我們考慮到模式的許多特性。例如,我們考慮過優先選擇具有最長實際 (非萬用字元) 前綴的模式。這樣會選擇 /posts/latest 而不是 /posts/{id}。但這樣無法區分 /users/{u}/posts/latest/users/{u}/posts/{id},而前者似乎要具有優先權。我們最終選擇一條基於模式意義而不是外觀的規則。每個有效的模式都匹配一組請求。例如,/posts/latest 匹配路徑為 /posts/latest 的請求,而 /posts/{id} 匹配第一段為 posts 的任何有兩個段落的請求。如果一個模式匹配的請求是另一個模式匹配請求的嚴格子集,我們認為這個模式比另一個模式更精確。模式 /posts/latest/posts/{id} 更精確,因為後者匹配的請求包含前者的匹配還有更多。

優先權規則很簡單:最精確的模式勝出。這個規則符合我們的直覺,認為 posts/latest 應該被優先於 posts/{id},而 /users/{u}/posts/latest 應該被優先於 /users/{u}/posts/{id}。這對於方法也是有道理的。例如,GET /posts/{id} 有高於 /posts/{id} 的優先權,因為前者僅匹配 GETHEAD 請求,而後者則匹配任何方法的請求。

“最精確獲勝”的規則是將原始”最長獲勝”的規則概括 (generalize),用於原始模式的路徑部分,即沒有萬用字元或 {$} 的部分。這樣的模式只有在一個是另一個的前綴時才會重疊,而較長的模式則是更精確的。

那如果兩個模式重疊但都沒有更精確呢?例如,/posts/{id}/{resource}/latest 都能匹配 /posts/latest。沒有明顯的答案指出哪個具優先權,所以我們認為這些模式彼此衝突。無論以何種順序註冊它們都會引發 panic

優先級規則對於方法和路徑的工作方式完全如上所述,但我們不得不為了保持兼容性對主機 (hosts) 做出一個例外:如果兩個模式在其他情況下會衝突,而其中一個有主機而另一個沒有,那麼帶有主機的模式將擁有優先權。

電腦科學的學生可能會回想起關於正規表達式 (regular expressions) 和正規語言 (regular languages) 的精彩理論。每個正規表達式都選定一個正規語言,即由該表達式匹配的字串集合。有些問題談論語言而非表達式的話,提出和回答會更容易。我們的優先權規則就是受到這個理論的啟發。實際上,每個路由模式都對應一個正規表達式,而匹配的請求集合扮演著正規語言的角色。

通過語言而非表達式來定義優先權,使其易於陳述和理解。但基於可能有無限集合的規則有一個缺點:如何有效實現它並不明確。我們可以通過檢查逐個段落來確定兩個模式是否衝突。粗略來說,如果一個模式在另一個模式有萬用字元的位置上有一個字面 (literal) 段落,則它更具體;但如果字面與萬用字元在兩個方向上都對齊,則這些模式衝突。

當新模式註冊到 ServeMux 時,它會檢查與先前註冊的模式是否存在衝突。但檢查每一對模式將會耗費二次方的時間。我們使用一個索引來跳過與新模式無法衝突的模式;在實踐中,這效果相當好。無論如何,這種檢查是在模式註冊時發生的,通常是在服務器啟動時。在 Go 1.22 中匹配進來的請求花費的時間與之前的版本相比並沒有太大變化。

兼容性

我們盡力保持新功能與舊版本的 Go 相容。新的模式語法是舊語法的超集,而新的優先級規則也是對舊規則的概括。但有一些邊緣案例 (edge cases)。例如,之前版本的 Go 接受帶有大括號的模式並將其作為字面處理,但 Go 1.22 使用大括號表示萬用字元。透過把 GODEBUG 設定成 httpmuxgo121 就能恢復舊的行為。

更多關於這些路由改善功能的細節,可以查看 net/http.ServeMux文件