Go 部落格

Go 1.22 的路由增強功能

Jonathan Amsterdam,代表 Go 團隊
2024 年 2 月 13 日

Go 1.22 為 net/http 套件的路由器帶來兩項增強功能:方法比對和萬用字元。這些功能讓你可以用樣式表達常見的路由,而不是用 Go 程式碼。雖然說明和使用它們都很簡單,但當有多個符合要求的樣式時,要找出成功的樣式,這其實是一個挑戰。

我們對 Go 的持續努力就是讓它成為用來建置生產系統的出色語言。我們研究了多個第三方網頁架構,萃取出我們認為是用最多的功能,並整合到 net/http 中。然後,我們與社群在 GitHub 討論串建議議題 中合作,驗證我們的選擇並改善我們的設計。將這些功能新增到標準函式庫中表示許多專案現在可以減少一個依賴項了。但第三方網頁架構仍然是目前使用者或有進階路由需求的程式的好選擇。

增強功能

新的路由功能幾乎只會影響傳遞至兩個 net/http.ServeMux 方法 HandleHandleFunc 以及對應最高層級的函式 http.Handlehttp.HandleFunc 的樣式字串。唯一的 API 變更在 net/http.Request 中新增兩個用於處理萬用字元相符的方法。

我們將使用一個假設的網誌伺服器說明這些變更,而每個文章都有唯一整數識別碼。如 GET /posts/234 那樣的請求會擷取 ID 為 234 的文章。在 Go 1.22 之前,處理這些請求的第一行程式碼會如下所示

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

尾隨斜線會將所有開頭為 /posts/ 的請求路由到 handlePost 函式,該函式必須檢查 HTTP 方法是否為 GET,擷取識別碼並擷取文章。因為方法檢查對滿足請求並非絕對必要,理論上很自然地會遺漏。這表示像 DELETE /posts/234 這樣的請求會擷取文章,這至少會令人驚訝。

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

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

此範本會比對開頭為「/posts/」且具有兩個片段的 GET 請求。(特殊範例,GET 也會比對 HEAD;所有其他方法完全比對。) handlePost2 函式不再需要檢查方法,並可以透過 Request 上的新 PathValue 方法來寫入來擷取識別碼字串

idString := req.PathValue("id")

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

如果未註冊其他比對範本,類似 DELETE /posts/234 的請求將會失敗。根據 HTTP 語意net/http 伺服器會回應此請求,並在 Allow 標頭中列出可用方法,並顯示 405 Method Not Allowed 錯誤。

萬用字元可以比對整個片段,例如上述範例中的 {id},或者如果以 ... 結尾,它也可以比對路徑的所有剩餘片段,例如 /files/{pathname...} 範本。

還有最後一個語法。如上所示,以斜線結尾的範本,例如 /posts/,會比對所有以該字串開頭的路徑。若只要比對包含尾隨斜線的路徑,你可以寫成 /posts/{$}。這樣會比對到 /posts/,但不會比對 /posts/posts/234

最後一個 API 為:net/http.Request 有一個 SetPathValue 方法,因此標準程式庫外的路由器可以透過 Request.PathValue 提供其路徑剖析的結果。

優先順序

每個 HTTP 路由器都必須處理重複的範本,例如 /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/latests 應優先於 posts/{id},且 /users/{u}/posts/latest 應優先於 /users/{u}/posts/{id}。這對於方法來說也合理。例如,GET /posts/{id} 優先於 /posts/{id},因為前者只匹配 GET 和 HEAD 請求,而後者匹配任何方法的請求。

「最具體獲勝」的規則概化了對於原始樣式的路徑部分所用的「最長者獲勝」的規則,這些部分沒有通配符或 {$}。此類樣式只有在其中一個是另一個樣式的字首時才會重疊,且較長者較具體。

如果兩個樣式重疊但都不是更具體的樣式呢?例如,/posts/{id}/{resource}/latest 都匹配 /posts/latest。這兩個樣式哪一個具有優先性顯而易見,因此我們認為這些樣式相互衝突。註冊兩個樣式(不論順序為何!)都會造成恐慌。

優先順序規則對於方法和路徑與上述完全相同,但我們必須為保存相容性而對主機設定一個例外:如果兩個樣式在其他方面會產生衝突,且一個樣式具有主機而另一個樣式沒有,那麼具有主機的樣式將具有優先順序。

電腦科學的學生可能會想起漂亮的正則表示式與正則語言概念。每種正則表示式皆選出正則語言,由表示式匹配的字串組合而成。透過談論語言而非表示式,某些問題較容易提出且回答。我們的優先權則受其啟發。的確,每種路由模式都對應到一種正則表示式,而配對要求集扮演正則語言的角色。

定義優先權時使用語言而非表示式,使得陳述和理解很方便。但有一個基於潛在無限集的規則的缺點:不清楚如何有效實作它。我們發現,我們可以透過逐步檢視它們的方式,來確定兩種模式是否有衝突。粗略來說,如果一種模式在另一種模式有萬用字元時有一個實際區段,則該模式會更具體;但在兩種方向,實際區段若與萬用字元對齊,則模式發生衝突。

隨著新的模式在 ServeMux 中註冊,它會檢查是否有與先前註冊的模式發生衝突。但檢查每對模式會花費二次方的時間。我們使用索引來略過無法與新模式發生衝突的模式;在實際中,它運作得很好。無論如何,此檢查在模式註冊時發生,通常在伺服器啟動時進行。在 Go 1.22 中配對傳入要求的時間與前一版本相差不大。

相容性

我們盡全力讓新功能與較舊版本的 Go 相容。新的模式語法是舊語法的上集合,而且新的優先權則對舊規則進行概括。但有幾個邊界情況。例如,先前版本的 Go 接受使用大括號的模式,並且視為實際區段處理,但 Go 1.22 將大括號用於萬用字元。GODEBUG 設定 httpmuxgo121 還原舊行為。

如需瞭解這些路由增強功能的更多詳細資料,請參閱 net/http.ServeMux 文件

下一篇: 切片上的強大通用函數
上一篇: Go 1.22 發布!
部落格索引