Go 中不同的 HTTP 路由作法
2024 年 3 月更新:Go 1.22 為標準函式庫的 重大改進帶來了對
http.ServeMux路由器的重大改進。你現在可以用包含 HTTP 方法與路徑變數的樣式來指定,例如mux.Handle("GET /{slug}/admin"),我也已經更新了程式碼。如果是新的專案,我會建議你直接忽略這篇文章,改用標準函式庫!
在 Go 中做 HTTP 路徑路由的方法有很多——不管是好是壞。標準函式庫有 http.ServeMux,但它只支援基本的的前綴比對。也有很多自己實作更進階路由的方法,包括 Axel Wagner 很有趣的ShiftPath 技巧。當然,還有大量的第三方路由函式庫。在這篇文章中,我會比較幾種自製的技巧以及一些現成的套件。
我先坦白自己的偏好:我喜歡簡單明瞭的程式碼,而且對龐大的依賴有點過敏(而這兩者有時是互相衝突的)。大多數名稱裡有「框架」的函式庫都不太合我的胃口,不過對於維護良好、只專注做好一兩件事的函式庫,我並不排斥使用。
我在這裡的目標是用八種不同的方法來處理同樣的 11 個 URL。這些 URL 來自於我維護的一個網頁應用程式中的一部分。它們使用了 GET 和 POST,但並不是特別符合 RESTful 或設計良好——就是那種你在真實世界系統中會看到的混亂樣貌。以下是這些方法與 URL:
GET / # home
GET /contact # contact
GET /api/widgets # apiGetWidgets
POST /api/widgets # apiCreateWidget
POST /api/widgets/:slug # apiUpdateWidget
POST /api/widgets/:slug/parts # apiCreateWidgetPart
POST /api/widgets/:slug/parts/:id/update # apiUpdateWidgetPart
POST /api/widgets/:slug/parts/:id/delete # apiDeleteWidgetPart
GET /:slug # widget
GET /:slug/admin # widgetAdmin
POST /:slug/image # widgetImage:slug 是一個對 URL 友善的 widget 識別字串,像是 foo-bar,而 :id 則是像 1234 這樣的正整數。每種路由作法都應該精確比對 URL——尾端斜線會回傳 404 Not Found(重新導向也是合理的作法,但在這裡我不這麼做)。每種路由器都應該處理指定的方法(GET 或 POST)並以 405 Method Not Allowed 回應其他方法。我寫了一些表格驅動測試來確保所有路由器都能正確運作。
在本文的其餘部分,我會展示各種作法的程式碼,並討論各自的優缺點(所有程式碼都在 benhoyt/go-routing 儲存庫中)。程式碼有點多,但都相當直觀,應該很容易快速瀏覽。你可以使用以下連結直接跳到特定的技巧。首先是五種自製技巧:
- 正則表格:遍歷預先編譯好的正則表達式,並透過 request context 傳遞比對結果
- 正則 switch:使用 switch 陳述式,其中的 case 會呼叫以正則為基礎的
match()輔助函式,將路徑參數掃描到變數中 - 樣式比對器:與上述類似,但改用簡單的樣式比對函式而非正則表達式
- 分割 switch:用
/分割路徑,然後根據路徑片段的內容進行 switch - ShiftPath:Axel Wagner 的階層式
ShiftPath技巧
以及三種使用第三方路由套件的版本:
我也試過 httprouter,據說它非常快,但它無法處理像 /contact 和 /:slug 這樣前綴重疊的 URL。嚴格來說這算是糟糕的 URL 設計,但很多真實世界的網頁應用程式就是這樣做,所以我覺得這限制相當大。
還有許多其他的第三方路由套件或「網頁框架」,但這三個在我的搜尋中脫穎而出(而且我相信它們相當具有代表性)。
在這次比較中,我並不在意速度。大多數作法都是透過迴圈或 switch 遍歷路由清單(相對於花俏的 trie 查詢結構)。所有這些作法都只會為請求時間增加幾 微秒(見效能測試),這在我經手過的任何網頁應用程式中都不是問題。
正則表格
我想先看的第一種作法,就是我目前在網頁應用程式中所使用的方法——這是我幾年前學 Go 時最先想到的做法,而我至今仍覺得它是個相當不錯的方法。
它基本上就是一張由預先編譯好的 regexp 物件組成的表格,搭配一個只有 21 行的路由函式去遍歷它們,並呼叫第一個同時符合路徑與 HTTP 方法的路由。以下是這些路由與 Serve() 路由函式:
var routes = []route{
newRoute("GET", "/", home),
newRoute("GET", "/contact", contact),
newRoute("GET", "/api/widgets", apiGetWidgets),
newRoute("POST", "/api/widgets", apiCreateWidget),
newRoute("POST", "/api/widgets/([^/]+)", apiUpdateWidget),
newRoute("POST", "/api/widgets/([^/]+)/parts", apiCreateWidgetPart),
newRoute("POST", "/api/widgets/([^/]+)/parts/([0-9]+)/update", apiUpdateWidgetPart),
newRoute("POST", "/api/widgets/([^/]+)/parts/([0-9]+)/delete", apiDeleteWidgetPart),
newRoute("GET", "/([^/]+)", widget),
newRoute("GET", "/([^/]+)/admin", widgetAdmin),
newRoute("POST", "/([^/]+)/image", widgetImage),
}
func newRoute(method, pattern string, handler http.HandlerFunc) route {
return route{method, regexp.MustCompile("^" + pattern + "$"), handler}
}
type route struct {
method string
regex *regexp.Regexp
handler http.HandlerFunc
}
func Serve(w http.ResponseWriter, r *http.Request) {
var allow []string
for _, route := range routes {
matches := route.regex.FindStringSubmatch(r.URL.Path)
if len(matches) > 0 {
if r.Method != route.method {
allow = append(allow, route.method)
continue
}
ctx := context.WithValue(r.Context(), ctxKey{}, matches[1:])
route.handler(w, r.WithContext(ctx))
return
}
}
if len(allow) > 0 {
w.Header().Set("Allow", strings.Join(allow, ", "))
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return
}
http.NotFound(w, r)
}路徑參數是透過將 matches 切片加入到 request context 中來處理,讓 handler 可以從中取出。我定義了一個自訂的 context key 型別,以及一個在 handler 內部使用的 getField 輔助函式:
type ctxKey struct{}
func getField(r *http.Request, index int) string {
fields := r.Context().Value(ctxKey{}).([]string)
return fields[index]
}一個帶有路徑參數的典型 handler 看起來像這樣:
// Handles POST /api/widgets/([^/]+)/parts/([0-9]+)/update
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
slug := getField(r, 0)
id, _ := strconv.Atoi(getField(r, 1))
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}我沒有檢查 Atoi() 回傳的錯誤,因為 ID 參數的正則表達式只會比對數字:[0-9]+。當然,這仍然不保證物件在資料庫中真的存在——這部分還是需要在 handler 中處理。(如果數字太大,Atoi 會回傳錯誤,但在那種情況下 id 會是 0,資料庫查詢也會失敗,所以不需要額外的檢查。)
除了用 context 傳遞欄位,另一個選擇是讓每個 route.handler 成為一個接收 []string 欄位並回傳一個閉包 http.HandleFunc 的函式,該閉包會捕捉 fields 參數。接著 Serve 函式就會像這樣具現化並呼叫該閉包:
handler := route.handler(matches[1:])
handler(w, r)然後每個 handler 就會長這樣:
func apiUpdateWidgetPart(fields []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := fields[0]
id, _ := strconv.Atoi(fields[1])
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}
}我稍微偏好 context 的作法,因為它讓 handler 的簽名保持簡單的 http.HandlerFunc,而且也避免了為每個 handler 定義巢狀函式。
正則表格作法沒什麼特別巧妙的地方,也跟不少第三方套件的運作方式類似。但它非常簡單,只需要幾行程式碼、幾分鐘就能寫完。如果有需要,也很容易修改:例如加上記錄、把錯誤回應改成 JSON 等等。
正則 switch
第二種作法仍然使用正則表達式,但搭配一個簡單的指令式 switch 陳述式和一個 match() 輔助函式來處理比對。這種作法的好處是,你可以在每個 case 中呼叫其他函式或測試其他條件。此外,match 函式的簽名讓你可以將路徑參數「掃描」到變數中,以便更直接地傳給 handler。以下是這些路由與 match() 函式:
func Serve(w http.ResponseWriter, r *http.Request) {
var h http.Handler
var slug string
var id int
p := r.URL.Path
switch {
case match(p, "/"):
h = get(home)
case match(p, "/contact"):
h = get(contact)
case match(p, "/api/widgets") && r.Method == "GET":
h = get(apiGetWidgets)
case match(p, "/api/widgets"):
h = post(apiCreateWidget)
case match(p, "/api/widgets/([^/]+)", &slug):
h = post(apiWidget{slug}.update)
case match(p, "/api/widgets/([^/]+)/parts", &slug):
h = post(apiWidget{slug}.createPart)
case match(p, "/api/widgets/([^/]+)/parts/([0-9]+)/update", &slug, &id):
h = post(apiWidgetPart{slug, id}.update)
case match(p, "/api/widgets/([^/]+)/parts/([0-9]+)/delete", &slug, &id):
h = post(apiWidgetPart{slug, id}.delete)
case match(p, "/([^/]+)", &slug):
h = get(widget{slug}.widget)
case match(p, "/([^/]+)/admin", &slug):
h = get(widget{slug}.admin)
case match(p, "/([^/]+)/image", &slug):
h = post(widget{slug}.image)
default:
http.NotFound(w, r)
return
}
h.ServeHTTP(w, r)
}
// match reports whether path matches regex ^pattern$, and if it matches,
// assigns any capture groups to the *string or *int vars.
func match(path, pattern string, vars ...interface{}) bool {
regex := mustCompileCached(pattern)
matches := regex.FindStringSubmatch(path)
if len(matches) <= 0 {
return false
}
for i, match := range matches[1:] {
switch p := vars[i].(type) {
case *string:
*p = match
case *int:
n, err := strconv.Atoi(match)
if err != nil {
return false
}
*p = n
default:
panic("vars must be *string or *int")
}
}
return true
}我得承認我相當喜歡這種作法。我喜歡它的簡單直接,也覺得對路徑參數那種類似掃描的行為很俐落。match() 內部的掃描會偵測型別,並在需要時將字串轉成整數。目前它只支援 string 和 int,這對大多數路由來說應該就夠了,但如果你需要,也很容易再加入更多型別。
以下是帶有路徑參數的 handler 範例(為了避免重複,我對所有接收這兩個參數的 handler 都使用了 apiWidgetPart 結構):
type apiWidgetPart struct {
slug string
id int
}
func (h apiWidgetPart) update(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", h.slug, h.id)
}
func (h apiWidgetPart) delete(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "apiDeleteWidgetPart %s %d\n", h.slug, h.id)
}請留意 get() 和 post() 輔助函式,它們本質上是檢查請求方法的簡單中介層,如下所示:
// get takes a HandlerFunc and wraps it to only allow the GET method
func get(h http.HandlerFunc) http.HandlerFunc {
return allowMethod(h, "GET")
}
// post takes a HandlerFunc and wraps it to only allow the POST method
func post(h http.HandlerFunc) http.HandlerFunc {
return allowMethod(h, "POST")
}
// allowMethod takes a HandlerFunc and wraps it in a handler that only
// responds if the request method is the given method, otherwise it
// responds with HTTP 405 Method Not Allowed.
func allowMethod(h http.HandlerFunc, method string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if method != r.Method {
w.Header().Set("Allow", method)
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return
}
h(w, r)
}
}有點尷尬的一點是它如何處理對應多種方法的路徑。可能有不同的做法,但我目前是在第一個路由中明確測試方法——這裡的 get() 包裝嚴格來說不是必要的,但為了保持一致性我還是加上了:
case match(p, "/api/widgets") && r.Method == "GET":
h = get(apiGetWidgets)
case match(p, "/api/widgets"):
h = post(apiCreateWidget)一開始我把 HTTP 方法的比對也放進 match() 輔助函式中,但那會讓正確回傳 405 Method Not Allowed 變得比較困難。
這種作法的另一個面向是惰性編譯正則表達式。我們可以直接呼叫 regexp.MustCompile,但那會在每次請求時都重新編譯每個正則。相反地,我加入了一個具併發安全性的 mustCompileCached 函式,讓正則表達式只在第一次使用時才編譯:
var (
regexen = make(map[string]*regexp.Regexp)
relock sync.Mutex
)
func mustCompileCached(pattern string) *regexp.Regexp {
relock.Lock()
defer relock.Unlock()
regex := regexen[pattern]
if regex == nil {
regex = regexp.MustCompile("^" + pattern + "$")
regexen[pattern] = regex
}
return regex
}整體而言,儘管我喜歡這種作法的清晰度以及類似掃描的 match() 輔助函式,它的缺點是為了快取正則編譯所需的額外雜亂程式碼。
樣式比對器
這種作法類似於正則 switch 方法,但改為使用一個簡單、自訂的樣式比對器,而非正則表達式。
提供給自訂 match() 函式的樣式只處理一種萬用字元 +,它會比對(並捕捉)請求路徑中直到下一個 / 為止的所有字元。這當然比正則比對弱得多,但一般來說,我在路由中從來不需要比「比對到下一個斜線為止」更複雜的功能。以下是路由與比對程式碼的樣子:
func Serve(w http.ResponseWriter, r *http.Request) {
var h http.Handler
var slug string
var id int
p := r.URL.Path
switch {
case match(p, "/"):
h = get(home)
case match(p, "/contact"):
h = get(contact)
case match(p, "/api/widgets") && r.Method == "GET":
h = get(apiGetWidgets)
case match(p, "/api/widgets"):
h = post(apiCreateWidget)
case match(p, "/api/widgets/+", &slug):
h = post(apiWidget{slug}.update)
case match(p, "/api/widgets/+/parts", &slug):
h = post(apiWidget{slug}.createPart)
case match(p, "/api/widgets/+/parts/+/update", &slug, &id):
h = post(apiWidgetPart{slug, id}.update)
case match(p, "/api/widgets/+/parts/+/delete", &slug, &id):
h = post(apiWidgetPart{slug, id}.delete)
case match(p, "/+", &slug):
h = get(widget{slug}.widget)
case match(p, "/+/admin", &slug):
h = get(widget{slug}.admin)
case match(p, "/+/image", &slug):
h = post(widget{slug}.image)
default:
http.NotFound(w, r)
return
}
h.ServeHTTP(w, r)
}
// match reports whether path matches the given pattern, which is a
// path with '+' wildcards wherever you want to use a parameter. Path
// parameters are assigned to the pointers in vars (len(vars) must be
// the number of wildcards), which must be of type *string or *int.
func match(path, pattern string, vars ...interface{}) bool {
for ; pattern != "" && path != ""; pattern = pattern[1:] {
switch pattern[0] {
case '+':
// '+' matches till next slash in path
slash := strings.IndexByte(path, '/')
if slash < 0 {
slash = len(path)
}
segment := path[:slash]
path = path[slash:]
switch p := vars[0].(type) {
case *string:
*p = segment
case *int:
n, err := strconv.Atoi(segment)
if err != nil || n < 0 {
return false
}
*p = n
default:
panic("vars must be *string or *int")
}
vars = vars[1:]
case path[0]:
// non-'+' pattern byte must match path byte
path = path[1:]
default:
return false
}
}
return path == "" && pattern == ""
}除此之外,get() 和 post() 輔助函式以及 handler 本身,都和正則 switch 方法完全相同。我相當喜歡這種作法(而且它很有效率),但逐位元組比對的程式碼寫起來有點繁瑣——絕對沒有呼叫 regex.FindStringSubmatch() 那麼簡單。
更新:Yuri Vishnevsky 在 Gophers Slack 上傳給我一個這個想法的有趣變體。用他的話來說:「我決定把片段內聯起來,讓 match 的參數讀起來就像路徑本身:match("foo", &bar, "baz")。」我蠻喜歡這個的——謝謝 Yuri!
分割 switch
這種作法只是用 / 分割請求路徑,然後用 switch 搭配 case 陳述式去比較路徑片段的數量與每個片段的內容。它很直接、簡單,但也有點容易出錯,有許多寫死的長度與索引。以下是程式碼:
func Serve(w http.ResponseWriter, r *http.Request) {
// Split path into slash-separated parts, for example, path "/foo/bar"
// gives p==["foo", "bar"] and path "/" gives p==[""].
p := strings.Split(r.URL.Path, "/")[1:]
n := len(p)
var h http.Handler
var id int
switch {
case n == 1 && p[0] == "":
h = get(home)
case n == 1 && p[0] == "contact":
h = get(contact)
case n == 2 && p[0] == "api" && p[1] == "widgets" && r.Method == "GET":
h = get(apiGetWidgets)
case n == 2 && p[0] == "api" && p[1] == "widgets":
h = post(apiCreateWidget)
case n == 3 && p[0] == "api" && p[1] == "widgets" && p[2] != "":
h = post(apiWidget{p[2]}.update)
case n == 4 && p[0] == "api" && p[1] == "widgets" && p[2] != "" && p[3] == "parts":
h = post(apiWidget{p[2]}.createPart)
case n == 6 && p[0] == "api" && p[1] == "widgets" && p[2] != "" && p[3] == "parts" && isId(p[4], &id) && p[5] == "update":
h = post(apiWidgetPart{p[2], id}.update)
case n == 6 && p[0] == "api" && p[1] == "widgets" && p[2] != "" && p[3] == "parts" && isId(p[4], &id) && p[5] == "delete":
h = post(apiWidgetPart{p[2], id}.delete)
case n == 1:
h = get(widget{p[0]}.widget)
case n == 2 && p[1] == "admin":
h = get(widget{p[0]}.admin)
case n == 2 && p[1] == "image":
h = post(widget{p[0]}.image)
default:
http.NotFound(w, r)
return
}
h.ServeHTTP(w, r)
}這些 handler 與其他以 switch 為基礎的方法完全相同,get 和 post 輔助函式也是。唯一的輔助函式是 isId,它會檢查 ID 片段是否確實為正整數:
func isId(s string, p *int) bool {
id, err := strconv.Atoi(s)
if err != nil || id <= 0 {
return false
}
*p = id
return true
}所以,雖然我喜歡這種作法最基本的簡潔——只是基本的字串相等比較——但比對的冗長以及容易出錯的整數常數,會讓我在除了非常簡單的路由以外的場景,都會再三考慮是否真的要使用它。
ShiftPath
Axel Wagner 寫了一篇部落格文章《How to not use an http-router in go》,他在文中主張不應該使用路由器(無論是第三方還是其他)。他提出了一種技巧,包含一個小型的 ShiftPath() 輔助函式,會回傳第一個路徑片段,並將 URL 的其餘部分往下移。目前的 handler 會根據第一個路徑片段進行 switch,然後委派給子 handler,由它們對 URL 的剩餘部分做同樣的事。
讓我們來看看 Axel 的技巧在我們這組 URL 的子集中會是什麼樣子:
func serve(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
serveHome(w, r)
case "api":
serveApi(w, r)
case "contact":
serveContact(w, r)
default:
widget{head}.ServeHTTP(w, r)
}
}
// shiftPath splits the given path into the first segment (head) and
// the rest (tail). For example, "/foo/bar/baz" gives "foo", "/bar/baz".
func shiftPath(p string) (head, tail string) {
p = path.Clean("/" + p)
i := strings.Index(p[1:], "/") + 1
if i <= 0 {
return p[1:], "/"
}
return p[1:i], p[i:]
}
// ensureMethod is a helper that reports whether the request's method is
// the given method, writing an Allow header and a 405 Method Not Allowed
// if not. The caller should return from the handler if this returns false.
func ensureMethod(w http.ResponseWriter, r *http.Request, method string) bool {
if method != r.Method {
w.Header().Set("Allow", method)
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return false
}
return true
}
// ...
// Handles /api and below
func serveApi(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "widgets":
serveApiWidgets(w, r)
default:
http.NotFound(w, r)
}
}
// Handles /api/widgets and below
func serveApiWidgets(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
if r.Method == "GET" {
serveApiGetWidgets(w, r)
} else {
serveApiCreateWidget(w, r)
}
default:
apiWidget{head}.ServeHTTP(w, r)
}
}
// Handles GET /api/widgets
func serveApiGetWidgets(w http.ResponseWriter, r *http.Request) {
if !ensureMethod(w, r, "GET") {
return
}
fmt.Fprint(w, "apiGetWidgets\n")
}
// Handles POST /api/widgets
func serveApiCreateWidget(w http.ResponseWriter, r *http.Request) {
if !ensureMethod(w, r, "POST") {
return
}
fmt.Fprint(w, "apiCreateWidget\n")
}
type apiWidget struct {
slug string
}
// Handles /api/widgets/:slug and below
func (h apiWidget) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
h.serveUpdate(w, r)
case "parts":
h.serveParts(w, r)
default:
http.NotFound(w, r)
}
}
func (h apiWidget) serveUpdate(w http.ResponseWriter, r *http.Request) {
if !ensureMethod(w, r, "POST") {
return
}
fmt.Fprintf(w, "apiUpdateWidget %s\n", h.slug)
}
func (h apiWidget) serveParts(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
h.serveCreatePart(w, r)
default:
id, err := strconv.Atoi(head)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
apiWidgetPart{h.slug, id}.ServeHTTP(w, r)
}
}
// ...對於這個路由器,我寫了一個 noTrailingSlash 裝飾器,以確保帶有尾端斜線的 URL 會回傳 Not Found,因為我們的 URL 規格將其定義為無效。ShiftPath 作法無法區分有無尾端斜線的情況,而我也找不到簡單的方法讓它做到這點。我認為用裝飾器來處理是個合理的做法,而不是在每個路由中明確處理——在一個特定的網頁應用程式中,你大概會想要不是允許尾端斜線並重新導向,就是像我在這裡這樣回傳 Not Found。
雖然我喜歡只使用標準函式庫的想法,而且路徑位移的技巧也相當聰明,但我更強烈地偏好把所有 URL 都放在同一個地方——Axel 的作法把邏輯分散到許多 handler 中,所以很難一眼看出哪個 handler 處理什麼。這也需要相當多的程式碼,其中有些還容易出錯。
我確實喜歡(就像 Axel 所說的)「[例如] ProfileHandler 的依賴在編譯時期就很清楚」這一點,雖然這對上面提到的其他幾種技巧來說也是成立的。整體而言,我覺得它太冗長了,而且會讓閱讀程式碼的人很難快速回答「給定這個 HTTP 方法與 URL,會發生什麼事?」這個問題。
在 GitHub 上查看完整的 ShiftPath 程式碼。
Chi
Chi 被稱為「輕量、符合慣例且可組合的路由器」,我認為它名副其實。它使用起來很簡單,程式碼在頁面上看起來也很漂亮。以下是路由定義:
func init() {
r := chi.NewRouter()
r.Get("/", home)
r.Get("/contact", contact)
r.Get("/api/widgets", apiGetWidgets)
r.Post("/api/widgets", apiCreateWidget)
r.Post("/api/widgets/{slug}", apiUpdateWidget)
r.Post("/api/widgets/{slug}/parts", apiCreateWidgetPart)
r.Post("/api/widgets/{slug}/parts/{id:[0-9]+}/update", apiUpdateWidgetPart)
r.Post("/api/widgets/{slug}/parts/{id:[0-9]+}/delete", apiDeleteWidgetPart)
r.Get("/{slug}", widgetGet)
r.Get("/{slug}/admin", widgetAdmin)
r.Post("/{slug}/image", widgetImage)
Serve = r
}而 handler 也很直觀。它們看起來和正則表格作法中的 handler 大同小異,只是自訂的 getField() 函式被換成了 chi.URLParam()。一個小優點是參數可以用名稱而非編號來存取:
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
id, _ := strconv.Atoi(chi.URLParam(r, "id"))
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}和我的正則表格路由器一樣,我忽略了 strconv.Atoi() 的錯誤回傳值,因為路徑參數的正則已經檢查過它是由數字組成的。
如果你要打造一個規模較大的網頁應用程式,Chi 其實看起來相當不錯。主要的 chi 套件只做路由,但這個模組還附帶了一整套可組合的中介層,可以處理像是 HTTP 驗證、記錄、尾端斜線處理等等。
Gorilla
Gorilla 工具組是一系列實作路由、session 處理等功能的套件。我們在這裡要使用的是 gorilla/mux 路由套件。它和 Chi 類似,只是方法比對稍微冗長一些:
func init() {
r := mux.NewRouter()
r.HandleFunc("/", home).Methods("GET")
r.HandleFunc("/contact", contact).Methods("GET")
r.HandleFunc("/api/widgets", apiGetWidgets).Methods("GET")
r.HandleFunc("/api/widgets", apiCreateWidget).Methods("POST")
r.HandleFunc("/api/widgets/{slug}", apiUpdateWidget).Methods("POST")
r.HandleFunc("/api/widgets/{slug}/parts", apiCreateWidgetPart).Methods("POST")
r.HandleFunc("/api/widgets/{slug}/parts/{id:[0-9]+}/update", apiUpdateWidgetPart).Methods("POST")
r.HandleFunc("/api/widgets/{slug}/parts/{id:[0-9]+}/delete", apiDeleteWidgetPart).Methods("POST")
r.HandleFunc("/{slug}", widgetGet).Methods("GET")
r.HandleFunc("/{slug}/admin", widgetAdmin).Methods("GET")
r.HandleFunc("/{slug}/image", widgetImage).Methods("POST")
Serve = r
}同樣地,handler 和 Chi 的很類似,但要取得路徑參數時,你要呼叫 mux.Vars(),它會回傳一個包含所有參數的 map,讓你用名稱來索引(在我看來這有點「設計上就沒有效率」,但也罷)。以下是一個 handler 的程式碼:
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := vars["slug"]
id, _ := strconv.Atoi(vars["id"])
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}Pat
Pat 很有趣——它是一個極簡的單檔路由器,支援方法與路徑參數,但不支援正則比對。路由設定程式碼看起來和 Chi 與 Gorilla 類似:
func init() {
r := pat.New()
r.Get("/", http.HandlerFunc(home))
r.Get("/contact", http.HandlerFunc(contact))
r.Get("/api/widgets", http.HandlerFunc(apiGetWidgets))
r.Post("/api/widgets", http.HandlerFunc(apiCreateWidget))
r.Post("/api/widgets/:slug", http.HandlerFunc(apiUpdateWidget))
r.Post("/api/widgets/:slug/parts", http.HandlerFunc(apiCreateWidgetPart))
r.Post("/api/widgets/:slug/parts/:id/update", http.HandlerFunc(apiUpdateWidgetPart))
r.Post("/api/widgets/:slug/parts/:id/delete", http.HandlerFunc(apiDeleteWidgetPart))
r.Get("/:slug", http.HandlerFunc(widgetGet))
r.Get("/:slug/admin", http.HandlerFunc(widgetAdmin))
r.Post("/:slug/image", http.HandlerFunc(widgetImage))
Serve = r
}一個差異是 Get() 和 Post() 函式接收的是 http.Handler 而非 http.HandlerFunc,這通常有點尷尬,因為你通常處理的是函式,而不是帶有 ServeHTTP 方法的型別。你可以輕鬆地用 http.HandlerFunc(h) 來轉換,但就是稍微囉嗦了一點。以下是一個 handler 的樣子:
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get(":slug")
id, err := strconv.Atoi(r.URL.Query().Get(":id"))
if err != nil {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}有趣的一點是,Pat 並不是用 context 來儲存路徑參數(以及用輔助函式來取出),而是把牠們塞進查詢參數中,並以 :(冒號)作為前綴。這是個聰明的技巧——雖然有點取巧。
請注意,在 Pat 中我有檢查 Atoi() 的錯誤回傳值,因為路由定義中沒有正則來確保 ID 全是數字。或者你也可以忽略這個錯誤,就讓程式碼在嘗試用 ID 0 去資料庫查詢零件、發現不存在時回傳 Not Found(資料庫的 ID 通常從 1 開始)。
效能測試
如我先前提到的,我在這次比較中並不在意速度——你大概也不該在意。如果你真的處在一個連路由一個 URL 多花幾微秒都會造成問題的規模,那當然可以用像 httprouter 這樣花俏的 trie 路由器,或自己寫經過大量效能分析的程式碼。這裡展示的所有手刻路由器,其運作時間都與路由數量成線性關係。
不過,為了證明這些作法都不會拖垮效能,以下是一個簡單的效能測試,比較了八種路由器各自處理 URL /api/widgets/foo/parts/1/update 的情況(程式碼在此)。數字是「每次操作的奈秒數」,所以越低越好。「操作」包含了執行路由與呼叫 handler。而「noop」路由器實際上什麼路由都不做,所以代表了基準情況的額外開銷。
| Router | ns/op |
|---|---|
| pat | 3646 |
| gorilla | 2642 |
| retable | 2014 |
| reswitch | 1970 |
| shiftpath | 1607 |
| chi | 1370 |
| match | 1025 |
| split | 984 |
| noop | 583 |
如你所見,Pat 和 Gorilla 比其他幾個慢,這顯示出即使是知名的函式庫也不代表經過高度最佳化。Chi 是其中最快的之一,而我自訂的樣式比對器與單純的 strings.Split() 方法則是最快的。
但要再次強調重點:這些全都已經夠好了——你幾乎永遠不該根據效能來選擇路由器。這裡的數字都是微秒等級,所以即使是 Pat 的 3646 奈秒,也只會為回應時間增加 3.6 微秒。在典型的網頁應用程式中,資料庫查詢時間大約會是這個的 1000 倍。
結論
整體來說,這是個有趣的實驗:我嘗試了幾個對我來說是新的(但肯定不是原創的)自訂路由作法,也試用了讓我好奇一陣子的 Axel 的「ShiftPath」作法。
如果要從自製的作法中選一個,我想我最後還是會回到起點(幾年前我用 Go 實作第一個伺服器時),選擇正則表格作法。正則表達式對這份工作來說有點笨重,但它們廣為人知且就在標準函式庫中,而 Serve() 函式只有 21 行程式碼。此外,我喜歡路由定義都整齊地放在一張表格中、一行一個——這讓人很容易掃視並判斷哪些 URL 對應到哪裡。
緊追在後的第二名(同樣以自製作法來說)會是正則 switch。我喜歡 match() 輔助函式那種類似掃描的行為,而且它也非常精簡(22 行)。不過,路由定義有點亂(每個路由要兩行),而接收路徑參數的 handler 需要型別或閉包的樣板程式碼——我覺得用 context 來儲存路徑參數有點取巧,但它的確讓簽名保持簡單!
就我個人而言,我可能會排除其他自製的作法:
- 我的分割 switch 作法。我喜歡它只用了
strings.Split()這點,但我覺得n == 3 && p[0] == "api" && p[1] == "widgets" && p[2] != ""這類比較有點醜、也容易出錯。 - 我的樣式比對器版本。我很享受為這個使用場景打造自訂樣式比對器的簡單過程(而且只有 33 行程式碼),但逐位元組的字串處理有點繁瑣,而且相較於以 regexp 為基礎的作法(功能更強大且就在標準函式庫中),它的優勢不夠大。
- ShiftPath 技巧。我很想喜歡它,但即使是簡單的 URL 比對,它也需要太多樣板程式碼,而且我更偏好把 URL 定義集中在一處。
我不同意 Axel 認為第三方路由函式庫會讓路由難以理解的看法:你通常只需要知道它們是按原始碼順序比對,還是按最精確優先的順序比對。我也不同意把所有路由放在同一個地方(至少對應用程式的某個子元件而言)是件壞事。
就第三方函式庫而言,我相當喜歡Chi 版本。我會認真考慮使用它,尤其是在以大型團隊的形式打造網頁應用程式時。Chi 看起來經過深思熟慮且測試完善,我也很喜歡它所提供的中介層可組合性。
另一方面,我也非常清楚 node-modules 症候群與 left-pad 事件,並認同 Russ Cox 的看法,認為應該謹慎使用依賴。開發者不該害怕一點點程式碼:寫一個小型的自訂正則路由器既有趣、又容易理解,也容易維護。
隨機一篇部落格
留言
登入後參與討論