Go 博客

Go 1.22 的路由增强功能

Jonathan Amsterdam,代表 Go 团队
2024 年 2 月 13 日

Go 1.22 为 `net/http` 包的路由器带来了两项增强功能:方法匹配和通配符。这些特性让您可以将常见路由表示为模式而非 Go 代码。尽管它们易于解释和使用,但当多个模式匹配一个请求时,如何选择获胜模式的规则却是一个挑战。

我们进行了这些更改,作为持续努力使 Go 成为构建生产系统优秀语言的一部分。我们研究了许多第三方 Web 框架,提取了我们认为最常用的特性,并将它们集成到 `net/http` 中。然后,我们通过在 GitHub 讨论提案议题中与社区协作,验证了我们的选择并改进了设计。将这些特性添加到标准库意味着许多项目可以减少一个依赖。但对于现有用户或具有高级路由需求的程序来说,第三方 Web 框架仍然是一个不错的选择。

增强功能

新的路由特性几乎完全影响传递给两个 `net/http.ServeMux` 方法 `Handle` 和 `HandleFunc` 以及相应的顶层函数 `http.Handle` 和 `http.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` 服务器将回复此类请求,并返回 `405 Method Not Allowed` 错误,并在 `Allow` 头中列出可用方法。

通配符可以匹配整个段,例如上面示例中的 `{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`。哪个优先没有明确的答案,因此我们认为这些模式相互冲突。注册其中任何一个(无论顺序如何!)都会导致 panic。

优先级规则对于方法和路径与上述完全一致,但为了保持兼容性,我们对主机做了一个例外:如果两个模式在其他方面冲突,而其中一个包含主机,另一个不包含,则包含主机的模式优先。

计算机科学的学生可能会回想起正则表达式和正则语言的美妙理论。每个正则表达式对应一种正则语言,即由该表达式匹配的字符串集合。通过讨论语言而非表达式,一些问题更容易提出和解答。我们的优先级规则受到了这一理论的启发。事实上,每个路由模式都对应一个正则表达式,而匹配请求的集合扮演着正则语言的角色。

通过语言而非表达式来定义优先级使其易于阐述和理解。但基于可能无限集合的规则有一个缺点:如何高效地实现它并不清楚。事实证明,我们可以通过逐段遍历模式来确定两个模式是否冲突。粗略地说,如果一个模式在另一个模式是通配符的地方有一个字面量段,那么它就更具体;但如果字面量与通配符在两个方向上都对齐,则模式冲突。

当新模式在 `ServeMux` 上注册时,它会检查与先前注册模式的冲突。但检查每一对模式将花费二次时间。我们使用索引来跳过不会与新模式冲突的模式;在实践中,这效果很好。无论如何,这个检查发生在模式注册时,通常在服务器启动时。Go 1.22 中匹配传入请求所需的时间与之前版本相比变化不大。

兼容性

我们尽一切努力使新功能与旧版本 Go 兼容。新模式语法是旧模式语法的超集,新优先级规则也推广了旧规则。但也有一些边缘情况。例如,Go 的先前版本接受带大括号的模式并将其视为字面量,但 Go 1.22 使用大括号表示通配符。GODEBUG 设置 `httpmuxgo121` 可以恢复旧的行为。

有关这些路由增强功能的更多详细信息,请参阅 `net/http.ServeMux` 文档

下一篇文章:切片上的健壮泛型函数
上一篇文章:Go 1.22 发布了!
博客索引