Different approaches to HTTP routing in Go

Ben Hoyt

Go 中不同的 HTTP 路由作法

原文由 Ben Hoyt 發布,訂閱此部落格

2024 年 3 月更新:Go 1.22 為標準函式庫的 重大改進帶來了對 http.ServeMux 路由器的重大改進。你現在可以用包含 HTTP 方法與路徑變數的樣式來指定,例如 mux.Handle("GET /{slug}/admin"),我也已經更新了程式碼。如果是新的專案,我會建議你直接忽略這篇文章,改用標準函式庫!

在 Go 中做 HTTP 路徑路由的方法有很多——不管是好是壞。標準函式庫有 http.ServeMux,但它只支援基本的的前綴比對。也有很多自己實作更進階路由的方法,包括 Axel Wagner 很有趣的ShiftPath 技巧。當然,還有大量的第三方路由函式庫。在這篇文章中,我會比較幾種自製的技巧以及一些現成的套件。

我先坦白自己的偏好:我喜歡簡單明瞭的程式碼,而且對龐大的依賴有點過敏(而這兩者有時是互相衝突的)。大多數名稱裡有「框架」的函式庫都不太合我的胃口,不過對於維護良好、只專注做好一兩件事的函式庫,我並不排斥使用。

我在這裡的目標是用八種不同的方法來處理同樣的 11 個 URL。這些 URL 來自於我維護的一個網頁應用程式中的一部分。它們使用了 GETPOST,但並不是特別符合 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(重新導向也是合理的作法,但在這裡我不這麼做)。每種路由器都應該處理指定的方法(GETPOST)並以 405 Method Not Allowed 回應其他方法。我寫了一些表格驅動測試來確保所有路由器都能正確運作。

在本文的其餘部分,我會展示各種作法的程式碼,並討論各自的優缺點(所有程式碼都在 benhoyt/go-routing 儲存庫中)。程式碼有點多,但都相當直觀,應該很容易快速瀏覽。你可以使用以下連結直接跳到特定的技巧。首先是五種自製技巧:

  • 正則表格:遍歷預先編譯好的正則表達式,並透過 request context 傳遞比對結果
  • 正則 switch:使用 switch 陳述式,其中的 case 會呼叫以正則為基礎的 match() 輔助函式,將路徑參數掃描到變數中
  • 樣式比對器:與上述類似,但改用簡單的樣式比對函式而非正則表達式
  • 分割 switch:用 / 分割路徑,然後根據路徑片段的內容進行 switch
  • ShiftPath:Axel Wagner 的階層式 ShiftPath 技巧

以及三種使用第三方路由套件的版本:

  • Chi:使用 github.com/go-chi/chi
  • Gorilla:使用 github.com/gorilla/mux
  • Pat:使用 github.com/bmizerany/pat

我也試過 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 等等。

在 GitHub 上查看完整的正則表格程式碼。

正則 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() 內部的掃描會偵測型別,並在需要時將字串轉成整數。目前它只支援 stringint,這對大多數路由來說應該就夠了,但如果你需要,也很容易再加入更多型別。

以下是帶有路徑參數的 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() 輔助函式,它的缺點是為了快取正則編譯所需的額外雜亂程式碼。

在 GitHub 上查看完整的正則 switch 程式碼。

樣式比對器

這種作法類似於正則 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() 那麼簡單。

在 GitHub 上查看完整的樣式比對器程式碼。

更新: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 為基礎的方法完全相同,getpost 輔助函式也是。唯一的輔助函式是 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
}

所以,雖然我喜歡這種作法最基本的簡潔——只是基本的字串相等比較——但比對的冗長以及容易出錯的整數常數,會讓我在除了非常簡單的路由以外的場景,都會再三考慮是否真的要使用它。

在 GitHub 上查看完整的分割 switch 程式碼。

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 驗證、記錄、尾端斜線處理等等。

在 GitHub 上查看完整的 Chi 程式碼。

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)
}

在 GitHub 上查看完整的 Gorilla 程式碼。

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 開始)。

在 GitHub 上查看完整的 Pat 程式碼。

效能測試

如我先前提到的,我在這次比較中並不在意速度——你大概也不該在意。如果你真的處在一個連路由一個 URL 多花幾微秒都會造成問題的規模,那當然可以用像 httprouter 這樣花俏的 trie 路由器,或自己寫經過大量效能分析的程式碼。這裡展示的所有手刻路由器,其運作時間都與路由數量成線性關係。

不過,為了證明這些作法都不會拖垮效能,以下是一個簡單的效能測試,比較了八種路由器各自處理 URL /api/widgets/foo/parts/1/update 的情況(程式碼在此)。數字是「每次操作的奈秒數」,所以越低越好。「操作」包含了執行路由與呼叫 handler。而「noop」路由器實際上什麼路由都不做,所以代表了基準情況的額外開銷。

Routerns/op
pat3646
gorilla2642
retable2014
reswitch1970
shiftpath1607
chi1370
match1025
split984
noop583

如你所見,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 的看法,認為應該謹慎使用依賴。開發者不該害怕一點點程式碼:寫一個小型的自訂正則路由器既有趣、又容易理解,也容易維護。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言