Go 中 HTTP 路由的多种实现方式
更新于 2024 年 3 月:Go 1.22 为标准库中的
http.ServeMux路由器带来了重大增强。现在你可以指定带有方法和路径变量的模式,例如mux.Handle("GET /{slug}/admin"),我也已更新了相关代码。对于新项目,我建议你忽略本文,直接使用标准库!
在 Go 中做 HTTP 路径路由的方法有很多——不管是好是坏。标准库自带的 http.ServeMux 只支持基本的前缀匹配。你也可以自己实现更高级的路由,其中就包括 Axel Wagner 那个很有意思的ShiftPath 技巧。当然,还有大量第三方路由库。本文将对几种自定义方案和一些现成的包做一个对比。
先坦白一下我的偏好:我喜欢简单清晰的代码,对庞大的依赖有点过敏(而这两者有时是矛盾的)。标题里带“框架”二字的大多数库都不太合我胃口,不过对于那些维护良好、专注做好一两件事的库,我并不排斥。
本文的目标是用八种不同的方法来路由同样的 11 个 URL。这些 URL 取自我维护的一个 Web 应用中的一部分。它们使用了 GET 和 POST,但算不上特别符合 REST 风格,设计得也不算优雅——就是那种在真实系统中常见的有点混乱的状况。下面是这些方法和 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 是类似 foo-bar 这样的 URL 友好型组件标识符,:id 则是类似 1234 的正整数。每种路由方案都应对 URL 进行精确匹配——带尾斜杠的请求将返回 404 Not Found(重定向它们也是合理的选择,但本文不这么做)。每个路由器都应处理指定的方法(GET 或 POST),并对其他方法返回 405 Method Not Allowed。我写了一些表格驱动测试来确保所有路由器都能按预期工作。
在本文的其余部分,我将展示各种方案的代码,并讨论各自的优缺点(所有代码都在 benhoyt/go-routing 仓库中)。代码量不少,但都相当直观,应该很容易浏览。你可以通过下面的链接直接跳到某种具体方案。首先是五种自定义方案:
- 正则表格:遍历预编译的正则表达式,并通过请求上下文传递匹配结果
- 正则 switch:使用 switch 语句,每个 case 调用基于正则的
match()辅助函数,将路径参数扫描到变量中 - 模式匹配:与上面类似,但使用简单的模式匹配函数而非正则表达式
- 分割 switch:按
/分割路径,然后对路径段的内容做 switch 判断 - ShiftPath:Axel Wagner 的分层式
ShiftPath技巧
以及三种使用第三方路由包的版本:
我也试过 httprouter,据说它速度非常快,但它无法处理像 /contact 和 /:slug 这样前缀重叠的 URL。严格来说这算是糟糕的 URL 设计,但很多真实的 Web 应用就是这么做的,所以我觉得这个限制相当大。
还有很多其他的第三方路由包或“Web 框架”,但这三个在我的搜索中最为突出(我认为它们也颇具代表性)。
在这次对比中,我并不关心速度。大多数方案都是通过循环或 switch 遍历路由列表(而不是使用花哨的 Trie 查找结构)。所有这些方案给请求时间增加的开销都只有几微秒(见基准测试),在我做过的任何 Web 应用中这都不是问题。
正则表格
我想先看的第一种方案,是我目前在 Web 应用中使用的那套方法——几年前刚学 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 切片放入请求上下文来处理,这样处理器就可以从上下文中取出来。我定义了一个自定义的上下文键类型,以及一个在处理器内部使用的 getField 辅助函数:
type ctxKey struct{}
func getField(r *http.Request, index int) string {
fields := r.Context().Value(ctxKey{}).([]string)
return fields[index]
}一个带有路径参数的典型处理器看起来像这样:
// 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]+。当然,这并不能保证对象在数据库中一定存在——这仍需在处理器中自行校验。(如果数字过大,Atoi 会返回错误,但此时 id 会是 0,数据库查询也会失败,所以无需额外检查。)
除了通过上下文传递字段,另一种做法是让每个 route.handler 变成一个接收 []string 类型字段并返回一个闭合了 fields 参数的 http.HandleFunc 闭包的函数。这样 Serve 函数就可以按如下方式实例化并调用该闭包:
handler := route.handler(matches[1:])
handler(w, r)这样每个处理器的写法就会变成这样:
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)
}
}我稍微更偏好上下文的做法,因为它让处理器的签名保持为简单的 http.HandlerFunc,同时也避免了为每个处理器定义嵌套函数。
正则表格的做法并没有什么特别巧妙之处,也和不少第三方包的实现方式类似。但它非常简单,只需几行代码、几分钟就能写完。如果需要修改也很容易:比如加上日志、把错误响应改成 JSON 等等。
正则 switch
第二种方案仍然使用正则表达式,但配合一个简单的命令式 switch 语句和一个 match() 辅助函数来逐一匹配。这种方法的好处是,你可以在每个 case 中调用其他函数或做其他判断。此外,match 函数的签名允许你把路径参数“扫描”到变量中,从而更直接地传给处理器。下面是路由和 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,这对大多数路由来说应该已经够用,如果需要,扩展更多类型也很容易。
带有路径参数的处理器是这样的(为避免重复,我对所有接收这两个参数的处理器都使用了 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() 辅助函数以及处理器本身,都和正则 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)
}处理器与其他基于 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 的剩余部分下移。当前处理器对第一个路径段做 switch,然后将任务委托给子处理器,子处理器再对剩余的 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 的做法本身并不区分有无尾斜杠,而且我也找不到让它区分的简单办法。我认为用装饰器来处理是合理的,而不是在每个路由中显式处理——在具体的 Web 应用中,你大概会选择要么允许尾斜杠并做重定向,要么像我这样直接返回 Not Found。
虽然我喜欢只用标准库的想法,而且路径位移的技巧也相当巧妙,但我更强烈地偏好把所有 URL 集中在一处——Axel 的方法把逻辑分散到了众多处理器中,很难一眼看清哪个 URL 由谁处理。而且代码量也相当多,其中一些还容易出错。
我确实喜欢(正如 Axel 所说)“比如 ProfileHandler 的依赖在编译时就很清晰”这一点,不过上面提到的其他几种技巧也具备这个优点。总体权衡下来,我觉得它太过冗长,阅读代码的人很难快速回答“给定这个 HTTP 方法和 URL,会发生什么?”这个问题。
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
}处理器也很直观。它们看起来和正则表格方案中的处理器大同小异,只是自定义的 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() 返回的错误,因为路径参数的正则已经校验过它是由数字组成的。
如果你要构建一个规模较大的 Web 应用,Chi 其实看起来相当不错。主 chi 包只负责路由,但该模块还附带了一大堆可组合的中间件,可以用来做 HTTP 认证、日志、尾斜杠处理等等。
Gorilla
Gorilla 工具集是一组实现了路由、会话处理等功能的包。我们在这里要用到的是 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
}同样,处理器的写法和 Chi 相似,但要获取路径参数,你需要调用 mux.Vars(),它会返回一个包含所有参数的 map,你再按名字去取(这在我看来有点“设计上就低效”,不过也罢)。下面是其中一个处理器的代码:
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) 轻松转换,但写法上会多一些噪音。下面是一个处理器的样子:
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 的情况(代码在此)。数字是“每次操作的纳秒数”,越低越好。“操作”包括执行路由并调用处理器。名为“noop”的路由器实际上什么都不路由,因此代表了基准情况的开销。
| 路由器 | 纳秒/操作 |
|---|---|
| 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 微秒。典型 Web 应用中数据库查询的时间大约是它的 1000 倍。
结论
总的来说,这是一次有趣的实验:我想出了几种新的(对我而言是新的,但肯定算不上原创)自定义路由方案,也尝试了令我好奇已久的 Axel 的“ShiftPath”方案。
如果要从这些自制方案中选一个,我想我最终还是会回到起点(几年前我用 Go 实现第一个服务器时的做法),选择正则表格方案。正则表达式对于这项工作来说有点重,但它们易于理解且就在标准库中,而 Serve() 函数只有 21 行代码。此外,我喜欢路由定义都整齐地排在一张表里、每行一条——这样很容易扫一眼就看清各个 URL 对应到哪里。
紧随其后(仍然是在自制方案中比较)的会是正则 switch。我喜欢 match() 辅助函数那种类似扫描的行为,而且它也非常小巧(22 行)。不过,路由定义的写法有点乱(每个路由要占两行),而且接收路径参数的处理器需要类型或闭包的样板代码——我觉得用上下文来存储路径参数有点 hack,但它的确让函数签名保持简洁!
对我个人而言,我大概会排除掉其他几种自定义方案:
- 我的分割 switch 方案。我喜欢它只用
strings.Split()的极简,但我觉得n == 3 && p[0] == "api" && p[1] == "widgets" && p[2] != ""这种比较有点丑陋且容易出错。 - 我的模式匹配版本。我很享受为这个用例构建自定义模式匹配器的简单(而且它只有 33 行代码),但逐字节的字符串处理有点繁琐,而且相比基于 regexp 的方案(功能更强且就在标准库中)并没有带来足够的优势。
- ShiftPath 技巧。我很想喜欢它,但即便是简单的 URL 匹配,它也需要太多样板代码,而且我更偏好把 URL 定义放在一处。
我不同意 Axel 认为第三方路由库会让路由难以理解的看法:你通常只需要知道它们是按源码顺序匹配,还是按最具体优先的顺序匹配。我也不同意把所有路由放在一处(至少对于应用的某个子组件而言)是件坏事。
就第三方库而言,我挺喜欢 Chi 版本。如果是作为大团队的一员来构建 Web 应用,我会认真考虑使用它。Chi 似乎经过了深思熟虑和充分测试,而且我喜欢它所提供的中间件的可组合性。
另一方面,我也十分清楚 node-modules 综合征和 left-pad 事件,并认同 Russ Cox 的观点:依赖应该谨慎使用。开发者不应该害怕多写一点代码:手写一个小巧定制的正则路由器既有趣,又易于理解和维护。
随机一篇博客
评论
登录后参与讨论